diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..06e38f4 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,126 @@ +# AGENT.md + +## Project Overview + +**DCR (Dexoron Cargo Realization)** — CLI-утилита для управления C/C++/ASM проектами в стиле Cargo. + +- **Язык реализации**: Rust (edition 2024) +- **Лицензия**: MIT +- **Репозиторий**: [github.com/dexoron/dcr](https://github.com/dexoron/dcr) + +## Architecture + +### Source Tree (`src/`) + +``` +src/ +├── main.rs # Точка входа, маршрутизация CLI-команд +├── config.rs # Глобальные константы: профили сборки, шаблон main.c +├── cli/ # Реализация CLI-команд +│ ├── new.rs # dcr new +│ ├── init.rs # dcr init +│ ├── build.rs # dcr build [--debug|--release] +│ ├── run.rs # dcr run [--debug|--release] +│ ├── clean.rs # dcr clean [--all] +│ ├── help.rs # dcr --help +│ └── flag_update.rs # dcr --update (self-update через GitHub Releases) +├── core/ # Бизнес-логика +│ ├── config.rs # Парсинг/валидация/форматирование dcr.toml (struct Config) +│ ├── deps.rs # Разрешение path-зависимостей, генерация dcr.lock +│ ├── runner.rs # Запуск скомпилированного бинарника +│ ├── workspace.rs # Поддержка [workspace] и упорядочивание сборки +│ └── builder/ # Бэкенды компиляторов/ассемблеров +│ ├── mod.rs # Общий интерфейс сборки +│ ├── gcc.rs # GCC backend +│ ├── clang.rs # Clang backend +│ ├── msvc.rs # MSVC backend +│ ├── nasm.rs # NASM backend (ASM) +│ └── gas.rs # GAS backend (ASM) +├── platform/ # Платформенно-зависимая логика +│ ├── mod.rs # Общий интерфейс платформ +│ ├── linux.rs +│ ├── macos.rs +│ └── windows.rs +└── utils/ # Вспомогательные утилиты + ├── fs.rs # Работа с файловой системой + ├── log.rs # Логирование (error, warn) + └── text.rs # Форматирование текста / цвета +``` + +### Key Concepts + +- **`dcr.toml`** — конфигурационный файл проекта (аналог `Cargo.toml`). Секции: `[package]`, `[build]`, `[toolchain]`, `[dependencies]`, `[workspace]`. +- **Профили сборки**: `debug` (по умолчанию) и `release`. Встроенные флаги зависят от компилятора. Пользовательские флаги через `build.cflags` / `build.ldflags`. +- **Инкрементальная сборка**: объектные файлы кешируются по mtime. Отслеживание заголовков пока не реализовано (см. `TODO.md`). +- **Workspace**: корневой `dcr.toml` с секцией `[workspace]` описывает member-проекты и их зависимости; сборка происходит в топологическом порядке. +- **Path dependencies**: зависимости по путям автоматически добавляют include/lib-пути; генерируется `dcr.lock`. +- **Build kinds**: `bin` (бинарник), `staticlib` (`.a`), `sharedlib` (`.so`/`.dylib`/`.dll`). + +## Supported Platforms + +| Platform | Target triple | +|---|---| +| Linux | `x86_64-unknown-linux-gnu` | +| macOS Intel | `x86_64-apple-darwin` | +| macOS ARM | `aarch64-apple-darwin` | +| Windows | `x86_64-pc-windows-msvc` | + +## Dependencies (Cargo) + +| Crate | Purpose | +|---|---| +| `reqwest` | HTTP-запросы для `--update` (blocking, rustls-tls) | +| `serde` | Сериализация/десериализация | +| `toml` | Парсинг `dcr.toml` | +| `sha2` | Хеширование при обновлении | +| `self-replace` | Замена бинарника при `--update` | + +## Development + +### Build & Run + +```bash +cargo build # debug build +cargo build --release # release build +cargo run -- new hello # создать проект "hello" +``` + +### Code Quality + +```bash +cargo fmt --all +cargo clippy --all-targets --all-features -- -D warnings +cargo check +cargo test +``` + +### CI/CD + +- **CI**: `.github/workflows/ci.yml` — проверки при PR/push +- **Release**: `.github/workflows/release.yml` — сборка бинарников при пуше тега `v*` + +### Tests + +- Интеграционные CLI-тесты: `tests/cli_basic.rs` + +## Code Conventions + +- Rust stable edition 2024 +- Форматирование: `cargo fmt` +- Линтинг: `cargo clippy` без warnings +- Функции — маленькие и сфокусированные +- Имена — понятные и описательные +- Пользовательские сообщения CLI — на английском языке +- Вывод ошибок/предупреждений через `utils::log::{error, warn}` + +## TODO + +- В файле `TODO.md` + +## Useful Links + +- Документация проекта: `docs/` +- Changelog: `CHANGELOG.md` +- Contributing: `CONTRIBUTING.md` +- FAQ: `FAQ.md` +- Установка: `install.sh` (Linux/macOS), `install.ps1` (Windows) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae36048..213b006 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [0.2.9] - 2026-03-14 +Added: +- Header dependency tracking for fine-grained incremental builds (rebuilds when `#include` files change) +- Complete internal rewrite of the builder module: merged duplicate compilation backends + +Changed: +- `cflags` and `default_flags` no longer incorrectly leak to the linker stage in GCC/Clang +- Reduced builder codebase size by over 20% while increasing reliability +- Greatly expanded test suite with 34 new tests covering configuration, CLI errors, and path operations + ## [0.2.8] - 2026-03-06 Added: - Workspace support (`[workspace]` with deps ordering) diff --git a/Cargo.lock b/Cargo.lock index b1c80eb..4c23516 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,7 +90,7 @@ dependencies = [ [[package]] name = "dcr" -version = "0.2.8" +version = "0.2.9" dependencies = [ "reqwest", "self-replace", diff --git a/Cargo.toml b/Cargo.toml index 5d47866..c449922 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dcr" -version = "0.2.8" +version = "0.2.9" edition = "2024" build = "build.rs" diff --git a/TODO.md b/TODO.md index 2dafcfe..6ac94cd 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,9 @@ # TODO -- Add header dependency tracking for incremental builds (GCC/Clang: -MMD, MSVC: /showIncludes) - Add external package managers (Conan, vcpkg) - Linker script + direct linker control -- Custom targets/steps \ No newline at end of file +- Custom targets/steps +- Cross-compilation and freestanding targets (-nostdinc, -nostdlib, custom target triple like i686-elf) +- Mixed C + ASM in one project (compile .c and .s/.S sources together) +- Per-member include directories in workspace (e.g. include paths for subprojects) +- Build-time code generation (auto-generated headers, pre-build scripts) \ No newline at end of file diff --git a/src/core/builder/common.rs b/src/core/builder/common.rs new file mode 100644 index 0000000..8c086a3 --- /dev/null +++ b/src/core/builder/common.rs @@ -0,0 +1,395 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +pub fn collect_sources( + extensions: &[&str], + exclude_dirs: &[PathBuf], +) -> Result, String> { + let mut sources = Vec::new(); + collect_sources_rec("./src", extensions, &mut sources, exclude_dirs)?; + sources.sort(); + if sources.is_empty() { + return Err("No source files found in ./src".to_string()); + } + Ok(sources) +} + +fn collect_sources_rec( + dir: &str, + extensions: &[&str], + out: &mut Vec, + exclude_dirs: &[PathBuf], +) -> Result<(), String> { + let dir_path = Path::new(dir); + let full_dir = if dir_path.is_absolute() { + dir_path.to_path_buf() + } else { + std::env::current_dir() + .map_err(|err| format!("src dir error: {err}"))? + .join(dir_path) + }; + if is_excluded(&full_dir, exclude_dirs) { + return Ok(()); + } + let entries = fs::read_dir(&full_dir).map_err(|err| format!("src dir error: {err}"))?; + for entry in entries { + let entry = entry.map_err(|err| format!("src dir error: {err}"))?; + let path = entry.path(); + if path.is_dir() { + if is_excluded(&path, exclude_dirs) { + continue; + } + collect_sources_rec(&path.to_string_lossy(), extensions, out, exclude_dirs)?; + continue; + } + if !path.is_file() { + 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]) -> bool { + exclude_dirs.iter().any(|dir| path.starts_with(dir)) +} + +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 rel = src_path + .strip_prefix("./src") + .or_else(|_| src_path.strip_prefix("src")) + .unwrap_or(src_path); + let mut out = obj_dir.join(rel); + out.set_extension(obj_ext.trim_start_matches('.')); + out.to_string_lossy().to_string() +} + +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; // Missing dependency triggers rebuild + } + } + } + false +} + +fn parse_d_file(content: &str) -> Vec { + let mut deps = Vec::new(); + let text = content.replace("\\\n", " ").replace("\\\r\n", " "); + let mut target_end = 0; + let chars: Vec = text.chars().collect(); + for i in 0..chars.len() { + if chars[i] == ':' { + if i == 1 && chars[0].is_ascii_alphabetic() && i + 1 < chars.len() && (chars[i+1] == '\\' || chars[i+1] == '/') { + continue; // Windows drive letter + } + target_end = i + 1; + break; + } + } + + let deps_str = if target_end > 0 { &text[target_end..] } else { &text }; + + let mut current_path = String::new(); + let mut in_escape = false; + + for c in deps_str.chars() { + if in_escape { + if c != '\n' && c != '\r' { + 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::atomic::{AtomicUsize, Ordering}; + + static TEST_COUNTER: AtomicUsize = AtomicUsize::new(0); + + 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(); + dir + } + + #[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.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.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.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.obj"); + } + + #[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(); + // Sleep briefly to ensure mtime difference + 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 excluded = vec![PathBuf::from("/project/src/vendor")]; + assert!(is_excluded(Path::new("/project/src/vendor"), &excluded)); + assert!(is_excluded( + Path::new("/project/src/vendor/lib.c"), + &excluded + )); + } + + #[test] + fn is_excluded_no_match() { + let excluded = vec![PathBuf::from("/project/src/vendor")]; + assert!(!is_excluded(Path::new("/project/src/main.c"), &excluded)); + assert!(!is_excluded(Path::new("/other/vendor"), &excluded)); + } + + #[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 prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(&dir).unwrap(); + let result = collect_sources(&["c"], &[]); + 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 prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(&dir).unwrap(); + let result = collect_sources(&["cpp", "cxx", "cc"], &[]); + 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 prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(&dir).unwrap(); + let result = collect_sources(&["c"], &[]); + std::env::set_current_dir(prev).unwrap(); + + assert!(result.is_err(), "empty src should return error"); + } + + #[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 prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(&dir).unwrap(); + let result = collect_sources(&["c"], &exclude); + 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 prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(&dir).unwrap(); + let result = collect_sources(&["c"], &[]); + std::env::set_current_dir(prev).unwrap(); + + let sources = result.expect("should find sources"); + assert_eq!(sources.len(), 2); + } + + #[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(50)); + 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(50)); + fs::write(&header, "#define A 2").unwrap(); // modify header + + assert!(needs_rebuild(&src.to_string_lossy(), &obj.to_string_lossy()), "changed header should trigger rebuild"); + } +} + diff --git a/src/core/builder/gas.rs b/src/core/builder/gas.rs index 9a0aded..e28c952 100644 --- a/src/core/builder/gas.rs +++ b/src/core/builder/gas.rs @@ -1,7 +1,8 @@ use crate::core::builder::BuildContext; +use crate::core::builder::common; use crate::platform; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::process::Command; use std::time::Instant; @@ -15,7 +16,7 @@ pub fn build(ctx: &BuildContext) -> Result { ctx.compiler }; let start_time = Instant::now(); - let sources = collect_sources(ctx.language, ctx.exclude_dirs)?; + let sources = collect_sources(ctx)?; let obj_dir = Path::new("./target").join(ctx.profile).join("obj"); let objects = build_objects(assembler, &sources, &obj_dir, ctx, "o")?; @@ -82,76 +83,9 @@ pub fn build(ctx: &BuildContext) -> Result { } } -pub(crate) fn collect_sources( - language: &str, - exclude_dirs: &[PathBuf], -) -> Result, String> { - let lang = language.to_lowercase(); - let mut sources = Vec::new(); - collect_sources_rec("./src", &lang, &mut sources, exclude_dirs)?; - sources.sort(); - if sources.is_empty() { - return Err("No source files found in ./src".to_string()); - } - Ok(sources) -} - -fn collect_sources_rec( - dir: &str, - lang: &str, - out: &mut Vec, - exclude_dirs: &[PathBuf], -) -> Result<(), String> { - let dir_path = Path::new(dir); - let full_dir = if dir_path.is_absolute() { - dir_path.to_path_buf() - } else { - std::env::current_dir() - .map_err(|err| format!("src dir error: {err}"))? - .join(dir_path) - }; - if is_excluded(&full_dir, exclude_dirs) { - return Ok(()); - } - let entries = fs::read_dir(&full_dir).map_err(|err| format!("src dir error: {err}"))?; - for entry in entries { - let entry = entry.map_err(|err| format!("src dir error: {err}"))?; - let path = entry.path(); - if path.is_dir() { - if is_excluded(&path, exclude_dirs) { - continue; - } - collect_sources_rec(&path.to_string_lossy(), lang, out, exclude_dirs)?; - continue; - } - if !path.is_file() { - continue; - } - let ext_raw = path.extension().and_then(|v| v.to_str()).unwrap_or(""); - let ext = ext_raw.to_lowercase(); - let file = normalize_source_path(&path); - let allowed = lang == "asm" && ext == "s" && ext_raw == "s"; - if allowed { - out.push(file); - } - } - Ok(()) -} - -fn is_excluded(path: &Path, exclude_dirs: &[PathBuf]) -> bool { - exclude_dirs.iter().any(|dir| path.starts_with(dir)) -} - -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(crate) fn collect_sources(ctx: &BuildContext) -> Result, String> { + // GAS handles only lowercase .s files + common::collect_sources(&["s"], ctx.exclude_dirs) } fn build_objects( @@ -163,11 +97,11 @@ fn build_objects( ) -> Result, String> { let mut objects = Vec::new(); for source in sources { - let obj_path = object_path(obj_dir, source, obj_ext); + let obj_path = common::object_path(obj_dir, source, obj_ext); if let Some(parent) = Path::new(&obj_path).parent() { fs::create_dir_all(parent).map_err(|err| format!("obj dir error: {err}"))?; } - if needs_rebuild(source, &obj_path) { + if common::needs_rebuild(source, &obj_path) { let mut cmd = Command::new(assembler); cmd.arg(source).arg("-o").arg(&obj_path); for flag in ctx.cflags { @@ -183,24 +117,3 @@ fn build_objects( } Ok(objects) } - -fn object_path(obj_dir: &Path, source: &str, obj_ext: &str) -> String { - let src_path = Path::new(source); - let rel = src_path - .strip_prefix("./src") - .or_else(|_| src_path.strip_prefix("src")) - .unwrap_or(src_path); - let mut out = obj_dir.join(rel); - out.set_extension(obj_ext.trim_start_matches('.')); - out.to_string_lossy().to_string() -} - -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()); - match (src_time, obj_time) { - (Ok(s), Ok(o)) => s > o, - (Ok(_), Err(_)) => true, - _ => true, - } -} diff --git a/src/core/builder/gcc.rs b/src/core/builder/gcc.rs deleted file mode 100644 index 97136ff..0000000 --- a/src/core/builder/gcc.rs +++ /dev/null @@ -1,250 +0,0 @@ -use crate::core::builder::BuildContext; -use crate::platform; -use std::fs; -use std::path::{Path, PathBuf}; -use std::process::Command; -use std::time::Instant; - -pub fn build(ctx: &BuildContext) -> Result { - let compiler = if ctx.compiler.is_empty() { - "cc" - } else { - ctx.compiler - }; - let start_time = Instant::now(); - let sources = collect_sources(ctx.language, ctx.exclude_dirs)?; - let obj_dir = Path::new("./target").join(ctx.profile).join("obj"); - let objects = build_objects(compiler, &sources, &obj_dir, ctx, "o")?; - - if ctx.kind == "staticlib" { - let lib_path = platform::lib_path(ctx.profile, ctx.project_name, ctx.target_dir); - let mut cmd = Command::new(ctx.archiver.unwrap_or("ar")); - cmd.arg("rcs").arg(&lib_path); - for obj in &objects { - cmd.arg(obj); - } - match cmd.status() { - Ok(status) if status.success() => { - let elapsed = ((start_time.elapsed().as_secs_f64() * 100.0).trunc()) / 100.0; - return Ok(elapsed); - } - Ok(_) => return Err("Build failed".to_string()), - Err(err) => return Err(format!("Build failed: {err}")), - } - } - - let mut cmd = Command::new(ctx.linker.unwrap_or(compiler)); - if ctx.kind == "sharedlib" { - if cfg!(target_os = "macos") { - cmd.arg("-dynamiclib"); - } else { - cmd.arg("-shared"); - } - } - for obj in &objects { - cmd.arg(obj); - } - for flag in default_flags(ctx.profile) { - cmd.arg(flag); - } - for flag in ctx.cflags { - cmd.arg(flag); - } - for dir in ctx.lib_dirs { - cmd.arg(format!("-L{dir}")); - } - for lib in ctx.libs { - cmd.arg(format!("-l{lib}")); - } - for flag in ctx.ldflags { - cmd.arg(flag); - } - let out_path = if ctx.kind == "sharedlib" { - platform::shared_lib_path(ctx.profile, ctx.project_name, ctx.target_dir) - } else { - platform::bin_path(ctx.profile, ctx.project_name, ctx.target_dir) - }; - cmd.arg("-o").arg(out_path); - - match cmd.status() { - Ok(status) if status.success() => { - let elapsed = ((start_time.elapsed().as_secs_f64() * 100.0).trunc()) / 100.0; - Ok(elapsed) - } - Ok(_) => Err("Build failed".to_string()), - Err(err) => Err(format!("Build failed: {err}")), - } -} - -pub(crate) fn collect_sources( - language: &str, - exclude_dirs: &[PathBuf], -) -> Result, String> { - let lang = language.to_lowercase(); - let mut sources = Vec::new(); - collect_sources_rec("./src", &lang, &mut sources, exclude_dirs)?; - sources.sort(); - if sources.is_empty() { - return Err("No source files found in ./src".to_string()); - } - Ok(sources) -} - -fn collect_sources_rec( - dir: &str, - lang: &str, - out: &mut Vec, - exclude_dirs: &[PathBuf], -) -> Result<(), String> { - let dir_path = Path::new(dir); - let full_dir = if dir_path.is_absolute() { - dir_path.to_path_buf() - } else { - std::env::current_dir() - .map_err(|err| format!("src dir error: {err}"))? - .join(dir_path) - }; - if is_excluded(&full_dir, exclude_dirs) { - return Ok(()); - } - let entries = fs::read_dir(&full_dir).map_err(|err| format!("src dir error: {err}"))?; - for entry in entries { - let entry = entry.map_err(|err| format!("src dir error: {err}"))?; - let path = entry.path(); - if path.is_dir() { - if is_excluded(&path, exclude_dirs) { - continue; - } - collect_sources_rec(&path.to_string_lossy(), lang, out, exclude_dirs)?; - continue; - } - if !path.is_file() { - continue; - } - let ext = path - .extension() - .and_then(|v| v.to_str()) - .unwrap_or("") - .to_lowercase(); - let file = normalize_source_path(&path); - let allowed = (lang == "c" && ext == "c") - || ((lang == "c++" || lang == "cpp" || lang == "cxx") - && (ext == "cpp" || ext == "cxx" || ext == "cc")) - || (lang == "asm" && (ext == "s" || ext == "asm")); - if allowed { - out.push(file); - } - } - Ok(()) -} - -fn is_excluded(path: &Path, exclude_dirs: &[PathBuf]) -> bool { - exclude_dirs.iter().any(|dir| path.starts_with(dir)) -} - -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() -} - -fn default_flags(profile: &str) -> &'static [&'static str] { - match profile { - "release" => &["-O3", "-DNDEBUG", "-march=native"], - "debug" => &[ - "-O0", - "-g", - "-Wall", - "-Wextra", - "-fno-omit-frame-pointer", - "-DDEBUG", - ], - _ => &[], - } -} - -fn build_objects( - compiler: &str, - sources: &[String], - obj_dir: &Path, - ctx: &BuildContext, - obj_ext: &str, -) -> Result, String> { - let mut objects = Vec::new(); - for source in sources { - let obj_path = object_path(obj_dir, source, obj_ext); - if let Some(parent) = Path::new(&obj_path).parent() { - fs::create_dir_all(parent).map_err(|err| format!("obj dir error: {err}"))?; - } - if needs_rebuild(source, &obj_path) { - let mut cmd = Command::new(compiler); - cmd.arg("-c").arg(source).arg("-o").arg(&obj_path); - if ctx.kind == "sharedlib" { - cmd.arg("-fPIC"); - } - if let Some(flag) = asm_lang_flag(source) { - cmd.arg("-x").arg(flag); - } - if let Some(platform) = ctx.platform - && !platform.trim().is_empty() - { - cmd.arg(format!("-march={}", platform)); - } - if !ctx.standard.is_empty() && ctx.language.to_lowercase() != "asm" { - cmd.arg(format!("-std={}", ctx.standard)); - } - for flag in default_flags(ctx.profile) { - cmd.arg(flag); - } - for flag in ctx.cflags { - cmd.arg(flag); - } - for dir in ctx.include_dirs { - cmd.arg(format!("-I{dir}")); - } - match cmd.status() { - Ok(status) if status.success() => {} - Ok(_) => return Err("Build failed".to_string()), - Err(err) => return Err(format!("Build failed: {err}")), - } - } - objects.push(obj_path); - } - Ok(objects) -} - -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, - } -} - -fn object_path(obj_dir: &Path, source: &str, obj_ext: &str) -> String { - let src_path = Path::new(source); - let rel = src_path - .strip_prefix("./src") - .or_else(|_| src_path.strip_prefix("src")) - .unwrap_or(src_path); - let mut out = obj_dir.join(rel); - out.set_extension(obj_ext.trim_start_matches('.')); - out.to_string_lossy().to_string() -} - -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()); - match (src_time, obj_time) { - (Ok(s), Ok(o)) => s > o, - (Ok(_), Err(_)) => true, - _ => true, - } -} diff --git a/src/core/builder/mod.rs b/src/core/builder/mod.rs index 4c442c3..3c95c91 100644 --- a/src/core/builder/mod.rs +++ b/src/core/builder/mod.rs @@ -1,8 +1,8 @@ -pub mod clang; +pub mod common; pub mod gas; -pub mod gcc; pub mod msvc; pub mod nasm; +pub mod unix_cc; pub struct BuildContext<'a> { pub profile: &'a str, @@ -34,37 +34,22 @@ pub fn build(ctx: &BuildContext) -> Result { if compiler.contains("nasm") { return nasm::build(ctx); } - if compiler.contains("gcc") || compiler.contains("g++") { - return gcc::build(ctx); - } - if compiler.contains("clang") || compiler.contains("clang++") { - return clang::build(ctx); - } if compiler == "cl" || compiler.contains("msvc") { return msvc::build(ctx); } - clang::build(ctx) + unix_cc::build(ctx) } pub fn collect_sources(ctx: &BuildContext) -> Result, String> { let compiler = ctx.compiler.to_lowercase(); - if compiler.contains("clang-cl") { - return msvc::collect_sources(ctx.language, ctx.exclude_dirs); + if compiler.contains("clang-cl") || compiler == "cl" || compiler.contains("msvc") { + return msvc::collect_sources(ctx); } if compiler == "as" || compiler.contains("gas") { - return gas::collect_sources(ctx.language, ctx.exclude_dirs); + return gas::collect_sources(ctx); } if compiler.contains("nasm") { - return nasm::collect_sources(ctx.language, ctx.exclude_dirs); + return nasm::collect_sources(ctx); } - if compiler.contains("gcc") || compiler.contains("g++") { - return gcc::collect_sources(ctx.language, ctx.exclude_dirs); - } - if compiler.contains("clang") || compiler.contains("clang++") { - return clang::collect_sources(ctx.language, ctx.exclude_dirs); - } - if compiler == "cl" || compiler.contains("msvc") { - return msvc::collect_sources(ctx.language, ctx.exclude_dirs); - } - clang::collect_sources(ctx.language, ctx.exclude_dirs) + unix_cc::collect_sources(ctx) } diff --git a/src/core/builder/msvc.rs b/src/core/builder/msvc.rs index 9aaa3c1..4e36b53 100644 --- a/src/core/builder/msvc.rs +++ b/src/core/builder/msvc.rs @@ -1,7 +1,8 @@ use crate::core::builder::BuildContext; +use crate::core::builder::common; use crate::platform; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::process::Command; use std::time::Instant; @@ -15,7 +16,7 @@ pub fn build(ctx: &BuildContext) -> Result { return Err("MSVC backend does not support build.language = \"asm\"".to_string()); } let start_time = Instant::now(); - let sources = collect_sources(ctx.language, ctx.exclude_dirs)?; + let sources = collect_sources(ctx)?; let obj_dir = Path::new("./target").join(ctx.profile).join("obj"); let objects = build_objects(compiler, &sources, &obj_dir, ctx, "obj")?; @@ -100,82 +101,18 @@ pub fn build(ctx: &BuildContext) -> Result { } } -pub(crate) fn collect_sources( - language: &str, - exclude_dirs: &[PathBuf], -) -> Result, String> { +pub(crate) fn collect_sources(ctx: &BuildContext) -> Result, String> { + let extensions = source_extensions(ctx.language); + common::collect_sources(&extensions, ctx.exclude_dirs) +} + +fn source_extensions(language: &str) -> Vec<&str> { let lang = language.to_lowercase(); - let mut sources = Vec::new(); - collect_sources_rec("./src", &lang, &mut sources, exclude_dirs)?; - sources.sort(); - if sources.is_empty() { - return Err("No source files found in ./src".to_string()); + match lang.as_str() { + "c" => vec!["c"], + "c++" | "cpp" | "cxx" => vec!["cpp", "cxx", "cc"], + _ => vec!["c"], } - Ok(sources) -} - -fn collect_sources_rec( - dir: &str, - lang: &str, - out: &mut Vec, - exclude_dirs: &[PathBuf], -) -> Result<(), String> { - let dir_path = Path::new(dir); - let full_dir = if dir_path.is_absolute() { - dir_path.to_path_buf() - } else { - std::env::current_dir() - .map_err(|err| format!("src dir error: {err}"))? - .join(dir_path) - }; - if is_excluded(&full_dir, exclude_dirs) { - return Ok(()); - } - let entries = fs::read_dir(&full_dir).map_err(|err| format!("src dir error: {err}"))?; - for entry in entries { - let entry = entry.map_err(|err| format!("src dir error: {err}"))?; - let path = entry.path(); - if path.is_dir() { - if is_excluded(&path, exclude_dirs) { - continue; - } - collect_sources_rec(&path.to_string_lossy(), lang, out, exclude_dirs)?; - continue; - } - if !path.is_file() { - continue; - } - let ext = path - .extension() - .and_then(|v| v.to_str()) - .unwrap_or("") - .to_lowercase(); - let file = normalize_source_path(&path); - let allowed = (lang == "c" && ext == "c") - || ((lang == "c++" || lang == "cpp" || lang == "cxx") - && (ext == "cpp" || ext == "cxx" || ext == "cc")) - || (lang == "asm" && (ext == "s" || ext == "asm")); - if allowed { - out.push(file); - } - } - Ok(()) -} - -fn is_excluded(path: &Path, exclude_dirs: &[PathBuf]) -> bool { - exclude_dirs.iter().any(|dir| path.starts_with(dir)) -} - -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() } fn msvc_standard_flag(language: &str, standard: &str) -> Result { @@ -235,11 +172,11 @@ fn build_objects( ) -> Result, String> { let mut objects = Vec::new(); for source in sources { - let obj_path = object_path(obj_dir, source, obj_ext); + let obj_path = common::object_path(obj_dir, source, obj_ext); if let Some(parent) = Path::new(&obj_path).parent() { fs::create_dir_all(parent).map_err(|err| format!("obj dir error: {err}"))?; } - if needs_rebuild(source, &obj_path) { + if common::needs_rebuild(source, &obj_path) { let mut cmd = Command::new(compiler); cmd.arg("/nologo"); match ctx.language.to_lowercase().as_str() { @@ -270,34 +207,50 @@ fn build_objects( cmd.arg(format!("/I{dir}")); } cmd.arg("/c").arg(source).arg(format!("/Fo:{}", obj_path)); - match cmd.status() { - Ok(status) if status.success() => {} - Ok(_) => return Err("Build failed".to_string()), - Err(err) => return Err(format!("Build failed: {err}")), + cmd.arg("/showIncludes"); + + let output = cmd.output().map_err(|err| format!("Build failed: {err}"))?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + let mut headers = Vec::new(); + let mut clean_stdout = String::new(); + for line in stdout.lines() { + if let Some(stripped) = line.strip_prefix("Note: including file:") { + headers.push(stripped.trim().to_string()); + } else if let Some(stripped) = line.strip_prefix("Примечание: включение файла:") { + headers.push(stripped.trim().to_string()); + } else { + clean_stdout.push_str(line); + clean_stdout.push('\n'); + } } + + if !output.status.success() { + eprint!("{}", clean_stdout); + eprint!("{}", stderr); + return Err("Build failed".to_string()); + } else { + let trimmed_out = clean_stdout.trim(); + let trimmed_err = stderr.trim(); + let src_filename = Path::new(source).file_name().and_then(|v| v.to_str()).unwrap_or(""); + if !trimmed_out.is_empty() && trimmed_out != src_filename { + print!("{}", clean_stdout); + } + if !trimmed_err.is_empty() { + eprintln!("{}", trimmed_err); + } + } + + let d_path = Path::new(&obj_path).with_extension("d"); + let mut d_content = format!("{}: \\\n", obj_path.replace('\\', "/")); + for h in headers { + let escaped = h.replace('\\', "/").replace(" ", "\\ "); + d_content.push_str(&format!(" {} \\\n", escaped)); + } + std::fs::write(&d_path, d_content).map_err(|err| format!("d file error: {err}"))?; } objects.push(obj_path); } Ok(objects) } - -fn object_path(obj_dir: &Path, source: &str, obj_ext: &str) -> String { - let src_path = Path::new(source); - let rel = src_path - .strip_prefix("./src") - .or_else(|_| src_path.strip_prefix("src")) - .unwrap_or(src_path); - let mut out = obj_dir.join(rel); - out.set_extension(obj_ext.trim_start_matches('.')); - out.to_string_lossy().to_string() -} - -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()); - match (src_time, obj_time) { - (Ok(s), Ok(o)) => s > o, - (Ok(_), Err(_)) => true, - _ => true, - } -} diff --git a/src/core/builder/nasm.rs b/src/core/builder/nasm.rs index db092d6..38150c3 100644 --- a/src/core/builder/nasm.rs +++ b/src/core/builder/nasm.rs @@ -1,7 +1,8 @@ use crate::core::builder::BuildContext; +use crate::core::builder::common; use crate::platform; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::process::Command; use std::time::Instant; @@ -15,7 +16,7 @@ pub fn build(ctx: &BuildContext) -> Result { ctx.compiler }; let start_time = Instant::now(); - let sources = collect_sources(ctx.language, ctx.exclude_dirs)?; + let sources = collect_sources(ctx)?; let obj_dir = Path::new("./target").join(ctx.profile).join("obj"); let objects = build_objects(assembler, &sources, &obj_dir, ctx, "o")?; @@ -82,76 +83,9 @@ pub fn build(ctx: &BuildContext) -> Result { } } -pub(crate) fn collect_sources( - language: &str, - exclude_dirs: &[PathBuf], -) -> Result, String> { - let lang = language.to_lowercase(); - let mut sources = Vec::new(); - collect_sources_rec("./src", &lang, &mut sources, exclude_dirs)?; - sources.sort(); - if sources.is_empty() { - return Err("No source files found in ./src".to_string()); - } - Ok(sources) -} - -fn collect_sources_rec( - dir: &str, - lang: &str, - out: &mut Vec, - exclude_dirs: &[PathBuf], -) -> Result<(), String> { - let dir_path = Path::new(dir); - let full_dir = if dir_path.is_absolute() { - dir_path.to_path_buf() - } else { - std::env::current_dir() - .map_err(|err| format!("src dir error: {err}"))? - .join(dir_path) - }; - if is_excluded(&full_dir, exclude_dirs) { - return Ok(()); - } - let entries = fs::read_dir(&full_dir).map_err(|err| format!("src dir error: {err}"))?; - for entry in entries { - let entry = entry.map_err(|err| format!("src dir error: {err}"))?; - let path = entry.path(); - if path.is_dir() { - if is_excluded(&path, exclude_dirs) { - continue; - } - collect_sources_rec(&path.to_string_lossy(), lang, out, exclude_dirs)?; - continue; - } - if !path.is_file() { - continue; - } - let ext_raw = path.extension().and_then(|v| v.to_str()).unwrap_or(""); - let ext = ext_raw.to_lowercase(); - let file = normalize_source_path(&path); - let allowed = lang == "asm" && (ext == "asm" || (ext == "s" && ext_raw == "s")); - if allowed { - out.push(file); - } - } - Ok(()) -} - -fn is_excluded(path: &Path, exclude_dirs: &[PathBuf]) -> bool { - exclude_dirs.iter().any(|dir| path.starts_with(dir)) -} - -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(crate) fn collect_sources(ctx: &BuildContext) -> Result, String> { + // NASM handles lowercase .s and .asm only (not .S which is GCC preprocessed) + common::collect_sources(&["asm", "s"], ctx.exclude_dirs) } fn build_objects( @@ -164,11 +98,11 @@ fn build_objects( let mut objects = Vec::new(); let format = nasm_format(ctx.platform); for source in sources { - let obj_path = object_path(obj_dir, source, obj_ext); + let obj_path = common::object_path(obj_dir, source, obj_ext); if let Some(parent) = Path::new(&obj_path).parent() { fs::create_dir_all(parent).map_err(|err| format!("obj dir error: {err}"))?; } - if needs_rebuild(source, &obj_path) { + if common::needs_rebuild(source, &obj_path) { let mut cmd = Command::new(assembler); cmd.arg("-f") .arg(format) @@ -224,24 +158,3 @@ fn nasm_format(platform: Option<&str>) -> &'static str { "elf64" } } - -fn object_path(obj_dir: &Path, source: &str, obj_ext: &str) -> String { - let src_path = Path::new(source); - let rel = src_path - .strip_prefix("./src") - .or_else(|_| src_path.strip_prefix("src")) - .unwrap_or(src_path); - let mut out = obj_dir.join(rel); - out.set_extension(obj_ext.trim_start_matches('.')); - out.to_string_lossy().to_string() -} - -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()); - match (src_time, obj_time) { - (Ok(s), Ok(o)) => s > o, - (Ok(_), Err(_)) => true, - _ => true, - } -} diff --git a/src/core/builder/clang.rs b/src/core/builder/unix_cc.rs similarity index 57% rename from src/core/builder/clang.rs rename to src/core/builder/unix_cc.rs index 97136ff..44d07de 100644 --- a/src/core/builder/clang.rs +++ b/src/core/builder/unix_cc.rs @@ -1,7 +1,8 @@ use crate::core::builder::BuildContext; +use crate::core::builder::common; use crate::platform; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::process::Command; use std::time::Instant; @@ -12,7 +13,8 @@ pub fn build(ctx: &BuildContext) -> Result { ctx.compiler }; let start_time = Instant::now(); - let sources = collect_sources(ctx.language, ctx.exclude_dirs)?; + let extensions = source_extensions(ctx.language); + let sources = common::collect_sources(&extensions, ctx.exclude_dirs)?; let obj_dir = Path::new("./target").join(ctx.profile).join("obj"); let objects = build_objects(compiler, &sources, &obj_dir, ctx, "o")?; @@ -44,12 +46,6 @@ pub fn build(ctx: &BuildContext) -> Result { for obj in &objects { cmd.arg(obj); } - for flag in default_flags(ctx.profile) { - cmd.arg(flag); - } - for flag in ctx.cflags { - cmd.arg(flag); - } for dir in ctx.lib_dirs { cmd.arg(format!("-L{dir}")); } @@ -76,82 +72,19 @@ pub fn build(ctx: &BuildContext) -> Result { } } -pub(crate) fn collect_sources( - language: &str, - exclude_dirs: &[PathBuf], -) -> Result, String> { +pub(crate) fn collect_sources(ctx: &BuildContext) -> Result, String> { + let extensions = source_extensions(ctx.language); + common::collect_sources(&extensions, ctx.exclude_dirs) +} + +fn source_extensions(language: &str) -> Vec<&str> { let lang = language.to_lowercase(); - let mut sources = Vec::new(); - collect_sources_rec("./src", &lang, &mut sources, exclude_dirs)?; - sources.sort(); - if sources.is_empty() { - return Err("No source files found in ./src".to_string()); + match lang.as_str() { + "c" => vec!["c"], + "c++" | "cpp" | "cxx" => vec!["cpp", "cxx", "cc"], + "asm" => vec!["s", "S", "asm"], + _ => vec!["c"], } - Ok(sources) -} - -fn collect_sources_rec( - dir: &str, - lang: &str, - out: &mut Vec, - exclude_dirs: &[PathBuf], -) -> Result<(), String> { - let dir_path = Path::new(dir); - let full_dir = if dir_path.is_absolute() { - dir_path.to_path_buf() - } else { - std::env::current_dir() - .map_err(|err| format!("src dir error: {err}"))? - .join(dir_path) - }; - if is_excluded(&full_dir, exclude_dirs) { - return Ok(()); - } - let entries = fs::read_dir(&full_dir).map_err(|err| format!("src dir error: {err}"))?; - for entry in entries { - let entry = entry.map_err(|err| format!("src dir error: {err}"))?; - let path = entry.path(); - if path.is_dir() { - if is_excluded(&path, exclude_dirs) { - continue; - } - collect_sources_rec(&path.to_string_lossy(), lang, out, exclude_dirs)?; - continue; - } - if !path.is_file() { - continue; - } - let ext = path - .extension() - .and_then(|v| v.to_str()) - .unwrap_or("") - .to_lowercase(); - let file = normalize_source_path(&path); - let allowed = (lang == "c" && ext == "c") - || ((lang == "c++" || lang == "cpp" || lang == "cxx") - && (ext == "cpp" || ext == "cxx" || ext == "cc")) - || (lang == "asm" && (ext == "s" || ext == "asm")); - if allowed { - out.push(file); - } - } - Ok(()) -} - -fn is_excluded(path: &Path, exclude_dirs: &[PathBuf]) -> bool { - exclude_dirs.iter().any(|dir| path.starts_with(dir)) -} - -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() } fn default_flags(profile: &str) -> &'static [&'static str] { @@ -178,11 +111,11 @@ fn build_objects( ) -> Result, String> { let mut objects = Vec::new(); for source in sources { - let obj_path = object_path(obj_dir, source, obj_ext); + let obj_path = common::object_path(obj_dir, source, obj_ext); if let Some(parent) = Path::new(&obj_path).parent() { fs::create_dir_all(parent).map_err(|err| format!("obj dir error: {err}"))?; } - if needs_rebuild(source, &obj_path) { + if common::needs_rebuild(source, &obj_path) { let mut cmd = Command::new(compiler); cmd.arg("-c").arg(source).arg("-o").arg(&obj_path); if ctx.kind == "sharedlib" { @@ -208,6 +141,8 @@ fn build_objects( for dir in ctx.include_dirs { cmd.arg(format!("-I{dir}")); } + let d_path = Path::new(&obj_path).with_extension("d"); + cmd.arg("-MMD").arg("-MF").arg(&d_path); match cmd.status() { Ok(status) if status.success() => {} Ok(_) => return Err("Build failed".to_string()), @@ -227,24 +162,3 @@ fn asm_lang_flag(source: &str) -> Option<&'static str> { _ => None, } } - -fn object_path(obj_dir: &Path, source: &str, obj_ext: &str) -> String { - let src_path = Path::new(source); - let rel = src_path - .strip_prefix("./src") - .or_else(|_| src_path.strip_prefix("src")) - .unwrap_or(src_path); - let mut out = obj_dir.join(rel); - out.set_extension(obj_ext.trim_start_matches('.')); - out.to_string_lossy().to_string() -} - -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()); - match (src_time, obj_time) { - (Ok(s), Ok(o)) => s > o, - (Ok(_), Err(_)) => true, - _ => true, - } -} diff --git a/src/core/config.rs b/src/core/config.rs index 10abd05..a8ad4eb 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -393,3 +393,199 @@ fn set_path(value: &mut Value, path: &[&str], new_value: Value) -> Result<(), Co Err(ConfigError::Invalid("empty key".into())) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static TEST_COUNTER: AtomicUsize = AtomicUsize::new(0); + + fn temp_dir(prefix: &str) -> PathBuf { + let n = TEST_COUNTER.fetch_add(1, Ordering::SeqCst); + let dir = std::env::temp_dir().join(format!("dcr_cfg_test_{prefix}_{n}")); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn write_toml_file(dir: &Path, content: &str) -> PathBuf { + let path = dir.join("dcr.toml"); + fs::write(&path, content).unwrap(); + path + } + + fn minimal_valid_toml() -> &'static str { + "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[build]\nlanguage = \"c\"\nstandard = \"c11\"\ncompiler = \"clang\"\nkind = \"bin\"\n\n[dependencies]\n" + } + + #[test] + fn open_valid_toml() { + let dir = temp_dir("open_valid"); + let path = write_toml_file(&dir, minimal_valid_toml()); + let config = Config::open(&path.to_string_lossy()); + assert!(config.is_ok(), "should open valid toml"); + } + + #[test] + fn open_invalid_toml_syntax() { + let dir = temp_dir("open_invalid"); + let path = write_toml_file(&dir, "this is not [valid toml !!!"); + let config = Config::open(&path.to_string_lossy()); + assert!(config.is_err(), "should fail on invalid TOML syntax"); + } + + #[test] + fn open_nonexistent_fails() { + let result = Config::open("/tmp/dcr_nonexistent_file_12345.toml"); + assert!(result.is_err(), "should fail on nonexistent file"); + } + + #[test] + fn validate_missing_package_fails() { + let dir = temp_dir("no_package"); + let path = write_toml_file( + &dir, + "[build]\nlanguage = \"c\"\nstandard = \"c11\"\ncompiler = \"clang\"\n\n[dependencies]\n", + ); + let result = Config::open(&path.to_string_lossy()); + assert!(result.is_err(), "missing [package] should fail validation"); + } + + #[test] + fn validate_missing_build_fails() { + let dir = temp_dir("no_build"); + let path = write_toml_file( + &dir, + "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[dependencies]\n", + ); + let result = Config::open(&path.to_string_lossy()); + assert!(result.is_err(), "missing [build] should fail validation"); + } + + #[test] + fn validate_empty_language_fails() { + let dir = temp_dir("empty_lang"); + let path = write_toml_file( + &dir, + "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[build]\nlanguage = \"\"\nstandard = \"c11\"\ncompiler = \"clang\"\n\n[dependencies]\n", + ); + let result = Config::open(&path.to_string_lossy()); + assert!(result.is_err(), "empty language should fail validation"); + } + + #[test] + fn validate_unknown_kind_fails() { + let dir = temp_dir("bad_kind"); + let path = write_toml_file( + &dir, + "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[build]\nlanguage = \"c\"\nstandard = \"c11\"\ncompiler = \"clang\"\nkind = \"exe\"\n\n[dependencies]\n", + ); + let result = Config::open(&path.to_string_lossy()); + assert!(result.is_err(), "unknown kind 'exe' should fail validation"); + } + + #[test] + fn validate_valid_kinds() { + for kind in ["bin", "staticlib", "sharedlib"] { + let dir = temp_dir(&format!("kind_{kind}")); + let content = format!( + "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[build]\nlanguage = \"c\"\nstandard = \"c11\"\ncompiler = \"clang\"\nkind = \"{kind}\"\n\n[dependencies]\n" + ); + let path = write_toml_file(&dir, &content); + let result = Config::open(&path.to_string_lossy()); + assert!(result.is_ok(), "kind '{kind}' should be valid"); + } + } + + #[test] + fn get_values() { + let dir = temp_dir("get_values"); + let path = write_toml_file(&dir, minimal_valid_toml()); + let config = Config::open(&path.to_string_lossy()).unwrap(); + + assert_eq!( + config.get("package.name").and_then(|v| v.as_str()), + Some("test") + ); + assert_eq!( + config.get("build.language").and_then(|v| v.as_str()), + Some("c") + ); + assert_eq!( + config.get("build.kind").and_then(|v| v.as_str()), + Some("bin") + ); + assert!(config.get("nonexistent.key").is_none()); + } + + #[test] + fn set_and_read_back() { + let dir = temp_dir("set_value"); + let path = write_toml_file(&dir, minimal_valid_toml()); + let mut config = Config::open(&path.to_string_lossy()).unwrap(); + + config + .set("package.name", Value::String("newname".to_string())) + .unwrap(); + assert_eq!( + config.get("package.name").and_then(|v| v.as_str()), + Some("newname") + ); + + // Verify persisted to disk + let config2 = Config::open(&path.to_string_lossy()).unwrap(); + assert_eq!( + config2.get("package.name").and_then(|v| v.as_str()), + Some("newname") + ); + } + + #[test] + fn new_creates_default_config() { + let dir = temp_dir("new_default"); + let path = dir.join("dcr.toml"); + let config = Config::new(&path.to_string_lossy()).unwrap(); + + assert!(path.exists(), "dcr.toml should be created"); + assert_eq!( + config.get("build.language").and_then(|v| v.as_str()), + Some("c") + ); + assert_eq!( + config.get("build.standard").and_then(|v| v.as_str()), + Some("c11") + ); + assert_eq!( + config.get("build.compiler").and_then(|v| v.as_str()), + Some("clang") + ); + assert_eq!( + config.get("build.kind").and_then(|v| v.as_str()), + Some("bin") + ); + } + + #[test] + fn validate_workspace_empty_path_fails() { + let dir = temp_dir("ws_empty_path"); + let path = write_toml_file( + &dir, + "[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n[build]\nlanguage = \"c\"\nstandard = \"c11\"\ncompiler = \"clang\"\n\n[workspace]\n[workspace.member1]\npath = \"\"\n\n[dependencies]\n", + ); + let result = Config::open(&path.to_string_lossy()); + assert!( + result.is_err(), + "workspace member with empty path should fail" + ); + } + + #[test] + fn check_returns_bool() { + let dir = temp_dir("check_bool"); + let path = write_toml_file(&dir, minimal_valid_toml()); + let config = Config::open(&path.to_string_lossy()).unwrap(); + assert!(config.check(), "valid config should return true"); + } +} + diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 74039a2..66f18f2 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -18,3 +18,61 @@ pub fn shared_lib_path(profile: &str, name: &str, target_dir: Option<&str>) -> S None => format!("./target/{profile}/lib{name}.so"), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bin_path_default() { + assert_eq!(bin_path("debug", "hello", None), "./target/debug/hello"); + } + + #[test] + fn bin_path_release() { + assert_eq!( + bin_path("release", "hello", None), + "./target/release/hello" + ); + } + + #[test] + fn bin_path_custom_target() { + assert_eq!(bin_path("debug", "hello", Some("out")), "out/hello"); + } + + #[test] + fn bin_path_custom_target_trailing_slash() { + assert_eq!(bin_path("debug", "hello", Some("out/")), "out/hello"); + } + + #[test] + fn lib_path_default() { + assert_eq!( + lib_path("debug", "mylib", None), + "./target/debug/libmylib.a" + ); + } + + #[test] + fn lib_path_custom_target() { + assert_eq!(lib_path("debug", "mylib", Some("out")), "out/libmylib.a"); + } + + #[test] + fn shared_lib_path_default() { + assert_eq!( + shared_lib_path("debug", "mylib", None), + "./target/debug/libmylib.so" + ); + } + + #[test] + fn shared_lib_path_custom_target() { + assert_eq!( + shared_lib_path("release", "mylib", Some("dist")), + "dist/libmylib.so" + ); + } +} + diff --git a/tests/cli_errors.rs b/tests/cli_errors.rs new file mode 100644 index 0000000..1cd074c --- /dev/null +++ b/tests/cli_errors.rs @@ -0,0 +1,307 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Once; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +static COUNTER: AtomicUsize = AtomicUsize::new(0); +static BUILD_ONCE: Once = Once::new(); + +fn bin_path() -> PathBuf { + if let Ok(exe) = std::env::var("CARGO_BIN_EXE_dcr") { + return PathBuf::from(exe); + } + ensure_bin_built(); + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("target"); + path.push("debug"); + path.push(format!("dcr{}", std::env::consts::EXE_SUFFIX)); + path +} + +fn unique_sandbox_dir(prefix: &str) -> PathBuf { + let pid = std::process::id(); + let n = COUNTER.fetch_add(1, Ordering::SeqCst); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + let mut path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + path.push("sandbox"); + path.push("cli-tests"); + path.push(format!("dcr_{prefix}_{pid}_{n}_{now}")); + std::fs::create_dir_all(&path).expect("failed to create temp dir"); + path +} + +fn run_dcr(args: &[&str], cwd: &Path) -> std::process::Output { + let mut cmd = Command::new(bin_path()); + cmd.args(args).current_dir(cwd); + cmd.output().expect("failed to run dcr") +} + +fn run_dcr_env(args: &[&str], cwd: &Path, envs: &[(&str, &str)]) -> std::process::Output { + let mut cmd = Command::new(bin_path()); + cmd.args(args).current_dir(cwd); + for (k, v) in envs { + cmd.env(k, v); + } + cmd.output().expect("failed to run dcr") +} + +fn ensure_bin_built() { + BUILD_ONCE.call_once(|| { + let status = Command::new("cargo") + .arg("build") + .current_dir(env!("CARGO_MANIFEST_DIR")) + .status() + .expect("failed to run cargo build"); + assert!(status.success(), "cargo build failed"); + }); +} + +fn available_compiler() -> Option<&'static str> { + for candidate in ["clang", "gcc", "cc"] { + let ok = Command::new(candidate) + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if ok { + return Some(candidate); + } + } + None +} + +// --- Tests --- + +#[test] +fn no_args_shows_help() { + let dir = unique_sandbox_dir("noargs"); + let out = run_dcr(&[], &dir); + // Should succeed (help returns 0) and produce some output + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("DCR") || stdout.contains("dcr") || stdout.contains("USAGE"), + "no args should show help text" + ); +} + +#[test] +fn unknown_command_fails() { + let dir = unique_sandbox_dir("unknown_cmd"); + let out = run_dcr(&["foobar"], &dir); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("error") || stdout.contains("Unknown"), + "unknown command should print error" + ); +} + +#[test] +fn build_without_toml_fails() { + let dir = unique_sandbox_dir("build_no_toml"); + let out = run_dcr(&["build"], &dir); + assert!(!out.status.success(), "build without dcr.toml should fail"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("dcr.toml") || stdout.contains("not found"), + "should mention missing dcr.toml" + ); +} + +#[test] +fn run_without_toml_fails() { + let dir = unique_sandbox_dir("run_no_toml"); + let out = run_dcr(&["run"], &dir); + assert!(!out.status.success(), "run without dcr.toml should fail"); +} + +#[test] +fn clean_without_toml_fails() { + let dir = unique_sandbox_dir("clean_no_toml"); + let out = run_dcr(&["clean"], &dir); + assert!( + !out.status.success(), + "clean without dcr.toml should fail" + ); +} + +#[test] +fn new_existing_dir_fails() { + let dir = unique_sandbox_dir("new_exist"); + // Create the project directory first + std::fs::create_dir_all(dir.join("hello")).expect("failed to create dir"); + let out = run_dcr(&["new", "hello"], &dir); + assert!(!out.status.success(), "dcr new on existing dir should fail"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("already exists") || stdout.contains("error"), + "should report dir already exists" + ); +} + +#[test] +fn init_nonempty_dir_fails() { + let dir = unique_sandbox_dir("init_nonempty"); + // Put a file in the dir so it's not empty + std::fs::write(dir.join("dummy.txt"), "x").expect("failed to write"); + let out = run_dcr(&["init"], &dir); + assert!(!out.status.success(), "dcr init in non-empty dir should fail"); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("not empty") || stdout.contains("error"), + "should report dir not empty" + ); +} + +#[test] +fn build_unknown_flag_fails() { + let dir = unique_sandbox_dir("build_badflag"); + // Init a valid project first + let out = run_dcr(&["init"], &dir); + assert!(out.status.success(), "init should succeed"); + let out = run_dcr(&["build", "--foobar"], &dir); + assert!( + !out.status.success(), + "build with unknown flag should fail" + ); +} + +#[test] +fn run_library_project_fails() { + let Some(compiler) = available_compiler() else { + eprintln!("no compiler found; skipping run_library test"); + return; + }; + + let dir = unique_sandbox_dir("run_lib"); + let out = run_dcr(&["init"], &dir); + assert!(out.status.success(), "init should succeed"); + + // Edit dcr.toml to set kind = "staticlib" + let toml_path = dir.join("dcr.toml"); + let toml = std::fs::read_to_string(&toml_path).expect("failed to read dcr.toml"); + let updated = toml.replace( + "kind = \"bin\"", + "kind = \"staticlib\"", + ); + std::fs::write(&toml_path, updated).expect("failed to write dcr.toml"); + + let envs = [("DCR_COMPILER", compiler)]; + let out = run_dcr_env(&["run"], &dir, &envs); + assert!( + !out.status.success(), + "dcr run on staticlib should fail" + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("library") || stdout.contains("Cannot run"), + "should report cannot run library" + ); +} + +#[test] +fn clean_specific_profile() { + let dir = unique_sandbox_dir("clean_profile"); + let out = run_dcr(&["init"], &dir); + assert!(out.status.success(), "init should succeed"); + + // Create target/debug and target/release dirs + std::fs::create_dir_all(dir.join("target").join("debug")).expect("create debug"); + std::fs::write(dir.join("target").join("debug").join("dummy.o"), "x").expect("write"); + std::fs::create_dir_all(dir.join("target").join("release")).expect("create release"); + std::fs::write(dir.join("target").join("release").join("dummy.o"), "x").expect("write"); + + // Clean only release + let out = run_dcr(&["clean", "--release"], &dir); + assert!(out.status.success(), "clean --release should succeed"); + assert!( + !dir.join("target").join("release").exists(), + "target/release should be removed" + ); + assert!( + dir.join("target").join("debug").is_dir(), + "target/debug should remain" + ); +} + +#[test] +fn new_no_name_fails() { + let dir = unique_sandbox_dir("new_noname"); + let out = run_dcr(&["new"], &dir); + assert!( + !out.status.success(), + "dcr new without name should fail" + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("not specified") || stdout.contains("error"), + "should report name not specified" + ); +} + +#[test] +fn staticlib_build() { + let Some(compiler) = available_compiler() else { + eprintln!("no compiler found; skipping staticlib test"); + return; + }; + let dir = unique_sandbox_dir("staticlib"); + let out = run_dcr(&["init"], &dir); + assert!(out.status.success(), "init should succeed"); + + let toml_path = dir.join("dcr.toml"); + let toml = std::fs::read_to_string(&toml_path).expect("read dcr.toml"); + let updated = toml.replace("kind = \"bin\"", "kind = \"staticlib\""); + std::fs::write(&toml_path, updated).expect("write dcr.toml"); + + let envs = [("DCR_COMPILER", compiler)]; + let out = run_dcr_env(&["build"], &dir, &envs); + assert!(out.status.success(), "staticlib build should succeed"); + // Check that a .a file exists + let lib_path = dir.join("target").join("debug"); + let has_lib = std::fs::read_dir(&lib_path) + .map(|entries| { + entries + .filter_map(|e| e.ok()) + .any(|e| { + e.file_name() + .to_string_lossy() + .ends_with(".a") + }) + }) + .unwrap_or(false); + assert!(has_lib, "staticlib should produce a .a file"); +} + +#[test] +fn build_release_profile() { + let Some(compiler) = available_compiler() else { + eprintln!("no compiler found; skipping release build test"); + return; + }; + let dir = unique_sandbox_dir("release_build"); + let out = run_dcr(&["init"], &dir); + assert!(out.status.success(), "init should succeed"); + + let envs = [("DCR_COMPILER", compiler)]; + let out = run_dcr_env(&["build", "--release"], &dir, &envs); + assert!(out.status.success(), "release build should succeed"); + assert!( + dir.join("target").join("release").is_dir(), + "target/release should exist" + ); +} + +#[test] +fn version_contains_version_string() { + let dir = unique_sandbox_dir("version"); + let out = run_dcr(&["--version"], &dir); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + // Should contain version like "dcr 0.2.8 (target)" + assert!(stdout.contains("dcr "), "version output should start with 'dcr '"); + assert!(stdout.contains('.'), "version output should contain a version number"); +}