mirror of
https://github.com/dexoron/dcr.git
synced 2026-10-21 12:58:34 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dd996b92d1 | |||
| a0e4fdbacf |
126
AGENT.md
Normal file
126
AGENT.md
Normal 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)
|
||||
10
CHANGELOG.md
10
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)
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -90,7 +90,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dcr"
|
||||
version = "0.2.8"
|
||||
version = "0.2.9"
|
||||
dependencies = [
|
||||
"reqwest",
|
||||
"self-replace",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "dcr"
|
||||
version = "0.2.8"
|
||||
version = "0.2.9"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
7
TODO.md
7
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
|
||||
- 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
421
src/core/builder/common.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
297
tests/cli_errors.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user