// 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 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. #[derive(Debug, Serialize, Deserialize)] pub struct Registry { pub url: String, pub priority: i32, } /// Configuration struct for DCR registries, mapping registry names to Registry details. #[derive(Debug, Serialize, Deserialize)] pub struct DcrConfig { pub registry: std::collections::HashMap, } /// Helper function to get the user's home directory path. fn home_dir() -> Option { crate::utils::fs::home_dir() } /// Helper to get the DCR config directory path. fn dcr_config_dir() -> Option { home_dir().map(|h| h.join(".dcr")) } /// Loads DCR registry configuration from the TOML file in the user's home directory. pub fn get_registry_config() -> Option { let home = home_dir()?; let config_path = home.join(".dcr/config.toml"); if !config_path.exists() { return None; } let content = fs::read_to_string(config_path).ok()?; toml::from_str(&content).ok() } /// Returns the path to the registry index file, preferring the DCR_INDEX_PATH env var or falling back to ~/.dcr/index.json. pub fn get_index_path() -> PathBuf { std::env::var("DCR_INDEX_PATH") .map(PathBuf::from) .unwrap_or_else(|_| { dcr_config_dir() .unwrap_or_else(|| PathBuf::from(".")) .join("index.json") }) } /// Returns the root directory for registry cache, based on the index path's parent, creating it if necessary. pub fn get_registry_cache_root() -> PathBuf { let index_path = get_index_path(); if let Some(parent) = index_path.parent() { if parent.exists() { return parent.to_path_buf(); } if let Err(e) = std::fs::create_dir_all(parent) { eprintln!("Warning: failed to create registry cache directory: {e}"); } parent.to_path_buf() } else { PathBuf::from(".") } } /// Extracts the package root path from registry info. If the path points to a dcr.toml manifest, returns its parent directory. pub fn package_root_from_registry_info(pkg_info: &JsonValue) -> Result { let raw_path = pkg_info .get("path") .and_then(|v| v.as_str()) .ok_or("Registry package is missing path")?; if raw_path.trim().is_empty() { return Err("Registry package path is empty".to_string()); } let full_path = registry_path_to_pathbuf(raw_path.trim()); if full_path.file_name().and_then(|v| v.to_str()) == Some("dcr.toml") { return full_path .parent() .map(Path::to_path_buf) .ok_or_else(|| format!("Invalid registry package path: {}", full_path.display())); } Ok(full_path) } /// Converts a raw registry path string to a PathBuf, handling file:// prefixes, Windows extended paths, and relative paths by joining to cache root. pub fn registry_path_to_pathbuf(raw: &str) -> PathBuf { let s = raw.trim(); if let Some(rest) = s.strip_prefix("file://") { if rest.len() >= 3 && rest.as_bytes()[0] == b'/' && rest.as_bytes()[2] == b':' { return PathBuf::from(&rest[1..]); } if rest.len() >= 2 && rest.as_bytes()[1] == b':' { return PathBuf::from(rest); } if rest.starts_with('/') { return PathBuf::from(rest); } return PathBuf::from(format!("/{rest}")); } let mut s = s.replace('\\', "/"); if let Some(rest) = s.strip_prefix("//?/") { s = rest.to_string(); } else if let Some(rest) = s.strip_prefix("//./") { s = rest.to_string(); } if s.len() >= 3 && s.as_bytes()[0] == b'/' && s.as_bytes()[2] == b':' { s = s[1..].to_string(); } let path = PathBuf::from(&s); if path.is_absolute() || (s.len() >= 2 && s.as_bytes()[1] == b':') || s.starts_with('/') { path } else { get_registry_cache_root().join(path) } } /// Returns the include directory path for a dependency root, typically under target/include. pub fn registry_include_dir(dep_root: &Path) -> PathBuf { dep_root.join("target").join("include") } /// Returns the library directory path for a dependency root, typically under target/lib. 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 /// same local index path (per-registry URLs are unused). /// /// # Parameters /// - `name`: Package name as in the index. /// /// # Returns /// Package JSON object, or an error if config/index is missing or the name is absent. pub fn resolve_package_from_registry(name: &str) -> Result { let config = get_registry_config().ok_or("No registry config found")?; let mut registries: Vec<(&String, &Registry)> = config.registry.iter().collect(); registries.sort_by_key(|b| std::cmp::Reverse(b.1.priority)); for (_name_reg, _reg) in registries { let index_path = get_index_path(); if index_path.exists() { let index_content = fs::read_to_string(&index_path).map_err(|e| e.to_string())?; let index: JsonValue = serde_json::from_str(&index_content).map_err(|e| e.to_string())?; if let Some(pkgs) = index.get("packages").and_then(|v| v.as_array()) { for pkg in pkgs { if pkg.get("name").and_then(|v| v.as_str()) == Some(name) { return Ok(pkg.clone()); } } } } } Err(format!( "Package {} not found in registry (checked: {:?})", name, get_index_path() )) } /// True if the TOML value is a registry-style dependency. /// /// Strings: not path-like and not git-like. Tables: have version/features/optional/registry /// and no `git`/`path`/`url` keys. pub fn is_registry_dep(value: &TomlValue) -> bool { if let Some(raw) = value.as_str() { let raw = raw.trim(); return !is_path_like_string(raw) && !is_git_like_string(raw); } if let Some(table) = value.as_table() { if table.contains_key("git") || table.contains_key("path") || table.contains_key("url") { return false; } table.contains_key("version") || table.contains_key("features") || table.contains_key("optional") || table.contains_key("registry") } else { false } } /// Extracts the path string from a dependency value if it's a path-like dependency. pub fn path_from_string_dep(value: &TomlValue) -> Option<&str> { let raw = value.as_str()?.trim(); if let Some(path) = raw.strip_prefix("path:") { return Some(path); } if is_path_like_string(raw) { return Some(raw); } None } /// Helper to check if a string represents a local file path. fn is_path_like_string(raw: &str) -> bool { raw.starts_with("path:") || raw.starts_with("./") || raw.starts_with("../") || raw.starts_with('/') || raw.starts_with("~/") || raw.contains('\\') } /// Helper to check if a string represents a git or remote dependency. fn is_git_like_string(raw: &str) -> bool { raw.starts_with("git:") || raw.starts_with("github:") || raw.starts_with("gitlab:") || raw.starts_with("http://") || raw.starts_with("https://") || raw.starts_with("git@") } #[cfg(test)] mod tests { use super::*; use toml::map::Map; #[test] fn registry_dep_detection_excludes_local_and_git_strings() { assert!(is_registry_dep(&TomlValue::String("1.2.3".to_string()))); assert!(!is_registry_dep(&TomlValue::String( "path:./libs/mylib".to_string() ))); assert!(!is_registry_dep(&TomlValue::String( "./libs/mylib".to_string() ))); assert!(!is_registry_dep(&TomlValue::String( "git:https://example.com/repo.git".to_string() ))); } #[test] fn registry_dep_detection_excludes_source_tables() { let mut path_table = Map::new(); path_table.insert( "path".to_string(), TomlValue::String("./libs/mylib".to_string()), ); assert!(!is_registry_dep(&TomlValue::Table(path_table))); let mut registry_table = Map::new(); registry_table.insert("features".to_string(), TomlValue::Array(Vec::new())); assert!(is_registry_dep(&TomlValue::Table(registry_table))); } #[test] fn registry_package_root_accepts_package_dir_or_manifest_path() { let root = std::env::temp_dir().join(format!("dcr_reg_pkg_{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); std::fs::create_dir_all(&root).unwrap(); let root_str = root.to_string_lossy().replace('\\', "/"); let pkg = serde_json::json!({ "path": root_str }); assert_eq!( package_root_from_registry_info(&pkg) .unwrap() .to_string_lossy() .replace('\\', "/"), root_str ); let manifest = format!("{root_str}/dcr.toml"); let pkg = serde_json::json!({ "path": manifest }); assert_eq!( package_root_from_registry_info(&pkg) .unwrap() .to_string_lossy() .replace('\\', "/"), root_str ); let _ = std::fs::remove_dir_all(&root); } #[test] fn registry_path_strips_windows_extended_prefix() { let p = registry_path_to_pathbuf("//?/D:/cache/mylib"); let s = p.to_string_lossy().replace('\\', "/"); assert_eq!(s, "D:/cache/mylib"); let p2 = registry_path_to_pathbuf("/D:/cache/mylib"); let s2 = p2.to_string_lossy().replace('\\', "/"); assert_eq!(s2, "D:/cache/mylib"); let p3 = registry_path_to_pathbuf("file:///D:/cache/mylib"); let s3 = p3.to_string_lossy().replace('\\', "/"); assert_eq!(s3, "D:/cache/mylib"); } }