// DCR — Cargo-like C/C++ project manager. // // Copyright (C) 2026 Dexoron (Bezotechestvo Vladimir) // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . use std::fs; use std::io::{self, IsTerminal, Write}; use std::path::{Path, PathBuf}; use std::process::Command; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Mutex, OnceLock}; use std::time::Instant; static OUTPUT_MUTEX: OnceLock> = OnceLock::new(); static PROGRESS_LABEL: OnceLock>> = OnceLock::new(); static PROGRESS_DIRTY: AtomicBool = AtomicBool::new(false); pub fn get_output_lock() -> &'static Mutex<()> { OUTPUT_MUTEX.get_or_init(|| Mutex::new(())) } fn progress_label_lock() -> &'static Mutex> { PROGRESS_LABEL.get_or_init(|| Mutex::new(None)) } pub fn set_progress_label(label: Option) { *progress_label_lock().lock().unwrap() = label; } pub fn finish_progress_line() { let was_dirty = PROGRESS_DIRTY.swap(false, Ordering::SeqCst); *progress_label_lock().lock().unwrap() = None; if !was_dirty { return; } if io::stderr().is_terminal() { let _guard = get_output_lock().lock().unwrap(); let _ = writeln!(io::stderr()); let _ = io::stderr().flush(); } } pub fn interrupt_progress_for_output() { if !PROGRESS_DIRTY.swap(false, Ordering::SeqCst) { return; } if io::stderr().is_terminal() { let _guard = get_output_lock().lock().unwrap(); let _ = writeln!(io::stderr()); let _ = io::stderr().flush(); } } fn report_compile_progress(done: usize, total: usize) { let label = progress_label_lock().lock().unwrap().clone(); let Some(label) = label else { return; }; if !io::stderr().is_terminal() { return; } let _guard = get_output_lock().lock().unwrap(); let msg = format!("{label} [{done}/{total}]"); if PROGRESS_DIRTY.load(Ordering::SeqCst) { eprint!("\r{msg:<80}"); } else { eprint!("{msg}"); } let _ = io::stderr().flush(); PROGRESS_DIRTY.store(true, Ordering::SeqCst); } pub fn collect_sources( roots: &[PathBuf], extensions: &[&str], exclude_dirs: &[PathBuf], include_paths: &[String], ) -> Result, String> { let mut sources = Vec::new(); for root in roots { if root.is_file() { if let Some(ext) = root.extension().and_then(|e| e.to_str()) && extensions.contains(&ext.to_lowercase().as_str()) { sources.push(root.to_string_lossy().to_string()); } } else { collect_sources_rec(root, extensions, &mut sources, exclude_dirs, include_paths)?; } } sources.sort(); Ok(sources) } fn collect_sources_rec( dir: &Path, extensions: &[&str], out: &mut Vec, exclude_dirs: &[PathBuf], include_paths: &[String], ) -> Result<(), String> { let full_dir = if dir.is_absolute() { dir.to_path_buf() } else { std::env::current_dir() .map_err(|err| format!("src dir error: {err}"))? .join(dir) }; if is_excluded(&full_dir, exclude_dirs, include_paths) && include_paths.is_empty() { return Ok(()); } let entries = fs::read_dir(&full_dir) .map_err(|err| format!("src dir error for {:?}: {err}", full_dir))?; for entry in entries { let entry = entry.map_err(|err| format!("src dir error for {:?}: {err}", full_dir))?; let path = entry.path(); if path.is_dir() { if is_excluded(&path, exclude_dirs, include_paths) && include_paths.is_empty() { continue; } collect_sources_rec(&path, extensions, out, exclude_dirs, include_paths)?; continue; } if !path.is_file() { continue; } if is_excluded(&path, exclude_dirs, include_paths) { continue; } let ext_raw = path.extension().and_then(|v| v.to_str()).unwrap_or(""); let ext_lower = ext_raw.to_lowercase(); let matched = extensions .iter() .any(|allowed| *allowed == ext_raw || *allowed == ext_lower); if matched { out.push(normalize_source_path(&path)); } } Ok(()) } pub fn is_excluded(path: &Path, exclude_dirs: &[PathBuf], include_paths: &[String]) -> bool { if matches_patterns(path, include_paths, true) { return false; } if matches_patterns(path, include_paths, false) { return true; } exclude_dirs.iter().any(|dir| path_is_under(path, dir)) } fn path_is_under(path: &Path, prefix: &Path) -> bool { if path.starts_with(prefix) { return true; } match (fs::canonicalize(path), fs::canonicalize(prefix)) { (Ok(p), Ok(pref)) => p.starts_with(pref), _ => { let p = path.to_string_lossy().replace('\\', "/"); let pref = prefix.to_string_lossy().replace('\\', "/"); if p == pref || p.starts_with(&format!("{pref}/")) { return true; } let p = p.strip_prefix("/private").unwrap_or(&p); let pref = pref.strip_prefix("/private").unwrap_or(&pref); p == pref || p.starts_with(&format!("{pref}/")) } } } fn matches_patterns(path: &Path, patterns: &[String], positive: bool) -> bool { if patterns.is_empty() { return false; } let cwd = std::env::current_dir().ok(); let abs_path = if path.is_absolute() { path.to_path_buf() } else if let Some(base) = cwd.as_ref() { base.join(path) } else { path.to_path_buf() }; let abs = abs_path.to_string_lossy().replace('\\', "/"); let rel = cwd .as_ref() .and_then(|base| abs_path.strip_prefix(base).ok()) .map(|p| p.to_string_lossy().replace('\\', "/")) .unwrap_or_default(); let rel_dot = if rel.is_empty() { String::new() } else { format!("./{rel}") }; for raw in patterns { let trimmed = raw.trim(); if trimmed.is_empty() { continue; } let is_neg = trimmed.starts_with('!'); if is_neg == positive { continue; } let normalized = trimmed.trim_start_matches('!').replace('\\', "/"); if normalized.is_empty() { continue; } if has_glob_magic(&normalized) { let pat = match glob::Pattern::new(&normalized) { Ok(p) => p, Err(_) => continue, }; if pat.matches(&abs) || (!rel.is_empty() && (pat.matches(&rel) || pat.matches(&rel_dot))) { return true; } continue; } let norm = normalized.trim_end_matches('/'); let candidate = Path::new(norm); let full = if candidate.is_absolute() { candidate.to_path_buf() } else if let Some(base) = cwd.as_ref() { base.join(candidate) } else { candidate.to_path_buf() }; if abs_path.starts_with(&full) { return true; } } false } pub fn has_glob_magic(value: &str) -> bool { value.chars().any(|c| matches!(c, '*' | '?' | '[')) } pub fn asm_lang_flag(source: &str) -> Option<&'static str> { let ext = Path::new(source).extension().and_then(|v| v.to_str())?; match ext { "S" => Some("assembler-with-cpp"), "s" | "asm" => Some("assembler"), _ => None, } } pub fn source_extensions(language: &str) -> Vec<&'static str> { let mut out = Vec::new(); for part in language.split(',').map(|s| s.trim()) { if let Some(lang) = crate::core::build::language::language_for_token(part) { out.extend(lang.extensions().iter().copied()); } } if out.is_empty() { out.extend(["c"]); } out } pub fn elapsed_secs(start: Instant) -> f64 { ((start.elapsed().as_secs_f64() * 100.0).trunc()) / 100.0 } pub fn parallel_build(total_tasks: usize, task_fn: F, max_jobs: usize) -> Result<(), String> where F: Fn(usize) -> Result<(), String> + Sync + Send, { let available = std::thread::available_parallelism() .map(|n| n.get()) .unwrap_or(1); let num_threads = if max_jobs > 0 { max_jobs.min(available) } else { available }; let counter = std::sync::atomic::AtomicUsize::new(0); let completed = std::sync::atomic::AtomicUsize::new(0); let err_msg = std::sync::Mutex::new(None); if total_tasks > 0 { report_compile_progress(0, total_tasks); } std::thread::scope(|s| { for _ in 0..num_threads { s.spawn(|| { loop { if err_msg.lock().unwrap().is_some() { break; } let i = counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed); if i >= total_tasks { break; } if let Err(e) = task_fn(i) { let mut err = err_msg.lock().unwrap(); if err.is_none() { *err = Some(e); } break; } let done = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; report_compile_progress(done, total_tasks); } }); } }); if let Some(err) = err_msg.into_inner().unwrap() { return Err(err); } Ok(()) } pub fn run_command_sync_output(cmd: &mut Command) -> Result<(), String> { let program = cmd.get_program().to_string_lossy().into_owned(); let output = cmd.output().map_err(|err| { if err.kind() == std::io::ErrorKind::NotFound { format!("{program} not found (check PATH / [toolchain])") } else { format!("Build failed: {err}") } })?; let has_out = !output.stdout.is_empty() || !output.stderr.is_empty(); if has_out { interrupt_progress_for_output(); } let _guard = get_output_lock().lock().unwrap(); if !output.stdout.is_empty() { eprint!("{}", String::from_utf8_lossy(&output.stdout)); } if !output.stderr.is_empty() { eprint!("{}", String::from_utf8_lossy(&output.stderr)); } if output.status.success() { Ok(()) } else { Err("Build failed".to_string()) } } pub fn normalize_source_path(path: &Path) -> String { if !path.is_absolute() { return path.to_string_lossy().to_string(); } if let Ok(base) = std::env::current_dir() && let Ok(rel) = path.strip_prefix(&base) { return format!("./{}", rel.to_string_lossy()); } path.to_string_lossy().to_string() } pub fn object_path(obj_dir: &Path, source: &str, obj_ext: &str) -> String { let src_path = Path::new(source); let stripped = src_path.strip_prefix("./").unwrap_or(src_path); let rel = stripped .components() .skip(1) .collect::(); let rel = if rel.components().next().is_some() { rel } else { stripped.to_path_buf() }; let mut out = obj_dir.join(rel); let ext = out .extension() .and_then(|e| e.to_str()) .unwrap_or("") .to_string(); let obj_ext = obj_ext.trim_start_matches('.'); if !ext.is_empty() { let stem = out .file_stem() .and_then(|s| s.to_str()) .unwrap_or("") .to_string(); out.set_file_name(format!("{stem}.{ext}.{obj_ext}")); } else { out.set_extension(obj_ext); } out.to_string_lossy().replace('\\', "/") } pub fn needs_rebuild(source: &str, object: &str) -> bool { let src_time = fs::metadata(source).and_then(|m| m.modified()); let obj_time = fs::metadata(object).and_then(|m| m.modified()); let o_time = match obj_time { Ok(t) => t, Err(_) => return true, }; match src_time { Ok(s) if s > o_time => return true, Err(_) => return true, _ => {} } let d_file = PathBuf::from(object).with_extension("d"); if let Ok(content) = fs::read_to_string(&d_file) { let deps = parse_d_file(&content); for dep in deps { let dep_path = Path::new(&dep); if dep_path == Path::new(object) || dep_path == Path::new(source) { continue; } if let Ok(dep_meta) = fs::metadata(dep_path) { if let Ok(dep_time) = dep_meta.modified() && dep_time > o_time { return true; } } else { return true; } } } false } pub fn needs_link(objects: &[String], output_path: &str) -> bool { let out_meta = match fs::metadata(output_path) { Ok(m) => m, Err(_) => return true, }; let out_time = match out_meta.modified() { Ok(t) => t, Err(_) => return true, }; for obj in objects { let obj_meta = match fs::metadata(obj) { Ok(m) => m, Err(_) => return true, }; let obj_time = match obj_meta.modified() { Ok(t) => t, Err(_) => return true, }; if obj_time > out_time { return true; } } false } fn parse_d_file(content: &str) -> Vec { let mut deps = Vec::new(); let mut target_end = 0; let bytes = content.as_bytes(); for (i, &b) in bytes.iter().enumerate() { if b == b':' { if i == 1 && bytes[0].is_ascii_alphabetic() && i + 1 < bytes.len() && (bytes[i + 1] == b'\\' || bytes[i + 1] == b'/') { continue; } target_end = i + 1; break; } } let deps_str = if target_end > 0 { &content[target_end..] } else { content }; let mut current_path = String::new(); let mut in_escape = false; let mut chars = deps_str.chars().peekable(); while let Some(c) = chars.next() { if in_escape { if c == '\n' { } else if c == '\r' { if chars.peek() == Some(&'\n') { chars.next(); } } else { current_path.push(c); } in_escape = false; } else if c == '\\' { in_escape = true; } else if c.is_whitespace() { if !current_path.is_empty() { deps.push(current_path.clone()); current_path.clear(); } } else { current_path.push(c); } } if !current_path.is_empty() { deps.push(current_path); } deps } #[cfg(test)] mod tests { use super::*; use std::fs; use std::path::PathBuf; use std::sync::Mutex; use std::sync::atomic::{AtomicUsize, Ordering}; static TEST_COUNTER: AtomicUsize = AtomicUsize::new(0); static CWD_LOCK: Mutex<()> = Mutex::new(()); fn cwd_lock() -> std::sync::MutexGuard<'static, ()> { CWD_LOCK .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } fn temp_dir(prefix: &str) -> PathBuf { let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst); let dir = std::env::temp_dir().join(format!("dcr_test_{prefix}_{n}")); let _ = fs::remove_dir_all(&dir); fs::create_dir_all(&dir).unwrap(); fs::canonicalize(&dir).unwrap_or(dir) } fn default_roots() -> Vec { vec![PathBuf::from("src")] } #[test] fn object_path_basic() { let obj_dir = Path::new("target/debug/obj"); let result = object_path(obj_dir, "./src/main.c", "o"); assert_eq!(result, "target/debug/obj/main.c.o"); } #[test] fn object_path_nested() { let obj_dir = Path::new("target/debug/obj"); let result = object_path(obj_dir, "./src/core/utils.c", "o"); assert_eq!(result, "target/debug/obj/core/utils.c.o"); } #[test] fn object_path_no_prefix() { let obj_dir = Path::new("target/debug/obj"); let result = object_path(obj_dir, "src/main.c", "o"); assert_eq!(result, "target/debug/obj/main.c.o"); } #[test] fn object_path_msvc_ext() { let obj_dir = Path::new("target/debug/obj"); let result = object_path(obj_dir, "./src/main.c", "obj"); assert_eq!(result, "target/debug/obj/main.c.obj"); } #[test] fn needs_link_no_output() { let dir = temp_dir("link_no_out"); let obj1 = dir.join("test1.o"); let obj2 = dir.join("test2.o"); let out = dir.join("app"); fs::write(&obj1, "").unwrap(); fs::write(&obj2, "").unwrap(); let objs = vec![ obj1.to_string_lossy().to_string(), obj2.to_string_lossy().to_string(), ]; assert!(needs_link(&objs, &out.to_string_lossy())); } #[test] fn needs_link_fresh() { let dir = temp_dir("link_fresh"); let obj1 = dir.join("test1.o"); let obj2 = dir.join("test2.o"); let out = dir.join("app"); fs::write(&obj1, "").unwrap(); fs::write(&obj2, "").unwrap(); std::thread::sleep(std::time::Duration::from_millis(50)); fs::write(&out, "").unwrap(); let objs = vec![ obj1.to_string_lossy().to_string(), obj2.to_string_lossy().to_string(), ]; assert!(!needs_link(&objs, &out.to_string_lossy())); } #[test] fn needs_link_stale() { let dir = temp_dir("link_stale"); let obj1 = dir.join("test1.o"); let obj2 = dir.join("test2.o"); let out = dir.join("app"); fs::write(&obj1, "").unwrap(); fs::write(&out, "").unwrap(); std::thread::sleep(std::time::Duration::from_millis(50)); fs::write(&obj2, "").unwrap(); let objs = vec![ obj1.to_string_lossy().to_string(), obj2.to_string_lossy().to_string(), ]; assert!(needs_link(&objs, &out.to_string_lossy())); } #[test] fn needs_rebuild_no_object() { let dir = temp_dir("rebuild_no_obj"); let src = dir.join("test.c"); fs::write(&src, "int main() {}").unwrap(); let obj = dir.join("test.o"); assert!(needs_rebuild( &src.to_string_lossy(), &obj.to_string_lossy() )); } #[test] fn needs_rebuild_fresh() { let dir = temp_dir("rebuild_fresh"); let src = dir.join("test.c"); let obj = dir.join("test.o"); fs::write(&src, "int main() {}").unwrap(); std::thread::sleep(std::time::Duration::from_millis(50)); fs::write(&obj, "").unwrap(); assert!(!needs_rebuild( &src.to_string_lossy(), &obj.to_string_lossy() )); } #[test] fn needs_rebuild_stale() { let dir = temp_dir("rebuild_stale"); let src = dir.join("test.c"); let obj = dir.join("test.o"); fs::write(&obj, "").unwrap(); std::thread::sleep(std::time::Duration::from_millis(50)); fs::write(&src, "int main() { return 1; }").unwrap(); assert!(needs_rebuild( &src.to_string_lossy(), &obj.to_string_lossy() )); } #[test] fn is_excluded_match() { let include: Vec = Vec::new(); let excluded = vec![PathBuf::from("/project/src/vendor")]; assert!(is_excluded( Path::new("/project/src/vendor"), &excluded, &include )); assert!(is_excluded( Path::new("/project/src/vendor/lib.c"), &excluded, &include )); } #[test] fn is_excluded_no_match() { let include: Vec = Vec::new(); let excluded = vec![PathBuf::from("/project/src/vendor")]; assert!(!is_excluded( Path::new("/project/src/main.c"), &excluded, &include )); assert!(!is_excluded( Path::new("/other/vendor"), &excluded, &include )); } #[test] fn collect_sources_c_files() { let dir = temp_dir("collect_c"); let src = dir.join("src"); fs::create_dir_all(&src).unwrap(); fs::write(src.join("main.c"), "").unwrap(); fs::write(src.join("utils.c"), "").unwrap(); fs::write(src.join("README.md"), "").unwrap(); // should be ignored let _guard = cwd_lock(); let prev = std::env::current_dir().unwrap(); std::env::set_current_dir(&dir).unwrap(); let include: Vec = Vec::new(); let roots = default_roots(); let result = collect_sources(&roots, &["c"], &[], &include); std::env::set_current_dir(prev).unwrap(); let sources = result.expect("should find sources"); assert_eq!(sources.len(), 2); assert!(sources.iter().any(|s| s.ends_with("main.c"))); assert!(sources.iter().any(|s| s.ends_with("utils.c"))); } #[test] fn collect_sources_cpp_files() { let dir = temp_dir("collect_cpp"); let src = dir.join("src"); fs::create_dir_all(&src).unwrap(); fs::write(src.join("main.cpp"), "").unwrap(); fs::write(src.join("helper.cxx"), "").unwrap(); fs::write(src.join("other.cc"), "").unwrap(); fs::write(src.join("skip.c"), "").unwrap(); // should be ignored let _guard = cwd_lock(); let prev = std::env::current_dir().unwrap(); std::env::set_current_dir(&dir).unwrap(); let include: Vec = Vec::new(); let roots = default_roots(); let result = collect_sources(&roots, &["cpp", "cxx", "cc"], &[], &include); std::env::set_current_dir(prev).unwrap(); let sources = result.expect("should find sources"); assert_eq!(sources.len(), 3); } #[test] fn collect_sources_empty_dir() { let dir = temp_dir("collect_empty"); let src = dir.join("src"); fs::create_dir_all(&src).unwrap(); let _guard = cwd_lock(); let prev = std::env::current_dir().unwrap(); std::env::set_current_dir(&dir).unwrap(); let include: Vec = Vec::new(); let roots = default_roots(); let result = collect_sources(&roots, &["c"], &[], &include); std::env::set_current_dir(prev).unwrap(); let sources = result.expect("empty src should return Ok"); assert!(sources.is_empty(), "empty src should return empty vec"); } #[test] fn collect_sources_respects_excludes() { let dir = temp_dir("collect_exclude"); let src = dir.join("src"); let vendor = src.join("vendor"); fs::create_dir_all(&vendor).unwrap(); fs::write(src.join("main.c"), "").unwrap(); fs::write(vendor.join("lib.c"), "").unwrap(); // should be excluded let exclude = vec![vendor.clone()]; let _guard = cwd_lock(); let prev = std::env::current_dir().unwrap(); std::env::set_current_dir(&dir).unwrap(); let include: Vec = Vec::new(); let roots = default_roots(); let result = collect_sources(&roots, &["c"], &exclude, &include); std::env::set_current_dir(prev).unwrap(); let sources = result.expect("should find sources"); assert_eq!(sources.len(), 1); assert!(sources[0].ends_with("main.c")); } #[test] fn collect_sources_nested() { let dir = temp_dir("collect_nested"); let src = dir.join("src"); let sub = src.join("core").join("deep"); fs::create_dir_all(&sub).unwrap(); fs::write(src.join("main.c"), "").unwrap(); fs::write(sub.join("nested.c"), "").unwrap(); let _guard = cwd_lock(); let prev = std::env::current_dir().unwrap(); std::env::set_current_dir(&dir).unwrap(); let include: Vec = Vec::new(); let roots = default_roots(); let result = collect_sources(&roots, &["c"], &[], &include); std::env::set_current_dir(prev).unwrap(); let sources = result.expect("should find sources"); assert_eq!(sources.len(), 2); } #[test] fn include_overrides_exclude() { let dir = temp_dir("include_override"); let src = dir.join("src"); let boot = src.join("boot"); let arch = boot.join("arch"); fs::create_dir_all(&arch).unwrap(); fs::write(arch.join("start.s"), "").unwrap(); fs::write(boot.join("skip.c"), "").unwrap(); let exclude = vec![boot.clone()]; let include = vec!["src/boot/arch/**".to_string()]; let _guard = cwd_lock(); let prev = std::env::current_dir().unwrap(); std::env::set_current_dir(&dir).unwrap(); let roots = default_roots(); let result = collect_sources(&roots, &["s", "c"], &exclude, &include); std::env::set_current_dir(prev).unwrap(); let sources = result.expect("should find sources"); assert_eq!(sources.len(), 1); assert!(sources[0].ends_with("start.s")); } #[test] fn exclude_glob_with_include_override() { let dir = temp_dir("exclude_glob"); let src = dir.join("src"); let legacy = src.join("legacy"); let allow = legacy.join("allow"); fs::create_dir_all(&allow).unwrap(); fs::write(legacy.join("skip.c"), "").unwrap(); fs::write(allow.join("keep.c"), "").unwrap(); let exclude = vec![dir.join("src"), dir.join(".")]; let include = vec![ "!src/legacy/**".to_string(), "src/legacy/allow/**".to_string(), ]; let _guard = cwd_lock(); let prev = std::env::current_dir().unwrap(); std::env::set_current_dir(&dir).unwrap(); let roots = default_roots(); let result = collect_sources(&roots, &["c"], &exclude, &include); std::env::set_current_dir(prev).unwrap(); let sources = result.expect("should find sources"); assert_eq!(sources.len(), 1); assert!(sources[0].ends_with("keep.c")); } #[test] fn exclude_glob_without_include_keeps_other_files() { let dir = temp_dir("exclude_glob_only"); let src = dir.join("src"); let gen_dir = src.join("gen"); fs::create_dir_all(&gen_dir).unwrap(); fs::write(src.join("main.c"), "").unwrap(); fs::write(gen_dir.join("skip.c"), "").unwrap(); let exclude: Vec = Vec::new(); let include = vec!["!src/gen/**".to_string()]; let _guard = cwd_lock(); let prev = std::env::current_dir().unwrap(); std::env::set_current_dir(&dir).unwrap(); let roots = default_roots(); let result = collect_sources(&roots, &["c"], &exclude, &include); std::env::set_current_dir(prev).unwrap(); let sources = result.expect("should find sources"); assert_eq!(sources.len(), 1); assert!(sources[0].ends_with("main.c")); } #[test] fn normalize_relative_path() { let result = normalize_source_path(Path::new("./src/main.c")); assert_eq!(result, "./src/main.c"); } #[test] fn parse_d_file_gcc_format() { let content = "target/obj/main.o: src/main.c src/utils.h \\\n src/core/types.h"; let deps = parse_d_file(content); assert_eq!(deps, vec!["src/main.c", "src/utils.h", "src/core/types.h"]); } #[test] fn parse_d_file_msvc_format() { let content = "target/obj/main.obj: \\\n C:/sdk/include/windows.h \\\n src/main.c"; let deps = parse_d_file(content); assert_eq!(deps, vec!["C:/sdk/include/windows.h", "src/main.c"]); } #[test] fn needs_rebuild_header_modified() { let dir = temp_dir("rebuild_header"); let src = dir.join("test.c"); let header = dir.join("test.h"); let obj = dir.join("test.o"); let d_file = dir.join("test.d"); fs::write(&src, "int main() {}").unwrap(); fs::write(&header, "#define A 1").unwrap(); std::thread::sleep(std::time::Duration::from_millis(1100)); fs::write(&obj, "").unwrap(); let d_content = format!( "{}: {} {}", obj.to_string_lossy(), src.to_string_lossy(), header.to_string_lossy() ); fs::write(&d_file, d_content).unwrap(); assert!( !needs_rebuild(&src.to_string_lossy(), &obj.to_string_lossy()), "should be fresh" ); std::thread::sleep(std::time::Duration::from_millis(1100)); fs::write(&header, "#define A 2").unwrap(); assert!( needs_rebuild(&src.to_string_lossy(), &obj.to_string_lossy()), "changed header should trigger rebuild" ); } #[test] fn asm_lang_flag_returns_correct_flag() { assert_eq!(asm_lang_flag("foo.S"), Some("assembler-with-cpp")); assert_eq!(asm_lang_flag("bar.s"), Some("assembler")); assert_eq!(asm_lang_flag("baz.asm"), Some("assembler")); assert_eq!(asm_lang_flag("main.c"), None); } #[test] fn source_extensions_parses_language_string() { let c = source_extensions("c"); assert!(c.contains(&"c")); assert!(!c.contains(&"cpp")); let cpp = source_extensions("c++"); assert!(cpp.contains(&"cpp")); assert!(cpp.contains(&"cxx")); assert!(cpp.contains(&"cc")); let asm = source_extensions("asm"); assert!(asm.contains(&"s")); assert!(asm.contains(&"S")); assert!(asm.contains(&"asm")); let mixed = source_extensions("c, c++"); assert!(mixed.contains(&"c")); assert!(mixed.contains(&"cpp")); let unknown = source_extensions("rust"); assert_eq!(unknown, vec!["c"]); } #[test] fn elapsed_secs_returns_positive() { let start = std::time::Instant::now(); std::thread::sleep(std::time::Duration::from_millis(50)); let elapsed = elapsed_secs(start); assert!(elapsed > 0.0); } }