diff --git a/Cargo.lock b/Cargo.lock index 636a5e2c..f12f809d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13,6 +13,43 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "abnegate-secret" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa24383e65469ff8390024afe21418dc61eb448c1abf94cc878ff9b6f9df4f7" +dependencies = [ + "aes-gcm 0.11.1", + "base64 0.23.1", + "dirs 7.0.0", + "getrandom 0.4.1", + "hex", + "serde", + "subtle", + "thiserror 2.0.18", + "tracing", + "zeroize", +] + +[[package]] +name = "abnegate-vcs" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6667d58389aa8708f32391c00f67d027c6c20f47e69882cbb51198bbc00ef4db" +dependencies = [ + "abnegate-secret", + "base64 0.23.1", + "nix 0.31.3", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "uuid", +] + [[package]] name = "addr2line" version = "0.25.1" @@ -38,6 +75,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "aes" version = "0.8.4" @@ -45,24 +92,51 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", + "zeroize", +] + [[package]] name = "aes-gcm" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ - "aead", - "aes", - "cipher", - "ctr", - "ghash", + "aead 0.5.2", + "aes 0.8.4", + "cipher 0.4.4", + "ctr 0.9.2", + "ghash 0.5.1", "subtle", ] +[[package]] +name = "aes-gcm" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" +dependencies = [ + "aead 0.6.1", + "aes 0.9.3", + "cipher 0.5.2", + "ctr 0.10.1", + "ctutils", + "ghash 0.6.0", + "zeroize", +] + [[package]] name = "ahash" version = "0.8.12" @@ -466,6 +540,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "base64ct" version = "1.8.3" @@ -564,7 +644,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" dependencies = [ "byteorder", - "cipher", + "cipher 0.4.4", ] [[package]] @@ -691,7 +771,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", - "inout", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", ] [[package]] @@ -749,7 +840,7 @@ checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" name = "claudear" version = "1.0.0" dependencies = [ - "aes-gcm", + "aes-gcm 0.10.3", "anyhow", "async-trait", "axum", @@ -764,7 +855,7 @@ dependencies = [ "claudear-engine", "claudear-integrations", "claudear-storage", - "dirs", + "dirs 6.0.0", "encoding_rs", "fastembed", "futures", @@ -840,13 +931,14 @@ name = "claudear-analysis" version = "1.0.0" dependencies = [ "abnegate-index", + "abnegate-vcs", "async-trait", "base64 0.22.1", "chrono", "claudear-config", "claudear-core", "claudear-storage", - "dirs", + "dirs 6.0.0", "fastembed", "futures", "ort", @@ -888,7 +980,7 @@ version = "1.0.0" dependencies = [ "chrono", "claudear-core", - "dirs", + "dirs 6.0.0", "regex-lite", "serde", "serde_json", @@ -901,12 +993,12 @@ dependencies = [ name = "claudear-core" version = "1.0.0" dependencies = [ - "aes-gcm", + "aes-gcm 0.10.3", "anyhow", "async-trait", "base64 0.22.1", "chrono", - "dirs", + "dirs 6.0.0", "futures", "hex", "hmac", @@ -1002,7 +1094,7 @@ dependencies = [ "claudear-config", "claudear-core", "claudear-storage", - "dirs", + "dirs 6.0.0", "encoding_rs", "futures", "futures-util", @@ -1068,6 +1160,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.4" @@ -1183,6 +1281,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1273,7 +1377,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.1", "hybrid-array", + "rand_core 0.10.0", ] [[package]] @@ -1282,7 +1388,25 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", +] + +[[package]] +name = "ctr" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", ] [[package]] @@ -1469,6 +1593,15 @@ dependencies = [ "dirs-sys", ] +[[package]] +name = "dirs" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d57d423b3c82e89b9a24ca3091fee61f456a26edbd28d26c65906f4bc1dcd8f" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs-sys" version = "0.5.0" @@ -1995,7 +2128,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" dependencies = [ "opaque-debug", - "polyval", + "polyval 0.6.2", +] + +[[package]] +name = "ghash" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eecf2d5dc9b66b732b97707a0210906b1d30523eb773193ab777c0c84b3e8d5" +dependencies = [ + "polyval 0.7.3", + "zeroize", ] [[package]] @@ -2163,7 +2306,7 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" dependencies = [ - "dirs", + "dirs 6.0.0", "http", "indicatif 0.17.11", "libc", @@ -2547,6 +2690,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -2716,9 +2868,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.183" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "libloading" @@ -2916,9 +3068,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi", @@ -3034,6 +3186,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nom" version = "5.1.3" @@ -3478,7 +3642,7 @@ checksum = "e4022a17595a00d6a369236fdae483f0de7f0a339960a53118b818238e132224" dependencies = [ "android_system_properties", "log", - "nix", + "nix 0.30.1", "objc2", "objc2-foundation", "objc2-ui-kit", @@ -3663,7 +3827,19 @@ dependencies = [ "cfg-if", "cpufeatures 0.2.17", "opaque-debug", - "universal-hash", + "universal-hash 0.5.1", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.0", + "universal-hash 0.6.1", + "zeroize", ] [[package]] @@ -4768,12 +4944,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5095,9 +5271,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.50.0" +version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ "bytes", "libc", @@ -5111,9 +5287,9 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", @@ -5633,6 +5809,16 @@ dependencies = [ "subtle", ] +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -6541,9 +6727,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index 16a089ee..4d58390a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -152,6 +152,7 @@ http-body-util = "0.1" # Shared infrastructure abnegate-index = "0.1.0" +abnegate-vcs = "0.1.3" # Internal crates claudear-core = { path = "crates/claudear-core" } diff --git a/crates/claudear-analysis/Cargo.toml b/crates/claudear-analysis/Cargo.toml index 9612dc31..2d9f1b72 100644 --- a/crates/claudear-analysis/Cargo.toml +++ b/crates/claudear-analysis/Cargo.toml @@ -13,6 +13,7 @@ cuda = ["ort/cuda"] [dependencies] abnegate-index = { workspace = true } +abnegate-vcs = { workspace = true } claudear-core = { workspace = true } claudear-config = { workspace = true } claudear-storage = { workspace = true } diff --git a/crates/claudear-analysis/src/repo/dependencies.rs b/crates/claudear-analysis/src/repo/dependencies.rs new file mode 100644 index 00000000..653a4bbc --- /dev/null +++ b/crates/claudear-analysis/src/repo/dependencies.rs @@ -0,0 +1,211 @@ +//! Dependencies between repositories, discovered from the package manifests +//! under configured paths and saved for cascades to follow. + +use crate::repo::index::expand_path_in; +use crate::repo::relationships::dependency_type; +use abnegate_vcs::DependencyDiscovery; +use abnegate_vcs::DiscoveredDependency; +use claudear_storage::RepoStore; +use std::path::Path; +use std::path::PathBuf; + +/// The dependencies on `known_orgs` declared under the configured `paths`, +/// with `~` in them resolved the way the repository index resolves it. +pub fn discover_configured(known_orgs: &[String], paths: &[String]) -> Vec { + discover_configured_in(known_orgs, paths, dirs::home_dir().as_deref()) +} + +fn discover_configured_in( + known_orgs: &[String], + paths: &[String], + home: Option<&Path>, +) -> Vec { + let paths: Vec = paths + .iter() + .map(|path| expand_path_in(path, home)) + .collect(); + DependencyDiscovery::new(known_orgs.to_vec()).scan_directories(&paths) +} + +/// Save every dependency a cascade can follow, returning how many were saved. +/// +/// A dependency that cannot be saved is logged and skipped, so one bad row +/// does not lose the rest. +pub fn save_discovered( + tracker: &T, + dependencies: &[DiscoveredDependency], +) -> usize { + let mut saved = 0; + for dependency in dependencies { + let Some(kind) = dependency_type(dependency.manifest) else { + tracing::warn!( + manifest = %dependency.manifest, + upstream = %dependency.depends_on, + downstream = %dependency.repository, + "Skipped a dependency from a manifest cascades do not follow" + ); + continue; + }; + match tracker.add_dependency( + &dependency.depends_on, + &dependency.repository, + kind.as_str(), + ) { + Ok(()) => saved += 1, + Err(error) => tracing::warn!( + error = %error, + upstream = %dependency.depends_on, + downstream = %dependency.repository, + "Failed to save dependency" + ), + } + } + saved +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + use tempfile::TempDir; + + const COMPOSER_MANIFEST: &str = "composer.json"; + const PACKAGE_MANIFEST: &str = "package.json"; + + fn write_manifest(directory: &Path, name: &str, manifest: serde_json::Value) { + std::fs::create_dir_all(directory).unwrap(); + std::fs::write(directory.join(name), manifest.to_string()).unwrap(); + } + + fn known_orgs() -> Vec { + vec!["appwrite".to_string(), "utopia-php".to_string()] + } + + fn configured(path: &Path) -> Vec { + vec![path.to_string_lossy().to_string()] + } + + #[test] + fn test_a_configured_directory_of_checkouts_is_read_one_level_down() { + let root = TempDir::new().unwrap(); + let cloud = root.path().join("cloud"); + let console = root.path().join("console"); + let unnamed = root.path().join("unnamed"); + let broken = root.path().join("broken"); + write_manifest( + &cloud, + COMPOSER_MANIFEST, + serde_json::json!({ + "name": "appwrite/cloud", + "require": { "utopia-php/database": "^1.0", "symfony/console": "^5.0" }, + "require-dev": { "utopia-php/database": "^1.0", "utopia-php/cli": "^1.0" }, + }), + ); + write_manifest( + &console, + PACKAGE_MANIFEST, + serde_json::json!({ + "name": "@appwrite/console", + "dependencies": { "@appwrite/sdk": "^1.0", "react": "^18.0" }, + "devDependencies": { "@appwrite/sdk": "^1.0" }, + }), + ); + write_manifest( + &unnamed, + COMPOSER_MANIFEST, + serde_json::json!({ "require": { "appwrite/sdk-for-php": "^1.0" } }), + ); + std::fs::create_dir(&broken).unwrap(); + std::fs::write(broken.join(COMPOSER_MANIFEST), "{ invalid json }").unwrap(); + + let mut dependencies = discover_configured(&known_orgs(), &configured(root.path())); + dependencies.sort_by(|left, right| { + (&left.repository, &left.depends_on).cmp(&(&right.repository, &right.depends_on)) + }); + + let found: Vec<_> = dependencies + .iter() + .map(|dependency| { + ( + dependency.repository.as_str(), + dependency.depends_on.as_str(), + dependency.manifest.as_str(), + dependency.repository_path.as_path(), + ) + }) + .collect(); + assert_eq!( + found, + vec![ + ( + "appwrite/cloud", + "utopia-php/cli", + "composer", + cloud.as_path() + ), + ( + "appwrite/cloud", + "utopia-php/database", + "composer", + cloud.as_path() + ), + ("appwrite/console", "appwrite/sdk", "npm", console.as_path()), + ( + "unnamed", + "appwrite/sdk-for-php", + "composer", + unnamed.as_path() + ), + ] + ); + } + + #[test] + fn test_a_configured_repository_that_declares_dependencies_is_not_read_one_level_down() { + let root = TempDir::new().unwrap(); + write_manifest( + root.path(), + COMPOSER_MANIFEST, + serde_json::json!({ + "name": "appwrite/appwrite", + "require": { "utopia-php/framework": "^1.0" }, + }), + ); + write_manifest( + &root.path().join("vendor-copy"), + COMPOSER_MANIFEST, + serde_json::json!({ + "name": "appwrite/vendored", + "require": { "utopia-php/cache": "^1.0" }, + }), + ); + + let dependencies = discover_configured(&known_orgs(), &configured(root.path())); + + assert_eq!(dependencies.len(), 1, "{dependencies:?}"); + assert_eq!(dependencies[0].repository, "appwrite/appwrite"); + assert_eq!(dependencies[0].depends_on, "utopia-php/framework"); + } + + #[test] + fn test_a_configured_home_relative_path_is_scanned_in_the_home_directory() { + let home = TempDir::new().unwrap(); + let checkouts = home.path().join("Local"); + let cloud = checkouts.join("cloud"); + write_manifest( + &cloud, + COMPOSER_MANIFEST, + serde_json::json!({ + "name": "appwrite/cloud", + "require": { "utopia-php/database": "^1.0" }, + }), + ); + + let dependencies = + discover_configured_in(&known_orgs(), &["~/Local".to_string()], Some(home.path())); + + assert_eq!(dependencies.len(), 1, "{dependencies:?}"); + assert_eq!(dependencies[0].depends_on, "utopia-php/database"); + assert_eq!(dependencies[0].repository_path, cloud); + } +} diff --git a/crates/claudear-analysis/src/repo/discovery.rs b/crates/claudear-analysis/src/repo/discovery.rs deleted file mode 100644 index 3f3bf0ca..00000000 --- a/crates/claudear-analysis/src/repo/discovery.rs +++ /dev/null @@ -1,551 +0,0 @@ -//! Auto-discovery of repository dependencies from package manifests. - -use claudear_core::error::Result; -use serde::Deserialize; -use std::collections::HashSet; -use std::fs; -use std::path::Path; - -/// Discovered dependency information. -#[derive(Debug, Clone)] -pub struct DiscoveredDependency { - /// The repository that has the dependency. - pub repo: String, - /// The dependency name (org/package format). - pub depends_on: String, - /// Type of dependency (composer, npm). - pub dep_type: String, - /// Local path where the repo was found. - pub repo_path: String, -} - -/// Scans directories for dependencies from known organizations. -pub struct DependencyDiscovery { - known_orgs: HashSet, -} - -impl DependencyDiscovery { - /// Create a new discovery scanner with the given known organizations. - pub fn new(known_orgs: Vec) -> Self { - Self { - known_orgs: known_orgs.into_iter().collect(), - } - } - - /// Check if a package name belongs to a known organization. - fn is_known_org(&self, package_name: &str) -> bool { - if let Some(org) = package_name.split('/').next() { - self.known_orgs.contains(org) - } else { - false - } - } - - /// Scan a single directory for dependencies. - pub fn scan_directory(&self, path: &Path) -> Result> { - let mut deps = Vec::new(); - - // Try to determine repo name from composer.json or package.json - let repo_name = self.get_repo_name(path); - - if let Some(ref name) = repo_name { - // Scan composer.json - let composer_path = path.join("composer.json"); - if composer_path.exists() { - if let Ok(composer_deps) = self.scan_composer(&composer_path, name) { - deps.extend(composer_deps); - } - } - - // Scan package.json - let package_path = path.join("package.json"); - if package_path.exists() { - if let Ok(npm_deps) = self.scan_package_json(&package_path, name) { - deps.extend(npm_deps); - } - } - } - - Ok(deps) - } - - /// Scan multiple directories and return all discovered dependencies. - pub fn scan_directories(&self, paths: &[String]) -> Result> { - let mut all_deps = Vec::new(); - - for path_str in paths { - let path = Path::new(path_str); - - // If path is a directory containing multiple repos (like ~/Local) - // scan each subdirectory - if path.is_dir() { - // First try scanning the path itself - if let Ok(deps) = self.scan_directory(path) { - if !deps.is_empty() { - all_deps.extend(deps); - continue; - } - } - - // Otherwise scan subdirectories - if let Ok(entries) = fs::read_dir(path) { - for entry in entries.flatten() { - let entry_path = entry.path(); - if entry_path.is_dir() { - if let Ok(deps) = self.scan_directory(&entry_path) { - all_deps.extend(deps); - } - } - } - } - } - } - - Ok(all_deps) - } - - /// Get the repository name from package manifests. - fn get_repo_name(&self, path: &Path) -> Option { - // Try composer.json first - let composer_path = path.join("composer.json"); - if composer_path.exists() { - if let Ok(content) = fs::read_to_string(&composer_path) { - if let Ok(composer) = serde_json::from_str::(&content) { - if let Some(name) = composer.name { - return Some(name); - } - } - } - } - - // Try package.json - let package_path = path.join("package.json"); - if package_path.exists() { - if let Ok(content) = fs::read_to_string(&package_path) { - if let Ok(package) = serde_json::from_str::(&content) { - if let Some(name) = package.name { - // npm packages might be @org/package, convert to org/package - return Some(name.trim_start_matches('@').to_string()); - } - } - } - } - - // Fall back to directory name - path.file_name() - .and_then(|n| n.to_str()) - .map(|s| s.to_string()) - } - - /// Scan composer.json for dependencies. - fn scan_composer(&self, path: &Path, repo_name: &str) -> Result> { - let content = fs::read_to_string(path)?; - let composer: ComposerJson = serde_json::from_str(&content)?; - - let mut deps = Vec::new(); - let repo_path = path - .parent() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_default(); - - // Process require - if let Some(require) = composer.require { - for (package, _version) in require { - if self.is_known_org(&package) { - deps.push(DiscoveredDependency { - repo: repo_name.to_string(), - depends_on: package, - dep_type: "composer".to_string(), - repo_path: repo_path.clone(), - }); - } - } - } - - // Process require-dev - if let Some(require_dev) = composer.require_dev { - for (package, _version) in require_dev { - if self.is_known_org(&package) { - deps.push(DiscoveredDependency { - repo: repo_name.to_string(), - depends_on: package, - dep_type: "composer".to_string(), - repo_path: repo_path.clone(), - }); - } - } - } - - Ok(deps) - } - - /// Scan package.json for dependencies. - fn scan_package_json(&self, path: &Path, repo_name: &str) -> Result> { - let content = fs::read_to_string(path)?; - let package: PackageJson = serde_json::from_str(&content)?; - - let mut deps = Vec::new(); - let repo_path = path - .parent() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_default(); - - // Process dependencies - if let Some(dependencies) = package.dependencies { - for (package_name, _version) in dependencies { - // npm scoped packages: @org/package -> org/package - let normalized = package_name.trim_start_matches('@').to_string(); - if self.is_known_org(&normalized) { - deps.push(DiscoveredDependency { - repo: repo_name.to_string(), - depends_on: normalized, - dep_type: "npm".to_string(), - repo_path: repo_path.clone(), - }); - } - } - } - - // Process devDependencies - if let Some(dev_dependencies) = package.dev_dependencies { - for (package_name, _version) in dev_dependencies { - let normalized = package_name.trim_start_matches('@').to_string(); - if self.is_known_org(&normalized) { - deps.push(DiscoveredDependency { - repo: repo_name.to_string(), - depends_on: normalized, - dep_type: "npm".to_string(), - repo_path: repo_path.clone(), - }); - } - } - } - - Ok(deps) - } -} - -#[derive(Deserialize)] -struct ComposerJson { - name: Option, - require: Option>, - #[serde(rename = "require-dev")] - require_dev: Option>, -} - -#[derive(Deserialize)] -struct PackageJson { - name: Option, - dependencies: Option>, - #[serde(rename = "devDependencies")] - dev_dependencies: Option>, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_is_known_org() { - let discovery = - DependencyDiscovery::new(vec!["utopia-php".to_string(), "appwrite".to_string()]); - - assert!(discovery.is_known_org("utopia-php/database")); - assert!(discovery.is_known_org("appwrite/sdk")); - assert!(!discovery.is_known_org("symfony/console")); - assert!(!discovery.is_known_org("laravel/framework")); - } - - #[test] - fn test_normalize_npm_scope() { - let name = "@appwrite/sdk"; - let normalized = name.trim_start_matches('@').to_string(); - assert_eq!(normalized, "appwrite/sdk"); - } - - #[test] - fn test_is_known_org_no_slash() { - let discovery = DependencyDiscovery::new(vec!["utopia-php".to_string()]); - // Package without slash: split('/').next() returns the whole string, - // which matches "utopia-php" in known_orgs - assert!(discovery.is_known_org("utopia-php")); - } - - #[test] - fn test_is_known_org_empty_string() { - let discovery = DependencyDiscovery::new(vec!["utopia-php".to_string()]); - assert!(!discovery.is_known_org("")); - } - - #[test] - fn test_is_known_org_empty_orgs() { - let discovery = DependencyDiscovery::new(vec![]); - assert!(!discovery.is_known_org("utopia-php/database")); - } - - #[test] - fn test_is_known_org_case_sensitive() { - let discovery = DependencyDiscovery::new(vec!["Appwrite".to_string()]); - assert!(discovery.is_known_org("Appwrite/sdk")); - assert!(!discovery.is_known_org("appwrite/sdk")); - } - - #[test] - fn test_scan_directory_no_manifests() { - let temp = tempfile::TempDir::new().unwrap(); - let discovery = DependencyDiscovery::new(vec!["utopia-php".to_string()]); - let deps = discovery.scan_directory(temp.path()).unwrap(); - assert!(deps.is_empty()); - } - - #[test] - fn test_scan_directory_composer_json() { - let temp = tempfile::TempDir::new().unwrap(); - let composer = serde_json::json!({ - "name": "appwrite/cloud", - "require": { - "utopia-php/database": "^1.0", - "symfony/console": "^5.0" - } - }); - std::fs::write( - temp.path().join("composer.json"), - serde_json::to_string(&composer).unwrap(), - ) - .unwrap(); - - let discovery = DependencyDiscovery::new(vec!["utopia-php".to_string()]); - let deps = discovery.scan_directory(temp.path()).unwrap(); - assert_eq!(deps.len(), 1); - assert_eq!(deps[0].depends_on, "utopia-php/database"); - assert_eq!(deps[0].repo, "appwrite/cloud"); - assert_eq!(deps[0].dep_type, "composer"); - } - - #[test] - fn test_scan_directory_composer_require_dev() { - let temp = tempfile::TempDir::new().unwrap(); - let composer = serde_json::json!({ - "name": "appwrite/cloud", - "require-dev": { - "utopia-php/testing": "^1.0" - } - }); - std::fs::write( - temp.path().join("composer.json"), - serde_json::to_string(&composer).unwrap(), - ) - .unwrap(); - - let discovery = DependencyDiscovery::new(vec!["utopia-php".to_string()]); - let deps = discovery.scan_directory(temp.path()).unwrap(); - assert_eq!(deps.len(), 1); - assert_eq!(deps[0].depends_on, "utopia-php/testing"); - } - - #[test] - fn test_scan_directory_package_json() { - let temp = tempfile::TempDir::new().unwrap(); - let package = serde_json::json!({ - "name": "@appwrite/console", - "dependencies": { - "@appwrite/sdk": "^1.0", - "react": "^18.0" - } - }); - std::fs::write( - temp.path().join("package.json"), - serde_json::to_string(&package).unwrap(), - ) - .unwrap(); - - let discovery = DependencyDiscovery::new(vec!["appwrite".to_string()]); - let deps = discovery.scan_directory(temp.path()).unwrap(); - assert_eq!(deps.len(), 1); - assert_eq!(deps[0].depends_on, "appwrite/sdk"); - assert_eq!(deps[0].dep_type, "npm"); - } - - #[test] - fn test_scan_directory_package_json_dev_deps() { - let temp = tempfile::TempDir::new().unwrap(); - let package = serde_json::json!({ - "name": "my-app", - "devDependencies": { - "@appwrite/testing": "^1.0" - } - }); - std::fs::write( - temp.path().join("package.json"), - serde_json::to_string(&package).unwrap(), - ) - .unwrap(); - - let discovery = DependencyDiscovery::new(vec!["appwrite".to_string()]); - let deps = discovery.scan_directory(temp.path()).unwrap(); - assert_eq!(deps.len(), 1); - assert_eq!(deps[0].depends_on, "appwrite/testing"); - } - - #[test] - fn test_scan_directory_malformed_json() { - let temp = tempfile::TempDir::new().unwrap(); - std::fs::write(temp.path().join("composer.json"), "{ invalid json }").unwrap(); - - let discovery = DependencyDiscovery::new(vec!["utopia-php".to_string()]); - // Should not error out - gracefully handles malformed JSON - let deps = discovery.scan_directory(temp.path()).unwrap(); - assert!(deps.is_empty()); - } - - #[test] - fn test_scan_directory_composer_no_name() { - let temp = tempfile::TempDir::new().unwrap(); - let composer = serde_json::json!({ - "require": { - "utopia-php/database": "^1.0" - } - }); - std::fs::write( - temp.path().join("composer.json"), - serde_json::to_string(&composer).unwrap(), - ) - .unwrap(); - - let discovery = DependencyDiscovery::new(vec!["utopia-php".to_string()]); - let deps = discovery.scan_directory(temp.path()).unwrap(); - // Falls back to directory name for repo name - assert_eq!(deps.len(), 1); - assert_eq!(deps[0].depends_on, "utopia-php/database"); - } - - #[test] - fn test_scan_directory_empty_require() { - let temp = tempfile::TempDir::new().unwrap(); - let composer = serde_json::json!({ - "name": "myapp", - "require": {} - }); - std::fs::write( - temp.path().join("composer.json"), - serde_json::to_string(&composer).unwrap(), - ) - .unwrap(); - - let discovery = DependencyDiscovery::new(vec!["utopia-php".to_string()]); - let deps = discovery.scan_directory(temp.path()).unwrap(); - assert!(deps.is_empty()); - } - - #[test] - fn test_scan_directories_empty_paths() { - let discovery = DependencyDiscovery::new(vec!["utopia-php".to_string()]); - let deps = discovery.scan_directories(&[]).unwrap(); - assert!(deps.is_empty()); - } - - #[test] - fn test_scan_directories_nonexistent_path() { - let discovery = DependencyDiscovery::new(vec!["utopia-php".to_string()]); - let deps = discovery - .scan_directories(&["/nonexistent/path".to_string()]) - .unwrap(); - assert!(deps.is_empty()); - } - - #[test] - fn test_scan_directories_scans_subdirectories() { - let temp = tempfile::TempDir::new().unwrap(); - - // Create a subdirectory with a composer.json - let sub = temp.path().join("my-project"); - std::fs::create_dir(&sub).unwrap(); - let composer = serde_json::json!({ - "name": "my-project", - "require": { - "utopia-php/database": "^1.0" - } - }); - std::fs::write( - sub.join("composer.json"), - serde_json::to_string(&composer).unwrap(), - ) - .unwrap(); - - let discovery = DependencyDiscovery::new(vec!["utopia-php".to_string()]); - let deps = discovery - .scan_directories(&[temp.path().to_string_lossy().to_string()]) - .unwrap(); - assert_eq!(deps.len(), 1); - assert_eq!(deps[0].depends_on, "utopia-php/database"); - } - - #[test] - fn test_get_repo_name_fallback_to_dir_name() { - let temp = tempfile::TempDir::new().unwrap(); - // No manifests - falls back to directory name - let discovery = DependencyDiscovery::new(vec![]); - let name = discovery.get_repo_name(temp.path()); - assert!(name.is_some()); - // Should be the temp dir's basename - } - - #[test] - fn test_get_repo_name_prefers_composer_over_package() { - let temp = tempfile::TempDir::new().unwrap(); - std::fs::write( - temp.path().join("composer.json"), - serde_json::json!({"name": "composer-name"}).to_string(), - ) - .unwrap(); - std::fs::write( - temp.path().join("package.json"), - serde_json::json!({"name": "package-name"}).to_string(), - ) - .unwrap(); - - let discovery = DependencyDiscovery::new(vec![]); - let name = discovery.get_repo_name(temp.path()); - assert_eq!(name, Some("composer-name".to_string())); - } - - #[test] - fn test_get_repo_name_npm_scope_stripped() { - let temp = tempfile::TempDir::new().unwrap(); - std::fs::write( - temp.path().join("package.json"), - serde_json::json!({"name": "@org/package"}).to_string(), - ) - .unwrap(); - - let discovery = DependencyDiscovery::new(vec![]); - let name = discovery.get_repo_name(temp.path()); - assert_eq!(name, Some("org/package".to_string())); - } - - #[test] - fn test_discovered_dependency_fields() { - let dep = DiscoveredDependency { - repo: "my-app".to_string(), - depends_on: "utopia-php/database".to_string(), - dep_type: "composer".to_string(), - repo_path: "/path/to/app".to_string(), - }; - assert_eq!(dep.repo, "my-app"); - assert_eq!(dep.depends_on, "utopia-php/database"); - assert_eq!(dep.dep_type, "composer"); - assert_eq!(dep.repo_path, "/path/to/app"); - } - - #[test] - fn test_discovered_dependency_clone() { - let dep = DiscoveredDependency { - repo: "app".to_string(), - depends_on: "lib".to_string(), - dep_type: "npm".to_string(), - repo_path: "/p".to_string(), - }; - let cloned = dep.clone(); - assert_eq!(cloned.repo, "app"); - } -} diff --git a/crates/claudear-analysis/src/repo/index.rs b/crates/claudear-analysis/src/repo/index.rs index e916a315..f1c1df4b 100644 --- a/crates/claudear-analysis/src/repo/index.rs +++ b/crates/claudear-analysis/src/repo/index.rs @@ -6,12 +6,14 @@ //! Note: GitHub/GitLab-specific index building functions live in the root crate's //! `repo::index` module since they depend on SCM provider types. -pub use claudear_core::types::{IndexedRepo, RepoIndex}; +pub use claudear_core::types::IndexedRepo; +pub use claudear_core::types::RepoIndex; use super::GitOps; use claudear_core::error::Result; use std::collections::HashSet; -use std::path::{Path, PathBuf}; +use std::path::Path; +use std::path::PathBuf; use walkdir::WalkDir; /// Build a repo index by scanning filesystem paths for repos from known orgs. @@ -78,16 +80,30 @@ pub fn index_repo_files(index: &mut RepoIndex, repo_name: &str) -> Option Some(file_count) } -/// Expand ~ to home directory. +/// How a configured path names the current user's home directory. +const HOME: &str = "~"; + +/// `path` with a leading `~` or `~/` resolved against the current user's home. fn expand_path(path: &str) -> PathBuf { - if let Some(stripped) = path.strip_prefix('~') { - if let Some(home) = dirs::home_dir() { - // Strip the leading / if present (e.g., ~/foo -> foo, ~ -> empty) - let suffix = stripped.strip_prefix('/').unwrap_or(stripped); - return home.join(suffix); - } + expand_path_in(path, dirs::home_dir().as_deref()) +} + +/// `path` with a leading `~` or `~/` resolved against `home`. Another user's +/// home (`~user/...`) cannot be resolved this way and is left as written. +pub(crate) fn expand_path_in(path: &str, home: Option<&Path>) -> PathBuf { + let Some(home) = home else { + return PathBuf::from(path); + }; + if path == HOME { + return home.to_path_buf(); + } + match path + .strip_prefix(HOME) + .and_then(|rest| rest.strip_prefix('/')) + { + Some(relative) => home.join(relative.trim_start_matches('/')), + None => PathBuf::from(path), } - PathBuf::from(path) } /// Check if a directory entry is hidden. @@ -208,10 +224,24 @@ mod tests { #[test] fn test_expand_path_home() { - let expanded = expand_path("~/test"); - if let Some(home) = dirs::home_dir() { - assert_eq!(expanded, home.join("test")); - } + let home = Path::new("/home/someone"); + assert_eq!(expand_path_in("~", Some(home)), home); + assert_eq!(expand_path_in("~/test", Some(home)), home.join("test")); + assert_eq!(expand_path_in("~//test", Some(home)), home.join("test")); + } + + #[test] + fn test_expand_path_leaves_another_users_home_as_written() { + let home = Path::new("/home/someone"); + assert_eq!( + expand_path_in("~other/projects", Some(home)), + PathBuf::from("~other/projects") + ); + } + + #[test] + fn test_expand_path_without_a_home_leaves_the_path_as_written() { + assert_eq!(expand_path_in("~/test", None), PathBuf::from("~/test")); } #[test] diff --git a/crates/claudear-analysis/src/repo/mod.rs b/crates/claudear-analysis/src/repo/mod.rs index 66e177c3..311b53d8 100644 --- a/crates/claudear-analysis/src/repo/mod.rs +++ b/crates/claudear-analysis/src/repo/mod.rs @@ -3,15 +3,25 @@ //! Provides git operations, dependency tracking, cascading changes, and repository indexing. pub mod code_index; -mod discovery; +mod dependencies; mod git; pub(crate) mod index; mod relationships; -pub use claudear_core::types::{IndexedRepo, RepoIndex}; -pub use discovery::{DependencyDiscovery, DiscoveredDependency}; -pub use git::{worktree_path, GitOps}; -pub use index::{build_repo_index, index_repo_files}; -pub use relationships::{ - CascadingChange, DependencyGraph, DependencyType, RepoRelationships, Repository, -}; +pub use abnegate_vcs::DependencyDiscovery; +pub use abnegate_vcs::DiscoveredDependency; +pub use abnegate_vcs::Manifest; +pub use claudear_core::types::IndexedRepo; +pub use claudear_core::types::RepoIndex; +pub use dependencies::discover_configured; +pub use dependencies::save_discovered; +pub use git::worktree_path; +pub use git::GitOps; +pub use index::build_repo_index; +pub use index::index_repo_files; +pub use relationships::dependency_type; +pub use relationships::CascadingChange; +pub use relationships::DependencyGraph; +pub use relationships::DependencyType; +pub use relationships::RepoRelationships; +pub use relationships::Repository; diff --git a/crates/claudear-analysis/src/repo/relationships.rs b/crates/claudear-analysis/src/repo/relationships.rs index 04474b4d..e64b7dc2 100644 --- a/crates/claudear-analysis/src/repo/relationships.rs +++ b/crates/claudear-analysis/src/repo/relationships.rs @@ -1,9 +1,13 @@ //! Repository dependency tracking and relationships. -use chrono::{DateTime, Utc}; +use abnegate_vcs::Manifest; +use chrono::DateTime; +use chrono::Utc; use claudear_core::error::Result; -use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet}; +use serde::Deserialize; +use serde::Serialize; +use std::collections::HashMap; +use std::collections::HashSet; /// Type of dependency between repositories. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -41,6 +45,15 @@ impl DependencyType { } } +/// The dependency type a package manager's manifest declares, if claudear cascades through it. +pub fn dependency_type(manifest: Manifest) -> Option { + match manifest { + Manifest::Composer => Some(DependencyType::Composer), + Manifest::Npm => Some(DependencyType::Npm), + _ => None, + } +} + /// A repository in the dependency graph. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Repository { @@ -520,6 +533,15 @@ impl Default for RepoRelationships { mod tests { use super::*; + #[test] + fn test_every_manifest_maps_to_a_dependency_type_stored_as_it_parses_back() { + for manifest in [Manifest::Composer, Manifest::Npm] { + let kind = dependency_type(manifest).expect("every known manifest cascades"); + assert_eq!(kind.as_str(), manifest.as_str()); + assert_eq!(DependencyType::parse(kind.as_str()), Some(kind)); + } + } + #[test] fn test_dependency_type_parse() { assert_eq!(DependencyType::parse("npm"), Some(DependencyType::Npm)); diff --git a/crates/claudear-engine/src/watcher.rs b/crates/claudear-engine/src/watcher.rs index f6fcbaec..d182393f 100644 --- a/crates/claudear-engine/src/watcher.rs +++ b/crates/claudear-engine/src/watcher.rs @@ -882,9 +882,8 @@ impl Watcher { /// Discover dependencies between indexed repos and save them to the database. pub async fn discover_dependencies(&self) { - let inferrer = match &self.inferrer { - Some(inf) => inf, - None => return, + let Some(inferrer) = &self.inferrer else { + return; }; let known_orgs = self.config.known_orgs.clone(); @@ -892,54 +891,41 @@ impl Watcher { return; } - let repo_paths: Vec = match inferrer.with_index(|index| { + let repository_paths: Vec<_> = match inferrer.with_index(|index| { Ok(index .list() .iter() - .map(|r| r.path.to_string_lossy().to_string()) + .map(|repository| repository.path.clone()) .collect()) }) { Ok(paths) => paths, - Err(e) => { - tracing::warn!("Failed to get repo paths for dependency discovery: {}", e); + Err(error) => { + tracing::warn!( + "Failed to get repo paths for dependency discovery: {}", + error + ); return; } }; - if repo_paths.is_empty() { + if repository_paths.is_empty() { return; } let tracker = self.tracker.clone(); - let result = tokio::task::spawn_blocking(move || -> claudear_core::error::Result { - let discovery = claudear_analysis::repo::DependencyDiscovery::new(known_orgs); - let discovered = discovery.scan_directories(&repo_paths)?; - let mut count = 0; - for dep in &discovered { - if let Err(e) = tracker.add_dependency(&dep.depends_on, &dep.repo, &dep.dep_type) { - tracing::warn!( - error = %e, - upstream = %dep.depends_on, - downstream = %dep.repo, - "Failed to save dependency" - ); - } else { - count += 1; - } - } - Ok(count) + let result = tokio::task::spawn_blocking(move || { + let discovered = claudear_analysis::repo::DependencyDiscovery::new(known_orgs) + .scan_directories(&repository_paths); + claudear_analysis::repo::save_discovered(tracker.as_ref(), &discovered) }) .await; match result { - Ok(Ok(count)) if count > 0 => { + Ok(count) if count > 0 => { tracing::info!("Discovered and saved {} dependencies", count); } - Ok(Err(e)) => { - tracing::warn!("Dependency discovery failed: {}", e); - } - Err(e) => { - tracing::warn!("Dependency discovery task panicked: {}", e); + Err(error) => { + tracing::warn!("Dependency discovery task panicked: {}", error); } _ => {} } @@ -17724,6 +17710,45 @@ mod tests { watcher.discover_dependencies().await; } + #[tokio::test] + async fn test_discover_dependencies_stores_what_the_cascade_loads() { + use claudear_analysis::repo::DependencyType; + + let checkout = tempfile::tempdir().unwrap(); + std::fs::write( + checkout.path().join("composer.json"), + serde_json::json!({ + "name": "appwrite/cloud", + "require": { "utopia-php/database": "^1.0", "symfony/console": "^5.0" }, + "require-dev": { "utopia-php/database": "^1.0" }, + }) + .to_string(), + ) + .unwrap(); + let tracker = Arc::new(SqliteTracker::in_memory().unwrap()); + let mut config = test_config(); + config.known_orgs = vec!["utopia-php".to_string()]; + let watcher = Watcher::new(WatcherOptions { + config, + inferrer: Some(inferrer_indexing(IndexedRepo::new( + "appwrite/cloud", + checkout.path(), + ))), + ..live_watcher_options(Arc::new(MockNotifier::new(true)), tracker.clone(), vec![]) + }); + + watcher.discover_dependencies().await; + + let stored = tracker.list_all_dependencies().unwrap(); + assert_eq!(stored.len(), 1, "{stored:?}"); + assert_eq!(stored[0].upstream, "utopia-php/database"); + assert_eq!(stored[0].downstream, "appwrite/cloud"); + assert_eq!( + DependencyType::parse(&stored[0].dep_type), + Some(DependencyType::Composer) + ); + } + #[tokio::test] async fn test_pull_and_reindex_all_repos_no_inferrer() { let notifier = Arc::new(MockNotifier::new(true)); diff --git a/src/main.rs b/src/main.rs index e5c0f3d4..aa3fe633 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2344,7 +2344,7 @@ async fn async_main(cli: Cli) -> anyhow::Result<()> { // Handle Repos commands early (don't need sources or repository validation) if let Commands::Repos(ref repos_cmd) = cli.command { - let db_tracker = SqliteTracker::new(&config.db_path)?; + let tracker = SqliteTracker::new(&config.db_path)?; /// Guard that calls `finish_indexing_progress` on drop so error paths /// (via `?`) never leave the DB stuck in "running" status. @@ -2360,7 +2360,7 @@ async fn async_main(cli: Cli) -> anyhow::Result<()> { match repos_cmd { ReposCommands::List => { // First show indexed repos from the database - let indexed_repos = db_tracker.list_indexed_repos()?; + let indexed_repos = tracker.list_indexed_repos()?; if indexed_repos.is_empty() { println!("\nNo indexed repositories."); @@ -2387,8 +2387,11 @@ async fn async_main(cli: Cli) -> anyhow::Result<()> { } ReposCommands::Index { force } => { - use claudear::DependencyDiscovery; - use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; + use claudear::repo::discover_configured; + use claudear::repo::save_discovered; + use indicatif::MultiProgress; + use indicatif::ProgressBar; + use indicatif::ProgressStyle; println!("\nBuilding repository index..."); println!(" Known orgs: {:?}", config.known_orgs); @@ -2411,10 +2414,8 @@ async fn async_main(cli: Cli) -> anyhow::Result<()> { let total_repos = repos.len(); // Track progress in DB for dashboard - let _ = db_tracker.start_indexing_progress(total_repos); - let _indexing_guard = IndexingGuard { - tracker: &db_tracker, - }; + let _ = tracker.start_indexing_progress(total_repos); + let _indexing_guard = IndexingGuard { tracker: &tracker }; let multi = MultiProgress::new(); @@ -2444,7 +2445,7 @@ async fn async_main(cli: Cli) -> anyhow::Result<()> { for repo in &repos { // Check if already indexed (skip unless force) if !force { - if let Ok(Some(_)) = db_tracker.get_indexed_repo(&repo.name) { + if let Ok(Some(_)) = tracker.get_indexed_repo(&repo.name) { skipped_count += 1; overall_pb.inc(1); overall_pb.set_message(format!( @@ -2452,7 +2453,7 @@ async fn async_main(cli: Cli) -> anyhow::Result<()> { saved_count, skipped_count )); // Notify dashboard so progress bar doesn't jump - let _ = db_tracker.update_indexing_progress( + let _ = tracker.update_indexing_progress( saved_count + skipped_count, &repo.name, 0, @@ -2468,7 +2469,7 @@ async fn async_main(cli: Cli) -> anyhow::Result<()> { repo_pb.set_prefix(repo.name.clone()); // Update dashboard progress - let _ = db_tracker.update_indexing_progress( + let _ = tracker.update_indexing_progress( saved_count + skipped_count, &repo.name, repo.files.len(), @@ -2476,7 +2477,7 @@ async fn async_main(cli: Cli) -> anyhow::Result<()> { ); // Save repo metadata - let repo_id = db_tracker.save_indexed_repo( + let repo_id = tracker.save_indexed_repo( &repo.name, &repo.path.to_string_lossy(), Some(repo.scm_url.as_str()), @@ -2497,7 +2498,7 @@ async fn async_main(cli: Cli) -> anyhow::Result<()> { .collect(); // Save file index - db_tracker.save_repo_files(repo_id, &files_with_types)?; + tracker.save_repo_files(repo_id, &files_with_types)?; total_files_saved += repo.files.len(); repo_pb.set_position(repo.files.len() as u64); @@ -2520,45 +2521,26 @@ async fn async_main(cli: Cli) -> anyhow::Result<()> { ); // Auto-discover dependencies between indexed repos - let dep_pb = multi.add(ProgressBar::new_spinner()); - dep_pb.set_style( + let dependency_progress = multi.add(ProgressBar::new_spinner()); + dependency_progress.set_style( ProgressStyle::with_template("{spinner:.blue} {msg}") .unwrap() .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"), ); - dep_pb.set_message("Discovering dependencies..."); - dep_pb.enable_steady_tick(std::time::Duration::from_millis(80)); - - let discovery = DependencyDiscovery::new(config.known_orgs.clone()); - match discovery.scan_directories(&config.auto_discover_paths) { - Ok(discovered) if !discovered.is_empty() => { - let mut dep_count = 0; - for dep in &discovered { - if let Err(e) = - db_tracker.add_dependency(&dep.depends_on, &dep.repo, &dep.dep_type) - { - tracing::warn!( - error = %e, - upstream = %dep.depends_on, - downstream = %dep.repo, - "Failed to save dependency" - ); - } else { - dep_count += 1; - } - } - dep_pb.finish_with_message(format!( - "Discovered and saved {} dependencies", - dep_count - )); - } - Ok(_) => { - dep_pb.finish_with_message("No dependencies found between indexed repos.") - } - Err(e) => dep_pb.finish_with_message(format!( - "Warning: dependency discovery failed: {}", - e - )), + dependency_progress.set_message("Discovering dependencies..."); + dependency_progress.enable_steady_tick(std::time::Duration::from_millis(80)); + + let discovered = + discover_configured(&config.known_orgs, &config.auto_discover_paths); + if discovered.is_empty() { + dependency_progress + .finish_with_message("No dependencies found under auto_discover_paths."); + } else { + let saved = save_discovered(&tracker, &discovered); + dependency_progress.finish_with_message(format!( + "Discovered and saved {} dependencies", + saved + )); } // Guard will call finish_indexing_progress() on drop @@ -2589,7 +2571,7 @@ async fn async_main(cli: Cli) -> anyhow::Result<()> { } ReposCommands::Stats => { - let stats = db_tracker.get_index_stats()?; + let stats = tracker.get_index_stats()?; println!("\nRepository Index Statistics:"); println!(" Total repositories: {}", stats.repo_count); @@ -2607,7 +2589,7 @@ async fn async_main(cli: Cli) -> anyhow::Result<()> { } // Also show inference stats if available - if let Ok(inference_stats) = db_tracker.get_inference_stats() { + if let Ok(inference_stats) = tracker.get_inference_stats() { println!("\n Inference Statistics:"); println!(" Total attempts: {}", inference_stats.total_attempts); if inference_stats.total_attempts > 0 { @@ -2640,7 +2622,7 @@ async fn async_main(cli: Cli) -> anyhow::Result<()> { downstream, dep_type, } => { - db_tracker.add_dependency(upstream, downstream, dep_type)?; + tracker.add_dependency(upstream, downstream, dep_type)?; println!( "Linked: {} depends on {} ({})", downstream, upstream, dep_type @@ -2653,7 +2635,7 @@ async fn async_main(cli: Cli) -> anyhow::Result<()> { let mut manager = RepoRelationships::with_defaults(); // Add DB dependencies to the manager - let db_deps = db_tracker.list_all_dependencies()?; + let db_deps = tracker.list_all_dependencies()?; for dep in db_deps { let dtype = DependencyType::parse(&dep.dep_type).unwrap_or(DependencyType::Manual); @@ -2673,8 +2655,8 @@ async fn async_main(cli: Cli) -> anyhow::Result<()> { ReposCommands::Cascade { repo } => { // Query DB directly for dependants - let direct = db_tracker.get_direct_dependants(repo)?; - let all = db_tracker.get_all_dependants(repo)?; + let direct = tracker.get_direct_dependants(repo)?; + let all = tracker.get_all_dependants(repo)?; println!("\n=== Cascading Changes from {} ===\n", repo); @@ -2698,13 +2680,13 @@ async fn async_main(cli: Cli) -> anyhow::Result<()> { } ReposCommands::Discover { paths, save, clear } => { - use claudear::DependencyDiscovery; + use claudear::repo::dependency_type; + use claudear::repo::discover_configured; - // Use provided paths or fall back to config let scan_paths = if paths.is_empty() { - config.auto_discover_paths.clone() + &config.auto_discover_paths } else { - paths.clone() + paths }; if scan_paths.is_empty() { @@ -2718,51 +2700,65 @@ async fn async_main(cli: Cli) -> anyhow::Result<()> { println!("Known orgs: {:?}", config.known_orgs); println!("Scanning: {:?}\n", scan_paths); - let discovery = DependencyDiscovery::new(config.known_orgs.clone()); - let discovered = discovery.scan_directories(&scan_paths)?; + let discovered = discover_configured(&config.known_orgs, scan_paths); if discovered.is_empty() { println!("No dependencies found from known organizations."); return Ok(()); } - // Group by repo for display - let mut by_repo: std::collections::HashMap> = + let mut by_repository: std::collections::HashMap<&str, Vec<_>> = std::collections::HashMap::new(); - for dep in &discovered { - by_repo.entry(dep.repo.clone()).or_default().push(dep); + for dependency in &discovered { + by_repository + .entry(dependency.repository.as_str()) + .or_default() + .push(dependency); } println!( "Discovered {} dependencies across {} repositories:\n", discovered.len(), - by_repo.len() + by_repository.len() ); - for (repo, deps) in &by_repo { - println!(" {}", repo); - for dep in deps { - println!(" -> {} ({})", dep.depends_on, dep.dep_type); + for (repository, dependencies) in &by_repository { + println!(" {}", repository); + for dependency in dependencies { + println!(" -> {} ({})", dependency.depends_on, dependency.manifest); } } if *save { if *clear { println!("\nClearing existing dependencies..."); - db_tracker.clear_repositories()?; + tracker.clear_repositories()?; } println!("\nSaving to database..."); - for dep in &discovered { - // Add repo with its path - db_tracker.upsert_repository(&dep.repo, Some(&dep.repo_path), None)?; - // Add dependency - db_tracker.add_dependency(&dep.depends_on, &dep.repo, &dep.dep_type)?; + let mut saved = 0; + for dependency in &discovered { + let Some(kind) = dependency_type(dependency.manifest) else { + tracing::warn!( + manifest = %dependency.manifest, + upstream = %dependency.depends_on, + downstream = %dependency.repository, + "Skipped a dependency from a manifest cascades do not follow" + ); + continue; + }; + tracker.upsert_repository( + &dependency.repository, + Some(&dependency.repository_path.to_string_lossy()), + None, + )?; + tracker.add_dependency( + &dependency.depends_on, + &dependency.repository, + kind.as_str(), + )?; + saved += 1; } - println!( - "Saved {} dependencies to {:?}", - discovered.len(), - config.db_path - ); + println!("Saved {} dependencies to {:?}", saved, config.db_path); } else { println!("\nRun with --save to persist to database."); } @@ -2895,45 +2891,26 @@ async fn async_main(cli: Cli) -> anyhow::Result<()> { // Sync to database (includes files by default) let sync_files = !*skip_files; - let synced = db_tracker.sync_from_index(&index, sync_files)?; + let synced = tracker.sync_from_index(&index, sync_files)?; println!("\nSynced {} repository paths to database", synced); if sync_files { // Show stats after file sync - let stats = db_tracker.get_index_stats()?; + let stats = tracker.get_index_stats()?; println!(" Including {} files in database", stats.file_count); } else { println!(" Skipped file lists (use without --skip-files for full indexing)"); } - // Auto-discover dependencies - use claudear::DependencyDiscovery; - let discovery = DependencyDiscovery::new(config.known_orgs.clone()); - match discovery.scan_directories(&config.auto_discover_paths) { - Ok(discovered) if !discovered.is_empty() => { - let mut dep_count = 0; - for dep in &discovered { - if let Err(e) = - db_tracker.add_dependency(&dep.depends_on, &dep.repo, &dep.dep_type) - { - tracing::warn!( - error = %e, - upstream = %dep.depends_on, - downstream = %dep.repo, - "Failed to save dependency" - ); - } else { - dep_count += 1; - } - } - println!(" Discovered and saved {} dependencies", dep_count); - } - Ok(_) => { - println!(" No dependencies found between indexed repos."); - } - Err(e) => { - println!(" Warning: dependency discovery failed: {}", e); - } + use claudear::repo::discover_configured; + use claudear::repo::save_discovered; + let discovered = + discover_configured(&config.known_orgs, &config.auto_discover_paths); + if discovered.is_empty() { + println!(" No dependencies found under auto_discover_paths."); + } else { + let saved = save_discovered(&tracker, &discovered); + println!(" Discovered and saved {} dependencies", saved); } } }