diff --git a/rust/crates/runtime/src/config.rs b/rust/crates/runtime/src/config.rs index 1566189282..d7e7dcaac7 100644 --- a/rust/crates/runtime/src/config.rs +++ b/rust/crates/runtime/src/config.rs @@ -40,6 +40,45 @@ pub struct RuntimeConfig { feature_config: RuntimeFeatureConfig, } +/// Machine-readable load state for a discovered config file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigFileStatus { + Loaded, + NotFound, + Skipped, + LoadError, +} + +impl ConfigFileStatus { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Loaded => "loaded", + Self::NotFound => "not_found", + Self::Skipped => "skipped", + Self::LoadError => "load_error", + } + } +} + +/// Structured status for a single discovered config file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigFileReport { + pub entry: ConfigEntry, + pub loaded: bool, + pub status: ConfigFileStatus, + pub reason: Option, + pub detail: Option, +} + +/// Best-effort inspection of the full config discovery/load pipeline. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigInspection { + pub files: Vec, + pub runtime_config: Option, + pub load_error: Option, +} + /// Parsed plugin-related settings extracted from runtime config. #[derive(Debug, Clone, PartialEq, Eq, Default)] pub struct RuntimePluginConfig { @@ -276,7 +315,7 @@ impl ConfigLoader { for entry in self.discover() { crate::config_validate::check_unsupported_format(&entry.path)?; - let Some(parsed) = read_optional_json_object(&entry.path)? else { + let OptionalConfigFile::Loaded(parsed) = read_optional_json_object(&entry.path)? else { continue; }; let validation = crate::config_validate::validate_config_file( @@ -299,32 +338,188 @@ impl ConfigLoader { eprintln!("warning: {warning}"); } - let merged_value = JsonValue::Object(merged.clone()); + build_runtime_config(merged, loaded_entries, mcp_servers) + } - let feature_config = RuntimeFeatureConfig { - hooks: parse_optional_hooks_config(&merged_value)?, - plugins: parse_optional_plugin_config(&merged_value)?, - mcp: McpConfigCollection { - servers: mcp_servers, + /// Inspect discovered files and return per-file statuses without aborting + /// the whole report on the first missing/skipped/invalid file. + #[must_use] + pub fn inspect(&self) -> ConfigInspection { + let mut merged = BTreeMap::new(); + let mut loaded_entries = Vec::new(); + let mut mcp_servers = BTreeMap::new(); + let mut all_warnings = Vec::new(); + let mut files = Vec::new(); + let mut first_error = None; + + for entry in self.discover() { + if let Err(error) = crate::config_validate::check_unsupported_format(&entry.path) { + let detail = error.to_string(); + first_error.get_or_insert_with(|| detail.clone()); + files.push(ConfigFileReport { + entry, + loaded: false, + status: ConfigFileStatus::LoadError, + reason: Some("unsupported_format".to_string()), + detail: Some(detail), + }); + continue; + } + + let parsed = match read_optional_json_object(&entry.path) { + Ok(OptionalConfigFile::Loaded(parsed)) => parsed, + Ok(OptionalConfigFile::NotFound) => { + files.push(ConfigFileReport { + entry, + loaded: false, + status: ConfigFileStatus::NotFound, + reason: Some("not_found".to_string()), + detail: None, + }); + continue; + } + Ok(OptionalConfigFile::Skipped { reason, detail }) => { + files.push(ConfigFileReport { + entry, + loaded: false, + status: ConfigFileStatus::Skipped, + reason: Some(reason), + detail, + }); + continue; + } + Err(error) => { + let detail = error.to_string(); + first_error.get_or_insert_with(|| detail.clone()); + let reason = match &error { + ConfigError::Io(io_error) + if io_error.kind() == std::io::ErrorKind::PermissionDenied => + { + "permission_denied" + } + ConfigError::Io(_) => "io_error", + ConfigError::Parse(_) => "parse_error", + }; + files.push(ConfigFileReport { + entry, + loaded: false, + status: ConfigFileStatus::LoadError, + reason: Some(reason.to_string()), + detail: Some(detail), + }); + continue; + } + }; + + let validation = crate::config_validate::validate_config_file( + &parsed.object, + &parsed.source, + &entry.path, + ); + if !validation.is_ok() { + let detail = validation.errors[0].to_string(); + first_error.get_or_insert_with(|| detail.clone()); + files.push(ConfigFileReport { + entry, + loaded: false, + status: ConfigFileStatus::LoadError, + reason: Some("validation_error".to_string()), + detail: Some(detail), + }); + continue; + } + all_warnings.extend(validation.warnings); + + if let Err(error) = validate_optional_hooks_config(&parsed.object, &entry.path) { + let detail = error.to_string(); + first_error.get_or_insert_with(|| detail.clone()); + files.push(ConfigFileReport { + entry, + loaded: false, + status: ConfigFileStatus::LoadError, + reason: Some("validation_error".to_string()), + detail: Some(detail), + }); + continue; + } + + if let Err(error) = + merge_mcp_servers(&mut mcp_servers, entry.source, &parsed.object, &entry.path) + { + let detail = error.to_string(); + first_error.get_or_insert_with(|| detail.clone()); + files.push(ConfigFileReport { + entry, + loaded: false, + status: ConfigFileStatus::LoadError, + reason: Some("parse_error".to_string()), + detail: Some(detail), + }); + continue; + } + + deep_merge_objects(&mut merged, &parsed.object); + loaded_entries.push(entry.clone()); + files.push(ConfigFileReport { + entry, + loaded: true, + status: ConfigFileStatus::Loaded, + reason: None, + detail: None, + }); + } + + for warning in &all_warnings { + eprintln!("warning: {warning}"); + } + + match build_runtime_config(merged, loaded_entries, mcp_servers) { + Ok(runtime_config) => ConfigInspection { + files, + runtime_config: Some(runtime_config), + load_error: first_error, }, - oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?, - model: parse_optional_model(&merged_value), - aliases: parse_optional_aliases(&merged_value)?, - permission_mode: parse_optional_permission_mode(&merged_value)?, - permission_rules: parse_optional_permission_rules(&merged_value)?, - sandbox: parse_optional_sandbox_config(&merged_value)?, - provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?, - trusted_roots: parse_optional_trusted_roots(&merged_value)?, - }; - - Ok(RuntimeConfig { - merged, - loaded_entries, - feature_config, - }) + Err(error) => { + first_error.get_or_insert_with(|| error.to_string()); + ConfigInspection { + files, + runtime_config: None, + load_error: first_error, + } + } + } } } +fn build_runtime_config( + merged: BTreeMap, + loaded_entries: Vec, + mcp_servers: BTreeMap, +) -> Result { + let merged_value = JsonValue::Object(merged.clone()); + let feature_config = RuntimeFeatureConfig { + hooks: parse_optional_hooks_config(&merged_value)?, + plugins: parse_optional_plugin_config(&merged_value)?, + mcp: McpConfigCollection { + servers: mcp_servers, + }, + oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?, + model: parse_optional_model(&merged_value), + aliases: parse_optional_aliases(&merged_value)?, + permission_mode: parse_optional_permission_mode(&merged_value)?, + permission_rules: parse_optional_permission_rules(&merged_value)?, + sandbox: parse_optional_sandbox_config(&merged_value)?, + provider_fallbacks: parse_optional_provider_fallbacks(&merged_value)?, + trusted_roots: parse_optional_trusted_roots(&merged_value)?, + }; + + Ok(RuntimeConfig { + merged, + loaded_entries, + feature_config, + }) +} + impl RuntimeConfig { #[must_use] pub fn empty() -> Self { @@ -671,16 +866,27 @@ struct ParsedConfigFile { source: String, } -fn read_optional_json_object(path: &Path) -> Result, ConfigError> { +enum OptionalConfigFile { + Loaded(ParsedConfigFile), + NotFound, + Skipped { + reason: String, + detail: Option, + }, +} + +fn read_optional_json_object(path: &Path) -> Result { let is_legacy_config = path.file_name().and_then(|name| name.to_str()) == Some(".claw.json"); let contents = match fs::read_to_string(path) { Ok(contents) => contents, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(OptionalConfigFile::NotFound); + } Err(error) => return Err(ConfigError::Io(error)), }; if contents.trim().is_empty() { - return Ok(Some(ParsedConfigFile { + return Ok(OptionalConfigFile::Loaded(ParsedConfigFile { object: BTreeMap::new(), source: contents, })); @@ -688,19 +894,30 @@ fn read_optional_json_object(path: &Path) -> Result, Co let parsed = match JsonValue::parse(&contents) { Ok(parsed) => parsed, - Err(_error) if is_legacy_config => return Ok(None), + Err(error) if is_legacy_config => { + return Ok(OptionalConfigFile::Skipped { + reason: "legacy_invalid_json".to_string(), + detail: Some(format!("{}: {error}", path.display())), + }); + } Err(error) => return Err(ConfigError::Parse(format!("{}: {error}", path.display()))), }; let Some(object) = parsed.as_object() else { if is_legacy_config { - return Ok(None); + return Ok(OptionalConfigFile::Skipped { + reason: "legacy_non_object".to_string(), + detail: Some(format!( + "{}: top-level legacy settings value is not a JSON object", + path.display() + )), + }); } return Err(ConfigError::Parse(format!( "{}: top-level settings value must be a JSON object", path.display() ))); }; - Ok(Some(ParsedConfigFile { + Ok(OptionalConfigFile::Loaded(ParsedConfigFile { object: object.clone(), source: contents, })) @@ -1244,8 +1461,8 @@ fn push_unique(target: &mut Vec, value: String) { #[cfg(test)] mod tests { use super::{ - deep_merge_objects, parse_permission_mode_label, ConfigLoader, ConfigSource, - McpServerConfig, McpTransport, ResolvedPermissionMode, RuntimeHookConfig, + deep_merge_objects, parse_permission_mode_label, ConfigFileStatus, ConfigLoader, + ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode, RuntimeHookConfig, RuntimePluginConfig, CLAW_SETTINGS_SCHEMA_NAME, }; use crate::json::JsonValue; @@ -1835,6 +2052,47 @@ mod tests { fs::remove_dir_all(root).expect("cleanup temp dir"); } + #[test] + fn inspect_classifies_missing_loaded_and_legacy_skipped_files() { + let root = temp_dir(); + let cwd = root.join("project"); + let home = root.join("home").join(".claw"); + fs::create_dir_all(cwd.join(".claw")).expect("project claw dir"); + fs::create_dir_all(&home).expect("home dir"); + fs::write(cwd.join(".claw.json"), "{not json").expect("legacy config"); + fs::write(cwd.join(".claw/settings.json"), r#"{"model":"opus"}"#) + .expect("project settings"); + + let inspection = ConfigLoader::new(&cwd, &home).inspect(); + assert!(inspection.load_error.is_none()); + assert!(inspection.runtime_config.is_some()); + + let loaded = inspection + .files + .iter() + .find(|file| file.loaded) + .expect("loaded file"); + assert_eq!(loaded.status, ConfigFileStatus::Loaded); + assert_eq!(loaded.reason, None); + + let missing = inspection + .files + .iter() + .find(|file| file.status == ConfigFileStatus::NotFound) + .expect("missing file"); + assert_eq!(missing.reason.as_deref(), Some("not_found")); + + let skipped = inspection + .files + .iter() + .find(|file| file.status == ConfigFileStatus::Skipped) + .expect("skipped legacy file"); + assert_eq!(skipped.reason.as_deref(), Some("legacy_invalid_json")); + assert!(!skipped.loaded); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + #[test] fn deep_merge_objects_merges_nested_maps() { // given diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index dbdbd07b64..2462ed5de4 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -6211,42 +6211,88 @@ fn render_config_json( ) -> Result> { let cwd = env::current_dir()?; let loader = ConfigLoader::default_for(&cwd); - let discovered = loader.discover(); - let runtime_config = loader.load()?; + let inspection = loader.inspect(); - let loaded_paths: Vec<_> = runtime_config - .loaded_entries() - .iter() - .map(|e| e.path.display().to_string()) - .collect(); + let (loaded_files, merged_key_count) = + inspection + .runtime_config + .as_ref() + .map_or((0, 0), |runtime_config| { + ( + runtime_config.loaded_entries().len(), + runtime_config.merged().len(), + ) + }); - let files: Vec<_> = discovered + let files: Vec<_> = inspection + .files .iter() - .map(|e| { - let source = match e.source { + .map(|file| { + let source = match file.entry.source { ConfigSource::User => "user", ConfigSource::Project => "project", ConfigSource::Local => "local", }; - let is_loaded = runtime_config - .loaded_entries() - .iter() - .any(|le| le.path == e.path); - serde_json::json!({ - "path": e.path.display().to_string(), - "source": source, - "loaded": is_loaded, - }) + let mut object = serde_json::Map::new(); + object.insert( + "path".to_string(), + serde_json::Value::String(file.entry.path.display().to_string()), + ); + object.insert( + "source".to_string(), + serde_json::Value::String(source.to_string()), + ); + object.insert("loaded".to_string(), serde_json::Value::Bool(file.loaded)); + object.insert( + "status".to_string(), + serde_json::Value::String(file.status.as_str().to_string()), + ); + if let Some(reason) = &file.reason { + object.insert( + "reason".to_string(), + serde_json::Value::String(reason.clone()), + ); + } + if let Some(reason) = &file.reason { + object.insert( + "skip_reason".to_string(), + serde_json::Value::String(reason.clone()), + ); + } + if let Some(detail) = &file.detail { + object.insert( + "detail".to_string(), + serde_json::Value::String(detail.clone()), + ); + } + serde_json::Value::Object(object) }) .collect(); - Ok(serde_json::json!({ + let status = if inspection.load_error.is_some() { + "error" + } else { + "ok" + }; + + let mut value = serde_json::json!({ "kind": "config", + "status": status, "cwd": cwd.display().to_string(), - "loaded_files": loaded_paths.len(), - "merged_keys": runtime_config.merged().len(), + "loaded_files": loaded_files, + "merged_keys": merged_key_count, + "merged_key_count": merged_key_count, + "merged_keys_meaning": "count of top-level keys in the effective merged JSON object", "files": files, - })) + }); + + if let Some(error) = inspection.load_error { + if let Some(object) = value.as_object_mut() { + object.insert("load_error".to_string(), serde_json::Value::String(error)); + } + } + + Ok(value) } fn render_memory_report() -> Result> { @@ -9248,9 +9294,9 @@ mod tests { merge_prompt_with_stdin, normalize_permission_mode, parse_args, parse_export_args, parse_git_status_branch, parse_git_status_metadata_for, parse_git_workspace_summary, parse_history_count, permission_policy, print_help_to, push_output_block, - render_config_report, render_diff_report, render_diff_report_for, render_help_topic, - render_memory_report, render_prompt_history_report, render_repl_help, render_resume_usage, - render_session_list, render_session_markdown, resolve_model_alias, + render_config_json, render_config_report, render_diff_report, render_diff_report_for, + render_help_topic, render_memory_report, render_prompt_history_report, render_repl_help, + render_resume_usage, render_session_list, render_session_markdown, resolve_model_alias, resolve_model_alias_with_config, resolve_repl_model, resolve_session_reference, response_to_events, resume_supported_slash_commands, run_resume_command, short_tool_id, slash_command_completion_candidates_with_sessions, split_error_hint, status_context, @@ -9497,7 +9543,25 @@ mod tests { let previous = std::env::current_dir().expect("cwd should load"); std::env::set_current_dir(cwd).expect("cwd should change"); let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)); - std::env::set_current_dir(previous).expect("cwd should restore"); + if previous.exists() { + std::env::set_current_dir(previous).expect("cwd should restore"); + } else { + std::env::set_current_dir(std::env::temp_dir()).expect("cwd should restore to temp"); + } + match result { + Ok(value) => value, + Err(payload) => std::panic::resume_unwind(payload), + } + } + + fn with_config_home(config_home: &Path, f: impl FnOnce() -> T) -> T { + let previous = std::env::var_os("CLAW_CONFIG_HOME"); + std::env::set_var("CLAW_CONFIG_HOME", config_home); + let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)); + match previous { + Some(value) => std::env::set_var("CLAW_CONFIG_HOME", value), + None => std::env::remove_var("CLAW_CONFIG_HOME"), + } match result { Ok(value) => value, Err(payload) => std::panic::resume_unwind(payload), @@ -12082,11 +12146,27 @@ mod tests { #[test] fn config_report_supports_section_views() { - let report = render_config_report(Some("env")).expect("config report should render"); + let _guard = env_lock(); + let root = temp_dir(); + let workspace = root.join("workspace"); + let config_home = root.join("home").join(".claw"); + fs::create_dir_all(&workspace).expect("workspace dir"); + fs::create_dir_all(&config_home).expect("config home dir"); + + let report = with_config_home(&config_home, || { + with_current_dir(&workspace, || { + render_config_report(Some("env")).expect("config report should render") + }) + }); assert!(report.contains("Merged section: env")); - let plugins_report = - render_config_report(Some("plugins")).expect("plugins config report should render"); + let plugins_report = with_config_home(&config_home, || { + with_current_dir(&workspace, || { + render_config_report(Some("plugins")).expect("plugins config report should render") + }) + }); assert!(plugins_report.contains("Merged section: plugins")); + + fs::remove_dir_all(root).expect("cleanup temp root"); } #[test] @@ -12100,10 +12180,111 @@ mod tests { #[test] fn config_report_uses_sectioned_layout() { - let report = render_config_report(None).expect("config report should render"); + let _guard = env_lock(); + let root = temp_dir(); + let workspace = root.join("workspace"); + let config_home = root.join("home").join(".claw"); + fs::create_dir_all(&workspace).expect("workspace dir"); + fs::create_dir_all(&config_home).expect("config home dir"); + + let report = with_config_home(&config_home, || { + with_current_dir(&workspace, || { + render_config_report(None).expect("config report should render") + }) + }); assert!(report.contains("Config")); assert!(report.contains("Discovered files")); assert!(report.contains("Merged JSON")); + + fs::remove_dir_all(root).expect("cleanup temp root"); + } + + #[test] + fn config_json_reports_structured_unloaded_file_reasons() { + let _guard = env_lock(); + let root = temp_dir(); + let workspace = root.join("workspace"); + let config_home = root.join("home").join(".claw"); + fs::create_dir_all(workspace.join(".claw")).expect("workspace config dir"); + fs::create_dir_all(&config_home).expect("config home dir"); + fs::write(workspace.join(".claw/settings.json"), r#"{"model":"opus"}"#) + .expect("write project settings"); + + let value = with_config_home(&config_home, || { + with_current_dir(&workspace, || { + render_config_json(None).expect("config json should render") + }) + }); + + assert_eq!(value["kind"], "config"); + assert_eq!(value["status"], "ok"); + assert_eq!(value["loaded_files"].as_u64(), Some(1)); + assert_eq!(value["merged_keys"], value["merged_key_count"]); + assert_eq!( + value["merged_keys_meaning"].as_str(), + Some("count of top-level keys in the effective merged JSON object") + ); + + let files = value["files"].as_array().expect("files array"); + let loaded_project = files + .iter() + .find(|file| { + file["loaded"] == true + && file["path"] + .as_str() + .is_some_and(|path| path.ends_with(".claw/settings.json")) + }) + .expect("project settings entry"); + assert_eq!(loaded_project["loaded"], true); + assert_eq!(loaded_project["status"], "loaded"); + assert!(loaded_project.get("reason").is_none()); + + let missing = files + .iter() + .find(|file| file["loaded"] == false && file["status"] == "not_found") + .expect("at least one missing discovered config"); + assert_eq!(missing["reason"], "not_found"); + assert_eq!(missing["skip_reason"], "not_found"); + + fs::remove_dir_all(root).expect("cleanup temp root"); + } + + #[test] + fn config_json_reports_parse_errors_without_dropping_file_statuses() { + let _guard = env_lock(); + let root = temp_dir(); + let workspace = root.join("workspace"); + let config_home = root.join("home").join(".claw"); + fs::create_dir_all(workspace.join(".claw")).expect("workspace config dir"); + fs::create_dir_all(&config_home).expect("config home dir"); + fs::write(workspace.join(".claw/settings.json"), "{not json") + .expect("write invalid project settings"); + + let value = with_config_home(&config_home, || { + with_current_dir(&workspace, || { + render_config_json(None).expect("config json should render") + }) + }); + + assert_eq!(value["status"], "error"); + assert!(value["load_error"].as_str().is_some()); + let files = value["files"].as_array().expect("files array"); + let error_file = files + .iter() + .find(|file| { + file["status"] == "load_error" + && file["path"] + .as_str() + .is_some_and(|path| path.ends_with(".claw/settings.json")) + }) + .expect("project settings entry"); + assert_eq!(error_file["loaded"], false); + assert_eq!(error_file["status"], "load_error"); + assert_eq!(error_file["reason"], "parse_error"); + assert_eq!(error_file["skip_reason"], "parse_error"); + assert!(error_file["detail"].as_str().is_some()); + + fs::remove_dir_all(root).expect("cleanup temp root"); } #[test]