diff --git a/Cargo.lock b/Cargo.lock index 306fc5cf74..3ce0f0c1eb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6027,6 +6027,7 @@ dependencies = [ "tracedecay-code-index", "tracedecay-configuration", "tracedecay-contracts", + "tracedecay-daemon-protocol", "tracedecay-domain", "tracedecay-global-db", "tracedecay-graph-db", @@ -6158,6 +6159,7 @@ dependencies = [ "tracedecay-api", "tracedecay-application", "tracedecay-automation-runtime", + "tracedecay-configuration", "tracedecay-contracts", "tracedecay-daemon-control", "tracedecay-daemon-identity", @@ -6310,8 +6312,10 @@ dependencies = [ name = "tracedecay-configuration" version = "0.1.0" dependencies = [ + "glob", "hex", "hotpath", + "serde", "serde_json", "sha2 0.11.0", "tempfile", @@ -6321,6 +6325,7 @@ dependencies = [ "tracedecay-contracts", "tracedecay-domain", "tracedecay-global-db", + "tracedecay-lcm", "tracedecay-policy", "tracedecay-runtime-core", "tracedecay-semantic-contracts", diff --git a/crates/tracedecay-application/Cargo.toml b/crates/tracedecay-application/Cargo.toml index 40c238d94a..bf99585e5c 100644 --- a/crates/tracedecay-application/Cargo.toml +++ b/crates/tracedecay-application/Cargo.toml @@ -67,6 +67,7 @@ url = "2" zeroize = "1.9.0" tracedecay-contracts = { path = "../tracedecay-contracts", version = "0.1.0" } tracedecay-automation = { path = "../tracedecay-automation", version = "0.1.0" } +tracedecay-daemon-protocol = { path = "../tracedecay-daemon-protocol", version = "0.1.0" } # Grammar-free entry only: `markdown_structure` compiles with no language # feature enabled, so section structure reaches retrieval without linking a # tree-sitter bundle into this crate. diff --git a/crates/tracedecay-application/src/lib.rs b/crates/tracedecay-application/src/lib.rs index e028ad9c4f..1dc75e57a9 100644 --- a/crates/tracedecay-application/src/lib.rs +++ b/crates/tracedecay-application/src/lib.rs @@ -84,6 +84,7 @@ pub mod observation; pub mod operation_stream; pub mod pr_tracking; pub mod primitives; +pub mod project_adoption; pub mod project_open_authorization; pub mod semantic_runtime; pub mod settings_control; diff --git a/crates/tracedecay/src/tracedecay/lifecycle/adoption.rs b/crates/tracedecay-application/src/project_adoption.rs similarity index 68% rename from crates/tracedecay/src/tracedecay/lifecycle/adoption.rs rename to crates/tracedecay-application/src/project_adoption.rs index a192015767..8430c2926d 100644 --- a/crates/tracedecay/src/tracedecay/lifecycle/adoption.rs +++ b/crates/tracedecay-application/src/project_adoption.rs @@ -27,13 +27,10 @@ use std::path::{Path, PathBuf}; use tracedecay_daemon_protocol::MovedStoreAdoption; - use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_global_db::RegisteredGlobalDb; use tracedecay_runtime_core::storage::{self, StoreLayout}; -use super::TraceDecay; - #[derive(Debug, Clone, PartialEq, Eq)] struct MovedNongitCandidate { project_id: String, @@ -51,114 +48,111 @@ enum MovedStoreEvidence { NoMatch, } -impl TraceDecay { - /// Remaps a moved non-git project onto `project_root` under an explicit - /// operator adoption decision. - /// - /// Returns `Ok(None)` when adoption was not requested or there is no - /// moved-store candidate, so first-touch may mint a new identity. - /// Ambiguous or conflicting adoption is a typed refusal, never an alias. - #[hotpath::measure(label = "lifecycle.adopt_moved_nongit", future = true)] - pub(crate) async fn adopt_moved_nongit_project( - project_root: &Path, - profile_root: &Path, - registry: &RegisteredGlobalDb, - adoption: &MovedStoreAdoption, - ) -> Result> { - if matches!(adoption, MovedStoreAdoption::Never) { - return Ok(None); - } - if tracedecay_runtime_core::worktree::git_common_dir(project_root).is_some() { - return Ok(None); - } +/// Remaps a moved non-git project onto `project_root` under an explicit +/// operator adoption decision. +/// +/// Returns `Ok(None)` when adoption was not requested or there is no +/// moved-store candidate, so first-touch may mint a new identity. +/// Ambiguous or conflicting adoption is a typed refusal, never an alias. +#[hotpath::measure(label = "lifecycle.adopt_moved_nongit", future = true)] +pub async fn adopt_moved_nongit_project( + project_root: &Path, + profile_root: &Path, + registry: &RegisteredGlobalDb, + adoption: &MovedStoreAdoption, +) -> Result> { + if matches!(adoption, MovedStoreAdoption::Never) { + return Ok(None); + } + if tracedecay_runtime_core::worktree::git_common_dir(project_root).is_some() { + return Ok(None); + } - let new_root = project_root - .canonicalize() - .map_err(|error| TraceDecayError::Config { - message: format!( - "could not canonicalize moved-project adoption root '{}': {error}", - project_root.display() - ), - })?; + let new_root = project_root + .canonicalize() + .map_err(|error| TraceDecayError::Config { + message: format!( + "could not canonicalize moved-project adoption root '{}': {error}", + project_root.display() + ), + })?; - if let Some(existing) = registry - .project_registry_context_by_alias(&new_root) - .await? - { - return refuse_if_adoption_conflicts(adoption, &existing.project.project_id, &new_root); - } + if let Some(existing) = registry + .project_registry_context_by_alias(&new_root) + .await? + { + return refuse_if_adoption_conflicts(adoption, &existing.project.project_id, &new_root); + } - let candidates = - discover_moved_nongit_candidates(&new_root, profile_root, registry).await?; - let resuming = candidates - .iter() - .filter(|candidate| candidate.records_new_root) - .collect::>(); - let selected = match adoption { - MovedStoreAdoption::Never => return Ok(None), - MovedStoreAdoption::AdoptNamed(requested) => { - match candidates - .iter() - .find(|candidate| &candidate.project_id == requested) - { - Some(candidate) => candidate, - None => { - return Err(TraceDecayError::Config { - message: format!( - "project '{requested}' is not a moved non-git store \ + let candidates = discover_moved_nongit_candidates(&new_root, profile_root, registry).await?; + let resuming = candidates + .iter() + .filter(|candidate| candidate.records_new_root) + .collect::>(); + let selected = match adoption { + MovedStoreAdoption::Never => return Ok(None), + MovedStoreAdoption::AdoptNamed(requested) => { + match candidates + .iter() + .find(|candidate| &candidate.project_id == requested) + { + Some(candidate) => candidate, + None => { + return Err(TraceDecayError::Config { + message: format!( + "project '{requested}' is not a moved non-git store \ that can be adopted at '{}'", - new_root.display() - ), - }); - } + new_root.display() + ), + }); } } - MovedStoreAdoption::AdoptUnique => match (resuming.as_slice(), candidates.as_slice()) { + } + MovedStoreAdoption::AdoptUnique => match (resuming.as_slice(), candidates.as_slice()) { + (_, []) => return Ok(None), + // A store whose manifest already records this exact root is + // positive linkage; it outranks unlinked stale rows. + ([resumable], _) => *resumable, + (_, [candidate]) => candidate, + _ => { + return Err(TraceDecayError::Config { + message: format!( + "moved non-git project adoption at '{}' is ambiguous \ + (candidates: {}); re-run `tracedecay init` with \ + --adopt-project , or with --fresh to mint a \ + new project identity here", + new_root.display(), + candidate_ids(&candidates) + ), + }); + } + }, + MovedStoreAdoption::OfferCandidates => { + match (resuming.as_slice(), candidates.as_slice()) { (_, []) => return Ok(None), - // A store whose manifest already records this exact root is - // positive linkage; it outranks unlinked stale rows. + // Resuming an interrupted remap needs no flag: the store's + // manifest recording this root was written under a previous + // explicit adoption and is the journal record to replay. ([resumable], _) => *resumable, - (_, [candidate]) => candidate, _ => { return Err(TraceDecayError::Config { message: format!( - "moved non-git project adoption at '{}' is ambiguous \ - (candidates: {}); re-run `tracedecay init` with \ - --adopt-project , or with --fresh to mint a \ - new project identity here", - new_root.display(), - candidate_ids(&candidates) - ), - }); - } - }, - MovedStoreAdoption::OfferCandidates => { - match (resuming.as_slice(), candidates.as_slice()) { - (_, []) => return Ok(None), - // Resuming an interrupted remap needs no flag: the store's - // manifest recording this root was written under a previous - // explicit adoption and is the journal record to replay. - ([resumable], _) => *resumable, - _ => { - return Err(TraceDecayError::Config { - message: format!( - "a moved non-git store may belong at '{}' (candidates: {}); \ + "a moved non-git store may belong at '{}' (candidates: {}); \ adoption rebinds a registered project identity and needs an \ explicit choice: re-run `tracedecay init` with \ --adopt-project (or --yes when exactly one \ candidate exists), or with --fresh to mint a new project \ identity here", - new_root.display(), - candidate_ids(&candidates) - ), - }); - } + new_root.display(), + candidate_ids(&candidates) + ), + }); } } - }; + } + }; - remap_moved_nongit_project(&new_root, profile_root, registry, selected).await - } + remap_moved_nongit_project(&new_root, profile_root, registry, selected).await } fn candidate_ids(candidates: &[MovedNongitCandidate]) -> String { @@ -263,15 +257,16 @@ fn moved_store_evidence( } } if layout.config_path.is_file() { - let config = crate::config::load_config_from_path(previous_root, &layout.config_path) - .map_err(|error| TraceDecayError::Config { - message: format!( - "cannot evaluate moved-store adoption evidence from '{}': {error}; \ + let config = + tracedecay_configuration::load_config_from_path(previous_root, &layout.config_path) + .map_err(|error| TraceDecayError::Config { + message: format!( + "cannot evaluate moved-store adoption evidence from '{}': {error}; \ repair or remove the store config, or re-run `tracedecay init` \ with --fresh to mint a new identity without adoption", - layout.config_path.display() - ), - })?; + layout.config_path.display() + ), + })?; let recorded = PathBuf::from(&config.root_dir); if paths_record_same_root(&recorded, new_root) { return Ok(MovedStoreEvidence::RecordsNewRoot); @@ -328,9 +323,10 @@ async fn remap_moved_nongit_project( ), })? .to_owned(); - let mut config = crate::config::load_config_from_path(new_root, &layout.config_path)?; + let mut config = + tracedecay_configuration::load_config_from_path(new_root, &layout.config_path)?; config.root_dir = root_dir; - crate::config::save_config_to_path(&layout.config_path, &config)?; + tracedecay_configuration::save_config_to_path(&layout.config_path, &config)?; } registry .upsert_code_project(&candidate.project_id, new_root, None, None, None) diff --git a/crates/tracedecay-cli/Cargo.toml b/crates/tracedecay-cli/Cargo.toml index 53e5f7b716..e07a24c5ec 100644 --- a/crates/tracedecay-cli/Cargo.toml +++ b/crates/tracedecay-cli/Cargo.toml @@ -163,6 +163,7 @@ tracedecay-agent-hosts = { path = "../tracedecay-agent-hosts", version = "0.1.0" tracedecay-application = { path = "../tracedecay-application", version = "0.1.0" } tracedecay-automation-runtime = { path = "../tracedecay-automation-runtime", version = "0.1.0" } tracedecay-api = { path = "../tracedecay-api", version = "0.1.0" } +tracedecay-configuration = { path = "../tracedecay-configuration", version = "0.1.0" } tracedecay-contracts = { path = "../tracedecay-contracts", version = "0.1.0" } tracedecay-daemon-control = { path = "../tracedecay-daemon-control", version = "0.1.0" } tracedecay-daemon-identity = { path = "../tracedecay-daemon-identity", version = "0.1.0" } diff --git a/crates/tracedecay-cli/src/commands/bench.rs b/crates/tracedecay-cli/src/commands/bench.rs index 1e93f1e13b..bef3dfc0df 100644 --- a/crates/tracedecay-cli/src/commands/bench.rs +++ b/crates/tracedecay-cli/src/commands/bench.rs @@ -8,7 +8,7 @@ pub(crate) async fn handle_bench( max_nodes: usize, ) -> tracedecay_domain::errors::Result<()> { let resolved = - super::scope::resolve_project_scope(tracedecay::config::resolve_path(path)).await?; + super::scope::resolve_project_scope(tracedecay_configuration::resolve_path(path)).await?; let queries_toml = queries .map(std::fs::read_to_string) .transpose() diff --git a/crates/tracedecay-cli/src/commands/branch.rs b/crates/tracedecay-cli/src/commands/branch.rs index d471827e6c..a57e396109 100644 --- a/crates/tracedecay-cli/src/commands/branch.rs +++ b/crates/tracedecay-cli/src/commands/branch.rs @@ -38,9 +38,10 @@ fn handle_branch_action_inner( match action { BranchAction::List { path } => { - let resolved = - super::scope::resolve_project_scope(tracedecay::config::resolve_path(path)) - .await?; + let resolved = super::scope::resolve_project_scope( + tracedecay_configuration::resolve_path(path), + ) + .await?; let status = daemon_tool_json( Some(&resolved.project_path), "tracedecay_status", @@ -189,9 +190,10 @@ fn handle_branch_action_inner( } } BranchAction::Add { name, path } => { - let resolved = - super::scope::resolve_project_scope(tracedecay::config::resolve_path(path)) - .await?; + let resolved = super::scope::resolve_project_scope( + tracedecay_configuration::resolve_path(path), + ) + .await?; let branch_name = match name { Some(n) => n, None => branch::current_branch(&resolved.project_path).ok_or_else(|| { @@ -230,9 +232,10 @@ fn handle_branch_action_inner( } } BranchAction::Remove { name, path } => { - let resolved = - super::scope::resolve_project_scope(tracedecay::config::resolve_path(path)) - .await?; + let resolved = super::scope::resolve_project_scope( + tracedecay_configuration::resolve_path(path), + ) + .await?; let response = daemon_tool_json( Some(&resolved.project_path), "tracedecay_admin_branch", @@ -258,9 +261,10 @@ fn handle_branch_action_inner( } } BranchAction::Removeall { path } => { - let resolved = - super::scope::resolve_project_scope(tracedecay::config::resolve_path(path)) - .await?; + let resolved = super::scope::resolve_project_scope( + tracedecay_configuration::resolve_path(path), + ) + .await?; let response = daemon_tool_json( Some(&resolved.project_path), "tracedecay_admin_branch", @@ -293,9 +297,10 @@ fn handle_branch_action_inner( } } BranchAction::Gc { path } => { - let resolved = - super::scope::resolve_project_scope(tracedecay::config::resolve_path(path)) - .await?; + let resolved = super::scope::resolve_project_scope( + tracedecay_configuration::resolve_path(path), + ) + .await?; let response = daemon_tool_json( Some(&resolved.project_path), "tracedecay_admin_branch", @@ -362,12 +367,13 @@ async fn handle_branch_autotrack_action( action: crate::cli::BranchAutotrackAction, ) -> tracedecay_domain::errors::Result<()> { use crate::cli::BranchAutotrackAction; - use tracedecay::config::MIN_AUTO_TRACK_PR_POLL_SECS; + use tracedecay_configuration::MIN_AUTO_TRACK_PR_POLL_SECS; match action { BranchAutotrackAction::Status { path } => { let resolved = - super::scope::resolve_project_scope(tracedecay::config::resolve_path(path)).await?; + super::scope::resolve_project_scope(tracedecay_configuration::resolve_path(path)) + .await?; let enabled = super::settings::current_project_setting( &resolved.project_path, tracedecay_domain::configuration::SYNC_AUTO_TRACK_PR_BRANCHES_SETTING_KEY, @@ -419,7 +425,8 @@ async fn handle_branch_autotrack_action( } BranchAutotrackAction::Enable { poll_secs, path } => { let resolved = - super::scope::resolve_project_scope(tracedecay::config::resolve_path(path)).await?; + super::scope::resolve_project_scope(tracedecay_configuration::resolve_path(path)) + .await?; let expected_revision = super::settings::current_configuration_revision(&resolved.project_path).await?; let current_enabled = super::settings::current_project_setting( @@ -481,7 +488,8 @@ async fn handle_branch_autotrack_action( } BranchAutotrackAction::Disable { path } => { let resolved = - super::scope::resolve_project_scope(tracedecay::config::resolve_path(path)).await?; + super::scope::resolve_project_scope(tracedecay_configuration::resolve_path(path)) + .await?; let expected_revision = super::settings::current_configuration_revision(&resolved.project_path).await?; let current = super::settings::current_project_setting( diff --git a/crates/tracedecay-cli/src/commands/index.rs b/crates/tracedecay-cli/src/commands/index.rs index 9effcdc8d0..3345894618 100644 --- a/crates/tracedecay-cli/src/commands/index.rs +++ b/crates/tracedecay-cli/src/commands/index.rs @@ -21,7 +21,7 @@ async fn is_fresh_install() -> bool { /// When invoked with no subcommand, offer to create the index if none exists. pub(crate) async fn handle_no_command() -> tracedecay_domain::errors::Result<()> { - let project_path = tracedecay::config::resolve_path(None); + let project_path = tracedecay_configuration::resolve_path(None); if TraceDecay::has_initialized_store(&project_path).await { // Already initialized — show help via clap let _ = ::command().print_help(); @@ -78,7 +78,7 @@ pub(crate) async fn handle_init( fresh: bool, assume_yes: bool, ) -> tracedecay_domain::errors::Result<()> { - let project_path = tracedecay::config::resolve_path(path); + let project_path = tracedecay_configuration::resolve_path(path); let profile_root = tracedecay_runtime_core::storage::default_profile_root()?; if let Some(message) = tracedecay_global_db::ephemeral_root_rejection(&project_path, &profile_root) @@ -497,9 +497,10 @@ pub(crate) async fn handle_sync( message: "brokered sync does not yet support --skip-folders/--include-folders; update tracedecay.toml first".to_string(), }); } - let resolved = - super::scope::resolve_project_scope(tracedecay::config::resolve_path_with_discovery(path)) - .await?; + let resolved = super::scope::resolve_project_scope( + tracedecay_configuration::resolve_path_with_discovery(path), + ) + .await?; let handshake = tracedecay::daemon::handshake_for_current_client( Some(resolved.project_path.clone()), None, diff --git a/crates/tracedecay-cli/src/commands/settings.rs b/crates/tracedecay-cli/src/commands/settings.rs index 3ded03ec4e..cac66ee0f7 100644 --- a/crates/tracedecay-cli/src/commands/settings.rs +++ b/crates/tracedecay-cli/src/commands/settings.rs @@ -328,9 +328,10 @@ pub(crate) fn report_configuration_receipt(receipt: Option<&EffectReceipt>) { #[hotpath::measure(label = "cli.settings.upload_counter", future = true)] pub(crate) async fn handle_upload_counter(enable: bool) -> tracedecay_domain::errors::Result<()> { - let resolved = - super::scope::resolve_project_scope(tracedecay::config::resolve_path_with_discovery(None)) - .await?; + let resolved = super::scope::resolve_project_scope( + tracedecay_configuration::resolve_path_with_discovery(None), + ) + .await?; let expected_revision = current_configuration_revision(&resolved.project_path).await?; let current = canonical_upload_enabled(&resolved.project_path).await?; let mutations = if current != enable { @@ -380,7 +381,7 @@ fn handle_gitignore_inner( // Erase the deeply nested gitignore-settings future before it reaches the // measured wrapper so every profiling feature can compute its layout. Box::pin(async move { - let project_path = tracedecay::config::resolve_path(path); + let project_path = tracedecay_configuration::resolve_path(path); match action.as_deref() { Some("on") => { let resolved = super::scope::resolve_project_scope(project_path).await?; diff --git a/crates/tracedecay-cli/src/main.rs b/crates/tracedecay-cli/src/main.rs index 8eaeec3cd2..a24c372b10 100644 --- a/crates/tracedecay-cli/src/main.rs +++ b/crates/tracedecay-cli/src/main.rs @@ -910,7 +910,7 @@ pub(crate) async fn resolve_cli_project_root( if let Some(root) = resolve_registered_project_root(project_id, project_path).await? { return Ok(root); } - Ok(tracedecay::config::resolve_path_with_discovery(path)) + Ok(tracedecay_configuration::resolve_path_with_discovery(path)) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1290,7 +1290,7 @@ async fn dispatch_runtime_command(command: Commands) -> tracedecay_domain::error port, open, } => { - let project_path = tracedecay::config::resolve_path_with_discovery(path); + let project_path = tracedecay_configuration::resolve_path_with_discovery(path); let result = hotpath::future!( commands::daemon_tool_json( Some(&project_path), @@ -1785,7 +1785,7 @@ async fn dispatch_configuration_command( ) -> tracedecay_domain::errors::Result<()> { match command { Commands::CurrentCounter { path } => { - let project_path = tracedecay::config::resolve_path(path); + let project_path = tracedecay_configuration::resolve_path(path); let result = hotpath::future!( commands::daemon_tool_json( Some(&project_path), @@ -1804,7 +1804,7 @@ async fn dispatch_configuration_command( println!("{value}"); } Commands::ResetCounter { path } => { - let project_path = tracedecay::config::resolve_path(path); + let project_path = tracedecay_configuration::resolve_path(path); let result = commands::daemon_tool_json( Some(&project_path), "tracedecay_admin_project", diff --git a/crates/tracedecay-cli/src/semantic_cmd.rs b/crates/tracedecay-cli/src/semantic_cmd.rs index f94ad785c3..f7049f8bcd 100644 --- a/crates/tracedecay-cli/src/semantic_cmd.rs +++ b/crates/tracedecay-cli/src/semantic_cmd.rs @@ -24,7 +24,7 @@ async fn activate( project: Option, json: bool, ) -> tracedecay_domain::errors::Result<()> { - let project_root = tracedecay::config::resolve_path_with_discovery(project); + let project_root = tracedecay_configuration::resolve_path_with_discovery(project); let handshake = tracedecay::daemon::handshake_for_current_client( Some(project_root.clone()), None, diff --git a/crates/tracedecay-cli/src/serve_cmd.rs b/crates/tracedecay-cli/src/serve_cmd.rs index 078951709a..91bb8d6191 100644 --- a/crates/tracedecay-cli/src/serve_cmd.rs +++ b/crates/tracedecay-cli/src/serve_cmd.rs @@ -91,9 +91,9 @@ fn proxy_serve_handshake( let path = sanitize_serve_path_arg(path_arg); let explicit_path = path.is_some(); let mut resolved_path = if explicit_path { - tracedecay::config::resolve_path(path) + tracedecay_configuration::resolve_path(path) } else { - tracedecay::config::resolve_path_with_discovery(None) + tracedecay_configuration::resolve_path_with_discovery(None) }; let ambient_discovery = @@ -111,7 +111,7 @@ fn proxy_serve_handshake( let auto_init_root = (!ambient_discovery && !initialized && tracedecay::config::cached_sync_config(&resolved_path).map_or_else( - |_| tracedecay::config::SyncConfig::default().auto_init, + |_| tracedecay_configuration::SyncConfig::default().auto_init, |config| config.auto_init, )) .then(|| tracedecay_runtime_core::worktree::git_worktree_root(&resolved_path)) diff --git a/crates/tracedecay-cli/src/status_cmd.rs b/crates/tracedecay-cli/src/status_cmd.rs index b6fedf9b67..d86ba1962b 100644 --- a/crates/tracedecay-cli/src/status_cmd.rs +++ b/crates/tracedecay-cli/src/status_cmd.rs @@ -469,7 +469,7 @@ async fn handle_status_command_within( } } - if !tracedecay::config::is_in_gitignore(&project_path) { + if !tracedecay_configuration::is_in_gitignore(&project_path) { let dir_name = tracedecay::config::active_data_dir_name(&project_path); if stderr_is_terminal { eprintln!( diff --git a/crates/tracedecay-cli/src/tool_command.rs b/crates/tracedecay-cli/src/tool_command.rs index adc3fe75ff..3be92fa5c9 100644 --- a/crates/tracedecay-cli/src/tool_command.rs +++ b/crates/tracedecay-cli/src/tool_command.rs @@ -543,7 +543,7 @@ impl DaemonToolDispatch { // the user profile into an accidental project handshake. let explicitly_targeted = explicit_project.is_some(); let project_path = match explicit_project { - Some(path) => Some(tracedecay::config::resolve_path(Some(path))), + Some(path) => Some(tracedecay_configuration::resolve_path(Some(path))), None => std::env::current_dir() .ok() .and_then(|cwd| implicit_tool_project_path(&cwd)), diff --git a/crates/tracedecay-cli/src/tool_command/tests.rs b/crates/tracedecay-cli/src/tool_command/tests.rs index bb31dfd91d..fa1b8db442 100644 --- a/crates/tracedecay-cli/src/tool_command/tests.rs +++ b/crates/tracedecay-cli/src/tool_command/tests.rs @@ -435,7 +435,7 @@ fn profile_scoped_session_refresh_dispatch_is_projectless() { ); assert_eq!( project_scoped.project_path, - Some(tracedecay::config::resolve_path(Some( + Some(tracedecay_configuration::resolve_path(Some( "/explicit/project".to_owned() ))), "{tool_name}" @@ -450,7 +450,7 @@ fn profile_scoped_session_refresh_dispatch_is_projectless() { ); assert_eq!( dispatch.project_path, - Some(tracedecay::config::resolve_path(Some( + Some(tracedecay_configuration::resolve_path(Some( "/explicit/project".to_owned() ))) ); diff --git a/crates/tracedecay-cli/src/work_command.rs b/crates/tracedecay-cli/src/work_command.rs index f852596e04..798ae8649e 100644 --- a/crates/tracedecay-cli/src/work_command.rs +++ b/crates/tracedecay-cli/src/work_command.rs @@ -12,7 +12,7 @@ pub(crate) async fn run(invocation: WorkInvocationArgs) -> tracedecay_domain::er #[cfg(feature = "hotpath")] hotpath::val!("cli.work.operation").set(&invocation.operation.operation_key()); let body = read_request(&invocation.request_file)?; - let project_root = tracedecay::config::resolve_path_with_discovery(invocation.project); + let project_root = tracedecay_configuration::resolve_path_with_discovery(invocation.project); let operation = invocation.operation; // The application round-trip timed apart from `cli.work.invoke` so daemon // latency is separable from request parsing, render, and delivery diff --git a/crates/tracedecay-cli/src/workflow_command.rs b/crates/tracedecay-cli/src/workflow_command.rs index e828e1d314..8d01bb28f9 100644 --- a/crates/tracedecay-cli/src/workflow_command.rs +++ b/crates/tracedecay-cli/src/workflow_command.rs @@ -14,7 +14,7 @@ pub(crate) async fn run( #[cfg(feature = "hotpath")] hotpath::val!("cli.workflow.operation").set(&invocation.operation.operation_key()); let body = read_request(&invocation.request_file)?; - let project_root = tracedecay::config::resolve_path_with_discovery(invocation.project); + let project_root = tracedecay_configuration::resolve_path_with_discovery(invocation.project); let operation = invocation.operation; let outcome = crate::workflow_cli::invoke_workflow_cli(project_root.clone(), operation, body).await?; diff --git a/crates/tracedecay-cli/tests/core_cli_suite/config_test.rs b/crates/tracedecay-cli/tests/core_cli_suite/config_test.rs index 23628583a0..38e60090d3 100644 --- a/crates/tracedecay-cli/tests/core_cli_suite/config_test.rs +++ b/crates/tracedecay-cli/tests/core_cli_suite/config_test.rs @@ -1,5 +1,7 @@ use tempfile::TempDir; -use tracedecay::config::*; +use tracedecay_configuration::{ + TraceDecayConfig, get_config_path, is_excluded, is_excluded_dir, is_in_gitignore, load_config, +}; #[test] fn default_config_excludes_generated_vendor_cache_trees_and_gitignore_on() { diff --git a/crates/tracedecay-configuration/Cargo.toml b/crates/tracedecay-configuration/Cargo.toml index 33a896e752..7ef10bf8f4 100644 --- a/crates/tracedecay-configuration/Cargo.toml +++ b/crates/tracedecay-configuration/Cargo.toml @@ -8,7 +8,9 @@ description = "Transport-neutral configuration control plane and runtime pin sur repository = "https://github.com/ScriptedAlchemy/tracedecay" [dependencies] +glob = "0.3" hotpath.workspace = true +serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.11" thiserror = "2" @@ -17,6 +19,7 @@ tracedecay-contracts = { path = "../tracedecay-contracts", version = "0.1.0" } tracedecay-automation = { path = "../tracedecay-automation", version = "0.1.0" } tracedecay-domain = { path = "../tracedecay-domain", version = "0.1.0" } tracedecay-global-db = { path = "../tracedecay-global-db", version = "0.1.0" } +tracedecay-lcm = { path = "../tracedecay-lcm", version = "0.1.0" } tracedecay-policy = { path = "../tracedecay-policy", version = "0.1.0" } tracedecay-runtime-core = { path = "../tracedecay-runtime-core", version = "0.1.0" } tracedecay-semantic-contracts.workspace = true diff --git a/crates/tracedecay-configuration/src/config/mod.rs b/crates/tracedecay-configuration/src/config/mod.rs index 3c12296175..2e78c18080 100644 --- a/crates/tracedecay-configuration/src/config/mod.rs +++ b/crates/tracedecay-configuration/src/config/mod.rs @@ -6,6 +6,7 @@ //! spelling so call sites share one import path. pub mod analyzer; +pub mod model; pub mod scope_control; pub mod topology; pub mod work_executable_binding; diff --git a/crates/tracedecay-configuration/src/config/model.rs b/crates/tracedecay-configuration/src/config/model.rs new file mode 100644 index 0000000000..39207506e0 --- /dev/null +++ b/crates/tracedecay-configuration/src/config/model.rs @@ -0,0 +1,976 @@ +//! Legacy `config.json` model, defaults, validation, and path policy. +//! +//! Shared runtime pin settings stay in [`crate::config`]; this module owns the +//! serde/migration shape and the include/exclude/gitignore helpers every +//! caller imports directly. + +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use glob::Pattern; +use serde::{Deserialize, Serialize}; +use tracedecay_contracts::storage::compaction::CompactionThresholdConfig; +use tracedecay_domain::configuration::ConfigurationSnapshotV1; +use tracedecay_domain::configuration::{ + SYNC_AUTO_INIT_SETTING_KEY, SYNC_AUTO_WATCH_SETTING_KEY, + SYNC_BACKSTOP_INTERVAL_MINS_SETTING_KEY, SYNC_BRANCH_GC_DAYS_SETTING_KEY, + SYNC_FULL_SYNC_ESCALATION_FILES_SETTING_KEY, SYNC_MAX_CONCURRENT_SYNCS_SETTING_KEY, + SYNC_ORPHAN_DB_GC_DAYS_SETTING_KEY, SYNC_READ_COOLDOWN_SECS_SETTING_KEY, + SYNC_READ_REFRESH_SETTING_KEY, SYNC_SESSION_START_STALE_THRESHOLD_SECS_SETTING_KEY, + SYNC_SESSION_START_SYNC_SETTING_KEY, SYNC_WATCH_DEBOUNCE_MS_SETTING_KEY, + SYNC_WATCH_LINKED_WORKTREES_SETTING_KEY, SYNC_WATCH_MAX_DELAY_MS_SETTING_KEY, + SYNC_WATCH_MAX_PROJECTS_SETTING_KEY, +}; +use tracedecay_domain::errors::{Result, TraceDecayError}; +use tracedecay_runtime_core::config::{ + GENERATED_DIR_SEGMENTS, active_data_dir_name, discover_project_root, get_tracedecay_dir, + is_generated_dir_segment, +}; +use tracedecay_semantic_contracts::SemanticConfig; + +use super::{ + PinnedRuntimeConfiguration, optional_text_setting, required_bool, required_unsigned, + required_usize, +}; + +/// Name of the legacy configuration migration input stored inside the data +/// directory. It is not a runtime authority and production code must never +/// rewrite it. +pub const CONFIG_FILENAME: &str = "config.json"; + +/// Atomic daemon retention/compaction policy tree. +/// +/// The value is canonical JSON for [`RetentionConfig`]. Keeping the session +/// (LCM), observation-evidence, orphan-store, debris, and compaction windows +/// under one setting keeps the retention engines threaded as a single +/// versioned unit the daemon backstop reads, mirroring the semantic key. Absent +/// or unset resolves to [`RetentionConfig::default`]'s bounded safe policy. +pub const SYNC_RETENTION_SETTING_KEY: &str = "sync.retention.v1"; + +/// Returns `true` if any component of `path` is a generated/vendored +/// directory segment, or `path` itself carries a minified-asset suffix +/// (`app.min.js`, `app.min.css`, ...) — mirrors the `**/*.min.*` default +/// exclude pattern built by [`default_exclude_patterns`]. +/// +/// Path-level (not just directory-level) so callers can filter a flat list +/// of file paths in one pass, e.g. the redundancy scanner's candidate list. +pub fn is_generated_path_segment(path: &str) -> bool { + has_minified_suffix(path) || path.split('/').any(is_generated_dir_segment) +} + +/// `true` for paths like `app.min.js` / `app.min.css.map` — a `.min.` +/// component followed by at least one more character. +fn has_minified_suffix(path: &str) -> bool { + path.rfind(".min.").is_some_and(|idx| idx + 5 < path.len()) +} + +/// Default glob-pattern exclude list for [`TraceDecayConfig::default`]. +/// +/// Built from [`GENERATED_DIR_SEGMENTS`] (both the `segment/**` root form +/// and the `**/segment/**` nested form, since a generated directory can +/// appear at the project root or anywhere below it) plus site-local +/// additions that intentionally are *not* part of the shared segment set: +/// +/// - `.git/**`, `.tracedecay/**` — VCS and `TraceDecay`'s own metadata dirs; +/// these are tool/repo bookkeeping, not generated *code*, so they stay +/// local to the config's default patterns rather than joining +/// [`GENERATED_DIR_SEGMENTS`] (which the migrate/scan/redundancy call +/// sites also consult for non-config-driven decisions). +/// - `bin/**` — historically excluded here by default, but not treated as +/// "generated" elsewhere: a `bin/` directory can hold real source in some +/// project layouts, so it isn't added to the shared segment list. +/// - `**/*.min.*` — mirrors [`is_generated_path_segment`]'s suffix check. +fn default_exclude_patterns() -> Vec { + let mut patterns: Vec = vec![ + ".git/**".to_string(), + ".tracedecay/**".to_string(), + "bin/**".to_string(), + "**/*.min.*".to_string(), + ]; + for segment in GENERATED_DIR_SEGMENTS { + patterns.push(format!("{segment}/**")); + patterns.push(format!("**/{segment}/**")); + } + patterns +} + +/// Legacy `config.json` representation and the materialized shape used by an +/// already-pinned resolved configuration snapshot. +/// +/// `version` and `root_dir` are legacy migration metadata only. Every runtime +/// setting below is sourced from [`ConfigurationSnapshotV1`] before a project +/// opens; serializing this type is retained solely for migration fixtures and +/// backwards-compatible legacy input decoding. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[allow( + clippy::struct_excessive_bools, + reason = "Independent legacy configuration switches retain their serialized migration shape" +)] +pub struct TraceDecayConfig { + /// Schema version of the configuration. + pub version: u32, + /// Root directory of the project being indexed. + pub root_dir: String, + /// Glob patterns for files to exclude during indexing. + pub exclude: Vec, + /// Glob patterns for paths to include despite the default hidden-directory, + /// generated-directory, and gitignore filters. For example, + /// `[".github/**"]` indexes files under `.github/` that would otherwise be + /// skipped. + #[serde(default)] + pub include: Vec, + /// Maximum file size in bytes; files larger than this are skipped. + pub max_file_size: u64, + /// Whether to extract doc comments from source files. + pub extract_docstrings: bool, + /// Whether to track call-site locations for edges. + pub track_call_sites: bool, + /// Whether to respect `.gitignore` rules when scanning files. + #[serde(default = "default_git_ignore")] + pub git_ignore: bool, + /// Whether a cold `tracedecay_diagnostics` call prewarms in the background + /// (detached dependency build + immediate `warming` status) instead of + /// blocking for minutes. Environment precedence is resolved into the + /// pinned snapshot during legacy migration, never during a tool call. + #[serde(default)] + pub diagnostics_prewarm: bool, + /// Whether the persistent native code graph may activate for this project. + /// Disabling it leaves exact and lexical retrieval available and reports + /// graph capability as unavailable. + #[serde(default = "default_native_graph_activation")] + pub native_graph_activation: bool, + /// Optional installed local semantic profile selection. Missing or + /// unavailable semantics never disables exact, lexical, or graph search. + #[serde(default)] + pub semantic: SemanticConfig, + /// Index-freshness auto-sync settings (git-metadata watcher, serve-stale, + /// branch lifecycle). Absent in older `config.json` files, so defaulted. + #[serde(default)] + pub sync: SyncConfig, + /// Analytics telemetry settings. Absent in older `config.json` files, so + /// defaulted. + #[serde(default)] + pub telemetry: TelemetryConfig, +} + +fn default_git_ignore() -> bool { + true +} + +fn default_native_graph_activation() -> bool { + true +} + +fn default_sync_auto_watch() -> bool { + false +} +fn default_sync_watch_linked_worktrees() -> bool { + false +} +fn default_sync_watch_debounce_ms() -> u64 { + 2000 +} +fn default_sync_watch_max_delay_ms() -> u64 { + 30000 +} +fn default_sync_watch_max_projects() -> usize { + 32 +} +fn default_sync_read_refresh() -> bool { + true +} +fn default_sync_read_cooldown_secs() -> u64 { + 30 +} +fn default_sync_session_start_sync() -> bool { + true +} +fn default_sync_session_start_stale_threshold_secs() -> u64 { + 600 +} +fn default_sync_backstop_interval_mins() -> u64 { + 15 +} +fn default_sync_full_sync_escalation_files() -> usize { + 500 +} +fn default_sync_max_concurrent_syncs() -> usize { + 2 +} +fn default_sync_branch_gc_days() -> u64 { + 14 +} +fn default_sync_orphan_db_gc_days() -> u64 { + 7 +} +fn default_sync_auto_init() -> bool { + true +} +fn default_sync_auto_track_pr_branches() -> bool { + false +} +fn default_sync_auto_track_pr_poll_secs() -> u64 { + 300 +} +fn default_retention_interval_hours() -> u64 { + 24 +} + +fn default_orphan_store_gc_days() -> Option { + Some(30) +} + +fn default_incident_debris_retention_days() -> Option { + Some(30) +} + +fn default_compaction_threshold() -> Option { + Some(CompactionThresholdConfig::default()) +} + +/// The daemon retention/compaction policy tree (Plan 38). Safe, bounded +/// maintenance is active by default for proven orphan stores, quarantined +/// debris, redundant projection-durable session copies, and free-page bloat. +/// Lossy session/evidence deletion remains disabled and soft budgets remain +/// owner-configured findings only. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RetentionConfig { + /// Session-store (LCM raw/projected) retention windows. + #[serde(default)] + pub session_lcm: tracedecay_lcm::LcmRetentionConfig, + /// Observation-evidence generation-scoped retention windows. + #[serde(default)] + pub observation: tracedecay_global_db::observation::retention::ObservationRetentionConfig, + /// Orphan profile-sharded store collection window (days). `None` disables + /// the sweep; the Doctor surface still reports findings read-only. + #[serde(default = "default_orphan_store_gc_days")] + pub orphan_store_gc_days: Option, + /// Retention window for quarantined recovery/corruption artifacts (days). + /// `None` disables collection while Doctor continues surfacing debris. + #[serde(default = "default_incident_debris_retention_days")] + pub incident_debris_retention_days: Option, + /// Incremental-vacuum compaction trigger. `None` disables compaction. + #[serde(default = "default_compaction_threshold")] + pub compaction: Option, + /// Owner-configured soft byte budgets keyed by exact logical store key. + /// Missing entries mean no budget was configured for that store. + #[serde(default)] + pub store_soft_budgets_bytes: BTreeMap, + /// Cadence between daemon retention passes (hours). + #[serde(default = "default_retention_interval_hours")] + pub interval_hours: u64, +} + +impl Default for RetentionConfig { + fn default() -> Self { + Self { + session_lcm: tracedecay_lcm::LcmRetentionConfig::default(), + observation: + tracedecay_global_db::observation::retention::ObservationRetentionConfig::default(), + orphan_store_gc_days: default_orphan_store_gc_days(), + incident_debris_retention_days: default_incident_debris_retention_days(), + compaction: default_compaction_threshold(), + store_soft_budgets_bytes: BTreeMap::new(), + interval_hours: default_retention_interval_hours(), + } + } +} + +impl RetentionConfig { + pub fn store_soft_budget( + &self, + store: &str, + ) -> Result> { + let Some(bytes) = self.store_soft_budgets_bytes.get(store).copied() else { + return Ok(None); + }; + let budget = tracedecay_contracts::storage::StoreSizeBudgetV1 { + store: tracedecay_contracts::storage::StoreKeyV1::new(store.to_owned()) + .map_err(|error| config_error(error.to_string()))?, + soft_limit_bytes: tracedecay_contracts::storage::StorageByteSizeV1(bytes), + }; + budget + .validate() + .map_err(|error| config_error(error.to_string()))?; + Ok(Some(budget)) + } + + /// Validate collection windows and the compaction trigger. Immediate + /// collection and ratios outside the unit interval are rejected. + pub(crate) fn validate(&self) -> Result<()> { + if self.orphan_store_gc_days == Some(0) { + return Err(config_error( + "retention orphan_store_gc_days must be greater than zero", + )); + } + if self.incident_debris_retention_days == Some(0) { + return Err(config_error( + "retention incident_debris_retention_days must be greater than zero", + )); + } + if let Some(compaction) = &self.compaction + && (!compaction.free_page_ratio_threshold.is_finite() + || compaction.free_page_ratio_threshold <= 0.0 + || compaction.free_page_ratio_threshold > 1.0) + { + return Err(config_error( + "retention compaction free_page_ratio_threshold must be within (0.0, 1.0]", + )); + } + for (store, bytes) in &self.store_soft_budgets_bytes { + tracedecay_contracts::storage::StoreKeyV1::new(store.clone()).map_err(|_| { + config_error(format!( + "retention store soft budget key '{store}' is not a valid StoreKeyV1" + )) + })?; + if *bytes == 0 { + return Err(config_error(format!( + "retention store soft budget for '{store}' must be greater than zero" + ))); + } + } + Ok(()) + } +} + +/// Floor for the PR-autotrack poll interval; polls faster than this hammer the +/// GitHub API / `git ls-remote` needlessly, so any smaller configured value is +/// clamped up to this. +pub const MIN_AUTO_TRACK_PR_POLL_SECS: u64 = 60; + +fn default_telemetry_timings() -> bool { + true +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct TelemetryConfig { + #[serde(default = "default_telemetry_timings")] + pub timings: bool, +} + +impl Default for TelemetryConfig { + fn default() -> Self { + Self { + timings: default_telemetry_timings(), + } + } +} + +/// Auto-sync / index-freshness knobs in the legacy migration shape. +/// +/// Runtime consumers receive these values only from a pinned resolved +/// configuration snapshot. `TRACEDECAY_SYNC_*` values are decoded as an +/// explicit legacy environment layer during migration, rather than being read +/// independently by each adapter. +/// +/// Every field carries a `#[serde(default = ...)]` so that a partial JSON +/// object (only some keys present) still deserializes, and a missing `sync` +/// key entirely falls back to [`SyncConfig::default`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[allow( + clippy::struct_excessive_bools, + reason = "Independent sync admission switches are configuration choices, not mutually exclusive states" +)] +pub struct SyncConfig { + /// Enable the daemon git-metadata watcher. + #[serde(default = "default_sync_auto_watch")] + pub auto_watch: bool, + /// Admit linked worktrees into the daemon watcher without an explicit + /// branch-indexing request. + #[serde(default = "default_sync_watch_linked_worktrees")] + pub watch_linked_worktrees: bool, + /// Per-project quiet-period debounce before a watcher-triggered sync (ms). + #[serde(default = "default_sync_watch_debounce_ms")] + pub watch_debounce_ms: u64, + /// Maximum time a watcher-triggered sync can be deferred by debounce (ms). + #[serde(default = "default_sync_watch_max_delay_ms")] + pub watch_max_delay_ms: u64, + /// Maximum number of recently-seen projects the watcher registers. + #[serde(default = "default_sync_watch_max_projects")] + pub watch_max_projects: usize, + /// Enable non-blocking sync-on-read for query tools. + #[serde(default = "default_sync_read_refresh")] + pub read_refresh: bool, + /// Cooldown between read-triggered background refreshes (seconds). + #[serde(default = "default_sync_read_cooldown_secs")] + pub read_cooldown_secs: u64, + /// Fire a catch-up sync on session start. + #[serde(default = "default_sync_session_start_sync")] + pub session_start_sync: bool, + /// Staleness threshold above which session-start sync runs (seconds). + #[serde(default = "default_sync_session_start_stale_threshold_secs")] + pub session_start_stale_threshold_secs: u64, + /// Daemon backstop scheduler interval (minutes); 0 disables it. + #[serde(default = "default_sync_backstop_interval_mins")] + pub backstop_interval_mins: u64, + /// Diff-scoped syncs above this many changed files escalate to a full sync. + #[serde(default = "default_sync_full_sync_escalation_files")] + pub full_sync_escalation_files: usize, + /// Daemon-wide cap on concurrent syncs. + #[serde(default = "default_sync_max_concurrent_syncs")] + pub max_concurrent_syncs: usize, + /// Grace period before a dead tracked-branch store is GC'd (days). + #[serde(default = "default_sync_branch_gc_days")] + pub branch_gc_days: u64, + /// Grace period before an orphan branch DB is GC'd (days). + #[serde(default = "default_sync_orphan_db_gc_days")] + pub orphan_db_gc_days: u64, + /// Auto-initialise never-indexed repos on first contact. + #[serde(default = "default_sync_auto_init")] + pub auto_init: bool, + /// Enable the daemon PR-branch auto-tracking mode: when on, the daemon polls + /// the repo's GitHub remote for open PRs and tracks/untracks each PR head + /// branch through the normal branch-tracking machinery. Off by default for + /// back-compat. + #[serde(default = "default_sync_auto_track_pr_branches")] + pub auto_track_pr_branches: bool, + /// Poll cadence (seconds) for PR-branch auto-tracking discovery. Clamped up + /// to [`MIN_AUTO_TRACK_PR_POLL_SECS`] at read time. + #[serde(default = "default_sync_auto_track_pr_poll_secs")] + pub auto_track_pr_poll_secs: u64, + /// Daemon retention/compaction policy tree (Plan 38). + #[serde(default)] + pub retention: RetentionConfig, +} + +impl SyncConfig { + /// The effective PR-autotrack poll interval, never below the safety floor. + #[must_use] + pub fn effective_auto_track_pr_poll_secs(&self) -> u64 { + self.auto_track_pr_poll_secs + .max(MIN_AUTO_TRACK_PR_POLL_SECS) + } +} + +impl Default for SyncConfig { + fn default() -> Self { + Self { + auto_watch: default_sync_auto_watch(), + watch_linked_worktrees: default_sync_watch_linked_worktrees(), + watch_debounce_ms: default_sync_watch_debounce_ms(), + watch_max_delay_ms: default_sync_watch_max_delay_ms(), + watch_max_projects: default_sync_watch_max_projects(), + read_refresh: default_sync_read_refresh(), + read_cooldown_secs: default_sync_read_cooldown_secs(), + session_start_sync: default_sync_session_start_sync(), + session_start_stale_threshold_secs: default_sync_session_start_stale_threshold_secs(), + backstop_interval_mins: default_sync_backstop_interval_mins(), + full_sync_escalation_files: default_sync_full_sync_escalation_files(), + max_concurrent_syncs: default_sync_max_concurrent_syncs(), + branch_gc_days: default_sync_branch_gc_days(), + orphan_db_gc_days: default_sync_orphan_db_gc_days(), + auto_init: default_sync_auto_init(), + auto_track_pr_branches: default_sync_auto_track_pr_branches(), + auto_track_pr_poll_secs: default_sync_auto_track_pr_poll_secs(), + retention: RetentionConfig::default(), + } + } +} + +/// Parses a boolean env value. Truthy spellings (`1`/`true`/`yes`/`on`) share +/// [`tracedecay_global_db::env_value_truthy`]; `0`/`false` are false. Any +/// other value is ignored (returns `None`) so an override is not applied. +pub(crate) fn parse_env_bool(raw: &str) -> Option { + if tracedecay_global_db::env_value_truthy(raw) { + return Some(true); + } + match raw.trim().to_ascii_lowercase().as_str() { + "0" | "false" => Some(false), + _ => None, + } +} + +/// Reads a `TRACEDECAY_` env var and parses it as a bool. +pub(crate) fn env_bool(suffix: &str) -> Option { + brand_env(suffix).as_deref().and_then(parse_env_bool) +} + +/// Reads a `TRACEDECAY_` env var and parses it as an integer of the +/// caller's choosing. +fn env_parse(suffix: &str) -> Option { + brand_env(suffix) + .as_deref() + .and_then(|raw| raw.trim().parse::().ok()) +} + +impl SyncConfig { + /// Applies legacy `TRACEDECAY_SYNC_*` environment overrides on top of + /// `self`. This remains for pre-store/bootstrap compatibility only; live + /// runtime adapters must consume [`PinnedRuntimeConfiguration`] instead. + #[must_use] + pub fn with_env_overrides(mut self) -> Self { + if let Some(value) = env_bool("SYNC_AUTO_WATCH") { + self.auto_watch = value; + } + if let Some(value) = env_bool("SYNC_WATCH_LINKED_WORKTREES") { + self.watch_linked_worktrees = value; + } + if let Some(value) = env_parse("SYNC_WATCH_DEBOUNCE_MS") { + self.watch_debounce_ms = value; + } + if let Some(value) = env_parse("SYNC_WATCH_MAX_DELAY_MS") { + self.watch_max_delay_ms = value; + } + if let Some(value) = env_parse("SYNC_WATCH_MAX_PROJECTS") { + self.watch_max_projects = value; + } + if let Some(value) = env_bool("SYNC_READ_REFRESH") { + self.read_refresh = value; + } + if let Some(value) = env_parse("SYNC_READ_COOLDOWN_SECS") { + self.read_cooldown_secs = value; + } + if let Some(value) = env_bool("SYNC_SESSION_START_SYNC") { + self.session_start_sync = value; + } + if let Some(value) = env_parse("SYNC_SESSION_START_STALE_THRESHOLD_SECS") { + self.session_start_stale_threshold_secs = value; + } + if let Some(value) = env_parse("SYNC_BACKSTOP_INTERVAL_MINS") { + self.backstop_interval_mins = value; + } + if let Some(value) = env_parse("SYNC_FULL_SYNC_ESCALATION_FILES") { + self.full_sync_escalation_files = value; + } + if let Some(value) = env_parse("SYNC_MAX_CONCURRENT_SYNCS") { + self.max_concurrent_syncs = value; + } + if let Some(value) = env_parse("SYNC_BRANCH_GC_DAYS") { + self.branch_gc_days = value; + } + if let Some(value) = env_parse("SYNC_ORPHAN_DB_GC_DAYS") { + self.orphan_db_gc_days = value; + } + if let Some(value) = env_bool("SYNC_AUTO_INIT") { + self.auto_init = value; + } + if let Some(value) = env_bool("SYNC_AUTO_TRACK_PR_BRANCHES") { + self.auto_track_pr_branches = value; + } + if let Some(value) = env_parse("SYNC_AUTO_TRACK_PR_POLL_SECS") { + self.auto_track_pr_poll_secs = value; + } + self + } +} + +impl Default for TraceDecayConfig { + fn default() -> Self { + Self { + version: 1, + root_dir: String::new(), + exclude: default_exclude_patterns(), + include: Vec::new(), + max_file_size: 1_048_576, + extract_docstrings: true, + track_call_sites: true, + git_ignore: default_git_ignore(), + diagnostics_prewarm: false, + native_graph_activation: default_native_graph_activation(), + semantic: SemanticConfig::default(), + sync: SyncConfig::default(), + telemetry: TelemetryConfig::default(), + } + } +} + +impl TraceDecayConfig { + /// Layers the daemon-only policy over the shared runtime settings of an + /// already validated pin. The shared settings are copied from the pin, so + /// they agree with every other consumer by construction; only the + /// daemon-only sync, retention, and legacy metadata fields are decoded + /// here, from the same snapshot, without defaults, file reads, or + /// environment reads. + #[hotpath::measure(label = "daemon.config.parse")] + pub fn from_runtime(runtime: &PinnedRuntimeConfiguration) -> Result { + let shared = runtime.config(); + let snapshot = runtime.snapshot(); + Ok(Self { + version: 1, + root_dir: runtime.target().project_root.to_string_lossy().to_string(), + exclude: shared.exclude.clone(), + include: shared.include.clone(), + max_file_size: shared.max_file_size, + extract_docstrings: shared.extract_docstrings, + track_call_sites: shared.track_call_sites, + git_ignore: shared.git_ignore, + diagnostics_prewarm: shared.diagnostics_prewarm, + native_graph_activation: shared.native_graph_activation, + semantic: shared.semantic.clone(), + sync: SyncConfig { + auto_watch: required_bool(snapshot, SYNC_AUTO_WATCH_SETTING_KEY)?, + watch_linked_worktrees: required_bool( + snapshot, + SYNC_WATCH_LINKED_WORKTREES_SETTING_KEY, + )?, + watch_debounce_ms: required_unsigned(snapshot, SYNC_WATCH_DEBOUNCE_MS_SETTING_KEY)?, + watch_max_delay_ms: required_unsigned( + snapshot, + SYNC_WATCH_MAX_DELAY_MS_SETTING_KEY, + )?, + watch_max_projects: required_usize(snapshot, SYNC_WATCH_MAX_PROJECTS_SETTING_KEY)?, + read_refresh: required_bool(snapshot, SYNC_READ_REFRESH_SETTING_KEY)?, + read_cooldown_secs: required_unsigned( + snapshot, + SYNC_READ_COOLDOWN_SECS_SETTING_KEY, + )?, + session_start_sync: required_bool(snapshot, SYNC_SESSION_START_SYNC_SETTING_KEY)?, + session_start_stale_threshold_secs: required_unsigned( + snapshot, + SYNC_SESSION_START_STALE_THRESHOLD_SECS_SETTING_KEY, + )?, + backstop_interval_mins: required_unsigned( + snapshot, + SYNC_BACKSTOP_INTERVAL_MINS_SETTING_KEY, + )?, + full_sync_escalation_files: required_usize( + snapshot, + SYNC_FULL_SYNC_ESCALATION_FILES_SETTING_KEY, + )?, + max_concurrent_syncs: required_usize( + snapshot, + SYNC_MAX_CONCURRENT_SYNCS_SETTING_KEY, + )?, + branch_gc_days: required_unsigned(snapshot, SYNC_BRANCH_GC_DAYS_SETTING_KEY)?, + orphan_db_gc_days: required_unsigned(snapshot, SYNC_ORPHAN_DB_GC_DAYS_SETTING_KEY)?, + auto_init: required_bool(snapshot, SYNC_AUTO_INIT_SETTING_KEY)?, + auto_track_pr_branches: shared.sync.auto_track_pr_branches, + auto_track_pr_poll_secs: shared.sync.auto_track_pr_poll_secs, + retention: retention_config_from_snapshot(snapshot)?, + }, + telemetry: TelemetryConfig { + timings: shared.telemetry.timings, + }, + }) + } +} + +fn retention_config_from_snapshot(snapshot: &ConfigurationSnapshotV1) -> Result { + let retention = match optional_text_setting(snapshot, SYNC_RETENTION_SETTING_KEY)? { + None => RetentionConfig::default(), + Some(value) => serde_json::from_str(value).map_err(|error| { + config_error(format!("resolved retention setting is invalid: {error}")) + })?, + }; + retention.validate()?; + Ok(retention) +} + +fn config_error(message: impl Into) -> TraceDecayError { + TraceDecayError::Config { + message: message.into(), + } +} + +/// Reads the `TRACEDECAY_` environment variable. +pub fn brand_env(suffix: &str) -> Option { + std::env::var(format!("TRACEDECAY_{suffix}")).ok() +} + +/// Returns the path to the configuration file (`config.json`) within the +/// resolved data directory. +pub fn get_config_path(project_root: &Path) -> PathBuf { + if let Ok(layout) = + tracedecay_runtime_core::storage::resolve_layout_for_current_profile(project_root) + { + return layout.config_path; + } + get_tracedecay_dir(project_root).join(CONFIG_FILENAME) +} + +/// Loads a legacy configuration input from disk. +/// +/// This compatibility reader is for migration and read-only diagnostics only; +/// runtime consumers must use a pinned resolved snapshot. If the file does +/// not exist, it returns the legacy defaults with `root_dir` set to the given +/// project root. +pub fn load_config(project_root: &Path) -> Result { + let config_path = get_config_path(project_root); + load_config_from_path(project_root, &config_path) +} + +/// Loads configuration from an explicit config path while preserving the +/// project root used for default config values. +pub fn load_config_from_path(project_root: &Path, config_path: &Path) -> Result { + if !config_path.exists() { + return Ok(TraceDecayConfig { + root_dir: project_root.to_string_lossy().to_string(), + ..TraceDecayConfig::default() + }); + } + + let contents = fs::read_to_string(config_path).map_err(|e| TraceDecayError::Config { + message: format!( + "failed to read config file '{}': {}", + config_path.display(), + e + ), + })?; + + let config: TraceDecayConfig = + serde_json::from_str(&contents).map_err(|e| TraceDecayError::Config { + message: format!( + "failed to parse config file '{}': {}", + config_path.display(), + e + ), + })?; + + Ok(config) +} + +/// Writes a legacy configuration fixture to an explicit path using an atomic +/// write. +/// +/// Production runtime code must use the daemon control plane instead of this +/// compatibility helper. It remains for fixtures and legacy-input tests while +/// callers complete their migration. +pub fn save_config_to_path(config_path: &Path, config: &TraceDecayConfig) -> Result<()> { + let data_dir = config_path + .parent() + .ok_or_else(|| TraceDecayError::Config { + message: format!( + "configuration path '{}' has no parent directory", + config_path.display() + ), + })?; + tracedecay_runtime_core::storage::PrivateStoreIo::create_dir_all(data_dir).map_err(|e| { + TraceDecayError::Config { + message: format!( + "failed to create tracedecay directory '{}': {}", + data_dir.display(), + e + ), + } + })?; + + let tmp_path = config_path.with_extension("tmp"); + + let json = serde_json::to_string_pretty(config).map_err(|e| TraceDecayError::Config { + message: format!("failed to serialize config: {e}"), + })?; + + fs::write(&tmp_path, &json).map_err(|e| TraceDecayError::Config { + message: format!( + "failed to write temporary config file '{}': {}", + tmp_path.display(), + e + ), + })?; + + fs::rename(&tmp_path, config_path).map_err(|e| TraceDecayError::Config { + message: format!( + "failed to rename temporary config file '{}' to '{}': {}", + tmp_path.display(), + config_path.display(), + e + ), + })?; + + Ok(()) +} + +/// Returns `true` if the project marker dir (`.tracedecay`) is ignored by Git +/// for this project. +/// +/// This respects the repository `.gitignore`, `.git/info/exclude`, and the +/// user's global excludes file via `git check-ignore`. If Git cannot answer +/// (for example outside a Git repository), falls back to checking the local +/// `.gitignore` file only. +pub fn is_in_gitignore(project_path: &Path) -> bool { + if let Some(is_ignored) = is_ignored_by_git(project_path, None) { + return is_ignored; + } + + is_in_local_gitignore(project_path) +} + +pub(crate) fn is_ignored_by_git( + project_path: &Path, + git_config_global: Option<&Path>, +) -> Option { + let fallback_global_excludes = || { + git_config_global + .and_then(|path| is_ignored_by_explicit_global_excludes(project_path, path)) + }; + let dir_name = active_data_dir_name(project_path); + let Ok(git) = tracedecay_runtime_core::git::try_git_program() else { + return fallback_global_excludes(); + }; + let mut command = Command::new(git); + command + .arg("-C") + .arg(project_path) + .arg("check-ignore") + .arg("-q") + .arg(format!("{dir_name}/")) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + + if let Some(path) = git_config_global { + command.env_clear(); + command.env("PATH", git_subprocess_path()); + command.env("GIT_CONFIG_GLOBAL", path); + command.env("GIT_CONFIG_NOSYSTEM", "1"); + } + + let Ok(status) = command.status() else { + return fallback_global_excludes(); + }; + + match status.code() { + Some(0) => Some(true), + Some(1) => Some(false), + _ => fallback_global_excludes(), + } +} + +pub(crate) fn is_ignored_by_explicit_global_excludes( + project_path: &Path, + git_config_global: &Path, +) -> Option { + let config = fs::read_to_string(git_config_global).ok()?; + let excludes_file = config.lines().find_map(|line| { + let trimmed = line.trim(); + let (key, value) = trimmed.split_once('=')?; + (key.trim() == "excludesFile").then(|| PathBuf::from(value.trim())) + })?; + let excludes = fs::read_to_string(excludes_file).ok()?; + let dir_name = active_data_dir_name(project_path); + let dir_pattern = format!("{dir_name}/"); + Some(excludes.lines().any(|line| { + let trimmed = line.trim(); + !trimmed.is_empty() + && !trimmed.starts_with('#') + && (trimmed == dir_name || trimmed == dir_pattern) + })) +} + +#[cfg(test)] +fn git_subprocess_path() -> OsString { + std::env::var_os("PATH").unwrap_or_else(|| { + #[cfg(windows)] + { + OsString::new() + } + #[cfg(not(windows))] + { + OsString::from("/usr/bin:/bin") + } + }) +} + +#[cfg(not(test))] +fn git_subprocess_path() -> OsString { + std::env::var_os("PATH").unwrap_or_default() +} + +fn is_in_local_gitignore(project_path: &Path) -> bool { + let dir_name = active_data_dir_name(project_path); + let gitignore = project_path.join(".gitignore"); + match fs::read_to_string(&gitignore) { + Ok(content) => content.lines().any(|line| { + let trimmed = line.trim(); + trimmed == dir_name + || trimmed == format!("{dir_name}/") + || trimmed == format!("/{dir_name}") + }), + Err(_) => false, + } +} + +/// Resolves a CLI path argument to an absolute `PathBuf`. +/// +/// If `path` is `Some`, uses that value; otherwise falls back to the current +/// working directory. +pub fn resolve_path(path: Option) -> PathBuf { + let path = match path { + Some(p) => PathBuf::from(p), + None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + }; + absolutize_path(path) +} + +fn absolutize_path(path: PathBuf) -> PathBuf { + if path.is_absolute() { + path + } else { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join(path) + } +} + +/// Like [`resolve_path`], but when `path` is `None` it walks up from `cwd` +/// to find the nearest initialised `TraceDecay` project before falling back to +/// `cwd` itself. +/// +/// Used by `serve`, `sync`, and `status`. NOT used by `init` (which must +/// create a fresh project at the target directory). +pub fn resolve_path_with_discovery(path: Option) -> PathBuf { + if let Some(p) = path { + PathBuf::from(p) + } else { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + discover_project_root(&cwd) + .or_else(|| tracedecay_runtime_core::worktree::git_worktree_root(&cwd)) + .unwrap_or(cwd) + } +} + +/// Returns `true` if the path matches any of the configured `include` patterns. +/// +/// This is used to allow hidden (dot-prefixed) directories that would +/// otherwise be skipped by the file walker. +pub fn is_included(path: &str, config: &TraceDecayConfig) -> bool { + any_pattern_matches(&config.include, &[path]) +} + +/// Returns `true` if a directory should be pruned during scanning. +/// +/// Matches `dir/_` against exclude patterns (for `dir/**`-style globs) and +/// also matches `dir` itself (for bare `**/dirname`-style globs). This +/// ensures that patterns like `**/node_modules` and `**/node_modules/**` +/// both trigger directory pruning in `scan_files_walkdir`. +pub fn is_excluded_dir(dir_path: &str, config: &TraceDecayConfig) -> bool { + // Try both the dummy-file probe (catches `dir/**`) and the bare directory + // path (catches `**/dirname`). + let descendant_probe = format!("{dir_path}/_"); + any_pattern_matches(&config.exclude, &[&descendant_probe, dir_path]) +} + +/// Returns `true` if the file matches any of the configured exclude patterns. +pub fn is_excluded(file_path: &str, config: &TraceDecayConfig) -> bool { + any_pattern_matches(&config.exclude, &[file_path]) +} + +/// Glob semantics shared by every include/exclude test. Kept in one place so +/// the four entry points cannot drift apart on case or separator handling. +const PATTERN_MATCH_OPTIONS: glob::MatchOptions = glob::MatchOptions { + case_sensitive: true, + require_literal_separator: false, + require_literal_leading_dot: false, +}; + +/// True when any of `patterns` matches any of `candidates`. Unparseable +/// patterns are skipped rather than failing the whole test, matching the +/// long-standing behaviour of the include/exclude entry points. +/// +/// Callers pass every candidate string they want probed, built once per call: +/// the directory variants used to format their `dir/_` probe once per pattern. +fn any_pattern_matches(patterns: &[String], candidates: &[&str]) -> bool { + patterns.iter().any(|pattern_str| { + Pattern::new(pattern_str).is_ok_and(|pattern| { + candidates + .iter() + .any(|candidate| pattern.matches_with(candidate, PATTERN_MATCH_OPTIONS)) + }) + }) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests; diff --git a/crates/tracedecay-configuration/src/config/model/tests.rs b/crates/tracedecay-configuration/src/config/model/tests.rs new file mode 100644 index 0000000000..63ef72d7bd --- /dev/null +++ b/crates/tracedecay-configuration/src/config/model/tests.rs @@ -0,0 +1,754 @@ +use super::{ + TraceDecayConfig, is_excluded, is_excluded_dir, is_generated_path_segment, + is_ignored_by_explicit_global_excludes, is_ignored_by_git, is_included, parse_env_bool, +}; +use std::ffi::OsString; +use std::fs; +use std::path::PathBuf; +use std::process::Command; +use tempfile::TempDir; +use tracedecay_runtime_core::config::{ + GENERATED_DIR_SEGMENTS, PinnedUserDataDir, USER_DATA_DIR_ENV, db_filename, + discover_project_root, get_project_db_path, get_tracedecay_dir, is_ambient_project_root, + is_generated_dir_segment, lock_user_data_dir_test_env, user_data_dir, +}; +use tracedecay_semantic_contracts::{ + DEFAULT_FASTEMBED_MODEL_ID, SemanticConfig, SemanticProfileSelection, +}; + +struct EnvRestore { + key: &'static str, + previous: Option, +} + +impl EnvRestore { + fn set(key: &'static str, value: impl AsRef) -> Self { + let previous = std::env::var_os(key); + unsafe { + std::env::set_var(key, value); + } + Self { key, previous } + } +} + +impl Drop for EnvRestore { + fn drop(&mut self) { + unsafe { + match self.previous.take() { + Some(previous) => std::env::set_var(self.key, previous), + None => std::env::remove_var(self.key), + } + } + } +} + +#[test] +fn test_data_dir_defaults_to_tracedecay_for_new_installs() { + let root = TempDir::new().unwrap(); + assert_eq!( + get_tracedecay_dir(root.path()), + root.path().join(".tracedecay") + ); + assert_eq!( + get_project_db_path(root.path()), + root.path().join(".tracedecay/tracedecay.db") + ); +} + +#[test] +fn test_data_dir_uses_tracedecay_when_present() { + let root = TempDir::new().unwrap(); + fs::create_dir(root.path().join(".tracedecay")).unwrap(); + assert_eq!( + get_tracedecay_dir(root.path()), + root.path().join(".tracedecay") + ); +} + +#[cfg(unix)] +#[test] +fn user_data_dir_canonicalizes_symlinked_existing_parent() { + let _lock = lock_user_data_dir_test_env(); + let root = TempDir::new().unwrap(); + let real_home = root.path().join("real-home"); + let linked_home = root.path().join("linked-home"); + fs::create_dir_all(&real_home).unwrap(); + std::os::unix::fs::symlink(&real_home, &linked_home).unwrap(); + let _env = EnvRestore::set(USER_DATA_DIR_ENV, linked_home.join(".tracedecay")); + + assert_eq!( + user_data_dir().unwrap(), + real_home.canonicalize().unwrap().join(".tracedecay") + ); +} + +#[test] +fn nextest_shared_target_profile_is_isolated_by_test_name() { + let _lock = lock_user_data_dir_test_env(); + let root = TempDir::new().unwrap(); + let target = root.path().join("target"); + fs::create_dir_all(target.join("debug")).unwrap(); + let profile = target.join("test-profile/.tracedecay"); + let _profile = EnvRestore::set(USER_DATA_DIR_ENV, &profile); + let _binary_id = EnvRestore::set("NEXTEST_BINARY_ID", "tracedecay::storage_suite"); + let _test_name = EnvRestore::set("NEXTEST_TEST_NAME", "storage_suite::isolated_profile"); + + let resolved = user_data_dir().unwrap(); + + let canonical_profile = target + .canonicalize() + .unwrap() + .join("test-profile/.tracedecay"); + assert!(resolved.starts_with(canonical_profile.join("nextest"))); + assert_ne!(resolved, canonical_profile); +} + +#[test] +fn nextest_shared_target_profile_is_isolated_under_the_perf_profile() { + let _lock = lock_user_data_dir_test_env(); + let root = TempDir::new().unwrap(); + let target = root.path().join("target"); + // A `cargo test-ci` / CI checkout only ever builds `target/perf`. + fs::create_dir_all(target.join("perf")).unwrap(); + let profile = target.join("test-profile/.tracedecay"); + let _profile = EnvRestore::set(USER_DATA_DIR_ENV, &profile); + let _binary_id = EnvRestore::set("NEXTEST_BINARY_ID", "tracedecay::storage_suite"); + let _test_name = EnvRestore::set("NEXTEST_TEST_NAME", "storage_suite::perf_profile"); + + let resolved = user_data_dir().unwrap(); + + let canonical_profile = target + .canonicalize() + .unwrap() + .join("test-profile/.tracedecay"); + assert!(resolved.starts_with(canonical_profile.join("nextest"))); + assert_ne!(resolved, canonical_profile); +} + +#[test] +fn nextest_preserves_explicit_temp_profile_override() { + let _lock = lock_user_data_dir_test_env(); + let root = TempDir::new().unwrap(); + let profile = root.path().join("test-profile/.tracedecay"); + let _profile = EnvRestore::set(USER_DATA_DIR_ENV, &profile); + let _test_name = EnvRestore::set("NEXTEST_TEST_NAME", "storage_suite::explicit_profile"); + + assert_eq!( + user_data_dir().unwrap(), + root.path() + .canonicalize() + .unwrap() + .join("test-profile/.tracedecay") + ); +} + +#[test] +fn test_db_filename_tracks_dir_brand() { + assert_eq!( + db_filename(std::path::Path::new("/p/.tracedecay")), + "tracedecay.db" + ); +} + +#[test] +fn test_is_included_matches_glob() { + let config = TraceDecayConfig { + include: vec![".github/**".to_string()], + ..TraceDecayConfig::default() + }; + assert!(is_included(".github/workflows/ci.yml", &config)); + assert!(is_included(".github/scripts/build.sh", &config)); + assert!(!is_included(".vscode/settings.json", &config)); + assert!(!is_included("src/main.rs", &config)); +} + +#[test] +fn test_is_included_empty_matches_nothing() { + let config = TraceDecayConfig::default(); + assert!(!is_included(".github/workflows/ci.yml", &config)); +} + +#[test] +fn test_include_records_explicit_override_even_when_excluded() { + let config = TraceDecayConfig { + include: vec![".config/**".to_string()], + exclude: vec![".config/secret/**".to_string()], + ..TraceDecayConfig::default() + }; + assert!(is_included(".config/secret/key.rs", &config)); + assert!(is_excluded(".config/secret/key.rs", &config)); +} + +#[test] +fn test_default_excludes_nested_node_modules() { + let config = TraceDecayConfig::default(); + // Top-level node_modules — should be excluded + assert!(is_excluded("node_modules/express/index.js", &config)); + // Nested node_modules inside a sub-project — must also be excluded + assert!(is_excluded( + "projectA/node_modules/express/index.js", + &config + )); + assert!(is_excluded( + "packages/web/node_modules/react/index.js", + &config + )); + assert!(is_excluded("dist/main.js", &config)); + assert!(is_excluded("packages/web/dist/main.js", &config)); + assert!(is_excluded("coverage/lcov.js", &config)); + assert!(is_excluded("packages/web/.next/server/app.js", &config)); +} + +#[test] +fn test_dir_pruning_pattern_matches_nested_dirs() { + // scan_files_walkdir checks is_excluded("{dir}/_") for directory pruning. + // Patterns like **/node_modules/** must match the dummy-file probe. + let config = TraceDecayConfig::default(); + assert!(is_excluded("node_modules/_", &config)); + assert!(is_excluded("projectA/node_modules/_", &config)); +} + +#[test] +fn test_is_excluded_dir_bare_pattern() { + // Users may write "**/node_modules" (no trailing /**). + // is_excluded_dir should match both bare and /**-suffixed patterns. + let config = TraceDecayConfig { + exclude: vec!["**/dist".to_string()], + ..TraceDecayConfig::default() + }; + assert!(is_excluded_dir("dist", &config)); + assert!(is_excluded_dir("packages/web/dist", &config)); + // Files inside dist should still be caught by accept_file's is_excluded + // but dir pruning prevents even walking into the directory. +} + +#[test] +fn test_is_in_gitignore_respects_global_excludes_file() { + let sandbox = TempDir::new().unwrap(); + let repo = sandbox.path().join("repo"); + fs::create_dir(&repo).unwrap(); + + let mut init = Command::new("git"); + init.env_clear().env("PATH", super::git_subprocess_path()); + let init_status = init + .arg("-C") + .arg(&repo) + .arg("init") + .arg("-q") + .env("GIT_CONFIG_NOSYSTEM", "1") + .status() + .unwrap(); + assert!(init_status.success(), "git init should succeed"); + + let excludes = sandbox.path().join("global_ignore"); + fs::write(&excludes, ".tracedecay\n").unwrap(); + + let git_config = sandbox.path().join("gitconfig"); + let excludes_value = excludes.to_string_lossy().replace('\\', "/"); + fs::write( + &git_config, + format!("[core]\n\texcludesFile = {excludes_value}\n"), + ) + .unwrap(); + + let ignored = is_ignored_by_git(&repo, Some(&git_config)); + + assert_eq!(ignored, Some(true)); +} + +#[test] +fn test_explicit_global_excludes_ignores_comments_and_blank_lines() { + let sandbox = TempDir::new().unwrap(); + let repo = sandbox.path().join("repo"); + fs::create_dir(&repo).unwrap(); + + let excludes = sandbox.path().join("global_ignore"); + fs::write(&excludes, "\n# comment\n.tracedecay/\n").unwrap(); + + let git_config = sandbox.path().join("gitconfig"); + let excludes_value = excludes.to_string_lossy().replace('\\', "/"); + fs::write( + &git_config, + format!("[core]\n\texcludesFile = {excludes_value}\n"), + ) + .unwrap(); + + let ignored = is_ignored_by_explicit_global_excludes(&repo, &git_config); + + assert_eq!(ignored, Some(true)); +} + +#[test] +fn semantic_config_defaults_to_offline_healthy_baseline() { + let config = TraceDecayConfig::default(); + assert_eq!(config.semantic, SemanticConfig::default()); + assert_eq!( + config.semantic.selected_model.as_deref(), + Some(DEFAULT_FASTEMBED_MODEL_ID) + ); + assert!(config.semantic.auto_download); + assert!(config.semantic.active_profile.is_none()); + assert!(config.semantic.rollback_profile.is_none()); + assert!(config.semantic.validate().is_ok()); + assert!(config.semantic.resources.max_concurrent_sessions >= 1); + + let json = serde_json::to_string(&config).unwrap(); + let parsed: TraceDecayConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.semantic, config.semantic); +} + +/// Host-absolute fixture path: `artifact_path` validation requires +/// `Path::is_absolute`, which a bare `/...` literal fails on Windows. +fn absolute_fixture_path(posix: &str) -> PathBuf { + if cfg!(windows) { + PathBuf::from(format!("C:{}", posix.replace('/', "\\"))) + } else { + PathBuf::from(posix) + } +} + +#[test] +fn semantic_config_accepts_only_explicit_local_installed_profiles() { + let local = SemanticProfileSelection { + profile_id: "code-embedding.v1".to_owned(), + accepted_profile_digest: tracedecay_domain::ManifestDigest::new(format!( + "sha256:{}", + "1".repeat(64) + )) + .unwrap(), + artifact_digest: "a".repeat(64), + artifact_path: absolute_fixture_path("/var/lib/tracedecay/models/code-embedding"), + }; + let mut semantic = SemanticConfig { + active_profile: Some(local.clone()), + rollback_profile: Some(SemanticProfileSelection { + profile_id: "code-embedding.previous".to_owned(), + accepted_profile_digest: tracedecay_domain::ManifestDigest::new(format!( + "sha256:{}", + "2".repeat(64) + )) + .unwrap(), + artifact_digest: "b".repeat(64), + artifact_path: absolute_fixture_path( + "/var/lib/tracedecay/models/code-embedding-previous", + ), + }), + ..SemanticConfig::default() + }; + assert!(semantic.validate().is_ok()); + + semantic.active_profile.as_mut().unwrap().artifact_path = + std::path::PathBuf::from("https://models.example/code-embedding"); + assert!( + semantic.validate().is_err(), + "runtime configuration must not admit network or ambient-cache discovery" + ); + semantic.active_profile = Some(local.clone()); + semantic.rollback_profile = Some(local); + assert!( + semantic.validate().is_err(), + "active and rollback selections must remain distinct" + ); +} + +#[test] +fn semantic_resource_ceilings_reject_zero_or_incoherent_limits() { + let mut semantic = SemanticConfig::default(); + semantic.resources.max_threads = 0; + assert!(semantic.validate().is_err()); + + semantic = SemanticConfig::default(); + semantic.resources.max_model_bytes = semantic.resources.max_resident_bytes + 1; + assert!(semantic.validate().is_err()); +} + +#[test] +fn telemetry_timing_defaults_on_and_round_trips() { + let config = TraceDecayConfig::default(); + assert!(config.telemetry.timings); + let json = serde_json::to_string(&config).unwrap(); + let parsed: TraceDecayConfig = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.telemetry, super::TelemetryConfig::default()); + + let legacy = r#"{ + "version": 1, + "root_dir": "/tmp/proj", + "exclude": [], + "max_file_size": 1048576, + "extract_docstrings": true, + "track_call_sites": true + }"#; + let parsed: TraceDecayConfig = serde_json::from_str(legacy).unwrap(); + assert!(parsed.telemetry.timings); + + let disabled = r#"{ + "version": 1, + "root_dir": "/tmp/proj", + "exclude": [], + "max_file_size": 1048576, + "extract_docstrings": true, + "track_call_sites": true, + "telemetry": { "timings": false } + }"#; + let parsed: TraceDecayConfig = serde_json::from_str(disabled).unwrap(); + assert!(!parsed.telemetry.timings); +} + +#[test] +fn diagnostics_prewarm_round_trips_and_defaults_off() { + let config = TraceDecayConfig::default(); + assert!(!config.diagnostics_prewarm, "prewarm must default off"); + let json = serde_json::to_string(&config).unwrap(); + let parsed: TraceDecayConfig = serde_json::from_str(&json).unwrap(); + assert!(!parsed.diagnostics_prewarm); + + // Explicit true round-trips, and old configs without the key default. + let mut on = config.clone(); + on.diagnostics_prewarm = true; + let parsed: TraceDecayConfig = + serde_json::from_str(&serde_json::to_string(&on).unwrap()).unwrap(); + assert!(parsed.diagnostics_prewarm); + let legacy = r#"{ + "version": 1, + "root_dir": "/tmp/proj", + "exclude": [], + "max_file_size": 1048576, + "extract_docstrings": true, + "track_call_sites": true + }"#; + let parsed: TraceDecayConfig = serde_json::from_str(legacy).unwrap(); + assert!(!parsed.diagnostics_prewarm); +} + +#[test] +fn config_without_sync_key_deserializes_to_default_sync() { + // Old config.json files predate the `sync` table; the field-level + // `#[serde(default)]` must fill it in. + let json = r#"{ + "version": 1, + "root_dir": "/tmp/proj", + "exclude": [], + "max_file_size": 1048576, + "extract_docstrings": true, + "track_call_sites": true + }"#; + let parsed: TraceDecayConfig = serde_json::from_str(json).unwrap(); + assert_eq!(parsed.sync, crate::SyncConfig::default()); +} + +#[test] +fn partial_sync_table_fills_missing_fields_with_defaults() { + // Only two sync keys present; every other field must default. + let json = r#"{ + "version": 1, + "root_dir": "/tmp/proj", + "exclude": [], + "max_file_size": 1048576, + "extract_docstrings": true, + "track_call_sites": true, + "sync": { "auto_watch": false, "backstop_interval_mins": 99 } + }"#; + let parsed: TraceDecayConfig = serde_json::from_str(json).unwrap(); + assert!(!parsed.sync.auto_watch); + assert!(!parsed.sync.watch_linked_worktrees); + assert_eq!(parsed.sync.backstop_interval_mins, 99); + // Untouched fields keep their defaults. + assert_eq!(parsed.sync.watch_debounce_ms, 2000); + assert_eq!(parsed.sync.max_concurrent_syncs, 2); + assert!(parsed.sync.read_refresh); +} + +#[test] +fn pr_autotrack_defaults_off_and_survives_missing_keys() { + // Back-compat: a config predating the PR-autotrack keys must default the + // feature OFF and to the 300s poll cadence. + let json = r#"{ + "version": 1, + "root_dir": "/tmp/proj", + "exclude": [], + "max_file_size": 1048576, + "extract_docstrings": true, + "track_call_sites": true, + "sync": { "auto_watch": true } + }"#; + let parsed: TraceDecayConfig = serde_json::from_str(json).unwrap(); + assert!(!parsed.sync.auto_track_pr_branches); + assert_eq!(parsed.sync.auto_track_pr_poll_secs, 300); + assert_eq!(parsed.sync.effective_auto_track_pr_poll_secs(), 300); +} + +#[test] +fn pr_autotrack_round_trips_and_clamps_poll_floor() { + let json = r#"{ + "version": 1, + "root_dir": "/tmp/proj", + "exclude": [], + "max_file_size": 1048576, + "extract_docstrings": true, + "track_call_sites": true, + "sync": { "auto_track_pr_branches": true, "auto_track_pr_poll_secs": 5 } + }"#; + let parsed: TraceDecayConfig = serde_json::from_str(json).unwrap(); + assert!(parsed.sync.auto_track_pr_branches); + assert_eq!(parsed.sync.auto_track_pr_poll_secs, 5); + // A too-small interval is clamped up to the safety floor. + assert_eq!( + parsed.sync.effective_auto_track_pr_poll_secs(), + crate::MIN_AUTO_TRACK_PR_POLL_SECS + ); + + // Serialize → deserialize preserves the raw values. + let round = serde_json::to_string(&parsed).unwrap(); + let reparsed: TraceDecayConfig = serde_json::from_str(&round).unwrap(); + assert_eq!(reparsed.sync, parsed.sync); +} + +#[test] +fn parse_env_bool_shares_canonical_truthy_spellings() { + for raw in ["1", "true", "TRUE", "yes", "on", " YES "] { + assert_eq!(parse_env_bool(raw), Some(true), "{raw}"); + } + for raw in ["0", "false", "FALSE"] { + assert_eq!(parse_env_bool(raw), Some(false), "{raw}"); + } + assert_eq!(parse_env_bool("maybe"), None); +} + +#[test] +fn pr_autotrack_env_overrides() { + let _lock = lock_user_data_dir_test_env(); + let _enable = EnvRestore::set("TRACEDECAY_SYNC_AUTO_TRACK_PR_BRANCHES", "true"); + let _poll = EnvRestore::set("TRACEDECAY_SYNC_AUTO_TRACK_PR_POLL_SECS", "120"); + + let overridden = crate::SyncConfig::default().with_env_overrides(); + assert!(overridden.auto_track_pr_branches); + assert_eq!(overridden.auto_track_pr_poll_secs, 120); +} + +#[test] +fn sync_config_env_overrides_bool_and_int() { + let _lock = lock_user_data_dir_test_env(); + let _watch = EnvRestore::set("TRACEDECAY_SYNC_AUTO_WATCH", "false"); + let _linked = EnvRestore::set("TRACEDECAY_SYNC_WATCH_LINKED_WORKTREES", "true"); + let _debounce = EnvRestore::set("TRACEDECAY_SYNC_WATCH_DEBOUNCE_MS", "5000"); + // Unparsable ints/bools are ignored (field keeps its base value). + let _bad = EnvRestore::set("TRACEDECAY_SYNC_MAX_CONCURRENT_SYNCS", "not-a-number"); + + let overridden = crate::SyncConfig::default().with_env_overrides(); + assert!(!overridden.auto_watch); + assert!(overridden.watch_linked_worktrees); + assert_eq!(overridden.watch_debounce_ms, 5000); + assert_eq!( + overridden.max_concurrent_syncs, + crate::SyncConfig::default().max_concurrent_syncs + ); +} + +#[test] +fn implicit_discovery_never_selects_the_user_profile_root() { + let _profile = PinnedUserDataDir::new(); + let home = PathBuf::from(std::env::var_os("HOME").expect("pinned HOME")); + fs::write(get_project_db_path(&home), b"").expect("ambient project marker"); + let nested = home.join("unrelated/nested"); + fs::create_dir_all(&nested).expect("nested directory"); + + assert!(is_ambient_project_root(&home)); + assert_eq!(discover_project_root(&nested), None); +} + +// --------------------------------------------------------------------------- +// Shared generated/vendored segment list +// +// GENERATED_DIR_SEGMENTS unifies what used to be four independently +// hand-maintained lists: this module's own DEFAULT_EXCLUDE_PATTERNS, +// tracedecay::scan's is_skipped_dir_hint, migrate::inventory's +// should_prune_dir, and mcp::tools::handlers::redundancy's +// is_generated_path. These tests pin the union those four call sites need +// and spot-check that segments unique to one of the formerly-separate lists +// are now recognized everywhere. +// --------------------------------------------------------------------------- + +#[test] +fn generated_dir_segments_cover_the_union_all_call_sites_need() { + // Formerly scan.rs-only (its HINTABLE_DIRS list). + for segment in [ + "node_modules", + "vendor", + "build", + "dist", + "out", + "coverage", + ".cache", + ".next", + ".turbo", + ".gradle", + ".venv", + "venv", + "__pycache__", + ] { + assert!( + GENERATED_DIR_SEGMENTS.contains(&segment), + "{segment} (from scan.rs's old list) missing from GENERATED_DIR_SEGMENTS" + ); + } + // Formerly migrate::inventory-only addition beyond the scan.rs set. + assert!(GENERATED_DIR_SEGMENTS.contains(&"target")); + // Formerly redundancy.rs-only addition beyond the scan.rs set. + assert!(GENERATED_DIR_SEGMENTS.contains(&".worktrees")); + // `.git` is intentionally NOT part of the shared list — it stays a + // site-local addition in migrate::inventory::should_prune_dir (see its + // doc comment) because it's VCS metadata, not generated/vendored code. + assert!(!GENERATED_DIR_SEGMENTS.contains(&".git")); +} + +#[test] +fn is_generated_dir_segment_delegates_for_segments_unique_to_one_former_list() { + // Every one of these previously lived in only one of the four lists; + // is_generated_dir_segment must now recognize all of them. + for segment in ["target", ".worktrees", "coverage", ".venv", "__pycache__"] { + assert!( + is_generated_dir_segment(segment), + "{segment} should be recognized as a generated/vendored segment" + ); + } + assert!(!is_generated_dir_segment("src")); + assert!(!is_generated_dir_segment("builder")); +} + +#[test] +fn is_generated_path_segment_matches_segments_and_minified_suffix() { + assert!(is_generated_path_segment("packages/web/target/debug/x")); + assert!(is_generated_path_segment(".worktrees/feature/src/lib.rs")); + assert!(is_generated_path_segment("assets/app.min.js")); + assert!(is_generated_path_segment("assets/app.min.css")); + assert!(!is_generated_path_segment("src/redundancy.rs")); + assert!(!is_generated_path_segment("builder/mod.rs")); +} + +#[test] +fn default_excludes_still_catch_target_and_worktrees() { + // Regression guard for the DEFAULT_EXCLUDE_PATTERNS rebuild: target/** + // previously had no **/target/** nested form (a real drift bug this + // unification fixes), and .worktrees was never excluded by default at + // all. + let config = TraceDecayConfig::default(); + assert!(is_excluded("target/debug/build", &config)); + assert!(is_excluded("crates/sub/target/debug/build", &config)); + assert!(is_excluded(".worktrees/feature/src/lib.rs", &config)); + // Site-local additions (not part of GENERATED_DIR_SEGMENTS) still work. + assert!(is_excluded(".git/HEAD", &config)); + assert!(is_excluded(".tracedecay/tracedecay.db", &config)); + assert!(is_excluded("bin/cli.js", &config)); +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod retention_config_tests { + use crate::{RetentionConfig, SyncConfig}; + use tracedecay_contracts::storage::compaction::CompactionThresholdConfig; + + #[test] + fn default_retention_runs_only_safe_bounded_maintenance() { + let retention = RetentionConfig::default(); + assert!( + retention.session_lcm.enabled, + "projection-durable session dedupe enabled by default" + ); + assert_eq!(retention.session_lcm.offload_after_days, Some(30)); + assert_eq!(retention.session_lcm.drop_after_days, Some(180)); + assert_eq!(retention.session_lcm.dedupe_projected_after_days, Some(30)); + assert_eq!(retention.session_lcm.max_batch_size, 500); + assert!( + retention.observation.enabled, + "released observation evidence maintenance is active by default" + ); + assert_eq!(retention.observation.anchor_release_after_days, Some(30)); + assert_eq!( + retention.observation.observation_release_after_days, + Some(30) + ); + assert_eq!( + retention.observation.provenance_release_after_days, + Some(30) + ); + assert_eq!(retention.orphan_store_gc_days, Some(30)); + assert_eq!(retention.incident_debris_retention_days, Some(30)); + let compaction = retention.compaction.expect("compaction enabled"); + assert!((compaction.free_page_ratio_threshold - 0.25).abs() < f64::EPSILON); + assert_eq!(compaction.minimum_reclaimable_bytes, 64 * 1024 * 1024); + assert_eq!(compaction.max_pages_per_tick, 1024); + assert_eq!(compaction, CompactionThresholdConfig::default()); + assert!(retention.store_soft_budgets_bytes.is_empty()); + // A default SyncConfig carries the same bounded retention tree. + assert_eq!(SyncConfig::default().retention, retention); + } + + #[test] + fn empty_json_object_deserializes_to_safe_defaults() { + // A serde-compat empty object (older config with no retention block) + // must resolve the same safe maintenance policy. + let retention: RetentionConfig = serde_json::from_str("{}").unwrap(); + assert_eq!(retention, RetentionConfig::default()); + + let nested: RetentionConfig = + serde_json::from_str(r#"{"session_lcm":{},"observation":{}}"#).unwrap(); + assert_eq!(nested, RetentionConfig::default()); + assert!(nested.observation.reclaim_superseded_cursor_advances); + } + + #[test] + fn retention_rejects_immediate_collection_and_invalid_compaction_ratio() { + let retention = RetentionConfig { + orphan_store_gc_days: Some(0), + ..RetentionConfig::default() + }; + assert!(retention.validate().is_err()); + + let retention = RetentionConfig { + incident_debris_retention_days: Some(0), + ..RetentionConfig::default() + }; + assert!(retention.validate().is_err()); + + let mut retention = RetentionConfig::default(); + retention + .compaction + .as_mut() + .expect("default compaction") + .free_page_ratio_threshold = 1.01; + assert!(retention.validate().is_err()); + } + + #[test] + fn retention_config_json_round_trips_with_windows_set() { + let json = r#"{ + "session_lcm": { "enabled": true, "drop_after_days": 30 }, + "observation": { "enabled": true, "anchor_release_after_days": 45 }, + "orphan_store_gc_days": 14, + "incident_debris_retention_days": 21, + "compaction": { "free_page_ratio_threshold": 0.25, "minimum_reclaimable_bytes": 1000000 }, + "store_soft_budgets_bytes": { "sessions.db": 2000000000 }, + "interval_hours": 12 + }"#; + let retention: RetentionConfig = serde_json::from_str(json).unwrap(); + assert!(retention.session_lcm.enabled); + assert_eq!(retention.session_lcm.drop_after_days, Some(30)); + assert!(retention.observation.enabled); + assert_eq!(retention.observation.anchor_release_after_days, Some(45)); + assert_eq!(retention.orphan_store_gc_days, Some(14)); + assert_eq!(retention.incident_debris_retention_days, Some(21)); + assert_eq!(retention.interval_hours, 12); + let compaction = retention.compaction.expect("compaction configured"); + assert!((compaction.free_page_ratio_threshold - 0.25).abs() < f64::EPSILON); + assert_eq!(compaction.minimum_reclaimable_bytes, 1_000_000); + assert_eq!( + retention.store_soft_budgets_bytes.get("sessions.db"), + Some(&2_000_000_000) + ); + + // Re-serialize and re-parse: the tree is stable across a round trip. + let reserialized = serde_json::to_string(&retention).unwrap(); + let reparsed: RetentionConfig = serde_json::from_str(&reserialized).unwrap(); + assert_eq!(retention, reparsed); + } +} diff --git a/crates/tracedecay-configuration/src/lib.rs b/crates/tracedecay-configuration/src/lib.rs index 4d6df1805b..9022b89629 100644 --- a/crates/tracedecay-configuration/src/lib.rs +++ b/crates/tracedecay-configuration/src/lib.rs @@ -7,11 +7,16 @@ pub mod config; pub mod configuration; +pub use config::model::{ + CONFIG_FILENAME, MIN_AUTO_TRACK_PR_POLL_SECS, RetentionConfig, SYNC_RETENTION_SETTING_KEY, + SyncConfig, TelemetryConfig, TraceDecayConfig, brand_env, get_config_path, is_excluded, + is_excluded_dir, is_generated_path_segment, is_in_gitignore, is_included, load_config, + load_config_from_path, resolve_path, resolve_path_with_discovery, save_config_to_path, +}; pub use config::{ OpenedRuntimeConfiguration, PinnedRuntimeConfiguration, PinnedRuntimeConfigurationCachePort, - RuntimeConfigurationTarget, SyncConfig, TelemetryConfig, TraceDecayConfig, - cached_pinned_runtime_configuration, install_pinned_runtime_configuration_cache, - publish_pinned_runtime_configuration, + RuntimeConfigurationTarget, cached_pinned_runtime_configuration, + install_pinned_runtime_configuration_cache, publish_pinned_runtime_configuration, }; pub use configuration::{ AuthorizedActor, CONFIGURATION_AUDIT_PAGE_LIMIT, ComponentConfigurationState, diff --git a/crates/tracedecay-contracts/src/storage/compaction.rs b/crates/tracedecay-contracts/src/storage/compaction.rs index 8522786bfb..101cd6bddf 100644 --- a/crates/tracedecay-contracts/src/storage/compaction.rs +++ b/crates/tracedecay-contracts/src/storage/compaction.rs @@ -29,6 +29,37 @@ pub enum CompactionPlacementV1 { DeferredBackground, } +/// Incremental-vacuum compaction trigger consumed by maintenance and persisted daemon +/// configuration. Threads [`CompactionTriggerPolicyV1`] through configured +/// thresholds: the pass samples a store's free-page ratio and, when this +/// threshold is met, runs a bounded incremental vacuum off the hot path. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct CompactionThresholdConfig { + /// Free-page ratio at or above which an incremental vacuum is scheduled. + pub free_page_ratio_threshold: f64, + /// Minimum reclaimable free bytes below which compaction is not worth it. + #[serde(default)] + pub minimum_reclaimable_bytes: u64, + /// Upper bound on freelist pages reclaimed per tick, keeping each vacuum + /// bounded and off the hot path. + #[serde(default = "default_compaction_max_pages_per_tick")] + pub max_pages_per_tick: u32, +} + +fn default_compaction_max_pages_per_tick() -> u32 { + 1024 +} + +impl Default for CompactionThresholdConfig { + fn default() -> Self { + Self { + free_page_ratio_threshold: 0.25, + minimum_reclaimable_bytes: 64 * 1024 * 1024, + max_pages_per_tick: default_compaction_max_pages_per_tick(), + } + } +} + /// The compaction trigger policy: a free-page-ratio threshold plus a floor on /// reclaimable bytes so a tiny-but-fragmented store is not vacuumed pointlessly. #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] diff --git a/crates/tracedecay-dashboard-api/src/config.rs b/crates/tracedecay-dashboard-api/src/config.rs index 7949271edb..4ba18aa4c6 100644 --- a/crates/tracedecay-dashboard-api/src/config.rs +++ b/crates/tracedecay-dashboard-api/src/config.rs @@ -1,34 +1,14 @@ //! Dashboard configuration values and injected root-owned read authority. -use std::collections::BTreeMap; use std::path::Path; use std::sync::{Arc, OnceLock}; -use tracedecay_contracts::storage::{StorageByteSizeV1, StoreKeyV1, StoreSizeBudgetV1}; use tracedecay_domain::errors::{Result, TraceDecayError}; pub use tracedecay_application::config::retrieval; +pub use tracedecay_configuration::RetentionConfig; pub use tracedecay_configuration::config::*; -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct RetentionConfig { - pub store_soft_budgets_bytes: BTreeMap, -} - -impl RetentionConfig { - pub fn store_soft_budget(&self, store: &str) -> Result> { - let Some(bytes) = self.store_soft_budgets_bytes.get(store).copied() else { - return Ok(None); - }; - let budget = StoreSizeBudgetV1 { - store: StoreKeyV1::new(store.to_owned()).map_err(config_error)?, - soft_limit_bytes: StorageByteSizeV1(bytes), - }; - budget.validate().map_err(config_error)?; - Ok(Some(budget)) - } -} - pub trait DashboardConfigurationReadPort: Send + Sync { fn cached_runtime_configuration( &self, diff --git a/crates/tracedecay-dashboard-api/src/events_api.rs b/crates/tracedecay-dashboard-api/src/events_api.rs index 42d4ee492d..0aed231727 100644 --- a/crates/tracedecay-dashboard-api/src/events_api.rs +++ b/crates/tracedecay-dashboard-api/src/events_api.rs @@ -981,7 +981,7 @@ pub(crate) async fn dashboard_state_fixture( store_root, config_path: project.path().join("config.json"), dashboard_root, - retention_config: crate::config::RetentionConfig::default(), + retention_config: tracedecay_configuration::RetentionConfig::default(), user_settings: Arc::new(ProductionUserSettingsDaemonClient::default()), profile_code_index_worker_settings: None, token_counts: Arc::new(crate::token_count::TokenCountCache::new()), diff --git a/crates/tracedecay-dashboard-api/src/lib.rs b/crates/tracedecay-dashboard-api/src/lib.rs index ac0fcb54e2..1453ff1c81 100644 --- a/crates/tracedecay-dashboard-api/src/lib.rs +++ b/crates/tracedecay-dashboard-api/src/lib.rs @@ -477,7 +477,7 @@ pub struct DashboardState { pub dashboard_root: PathBuf, /// Retention policy resolved with the owning runtime configuration. /// Dashboard reads must not re-open mutable config input per request. - pub retention_config: crate::config::RetentionConfig, + pub retention_config: tracedecay_configuration::RetentionConfig, /// Daemon-owned user-profile settings authority. Dashboard routes never /// load or mutate `config.toml` directly. pub user_settings: Arc, @@ -2412,7 +2412,7 @@ mod authority_tests { store_root: layout.data_root.clone(), config_path: layout.config_path.clone(), dashboard_root: layout.dashboard_root.clone(), - retention_config: crate::config::RetentionConfig::default(), + retention_config: tracedecay_configuration::RetentionConfig::default(), user_settings: Arc::new( tracedecay_configuration::ProductionUserSettingsDaemonClient::default(), ), diff --git a/crates/tracedecay-dashboard-api/src/settings_api.rs b/crates/tracedecay-dashboard-api/src/settings_api.rs index ea6ebbf461..42cf055337 100644 --- a/crates/tracedecay-dashboard-api/src/settings_api.rs +++ b/crates/tracedecay-dashboard-api/src/settings_api.rs @@ -32,9 +32,9 @@ use crate::application::settings_control::{ TelemetrySettingsPatchV1, context_scout_settings_are_enabled, effective_context_scout_settings, preview_project_settings, }; -use crate::config::TraceDecayConfig; use crate::request_identity::{GlobalRequestSurface, mint_global_request_id}; use tracedecay_automation_runtime::automation::config::from_configuration_snapshot; +use tracedecay_configuration::config::TraceDecayConfig; use tracedecay_configuration::{ DirectConfigurationMutation, UserSettingsMutationV1, UserSettingsSnapshotV1, parse_duration_millis, plan_user_settings_mutation, diff --git a/crates/tracedecay-dashboard-api/src/storage_telemetry_api.rs b/crates/tracedecay-dashboard-api/src/storage_telemetry_api.rs index 186167fb19..aca5143652 100644 --- a/crates/tracedecay-dashboard-api/src/storage_telemetry_api.rs +++ b/crates/tracedecay-dashboard-api/src/storage_telemetry_api.rs @@ -8,7 +8,7 @@ //! //! Both typed dimensions now have a real server-side source: //! - **budget**: the owner-configurable soft budgets live in the configuration -//! control plane under [`crate::config::SYNC_RETENTION_SETTING_KEY`] +//! control plane under [`tracedecay_configuration::SYNC_RETENTION_SETTING_KEY`] //! (`sync.retention.v1` → `store_soft_budgets_bytes`, keyed by store key). //! A configured budget is evaluated against the live sample; a store with no //! entry reports `unset` — *the owner has not configured a budget*, which is @@ -202,7 +202,7 @@ enum ResolvedStoreBudgetV1 { /// Resolve one store's owner-configured soft budget from the retention config. fn resolve_store_budget( store_name: &str, - retention: Option<&crate::config::RetentionConfig>, + retention: Option<&tracedecay_configuration::RetentionConfig>, ) -> ResolvedStoreBudgetV1 { let Some(retention) = retention else { return ResolvedStoreBudgetV1::Unknown( @@ -553,7 +553,7 @@ async fn sample_store( /// and growth dimensions. fn telemetry_entry( sampled: SampledStoreV1, - retention: Option<&crate::config::RetentionConfig>, + retention: Option<&tracedecay_configuration::RetentionConfig>, ) -> StoreTelemetryEntryV1 { let sample = sampled.sample(); let (total_bytes, free_bytes, free_page_ratio) = sample.map_or((None, None, None), |sample| { @@ -590,7 +590,7 @@ fn telemetry_entry( fn budget_dimension( store_name: &str, sample: Option<&StoreSizeSampleV1>, - retention: Option<&crate::config::RetentionConfig>, + retention: Option<&tracedecay_configuration::RetentionConfig>, ) -> StoreBudgetDimensionV1 { let budget = match resolve_store_budget(store_name, retention) { ResolvedStoreBudgetV1::Configured(budget) => budget, @@ -666,8 +666,8 @@ fn fallback_store_key() -> StoreKeyV1 { #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { use super::*; - use crate::config::RetentionConfig; use crate::read_model::{DashboardDomainStateV1, DashboardFreshnessStateV1}; + use tracedecay_configuration::RetentionConfig; async fn state_for_test() -> (tempfile::TempDir, DashboardState, u64) { let (project, state) = diff --git a/crates/tracedecay-dashboard-api/src/tracedecay.rs b/crates/tracedecay-dashboard-api/src/tracedecay.rs index ba87f48e39..ce5d59bd82 100644 --- a/crates/tracedecay-dashboard-api/src/tracedecay.rs +++ b/crates/tracedecay-dashboard-api/src/tracedecay.rs @@ -9,7 +9,7 @@ use tracedecay_configuration::UserSettingsDaemonClient; use tracedecay_runtime_core::db::Database; use tracedecay_runtime_core::storage::StoreLayout; -use crate::config::RetentionConfig; +use tracedecay_configuration::RetentionConfig; /// Immutable project values captured by the composition root for dashboard /// state construction. diff --git a/crates/tracedecay-maintenance/src/generation.rs b/crates/tracedecay-maintenance/src/generation.rs index 6fb4c10856..7f6a6bc838 100644 --- a/crates/tracedecay-maintenance/src/generation.rs +++ b/crates/tracedecay-maintenance/src/generation.rs @@ -2,13 +2,13 @@ use crate::compaction_receipt::record_live_compaction_outcome; use crate::lease::ProjectStoreMaintenanceLeaseV1; -use crate::retention::branch_compaction::CompactionThresholdConfig; use crate::store_maintenance::{ CodeGenerationRetentionOutcomeV1, run_branch_compaction, run_code_generation_retention, run_code_index_scope_reconciliation, run_semantic_vector_generation_retention, }; use crate::telemetry::StoreTelemetrySamplingRegistry; use crate::tick::{MaintenanceContinuation, MaintenanceTickOutcome}; +use tracedecay_contracts::storage::compaction::CompactionThresholdConfig; /// Run the production generation-maintenance journey for one admitted store lease. /// diff --git a/crates/tracedecay-maintenance/src/retention/branch_compaction.rs b/crates/tracedecay-maintenance/src/retention/branch_compaction.rs index d1038be3c7..902593077f 100644 --- a/crates/tracedecay-maintenance/src/retention/branch_compaction.rs +++ b/crates/tracedecay-maintenance/src/retention/branch_compaction.rs @@ -43,44 +43,14 @@ use std::path::{Path, PathBuf}; use rusqlite::{Connection, OpenFlags}; -use serde::{Deserialize, Serialize}; -use tracedecay_contracts::storage::compaction::CompactionTriggerPolicyV1; +use tracedecay_contracts::storage::compaction::{ + CompactionThresholdConfig, CompactionTriggerPolicyV1, +}; use tracedecay_contracts::storage::identity::{FreePageRatioV1, StorageByteSizeV1, StoreKeyV1}; use tracedecay_contracts::storage::telemetry::StoreSizeSampleV1; use tracedecay_domain::UtcMicros; use tracedecay_runtime_core::sqlite_read_snapshot::{BOUNDED_PROBE_BUSY_TIMEOUT, pragma_u64}; -/// Incremental-vacuum compaction trigger consumed by this pass and by daemon -/// owner config. Threads [`CompactionTriggerPolicyV1`] through configured -/// thresholds: the pass samples a store's free-page ratio and, when this -/// threshold is met, runs a bounded incremental vacuum off the hot path. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct CompactionThresholdConfig { - /// Free-page ratio at or above which an incremental vacuum is scheduled. - pub free_page_ratio_threshold: f64, - /// Minimum reclaimable free bytes below which compaction is not worth it. - #[serde(default)] - pub minimum_reclaimable_bytes: u64, - /// Upper bound on freelist pages reclaimed per tick, keeping each vacuum - /// bounded and off the hot path. - #[serde(default = "default_compaction_max_pages_per_tick")] - pub max_pages_per_tick: u32, -} - -fn default_compaction_max_pages_per_tick() -> u32 { - 1024 -} - -impl Default for CompactionThresholdConfig { - fn default() -> Self { - Self { - free_page_ratio_threshold: 0.25, - minimum_reclaimable_bytes: 64 * 1024 * 1024, - max_pages_per_tick: default_compaction_max_pages_per_tick(), - } - } -} - /// `PRAGMA auto_vacuum` mode in which `incremental_vacuum` actually reclaims /// pages. `0` is `NONE` and `1` is `FULL`; only `2` (`INCREMENTAL`) responds. const AUTO_VACUUM_INCREMENTAL: u64 = 2; diff --git a/crates/tracedecay-maintenance/src/retention/live_compaction.rs b/crates/tracedecay-maintenance/src/retention/live_compaction.rs index 0b8dde43c7..d9b667be74 100644 --- a/crates/tracedecay-maintenance/src/retention/live_compaction.rs +++ b/crates/tracedecay-maintenance/src/retention/live_compaction.rs @@ -9,7 +9,7 @@ use tracedecay_domain::UtcMicros; use tracedecay_global_db::RegisteredGlobalDb; use tracedecay_runtime_core::db::Database; -use super::branch_compaction::CompactionThresholdConfig; +use tracedecay_contracts::storage::compaction::CompactionThresholdConfig; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LiveStoreCompactionFailureV1 { diff --git a/crates/tracedecay-maintenance/src/store_maintenance/mod.rs b/crates/tracedecay-maintenance/src/store_maintenance/mod.rs index c9bb496d15..35cf80154b 100644 --- a/crates/tracedecay-maintenance/src/store_maintenance/mod.rs +++ b/crates/tracedecay-maintenance/src/store_maintenance/mod.rs @@ -12,11 +12,11 @@ use std::path::{Path, PathBuf}; use crate::clock::now_secs_i64; use crate::lease::ProjectStoreMaintenanceLeaseV1; use crate::log_maintenance_event; -use crate::retention::branch_compaction::CompactionThresholdConfig; use crate::telemetry::StoreTelemetrySamplingRegistry; use crate::tick::MaintenanceTickOutcome; use tracedecay_application::semantic_runtime::ProjectSemanticActivationExt; use tracedecay_code_index_runtime::code_index_scheduler::CodeIndexSchedulerRegistryV1; +use tracedecay_contracts::storage::compaction::CompactionThresholdConfig; use tracedecay_semantic_contracts::SemanticConfig; mod graph_replay; diff --git a/crates/tracedecay/src/config.rs b/crates/tracedecay/src/config.rs index 3d76a497a5..85d2202199 100644 --- a/crates/tracedecay/src/config.rs +++ b/crates/tracedecay/src/config.rs @@ -1,30 +1,19 @@ use std::collections::BTreeMap; -use std::ffi::OsString; -use std::fs; use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; use std::sync::{Arc, LazyLock, OnceLock, RwLock}; -use glob::Pattern; -use serde::{Deserialize, Serialize}; use tracedecay_contracts::clock::now_micros; use tracedecay_domain::ProjectId; use tracedecay_domain::configuration::{ CodeIndexWorkerSelectionV1, ConfigurationLayerIdV1, ConfigurationRevisionId, - ConfigurationSnapshotV1, ConfigurationValueV1, SOURCE_BINDINGS_SETTING_KEY, - SYNC_AUTO_INIT_SETTING_KEY, SYNC_AUTO_WATCH_SETTING_KEY, - SYNC_BACKSTOP_INTERVAL_MINS_SETTING_KEY, SYNC_BRANCH_GC_DAYS_SETTING_KEY, - SYNC_FULL_SYNC_ESCALATION_FILES_SETTING_KEY, SYNC_MAX_CONCURRENT_SYNCS_SETTING_KEY, - SYNC_ORPHAN_DB_GC_DAYS_SETTING_KEY, SYNC_READ_COOLDOWN_SECS_SETTING_KEY, - SYNC_READ_REFRESH_SETTING_KEY, SYNC_SESSION_START_STALE_THRESHOLD_SECS_SETTING_KEY, - SYNC_SESSION_START_SYNC_SETTING_KEY, SYNC_WATCH_DEBOUNCE_MS_SETTING_KEY, - SYNC_WATCH_LINKED_WORKTREES_SETTING_KEY, SYNC_WATCH_MAX_DELAY_MS_SETTING_KEY, - SYNC_WATCH_MAX_PROJECTS_SETTING_KEY, SettingKey, UserProfileId, + ConfigurationSnapshotV1, ConfigurationValueV1, SOURCE_BINDINGS_SETTING_KEY, SettingKey, + UserProfileId, }; use tracedecay_configuration::ConfigurationControlStore; -use tracedecay_configuration::config::{ - optional_text_setting, required_bool, required_unsigned, required_usize, +use tracedecay_configuration::{ + SyncConfig, TelemetryConfig, TraceDecayConfig, get_config_path, is_in_gitignore, + load_config_from_path, }; use tracedecay_domain::errors::{Result, TraceDecayError}; use tracedecay_global_db::configuration::{ @@ -32,17 +21,10 @@ use tracedecay_global_db::configuration::{ ProfileCodeIndexWorkerConfigurationV1, }; use tracedecay_global_db::{RegisteredGlobalDb, RegisteredGlobalDbLeaseV1}; -use tracedecay_maintenance::retention::branch_compaction::CompactionThresholdConfig; -use tracedecay_semantic_contracts::SemanticConfig; pub use tracedecay_application::config::retrieval; pub use tracedecay_global_db::configuration::{registry, resolver}; -/// Name of the legacy configuration migration input stored inside the data -/// directory. It is not a runtime authority and production code must never -/// rewrite it. -pub const CONFIG_FILENAME: &str = "config.json"; - /// Kernel-owned path primitives. The definitions live in /// `tracedecay_runtime_core::config` because the storage layout, database, /// branch-metadata, and store layers depend on them and moved into that crate; @@ -61,15 +43,6 @@ pub use tracedecay_runtime_core::config::{ /// semantic selection. pub use tracedecay_domain::configuration::SEMANTIC_RUNTIME_SETTING_KEY; -/// Atomic daemon retention/compaction policy tree (Plan 38). -/// -/// The value is canonical JSON for [`RetentionConfig`]. Keeping the session -/// (LCM), observation-evidence, orphan-store, debris, and compaction windows -/// under one setting keeps the retention engines threaded as a single -/// versioned unit the daemon backstop reads, mirroring the semantic key. Absent -/// or unset resolves to [`RetentionConfig::default`]'s bounded safe policy. -pub const SYNC_RETENTION_SETTING_KEY: &str = "sync.retention.v1"; - /// The shared generated/vendored segment list and its membership test moved /// into `tracedecay_runtime_core::config`: the extracted migration inventory /// scanner consults them and cannot reach back into the root crate. @@ -77,545 +50,6 @@ pub const SYNC_RETENTION_SETTING_KEY: &str = "sync.retention.v1"; /// resolving. pub use tracedecay_runtime_core::config::{GENERATED_DIR_SEGMENTS, is_generated_dir_segment}; -/// Returns `true` if any component of `path` is a generated/vendored -/// directory segment, or `path` itself carries a minified-asset suffix -/// (`app.min.js`, `app.min.css`, ...) — mirrors the `**/*.min.*` default -/// exclude pattern built by [`default_exclude_patterns`]. -/// -/// Path-level (not just directory-level) so callers can filter a flat list -/// of file paths in one pass, e.g. the redundancy scanner's candidate list. -pub fn is_generated_path_segment(path: &str) -> bool { - has_minified_suffix(path) || path.split('/').any(is_generated_dir_segment) -} - -/// `true` for paths like `app.min.js` / `app.min.css.map` — a `.min.` -/// component followed by at least one more character. -fn has_minified_suffix(path: &str) -> bool { - path.rfind(".min.").is_some_and(|idx| idx + 5 < path.len()) -} - -/// Default glob-pattern exclude list for [`TraceDecayConfig::default`]. -/// -/// Built from [`GENERATED_DIR_SEGMENTS`] (both the `segment/**` root form -/// and the `**/segment/**` nested form, since a generated directory can -/// appear at the project root or anywhere below it) plus site-local -/// additions that intentionally are *not* part of the shared segment set: -/// -/// - `.git/**`, `.tracedecay/**` — VCS and `TraceDecay`'s own metadata dirs; -/// these are tool/repo bookkeeping, not generated *code*, so they stay -/// local to the config's default patterns rather than joining -/// [`GENERATED_DIR_SEGMENTS`] (which the migrate/scan/redundancy call -/// sites also consult for non-config-driven decisions). -/// - `bin/**` — historically excluded here by default, but not treated as -/// "generated" elsewhere: a `bin/` directory can hold real source in some -/// project layouts, so it isn't added to the shared segment list. -/// - `**/*.min.*` — mirrors [`is_generated_path_segment`]'s suffix check. -fn default_exclude_patterns() -> Vec { - let mut patterns: Vec = vec![ - ".git/**".to_string(), - ".tracedecay/**".to_string(), - "bin/**".to_string(), - "**/*.min.*".to_string(), - ]; - for segment in GENERATED_DIR_SEGMENTS { - patterns.push(format!("{segment}/**")); - patterns.push(format!("**/{segment}/**")); - } - patterns -} - -/// Legacy `config.json` representation and the materialized shape used by an -/// already-pinned resolved configuration snapshot. -/// -/// `version` and `root_dir` are legacy migration metadata only. Every runtime -/// setting below is sourced from [`ConfigurationSnapshotV1`] before a project -/// opens; serializing this type is retained solely for migration fixtures and -/// backwards-compatible legacy input decoding. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[allow( - clippy::struct_excessive_bools, - reason = "Independent legacy configuration switches retain their serialized migration shape" -)] -pub struct TraceDecayConfig { - /// Schema version of the configuration. - pub version: u32, - /// Root directory of the project being indexed. - pub root_dir: String, - /// Glob patterns for files to exclude during indexing. - pub exclude: Vec, - /// Glob patterns for paths to include despite the default hidden-directory, - /// generated-directory, and gitignore filters. For example, - /// `[".github/**"]` indexes files under `.github/` that would otherwise be - /// skipped. - #[serde(default)] - pub include: Vec, - /// Maximum file size in bytes; files larger than this are skipped. - pub max_file_size: u64, - /// Whether to extract doc comments from source files. - pub extract_docstrings: bool, - /// Whether to track call-site locations for edges. - pub track_call_sites: bool, - /// Whether to respect `.gitignore` rules when scanning files. - #[serde(default = "default_git_ignore")] - pub git_ignore: bool, - /// Whether a cold `tracedecay_diagnostics` call prewarms in the background - /// (detached dependency build + immediate `warming` status) instead of - /// blocking for minutes. Environment precedence is resolved into the - /// pinned snapshot during legacy migration, never during a tool call. - #[serde(default)] - pub diagnostics_prewarm: bool, - /// Whether the persistent native code graph may activate for this project. - /// Disabling it leaves exact and lexical retrieval available and reports - /// graph capability as unavailable. - #[serde(default = "default_native_graph_activation")] - pub native_graph_activation: bool, - /// Optional installed local semantic profile selection. Missing or - /// unavailable semantics never disables exact, lexical, or graph search. - #[serde(default)] - pub semantic: SemanticConfig, - /// Index-freshness auto-sync settings (git-metadata watcher, serve-stale, - /// branch lifecycle). Absent in older `config.json` files, so defaulted. - #[serde(default)] - pub sync: SyncConfig, - /// Analytics telemetry settings. Absent in older `config.json` files, so - /// defaulted. - #[serde(default)] - pub telemetry: TelemetryConfig, -} - -fn default_git_ignore() -> bool { - true -} - -fn default_native_graph_activation() -> bool { - true -} - -fn default_sync_auto_watch() -> bool { - false -} -fn default_sync_watch_linked_worktrees() -> bool { - false -} -fn default_sync_watch_debounce_ms() -> u64 { - 2000 -} -fn default_sync_watch_max_delay_ms() -> u64 { - 30000 -} -fn default_sync_watch_max_projects() -> usize { - 32 -} -fn default_sync_read_refresh() -> bool { - true -} -fn default_sync_read_cooldown_secs() -> u64 { - 30 -} -fn default_sync_session_start_sync() -> bool { - true -} -fn default_sync_session_start_stale_threshold_secs() -> u64 { - 600 -} -fn default_sync_backstop_interval_mins() -> u64 { - 15 -} -fn default_sync_full_sync_escalation_files() -> usize { - 500 -} -fn default_sync_max_concurrent_syncs() -> usize { - 2 -} -fn default_sync_branch_gc_days() -> u64 { - 14 -} -fn default_sync_orphan_db_gc_days() -> u64 { - 7 -} -fn default_sync_auto_init() -> bool { - true -} -fn default_sync_auto_track_pr_branches() -> bool { - false -} -fn default_sync_auto_track_pr_poll_secs() -> u64 { - 300 -} -fn default_retention_interval_hours() -> u64 { - 24 -} - -#[allow( - clippy::unnecessary_wraps, - reason = "Serde defaults must return the optional field type; explicit None disables maintenance" -)] -fn default_orphan_store_gc_days() -> Option { - Some(30) -} - -#[allow( - clippy::unnecessary_wraps, - reason = "Serde defaults must return the optional field type; explicit None disables maintenance" -)] -fn default_incident_debris_retention_days() -> Option { - Some(30) -} - -#[allow( - clippy::unnecessary_wraps, - reason = "Serde defaults must return the optional field type; explicit None disables maintenance" -)] -fn default_compaction_threshold() -> Option { - Some(CompactionThresholdConfig::default()) -} - -/// The daemon retention/compaction policy tree (Plan 38). Safe, bounded -/// maintenance is active by default for proven orphan stores, quarantined -/// debris, redundant projection-durable session copies, and free-page bloat. -/// Lossy session/evidence deletion remains disabled and soft budgets remain -/// owner-configured findings only. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct RetentionConfig { - /// Session-store (LCM raw/projected) retention windows. - #[serde(default)] - pub session_lcm: tracedecay_lcm::LcmRetentionConfig, - /// Observation-evidence generation-scoped retention windows. - #[serde(default)] - pub observation: tracedecay_global_db::observation::retention::ObservationRetentionConfig, - /// Orphan profile-sharded store collection window (days). `None` disables - /// the sweep; the Doctor surface still reports findings read-only. - #[serde(default = "default_orphan_store_gc_days")] - pub orphan_store_gc_days: Option, - /// Retention window for quarantined recovery/corruption artifacts (days). - /// `None` disables collection while Doctor continues surfacing debris. - #[serde(default = "default_incident_debris_retention_days")] - pub incident_debris_retention_days: Option, - /// Incremental-vacuum compaction trigger. `None` disables compaction. - #[serde(default = "default_compaction_threshold")] - pub compaction: Option, - /// Owner-configured soft byte budgets keyed by exact logical store key. - /// Missing entries mean no budget was configured for that store. - #[serde(default)] - pub store_soft_budgets_bytes: BTreeMap, - /// Cadence between daemon retention passes (hours). - #[serde(default = "default_retention_interval_hours")] - pub interval_hours: u64, -} - -impl Default for RetentionConfig { - fn default() -> Self { - Self { - session_lcm: tracedecay_lcm::LcmRetentionConfig::default(), - observation: - tracedecay_global_db::observation::retention::ObservationRetentionConfig::default(), - orphan_store_gc_days: default_orphan_store_gc_days(), - incident_debris_retention_days: default_incident_debris_retention_days(), - compaction: default_compaction_threshold(), - store_soft_budgets_bytes: BTreeMap::new(), - interval_hours: default_retention_interval_hours(), - } - } -} - -impl RetentionConfig { - pub(crate) fn store_soft_budget( - &self, - store: &str, - ) -> Result> { - let Some(bytes) = self.store_soft_budgets_bytes.get(store).copied() else { - return Ok(None); - }; - let budget = tracedecay_contracts::storage::StoreSizeBudgetV1 { - store: tracedecay_contracts::storage::StoreKeyV1::new(store.to_owned()) - .map_err(|error| config_error(error.to_string()))?, - soft_limit_bytes: tracedecay_contracts::storage::StorageByteSizeV1(bytes), - }; - budget - .validate() - .map_err(|error| config_error(error.to_string()))?; - Ok(Some(budget)) - } - - /// Validate collection windows and the compaction trigger. Immediate - /// collection and ratios outside the unit interval are rejected. - fn validate(&self) -> Result<()> { - if self.orphan_store_gc_days == Some(0) { - return Err(config_error( - "retention orphan_store_gc_days must be greater than zero", - )); - } - if self.incident_debris_retention_days == Some(0) { - return Err(config_error( - "retention incident_debris_retention_days must be greater than zero", - )); - } - if let Some(compaction) = &self.compaction - && (!compaction.free_page_ratio_threshold.is_finite() - || compaction.free_page_ratio_threshold <= 0.0 - || compaction.free_page_ratio_threshold > 1.0) - { - return Err(config_error( - "retention compaction free_page_ratio_threshold must be within (0.0, 1.0]", - )); - } - for (store, bytes) in &self.store_soft_budgets_bytes { - tracedecay_contracts::storage::StoreKeyV1::new(store.clone()).map_err(|_| { - config_error(format!( - "retention store soft budget key '{store}' is not a valid StoreKeyV1" - )) - })?; - if *bytes == 0 { - return Err(config_error(format!( - "retention store soft budget for '{store}' must be greater than zero" - ))); - } - } - Ok(()) - } -} - -/// Floor for the PR-autotrack poll interval; polls faster than this hammer the -/// GitHub API / `git ls-remote` needlessly, so any smaller configured value is -/// clamped up to this. -pub const MIN_AUTO_TRACK_PR_POLL_SECS: u64 = 60; - -fn default_telemetry_timings() -> bool { - true -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct TelemetryConfig { - #[serde(default = "default_telemetry_timings")] - pub timings: bool, -} - -impl Default for TelemetryConfig { - fn default() -> Self { - Self { - timings: default_telemetry_timings(), - } - } -} - -/// Auto-sync / index-freshness knobs in the legacy migration shape. -/// -/// Runtime consumers receive these values only from a pinned resolved -/// configuration snapshot. `TRACEDECAY_SYNC_*` values are decoded as an -/// explicit legacy environment layer during migration, rather than being read -/// independently by each adapter. -/// -/// Every field carries a `#[serde(default = ...)]` so that a partial JSON -/// object (only some keys present) still deserializes, and a missing `sync` -/// key entirely falls back to [`SyncConfig::default`]. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -#[allow( - clippy::struct_excessive_bools, - reason = "Independent sync admission switches are configuration choices, not mutually exclusive states" -)] -pub struct SyncConfig { - /// Enable the daemon git-metadata watcher. - #[serde(default = "default_sync_auto_watch")] - pub auto_watch: bool, - /// Admit linked worktrees into the daemon watcher without an explicit - /// branch-indexing request. - #[serde(default = "default_sync_watch_linked_worktrees")] - pub watch_linked_worktrees: bool, - /// Per-project quiet-period debounce before a watcher-triggered sync (ms). - #[serde(default = "default_sync_watch_debounce_ms")] - pub watch_debounce_ms: u64, - /// Maximum time a watcher-triggered sync can be deferred by debounce (ms). - #[serde(default = "default_sync_watch_max_delay_ms")] - pub watch_max_delay_ms: u64, - /// Maximum number of recently-seen projects the watcher registers. - #[serde(default = "default_sync_watch_max_projects")] - pub watch_max_projects: usize, - /// Enable non-blocking sync-on-read for query tools. - #[serde(default = "default_sync_read_refresh")] - pub read_refresh: bool, - /// Cooldown between read-triggered background refreshes (seconds). - #[serde(default = "default_sync_read_cooldown_secs")] - pub read_cooldown_secs: u64, - /// Fire a catch-up sync on session start. - #[serde(default = "default_sync_session_start_sync")] - pub session_start_sync: bool, - /// Staleness threshold above which session-start sync runs (seconds). - #[serde(default = "default_sync_session_start_stale_threshold_secs")] - pub session_start_stale_threshold_secs: u64, - /// Daemon backstop scheduler interval (minutes); 0 disables it. - #[serde(default = "default_sync_backstop_interval_mins")] - pub backstop_interval_mins: u64, - /// Diff-scoped syncs above this many changed files escalate to a full sync. - #[serde(default = "default_sync_full_sync_escalation_files")] - pub full_sync_escalation_files: usize, - /// Daemon-wide cap on concurrent syncs. - #[serde(default = "default_sync_max_concurrent_syncs")] - pub max_concurrent_syncs: usize, - /// Grace period before a dead tracked-branch store is GC'd (days). - #[serde(default = "default_sync_branch_gc_days")] - pub branch_gc_days: u64, - /// Grace period before an orphan branch DB is GC'd (days). - #[serde(default = "default_sync_orphan_db_gc_days")] - pub orphan_db_gc_days: u64, - /// Auto-initialise never-indexed repos on first contact. - #[serde(default = "default_sync_auto_init")] - pub auto_init: bool, - /// Enable the daemon PR-branch auto-tracking mode: when on, the daemon polls - /// the repo's GitHub remote for open PRs and tracks/untracks each PR head - /// branch through the normal branch-tracking machinery. Off by default for - /// back-compat. - #[serde(default = "default_sync_auto_track_pr_branches")] - pub auto_track_pr_branches: bool, - /// Poll cadence (seconds) for PR-branch auto-tracking discovery. Clamped up - /// to [`MIN_AUTO_TRACK_PR_POLL_SECS`] at read time. - #[serde(default = "default_sync_auto_track_pr_poll_secs")] - pub auto_track_pr_poll_secs: u64, - /// Daemon retention/compaction policy tree (Plan 38). - #[serde(default)] - pub retention: RetentionConfig, -} - -impl SyncConfig { - /// The effective PR-autotrack poll interval, never below the safety floor. - #[must_use] - pub fn effective_auto_track_pr_poll_secs(&self) -> u64 { - self.auto_track_pr_poll_secs - .max(MIN_AUTO_TRACK_PR_POLL_SECS) - } -} - -impl Default for SyncConfig { - fn default() -> Self { - Self { - auto_watch: default_sync_auto_watch(), - watch_linked_worktrees: default_sync_watch_linked_worktrees(), - watch_debounce_ms: default_sync_watch_debounce_ms(), - watch_max_delay_ms: default_sync_watch_max_delay_ms(), - watch_max_projects: default_sync_watch_max_projects(), - read_refresh: default_sync_read_refresh(), - read_cooldown_secs: default_sync_read_cooldown_secs(), - session_start_sync: default_sync_session_start_sync(), - session_start_stale_threshold_secs: default_sync_session_start_stale_threshold_secs(), - backstop_interval_mins: default_sync_backstop_interval_mins(), - full_sync_escalation_files: default_sync_full_sync_escalation_files(), - max_concurrent_syncs: default_sync_max_concurrent_syncs(), - branch_gc_days: default_sync_branch_gc_days(), - orphan_db_gc_days: default_sync_orphan_db_gc_days(), - auto_init: default_sync_auto_init(), - auto_track_pr_branches: default_sync_auto_track_pr_branches(), - auto_track_pr_poll_secs: default_sync_auto_track_pr_poll_secs(), - retention: RetentionConfig::default(), - } - } -} - -/// Parses a boolean env value. Truthy spellings (`1`/`true`/`yes`/`on`) share -/// [`tracedecay_global_db::env_value_truthy`]; `0`/`false` are false. Any -/// other value is ignored (returns `None`) so an override is not applied. -fn parse_env_bool(raw: &str) -> Option { - if tracedecay_global_db::env_value_truthy(raw) { - return Some(true); - } - match raw.trim().to_ascii_lowercase().as_str() { - "0" | "false" => Some(false), - _ => None, - } -} - -/// Reads a `TRACEDECAY_` env var and parses it as a bool. -pub(crate) fn env_bool(suffix: &str) -> Option { - brand_env(suffix).as_deref().and_then(parse_env_bool) -} - -/// Reads a `TRACEDECAY_` env var and parses it as an integer of the -/// caller's choosing. -fn env_parse(suffix: &str) -> Option { - brand_env(suffix) - .as_deref() - .and_then(|raw| raw.trim().parse::().ok()) -} - -impl SyncConfig { - /// Applies legacy `TRACEDECAY_SYNC_*` environment overrides on top of - /// `self`. This remains for pre-store/bootstrap compatibility only; live - /// runtime adapters must consume [`PinnedRuntimeConfiguration`] instead. - #[must_use] - pub fn with_env_overrides(mut self) -> Self { - if let Some(value) = env_bool("SYNC_AUTO_WATCH") { - self.auto_watch = value; - } - if let Some(value) = env_bool("SYNC_WATCH_LINKED_WORKTREES") { - self.watch_linked_worktrees = value; - } - if let Some(value) = env_parse("SYNC_WATCH_DEBOUNCE_MS") { - self.watch_debounce_ms = value; - } - if let Some(value) = env_parse("SYNC_WATCH_MAX_DELAY_MS") { - self.watch_max_delay_ms = value; - } - if let Some(value) = env_parse("SYNC_WATCH_MAX_PROJECTS") { - self.watch_max_projects = value; - } - if let Some(value) = env_bool("SYNC_READ_REFRESH") { - self.read_refresh = value; - } - if let Some(value) = env_parse("SYNC_READ_COOLDOWN_SECS") { - self.read_cooldown_secs = value; - } - if let Some(value) = env_bool("SYNC_SESSION_START_SYNC") { - self.session_start_sync = value; - } - if let Some(value) = env_parse("SYNC_SESSION_START_STALE_THRESHOLD_SECS") { - self.session_start_stale_threshold_secs = value; - } - if let Some(value) = env_parse("SYNC_BACKSTOP_INTERVAL_MINS") { - self.backstop_interval_mins = value; - } - if let Some(value) = env_parse("SYNC_FULL_SYNC_ESCALATION_FILES") { - self.full_sync_escalation_files = value; - } - if let Some(value) = env_parse("SYNC_MAX_CONCURRENT_SYNCS") { - self.max_concurrent_syncs = value; - } - if let Some(value) = env_parse("SYNC_BRANCH_GC_DAYS") { - self.branch_gc_days = value; - } - if let Some(value) = env_parse("SYNC_ORPHAN_DB_GC_DAYS") { - self.orphan_db_gc_days = value; - } - if let Some(value) = env_bool("SYNC_AUTO_INIT") { - self.auto_init = value; - } - if let Some(value) = env_bool("SYNC_AUTO_TRACK_PR_BRANCHES") { - self.auto_track_pr_branches = value; - } - if let Some(value) = env_parse("SYNC_AUTO_TRACK_PR_POLL_SECS") { - self.auto_track_pr_poll_secs = value; - } - self - } -} - -impl Default for TraceDecayConfig { - fn default() -> Self { - Self { - version: 1, - root_dir: String::new(), - exclude: default_exclude_patterns(), - include: Vec::new(), - max_file_size: 1_048_576, - extract_docstrings: true, - track_call_sites: true, - git_ignore: default_git_ignore(), - diagnostics_prewarm: false, - native_graph_activation: default_native_graph_activation(), - semantic: SemanticConfig::default(), - sync: SyncConfig::default(), - telemetry: TelemetryConfig::default(), - } - } -} - /// Typed project route for the configuration daemon boundary. The path is /// display/routing context only; [`ProjectId`] remains the authority key. pub use tracedecay_configuration::config::RuntimeConfigurationTarget; @@ -1283,112 +717,12 @@ pub fn cached_telemetry_config(project_root: &Path) -> Result { .telemetry) } -impl TraceDecayConfig { - /// Layers the daemon-only policy over the shared runtime settings of an - /// already validated pin. The shared settings are copied from the pin, so - /// they agree with every other consumer by construction; only the - /// daemon-only sync, retention, and legacy metadata fields are decoded - /// here, from the same snapshot, without defaults, file reads, or - /// environment reads. - #[hotpath::measure(label = "daemon.config.parse")] - fn from_runtime( - runtime: &tracedecay_configuration::config::PinnedRuntimeConfiguration, - ) -> Result { - let shared = runtime.config(); - let snapshot = runtime.snapshot(); - Ok(Self { - version: 1, - root_dir: runtime.target().project_root.to_string_lossy().to_string(), - exclude: shared.exclude.clone(), - include: shared.include.clone(), - max_file_size: shared.max_file_size, - extract_docstrings: shared.extract_docstrings, - track_call_sites: shared.track_call_sites, - git_ignore: shared.git_ignore, - diagnostics_prewarm: shared.diagnostics_prewarm, - native_graph_activation: shared.native_graph_activation, - semantic: shared.semantic.clone(), - sync: SyncConfig { - auto_watch: required_bool(snapshot, SYNC_AUTO_WATCH_SETTING_KEY)?, - watch_linked_worktrees: required_bool( - snapshot, - SYNC_WATCH_LINKED_WORKTREES_SETTING_KEY, - )?, - watch_debounce_ms: required_unsigned(snapshot, SYNC_WATCH_DEBOUNCE_MS_SETTING_KEY)?, - watch_max_delay_ms: required_unsigned( - snapshot, - SYNC_WATCH_MAX_DELAY_MS_SETTING_KEY, - )?, - watch_max_projects: required_usize(snapshot, SYNC_WATCH_MAX_PROJECTS_SETTING_KEY)?, - read_refresh: required_bool(snapshot, SYNC_READ_REFRESH_SETTING_KEY)?, - read_cooldown_secs: required_unsigned( - snapshot, - SYNC_READ_COOLDOWN_SECS_SETTING_KEY, - )?, - session_start_sync: required_bool(snapshot, SYNC_SESSION_START_SYNC_SETTING_KEY)?, - session_start_stale_threshold_secs: required_unsigned( - snapshot, - SYNC_SESSION_START_STALE_THRESHOLD_SECS_SETTING_KEY, - )?, - backstop_interval_mins: required_unsigned( - snapshot, - SYNC_BACKSTOP_INTERVAL_MINS_SETTING_KEY, - )?, - full_sync_escalation_files: required_usize( - snapshot, - SYNC_FULL_SYNC_ESCALATION_FILES_SETTING_KEY, - )?, - max_concurrent_syncs: required_usize( - snapshot, - SYNC_MAX_CONCURRENT_SYNCS_SETTING_KEY, - )?, - branch_gc_days: required_unsigned(snapshot, SYNC_BRANCH_GC_DAYS_SETTING_KEY)?, - orphan_db_gc_days: required_unsigned(snapshot, SYNC_ORPHAN_DB_GC_DAYS_SETTING_KEY)?, - auto_init: required_bool(snapshot, SYNC_AUTO_INIT_SETTING_KEY)?, - auto_track_pr_branches: shared.sync.auto_track_pr_branches, - auto_track_pr_poll_secs: shared.sync.auto_track_pr_poll_secs, - retention: retention_config_from_snapshot(snapshot)?, - }, - telemetry: TelemetryConfig { - timings: shared.telemetry.timings, - }, - }) - } -} - -fn retention_config_from_snapshot(snapshot: &ConfigurationSnapshotV1) -> Result { - let retention = match optional_text_setting(snapshot, SYNC_RETENTION_SETTING_KEY)? { - None => RetentionConfig::default(), - Some(value) => serde_json::from_str(value).map_err(|error| { - config_error(format!("resolved retention setting is invalid: {error}")) - })?, - }; - retention.validate()?; - Ok(retention) -} - fn config_error(message: impl Into) -> TraceDecayError { TraceDecayError::Config { message: message.into(), } } -/// Reads the `TRACEDECAY_` environment variable. -pub fn brand_env(suffix: &str) -> Option { - std::env::var(format!("TRACEDECAY_{suffix}")).ok() -} - -/// Returns the path to the configuration file (`config.json`) within the -/// resolved data directory. -pub fn get_config_path(project_root: &Path) -> PathBuf { - if let Ok(layout) = - tracedecay_runtime_core::storage::resolve_layout_for_current_profile(project_root) - { - return layout.config_path; - } - get_tracedecay_dir(project_root).join(CONFIG_FILENAME) -} - pub async fn get_config_path_with_identity(project_root: &Path) -> PathBuf { if let Ok(layout) = crate::tracedecay::TraceDecay::resolve_store_layout_for_identity(project_root).await @@ -1398,234 +732,11 @@ pub async fn get_config_path_with_identity(project_root: &Path) -> PathBuf { get_config_path(project_root) } -/// Loads a legacy configuration input from disk. -/// -/// This compatibility reader is for migration and read-only diagnostics only; -/// runtime consumers must use a pinned resolved snapshot. If the file does -/// not exist, it returns the legacy defaults with `root_dir` set to the given -/// project root. -pub fn load_config(project_root: &Path) -> Result { - let config_path = get_config_path(project_root); - load_config_from_path(project_root, &config_path) -} - pub async fn load_config_with_identity(project_root: &Path) -> Result { let config_path = get_config_path_with_identity(project_root).await; load_config_from_path(project_root, &config_path) } -/// Loads configuration from an explicit config path while preserving the -/// project root used for default config values. -pub fn load_config_from_path(project_root: &Path, config_path: &Path) -> Result { - if !config_path.exists() { - return Ok(TraceDecayConfig { - root_dir: project_root.to_string_lossy().to_string(), - ..TraceDecayConfig::default() - }); - } - - let contents = fs::read_to_string(config_path).map_err(|e| TraceDecayError::Config { - message: format!( - "failed to read config file '{}': {}", - config_path.display(), - e - ), - })?; - - let config: TraceDecayConfig = - serde_json::from_str(&contents).map_err(|e| TraceDecayError::Config { - message: format!( - "failed to parse config file '{}': {}", - config_path.display(), - e - ), - })?; - - Ok(config) -} - -/// Writes a legacy configuration fixture to an explicit path using an atomic -/// write. -/// -/// Production runtime code must use the daemon control plane instead of this -/// compatibility helper. It remains for fixtures and legacy-input tests while -/// callers complete their migration. -pub fn save_config_to_path(config_path: &Path, config: &TraceDecayConfig) -> Result<()> { - let data_dir = config_path - .parent() - .ok_or_else(|| TraceDecayError::Config { - message: format!( - "configuration path '{}' has no parent directory", - config_path.display() - ), - })?; - tracedecay_runtime_core::storage::PrivateStoreIo::create_dir_all(data_dir).map_err(|e| { - TraceDecayError::Config { - message: format!( - "failed to create tracedecay directory '{}': {}", - data_dir.display(), - e - ), - } - })?; - - let tmp_path = config_path.with_extension("tmp"); - - let json = serde_json::to_string_pretty(config).map_err(|e| TraceDecayError::Config { - message: format!("failed to serialize config: {e}"), - })?; - - fs::write(&tmp_path, &json).map_err(|e| TraceDecayError::Config { - message: format!( - "failed to write temporary config file '{}': {}", - tmp_path.display(), - e - ), - })?; - - fs::rename(&tmp_path, config_path).map_err(|e| TraceDecayError::Config { - message: format!( - "failed to rename temporary config file '{}' to '{}': {}", - tmp_path.display(), - config_path.display(), - e - ), - })?; - - Ok(()) -} - -/// Returns `true` if the project marker dir (`.tracedecay`) is ignored by Git -/// for this project. -/// -/// This respects the repository `.gitignore`, `.git/info/exclude`, and the -/// user's global excludes file via `git check-ignore`. If Git cannot answer -/// (for example outside a Git repository), falls back to checking the local -/// `.gitignore` file only. -pub fn is_in_gitignore(project_path: &Path) -> bool { - if let Some(is_ignored) = is_ignored_by_git(project_path, None) { - return is_ignored; - } - - is_in_local_gitignore(project_path) -} - -fn is_ignored_by_git(project_path: &Path, git_config_global: Option<&Path>) -> Option { - let fallback_global_excludes = || { - git_config_global - .and_then(|path| is_ignored_by_explicit_global_excludes(project_path, path)) - }; - let dir_name = active_data_dir_name(project_path); - let Ok(git) = tracedecay_runtime_core::git::try_git_program() else { - return fallback_global_excludes(); - }; - let mut command = Command::new(git); - command - .arg("-C") - .arg(project_path) - .arg("check-ignore") - .arg("-q") - .arg(format!("{dir_name}/")) - .stdout(Stdio::null()) - .stderr(Stdio::null()); - - if let Some(path) = git_config_global { - command.env_clear(); - command.env("PATH", git_subprocess_path()); - command.env("GIT_CONFIG_GLOBAL", path); - command.env("GIT_CONFIG_NOSYSTEM", "1"); - } - - let Ok(status) = command.status() else { - return fallback_global_excludes(); - }; - - match status.code() { - Some(0) => Some(true), - Some(1) => Some(false), - _ => fallback_global_excludes(), - } -} - -fn is_ignored_by_explicit_global_excludes( - project_path: &Path, - git_config_global: &Path, -) -> Option { - let config = fs::read_to_string(git_config_global).ok()?; - let excludes_file = config.lines().find_map(|line| { - let trimmed = line.trim(); - let (key, value) = trimmed.split_once('=')?; - (key.trim() == "excludesFile").then(|| PathBuf::from(value.trim())) - })?; - let excludes = fs::read_to_string(excludes_file).ok()?; - let dir_name = active_data_dir_name(project_path); - let dir_pattern = format!("{dir_name}/"); - Some(excludes.lines().any(|line| { - let trimmed = line.trim(); - !trimmed.is_empty() - && !trimmed.starts_with('#') - && (trimmed == dir_name || trimmed == dir_pattern) - })) -} - -#[cfg(test)] -fn git_subprocess_path() -> OsString { - std::env::var_os("PATH").unwrap_or_else(|| { - #[cfg(windows)] - { - OsString::new() - } - #[cfg(not(windows))] - { - OsString::from("/usr/bin:/bin") - } - }) -} - -#[cfg(not(test))] -fn git_subprocess_path() -> OsString { - std::env::var_os("PATH").unwrap_or_default() -} - -fn is_in_local_gitignore(project_path: &Path) -> bool { - let dir_name = active_data_dir_name(project_path); - let gitignore = project_path.join(".gitignore"); - match fs::read_to_string(&gitignore) { - Ok(content) => content.lines().any(|line| { - let trimmed = line.trim(); - trimmed == dir_name - || trimmed == format!("{dir_name}/") - || trimmed == format!("/{dir_name}") - }), - Err(_) => false, - } -} - -/// Resolves a CLI path argument to an absolute `PathBuf`. -/// -/// If `path` is `Some`, uses that value; otherwise falls back to the current -/// working directory. -pub fn resolve_path(path: Option) -> PathBuf { - let path = match path { - Some(p) => PathBuf::from(p), - None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), - }; - absolutize_path(path) -} - -fn absolutize_path(path: PathBuf) -> PathBuf { - if path.is_absolute() { - path - } else { - std::env::current_dir() - .unwrap_or_else(|_| PathBuf::from(".")) - .join(path) - } -} - -/// Like [`discover_project_root`], but on a sync miss checks the git worktree -/// root with [`crate::tracedecay::TraceDecay::has_initialized_store`] so renamed -/// or global-only repos still resolve without probing unrelated ancestors. #[hotpath::measure(label = "daemon.config.discover", future = true)] pub async fn discover_project_root_with_identity(start: &Path) -> Option { if let Some(root) = discover_project_root(start) { @@ -1640,73 +751,6 @@ pub async fn discover_project_root_with_identity(start: &Path) -> Option) -> PathBuf { - if let Some(p) = path { - PathBuf::from(p) - } else { - let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); - discover_project_root(&cwd) - .or_else(|| tracedecay_runtime_core::worktree::git_worktree_root(&cwd)) - .unwrap_or(cwd) - } -} - -/// Returns `true` if the path matches any of the configured `include` patterns. -/// -/// This is used to allow hidden (dot-prefixed) directories that would -/// otherwise be skipped by the file walker. -pub fn is_included(path: &str, config: &TraceDecayConfig) -> bool { - any_pattern_matches(&config.include, &[path]) -} - -/// Returns `true` if a directory should be pruned during scanning. -/// -/// Matches `dir/_` against exclude patterns (for `dir/**`-style globs) and -/// also matches `dir` itself (for bare `**/dirname`-style globs). This -/// ensures that patterns like `**/node_modules` and `**/node_modules/**` -/// both trigger directory pruning in `scan_files_walkdir`. -pub fn is_excluded_dir(dir_path: &str, config: &TraceDecayConfig) -> bool { - // Try both the dummy-file probe (catches `dir/**`) and the bare directory - // path (catches `**/dirname`). - let descendant_probe = format!("{dir_path}/_"); - any_pattern_matches(&config.exclude, &[&descendant_probe, dir_path]) -} - -/// Returns `true` if the file matches any of the configured exclude patterns. -pub fn is_excluded(file_path: &str, config: &TraceDecayConfig) -> bool { - any_pattern_matches(&config.exclude, &[file_path]) -} - -/// Glob semantics shared by every include/exclude test. Kept in one place so -/// the four entry points cannot drift apart on case or separator handling. -const PATTERN_MATCH_OPTIONS: glob::MatchOptions = glob::MatchOptions { - case_sensitive: true, - require_literal_separator: false, - require_literal_leading_dot: false, -}; - -/// True when any of `patterns` matches any of `candidates`. Unparseable -/// patterns are skipped rather than failing the whole test, matching the -/// long-standing behaviour of the include/exclude entry points. -/// -/// Callers pass every candidate string they want probed, built once per call: -/// the directory variants used to format their `dir/_` probe once per pattern. -fn any_pattern_matches(patterns: &[String], candidates: &[&str]) -> bool { - patterns.iter().any(|pattern_str| { - Pattern::new(pattern_str).is_ok_and(|pattern| { - candidates - .iter() - .any(|candidate| pattern.matches_with(candidate, PATTERN_MATCH_OPTIONS)) - }) - }) -} - /// Serializes test and benchmark code that mutates process-wide storage env /// vars (`TRACEDECAY_DATA_DIR` and related HOME/profile pins). /// diff --git a/crates/tracedecay/src/config/tests.rs b/crates/tracedecay/src/config/tests.rs index 7236b30b57..696b8efc6a 100644 --- a/crates/tracedecay/src/config/tests.rs +++ b/crates/tracedecay/src/config/tests.rs @@ -1,292 +1,12 @@ -use super::{ - GENERATED_DIR_SEGMENTS, TraceDecayConfig, USER_DATA_DIR_ENV, db_filename, get_project_db_path, - get_tracedecay_dir, is_excluded, is_excluded_dir, is_generated_dir_segment, - is_generated_path_segment, is_ignored_by_explicit_global_excludes, is_ignored_by_git, - is_included, lock_user_data_dir_test_env, user_data_dir, -}; -use std::ffi::OsString; use std::fs; -use std::path::PathBuf; use std::process::Command; use tempfile::TempDir; -use tracedecay_semantic_contracts::{ - DEFAULT_FASTEMBED_MODEL_ID, SemanticConfig, SemanticProfileSelection, -}; - -struct EnvRestore { - key: &'static str, - previous: Option, -} - -impl EnvRestore { - fn set(key: &'static str, value: impl AsRef) -> Self { - let previous = std::env::var_os(key); - unsafe { - std::env::set_var(key, value); - } - Self { key, previous } - } -} - -impl Drop for EnvRestore { - fn drop(&mut self) { - unsafe { - match self.previous.take() { - Some(previous) => std::env::set_var(self.key, previous), - None => std::env::remove_var(self.key), - } - } - } -} - -#[test] -fn test_data_dir_defaults_to_tracedecay_for_new_installs() { - let root = TempDir::new().unwrap(); - assert_eq!( - get_tracedecay_dir(root.path()), - root.path().join(".tracedecay") - ); - assert_eq!( - get_project_db_path(root.path()), - root.path().join(".tracedecay/tracedecay.db") - ); -} - -#[test] -fn test_data_dir_uses_tracedecay_when_present() { - let root = TempDir::new().unwrap(); - fs::create_dir(root.path().join(".tracedecay")).unwrap(); - assert_eq!( - get_tracedecay_dir(root.path()), - root.path().join(".tracedecay") - ); -} - -#[cfg(unix)] -#[test] -fn user_data_dir_canonicalizes_symlinked_existing_parent() { - let _lock = lock_user_data_dir_test_env(); - let root = TempDir::new().unwrap(); - let real_home = root.path().join("real-home"); - let linked_home = root.path().join("linked-home"); - fs::create_dir_all(&real_home).unwrap(); - std::os::unix::fs::symlink(&real_home, &linked_home).unwrap(); - let _env = EnvRestore::set(USER_DATA_DIR_ENV, linked_home.join(".tracedecay")); - - assert_eq!( - user_data_dir().unwrap(), - real_home.canonicalize().unwrap().join(".tracedecay") - ); -} - -#[test] -fn nextest_shared_target_profile_is_isolated_by_test_name() { - let _lock = lock_user_data_dir_test_env(); - let root = TempDir::new().unwrap(); - let target = root.path().join("target"); - fs::create_dir_all(target.join("debug")).unwrap(); - let profile = target.join("test-profile/.tracedecay"); - let _profile = EnvRestore::set(USER_DATA_DIR_ENV, &profile); - let _binary_id = EnvRestore::set("NEXTEST_BINARY_ID", "tracedecay::storage_suite"); - let _test_name = EnvRestore::set("NEXTEST_TEST_NAME", "storage_suite::isolated_profile"); - - let resolved = user_data_dir().unwrap(); - - let canonical_profile = target - .canonicalize() - .unwrap() - .join("test-profile/.tracedecay"); - assert!(resolved.starts_with(canonical_profile.join("nextest"))); - assert_ne!(resolved, canonical_profile); -} - -#[test] -fn nextest_shared_target_profile_is_isolated_under_the_perf_profile() { - let _lock = lock_user_data_dir_test_env(); - let root = TempDir::new().unwrap(); - let target = root.path().join("target"); - // A `cargo test-ci` / CI checkout only ever builds `target/perf`. - fs::create_dir_all(target.join("perf")).unwrap(); - let profile = target.join("test-profile/.tracedecay"); - let _profile = EnvRestore::set(USER_DATA_DIR_ENV, &profile); - let _binary_id = EnvRestore::set("NEXTEST_BINARY_ID", "tracedecay::storage_suite"); - let _test_name = EnvRestore::set("NEXTEST_TEST_NAME", "storage_suite::perf_profile"); - - let resolved = user_data_dir().unwrap(); - - let canonical_profile = target - .canonicalize() - .unwrap() - .join("test-profile/.tracedecay"); - assert!(resolved.starts_with(canonical_profile.join("nextest"))); - assert_ne!(resolved, canonical_profile); -} - -#[test] -fn nextest_preserves_explicit_temp_profile_override() { - let _lock = lock_user_data_dir_test_env(); - let root = TempDir::new().unwrap(); - let profile = root.path().join("test-profile/.tracedecay"); - let _profile = EnvRestore::set(USER_DATA_DIR_ENV, &profile); - let _test_name = EnvRestore::set("NEXTEST_TEST_NAME", "storage_suite::explicit_profile"); - - assert_eq!( - user_data_dir().unwrap(), - root.path() - .canonicalize() - .unwrap() - .join("test-profile/.tracedecay") - ); -} - -#[test] -fn test_db_filename_tracks_dir_brand() { - assert_eq!( - db_filename(std::path::Path::new("/p/.tracedecay")), - "tracedecay.db" - ); -} - -#[test] -fn test_is_included_matches_glob() { - let config = TraceDecayConfig { - include: vec![".github/**".to_string()], - ..TraceDecayConfig::default() - }; - assert!(is_included(".github/workflows/ci.yml", &config)); - assert!(is_included(".github/scripts/build.sh", &config)); - assert!(!is_included(".vscode/settings.json", &config)); - assert!(!is_included("src/main.rs", &config)); -} +use tracedecay_configuration::{TraceDecayConfig, get_config_path, save_config_to_path}; +use tracedecay_semantic_contracts::DEFAULT_FASTEMBED_MODEL_ID; #[test] -fn test_is_included_empty_matches_nothing() { +fn semantic_defaults_cover_the_cataloged_fastembed_model() { let config = TraceDecayConfig::default(); - assert!(!is_included(".github/workflows/ci.yml", &config)); -} - -#[test] -fn test_include_records_explicit_override_even_when_excluded() { - let config = TraceDecayConfig { - include: vec![".config/**".to_string()], - exclude: vec![".config/secret/**".to_string()], - ..TraceDecayConfig::default() - }; - assert!(is_included(".config/secret/key.rs", &config)); - assert!(is_excluded(".config/secret/key.rs", &config)); -} - -#[test] -fn test_default_excludes_nested_node_modules() { - let config = TraceDecayConfig::default(); - // Top-level node_modules — should be excluded - assert!(is_excluded("node_modules/express/index.js", &config)); - // Nested node_modules inside a sub-project — must also be excluded - assert!(is_excluded( - "projectA/node_modules/express/index.js", - &config - )); - assert!(is_excluded( - "packages/web/node_modules/react/index.js", - &config - )); - assert!(is_excluded("dist/main.js", &config)); - assert!(is_excluded("packages/web/dist/main.js", &config)); - assert!(is_excluded("coverage/lcov.js", &config)); - assert!(is_excluded("packages/web/.next/server/app.js", &config)); -} - -#[test] -fn test_dir_pruning_pattern_matches_nested_dirs() { - // scan_files_walkdir checks is_excluded("{dir}/_") for directory pruning. - // Patterns like **/node_modules/** must match the dummy-file probe. - let config = TraceDecayConfig::default(); - assert!(is_excluded("node_modules/_", &config)); - assert!(is_excluded("projectA/node_modules/_", &config)); -} - -#[test] -fn test_is_excluded_dir_bare_pattern() { - // Users may write "**/node_modules" (no trailing /**). - // is_excluded_dir should match both bare and /**-suffixed patterns. - let config = TraceDecayConfig { - exclude: vec!["**/dist".to_string()], - ..TraceDecayConfig::default() - }; - assert!(is_excluded_dir("dist", &config)); - assert!(is_excluded_dir("packages/web/dist", &config)); - // Files inside dist should still be caught by accept_file's is_excluded - // but dir pruning prevents even walking into the directory. -} - -#[test] -fn test_is_in_gitignore_respects_global_excludes_file() { - let sandbox = TempDir::new().unwrap(); - let repo = sandbox.path().join("repo"); - fs::create_dir(&repo).unwrap(); - - let mut init = Command::new("git"); - init.env_clear().env("PATH", super::git_subprocess_path()); - let init_status = init - .arg("-C") - .arg(&repo) - .arg("init") - .arg("-q") - .env("GIT_CONFIG_NOSYSTEM", "1") - .status() - .unwrap(); - assert!(init_status.success(), "git init should succeed"); - - let excludes = sandbox.path().join("global_ignore"); - fs::write(&excludes, ".tracedecay\n").unwrap(); - - let git_config = sandbox.path().join("gitconfig"); - let excludes_value = excludes.to_string_lossy().replace('\\', "/"); - fs::write( - &git_config, - format!("[core]\n\texcludesFile = {excludes_value}\n"), - ) - .unwrap(); - - let ignored = is_ignored_by_git(&repo, Some(&git_config)); - - assert_eq!(ignored, Some(true)); -} - -#[test] -fn test_explicit_global_excludes_ignores_comments_and_blank_lines() { - let sandbox = TempDir::new().unwrap(); - let repo = sandbox.path().join("repo"); - fs::create_dir(&repo).unwrap(); - - let excludes = sandbox.path().join("global_ignore"); - fs::write(&excludes, "\n# comment\n.tracedecay/\n").unwrap(); - - let git_config = sandbox.path().join("gitconfig"); - let excludes_value = excludes.to_string_lossy().replace('\\', "/"); - fs::write( - &git_config, - format!("[core]\n\texcludesFile = {excludes_value}\n"), - ) - .unwrap(); - - let ignored = is_ignored_by_explicit_global_excludes(&repo, &git_config); - - assert_eq!(ignored, Some(true)); -} - -#[test] -fn semantic_config_defaults_to_offline_healthy_baseline() { - let config = TraceDecayConfig::default(); - assert_eq!(config.semantic, SemanticConfig::default()); - assert_eq!( - config.semantic.selected_model.as_deref(), - Some(DEFAULT_FASTEMBED_MODEL_ID) - ); - assert!(config.semantic.auto_download); - assert!(config.semantic.active_profile.is_none()); - assert!(config.semantic.rollback_profile.is_none()); - assert!(config.semantic.validate().is_ok()); let catalog = tracedecay_semantic::production_fastembed_catalog(); let model = catalog .get(DEFAULT_FASTEMBED_MODEL_ID) @@ -294,277 +14,10 @@ fn semantic_config_defaults_to_offline_healthy_baseline() { let model_bytes = model.members.get("model").expect("model member").length; assert!(config.semantic.resources.max_model_bytes >= model_bytes); assert!(config.semantic.resources.max_resident_bytes >= model_bytes.saturating_mul(2)); - // Concurrent sessions are host-derived sizing (the serving reservation - // divided by the pinned intra-op width), not a fixed constant. Only the - // floor is a contract: every host embeds with at least one session. assert_eq!( config.semantic.resources.max_concurrent_sessions, tracedecay_semantic::embedding_parallelism::default_max_concurrent_sessions(), ); - assert!(config.semantic.resources.max_concurrent_sessions >= 1); - - let json = serde_json::to_string(&config).unwrap(); - let parsed: TraceDecayConfig = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.semantic, config.semantic); -} - -/// Host-absolute fixture path: `artifact_path` validation requires -/// `Path::is_absolute`, which a bare `/...` literal fails on Windows. -fn absolute_fixture_path(posix: &str) -> PathBuf { - if cfg!(windows) { - PathBuf::from(format!("C:{}", posix.replace('/', "\\"))) - } else { - PathBuf::from(posix) - } -} - -#[test] -fn semantic_config_accepts_only_explicit_local_installed_profiles() { - let local = SemanticProfileSelection { - profile_id: "code-embedding.v1".to_owned(), - accepted_profile_digest: tracedecay_domain::ManifestDigest::new(format!( - "sha256:{}", - "1".repeat(64) - )) - .unwrap(), - artifact_digest: "a".repeat(64), - artifact_path: absolute_fixture_path("/var/lib/tracedecay/models/code-embedding"), - }; - let mut semantic = SemanticConfig { - active_profile: Some(local.clone()), - rollback_profile: Some(SemanticProfileSelection { - profile_id: "code-embedding.previous".to_owned(), - accepted_profile_digest: tracedecay_domain::ManifestDigest::new(format!( - "sha256:{}", - "2".repeat(64) - )) - .unwrap(), - artifact_digest: "b".repeat(64), - artifact_path: absolute_fixture_path( - "/var/lib/tracedecay/models/code-embedding-previous", - ), - }), - ..SemanticConfig::default() - }; - assert!(semantic.validate().is_ok()); - - semantic.active_profile.as_mut().unwrap().artifact_path = - std::path::PathBuf::from("https://models.example/code-embedding"); - assert!( - semantic.validate().is_err(), - "runtime configuration must not admit network or ambient-cache discovery" - ); - semantic.active_profile = Some(local.clone()); - semantic.rollback_profile = Some(local); - assert!( - semantic.validate().is_err(), - "active and rollback selections must remain distinct" - ); -} - -#[test] -fn semantic_resource_ceilings_reject_zero_or_incoherent_limits() { - let mut semantic = SemanticConfig::default(); - semantic.resources.max_threads = 0; - assert!(semantic.validate().is_err()); - - semantic = SemanticConfig::default(); - semantic.resources.max_model_bytes = semantic.resources.max_resident_bytes + 1; - assert!(semantic.validate().is_err()); -} - -#[test] -fn telemetry_timing_defaults_on_and_round_trips() { - let config = TraceDecayConfig::default(); - assert!(config.telemetry.timings); - let json = serde_json::to_string(&config).unwrap(); - let parsed: TraceDecayConfig = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.telemetry, super::TelemetryConfig::default()); - - let legacy = r#"{ - "version": 1, - "root_dir": "/tmp/proj", - "exclude": [], - "max_file_size": 1048576, - "extract_docstrings": true, - "track_call_sites": true - }"#; - let parsed: TraceDecayConfig = serde_json::from_str(legacy).unwrap(); - assert!(parsed.telemetry.timings); - - let disabled = r#"{ - "version": 1, - "root_dir": "/tmp/proj", - "exclude": [], - "max_file_size": 1048576, - "extract_docstrings": true, - "track_call_sites": true, - "telemetry": { "timings": false } - }"#; - let parsed: TraceDecayConfig = serde_json::from_str(disabled).unwrap(); - assert!(!parsed.telemetry.timings); -} - -#[test] -fn diagnostics_prewarm_round_trips_and_defaults_off() { - let config = TraceDecayConfig::default(); - assert!(!config.diagnostics_prewarm, "prewarm must default off"); - let json = serde_json::to_string(&config).unwrap(); - let parsed: TraceDecayConfig = serde_json::from_str(&json).unwrap(); - assert!(!parsed.diagnostics_prewarm); - - // Explicit true round-trips, and old configs without the key default. - let mut on = config.clone(); - on.diagnostics_prewarm = true; - let parsed: TraceDecayConfig = - serde_json::from_str(&serde_json::to_string(&on).unwrap()).unwrap(); - assert!(parsed.diagnostics_prewarm); - let legacy = r#"{ - "version": 1, - "root_dir": "/tmp/proj", - "exclude": [], - "max_file_size": 1048576, - "extract_docstrings": true, - "track_call_sites": true - }"#; - let parsed: TraceDecayConfig = serde_json::from_str(legacy).unwrap(); - assert!(!parsed.diagnostics_prewarm); -} - -#[test] -fn config_without_sync_key_deserializes_to_default_sync() { - // Old config.json files predate the `sync` table; the field-level - // `#[serde(default)]` must fill it in. - let json = r#"{ - "version": 1, - "root_dir": "/tmp/proj", - "exclude": [], - "max_file_size": 1048576, - "extract_docstrings": true, - "track_call_sites": true - }"#; - let parsed: TraceDecayConfig = serde_json::from_str(json).unwrap(); - assert_eq!(parsed.sync, super::SyncConfig::default()); -} - -#[test] -fn partial_sync_table_fills_missing_fields_with_defaults() { - // Only two sync keys present; every other field must default. - let json = r#"{ - "version": 1, - "root_dir": "/tmp/proj", - "exclude": [], - "max_file_size": 1048576, - "extract_docstrings": true, - "track_call_sites": true, - "sync": { "auto_watch": false, "backstop_interval_mins": 99 } - }"#; - let parsed: TraceDecayConfig = serde_json::from_str(json).unwrap(); - assert!(!parsed.sync.auto_watch); - assert!(!parsed.sync.watch_linked_worktrees); - assert_eq!(parsed.sync.backstop_interval_mins, 99); - // Untouched fields keep their defaults. - assert_eq!(parsed.sync.watch_debounce_ms, 2000); - assert_eq!(parsed.sync.max_concurrent_syncs, 2); - assert!(parsed.sync.read_refresh); -} - -#[test] -fn pr_autotrack_defaults_off_and_survives_missing_keys() { - // Back-compat: a config predating the PR-autotrack keys must default the - // feature OFF and to the 300s poll cadence. - let json = r#"{ - "version": 1, - "root_dir": "/tmp/proj", - "exclude": [], - "max_file_size": 1048576, - "extract_docstrings": true, - "track_call_sites": true, - "sync": { "auto_watch": true } - }"#; - let parsed: TraceDecayConfig = serde_json::from_str(json).unwrap(); - assert!(!parsed.sync.auto_track_pr_branches); - assert_eq!(parsed.sync.auto_track_pr_poll_secs, 300); - assert_eq!(parsed.sync.effective_auto_track_pr_poll_secs(), 300); -} - -#[test] -fn pr_autotrack_round_trips_and_clamps_poll_floor() { - let json = r#"{ - "version": 1, - "root_dir": "/tmp/proj", - "exclude": [], - "max_file_size": 1048576, - "extract_docstrings": true, - "track_call_sites": true, - "sync": { "auto_track_pr_branches": true, "auto_track_pr_poll_secs": 5 } - }"#; - let parsed: TraceDecayConfig = serde_json::from_str(json).unwrap(); - assert!(parsed.sync.auto_track_pr_branches); - assert_eq!(parsed.sync.auto_track_pr_poll_secs, 5); - // A too-small interval is clamped up to the safety floor. - assert_eq!( - parsed.sync.effective_auto_track_pr_poll_secs(), - super::MIN_AUTO_TRACK_PR_POLL_SECS - ); - - // Serialize → deserialize preserves the raw values. - let round = serde_json::to_string(&parsed).unwrap(); - let reparsed: TraceDecayConfig = serde_json::from_str(&round).unwrap(); - assert_eq!(reparsed.sync, parsed.sync); -} - -#[test] -fn parse_env_bool_shares_canonical_truthy_spellings() { - for raw in ["1", "true", "TRUE", "yes", "on", " YES "] { - assert_eq!(super::parse_env_bool(raw), Some(true), "{raw}"); - } - for raw in ["0", "false", "FALSE"] { - assert_eq!(super::parse_env_bool(raw), Some(false), "{raw}"); - } - assert_eq!(super::parse_env_bool("maybe"), None); -} - -#[test] -fn pr_autotrack_env_overrides() { - let _lock = lock_user_data_dir_test_env(); - let _enable = EnvRestore::set("TRACEDECAY_SYNC_AUTO_TRACK_PR_BRANCHES", "true"); - let _poll = EnvRestore::set("TRACEDECAY_SYNC_AUTO_TRACK_PR_POLL_SECS", "120"); - - let overridden = super::SyncConfig::default().with_env_overrides(); - assert!(overridden.auto_track_pr_branches); - assert_eq!(overridden.auto_track_pr_poll_secs, 120); -} - -#[test] -fn sync_config_env_overrides_bool_and_int() { - let _lock = lock_user_data_dir_test_env(); - let _watch = EnvRestore::set("TRACEDECAY_SYNC_AUTO_WATCH", "false"); - let _linked = EnvRestore::set("TRACEDECAY_SYNC_WATCH_LINKED_WORKTREES", "true"); - let _debounce = EnvRestore::set("TRACEDECAY_SYNC_WATCH_DEBOUNCE_MS", "5000"); - // Unparsable ints/bools are ignored (field keeps its base value). - let _bad = EnvRestore::set("TRACEDECAY_SYNC_MAX_CONCURRENT_SYNCS", "not-a-number"); - - let overridden = super::SyncConfig::default().with_env_overrides(); - assert!(!overridden.auto_watch); - assert!(overridden.watch_linked_worktrees); - assert_eq!(overridden.watch_debounce_ms, 5000); - assert_eq!( - overridden.max_concurrent_syncs, - super::SyncConfig::default().max_concurrent_syncs - ); -} - -#[test] -fn implicit_discovery_never_selects_the_user_profile_root() { - let _profile = super::PinnedUserDataDir::new(); - let home = PathBuf::from(std::env::var_os("HOME").expect("pinned HOME")); - fs::write(super::get_project_db_path(&home), b"").expect("ambient project marker"); - let nested = home.join("unrelated/nested"); - fs::create_dir_all(&nested).expect("nested directory"); - - assert!(super::is_ambient_project_root(&home)); - assert_eq!(super::discover_project_root(&nested), None); } #[tokio::test] @@ -696,7 +149,7 @@ async fn config_path_with_identity_does_not_open_registry_without_enrollment() { }, ) .unwrap(); - super::save_config_to_path( + save_config_to_path( &identity_layout.config_path, &TraceDecayConfig { root_dir: "identity-config".to_string(), @@ -707,7 +160,7 @@ async fn config_path_with_identity_does_not_open_registry_without_enrollment() { assert_eq!( super::get_config_path_with_identity(&project_root).await, - super::get_config_path(&project_root) + get_config_path(&project_root) ); assert_eq!( super::load_config_with_identity(&project_root) @@ -786,91 +239,6 @@ async fn discover_project_root_with_identity_preserves_sync_fast_path() { ); } -// --------------------------------------------------------------------------- -// Shared generated/vendored segment list -// -// GENERATED_DIR_SEGMENTS unifies what used to be four independently -// hand-maintained lists: this module's own DEFAULT_EXCLUDE_PATTERNS, -// tracedecay::scan's is_skipped_dir_hint, migrate::inventory's -// should_prune_dir, and mcp::tools::handlers::redundancy's -// is_generated_path. These tests pin the union those four call sites need -// and spot-check that segments unique to one of the formerly-separate lists -// are now recognized everywhere. -// --------------------------------------------------------------------------- - -#[test] -fn generated_dir_segments_cover_the_union_all_call_sites_need() { - // Formerly scan.rs-only (its HINTABLE_DIRS list). - for segment in [ - "node_modules", - "vendor", - "build", - "dist", - "out", - "coverage", - ".cache", - ".next", - ".turbo", - ".gradle", - ".venv", - "venv", - "__pycache__", - ] { - assert!( - GENERATED_DIR_SEGMENTS.contains(&segment), - "{segment} (from scan.rs's old list) missing from GENERATED_DIR_SEGMENTS" - ); - } - // Formerly migrate::inventory-only addition beyond the scan.rs set. - assert!(GENERATED_DIR_SEGMENTS.contains(&"target")); - // Formerly redundancy.rs-only addition beyond the scan.rs set. - assert!(GENERATED_DIR_SEGMENTS.contains(&".worktrees")); - // `.git` is intentionally NOT part of the shared list — it stays a - // site-local addition in migrate::inventory::should_prune_dir (see its - // doc comment) because it's VCS metadata, not generated/vendored code. - assert!(!GENERATED_DIR_SEGMENTS.contains(&".git")); -} - -#[test] -fn is_generated_dir_segment_delegates_for_segments_unique_to_one_former_list() { - // Every one of these previously lived in only one of the four lists; - // is_generated_dir_segment must now recognize all of them. - for segment in ["target", ".worktrees", "coverage", ".venv", "__pycache__"] { - assert!( - is_generated_dir_segment(segment), - "{segment} should be recognized as a generated/vendored segment" - ); - } - assert!(!is_generated_dir_segment("src")); - assert!(!is_generated_dir_segment("builder")); -} - -#[test] -fn is_generated_path_segment_matches_segments_and_minified_suffix() { - assert!(is_generated_path_segment("packages/web/target/debug/x")); - assert!(is_generated_path_segment(".worktrees/feature/src/lib.rs")); - assert!(is_generated_path_segment("assets/app.min.js")); - assert!(is_generated_path_segment("assets/app.min.css")); - assert!(!is_generated_path_segment("src/redundancy.rs")); - assert!(!is_generated_path_segment("builder/mod.rs")); -} - -#[test] -fn default_excludes_still_catch_target_and_worktrees() { - // Regression guard for the DEFAULT_EXCLUDE_PATTERNS rebuild: target/** - // previously had no **/target/** nested form (a real drift bug this - // unification fixes), and .worktrees was never excluded by default at - // all. - let config = TraceDecayConfig::default(); - assert!(is_excluded("target/debug/build", &config)); - assert!(is_excluded("crates/sub/target/debug/build", &config)); - assert!(is_excluded(".worktrees/feature/src/lib.rs", &config)); - // Site-local additions (not part of GENERATED_DIR_SEGMENTS) still work. - assert!(is_excluded(".git/HEAD", &config)); - assert!(is_excluded(".tracedecay/tracedecay.db", &config)); - assert!(is_excluded("bin/cli.js", &config)); -} - mod runtime_configuration_cutover { #[cfg(unix)] use std::process::Command; @@ -893,11 +261,11 @@ mod runtime_configuration_cutover { use crate::config::resolver::{ConfigurationLayerV1, resolve_configuration}; use crate::config::{ PinnedRuntimeConfiguration, RuntimeConfigurationCache, RuntimeConfigurationTarget, - TraceDecayConfig, cached_runtime_configuration, cached_sync_config, - cached_telemetry_config, install_pinned_runtime_configuration, - runtime_configuration_for_layout, + cached_runtime_configuration, cached_sync_config, cached_telemetry_config, + install_pinned_runtime_configuration, runtime_configuration_for_layout, }; use crate::test_support::host_admission::HostAdmissionTestRuntimeV1; + use tracedecay_configuration::TraceDecayConfig; use tracedecay_configuration::{ ConfigurationControlStore, ConfigurationMutationAuthority, DirectConfigurationMutation, ProjectConfigurationRuntime, @@ -1241,7 +609,7 @@ mod runtime_configuration_cutover { ); assert_eq!( root_pin.config().sync.retention, - crate::config::RetentionConfig::default() + tracedecay_configuration::RetentionConfig::default() ); } @@ -1865,115 +1233,3 @@ mod runtime_configuration_cutover { ); } } - -#[cfg(test)] -#[allow(clippy::unwrap_used)] -mod retention_config_tests { - use crate::config::{RetentionConfig, SyncConfig}; - use tracedecay_maintenance::retention::branch_compaction::CompactionThresholdConfig; - - #[test] - fn default_retention_runs_only_safe_bounded_maintenance() { - let retention = RetentionConfig::default(); - assert!( - retention.session_lcm.enabled, - "projection-durable session dedupe enabled by default" - ); - assert_eq!(retention.session_lcm.offload_after_days, Some(30)); - assert_eq!(retention.session_lcm.drop_after_days, Some(180)); - assert_eq!(retention.session_lcm.dedupe_projected_after_days, Some(30)); - assert_eq!(retention.session_lcm.max_batch_size, 500); - assert!( - retention.observation.enabled, - "released observation evidence maintenance is active by default" - ); - assert_eq!(retention.observation.anchor_release_after_days, Some(30)); - assert_eq!( - retention.observation.observation_release_after_days, - Some(30) - ); - assert_eq!( - retention.observation.provenance_release_after_days, - Some(30) - ); - assert_eq!(retention.orphan_store_gc_days, Some(30)); - assert_eq!(retention.incident_debris_retention_days, Some(30)); - let compaction = retention.compaction.expect("compaction enabled"); - assert!((compaction.free_page_ratio_threshold - 0.25).abs() < f64::EPSILON); - assert_eq!(compaction.minimum_reclaimable_bytes, 64 * 1024 * 1024); - assert_eq!(compaction.max_pages_per_tick, 1024); - assert_eq!(compaction, CompactionThresholdConfig::default()); - assert!(retention.store_soft_budgets_bytes.is_empty()); - // A default SyncConfig carries the same bounded retention tree. - assert_eq!(SyncConfig::default().retention, retention); - } - - #[test] - fn empty_json_object_deserializes_to_safe_defaults() { - // A serde-compat empty object (older config with no retention block) - // must resolve the same safe maintenance policy. - let retention: RetentionConfig = serde_json::from_str("{}").unwrap(); - assert_eq!(retention, RetentionConfig::default()); - - let nested: RetentionConfig = - serde_json::from_str(r#"{"session_lcm":{},"observation":{}}"#).unwrap(); - assert_eq!(nested, RetentionConfig::default()); - assert!(nested.observation.reclaim_superseded_cursor_advances); - } - - #[test] - fn retention_rejects_immediate_collection_and_invalid_compaction_ratio() { - let retention = RetentionConfig { - orphan_store_gc_days: Some(0), - ..RetentionConfig::default() - }; - assert!(retention.validate().is_err()); - - let retention = RetentionConfig { - incident_debris_retention_days: Some(0), - ..RetentionConfig::default() - }; - assert!(retention.validate().is_err()); - - let mut retention = RetentionConfig::default(); - retention - .compaction - .as_mut() - .expect("default compaction") - .free_page_ratio_threshold = 1.01; - assert!(retention.validate().is_err()); - } - - #[test] - fn retention_config_json_round_trips_with_windows_set() { - let json = r#"{ - "session_lcm": { "enabled": true, "drop_after_days": 30 }, - "observation": { "enabled": true, "anchor_release_after_days": 45 }, - "orphan_store_gc_days": 14, - "incident_debris_retention_days": 21, - "compaction": { "free_page_ratio_threshold": 0.25, "minimum_reclaimable_bytes": 1000000 }, - "store_soft_budgets_bytes": { "sessions.db": 2000000000 }, - "interval_hours": 12 - }"#; - let retention: RetentionConfig = serde_json::from_str(json).unwrap(); - assert!(retention.session_lcm.enabled); - assert_eq!(retention.session_lcm.drop_after_days, Some(30)); - assert!(retention.observation.enabled); - assert_eq!(retention.observation.anchor_release_after_days, Some(45)); - assert_eq!(retention.orphan_store_gc_days, Some(14)); - assert_eq!(retention.incident_debris_retention_days, Some(21)); - assert_eq!(retention.interval_hours, 12); - let compaction = retention.compaction.expect("compaction configured"); - assert!((compaction.free_page_ratio_threshold - 0.25).abs() < f64::EPSILON); - assert_eq!(compaction.minimum_reclaimable_bytes, 1_000_000); - assert_eq!( - retention.store_soft_budgets_bytes.get("sessions.db"), - Some(&2_000_000_000) - ); - - // Re-serialize and re-parse: the tree is stable across a round trip. - let reserialized = serde_json::to_string(&retention).unwrap(); - let reparsed: RetentionConfig = serde_json::from_str(&reserialized).unwrap(); - assert_eq!(retention, reparsed); - } -} diff --git a/crates/tracedecay/src/daemon/bootstrap.rs b/crates/tracedecay/src/daemon/bootstrap.rs index f81c3a341f..08d3efbe86 100644 --- a/crates/tracedecay/src/daemon/bootstrap.rs +++ b/crates/tracedecay/src/daemon/bootstrap.rs @@ -167,7 +167,7 @@ async fn run_foreground_loopback( ); let lifecycle = DaemonLifecycle::default(); - let sync_config = crate::config::SyncConfig::default().with_env_overrides(); + let sync_config = tracedecay_configuration::SyncConfig::default().with_env_overrides(); let profile_database = store_administration.registered_profile_database().await?; let maintenance = maintenance::MaintenanceCoordinator::spawn( profile_root.clone(), @@ -637,7 +637,7 @@ async fn run_foreground_unix( .session_runtime_registry() .await?, ); - let sync_config = crate::config::SyncConfig::default().with_env_overrides(); + let sync_config = tracedecay_configuration::SyncConfig::default().with_env_overrides(); let profile_database = engine .store_administration .registered_profile_database() diff --git a/crates/tracedecay/src/daemon/core_proxy.rs b/crates/tracedecay/src/daemon/core_proxy.rs index edd96981b5..c423d90be9 100644 --- a/crates/tracedecay/src/daemon/core_proxy.rs +++ b/crates/tracedecay/src/daemon/core_proxy.rs @@ -510,7 +510,7 @@ pub(crate) async fn resolve_daemon_initialize_route( // initialize-roots repos unable to open at all. let allow_init = crate::config::cached_sync_config(&identity.worktree_root) .map_or_else( - |_| crate::config::SyncConfig::default().auto_init, + |_| tracedecay_configuration::SyncConfig::default().auto_init, |config| config.auto_init, ); return Ok(Some(InitializeRouteMetadata { diff --git a/crates/tracedecay/src/daemon/doctor_kernel.rs b/crates/tracedecay/src/daemon/doctor_kernel.rs index e2ec4b43d9..2b3e52dec1 100644 --- a/crates/tracedecay/src/daemon/doctor_kernel.rs +++ b/crates/tracedecay/src/daemon/doctor_kernel.rs @@ -341,7 +341,7 @@ async fn collect_over_budget_store_findings( tracedecay_contracts::storage::StoreKeyV1, GuardedStoreTelemetryPort, )], - retention: &crate::config::RetentionConfig, + retention: &tracedecay_configuration::RetentionConfig, ) -> CollectedStoreTelemetryV1 { use std::collections::BTreeMap; use tracedecay_contracts::storage::{ @@ -785,7 +785,7 @@ pub(in crate::daemon) fn production_doctor_report_reader( profile_root: PathBuf, host_home: Option, remote_operational: Arc RemoteOperationalReadV1 + Send + Sync>, - retention: crate::config::RetentionConfig, + retention: tracedecay_configuration::RetentionConfig, schedulers: tracedecay_code_index_runtime::code_index_scheduler::CodeIndexSchedulerRegistryV1, diagnostic_broker: Arc>, feedback_runtimes: DaemonFeedbackRuntimeRegistrar, diff --git a/crates/tracedecay/src/daemon/engine.rs b/crates/tracedecay/src/daemon/engine.rs index 2ab23933bd..7f0ba5d641 100644 --- a/crates/tracedecay/src/daemon/engine.rs +++ b/crates/tracedecay/src/daemon/engine.rs @@ -15,7 +15,7 @@ use tracedecay_daemon_protocol::{client_version_skew, version_skew_action}; use tracedecay_hooks::core_events::HOOK_EVENT_METHOD; #[cfg(unix)] -fn git_watch_sync_config(config: &crate::config::SyncConfig) -> GitWatchSyncConfigV1 { +fn git_watch_sync_config(config: &tracedecay_configuration::SyncConfig) -> GitWatchSyncConfigV1 { GitWatchSyncConfigV1 { auto_watch: config.auto_watch, watch_linked_worktrees: config.watch_linked_worktrees, diff --git a/crates/tracedecay/src/daemon/maintenance.rs b/crates/tracedecay/src/daemon/maintenance.rs index 7e8016a005..90d67436df 100644 --- a/crates/tracedecay/src/daemon/maintenance.rs +++ b/crates/tracedecay/src/daemon/maintenance.rs @@ -41,7 +41,7 @@ async fn join_abandoned_maintenance_task(task: Option>, owner: &' async fn run_registered_store_retention( database: &tracedecay_global_db::RegisteredGlobalDb, - config: &crate::config::RetentionConfig, + config: &tracedecay_configuration::RetentionConfig, ) -> bool { let now = match now_secs_i64() { Ok(now) => now, @@ -306,7 +306,7 @@ impl MaintenanceCoordinator { profile_database: tracedecay_global_db::RegisteredGlobalDbLeaseV1, administration: StoreAdministration, code_index_schedulers: tracedecay_code_index_runtime::code_index_scheduler::CodeIndexSchedulerRegistryV1, - retention: crate::config::RetentionConfig, + retention: tracedecay_configuration::RetentionConfig, branch_gc: BranchStoreGcCadenceV1, ) -> Self { let coordinator = Self::default(); @@ -417,7 +417,7 @@ impl MaintenanceCoordinator { profile_database: tracedecay_global_db::RegisteredGlobalDbLeaseV1, administration: StoreAdministration, code_index_schedulers: tracedecay_code_index_runtime::code_index_scheduler::CodeIndexSchedulerRegistryV1, - retention: crate::config::RetentionConfig, + retention: tracedecay_configuration::RetentionConfig, branch_gc: BranchStoreGcCadenceV1, interval: Duration, ) { @@ -446,7 +446,7 @@ impl MaintenanceCoordinator { profile_database: &tracedecay_global_db::RegisteredGlobalDb, administration: &StoreAdministration, code_index_schedulers: &tracedecay_code_index_runtime::code_index_scheduler::CodeIndexSchedulerRegistryV1, - retention: &crate::config::RetentionConfig, + retention: &tracedecay_configuration::RetentionConfig, branch_gc: BranchStoreGcCadenceV1, continuation: Option, ) -> MaintenanceTickOutcome { @@ -849,7 +849,9 @@ impl ResidentMemoryLogStateV1 { } } -pub(super) fn retention_maintenance_enabled(retention: &crate::config::RetentionConfig) -> bool { +pub(super) fn retention_maintenance_enabled( + retention: &tracedecay_configuration::RetentionConfig, +) -> bool { retention.session_lcm.enabled || retention.observation.enabled || retention.orphan_store_gc_days.is_some() @@ -1820,7 +1822,7 @@ mod tests { #[test] fn debris_retention_enables_maintenance_without_orphan_gc() { - let mut retention = crate::config::RetentionConfig::default(); + let mut retention = tracedecay_configuration::RetentionConfig::default(); retention.session_lcm.enabled = false; retention.observation.enabled = false; retention.orphan_store_gc_days = None; @@ -1832,7 +1834,7 @@ mod tests { #[test] fn soft_budget_alone_never_enables_destructive_maintenance() { - let mut retention = crate::config::RetentionConfig::default(); + let mut retention = tracedecay_configuration::RetentionConfig::default(); retention.session_lcm.enabled = false; retention.observation.enabled = false; retention.orphan_store_gc_days = None; diff --git a/crates/tracedecay/src/daemon/scheduler.rs b/crates/tracedecay/src/daemon/scheduler.rs index e08717bae5..ccadcea29a 100644 --- a/crates/tracedecay/src/daemon/scheduler.rs +++ b/crates/tracedecay/src/daemon/scheduler.rs @@ -1477,7 +1477,7 @@ fn finish_global_retention(now: std::time::Instant, succeeded: bool) { } fn global_table_retention_config( - config: &crate::config::RetentionConfig, + config: &tracedecay_configuration::RetentionConfig, ) -> tracedecay_maintenance::retention::RetentionConfig { let (session_messages_days, lcm_raw_messages_days) = if config.session_lcm.enabled { ( @@ -1503,7 +1503,7 @@ fn global_table_retention_config( async fn maybe_run_global_retention( administration: &super::branch_admin::StoreAdministration, database: &tracedecay_global_db::RegisteredGlobalDb, - config: &crate::config::RetentionConfig, + config: &tracedecay_configuration::RetentionConfig, ) { let Some(reservation) = reserve_global_retention(std::time::Instant::now()) else { return; @@ -1704,8 +1704,8 @@ mod global_retention_tests { .expect("decode retention deletion receipt count") } - fn global_retention_config() -> crate::config::RetentionConfig { - let mut config = crate::config::RetentionConfig::default(); + fn global_retention_config() -> tracedecay_configuration::RetentionConfig { + let mut config = tracedecay_configuration::RetentionConfig::default(); config.session_lcm.enabled = true; config.session_lcm.dedupe_projected_after_days = Some(1); config.session_lcm.drop_after_days = None; diff --git a/crates/tracedecay/src/daemon/tests/restart_proxy.rs b/crates/tracedecay/src/daemon/tests/restart_proxy.rs index d1d3aa9aee..6d50c24ded 100644 --- a/crates/tracedecay/src/daemon/tests/restart_proxy.rs +++ b/crates/tracedecay/src/daemon/tests/restart_proxy.rs @@ -677,11 +677,11 @@ async fn initialize_root_routing_fails_closed_without_pinned_configuration() { }) .to_string(); - let config = crate::config::TraceDecayConfig { + let config = tracedecay_configuration::TraceDecayConfig { root_dir: project.display().to_string(), - ..crate::config::TraceDecayConfig::default() + ..tracedecay_configuration::TraceDecayConfig::default() }; - let config_path = crate::config::get_config_path(&project); + let config_path = tracedecay_configuration::get_config_path(&project); std::fs::create_dir_all(config_path.parent().expect("legacy config parent")) .expect("create legacy config parent"); let legacy_input = serde_json::to_string_pretty(&config).expect("serialize legacy config"); diff --git a/crates/tracedecay/src/dashboard.rs b/crates/tracedecay/src/dashboard.rs index c4cfbd762c..d44d186f0e 100644 --- a/crates/tracedecay/src/dashboard.rs +++ b/crates/tracedecay/src/dashboard.rs @@ -70,14 +70,7 @@ pub(crate) fn dashboard_project_context( store_layout: graph.store_layout().clone(), dashboard_db_path: graph.dashboard_db_path(), dashboard_database: graph.dashboard_database_guard(), - retention_config: tracedecay_dashboard_api::config::RetentionConfig { - store_soft_budgets_bytes: graph - .get_config() - .sync - .retention - .store_soft_budgets_bytes - .clone(), - }, + retention_config: graph.get_config().sync.retention.clone(), host_io: tracedecay_agent_hosts::host_io(), user_settings_client: graph.configuration_runtime().user_settings_client(), } diff --git a/crates/tracedecay/src/mcp/server.rs b/crates/tracedecay/src/mcp/server.rs index 8947ba4806..3c55bcbfd3 100644 --- a/crates/tracedecay/src/mcp/server.rs +++ b/crates/tracedecay/src/mcp/server.rs @@ -490,7 +490,7 @@ pub struct McpServer { /// The `[sync]` config resolved once at construction from the project /// root (plus `TRACEDECAY_SYNC_*` env overrides). Cached so the read /// hot path never re-reads the config file per `tools/call`. - sync_config: crate::config::SyncConfig, + sync_config: tracedecay_configuration::SyncConfig, /// Savings-ledger recorder tasks spawned so far / finished so far, plus /// a notifier pinged on every completion. Production never awaits these /// (ledger writes stay fire-and-forget); tests await @@ -1290,7 +1290,7 @@ impl McpServer { } } - pub(crate) fn watcher_sync_config(&self) -> &crate::config::SyncConfig { + pub(crate) fn watcher_sync_config(&self) -> &tracedecay_configuration::SyncConfig { &self.sync_config } diff --git a/crates/tracedecay/src/tracedecay.rs b/crates/tracedecay/src/tracedecay.rs index 381b99d0a8..a9308b0b54 100644 --- a/crates/tracedecay/src/tracedecay.rs +++ b/crates/tracedecay/src/tracedecay.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use std::sync::{Arc, OnceLock}; -use crate::config::TraceDecayConfig; +use tracedecay_configuration::TraceDecayConfig; use tracedecay_contracts::context_scout::ContextScoutAddressV1; use tracedecay_domain::errors::Result; use tracedecay_graph_query::SourceReadContext; diff --git a/crates/tracedecay/src/tracedecay/lifecycle/identity.rs b/crates/tracedecay/src/tracedecay/lifecycle/identity.rs index 0d47fc97c7..c85e53e740 100644 --- a/crates/tracedecay/src/tracedecay/lifecycle/identity.rs +++ b/crates/tracedecay/src/tracedecay/lifecycle/identity.rs @@ -142,13 +142,14 @@ impl TraceDecay { Some(layout) => Ok(layout), None if allow_default_identity => { if let Some(registry_database) = registry_database - && let Some(layout) = Self::adopt_moved_nongit_project( - project_root, - &profile_root, - registry_database, - adoption, - ) - .await? + && let Some(layout) = + tracedecay_application::project_adoption::adopt_moved_nongit_project( + project_root, + &profile_root, + registry_database, + adoption, + ) + .await? { return Ok(layout); } diff --git a/crates/tracedecay/src/tracedecay/lifecycle/mod.rs b/crates/tracedecay/src/tracedecay/lifecycle/mod.rs index 3463c06d5b..0473b86267 100644 --- a/crates/tracedecay/src/tracedecay/lifecycle/mod.rs +++ b/crates/tracedecay/src/tracedecay/lifecycle/mod.rs @@ -28,7 +28,6 @@ use tracedecay_store_runtime::DaemonSessionRuntimeRegistryV1; use super::{TraceDecay, TraceDecayOpenOptions}; -mod adoption; mod branches; mod identity; mod registry; diff --git a/crates/tracedecay/src/tracedecay/queries/meta.rs b/crates/tracedecay/src/tracedecay/queries/meta.rs index b81e29f8ae..3903c4f7dc 100644 --- a/crates/tracedecay/src/tracedecay/queries/meta.rs +++ b/crates/tracedecay/src/tracedecay/queries/meta.rs @@ -1,10 +1,10 @@ use std::path::Path; -use crate::config::TraceDecayConfig; use crate::tracedecay::TraceDecay; use tracedecay_application::tracedecay::{ add_local_counter, get_local_counter, get_tokens_saved, reset_local_counter, set_tokens_saved, }; +use tracedecay_configuration::TraceDecayConfig; use tracedecay_domain::errors::Result; impl TraceDecay { diff --git a/crates/tracedecay/tests/storage_suite/storage_resolver_test.rs b/crates/tracedecay/tests/storage_suite/storage_resolver_test.rs index 4b23f42fa5..2b1aa3957d 100644 --- a/crates/tracedecay/tests/storage_suite/storage_resolver_test.rs +++ b/crates/tracedecay/tests/storage_suite/storage_resolver_test.rs @@ -8,10 +8,11 @@ use serde_json::Value; #[cfg(unix)] use std::os::unix::fs::symlink; use tempfile::TempDir; -use tracedecay::config::{TraceDecayConfig, USER_DATA_DIR_ENV}; -use tracedecay::config::{discover_project_root, get_config_path, load_config}; +use tracedecay::config::USER_DATA_DIR_ENV; +use tracedecay::config::discover_project_root; use tracedecay::test_support::host_admission::HostAdmissionTestRuntimeV1; use tracedecay::tracedecay::{TraceDecay, TraceDecayOpenOptions}; +use tracedecay_configuration::{TraceDecayConfig, get_config_path, load_config}; use tracedecay_global_db::{ProjectObservationStoreError, StoreInstanceUpsert}; use tracedecay_mcp::response_handles::{ ResponseHandleLookup, retrieve_response_handle, store_response_handle,