2 Commits

15 changed files with 1213 additions and 675 deletions

126
AGENT.md Normal file
View File

@@ -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 <name>
│ ├── 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)

View File

@@ -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)

2
Cargo.lock generated
View File

@@ -90,7 +90,7 @@ dependencies = [
[[package]]
name = "dcr"
version = "0.2.8"
version = "0.2.9"
dependencies = [
"reqwest",
"self-replace",

View File

@@ -1,6 +1,6 @@
[package]
name = "dcr"
version = "0.2.8"
version = "0.2.9"
edition = "2024"
build = "build.rs"

View File

@@ -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
- 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)

421
src/core/builder/common.rs Normal file
View File

@@ -0,0 +1,421 @@
use std::fs;
use std::path::{Path, PathBuf};
pub fn collect_sources(
extensions: &[&str],
exclude_dirs: &[PathBuf],
) -> Result<Vec<String>, 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<String>,
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<String> {
let mut deps = Vec::new();
let text = content.replace("\\\n", " ").replace("\\\r\n", " ");
let mut target_end = 0;
let chars: Vec<char> = 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"
);
}
}

View File

@@ -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<f64, String> {
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<f64, String> {
}
}
pub(crate) fn collect_sources(
language: &str,
exclude_dirs: &[PathBuf],
) -> Result<Vec<String>, 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<String>,
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<Vec<String>, 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<Vec<String>, 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,
}
}

View File

@@ -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<f64, String> {
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<Vec<String>, 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<String>,
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<Vec<String>, 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,
}
}

View File

@@ -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<f64, String> {
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<Vec<String>, 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)
}

View File

@@ -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<f64, String> {
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<f64, String> {
}
}
pub(crate) fn collect_sources(
language: &str,
exclude_dirs: &[PathBuf],
) -> Result<Vec<String>, String> {
pub(crate) fn collect_sources(ctx: &BuildContext) -> Result<Vec<String>, 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<String>,
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<String, String> {
@@ -235,11 +172,11 @@ fn build_objects(
) -> Result<Vec<String>, 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,54 @@ 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,
}
}

View File

@@ -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<f64, String> {
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<f64, String> {
}
}
pub(crate) fn collect_sources(
language: &str,
exclude_dirs: &[PathBuf],
) -> Result<Vec<String>, 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<String>,
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<Vec<String>, 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,
}
}

View File

@@ -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<f64, String> {
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<f64, String> {
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<f64, String> {
}
}
pub(crate) fn collect_sources(
language: &str,
exclude_dirs: &[PathBuf],
) -> Result<Vec<String>, String> {
pub(crate) fn collect_sources(ctx: &BuildContext) -> Result<Vec<String>, 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<String>,
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<Vec<String>, 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,
}
}

View File

@@ -393,3 +393,198 @@ 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");
}
}

View File

@@ -18,3 +18,57 @@ 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"
);
}
}

297
tests/cli_errors.rs Normal file
View File

@@ -0,0 +1,297 @@
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"
);
}