From b9cbef43e91282abc6c7fcec8cb32d6a4b8b73b2 Mon Sep 17 00:00:00 2001 From: Dexoron Date: Wed, 5 Aug 2026 00:37:00 +0800 Subject: [PATCH] feat: add .dcr/ build facts and canonical artifact paths for IDE integrations --- CHANGELOG.md | 60 +++ Cargo.lock | 2 +- Cargo.toml | 2 +- docs/changelog.mdx | 29 ++ man/man1/dcr-build.1 | 17 +- man/man1/dcr-gen.1 | 54 ++- man/man1/dcr.1 | 2 +- src/cli/build.rs | 160 +++++++- src/cli/flags.rs | 7 + src/cli/gen.rs | 595 ++++++++++++++++++++++++----- src/cli/init.rs | 4 + src/cli/new.rs | 5 + src/core/build/builder/artifact.rs | 121 ++++++ src/core/build/cache.rs | 43 +-- src/core/build/engine.rs | 77 +--- src/core/vcs/git.rs | 5 +- src/platform/bsd.rs | 68 ++-- src/platform/linux.rs | 80 ++-- src/platform/macos.rs | 26 +- src/platform/mod.rs | 1 + src/platform/path_util.rs | 80 ++++ src/platform/posix.rs | 21 +- src/platform/windows.rs | 39 +- src/utils/build.rs | 112 +++++- src/utils/fs.rs | 152 ++++++++ tests/cli_gen.rs | 144 +++++++ tests/cli_new.rs | 10 +- 27 files changed, 1590 insertions(+), 326 deletions(-) create mode 100644 src/platform/path_util.rs create mode 100644 tests/cli_gen.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 5505c99..afe4f42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,65 @@ # Changelog +## [0.8.4] - 2026-08-04 "Инфраструктура .dcr/ и интеграция с IDE / Internal .dcr/ Layout & IDE Integration" + +### RU + +**Добавлено:** + +- **Директория `.dcr/`** — единый machine-readable источник фактов о сборке для IDE/расширений: + - `.dcr/compile_commands.json` + - `.dcr/build-info.json` (`schemaVersion`, `artifact_path`, `profile`, …) + - `.dcr/toolchain.json` (compiler/debugger paths, moc/uic/rcc) + - `.dcr/ide/` (заготовки lock-файлов для расширений) +- **`dcr gen project-info`** — поля `target`, `target_dir`, `artifact_path`, `artifact_kind`, `source_roots`, `include_globs`, `exclude_globs`; пути абсолютные/канонические. +- **`dcr build --print-artifact-path`** — после успешной сборки одна строка absolute path на stdout. +- **`dcr gen … --quiet` / `-q`** — без human-лога на stdout для `compile-commands` / `project-info`. +- **`.clangd`** — при первом `gen` создаётся с `CompilationDatabase: .dcr` (маркер `# managed by dcr gen`); ручной `.clangd` не перезаписывается. +- **Хелперы** `canonicalize_path`, `atomic_write`, `ensure_dcr_dir`; pure `resolve_artifact_path` через `platform::*`. +- **`dcr new` / `dcr init`** — `.dcr/` в `.gitignore`. + +**Изменено (breaking):** + +- **`compile_commands.json` перенесён** из корня проекта в **`.dcr/compile_commands.json`**. + clangd находит базу через `.clangd` / `clangd.arguments --compile-commands-dir=.dcr`. + CLion `misc.xml` и VSCode `settings.json` обновлены под новый путь. +- **Атомарная запись** `compile_commands.json` (tmp + rename). +- **`gen vscode` / `gen clion` launch/run** — `program`/`executable` через тот же artifact resolver, что `project-info` / `build-info.json` (Linux triple, Windows `.exe`, custom `target`/`filename`). + +**Исправлено:** + +- Launch-конфиги больше не указывают `target//` без triple на Linux. +- Симлинки/mount: root и пути в compile database канонизируются best-effort. + +### EN + +**Added:** + +- **`.dcr/` directory** — shared machine-readable build facts for IDE integrations: + - `.dcr/compile_commands.json` + - `.dcr/build-info.json` (`schemaVersion`, `artifact_path`, `profile`, …) + - `.dcr/toolchain.json` (compiler/debugger paths, moc/uic/rcc) + - `.dcr/ide/` (placeholder for IDE lock files) +- **`dcr gen project-info`** — `target`, `target_dir`, `artifact_path`, `artifact_kind`, `source_roots`, `include_globs`, `exclude_globs`; absolute/canonical paths. +- **`dcr build --print-artifact-path`** — one absolute artifact path line on stdout after a successful build. +- **`dcr gen … --quiet` / `-q`** — suppress human success logs for gen subcommands. +- **`.clangd`** — created on first gen with `CompilationDatabase: .dcr` (`# managed by dcr gen`); custom `.clangd` is not overwritten. +- **Helpers** `canonicalize_path`, `atomic_write`, `ensure_dcr_dir`; pure `resolve_artifact_path` via `platform::*`. +- **`dcr new` / `dcr init`** — add `.dcr/` to `.gitignore`. + +**Changed (breaking):** + +- **`compile_commands.json` moved** from project root to **`.dcr/compile_commands.json`**. + clangd discovers it via `.clangd` / `--compile-commands-dir=.dcr`. + CLion and VSCode generators updated accordingly. +- **Atomic write** for compile_commands (tmp + rename). +- **`gen vscode` / `gen clion` launch/run** — use the same artifact path resolver as `project-info` / `build-info.json`. + +**Fixed:** + +- Launch configs no longer hardcode `target//` without the Linux triple segment. +- Symlink/mount roots: best-effort canonicalize for root and compile database paths. + ## [0.8.3] - 2026-08-03 "Конфигурация host target, аргументы run и нативные пути артефактов / Host Target Config, Run Args & Native Artifact Paths" ### RU diff --git a/Cargo.lock b/Cargo.lock index cccfe4b..4894ba3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,7 +154,7 @@ dependencies = [ [[package]] name = "dcr" -version = "0.8.3" +version = "0.8.4" dependencies = [ "ctrlc", "fatfs", diff --git a/Cargo.toml b/Cargo.toml index bf160b7..1741a27 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dcr" -version = "0.8.3" +version = "0.8.4" edition = "2024" build = "build.rs" description = "DCR is a utility for managing C/C++ projects in a Cargo-like style." diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 0b7bc74..2dcbea2 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -4,6 +4,35 @@ sidebar_label: Changelog # Changelog +## 0.8.4 (2026-08-04) + +**Added:** + +- **`.dcr/` directory** — shared machine-readable build facts for IDE integrations: + - `.dcr/compile_commands.json` + - `.dcr/build-info.json` (`schemaVersion`, `artifact_path`, `profile`, …) + - `.dcr/toolchain.json` (compiler/debugger paths, moc/uic/rcc) + - `.dcr/ide/` (placeholder for IDE lock files) +- **`dcr gen project-info`** — `target`, `target_dir`, `artifact_path`, `artifact_kind`, `source_roots`, `include_globs`, `exclude_globs`; absolute/canonical paths. +- **`dcr build --print-artifact-path`** — one absolute artifact path line on stdout after a successful build. +- **`dcr gen … --quiet` / `-q`** — suppress human success logs for gen subcommands. +- **`.clangd`** — created on first gen with `CompilationDatabase: .dcr` (`# managed by dcr gen`); custom `.clangd` is not overwritten. +- **Helpers** `canonicalize_path`, `atomic_write`, `ensure_dcr_dir`; pure `resolve_artifact_path` via `platform::*`. +- **`dcr new` / `dcr init`** — add `.dcr/` to `.gitignore`. + +**Changed (breaking):** + +- **`compile_commands.json` moved** from project root to **`.dcr/compile_commands.json`**. + clangd discovers it via `.clangd` / `--compile-commands-dir=.dcr`. + CLion and VSCode generators updated accordingly. +- **Atomic write** for compile_commands (tmp + rename). +- **`gen vscode` / `gen clion` launch/run** — use the same artifact path resolver as `project-info` / `build-info.json`. + +**Fixed:** + +- Launch configs no longer hardcode `target//` without the Linux triple segment. +- Symlink/mount roots: best-effort canonicalize for root and compile database paths. + ## 0.8.3 (2026-08-03) ### Added diff --git a/man/man1/dcr-build.1 b/man/man1/dcr-build.1 index 7c80c21..1cde991 100644 --- a/man/man1/dcr-build.1 +++ b/man/man1/dcr-build.1 @@ -1,4 +1,4 @@ -.TH DCR-BUILD 1 "2026-06-02" "dcr 0.7.0" "DCR Manual" +.TH DCR-BUILD 1 "2026-08-04" "dcr 0.8.4" "DCR Manual" .SH NAME dcr-build \- Compile a DCR project .SH SYNOPSIS @@ -9,8 +9,15 @@ dcr-build \- Compile a DCR project [\fB\-\-clean\fR] [\fB\-\-verbose\fR] [\fB\-\-workspace\fR \fI\fR] +[\fB\-\-print\-artifact\-path\fR] .SH DESCRIPTION -Compiles the project. Default profile is \fB\-\-debug\fR. +Compiles the project. Default profile is +.B \-\-debug . +On success, updates +.B .dcr/build-info.json +and +.B .dcr/toolchain.json +when applicable. .SH OPTIONS .TP .B \-\-debug @@ -33,5 +40,9 @@ Print detailed build output including compiler commands. .TP .B \-\-workspace \fI\fR Build a specific workspace member. +.TP +.B \-\-print\-artifact\-path +After a successful build, print the absolute path of the main artifact as a single line on stdout. .SH SEE ALSO -.BR dcr (1) +.BR dcr (1), +.BR dcr-gen (1) diff --git a/man/man1/dcr-gen.1 b/man/man1/dcr-gen.1 index 5b61487..6c742a1 100644 --- a/man/man1/dcr-gen.1 +++ b/man/man1/dcr-gen.1 @@ -1,23 +1,67 @@ -.TH DCR-GEN 1 "2026-06-02" "dcr 0.7.0" "DCR Manual" +.TH DCR-GEN 1 "2026-08-04" "dcr 0.8.4" "DCR Manual" .SH NAME dcr-gen \- Generate IDE and tooling integration files .SH SYNOPSIS .B dcr gen \fI\fR +[\fB\-\-debug\fR | \fB\-\-release\fR] +[\fB\-\-quiet\fR | \fB\-q\fR] .SH DESCRIPTION Generates IDE and tooling integration files for DCR projects. +Machine-readable build facts are written under +.B .dcr/ +(compile database, build-info, toolchain). .SH SUBCOMMANDS .TP .B project-info -Print project metadata as JSON to stdout. +Print project metadata as JSON to stdout (absolute paths, artifact_path, target_dir). +Also updates +.B .dcr/build-info.json +and +.B .dcr/toolchain.json . .TP .B compile-commands -Generate \fBcompile_commands.json\fR for IDE tooling. +Generate +.B .dcr/compile_commands.json +(atomic write) and ensure +.B .clangd +points at +.B .dcr +as CompilationDatabase. .TP .B vscode -Generate \fB.vscode/\fR integration files (tasks, launch configurations). +Generate +.B .vscode/ +integration files (tasks, launch configurations) and compile commands under +.B .dcr/ . .TP .B clion -Generate \fB.idea/\fR integration files for CLion. +Generate +.B .idea/ +integration files for CLion and compile commands under +.B .dcr/ . +.SH OPTIONS +.TP +.B \-\-debug +Use debug profile (default). +.TP +.B \-\-release +Use release profile. +.TP +.B \-\-quiet\fR, \fB\-q +Suppress human-readable success messages on stdout. +.SH FILES +.TP +.B .dcr/compile_commands.json +JSON Compilation Database (moved from project root; breaking change in 0.8.4). +.TP +.B .dcr/build-info.json +Last known artifact path, profile, compiler. +.TP +.B .dcr/toolchain.json +Resolved compiler/debugger (and Qt tools if configured). +.TP +.B .clangd +Created once with CompilationDatabase: .dcr; not overwritten if customized. .SH SEE ALSO .BR dcr (1), .BR dcr-build (1) diff --git a/man/man1/dcr.1 b/man/man1/dcr.1 index 4af6cfd..0cdaa2e 100644 --- a/man/man1/dcr.1 +++ b/man/man1/dcr.1 @@ -128,7 +128,7 @@ Version from registry (e.g. \fB1.2.3\fR). Print project metadata as JSON to stdout. .TP .B compile-commands -Generate \fBcompile_commands.json\fR for IDE tooling. +Generate \fB.dcr/compile_commands.json\fR for IDE tooling. .TP .B vscode Generate \fB.vscode/\fR integration files (tasks, launch configurations). diff --git a/src/cli/build.rs b/src/cli/build.rs index 36bae24..66551c6 100644 --- a/src/cli/build.rs +++ b/src/cli/build.rs @@ -17,13 +17,20 @@ use crate::cli::clean::clean; use crate::cli::flags::parse_build_run_flags; +use crate::cli::r#gen::{ProjectInfo, write_dcr_metadata}; +use crate::core::build::builder::artifact::{absolute_artifact_path, resolve_artifact_path}; use crate::core::build::common; use crate::core::build::{BuildEvent, BuildReporter, BuildRequest, run_build}; use crate::core::build_config::Config; -use crate::utils::fs::find_project_root; +use crate::utils::build::{ + get_config_opt, get_config_str, get_language_with_profile_or_default, get_string_with_profile, + normalize_kind, resolve_artifact_target_dir, resolve_compiler, +}; +use crate::utils::fs::{canonicalize_path, find_project_root}; use crate::utils::log::error; use crate::utils::text::{BOLD_CYAN, BOLD_GREEN, BOLD_YELLOW, colored, printc}; use std::io::IsTerminal; +use std::path::Path; use std::sync::{Arc, atomic::AtomicBool}; pub use crate::core::build::get_build_string_with_profile; @@ -109,7 +116,7 @@ pub fn build(args: &[String]) -> i32 { if args.first().is_some_and(|a| a == "--help") { printc("USAGE:", BOLD_GREEN); printc( - " dcr build [--debug | --release] [--target ] [--force] [--clean] [--verbose]", + " dcr build [--debug | --release] [--target ] [--force] [--clean] [--verbose] [--print-artifact-path]", BOLD_CYAN, ); println!(); @@ -124,6 +131,9 @@ pub fn build(args: &[String]) -> i32 { println!(" --clean Clean before building"); println!(" --verbose Print detailed build output"); println!(" --workspace Build a specific workspace member"); + println!( + " --print-artifact-path Print absolute main artifact path on stdout after success" + ); return 0; } @@ -141,7 +151,7 @@ pub fn build(args: &[String]) -> i32 { } }; let root = match find_project_root(&start_dir) { - Ok(Some(dir)) => dir, + Ok(Some(dir)) => canonicalize_path(&dir), Ok(None) => { error("dcr.toml file not found"); return 1; @@ -174,16 +184,33 @@ pub fn build(args: &[String]) -> i32 { } let req = BuildRequest { - profile: flags.profile, - target: flags.target, + profile: flags.profile.clone(), + target: flags.target.clone(), force: flags.force, verbose: flags.verbose, - workspace: flags.workspace, + workspace: flags.workspace.clone(), cancel, }; let mut reporter = CliReporter; match run_build(&root, &req, &mut reporter) { - Ok(_) => 0, + Ok(_) => { + if let Some(info) = + resolve_post_build_info(&root, &flags.profile, flags.target.as_deref()) + { + let _ = write_dcr_metadata(&root, &info); + if flags.print_artifact_path + && let Some(ref path) = info.artifact_path + { + println!("{path}"); + } + } else if flags.print_artifact_path + && let Some(path) = + fallback_artifact_path(&root, &flags.profile, flags.target.as_deref()) + { + println!("{path}"); + } + 0 + } Err(err) => { common::finish_progress_line(); error(&err.message); @@ -191,3 +218,122 @@ pub fn build(args: &[String]) -> i32 { } } } + +fn resolve_post_build_info( + root: &Path, + profile: &str, + cli_target: Option<&str>, +) -> Option { + let config = Config::open(root.join("dcr.toml").to_str()?).ok()?; + if config.is_workspace_only() { + return None; + } + let name = get_config_str(&config, "package.name"); + let version = get_config_str(&config, "package.version"); + let language = get_language_with_profile_or_default(&config, profile); + let standard = get_string_with_profile(&config, "standard", profile); + let cxx_standard = get_string_with_profile(&config, "cxx_standard", profile); + let compiler_s = get_string_with_profile(&config, "compiler", profile); + let kind = normalize_kind(&get_string_with_profile(&config, "kind", profile)).to_string(); + let build_target = cli_target + .map(|s| s.to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| get_string_with_profile(&config, "target", profile)); + let output_filename_s = get_string_with_profile(&config, "filename", profile); + let output_extension_s = get_string_with_profile(&config, "extension", profile); + let output_filename = if output_filename_s.is_empty() { + None + } else { + Some(output_filename_s) + }; + let output_extension = if output_extension_s.is_empty() { + None + } else { + Some(output_extension_s) + }; + let tc_cc = get_config_opt(&config, "toolchain.cc"); + let tc_cxx = get_config_opt(&config, "toolchain.cxx"); + let tc_as = get_config_opt(&config, "toolchain.as"); + let resolved_compiler = resolve_compiler( + &language, + &compiler_s, + tc_cc.as_deref(), + tc_cxx.as_deref(), + tc_as.as_deref(), + ); + let out_dir = get_string_with_profile(&config, "out_dir", profile); + let has_explicit = cli_target.is_some_and(|t| !t.is_empty()) || !build_target.trim().is_empty(); + let target_dir = + resolve_artifact_target_dir(root, None, profile, &build_target, &out_dir, has_explicit); + let rel = resolve_artifact_path( + &kind, + profile, + &name, + Some(&target_dir), + output_filename.as_deref(), + output_extension.as_deref(), + )?; + let artifact_path = absolute_artifact_path(root, &rel) + .to_string_lossy() + .to_string(); + let abs_target_dir = { + let p = Path::new(&target_dir); + let path = if p.is_absolute() { + canonicalize_path(p) + } else { + canonicalize_path(&root.join(p)) + }; + path.to_string_lossy().into_owned() + }; + Some(ProjectInfo { + name, + version, + root: root.to_path_buf(), + profile: profile.to_string(), + language, + standard, + cxx_standard, + compiler: resolved_compiler, + kind: kind.clone(), + target: build_target, + target_dir: Some(abs_target_dir), + artifact_path: Some(artifact_path), + artifact_kind: kind, + sources: vec![], + include_dirs: vec![], + lib_dirs: vec![], + libs: vec![], + cflags: vec![], + ldflags: vec![], + source_roots: vec![], + include_globs: vec![], + exclude_globs: vec![], + output_filename, + output_extension, + out_dir, + debugger: "lldb".to_string(), + moc: None, + uic: None, + rcc: None, + workspace_root: None, + }) +} + +fn fallback_artifact_path(root: &Path, profile: &str, target: Option<&str>) -> Option { + let config = Config::open(root.join("dcr.toml").to_str()?).ok()?; + let name = get_config_str(&config, "package.name"); + if name.is_empty() { + return None; + } + let build_target = target.unwrap_or("").to_string(); + let out_dir = get_string_with_profile(&config, "out_dir", profile); + let has_explicit = !build_target.trim().is_empty(); + let target_dir = + resolve_artifact_target_dir(root, None, profile, &build_target, &out_dir, has_explicit); + let rel = resolve_artifact_path("bin", profile, &name, Some(&target_dir), None, None)?; + Some( + absolute_artifact_path(root, &rel) + .to_string_lossy() + .to_string(), + ) +} diff --git a/src/cli/flags.rs b/src/cli/flags.rs index c8c68b4..14fe15e 100644 --- a/src/cli/flags.rs +++ b/src/cli/flags.rs @@ -25,6 +25,7 @@ pub struct BuildRunFlags { pub force: bool, pub clean: bool, pub verbose: bool, + pub print_artifact_path: bool, pub bin_args: Vec, } @@ -43,6 +44,7 @@ pub fn parse_build_run_flags(args: &[String]) -> Result { let mut force = false; let mut clean = false; let mut verbose = false; + let mut print_artifact_path = false; let mut iter = dcr_args.iter(); while let Some(arg) = iter.next() { @@ -63,6 +65,10 @@ pub fn parse_build_run_flags(args: &[String]) -> Result { verbose = true; continue; } + if candidate == "print-artifact-path" { + print_artifact_path = true; + continue; + } if candidate == "workspace" { if let Some(w) = iter.next() { workspace = Some(w.clone()); @@ -100,6 +106,7 @@ pub fn parse_build_run_flags(args: &[String]) -> Result { force, clean, verbose, + print_artifact_path, bin_args: bin_args_slice.to_vec(), }) } diff --git a/src/cli/gen.rs b/src/cli/gen.rs index 5812cd2..234d342 100644 --- a/src/cli/gen.rs +++ b/src/cli/gen.rs @@ -16,6 +16,7 @@ // along with this program. If not, see . use crate::core::build::builder::BuildContext; +use crate::core::build::builder::artifact::{absolute_artifact_path, resolve_artifact_path}; use crate::core::build::builder::collect_sources; use crate::core::build::common; use crate::core::build_config::Config; @@ -23,12 +24,15 @@ use crate::core::workspace::parse_workspace; use crate::utils::build::{ get_bool_with_profile, get_config_opt, get_config_str, get_language_with_profile_or_default, get_list_with_profile, get_string_with_profile, normalize_kind, normalize_platform, - normalize_target, resolve_compiler, resolve_pkg_config_flags_lossy, + resolve_artifact_target_dir, resolve_compiler, resolve_pkg_config_flags_lossy, +}; +use crate::utils::fs::{ + absolute_join, atomic_write, canonicalize_path, ensure_dcr_dir, find_project_root, }; -use crate::utils::fs::find_project_root; use crate::utils::log::error; use crate::utils::text::{BOLD_CYAN, BOLD_GREEN, printc}; use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; /// Like `deps::resolve_deps` but does NOT require lib directories to exist. /// Used by `gen` commands where the project may not have been built yet. @@ -164,7 +168,6 @@ fn resolve_deps_for_gen(config: &Config, profile: &str, project_root: &Path) -> // ── public API ─────────────────────────────────────────────────────────────── -/// Everything needed to generate output for one project member. pub struct ProjectInfo { pub name: String, pub version: String, @@ -175,12 +178,27 @@ pub struct ProjectInfo { pub cxx_standard: String, pub compiler: String, pub kind: String, + pub target: String, + pub target_dir: Option, + pub artifact_path: Option, + pub artifact_kind: String, pub sources: Vec, pub include_dirs: Vec, pub lib_dirs: Vec, pub libs: Vec, pub cflags: Vec, pub ldflags: Vec, + pub source_roots: Vec, + pub include_globs: Vec, + pub exclude_globs: Vec, + pub output_filename: Option, + pub output_extension: Option, + pub out_dir: String, + pub debugger: String, + pub moc: Option, + pub uic: Option, + pub rcc: Option, + pub workspace_root: Option, } // ── entry-point for `dcr gen` ──────────────────────────────────────────────── @@ -196,9 +214,13 @@ pub fn r#gen(args: &[String]) -> i32 { println!(); printc("SUBCOMMANDS:", BOLD_GREEN); println!(" project-info Print project metadata as JSON"); - println!(" compile-commands Generate compile_commands.json"); + println!(" compile-commands Generate .dcr/compile_commands.json"); println!(" vscode Generate .vscode/ integration files"); println!(" clion Generate .idea/ integration files"); + println!(); + printc("OPTIONS:", BOLD_GREEN); + println!(" --debug | --release Profile (default: debug)"); + println!(" --quiet | -q Suppress success messages on stdout"); return 0; } Some(s) => s.as_str(), @@ -208,7 +230,7 @@ pub fn r#gen(args: &[String]) -> i32 { println!(); printc("SUBCOMMANDS:", BOLD_GREEN); println!(" project-info Print project metadata as JSON"); - println!(" compile-commands Generate compile_commands.json"); + println!(" compile-commands Generate .dcr/compile_commands.json"); println!(" vscode Generate .vscode/ integration files"); println!(" clion Generate .idea/ integration files"); return 1; @@ -231,16 +253,24 @@ pub fn r#gen(args: &[String]) -> i32 { // ── shared: collect per-project data ───────────────────────────────────────── -fn collect_project_info(root: &Path, profile: &str) -> Result { - // Run from the project root so relative paths in dcr.toml resolve correctly. +fn collect_project_info( + root: &Path, + profile: &str, + workspace_root: Option<&Path>, +) -> Result { let prev = std::env::current_dir().map_err(|e| e.to_string())?; std::env::set_current_dir(root).map_err(|e| e.to_string())?; - let result = collect_project_info_inner(root, profile); + let result = collect_project_info_inner(root, profile, workspace_root); let _ = std::env::set_current_dir(prev); result } -fn collect_project_info_inner(root: &Path, profile: &str) -> Result { +fn collect_project_info_inner( + root: &Path, + profile: &str, + workspace_root: Option<&Path>, +) -> Result { + let root = canonicalize_path(root); let config = Config::open("./dcr.toml").map_err(|e| e.to_string())?; let name = get_config_str(&config, "package.name"); @@ -249,15 +279,32 @@ fn collect_project_info_inner(root: &Path, profile: &str) -> Result Result Result Result Result Result = sources .iter() .map(|s| { - let p = Path::new(s); - if p.is_absolute() { - s.clone() - } else { - root.join(s).to_string_lossy().to_string() - } + absolute_join(&root, Path::new(s)) + .to_string_lossy() + .to_string() }) .collect(); + let abs_include_dirs: Vec = merged_include_dirs + .iter() + .map(|d| { + absolute_join(&root, Path::new(d)) + .to_string_lossy() + .to_string() + }) + .collect(); + + let abs_lib_dirs: Vec = resolved + .lib_dirs + .iter() + .map(|d| { + absolute_join(&root, Path::new(d)) + .to_string_lossy() + .to_string() + }) + .collect(); + + let abs_source_roots: Vec = source_roots + .iter() + .map(|p| canonicalize_path(p).to_string_lossy().to_string()) + .collect(); + + let abs_target_dir = { + let p = Path::new(&target_dir_binding); + let path = if p.is_absolute() { + canonicalize_path(p) + } else { + absolute_join(&root, p) + }; + path.to_string_lossy().into_owned() + }; + + let relative_artifact = resolve_artifact_path( + &kind, + profile, + &name, + Some(&abs_target_dir), + output_filename.as_deref(), + output_extension.as_deref(), + ); + + let artifact_path = relative_artifact.map(|rel| { + absolute_artifact_path(&root, &rel) + .to_string_lossy() + .to_string() + }); + + let debugger = resolve_debugger(tc_debugger.as_deref()); + let ws_root = workspace_root.map(canonicalize_path); + Ok(ProjectInfo { name, version, - root: root.to_path_buf(), + root, profile: profile.to_string(), language, standard, cxx_standard, compiler: resolved_compiler, - kind: normalize_kind(&kind).to_string(), + kind: kind.clone(), + target: build_target, + target_dir: Some(abs_target_dir), + artifact_path, + artifact_kind: kind, sources: abs_sources, - include_dirs: merged_include_dirs, - lib_dirs: resolved.lib_dirs, + include_dirs: abs_include_dirs, + lib_dirs: abs_lib_dirs, libs: resolved.libs, cflags: resolved_cflags, ldflags: resolved_ldflags, + source_roots: abs_source_roots, + include_globs: build_includes, + exclude_globs: build_excludes, + output_filename, + output_extension, + out_dir: out_dir_config, + debugger, + moc: tc_moc.and_then(|v| resolve_tool_path(&v)), + uic: tc_uic.and_then(|v| resolve_tool_path(&v)), + rcc: tc_rcc.and_then(|v| resolve_tool_path(&v)), + workspace_root: ws_root, }) } -/// Collect info for root project + all workspace members. +fn find_in_path(name: &str) -> Option { + if name.contains('/') || name.contains('\\') { + let p = Path::new(name); + if p.exists() { + return Some(canonicalize_path(p).to_string_lossy().to_string()); + } + return Some(name.to_string()); + } + let path_var = std::env::var_os("PATH")?; + for dir in std::env::split_paths(&path_var) { + let candidate = dir.join(name); + if candidate.is_file() { + return Some(canonicalize_path(&candidate).to_string_lossy().to_string()); + } + #[cfg(windows)] + { + let with_exe = dir.join(format!("{name}.exe")); + if with_exe.is_file() { + return Some(canonicalize_path(&with_exe).to_string_lossy().to_string()); + } + } + } + None +} + +fn resolve_tool_path(name: &str) -> Option { + let t = name.trim(); + if t.is_empty() { + return None; + } + find_in_path(t).or_else(|| Some(t.to_string())) +} + +fn resolve_debugger(configured: Option<&str>) -> String { + if let Some(d) = configured { + let t = d.trim(); + if !t.is_empty() { + return t.to_string(); + } + } + for candidate in ["lldb", "gdb"] { + if find_in_path(candidate).is_some() { + return candidate.to_string(); + } + } + if cfg!(windows) { + "cppvsdbg".to_string() + } else { + "lldb".to_string() + } +} + +fn utc_now_iso() -> String { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let days = secs / 86400; + let tod = secs % 86400; + let hour = tod / 3600; + let min = (tod % 3600) / 60; + let sec = tod % 60; + let (year, month, day) = civil_from_days(days as i64); + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{min:02}:{sec:02}Z") +} + +fn civil_from_days(days: i64) -> (i32, u32, u32) { + let z = days + 719468; + let era = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = (z - era * 146097) as u32; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = (yoe as i64) + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + (y as i32, m, d) +} + +pub fn write_dcr_metadata(root: &Path, info: &ProjectInfo) -> Result<(), String> { + let dcr = ensure_dcr_dir(root).map_err(|e| format!("Failed to create .dcr/: {e}"))?; + + let artifact_json = match &info.artifact_path { + Some(p) => json_str(p), + None => "null".to_string(), + }; + let target_dir_json = match &info.target_dir { + Some(p) => json_str(p), + None => "null".to_string(), + }; + + let build_info = format!( + r#"{{ + "schemaVersion": 1, + "generatedAt": {generated}, + "profile": {profile}, + "compiler": {compiler}, + "target": {target}, + "target_dir": {target_dir}, + "artifact_path": {artifact}, + "artifact_kind": {kind} +}} +"#, + generated = json_str(&utc_now_iso()), + profile = json_str(&info.profile), + compiler = json_str(&info.compiler), + target = json_str(&info.target), + target_dir = target_dir_json, + artifact = artifact_json, + kind = json_str(&info.artifact_kind), + ); + atomic_write(&dcr.join("build-info.json"), build_info.as_bytes()) + .map_err(|e| format!("Failed to write build-info.json: {e}"))?; + + let compiler_path = find_in_path(&info.compiler).unwrap_or_else(|| info.compiler.clone()); + let debugger_path = find_in_path(&info.debugger).unwrap_or_else(|| info.debugger.clone()); + + let mut toolchain = String::from("{\n"); + toolchain.push_str(" \"schemaVersion\": 1,\n"); + toolchain.push_str(&format!(" \"compiler\": {},\n", json_str(&info.compiler))); + toolchain.push_str(&format!( + " \"compilerPath\": {},\n", + json_str(&compiler_path) + )); + toolchain.push_str(&format!(" \"debugger\": {},\n", json_str(&info.debugger))); + toolchain.push_str(&format!(" \"debuggerPath\": {}", json_str(&debugger_path))); + if let Some(ref m) = info.moc { + toolchain.push_str(",\n"); + toolchain.push_str(&format!(" \"moc\": {}", json_str(m))); + } + if let Some(ref u) = info.uic { + toolchain.push_str(",\n"); + toolchain.push_str(&format!(" \"uic\": {}", json_str(u))); + } + if let Some(ref r) = info.rcc { + toolchain.push_str(",\n"); + toolchain.push_str(&format!(" \"rcc\": {}", json_str(r))); + } + toolchain.push_str("\n}\n"); + atomic_write(&dcr.join("toolchain.json"), toolchain.as_bytes()) + .map_err(|e| format!("Failed to write toolchain.json: {e}"))?; + + Ok(()) +} + +fn ensure_clangd(root: &Path, quiet: bool) { + let path = root.join(".clangd"); + let marker = "# managed by dcr gen"; + let desired = format!("{marker}\nCompileFlags:\n CompilationDatabase: .dcr\n"); + if path.exists() { + match std::fs::read_to_string(&path) { + Ok(content) + if content.contains(marker) || content.contains("CompilationDatabase: .dcr") => + { + return; + } + Ok(_) => { + if !quiet { + eprintln!( + "Warning: .clangd already exists and is not managed by dcr; \ + set CompilationDatabase to .dcr manually if needed" + ); + } + return; + } + Err(_) => return, + } + } + let _ = std::fs::write(&path, desired); +} + fn collect_all(root: &Path, profile: &str) -> Result, String> { - // Check for workspace + let root = canonicalize_path(root); let config = { let prev = std::env::current_dir().map_err(|e| e.to_string())?; - std::env::set_current_dir(root).map_err(|e| e.to_string())?; + std::env::set_current_dir(&root).map_err(|e| e.to_string())?; let cfg = Config::open("./dcr.toml").map_err(|e| e.to_string()); let _ = std::env::set_current_dir(prev); cfg? }; let mut all = Vec::new(); + let is_workspace = parse_workspace(&config, profile, None, &root) + .ok() + .flatten() + .is_some(); + let ws_root = if is_workspace { + Some(root.as_path()) + } else { + None + }; - if let Ok(Some(ws)) = parse_workspace(&config, profile, None, root) { + if let Ok(Some(ws)) = parse_workspace(&config, profile, None, &root) { for member in &ws.members { - match collect_project_info(&member.path, profile) { + let member_path = canonicalize_path(&member.path); + match collect_project_info(&member_path, profile, ws_root) { Ok(info) => all.push(info), Err(e) => eprintln!( "Warning: skipping workspace member {}: {e}", - member.path.display() + member_path.display() ), } } } - // Root project itself - let root_info = collect_project_info(root, profile)?; - all.push(root_info); + if !is_workspace || !config.is_workspace_only() { + match collect_project_info(&root, profile, ws_root) { + Ok(info) => all.push(info), + Err(e) if is_workspace => { + eprintln!("Warning: skipping workspace root package: {e}"); + } + Err(e) => return Err(e), + } + } + + if all.is_empty() { + return Err("No project members found".to_string()); + } Ok(all) } @@ -456,7 +777,7 @@ fn collect_all(root: &Path, profile: &str) -> Result, String> { // ── dcr gen project-info ───────────────────────────────────────────────────── fn gen_project_info(args: &[String]) -> i32 { - let (root, profile) = match parse_gen_args(args) { + let (root, profile, quiet) = match parse_gen_args(args) { Ok(v) => v, Err(code) => return code, }; @@ -469,6 +790,14 @@ fn gen_project_info(args: &[String]) -> i32 { } }; + if let Some(info) = all.last() + && let Err(e) = write_dcr_metadata(&root, info) + && !quiet + { + eprintln!("Warning: {e}"); + } + ensure_clangd(&root, quiet); + print!("["); for (i, info) in all.iter().enumerate() { if i > 0 { @@ -509,6 +838,25 @@ fn project_info_to_json(info: &ProjectInfo) -> String { json_str(&info.compiler) )); out.push_str(&format!(" \"kind\": {},\n", json_str(&info.kind))); + out.push_str(&format!(" \"target\": {},\n", json_str(&info.target))); + out.push_str(&format!( + " \"target_dir\": {},\n", + match &info.target_dir { + Some(p) => json_str(p), + None => "null".to_string(), + } + )); + out.push_str(&format!( + " \"artifact_path\": {},\n", + match &info.artifact_path { + Some(p) => json_str(p), + None => "null".to_string(), + } + )); + out.push_str(&format!( + " \"artifact_kind\": {},\n", + json_str(&info.artifact_kind) + )); out.push_str(&format!( " \"sources\": {},\n", json_str_array(&info.sources) @@ -527,9 +875,21 @@ fn project_info_to_json(info: &ProjectInfo) -> String { json_str_array(&info.cflags) )); out.push_str(&format!( - " \"ldflags\": {}\n", + " \"ldflags\": {},\n", json_str_array(&info.ldflags) )); + out.push_str(&format!( + " \"source_roots\": {},\n", + json_str_array(&info.source_roots) + )); + out.push_str(&format!( + " \"include_globs\": {},\n", + json_str_array(&info.include_globs) + )); + out.push_str(&format!( + " \"exclude_globs\": {}\n", + json_str_array(&info.exclude_globs) + )); out.push_str(" }"); out } @@ -537,7 +897,7 @@ fn project_info_to_json(info: &ProjectInfo) -> String { // ── dcr gen compile-commands ───────────────────────────────────────────────── fn gen_compile_commands(args: &[String]) -> i32 { - let (root, profile) = match parse_gen_args(args) { + let (root, profile, quiet) = match parse_gen_args(args) { Ok(v) => v, Err(code) => return code, }; @@ -550,16 +910,29 @@ fn gen_compile_commands(args: &[String]) -> i32 { } }; - gen_compile_commands_inner(&root, &profile, &all) + gen_compile_commands_inner(&root, &profile, &all, quiet) } -fn gen_compile_commands_inner(root: &Path, profile: &str, all: &[ProjectInfo]) -> i32 { - let entries = build_compile_commands(all, profile); +fn gen_compile_commands_inner(root: &Path, profile: &str, all: &[ProjectInfo], quiet: bool) -> i32 { + if let Err(e) = ensure_dcr_dir(root) { + error(&format!("Failed to create .dcr/: {e}")); + return 1; + } - let out_path = root.join("compile_commands.json"); - match std::fs::write(&out_path, &entries) { + let entries = build_compile_commands(all, profile); + let out_path = root.join(".dcr").join("compile_commands.json"); + match atomic_write(&out_path, entries.as_bytes()) { Ok(_) => { - println!("Generated {}", out_path.display()); + if let Some(info) = all.last() + && let Err(e) = write_dcr_metadata(root, info) + && !quiet + { + eprintln!("Warning: {e}"); + } + ensure_clangd(root, quiet); + if !quiet { + println!("Generated {}", out_path.display()); + } 0 } Err(e) => { @@ -615,16 +988,21 @@ fn build_compile_command(info: &ProjectInfo, source: &str, profile: &str) -> Vec cmd.push(flag.to_string()); } - cmd.push(source.to_string()); + let abs_source = absolute_join(&info.root, Path::new(source)); + cmd.push(abs_source.to_string_lossy().to_string()); // Object path (for -o, approximate — not critical for IntelliSense) - let obj_dir = info.root.join("target").join(profile).join("obj"); + + let obj_base = info + .target_dir + .as_ref() + .map(PathBuf::from) + .unwrap_or_else(|| info.root.join("target").join(profile)); + let obj_dir = obj_base.join("obj"); let obj_path = { let p = Path::new(source); let rel = strip_src_prefix(p); - obj_dir - .join(rel) - .with_extension("o") + absolute_join(&info.root, &obj_dir.join(rel).with_extension("o")) .to_string_lossy() .to_string() }; @@ -670,19 +1048,15 @@ fn build_compile_command(info: &ProjectInfo, source: &str, profile: &str) -> Vec // Expand relative -I paths to absolute so clangd/cpptools work // regardless of their working directory. if let Some(rel) = flag.strip_prefix("-I") { - let p = Path::new(rel); - let abs = if p.is_absolute() { - p.to_path_buf() - } else { - info.root.join(p) - }; + let abs = absolute_join(&info.root, Path::new(rel)); cmd.push(format!("-I{}", abs.to_string_lossy())); } else { cmd.push(flag.clone()); } } for dir in &info.include_dirs { - cmd.push(format!("-I{dir}")); + let abs = absolute_join(&info.root, Path::new(dir)); + cmd.push(format!("-I{}", abs.to_string_lossy())); } cmd @@ -703,7 +1077,7 @@ fn strip_src_prefix(p: &Path) -> PathBuf { // ── dcr gen vscode ─────────────────────────────────────────────────────────── fn gen_vscode(args: &[String]) -> i32 { - let (root, profile) = match parse_gen_args(args) { + let (root, profile, quiet) = match parse_gen_args(args) { Ok(v) => v, Err(code) => return code, }; @@ -718,7 +1092,7 @@ fn gen_vscode(args: &[String]) -> i32 { }; // 1. Generate compile_commands.json - let cc_code = gen_compile_commands_inner(&root, &profile, &all); + let cc_code = gen_compile_commands_inner(&root, &profile, &all, quiet); if cc_code != 0 { return cc_code; } @@ -842,6 +1216,58 @@ fn gen_tasks_json() -> String { .to_string() } +fn resolve_launch_program(info: &ProjectInfo, profile: &str) -> String { + if info.profile == profile + && let Some(ref p) = info.artifact_path + { + return p.clone(); + } + let has_explicit = !info.target.trim().is_empty(); + let target_dir = resolve_artifact_target_dir( + &info.root, + info.workspace_root.as_deref(), + profile, + &info.target, + &info.out_dir, + has_explicit, + ); + let rel = resolve_artifact_path( + &info.kind, + profile, + &info.name, + Some(&target_dir), + info.output_filename.as_deref(), + info.output_extension.as_deref(), + ); + match rel { + Some(r) => absolute_artifact_path(&info.root, &r) + .to_string_lossy() + .to_string(), + None => { + let name = if cfg!(windows) && !info.name.to_ascii_lowercase().ends_with(".exe") { + format!("{}.exe", info.name) + } else { + info.name.clone() + }; + Path::new(&target_dir) + .join(name) + .to_string_lossy() + .into_owned() + } + } +} + +fn debug_type_for(info: &ProjectInfo) -> &'static str { + let d = info.debugger.to_lowercase(); + if d.contains("gdb") || d == "cppdbg" { + "cppdbg" + } else if cfg!(windows) && (d.contains("vs") || d == "cppvsdbg") { + "cppvsdbg" + } else { + "lldb" + } +} + fn gen_launch_json(projects: &[ProjectInfo], _root: &Path) -> String { let mut configs = Vec::new(); @@ -851,25 +1277,14 @@ fn gen_launch_json(projects: &[ProjectInfo], _root: &Path) -> String { } // binary expected at info.root/target// (account for member projects) - let debug_bin = info - .root - .join("target") - .join("debug") - .join(&info.name) - .to_string_lossy() - .to_string(); - let release_bin = info - .root - .join("target") - .join("release") - .join(&info.name) - .to_string_lossy() - .to_string(); + let debug_bin = resolve_launch_program(info, "debug"); + let release_bin = resolve_launch_program(info, "release"); + let dtype = debug_type_for(info); let debug_entry = format!( r#" {{ "name": {name}, - "type": "lldb", + "type": {dtype}, "request": "launch", "program": {prog}, "args": [], @@ -879,6 +1294,7 @@ fn gen_launch_json(projects: &[ProjectInfo], _root: &Path) -> String { "preLaunchTask": "dcr: build (debug)" }}"#, name = json_str(&format!("{} (debug)", info.name)), + dtype = json_str(dtype), prog = json_str(&debug_bin), cwd = json_str(&info.root.to_string_lossy()), ); @@ -886,7 +1302,7 @@ fn gen_launch_json(projects: &[ProjectInfo], _root: &Path) -> String { let release_entry = format!( r#" {{ "name": {name}, - "type": "lldb", + "type": {dtype}, "request": "launch", "program": {prog}, "args": [], @@ -896,6 +1312,7 @@ fn gen_launch_json(projects: &[ProjectInfo], _root: &Path) -> String { "preLaunchTask": "dcr: build (release)" }}"#, name = json_str(&format!("{} (release)", info.name)), + dtype = json_str(dtype), prog = json_str(&release_bin), cwd = json_str(&info.root.to_string_lossy()), ); @@ -925,7 +1342,7 @@ fn gen_launch_json(projects: &[ProjectInfo], _root: &Path) -> String { } fn gen_settings_json(root: &Path) -> String { - let cc_dir = root.to_string_lossy(); + let cc_dir = root.join(".dcr").to_string_lossy().to_string(); format!( r#"{{ "clangd.arguments": [ @@ -945,7 +1362,7 @@ fn gen_settings_json(root: &Path) -> String { // ── dcr gen clion ───────────────────────────────────────────────────────────── fn gen_clion(args: &[String]) -> i32 { - let (root, profile) = match parse_gen_args(args) { + let (root, profile, quiet) = match parse_gen_args(args) { Ok(v) => v, Err(code) => return code, }; @@ -960,7 +1377,7 @@ fn gen_clion(args: &[String]) -> i32 { }; // 1. compile_commands.json - let cc_code = gen_compile_commands_inner(&root, &profile, &all); + let cc_code = gen_compile_commands_inner(&root, &profile, &all, quiet); if cc_code != 0 { return cc_code; } @@ -1140,7 +1557,7 @@ fn gen_clion_custom_targets() -> String { } fn gen_clion_misc_xml(root: &Path) -> String { - let cc_path = root.join("compile_commands.json"); + let cc_path = root.join(".dcr").join("compile_commands.json"); let cc = xml_escape(&cc_path.to_string_lossy()); format!( r#" @@ -1154,9 +1571,9 @@ fn gen_clion_misc_xml(root: &Path) -> String { ) } -fn gen_clion_run_config(info: &ProjectInfo, root: &Path, profile: &str) -> String { - let bin_path = root.join("target").join(profile).join(&info.name); - let bin = xml_escape(&bin_path.to_string_lossy()); +fn gen_clion_run_config(info: &ProjectInfo, _root: &Path, profile: &str) -> String { + let bin_path = resolve_launch_program(info, profile); + let bin = xml_escape(&bin_path); let target = if profile == "release" { "dcr: build (release)" } else { @@ -1206,12 +1623,14 @@ fn xml_escape(s: &str) -> String { // ── helpers ─────────────────────────────────────────────────────────────────── -fn parse_gen_args(args: &[String]) -> Result<(PathBuf, String), i32> { +fn parse_gen_args(args: &[String]) -> Result<(PathBuf, String, bool), i32> { let mut profile = "debug".to_string(); + let mut quiet = false; for arg in args { match arg.as_str() { "--debug" => profile = "debug".to_string(), "--release" => profile = "release".to_string(), + "--quiet" | "-q" => quiet = true, _ => {} } } @@ -1222,7 +1641,7 @@ fn parse_gen_args(args: &[String]) -> Result<(PathBuf, String), i32> { })?; let root = match find_project_root(&start) { - Ok(Some(r)) => r, + Ok(Some(r)) => canonicalize_path(&r), Ok(None) => { error("dcr.toml not found"); return Err(1); @@ -1233,7 +1652,7 @@ fn parse_gen_args(args: &[String]) -> Result<(PathBuf, String), i32> { } }; - Ok((root, profile)) + Ok((root, profile, quiet)) } fn json_str(s: &str) -> String { diff --git a/src/cli/init.rs b/src/cli/init.rs index 839bdd2..c98c4b8 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -159,6 +159,10 @@ pub fn init(args: &[String]) -> i32 { } } + if let Err(e) = crate::utils::fs::ensure_gitignore_has_dcr(std::path::Path::new(".")) { + warn(&format!("Failed to update .gitignore: {e}")); + } + println!( "Project `{}` successfully created\n", colored(project_name.as_str(), BOLD_GREEN) diff --git a/src/cli/new.rs b/src/cli/new.rs index e133ae8..d984bf5 100644 --- a/src/cli/new.rs +++ b/src/cli/new.rs @@ -180,6 +180,11 @@ pub fn new(args: &[String]) -> i32 { } } + let project_path = std::path::Path::new(project_name); + if let Err(e) = crate::utils::fs::ensure_gitignore_has_dcr(project_path) { + warn(&format!("Failed to update .gitignore: {e}")); + } + println!( "Project `{}` successfully created\n", colored(project_name, BOLD_GREEN) diff --git a/src/core/build/builder/artifact.rs b/src/core/build/builder/artifact.rs index 03ce351..8b2244b 100644 --- a/src/core/build/builder/artifact.rs +++ b/src/core/build/builder/artifact.rs @@ -30,6 +30,64 @@ pub(crate) fn artifact_dir(ctx: &BuildContext) -> PathBuf { } } +pub fn resolve_artifact_path( + kind: &str, + profile: &str, + project_name: &str, + target_dir: Option<&str>, + output_filename: Option<&str>, + output_extension: Option<&str>, +) -> Option { + if crate::utils::build::is_flat_bin(kind) { + let name = output_filename.unwrap_or(project_name); + let ext = output_extension.unwrap_or("bin"); + let file = if ext.is_empty() { + name.to_string() + } else { + format!("{name}.{ext}") + }; + let dir = match target_dir { + Some(dir) => Path::new(dir).to_path_buf(), + None => Path::new("./target").join(profile), + }; + return Some(dir.join(file).to_string_lossy().to_string()); + } + + if matches!(kind, "none" | "custom") { + return None; + } + + let name = output_filename.unwrap_or(project_name); + let ext = output_extension.unwrap_or(""); + let final_name = if ext.is_empty() { + name.to_string() + } else { + format!("{}.{}", name, ext) + }; + + let path = match kind { + "staticlib" => platform::lib_path(profile, &final_name, target_dir), + "sharedlib" => platform::shared_lib_path(profile, &final_name, target_dir), + "efi" => platform::efi_path(profile, &final_name, target_dir), + "elf" => platform::elf_path(profile, &final_name, target_dir), + _ => platform::bin_path(profile, &final_name, target_dir), + }; + Some(path) +} + +pub fn absolute_artifact_path(project_root: &Path, relative_or_abs: &str) -> PathBuf { + let p = Path::new(relative_or_abs); + let joined = if p.is_absolute() { + p.to_path_buf() + } else { + let stripped = relative_or_abs + .strip_prefix("./") + .unwrap_or(relative_or_abs); + project_root.join(stripped) + }; + crate::utils::fs::canonicalize_path(&joined) +} + pub(crate) fn flat_output_path(ctx: &BuildContext) -> String { let name = ctx.output_filename.unwrap_or(ctx.project_name); let ext = ctx.output_extension.unwrap_or("bin"); @@ -282,3 +340,66 @@ fn run(ctx: &BuildContext, mut cmd: Command, start_time: Instant) -> Result) -> PathBuf { } } -fn build_output_path(ctx: &BuildContext) -> String { - if crate::utils::build::is_flat_bin(ctx.kind) { - return crate::core::build::builder::artifact::flat_output_path(ctx); - } - - let name = ctx.output_filename.unwrap_or(ctx.project_name); - let ext = ctx.output_extension.unwrap_or(""); - - let final_name = if ext.is_empty() { - name.to_string() - } else { - format!("{}.{}", name, ext) - }; - - if ctx.kind == "staticlib" { - return crate::platform::lib_path(ctx.profile, &final_name, ctx.target_dir); - } - if ctx.kind == "sharedlib" { - return crate::platform::shared_lib_path(ctx.profile, &final_name, ctx.target_dir); - } - if ctx.kind == "efi" { - return crate::platform::efi_path(ctx.profile, &final_name, ctx.target_dir); - } - if ctx.kind == "elf" { - return crate::platform::elf_path(ctx.profile, &final_name, ctx.target_dir); - } - crate::platform::bin_path(ctx.profile, &final_name, ctx.target_dir) +pub fn build_output_path(ctx: &BuildContext) -> String { + crate::core::build::builder::artifact::resolve_artifact_path( + ctx.kind, + ctx.profile, + ctx.project_name, + ctx.target_dir, + ctx.output_filename, + ctx.output_extension, + ) + .unwrap_or_else(|| { + crate::platform::bin_path( + ctx.profile, + ctx.output_filename.unwrap_or(ctx.project_name), + ctx.target_dir, + ) + }) } pub(crate) fn collect_header_files( diff --git a/src/core/build/engine.rs b/src/core/build/engine.rs index ca9f935..fab973b 100644 --- a/src/core/build/engine.rs +++ b/src/core/build/engine.rs @@ -38,8 +38,9 @@ use crate::core::workspace::parse_workspace; use crate::utils::build::{ default_target_triple, get_bool_with_profile, get_config_opt, get_config_str, get_language_with_profile, is_bare_metal_target, normalize_kind, normalize_platform, - normalize_target, normalize_target_os, parse_version_info, prepend_clang_target_flag, - primary_language, resolve_compiler, resolve_pkg_config_flags, resolve_tool, substitute_vars, + normalize_target_os, parse_version_info, prepend_clang_target_flag, primary_language, + resolve_artifact_target_dir, resolve_compiler, resolve_pkg_config_flags, resolve_tool, + substitute_vars, }; use crate::utils::fs::check_dir; use std::fs; @@ -544,70 +545,14 @@ fn build_project_at( let resolved_archiver = resolve_tool("DCR_AR", tc_ar.as_deref()); let out_dir_config = get_build_string_with_profile(&config, "out_dir", profile); - let target_dir_binding = if !out_dir_config.is_empty() { - let p = Path::new(&out_dir_config); - if p.is_absolute() { - out_dir_config - } else { - project_root.join(p).to_string_lossy().to_string() - } - } else { - match workspace_root { - Some(root) => { - let target_str = build_target - .map(normalize_target_os) - .filter(|t| !t.is_empty()); - let rel: PathBuf = match target_str { - Some(ref t) => Path::new("target").join(t).join(profile), - None => { - let default_dir = if cfg!(target_os = "linux") { - let arch = std::env::consts::ARCH; - format!("{arch}-unknown-linux-gnu/{profile}") - } else if cfg!(any( - target_os = "freebsd", - target_os = "openbsd", - target_os = "netbsd", - target_os = "dragonfly" - )) { - let arch = std::env::consts::ARCH; - let os = std::env::consts::OS; - format!("{arch}-unknown-{os}/{profile}") - } else { - profile.to_string() - }; - Path::new("target").join(&default_dir) - } - }; - root.join(&rel).to_string_lossy().to_string() - } - None => { - let base_dir = if has_explicit_target { - PathBuf::from( - normalize_target(&build_target_owned, profile) - .expect("explicit build target must be non-empty"), - ) - } else { - let default_dir = if cfg!(target_os = "linux") { - let arch = std::env::consts::ARCH; - format!("{arch}-unknown-linux-gnu/{profile}") - } else if cfg!(any( - target_os = "freebsd", - target_os = "openbsd", - target_os = "netbsd", - target_os = "dragonfly" - )) { - let arch = std::env::consts::ARCH; - let os = std::env::consts::OS; - format!("{arch}-unknown-{os}/{profile}") - } else { - profile.to_string() - }; - Path::new("target").join(&default_dir) - }; - project_root.join(base_dir).to_string_lossy().to_string() - } - } - }; + let target_dir_binding = resolve_artifact_target_dir( + project_root, + workspace_root, + profile, + &build_target_owned, + &out_dir_config, + has_explicit_target, + ); let target_dir = if target_dir_binding.is_empty() { None } else { diff --git a/src/core/vcs/git.rs b/src/core/vcs/git.rs index 83696f1..7a4c9f3 100644 --- a/src/core/vcs/git.rs +++ b/src/core/vcs/git.rs @@ -24,8 +24,11 @@ pub fn init(path: &Path) -> Result<(), String> { let gitignore_path = path.join(".gitignore"); if !gitignore_path.exists() { - fs::write(&gitignore_path, "/target\n") + fs::write(&gitignore_path, "/target\n.dcr/\n") .map_err(|e| format!("failed to create .gitignore: {}", e))?; + } else { + crate::utils::fs::ensure_gitignore_has_dcr(path) + .map_err(|e| format!("failed to update .gitignore: {}", e))?; } Ok(()) diff --git a/src/platform/bsd.rs b/src/platform/bsd.rs index 122b786..3570992 100644 --- a/src/platform/bsd.rs +++ b/src/platform/bsd.rs @@ -15,63 +15,45 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use super::path_util::{default_bin_rel, default_lib_rel, join_dir}; +use crate::utils::build::default_target_triple; + +fn host_triple() -> String { + default_target_triple() +} + pub fn bin_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { match target_dir { - Some(dir) => format!("{}/{}", dir.trim_end_matches('/'), name), - None => { - let arch = std::env::consts::ARCH; - let os = std::env::consts::OS; - let target = format!("{arch}-unknown-{os}"); - format!("./target/{target}/{profile}/{name}") - } + Some(dir) => join_dir(dir, name), + None => default_bin_rel(profile, name, Some(&host_triple())), } } pub fn lib_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { + let file = format!("lib{name}.a"); match target_dir { - Some(dir) => format!("{}/lib{}.a", dir.trim_end_matches('/'), name), - None => { - let arch = std::env::consts::ARCH; - let os = std::env::consts::OS; - let target = format!("{arch}-unknown-{os}"); - format!("./target/{target}/{profile}/lib{name}.a") - } + Some(dir) => join_dir(dir, &file), + None => default_lib_rel(profile, &file, Some(&host_triple())), } } pub fn elf_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { - match target_dir { - Some(dir) => format!("{}/{}", dir.trim_end_matches('/'), name), - None => { - let arch = std::env::consts::ARCH; - let os = std::env::consts::OS; - let target = format!("{arch}-unknown-{os}"); - format!("./target/{target}/{profile}/{name}") - } - } + bin_path(profile, name, target_dir) } pub fn efi_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { + let file = format!("{name}.efi"); match target_dir { - Some(dir) => format!("{}/{}.efi", dir.trim_end_matches('/'), name), - None => { - let arch = std::env::consts::ARCH; - let os = std::env::consts::OS; - let target = format!("{arch}-unknown-{os}"); - format!("./target/{target}/{profile}/{name}.efi") - } + Some(dir) => join_dir(dir, &file), + None => default_lib_rel(profile, &file, Some(&host_triple())), } } pub fn shared_lib_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { + let file = format!("lib{name}.so"); match target_dir { - Some(dir) => format!("{}/lib{}.so", dir.trim_end_matches('/'), name), - None => { - let arch = std::env::consts::ARCH; - let os = std::env::consts::OS; - let target = format!("{arch}-unknown-{os}"); - format!("./target/{target}/{profile}/lib{name}.so") - } + Some(dir) => join_dir(dir, &file), + None => default_lib_rel(profile, &file, Some(&host_triple())), } } @@ -81,25 +63,19 @@ mod tests { #[test] fn bin_path_default() { - let arch = std::env::consts::ARCH; - let os = std::env::consts::OS; - let expected = format!("./target/{arch}-unknown-{os}/debug/hello"); + let expected = default_bin_rel("debug", "hello", Some(&host_triple())); assert_eq!(bin_path("debug", "hello", None), expected); } #[test] fn lib_path_default() { - let arch = std::env::consts::ARCH; - let os = std::env::consts::OS; - let expected = format!("./target/{arch}-unknown-{os}/debug/libmylib.a"); + let expected = default_lib_rel("debug", "libmylib.a", Some(&host_triple())); assert_eq!(lib_path("debug", "mylib", None), expected); } #[test] fn shared_lib_path_default() { - let arch = std::env::consts::ARCH; - let os = std::env::consts::OS; - let expected = format!("./target/{arch}-unknown-{os}/debug/libmylib.so"); + let expected = default_lib_rel("debug", "libmylib.so", Some(&host_triple())); assert_eq!(shared_lib_path("debug", "mylib", None), expected); } } diff --git a/src/platform/linux.rs b/src/platform/linux.rs index e30eec0..91f8572 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -15,58 +15,45 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use super::path_util::{default_bin_rel, default_lib_rel, join_dir}; +use crate::utils::build::default_target_triple; + +fn host_triple() -> String { + default_target_triple() +} + pub fn bin_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { match target_dir { - Some(dir) => format!("{}/{}", dir.trim_end_matches('/'), name), - None => { - let arch = std::env::consts::ARCH; - let target = format!("{arch}-unknown-linux-gnu"); - format!("./target/{target}/{profile}/{name}") - } + Some(dir) => join_dir(dir, name), + None => default_bin_rel(profile, name, Some(&host_triple())), } } pub fn lib_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { + let file = format!("lib{name}.a"); match target_dir { - Some(dir) => format!("{}/lib{}.a", dir.trim_end_matches('/'), name), - None => { - let arch = std::env::consts::ARCH; - let target = format!("{arch}-unknown-linux-gnu"); - format!("./target/{target}/{profile}/lib{name}.a") - } + Some(dir) => join_dir(dir, &file), + None => default_lib_rel(profile, &file, Some(&host_triple())), } } pub fn elf_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { - match target_dir { - Some(dir) => format!("{}/{}", dir.trim_end_matches('/'), name), - None => { - let arch = std::env::consts::ARCH; - let target = format!("{arch}-unknown-linux-gnu"); - format!("./target/{target}/{profile}/{name}") - } - } + bin_path(profile, name, target_dir) } pub fn efi_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { + let file = format!("{name}.efi"); match target_dir { - Some(dir) => format!("{}/{}.efi", dir.trim_end_matches('/'), name), - None => { - let arch = std::env::consts::ARCH; - let target = format!("{arch}-unknown-linux-gnu"); - format!("./target/{target}/{profile}/{name}.efi") - } + Some(dir) => join_dir(dir, &file), + None => default_lib_rel(profile, &file, Some(&host_triple())), } } pub fn shared_lib_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { + let file = format!("lib{name}.so"); match target_dir { - Some(dir) => format!("{}/lib{}.so", dir.trim_end_matches('/'), name), - None => { - let arch = std::env::consts::ARCH; - let target = format!("{arch}-unknown-linux-gnu"); - format!("./target/{target}/{profile}/lib{name}.so") - } + Some(dir) => join_dir(dir, &file), + None => default_lib_rel(profile, &file, Some(&host_triple())), } } @@ -76,44 +63,49 @@ mod tests { #[test] fn bin_path_default() { - let arch = std::env::consts::ARCH; - let expected = format!("./target/{arch}-unknown-linux-gnu/debug/hello"); + let expected = default_bin_rel("debug", "hello", Some(&host_triple())); assert_eq!(bin_path("debug", "hello", None), expected); } #[test] fn bin_path_release() { - let arch = std::env::consts::ARCH; - let expected = format!("./target/{arch}-unknown-linux-gnu/release/hello"); + let expected = default_bin_rel("release", "hello", Some(&host_triple())); assert_eq!(bin_path("release", "hello", None), expected); } #[test] fn bin_path_custom_target() { - assert_eq!(bin_path("debug", "hello", Some("out")), "out/hello"); + assert_eq!( + bin_path("debug", "hello", Some("out")), + join_dir("out", "hello") + ); } #[test] fn bin_path_custom_target_trailing_slash() { - assert_eq!(bin_path("debug", "hello", Some("out/")), "out/hello"); + assert_eq!( + bin_path("debug", "hello", Some("out/")), + join_dir("out/", "hello") + ); } #[test] fn lib_path_default() { - let arch = std::env::consts::ARCH; - let expected = format!("./target/{arch}-unknown-linux-gnu/debug/libmylib.a"); + let expected = default_lib_rel("debug", "libmylib.a", Some(&host_triple())); assert_eq!(lib_path("debug", "mylib", None), expected); } #[test] fn lib_path_custom_target() { - assert_eq!(lib_path("debug", "mylib", Some("out")), "out/libmylib.a"); + assert_eq!( + lib_path("debug", "mylib", Some("out")), + join_dir("out", "libmylib.a") + ); } #[test] fn shared_lib_path_default() { - let arch = std::env::consts::ARCH; - let expected = format!("./target/{arch}-unknown-linux-gnu/debug/libmylib.so"); + let expected = default_lib_rel("debug", "libmylib.so", Some(&host_triple())); assert_eq!(shared_lib_path("debug", "mylib", None), expected); } @@ -121,7 +113,7 @@ mod tests { fn shared_lib_path_custom_target() { assert_eq!( shared_lib_path("release", "mylib", Some("dist")), - "dist/libmylib.so" + join_dir("dist", "libmylib.so") ); } } diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 9201619..39e1fa8 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -15,37 +15,39 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use super::path_util::{default_bin_rel, default_lib_rel, join_dir}; + pub fn bin_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { match target_dir { - Some(dir) => format!("{}/{}", dir.trim_end_matches('/'), name), - None => format!("./target/{profile}/{name}"), + Some(dir) => join_dir(dir, name), + None => default_bin_rel(profile, name, None), } } pub fn lib_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { + let file = format!("lib{name}.a"); match target_dir { - Some(dir) => format!("{}/lib{}.a", dir.trim_end_matches('/'), name), - None => format!("./target/{profile}/lib{name}.a"), + Some(dir) => join_dir(dir, &file), + None => default_lib_rel(profile, &file, None), } } pub fn elf_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { - match target_dir { - Some(dir) => format!("{}/{}", dir.trim_end_matches('/'), name), - None => format!("./target/{profile}/{name}"), - } + bin_path(profile, name, target_dir) } pub fn efi_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { + let file = format!("{name}.efi"); match target_dir { - Some(dir) => format!("{}/{}.efi", dir.trim_end_matches('/'), name), - None => format!("./target/{profile}/{name}.efi"), + Some(dir) => join_dir(dir, &file), + None => default_lib_rel(profile, &file, None), } } pub fn shared_lib_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { + let file = format!("lib{name}.dylib"); match target_dir { - Some(dir) => format!("{}/lib{}.dylib", dir.trim_end_matches('/'), name), - None => format!("./target/{profile}/lib{name}.dylib"), + Some(dir) => join_dir(dir, &file), + None => default_lib_rel(profile, &file, None), } } diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 146231c..25ff20a 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -26,6 +26,7 @@ pub mod bsd; pub mod linux; #[cfg(target_os = "macos")] pub mod macos; +mod path_util; #[cfg(not(any( target_os = "linux", target_os = "macos", diff --git a/src/platform/path_util.rs b/src/platform/path_util.rs new file mode 100644 index 0000000..eb6d815 --- /dev/null +++ b/src/platform/path_util.rs @@ -0,0 +1,80 @@ +// DCR — Cargo-like C/C++ project manager. +// +// Copyright (C) 2026 Dexoron (Bezotechestvo Vladimir) +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +use std::path::{Path, PathBuf}; + +pub fn join_dir(dir: &str, file: &str) -> String { + let trimmed = dir.trim_end_matches(['/', '\\']); + Path::new(trimmed).join(file).to_string_lossy().into_owned() +} + +#[cfg_attr(not(windows), allow(dead_code))] +pub fn with_exe_suffix(name: &str) -> String { + let lower = name.to_ascii_lowercase(); + if lower.ends_with(".exe") + || lower.ends_with(".dll") + || lower.ends_with(".sys") + || lower.ends_with(".com") + || lower.ends_with(".efi") + || Path::new(name).extension().is_some() + { + name.to_string() + } else { + format!("{name}.exe") + } +} + +pub fn default_bin_rel(profile: &str, name: &str, triple_segment: Option<&str>) -> String { + let mut p = PathBuf::from("./target"); + if let Some(t) = triple_segment { + p = p.join(t); + } + p.join(profile).join(name).to_string_lossy().into_owned() +} + +pub fn default_lib_rel(profile: &str, file: &str, triple_segment: Option<&str>) -> String { + let mut p = PathBuf::from("./target"); + if let Some(t) = triple_segment { + p = p.join(t); + } + p.join(profile).join(file).to_string_lossy().into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn join_dir_trims_both_slashes() { + assert_eq!( + join_dir("out/", "a"), + Path::new("out").join("a").to_string_lossy() + ); + assert_eq!( + join_dir(r"out\", "a"), + Path::new("out").join("a").to_string_lossy() + ); + } + + #[test] + fn with_exe_no_double() { + assert_eq!(with_exe_suffix("hello"), "hello.exe"); + assert_eq!(with_exe_suffix("hello.exe"), "hello.exe"); + assert_eq!(with_exe_suffix("KERNEL.ELF"), "KERNEL.ELF"); + assert_eq!(with_exe_suffix("x.dll"), "x.dll"); + } +} diff --git a/src/platform/posix.rs b/src/platform/posix.rs index 74c854e..d6f9778 100644 --- a/src/platform/posix.rs +++ b/src/platform/posix.rs @@ -1,14 +1,17 @@ +use super::path_util::{default_bin_rel, default_lib_rel, join_dir}; + pub fn bin_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { match target_dir { - Some(dir) => format!("{}/{}", dir.trim_end_matches('/'), name), - None => format!("./target/{profile}/{name}"), + Some(dir) => join_dir(dir, name), + None => default_bin_rel(profile, name, None), } } pub fn lib_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { + let file = format!("lib{name}.a"); match target_dir { - Some(dir) => format!("{}/lib{}.a", dir.trim_end_matches('/'), name), - None => format!("./target/{profile}/lib{name}.a"), + Some(dir) => join_dir(dir, &file), + None => default_lib_rel(profile, &file, None), } } @@ -17,15 +20,17 @@ pub fn elf_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { } pub fn efi_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { + let file = format!("{name}.efi"); match target_dir { - Some(dir) => format!("{}/{}.efi", dir.trim_end_matches('/'), name), - None => format!("./target/{profile}/{name}.efi"), + Some(dir) => join_dir(dir, &file), + None => default_lib_rel(profile, &file, None), } } pub fn shared_lib_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { + let file = format!("lib{name}.so"); match target_dir { - Some(dir) => format!("{}/lib{}.so", dir.trim_end_matches('/'), name), - None => format!("./target/{profile}/lib{name}.so"), + Some(dir) => join_dir(dir, &file), + None => default_lib_rel(profile, &file, None), } } diff --git a/src/platform/windows.rs b/src/platform/windows.rs index a79c8cb..532d937 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -15,37 +15,52 @@ // You should have received a copy of the GNU General Public License // along with this program. If not, see . +use super::path_util::{default_bin_rel, default_lib_rel, join_dir, with_exe_suffix}; + pub fn bin_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { + let file = with_exe_suffix(name); match target_dir { - Some(dir) => format!("{}/{}.exe", dir.trim_end_matches('/'), name), - None => format!("./target/{profile}/{name}.exe"), + Some(dir) => join_dir(dir, &file), + None => default_bin_rel(profile, &file, None), } } pub fn lib_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { + let file = if name.to_ascii_lowercase().ends_with(".lib") { + name.to_string() + } else { + format!("{name}.lib") + }; match target_dir { - Some(dir) => format!("{}/{}.lib", dir.trim_end_matches('/'), name), - None => format!("./target/{profile}/{name}.lib"), + Some(dir) => join_dir(dir, &file), + None => default_lib_rel(profile, &file, None), } } pub fn elf_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { - match target_dir { - Some(dir) => format!("{}/{}.exe", dir.trim_end_matches('/'), name), - None => format!("./target/{profile}/{name}.exe"), - } + bin_path(profile, name, target_dir) } pub fn efi_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { + let file = if name.to_ascii_lowercase().ends_with(".efi") { + name.to_string() + } else { + format!("{name}.efi") + }; match target_dir { - Some(dir) => format!("{}/{}.efi", dir.trim_end_matches('/'), name), - None => format!("./target/{profile}/{name}.efi"), + Some(dir) => join_dir(dir, &file), + None => default_lib_rel(profile, &file, None), } } pub fn shared_lib_path(profile: &str, name: &str, target_dir: Option<&str>) -> String { + let file = if name.to_ascii_lowercase().ends_with(".dll") { + name.to_string() + } else { + format!("{name}.dll") + }; match target_dir { - Some(dir) => format!("{}/{}.dll", dir.trim_end_matches('/'), name), - None => format!("./target/{profile}/{name}.dll"), + Some(dir) => join_dir(dir, &file), + None => default_lib_rel(profile, &file, None), } } diff --git a/src/utils/build.rs b/src/utils/build.rs index 155d8b8..86b2c1e 100644 --- a/src/utils/build.rs +++ b/src/utils/build.rs @@ -17,6 +17,7 @@ use crate::core::build_config::Config; use crate::utils::log::warn; +use std::path::{Path, PathBuf}; pub struct VersionInfo { pub full: String, @@ -81,6 +82,74 @@ pub fn normalize_target(target: &str, profile: &str) -> Option { } } +pub fn native_host_target_rel(profile: &str) -> PathBuf { + if cfg!(any( + target_os = "linux", + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd", + target_os = "dragonfly" + )) { + Path::new("target") + .join(default_target_triple()) + .join(profile) + } else { + Path::new("target").join(profile) + } +} + +pub fn resolve_artifact_target_dir( + project_root: &Path, + workspace_root: Option<&Path>, + profile: &str, + build_target: &str, + out_dir: &str, + has_explicit_target: bool, +) -> String { + if !out_dir.trim().is_empty() { + let p = Path::new(out_dir.trim()); + return if p.is_absolute() { + out_dir.trim().to_string() + } else { + project_root.join(p).to_string_lossy().into_owned() + }; + } + + if let Some(ws) = workspace_root { + let triple = if build_target.trim().is_empty() { + default_target_triple() + } else { + let n = normalize_target_os(build_target.trim()); + if n.is_empty() { + default_target_triple() + } else { + n.to_string() + } + }; + return ws + .join("target") + .join(triple) + .join(profile) + .to_string_lossy() + .into_owned(); + } + + let base = if has_explicit_target || !build_target.trim().is_empty() { + let triple = { + let n = normalize_target_os(build_target.trim()); + if n.is_empty() { + default_target_triple() + } else { + n.to_string() + } + }; + PathBuf::from(format!("target/{triple}/{profile}")) + } else { + native_host_target_rel(profile) + }; + project_root.join(base).to_string_lossy().into_owned() +} + pub fn normalize_kind(kind: &str) -> &str { let trimmed = kind.trim(); if trimmed.is_empty() { "bin" } else { trimmed } @@ -531,16 +600,26 @@ mod tests { fn default_target_triple_uses_host_arch() { let target = default_target_triple(); if cfg!(target_os = "linux") { + let env = if cfg!(target_env = "musl") { + "musl" + } else { + "gnu" + }; assert_eq!( target, - format!("{}-unknown-linux-gnu", std::env::consts::ARCH) + format!("{}-unknown-linux-{env}", std::env::consts::ARCH) ); } else if cfg!(target_os = "macos") { assert_eq!(target, format!("{}-apple-darwin", std::env::consts::ARCH)); } else if cfg!(target_os = "windows") { + let env = if cfg!(target_env = "gnu") { + "gnu" + } else { + "msvc" + }; assert_eq!( target, - format!("{}-pc-windows-msvc", std::env::consts::ARCH) + format!("{}-pc-windows-{env}", std::env::consts::ARCH) ); } else if cfg!(any( target_os = "freebsd", @@ -561,6 +640,35 @@ mod tests { } } + #[test] + fn resolve_artifact_target_dir_native_and_workspace() { + let root = Path::new("/proj"); + let ws = Path::new("/ws"); + let native = resolve_artifact_target_dir(root, None, "debug", "", "", false); + if cfg!(target_os = "linux") + || cfg!(any( + target_os = "freebsd", + target_os = "openbsd", + target_os = "netbsd", + target_os = "dragonfly" + )) + { + assert!(native.contains(&default_target_triple())); + assert!(native.ends_with("debug") || native.contains("/debug")); + } else { + assert!( + native.ends_with("debug") + || native.ends_with("debug\\") + || native.contains("target") + ); + } + let ws_dir = resolve_artifact_target_dir(root, Some(ws), "debug", "", "", false); + assert!(ws_dir.starts_with("/ws") || ws_dir.contains("ws")); + assert!(ws_dir.contains(&default_target_triple()) || cfg!(windows)); + let out = resolve_artifact_target_dir(root, None, "debug", "", "dist", false); + assert!(out.contains("dist")); + } + #[test] fn prepend_clang_target_flag_adds_once_for_clang() { let mut flags = vec!["-O2".to_string()]; diff --git a/src/utils/fs.rs b/src/utils/fs.rs index c1f142d..292ab46 100644 --- a/src/utils/fs.rs +++ b/src/utils/fs.rs @@ -16,6 +16,7 @@ // along with this program. If not, see . use std::io; +use std::io::Write; use std::path::{Path, PathBuf}; pub fn home_dir() -> Option { @@ -75,3 +76,154 @@ where let _ = std::env::set_current_dir(prev); result } + +pub fn strip_verbatim_prefix(path: PathBuf) -> PathBuf { + let s = path.to_string_lossy(); + let mut cleaned = s.replace('\\', "/"); + if let Some(rest) = cleaned.strip_prefix("//?/") { + cleaned = rest.to_string(); + } else if let Some(rest) = cleaned.strip_prefix("//./") { + cleaned = rest.to_string(); + } else if let Some(rest) = cleaned.strip_prefix(r"\\?\") { + cleaned = rest.replace('\\', "/"); + } else if let Some(rest) = cleaned.strip_prefix(r"\\.\") { + cleaned = rest.replace('\\', "/"); + } + if cleaned.len() >= 3 && cleaned.as_bytes()[0] == b'/' && cleaned.as_bytes()[2] == b':' { + cleaned = cleaned[1..].to_string(); + } + if cfg!(windows) { + PathBuf::from(cleaned.replace('/', "\\")) + } else { + PathBuf::from(cleaned) + } +} + +pub fn canonicalize_path(path: &Path) -> PathBuf { + let raw = std::fs::canonicalize(path).unwrap_or_else(|_| { + if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map(|cwd| cwd.join(path)) + .unwrap_or_else(|_| path.to_path_buf()) + } + }); + strip_verbatim_prefix(raw) +} + +pub fn absolute_join(root: &Path, path: &Path) -> PathBuf { + let joined = if path.is_absolute() { + path.to_path_buf() + } else { + root.join(path) + }; + canonicalize_path(&joined) +} + +pub fn ensure_dcr_dir(root: &Path) -> io::Result { + let dcr = root.join(".dcr"); + std::fs::create_dir_all(dcr.join("ide"))?; + Ok(dcr) +} + +pub fn atomic_write(path: &Path, contents: impl AsRef<[u8]>) -> io::Result<()> { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let pid = std::process::id(); + let file_name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "tmp".to_string()); + let tmp = path.with_file_name(format!("{file_name}.tmp.{pid}")); + { + let mut f = std::fs::File::create(&tmp)?; + f.write_all(contents.as_ref())?; + let _ = f.sync_all(); + } + match std::fs::rename(&tmp, path) { + Ok(()) => Ok(()), + Err(e) => { + if path.exists() { + let _ = std::fs::remove_file(path); + match std::fs::rename(&tmp, path) { + Ok(()) => Ok(()), + Err(e2) => { + let _ = std::fs::remove_file(&tmp); + Err(e2) + } + } + } else { + let _ = std::fs::remove_file(&tmp); + Err(e) + } + } + } +} + +pub fn ensure_gitignore_has_dcr(project_dir: &Path) -> io::Result<()> { + let path = project_dir.join(".gitignore"); + if path.exists() { + let content = std::fs::read_to_string(&path)?; + let has = content.lines().any(|l| { + let t = l.trim(); + t == ".dcr/" || t == ".dcr" || t == "/.dcr/" || t == "/.dcr" + }); + if !has { + let mut new_content = content; + if !new_content.ends_with('\n') && !new_content.is_empty() { + new_content.push('\n'); + } + new_content.push_str(".dcr/\n"); + std::fs::write(&path, new_content)?; + } + } else { + std::fs::write(&path, "/target\n.dcr/\n")?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn canonicalize_path_absolute() { + let cwd = std::env::current_dir().unwrap(); + let got = canonicalize_path(&cwd); + assert!(got.is_absolute()); + } + + #[test] + fn canonicalize_path_relative_fallback() { + let p = Path::new("does-not-exist-xyz-12345"); + let got = canonicalize_path(p); + assert!(got.is_absolute()); + } + + #[test] + fn atomic_write_roundtrip() { + let dir = std::env::temp_dir().join(format!("dcr_atomic_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let path = dir.join("out.json"); + atomic_write(&path, b"hello").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "hello"); + atomic_write(&path, b"world").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "world"); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn ensure_dcr_dir_creates_ide() { + let dir = std::env::temp_dir().join(format!("dcr_dir_{}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let dcr = ensure_dcr_dir(&dir).unwrap(); + assert!(dcr.is_dir()); + assert!(dcr.join("ide").is_dir()); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/tests/cli_gen.rs b/tests/cli_gen.rs new file mode 100644 index 0000000..1cdfd2c --- /dev/null +++ b/tests/cli_gen.rs @@ -0,0 +1,144 @@ +mod common; +use common::*; +use std::fs; +use std::path::Path; + +#[test] +fn gen_project_info_has_artifact_path() { + let dir = unique_sandbox_dir("gen_pi"); + let out = run_dcr(&["new", "app", "--vcs", "none"], &dir); + assert!(out.status.success()); + let project = dir.join("app"); + + let out = run_dcr(&["gen", "project-info", "-q"], &project); + assert!( + out.status.success(), + "project-info failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + assert!( + stdout.contains("\"artifact_path\""), + "missing artifact_path: {stdout}" + ); + assert!(stdout.contains("\"target_dir\""), "missing target_dir"); + assert!( + stdout.contains("\"artifact_kind\""), + "missing artifact_kind" + ); + assert!(stdout.contains("\"source_roots\""), "missing source_roots"); + + let root_canon = fs::canonicalize(&project).unwrap(); + assert!( + stdout.contains(&root_canon.to_string_lossy().to_string()) || stdout.contains("app"), + "root should be absolute-ish: {stdout}" + ); + + assert!(project.join(".dcr").join("build-info.json").is_file()); + assert!(project.join(".dcr").join("toolchain.json").is_file()); + assert!(project.join(".clangd").is_file()); + let clangd = fs::read_to_string(project.join(".clangd")).unwrap(); + assert!(clangd.contains("CompilationDatabase: .dcr")); +} + +#[test] +fn gen_compile_commands_in_dcr_dir() { + let dir = unique_sandbox_dir("gen_cc"); + let out = run_dcr(&["new", "app", "--vcs", "none"], &dir); + assert!(out.status.success()); + let project = dir.join("app"); + + let out = run_dcr(&["gen", "compile-commands", "-q"], &project); + assert!( + out.status.success(), + "compile-commands failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!( + !project.join("compile_commands.json").exists(), + "compile_commands.json must not be in project root" + ); + let cc = project.join(".dcr").join("compile_commands.json"); + assert!(cc.is_file(), "expected .dcr/compile_commands.json"); + let content = fs::read_to_string(&cc).unwrap(); + assert!(content.contains("\"directory\""), "missing directory field"); + assert!(content.contains("\"file\""), "missing file field"); + assert!(content.starts_with('[')); + + assert!(project.join(".clangd").is_file()); + assert!(project.join(".dcr").join("ide").is_dir()); +} + +#[test] +fn gen_clangd_not_overwritten_if_custom() { + let dir = unique_sandbox_dir("gen_clangd"); + let out = run_dcr(&["new", "app", "--vcs", "none"], &dir); + assert!(out.status.success()); + let project = dir.join("app"); + + let custom = "CompileFlags:\n Add: [-Wall]\n"; + fs::write(project.join(".clangd"), custom).unwrap(); + + let out = run_dcr(&["gen", "compile-commands", "-q"], &project); + assert!(out.status.success()); + let after = fs::read_to_string(project.join(".clangd")).unwrap(); + assert_eq!(after, custom, "custom .clangd must not be overwritten"); +} + +#[test] +fn gen_vscode_launch_uses_resolver() { + let dir = unique_sandbox_dir("gen_vscode"); + let out = run_dcr(&["new", "app", "--vcs", "none"], &dir); + assert!(out.status.success()); + let project = dir.join("app"); + + let out = run_dcr(&["gen", "vscode"], &project); + assert!( + out.status.success(), + "gen vscode failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let launch = fs::read_to_string(project.join(".vscode").join("launch.json")).unwrap(); + assert!(launch.contains("\"program\""), "launch missing program"); + assert!( + !launch.contains("target/debug/app\"") || launch.contains("linux"), + "program should use platform path layout when applicable: {launch}" + ); + let settings = fs::read_to_string(project.join(".vscode").join("settings.json")).unwrap(); + assert!( + settings.contains(".dcr") || settings.contains("compile-commands-dir"), + "settings should point at .dcr: {settings}" + ); +} + +#[test] +fn build_print_artifact_path() { + if available_compiler().is_none() { + return; + } + let dir = unique_sandbox_dir("build_print"); + let out = run_dcr(&["new", "app", "--vcs", "none"], &dir); + assert!(out.status.success()); + let project = dir.join("app"); + + let out = run_dcr(&["build", "--print-artifact-path"], &project); + assert!( + out.status.success(), + "build failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let stdout = String::from_utf8_lossy(&out.stdout); + let path_line = stdout + .lines() + .map(str::trim) + .rfind(|l| !l.is_empty()) + .unwrap_or(""); + assert!( + Path::new(path_line).is_absolute() || path_line.contains("app"), + "expected artifact path on stdout, got: {stdout}" + ); + assert!( + project.join(".dcr").join("build-info.json").is_file(), + "build-info.json should be written after build" + ); +} diff --git a/tests/cli_new.rs b/tests/cli_new.rs index b628106..ddf07ea 100644 --- a/tests/cli_new.rs +++ b/tests/cli_new.rs @@ -37,7 +37,9 @@ fn new_vcs_options_work() { let project_dir = dir.join("hello_none"); assert!(project_dir.is_dir()); assert!(!project_dir.join(".git").exists()); - assert!(!project_dir.join(".gitignore").exists()); + assert!(project_dir.join(".gitignore").is_file()); + let gi = std::fs::read_to_string(project_dir.join(".gitignore")).unwrap(); + assert!(gi.contains(".dcr/")); // 2. Test --vcs git if is_git_in_path() { @@ -50,6 +52,7 @@ fn new_vcs_options_work() { assert!(project_dir.join(".gitignore").is_file()); let gitignore = std::fs::read_to_string(project_dir.join(".gitignore")).unwrap(); assert!(gitignore.contains("/target")); + assert!(gitignore.contains(".dcr/")); } } @@ -60,7 +63,9 @@ fn init_vcs_options_work() { let out = run_dcr(&["init", "--vcs", "none"], &dir); assert!(out.status.success(), "dcr init --vcs none should succeed"); assert!(!dir.join(".git").exists()); - assert!(!dir.join(".gitignore").exists()); + assert!(dir.join(".gitignore").is_file()); + let gi = std::fs::read_to_string(dir.join(".gitignore")).unwrap(); + assert!(gi.contains(".dcr/")); // 2. Test --vcs git if is_git_in_path() { @@ -71,5 +76,6 @@ fn init_vcs_options_work() { assert!(dir.join(".gitignore").is_file()); let gitignore = std::fs::read_to_string(dir.join(".gitignore")).unwrap(); assert!(gitignore.contains("/target")); + assert!(gitignore.contains(".dcr/")); } }