diff --git a/.gitignore b/.gitignore index 8c9d461..40eb106 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ sandbox/** personal/ .* !.gitignore -!.github/ \ No newline at end of file +!.github/ +graphify-out/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 703e324..3fd6fd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [Unreleased] +## [v0.9.0] - 2026-08-31 "Полные Path/Git-зависимости и готовые библиотеки / Complete Path/Git Dependencies & Prebuilt Libraries" ### RU @@ -8,6 +8,10 @@ - **Зеркала загрузок в `dcrup`** — `--mirror auto|cf|gh|sf|ya` и `DCRUP_MIRROR`. По умолчанию `auto`: **cf → gh → sf → ya** (CloudFlare CDN, GitHub, SourceForge, Yandex CDN). +- **Полные Path/Git-зависимости** — DCR-пакеты из `path` и `git` собираются перед потребителем; Git-код кешируется в `~/.dcr/git`, поддерживает `branch`/`tag`/`rev` и фиксирует точный commit в `dcr.lock`. +- **Транзитивные зависимости и защита от циклов** — DCR рекурсивно собирает DCR-пакеты и показывает цепочку циклической зависимости. +- **Внешние готовые библиотеки через path/Git** — `include`, `lib` и `libs` подключают библиотеку без `dcr.toml`; Git prebuilt-источники тоже фиксируются по точному commit. +- **Явный режим зависимости** — `mode = "build"` и `mode = "prebuild"` позволяют выбрать сборку DCR-пакета или подключение готовых артефактов, даже когда репозиторий содержит оба варианта. **Удалено:** @@ -19,6 +23,10 @@ - **Download mirrors in `dcrup`** — `--mirror auto|cf|gh|sf|ya` and `DCRUP_MIRROR`. Default `auto` tries **cf → gh → sf → ya** (CloudFlare CDN, GitHub, SourceForge, Yandex CDN). +- **Complete Path/Git dependencies** — DCR packages from `path` and `git` are built before their consumer; Git sources are cached in `~/.dcr/git`, support `branch`/`tag`/`rev`, and lock the exact commit in `dcr.lock`. +- **Transitive dependencies and cycle protection** — DCR recursively builds DCR packages and reports the dependency cycle chain. +- **Prebuilt external libraries through path/Git** — `include`, `lib`, and `libs` link libraries without `dcr.toml`; Git prebuilt sources are locked to their exact commit as well. +- **Explicit dependency mode** — `mode = "build"` and `mode = "prebuild"` select DCR package builds or ready-made artifacts when a repository contains both forms. **Removed:** diff --git a/Cargo.lock b/Cargo.lock index 6059987..19e43e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -154,7 +154,7 @@ dependencies = [ [[package]] name = "dcr" -version = "0.8.5" +version = "0.9.0" dependencies = [ "ctrlc", "fatfs", diff --git a/Cargo.toml b/Cargo.toml index dd6bb1c..44a2809 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "dcr" -version = "0.8.5" +version = "0.9.0" edition = "2024" build = "build.rs" description = "DCR is a utility for managing C/C++ projects in a Cargo-like style." @@ -88,4 +88,13 @@ opt-level = 1 lto = false codegen-units = 16 panic = "abort" -strip = true \ No newline at end of file +strip = true + +[profile.dev] +incremental = false +opt-level = 0 +debug = "line-tables-only" + +[profile.dev.package."*"] +opt-level = 1 +debug = false \ No newline at end of file diff --git a/README.md b/README.md index 806a8e7..2d03c09 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,10 @@ ```sh yay -S dcr +# or +paru -S dcr ``` +> 💡 Do you like DCR? Vote for the package on the [AUR](https://aur.archlinux.org/packages/dcr) page—it will help the project get into the official "extra" repository! diff --git a/docs/changelog.mdx b/docs/changelog.mdx index f306e58..48aa30b 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -4,10 +4,15 @@ sidebar_label: Changelog # Changelog -## Unreleased +## 0.9.0 (2026-08-31) - **Download mirrors in `dcrup`** — `--mirror auto|cf|gh|sf|ya` and `DCRUP_MIRROR`. Default `auto` tries **cf → gh → sf → ya** (CloudFlare CDN, GitHub, SourceForge, Yandex CDN). +- **Complete Path/Git dependencies** — DCR packages from `path` and `git` are built before their consumer. Git sources are cached in `~/.dcr/git`, support `branch`/`tag`/`rev`, and record the exact commit in `dcr.lock`. +- **Build and prebuild modes** — `path` and `git` dependencies can build a DCR package from `dcr.toml` or link existing `include`/`lib`/`libs` artifacts; `mode = "build"` / `"prebuild"` selects explicitly when both forms are present. +- **Prebuilt dependency examples** — documentation now shows the `dcr-math-lib` Git repository and recommended build/prebuilt layouts for both `git` and `path`. +- **Transitive dependency builds and cycle reporting** — DCR recursively builds DCR packages and reports a clear dependency chain for cycles. +- **Bare path arguments for `dcr add`** — `dcr add mylib ../mylib` now works in addition to the explicit `path:` form. - **Removed `install.sh` / `install.ps1` / `install_bsd.sh`** — superseded by `dcrup`. ## 0.8.5 (2026-08-08) diff --git a/docs/commands/dependency-commands.mdx b/docs/commands/dependency-commands.mdx index 8e48d9a..64150fb 100644 --- a/docs/commands/dependency-commands.mdx +++ b/docs/commands/dependency-commands.mdx @@ -29,7 +29,7 @@ Flags: - `--tag ` — git tag - `--rev ` — git commit -If source is omitted — DCR searches connected registries. +If source is omitted — DCR searches connected registries. A bare relative or absolute path is accepted as a path dependency. ## `dcr tree` diff --git a/docs/reference/dependencies.mdx b/docs/reference/dependencies.mdx index aeb8eca..f2a696d 100644 --- a/docs/reference/dependencies.mdx +++ b/docs/reference/dependencies.mdx @@ -23,10 +23,10 @@ The version string is used as-is for registry lookup. ```toml [dependencies] -fmt = { git = "https://github.com/fmtlib/fmt", tag = "10.1.1" } +dcr-math = { git = "https://github.com/dexoron/dcr-math-lib.git", tag = "v0.1.0" } ``` -Fields: `git`, `branch`, `tag`, `rev`. +Fields: `git`, `branch`, `tag`, `rev`, `mode`, `include`, `lib`, `libs`. ### Table (path) @@ -35,6 +35,15 @@ Fields: `git`, `branch`, `tag`, `rev`. mylib = { path = "../mylib" } ``` +For a prebuilt external library, specify its layout explicitly. The three +arrays are paths relative to the dependency root (`include`, `lib`) and linker +names (`libs`): + +```toml +[dependencies] +dcr-math = { path = "../deps/dcr-math", include = ["include"], lib = ["lib"], libs = ["dcr-math"] } +``` + ## Registry DCR uses a package registry for dependency lookup by name. @@ -42,7 +51,7 @@ DCR uses a package registry for dependency lookup by name. ```toml # ~/.dcr/config.toml [registry.default] -url = "https://index.dcr.pm" +url = "https://index.dcr.dexoron.tech" priority = 1 ``` @@ -50,7 +59,23 @@ Registry priority: order in `config.toml`. The `DCR_INDEX_PATH` variable overrid ## Git dependencies -Git dependencies are parsed and recorded in `dcr.lock`. DCR supports specifying branch/tag/rev for git sources: +Git dependencies are cloned into `~/.dcr/git/`; their exact checked-out commit is written to `dcr.lock`. + +Both Git and path dependencies have two modes: + +- **DCR package:** the source has `dcr.toml`. DCR builds it before the consumer and uses the package name from its manifest for linking. +- **Prebuilt library:** it provides existing headers and binaries through `include`, `lib`, and `libs`. DCR passes them to the compiler/linker without building the dependency. + +The mode is normally detected automatically. Use `mode = "build"` or `mode = "prebuild"` when a source contains both a manifest and prebuilt artifacts and you need to choose explicitly. + +`dcr-math-lib` publishes both forms. Select its prebuilt library explicitly: + +```toml +[dependencies] +dcr-math = { git = "https://github.com/dexoron/dcr-math-lib.git", tag = "v0.1.0", mode = "prebuild", include = ["include"], lib = ["lib"], libs = ["dcr-math"] } +``` + +DCR supports specifying branch/tag/rev for git sources: - `branch` — switch to a branch - `tag` — switch to a tag @@ -59,7 +84,7 @@ Git dependencies are parsed and recorded in `dcr.lock`. DCR supports specifying ## Path dependencies -Local paths. DCR automatically discovers include and lib directories from the neighbor's `dcr.toml`. +Local paths may point at a DCR package or a prebuilt external library. A path DCR package is built before its consumer, including its own path/git dependencies. DCR detects dependency cycles. A prebuilt library is never built by DCR; it is linked from the directories declared in its dependency entry. ```toml [dependencies] @@ -67,18 +92,51 @@ mylib = { path = "/abs/path/to/lib" } mylib = { path = "../relative/path" } ``` -*Note: Header resolution automatically checks the packaged `target/include` directory of path dependencies, ensuring consumer packages can find headers from compiled static or dynamic libraries.* +For DCR packages, the package name from `dcr.toml` is used for linking (so the dependency key may differ from the library name). Header resolution includes the packaged `target/include` directory and link search includes `target/lib`. + +### Recommended library layouts + +Buildable DCR library: + +```text +dcr-math-lib/ +├── dcr.toml +└── src/ + ├── dcr_math.c + └── dcr_math.h +``` + +Prebuilt library: + +```text +dcr-math-lib/ +├── include/ +│ └── dcr_math.h +└── lib/ + └── libdcr-math.a # or .so / .lib +``` + +`libs` contains linker names without the `lib` prefix and file extension: use +`["dcr-math"]` for `libdcr-math.a`. + +If one repository contains both forms, keep the prebuilt files in `include/` and `lib/` and select the mode explicitly: + +```toml +[dependencies] +dcr-math = { git = "https://github.com/dexoron/dcr-math-lib.git", tag = "v0.1.0", mode = "prebuild", include = ["include"], lib = ["lib"], libs = ["dcr-math"] } +``` + +For a local copy, replace `git` with `path = "../dcr-math-lib"`. In `prebuild` mode DCR does not run the dependency build, even when `dcr.toml` is present. ## dcr.lock Dependency lock file. Contains package names and sources. -Created during `dcr build` when registry dependencies are present. Not updated during `dcr add` — only on the next `dcr build`. +Created during `dcr build` for resolved registry, path, and Git dependencies. Not updated during `dcr add` — only on the next `dcr build`. ## Resolution process -1. Load all dependencies (registry → git → path) -2. For path deps: recursively read their `dcr.toml` -3. For registry deps: search `index.json` -4. For git deps: clone to cache -5. Collect `include_dirs`, `lib_dirs`, `libs` for the compiler +1. Resolve dependency sources (registry → git → path) +2. Fetch git dependencies into `~/.dcr/git/` and check out the requested ref +3. Build DCR package dependencies recursively +4. Collect `include_dirs`, `lib_dirs`, `libs` for the compiler diff --git a/src/cli/add.rs b/src/cli/add.rs index 474db51..72e2b0a 100644 --- a/src/cli/add.rs +++ b/src/cli/add.rs @@ -288,8 +288,15 @@ fn parse_add_args(args: &[String]) -> Result { || source_spec.starts_with("git@") { git = Some(source_spec); + } else if source_spec.starts_with("./") + || source_spec.starts_with("../") + || std::path::Path::new(&source_spec).is_absolute() + { + path = Some(source_spec); } else { - error("Source must have a prefix (path:, git:, github:, gitlab:) or be a full URL"); + error( + "Source must be a path, a prefixed source (path:, git:, github:, gitlab:), or a full URL", + ); return Err(1); } diff --git a/src/core/build/engine.rs b/src/core/build/engine.rs index 8389362..8facd20 100644 --- a/src/core/build/engine.rs +++ b/src/core/build/engine.rs @@ -35,7 +35,7 @@ use crate::core::build::steps::{ get_build_steps_with_profile, run_build_steps, verify_expectations, }; use crate::core::build_config::Config; -use crate::core::deps::{register, resolve_deps}; +use crate::core::deps::resolve_deps; use crate::core::workspace::parse_workspace; use crate::utils::build::{ default_target_triple, get_bool_with_profile, get_config_opt, get_config_str, @@ -335,6 +335,81 @@ fn build_project_at( workspace_root: Option<&Path>, cancel: &Arc, rep: &mut dyn BuildReporter, +) -> Result<(), String> { + let mut dependency_stack = Vec::new(); + build_project_at_with_stack( + project_root, + profile, + target, + explicit_target, + exclude_dirs, + force, + verbose, + workspace_root, + cancel, + rep, + &mut dependency_stack, + ) +} + +/// Builds a package while tracking the dependency chain to reject cycles. +#[allow(clippy::too_many_arguments)] +fn build_project_at_with_stack( + project_root: &Path, + profile: &str, + target: Option<&str>, + explicit_target: bool, + exclude_dirs: &[std::path::PathBuf], + force: bool, + verbose: bool, + workspace_root: Option<&Path>, + cancel: &Arc, + rep: &mut dyn BuildReporter, + dependency_stack: &mut Vec, +) -> Result<(), String> { + let root = project_root + .canonicalize() + .unwrap_or_else(|_| project_root.to_path_buf()); + if let Some(index) = dependency_stack.iter().position(|item| item == &root) { + let mut chain: Vec = dependency_stack[index..] + .iter() + .map(|item| item.display().to_string()) + .collect(); + chain.push(root.display().to_string()); + return Err(format!("Dependency cycle detected: {}", chain.join(" -> "))); + } + dependency_stack.push(root); + let result = build_project_at_inner( + project_root, + profile, + target, + explicit_target, + exclude_dirs, + force, + verbose, + workspace_root, + cancel, + rep, + dependency_stack, + ); + dependency_stack.pop(); + result +} + +/// Builds one DCR package after the dependency-cycle guard has been installed. +#[allow(clippy::too_many_arguments)] +fn build_project_at_inner( + project_root: &Path, + profile: &str, + target: Option<&str>, + explicit_target: bool, + exclude_dirs: &[std::path::PathBuf], + force: bool, + verbose: bool, + workspace_root: Option<&Path>, + cancel: &Arc, + rep: &mut dyn BuildReporter, + dependency_stack: &mut Vec, ) -> Result<(), String> { if cancel.load(Ordering::SeqCst) { return Err("Build interrupted".to_string()); @@ -641,62 +716,57 @@ fn build_project_at( } } - let deps_table = config.get("dependencies").and_then(|v| v.as_table()); let mut resolved = resolve_deps(&config, profile, build_target, project_root)?; resolved.include_dirs.extend(ws_include_dirs); resolved.lib_dirs.extend(ws_lib_dirs); resolved.libs.extend(ws_libs); - // Registry dependencies are cached under the DCR registry root. Build - // them as normal DCR projects before the current project is linked. - if let Some(deps) = deps_table { - for (name, value) in deps { - if register::is_registry_dep(value) { - let pkg_info = register::resolve_package_from_registry(name)?; - let version = pkg_info - .get("latest_version") - .or_else(|| pkg_info.get("version")) - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let dep_root = register::package_root_from_registry_info(&pkg_info)?; - let include_dir = dep_root.join("target").join("include"); - let lib_dir = dep_root.join("target").join("lib"); - - if !include_dir.exists() || !lib_dir.exists() { - rep.on_event(BuildEvent::DepBuilding { name, version }); - if !dep_root.join("dcr.toml").is_file() { - return Err(format!( - "Registry dependency `{}` is missing dcr.toml at {}", - name, - dep_root.display() - )); - } - build_project_at( - &dep_root, - profile, - build_target, - has_explicit_target, - &[], - force, - verbose, - None, - cancel, - &mut *rep, - )?; - rep.on_event(BuildEvent::DepReady { - name, - version, - rebuilt: true, - }); - } else { - rep.on_event(BuildEvent::DepReady { - name, - version, - rebuilt: false, - }); - } - } + let mut built_roots = std::collections::HashSet::new(); + let mut transitive_roots = std::collections::HashSet::new(); + let direct_package_roots = resolved.package_roots.clone(); + for dep_root in &direct_package_roots { + if !built_roots.insert(dep_root.clone()) { + continue; } + let dep_root = Path::new(dep_root); + let dep_config = + Config::open(&dep_root.join("dcr.toml").to_string_lossy()).map_err(|_| { + format!( + "DCR dependency is missing dcr.toml at {}", + dep_root.display() + ) + })?; + let name = get_config_str(&dep_config, "package.name"); + let version = get_config_str(&dep_config, "package.version"); + rep.on_event(BuildEvent::DepBuilding { + name: &name, + version: &version, + }); + build_project_at_with_stack( + dep_root, + profile, + build_target, + has_explicit_target, + &[], + force, + verbose, + None, + cancel, + &mut *rep, + dependency_stack, + )?; + rep.on_event(BuildEvent::DepReady { + name: &name, + version: &version, + rebuilt: true, + }); + extend_transitive_link_deps( + dep_root, + profile, + build_target, + &mut resolved, + &mut transitive_roots, + )?; } let (resolved_cflags, resolved_ldflags) = @@ -919,6 +989,40 @@ fn build_project_at( Ok(()) } +/// Adds link flags needed by dependencies of a static DCR library. +/// Static archives do not contain their own dependency objects, so the final +/// consumer must link every transitive package in dependency order. +fn extend_transitive_link_deps( + package_root: &Path, + profile: &str, + target: Option<&str>, + consumer_deps: &mut crate::core::deps::common::ResolvedDeps, + visited: &mut std::collections::HashSet, +) -> Result<(), String> { + let root = package_root + .canonicalize() + .unwrap_or_else(|_| package_root.to_path_buf()); + if !visited.insert(root.clone()) { + return Ok(()); + } + let config = + Config::open(&root.join("dcr.toml").to_string_lossy()).map_err(|err| err.to_string())?; + let deps = resolve_deps(&config, profile, target, &root)?; + consumer_deps.include_dirs.extend(deps.include_dirs); + consumer_deps.lib_dirs.extend(deps.lib_dirs); + consumer_deps.libs.extend(deps.libs); + for child_root in deps.package_roots { + extend_transitive_link_deps( + Path::new(&child_root), + profile, + target, + consumer_deps, + visited, + )?; + } + Ok(()) +} + /// Converts relative paths in flags to absolute paths based on project root for include and link flags. fn absolutize_flag_path(flag: &str, project_root: &Path, prefixes: &[&str]) -> String { for prefix in prefixes { diff --git a/src/core/deps/common.rs b/src/core/deps/common.rs index ddded48..76fa078 100644 --- a/src/core/deps/common.rs +++ b/src/core/deps/common.rs @@ -4,4 +4,5 @@ pub struct ResolvedDeps { pub include_dirs: Vec, pub lib_dirs: Vec, pub libs: Vec, + pub package_roots: Vec, } diff --git a/src/core/deps/mod.rs b/src/core/deps/mod.rs index 5a3c2b8..71f1ba7 100644 --- a/src/core/deps/mod.rs +++ b/src/core/deps/mod.rs @@ -39,8 +39,8 @@ fn dep_version(path: &Path) -> String { /// Resolves dependencies from config into include/lib dirs and lib names, and writes the lock file. /// -/// Registry and path deps contribute to `ResolvedDeps`. Git deps are recorded in the lock only -/// (no fetch or include/lib resolution). +/// Registry, path, and git deps contribute to `ResolvedDeps`. Path and git sources support both +/// DCR packages (built from `dcr.toml`) and prebuilt libraries (described with include/lib/libs). /// /// # Parameters /// - `config`: Loaded `dcr.toml`. @@ -88,6 +88,9 @@ pub fn resolve_deps( .to_string(), ); resolved.libs.push(name.clone()); + resolved + .package_roots + .push(dep_root.to_string_lossy().to_string()); lock_packages.push(DepLock { name: name.clone(), @@ -107,6 +110,8 @@ pub fn resolve_deps( }); } else if let Some(path) = path_dep_path(value) { let dep_root = project_root.join(path); + let dcr_package_name = package_name(&dep_root); + let use_dcr_package = use_dcr_package(value, dcr_package_name.is_some(), name)?; if let Some(table) = value.as_table() { if let Some(includes) = table.get("include").and_then(|v| v.as_array()) { for inc in includes { @@ -118,10 +123,7 @@ pub fn resolve_deps( } } else { push_if_exists(&mut resolved.include_dirs, &dep_root.join("include")); - push_if_exists( - &mut resolved.include_dirs, - &dep_root.join("target").join("include"), - ); + push_package_output_dirs(&mut resolved, &dep_root); } if let Some(lib_dirs) = table.get("lib").and_then(|v| v.as_array()) { @@ -143,16 +145,29 @@ pub fn resolve_deps( } } } else { - resolved.libs.push(name.clone()); + resolved + .libs + .push(dcr_package_name.clone().unwrap_or_else(|| name.clone())); } } else { push_if_exists(&mut resolved.include_dirs, &dep_root.join("include")); - push_if_exists( - &mut resolved.include_dirs, - &dep_root.join("target").join("include"), - ); + push_package_output_dirs(&mut resolved, &dep_root); push_default_lib_dirs(&mut resolved.lib_dirs, &dep_root); - resolved.libs.push(name.clone()); + resolved + .libs + .push(dcr_package_name.clone().unwrap_or_else(|| name.clone())); + } + + if use_dcr_package { + resolved + .package_roots + .push(dep_root.to_string_lossy().to_string()); + push_package_output_dirs(&mut resolved, &dep_root); + } else if !has_complete_prebuilt_layout(value) { + return Err(format!( + "Path dependency `{}` has no dcr.toml; specify include, lib, and libs for a prebuilt library", + name + )); } lock_packages.push(DepLock { @@ -162,11 +177,46 @@ pub fn resolve_deps( source: format!("path+{}", dep_root.display()), }); } else if let Some(git_info) = git_dep(value) { + let dep_root = register::fetch_git_dependency( + git_info.url, + git_info.branch.as_deref(), + git_info.tag.as_deref(), + git_info.rev.as_deref(), + )?; + let dcr_package_name = package_name(&dep_root); + let use_dcr_package = use_dcr_package(value, dcr_package_name.is_some(), name)?; + let (has_include, has_lib, has_libs) = value + .as_table() + .map(|table| push_explicit_layout(&mut resolved, &dep_root, table)) + .unwrap_or((false, false, false)); + if !has_include { + push_if_exists(&mut resolved.include_dirs, &dep_root.join("include")); + if use_dcr_package { + push_package_output_dirs(&mut resolved, &dep_root); + } + } + if !has_lib { + push_default_lib_dirs(&mut resolved.lib_dirs, &dep_root); + } + if !has_libs && let Some(package_name) = dcr_package_name.as_deref() { + resolved.libs.push(package_name.to_string()); + } + if use_dcr_package { + resolved + .package_roots + .push(dep_root.to_string_lossy().to_string()); + } else if !(has_include && has_lib && has_libs) { + return Err(format!( + "Git dependency `{}` has no dcr.toml; specify include, lib, and libs for a prebuilt library", + name + )); + } + let commit = register::git_commit(&dep_root)?; lock_packages.push(DepLock { name: name.clone(), - version: git_info.version.unwrap_or_default(), - checksum: String::new(), - source: format!("git+{}", git_info.url), + version: git_info.version.unwrap_or(commit.clone()), + checksum: commit.clone(), + source: format!("git+{}#{}", git_info.url, commit), }); } } @@ -187,6 +237,9 @@ pub fn resolve_deps( struct GitDep<'a> { url: &'a str, version: Option, + branch: Option, + tag: Option, + rev: Option, } /// Parses a git dependency from a TOML value table. @@ -198,7 +251,25 @@ fn git_dep(value: &toml::Value) -> Option> { .get("version") .and_then(|v| v.as_str()) .map(|s| s.to_string()); - return Some(GitDep { url, version }); + let branch = table + .get("branch") + .and_then(|v| v.as_str()) + .map(str::to_string); + let tag = table + .get("tag") + .and_then(|v| v.as_str()) + .map(str::to_string); + let rev = table + .get("rev") + .and_then(|v| v.as_str()) + .map(str::to_string); + return Some(GitDep { + url, + version, + branch, + tag, + rev, + }); } None } @@ -226,6 +297,117 @@ fn push_default_lib_dirs(paths: &mut Vec, dep_root: &Path) { push_if_exists(paths, &dep_root.join("target").join("lib")); } +/// Adds explicitly configured prebuilt include/lib directories and linker names. +fn push_explicit_layout( + resolved: &mut ResolvedDeps, + dep_root: &Path, + table: &toml::map::Map, +) -> (bool, bool, bool) { + let mut has_include = false; + let mut has_lib = false; + let mut has_libs = false; + if let Some(includes) = table.get("include").and_then(|v| v.as_array()) { + has_include = true; + for inc in includes { + if let Some(path) = inc.as_str() { + resolved + .include_dirs + .push(dep_root.join(path).to_string_lossy().to_string()); + } + } + } + if let Some(lib_dirs) = table.get("lib").and_then(|v| v.as_array()) { + has_lib = true; + for path in lib_dirs { + if let Some(path) = path.as_str() { + resolved + .lib_dirs + .push(dep_root.join(path).to_string_lossy().to_string()); + } + } + } + if let Some(libs) = table.get("libs").and_then(|v| v.as_array()) { + has_libs = true; + for lib in libs { + if let Some(name) = lib.as_str() { + resolved.libs.push(name.to_string()); + } + } + } + (has_include, has_lib, has_libs) +} + +/// Reports whether a source without a DCR manifest fully describes its prebuilt layout. +fn has_complete_prebuilt_layout(value: &toml::Value) -> bool { + value.as_table().is_some_and(|table| { + ["include", "lib", "libs"].iter().all(|field| { + table + .get(*field) + .and_then(|value| value.as_array()) + .is_some_and(|values| !values.is_empty()) + }) + }) +} + +/// Selects automatic, forced-build, or forced-prebuilt dependency resolution. +fn use_dcr_package( + value: &toml::Value, + has_manifest: bool, + dependency_name: &str, +) -> Result { + let mode = value + .as_table() + .and_then(|table| table.get("mode")) + .map(|value| { + value.as_str().ok_or_else(|| { + format!( + "Dependency `{dependency_name}` field `mode` must be \"build\" or \"prebuild\"" + ) + }) + }) + .transpose()?; + match mode { + None => Ok(has_manifest), + Some("build") if has_manifest => Ok(true), + Some("build") => Err(format!( + "Dependency `{dependency_name}` uses mode = \"build\" but has no dcr.toml" + )), + Some("prebuild") => Ok(false), + Some(_) => Err(format!( + "Dependency `{dependency_name}` field `mode` must be \"build\" or \"prebuild\"" + )), + } +} + +/// Adds DCR's packaged output directories even before the dependency is built. +fn push_package_output_dirs(resolved: &mut ResolvedDeps, dep_root: &Path) { + push_unique_path( + &mut resolved.include_dirs, + dep_root.join("target").join("include"), + ); + push_unique_path(&mut resolved.lib_dirs, dep_root.join("target").join("lib")); +} + +/// Adds a path once, preserving the linker and include-search order. +fn push_unique_path(paths: &mut Vec, path: std::path::PathBuf) { + let path = path.to_string_lossy().to_string(); + if !paths.contains(&path) { + paths.push(path); + } +} + +/// Reads a DCR package name when the dependency has a manifest. +fn package_name(path: &Path) -> Option { + Config::open(&path.join("dcr.toml").to_string_lossy()) + .ok() + .and_then(|config| { + config + .get("package.name") + .and_then(|value| value.as_str()) + .map(str::to_string) + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/core/deps/register.rs b/src/core/deps/register.rs index 4dfeef0..75d61da 100644 --- a/src/core/deps/register.rs +++ b/src/core/deps/register.rs @@ -17,8 +17,10 @@ use serde::{Deserialize, Serialize}; use serde_json::Value as JsonValue; +use sha2::{Digest, Sha256}; use std::fs; use std::path::{Path, PathBuf}; +use std::process::Command; use toml::Value as TomlValue; /// Represents a registry configuration entry with its URL and priority. @@ -149,6 +151,95 @@ pub fn registry_lib_dir(dep_root: &Path) -> PathBuf { dep_root.join("target").join("lib") } +/// Fetches a git dependency into the DCR cache and returns its working tree. +/// The URL and selected ref determine the cache key; an existing checkout is reused. +pub fn fetch_git_dependency( + url: &str, + branch: Option<&str>, + tag: Option<&str>, + rev: Option<&str>, +) -> Result { + if !crate::utils::git::is_git_available() { + return Err("git executable not found in PATH".to_string()); + } + let mut hasher = Sha256::new(); + hasher.update(url.as_bytes()); + hasher.update(b"\0"); + hasher.update(branch.unwrap_or("").as_bytes()); + hasher.update(b"\0"); + hasher.update(tag.unwrap_or("").as_bytes()); + hasher.update(b"\0"); + hasher.update(rev.unwrap_or("").as_bytes()); + let key = format!("{:x}", hasher.finalize()); + let root = dcr_config_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("git") + .join(&key); + if root.join(".git").is_dir() { + let status = Command::new("git") + .args(["fetch", "--all", "--tags", "--prune"]) + .current_dir(&root) + .status() + .map_err(|e| format!("failed to execute git fetch: {e}"))?; + if !status.success() { + eprintln!("Warning: git fetch failed for {url}; using the cached checkout"); + } + return checkout_git_ref(&root, branch, tag, rev); + } + if root.exists() { + return Err(format!( + "git cache path is not a repository: {}", + root.display() + )); + } + if let Some(parent) = root.parent() { + fs::create_dir_all(parent).map_err(|e| format!("failed to create git cache: {e}"))?; + } + let status = Command::new("git") + .args(["clone", "--", url]) + .arg(&root) + .status() + .map_err(|e| format!("failed to execute git clone: {e}"))?; + if !status.success() { + return Err(format!("git clone failed for {url}")); + } + checkout_git_ref(&root, branch, tag, rev) +} + +/// Returns the exact commit checked out in a cached git dependency. +pub fn git_commit(root: &Path) -> Result { + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(root) + .output() + .map_err(|e| format!("failed to execute git rev-parse: {e}"))?; + if !output.status.success() { + return Err(format!("failed to read git revision at {}", root.display())); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn checkout_git_ref( + root: &Path, + branch: Option<&str>, + tag: Option<&str>, + rev: Option<&str>, +) -> Result { + let selected = rev.or(tag).or(branch); + let Some(reference) = selected else { + return Ok(root.to_path_buf()); + }; + let status = Command::new("git") + .args(["checkout", "--force", reference]) + .current_dir(root) + .status() + .map_err(|e| format!("failed to execute git checkout: {e}"))?; + if !status.success() { + return Err(format!("git checkout failed for ref `{reference}`")); + } + Ok(root.to_path_buf()) +} + /// Looks up `name` in the local package index (`DCR_INDEX_PATH` / `~/.dcr/index.json`). /// /// Configured registries are sorted by priority, but the lookup currently always reads the diff --git a/tests/cli_deps.rs b/tests/cli_deps.rs index 146f7a7..eda2cb6 100644 --- a/tests/cli_deps.rs +++ b/tests/cli_deps.rs @@ -16,6 +16,15 @@ fn dcr_add_dependencies() { "path dep not found in toml" ); + // A bare filesystem path is equivalent to the explicit path: form. + let out = run_dcr(&["add", "otherlib", "../otherlib"], &dir); + assert!(out.status.success(), "dcr add bare path should succeed"); + let toml = std::fs::read_to_string(dir.join("dcr.toml")).unwrap(); + assert!( + toml.contains("otherlib = { path = \"../otherlib\" }"), + "bare path dep not found in toml" + ); + // Test github: prefix let out = run_dcr(&["add", "gh_lib", "github:user/repo"], &dir); assert!(out.status.success(), "dcr add github should succeed"); @@ -96,6 +105,365 @@ fn dcr_builds_lib_package() { assert!(target_dir.join("lib").exists(), "lib directory missing"); } +#[test] +fn path_prebuilt_dependency_without_manifest_links() { + let Some(compiler) = available_compiler() else { + eprintln!("no compiler found; skipping prebuilt path dependency test"); + return; + }; + if !is_ar_in_path() { + eprintln!("ar unavailable; skipping prebuilt path dependency test"); + return; + } + + let root = unique_sandbox_dir("path_prebuilt_dep"); + let dep = root.join("dep"); + let app = root.join("app"); + write_prebuilt_library( + &dep, + compiler, + "prebuilt", + "int prebuilt_answer(void);\n", + "int prebuilt_answer(void) { return 42; }\n", + ); + std::fs::create_dir_all(app.join("src")).unwrap(); + std::fs::write( + app.join("dcr.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[build]\nlanguage = \"c\"\ncompiler = \"clang\"\nkind = \"bin\"\n\n[dependencies]\nprebuilt = { path = \"../dep\", include = [\"include\"], lib = [\"lib\"], libs = [\"prebuilt\"] }\n", + ) + .unwrap(); + std::fs::write( + app.join("src/main.c"), + "#include \"prebuilt.h\"\nint main(void) { return prebuilt_answer() == 42 ? 0 : 1; }\n", + ) + .unwrap(); + + let out = run_dcr_env(&["build"], &app, &[("DCR_COMPILER", compiler)]); + assert!( + out.status.success(), + "prebuilt path dependency build failed: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +#[test] +fn path_dcr_dependency_is_built_before_consumer() { + let Some(compiler) = available_compiler() else { + eprintln!("no compiler found; skipping path dependency build test"); + return; + }; + let root = unique_sandbox_dir("path_dcr_dep"); + let dep = root.join("dep"); + let app = root.join("app"); + std::fs::create_dir_all(dep.join("src")).unwrap(); + std::fs::create_dir_all(app.join("src")).unwrap(); + std::fs::write( + dep.join("dcr.toml"), + "[package]\nname = \"mathlib\"\nversion = \"0.1.0\"\ntype = \"lib\"\n\n[build]\nlanguage = \"c\"\ncompiler = \"clang\"\nkind = \"staticlib\"\n", + ) + .unwrap(); + std::fs::write(dep.join("src/mathlib.h"), "int answer(void);\n").unwrap(); + std::fs::write( + dep.join("src/mathlib.c"), + "int answer(void) { return 42; }\n", + ) + .unwrap(); + std::fs::write( + app.join("dcr.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[build]\nlanguage = \"c\"\ncompiler = \"clang\"\nkind = \"bin\"\n\n[dependencies]\nrenamed = { path = \"../dep\" }\n", + ) + .unwrap(); + std::fs::write( + app.join("src/main.c"), + "#include \"mathlib.h\"\nint main(void) { return answer() == 42 ? 0 : 1; }\n", + ) + .unwrap(); + + let out = run_dcr_env(&["build"], &app, &[("DCR_COMPILER", compiler)]); + assert!( + out.status.success(), + "path dependency build failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(dep.join("target/include/mathlib.h").is_file()); + assert!(dep.join("target/lib").is_dir()); +} + +#[test] +fn path_dcr_dependencies_build_transitively() { + let Some(compiler) = available_compiler() else { + eprintln!("no compiler found; skipping transitive path dependency test"); + return; + }; + let root = unique_sandbox_dir("path_dcr_transitive"); + let leaf = root.join("leaf"); + let middle = root.join("middle"); + let app = root.join("app"); + for project in [&leaf, &middle, &app] { + std::fs::create_dir_all(project.join("src")).unwrap(); + } + std::fs::write( + leaf.join("dcr.toml"), + "[package]\nname = \"leaf\"\nversion = \"0.1.0\"\ntype = \"lib\"\n\n[build]\nlanguage = \"c\"\ncompiler = \"clang\"\nkind = \"staticlib\"\n", + ) + .unwrap(); + std::fs::write(leaf.join("src/leaf.h"), "int leaf_value(void);\n").unwrap(); + std::fs::write( + leaf.join("src/leaf.c"), + "int leaf_value(void) { return 40; }\n", + ) + .unwrap(); + std::fs::write( + middle.join("dcr.toml"), + "[package]\nname = \"middle\"\nversion = \"0.1.0\"\ntype = \"lib\"\n\n[build]\nlanguage = \"c\"\ncompiler = \"clang\"\nkind = \"staticlib\"\n\n[dependencies]\nleaf = { path = \"../leaf\" }\n", + ) + .unwrap(); + std::fs::write(middle.join("src/middle.h"), "int middle_value(void);\n").unwrap(); + std::fs::write( + middle.join("src/middle.c"), + "#include \"leaf.h\"\nint middle_value(void) { return leaf_value() + 2; }\n", + ) + .unwrap(); + std::fs::write( + app.join("dcr.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[build]\nlanguage = \"c\"\ncompiler = \"clang\"\nkind = \"bin\"\n\n[dependencies]\nmiddle = { path = \"../middle\" }\n", + ) + .unwrap(); + std::fs::write( + app.join("src/main.c"), + "#include \"middle.h\"\nint main(void) { return middle_value() == 42 ? 0 : 1; }\n", + ) + .unwrap(); + + let out = run_dcr_env(&["build"], &app, &[("DCR_COMPILER", compiler)]); + assert!( + out.status.success(), + "transitive path dependency build failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + assert!(leaf.join("target/lib").is_dir()); + assert!(middle.join("target/lib").is_dir()); +} + +#[test] +fn path_dependency_cycle_reports_the_full_chain() { + let root = unique_sandbox_dir("path_dcr_cycle"); + let first = root.join("first"); + let second = root.join("second"); + for project in [&first, &second] { + std::fs::create_dir_all(project.join("src")).unwrap(); + } + std::fs::write( + first.join("dcr.toml"), + "[package]\nname = \"first\"\nversion = \"0.1.0\"\n\n[build]\nlanguage = \"c\"\nkind = \"staticlib\"\n\n[dependencies]\nsecond = { path = \"../second\" }\n", + ) + .unwrap(); + std::fs::write( + second.join("dcr.toml"), + "[package]\nname = \"second\"\nversion = \"0.1.0\"\n\n[build]\nlanguage = \"c\"\nkind = \"staticlib\"\n\n[dependencies]\nfirst = { path = \"../first\" }\n", + ) + .unwrap(); + + let out = run_dcr(&["build"], &first); + let stderr = String::from_utf8_lossy(&out.stderr); + assert!(!out.status.success()); + assert!(stderr.contains("Dependency cycle detected"), "{stderr}"); + assert!( + stderr.contains("first") && stderr.contains("second"), + "{stderr}" + ); +} + +#[test] +fn git_dcr_dependency_is_cloned_built_and_locked() { + let Some(compiler) = available_compiler() else { + eprintln!("no compiler found; skipping git dependency build test"); + return; + }; + if !is_git_in_path() { + eprintln!("git unavailable; skipping git dependency build test"); + return; + } + let root = unique_sandbox_dir("git_dcr_dep"); + let home = root.join("home"); + let origin = root.join("origin"); + let app = root.join("app"); + std::fs::create_dir_all(origin.join("src")).unwrap(); + std::fs::create_dir_all(app.join("src")).unwrap(); + std::fs::write( + origin.join("dcr.toml"), + "[package]\nname = \"gitmath\"\nversion = \"0.1.0\"\ntype = \"lib\"\n\n[build]\nlanguage = \"c\"\ncompiler = \"clang\"\nkind = \"staticlib\"\n", + ) + .unwrap(); + std::fs::write(origin.join("src/gitmath.h"), "int git_answer(void);\n").unwrap(); + std::fs::write( + origin.join("src/gitmath.c"), + "int git_answer(void) { return 7; }\n", + ) + .unwrap(); + for args in [ + vec!["init"], + vec!["add", "."], + vec![ + "-c", + "user.email=dcr@example.test", + "-c", + "user.name=DCR", + "commit", + "-m", + "init", + ], + vec!["tag", "v0.1.0"], + ] { + let status = std::process::Command::new("git") + .args(args) + .current_dir(&origin) + .status() + .unwrap(); + assert!(status.success()); + } + let origin_url = origin.to_string_lossy().to_string(); + std::fs::write( + app.join("dcr.toml"), + format!("[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[build]\nlanguage = \"c\"\ncompiler = \"clang\"\nkind = \"bin\"\n\n[dependencies]\ngitmath = {{ git = \"{origin_url}\", tag = \"v0.1.0\" }}\n"), + ) + .unwrap(); + std::fs::write( + app.join("src/main.c"), + "#include \"gitmath.h\"\nint main(void) { return git_answer() == 7 ? 0 : 1; }\n", + ) + .unwrap(); + let home_s = home.to_string_lossy().to_string(); + let out = run_dcr_env( + &["build"], + &app, + &[("DCR_COMPILER", compiler), ("HOME", home_s.as_str())], + ); + assert!( + out.status.success(), + "git dependency build failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let lock = std::fs::read_to_string(app.join("dcr.lock")).unwrap(); + assert!(lock.contains("git+")); + assert!(!lock.contains("checksum = \"\"")); +} + +#[test] +fn git_prebuilt_dependency_without_manifest_links() { + let Some(compiler) = available_compiler() else { + eprintln!("no compiler found; skipping prebuilt git dependency test"); + return; + }; + if !is_git_in_path() || !is_ar_in_path() { + eprintln!("git or ar unavailable; skipping prebuilt git dependency test"); + return; + } + + let root = unique_sandbox_dir("git_prebuilt_dep"); + let home = root.join("home"); + let origin = root.join("origin"); + let app = root.join("app"); + write_prebuilt_library( + &origin, + compiler, + "gitprebuilt", + "int git_prebuilt_answer(void);\n", + "int git_prebuilt_answer(void) { return 9; }\n", + ); + std::fs::create_dir_all(app.join("src")).unwrap(); + for args in [ + vec!["init"], + vec!["add", "."], + vec![ + "-c", + "user.email=dcr@example.test", + "-c", + "user.name=DCR", + "commit", + "-m", + "init", + ], + vec!["tag", "v0.1.0"], + ] { + let status = std::process::Command::new("git") + .args(args) + .current_dir(&origin) + .status() + .unwrap(); + assert!(status.success()); + } + let origin_url = origin.to_string_lossy().to_string(); + std::fs::write( + app.join("dcr.toml"), + format!("[package]\nname = \"app\"\nversion = \"0.1.0\"\n\n[build]\nlanguage = \"c\"\ncompiler = \"clang\"\nkind = \"bin\"\n\n[dependencies]\ngitprebuilt = {{ git = \"{origin_url}\", tag = \"v0.1.0\", include = [\"include\"], lib = [\"lib\"], libs = [\"gitprebuilt\"] }}\n"), + ) + .unwrap(); + std::fs::write( + app.join("src/main.c"), + "#include \"gitprebuilt.h\"\nint main(void) { return git_prebuilt_answer() == 9 ? 0 : 1; }\n", + ) + .unwrap(); + + let home_s = home.to_string_lossy().to_string(); + let out = run_dcr_env( + &["build"], + &app, + &[("DCR_COMPILER", compiler), ("HOME", home_s.as_str())], + ); + assert!( + out.status.success(), + "prebuilt git dependency build failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let lock = std::fs::read_to_string(app.join("dcr.lock")).unwrap(); + assert!(lock.contains("git+")); + assert!(!lock.contains("checksum = \"\"")); +} + +fn write_prebuilt_library( + root: &std::path::Path, + compiler: &str, + name: &str, + header: &str, + source: &str, +) { + let include_dir = root.join("include"); + let lib_dir = root.join("lib"); + let src_dir = root.join("src"); + std::fs::create_dir_all(&include_dir).unwrap(); + std::fs::create_dir_all(&lib_dir).unwrap(); + std::fs::create_dir_all(&src_dir).unwrap(); + std::fs::write(include_dir.join(format!("{name}.h")), header).unwrap(); + let source_path = src_dir.join(format!("{name}.c")); + let object_path = lib_dir.join(format!("{name}.o")); + let archive_path = lib_dir.join(format!("lib{name}.a")); + std::fs::write(&source_path, source).unwrap(); + let status = std::process::Command::new(compiler) + .args(["-c"]) + .arg(&source_path) + .args(["-o"]) + .arg(&object_path) + .status() + .unwrap(); + assert!(status.success(), "failed to compile prebuilt library"); + let status = std::process::Command::new("ar") + .args(["rcs"]) + .arg(&archive_path) + .arg(&object_path) + .status() + .unwrap(); + assert!(status.success(), "failed to archive prebuilt library"); +} + +fn is_ar_in_path() -> bool { + std::process::Command::new("ar") + .arg("--version") + .output() + .map(|out| out.status.success()) + .unwrap_or(false) +} + #[test] fn registry_dependency_is_built_from_cache() { let Some(compiler) = available_compiler() else {