From 9cb248ab3e46d4e9cb26d264015834c79f6e6dc3 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Thu, 14 May 2026 10:31:51 -0400 Subject: [PATCH 1/2] refactor: improve CLI coverage structure Signed-off-by: Will Killian --- codecov.yml | 10 +- crates/cli/src/plugins.rs | 529 +----------------- crates/cli/src/plugins/config_io.rs | 174 ++++++ crates/cli/src/plugins/editor_model.rs | 385 +++++++++++++ crates/cli/src/setup.rs | 501 ++--------------- crates/cli/src/setup/model.rs | 425 ++++++++++++++ crates/cli/tests/cli_tests.rs | 65 +++ .../coverage/completions_install_tests.rs | 48 ++ crates/cli/tests/coverage/doctor_tests.rs | 177 ++++++ crates/cli/tests/coverage/plugins_tests.rs | 198 +++++++ crates/cli/tests/coverage/server_tests.rs | 56 ++ crates/cli/tests/coverage/setup_tests.rs | 191 +++++++ 12 files changed, 1787 insertions(+), 972 deletions(-) create mode 100644 crates/cli/src/plugins/config_io.rs create mode 100644 crates/cli/src/plugins/editor_model.rs create mode 100644 crates/cli/src/setup/model.rs diff --git a/codecov.yml b/codecov.yml index 6b696fff5..601bb8775 100644 --- a/codecov.yml +++ b/codecov.yml @@ -54,13 +54,13 @@ component_management: threshold: 0.5% base: auto if_ci_failed: error - - component_id: gateway_runtime - name: Gateway Runtime + - component_id: cli + name: CLI paths: - "crates/cli/src" statuses: - type: project - target: 95% + target: 88% threshold: 0.5% base: auto if_ci_failed: error @@ -123,6 +123,10 @@ ignore: - "third_party/" - "**/tests/**" - "crates/cli/tests/" + # CLI TTY shells are exercised by smoke tests, but their prompt loops are + # intentionally split away from testable model modules. + - "crates/cli/src/plugins.rs" + - "crates/cli/src/setup.rs" - "**/tests-js/**" # The Node binding currently reports JS package coverage separately; exclude the # native Rust bridge until we have direct Rust-side coverage for this crate. diff --git a/crates/cli/src/plugins.rs b/crates/cli/src/plugins.rs index d92258c32..e9726c6de 100644 --- a/crates/cli/src/plugins.rs +++ b/crates/cli/src/plugins.rs @@ -2,34 +2,29 @@ // SPDX-License-Identifier: Apache-2.0 //! Interactive plugin configuration editing. +//! +//! Keep this module focused on TTY and `dialoguer` orchestration. New testable plugin config +//! behavior should live in `plugins/config_io.rs` or `plugins/editor_model.rs`, with focused unit +//! tests, so Codecov does not depend on exercising interactive prompt loops. use std::io::IsTerminal; -use std::path::{Path, PathBuf}; +use std::path::Path; use console::{Key, Term, style}; use dialoguer::theme::ColorfulTheme; use dialoguer::{Input, Select}; use nemo_flow::config_editor::{EditorConfig, EditorFieldKind, EditorFieldSpec}; -use nemo_flow::observability::plugin_component::{OBSERVABILITY_PLUGIN_KIND, ObservabilityConfig}; -use nemo_flow::plugin::{ConfigPolicy, PluginComponentSpec, PluginConfig, validate_plugin_config}; -use serde::Serialize; -use serde::de::DeserializeOwned; -use serde_json::{Map, Value, json}; +use nemo_flow::observability::plugin_component::ObservabilityConfig; +use serde_json::{Value, json}; -use crate::config::{ - PluginsEditCommand, global_plugin_config_path, project_plugin_config_path, - user_plugin_config_path, -}; +use crate::config::PluginsEditCommand; use crate::error::CliError; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum TargetScope { - User, - Project, - Global, -} +mod config_io; +mod editor_model; -const POLICY_SECTION: &str = "policy"; +use self::config_io::*; +use self::editor_model::*; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum MenuShortcut { @@ -343,115 +338,6 @@ fn ensure_tty() -> Result<(), CliError> { Ok(()) } -fn target_scope(command: &PluginsEditCommand) -> Result { - let selected = [command.user, command.project, command.global] - .into_iter() - .filter(|selected| *selected) - .count(); - if selected > 1 { - return Err(CliError::Config( - "choose only one of --user, --project, or --global".into(), - )); - } - if command.project { - Ok(TargetScope::Project) - } else if command.global { - Ok(TargetScope::Global) - } else { - Ok(TargetScope::User) - } -} - -fn target_path(scope: TargetScope) -> Result { - match scope { - TargetScope::User => user_plugin_config_path().ok_or_else(|| { - CliError::Config( - "cannot determine user config directory; set HOME or XDG_CONFIG_HOME".into(), - ) - }), - TargetScope::Project => { - let cwd = std::env::current_dir()?; - Ok(project_plugin_config_path(&cwd)) - } - TargetScope::Global => Ok(global_plugin_config_path()), - } -} - -fn read_plugin_config(path: &Path) -> Result { - if !path.exists() { - return Ok(PluginConfig::default()); - } - let raw = std::fs::read_to_string(path)?; - let parsed = raw - .parse::() - .map(toml::Value::Table) - .map_err(|error| { - CliError::Config(format!( - "invalid plugin TOML in {}: {error}", - path.display() - )) - })?; - serde_json::from_value( - serde_json::to_value(parsed) - .map_err(|error| CliError::Config(format!("invalid plugin TOML shape: {error}")))?, - ) - .map_err(|error| CliError::Config(format!("invalid plugin config: {error}"))) -} - -fn write_plugin_config(path: &Path, config: &PluginConfig) -> Result<(), CliError> { - let mut value = serde_json::to_value(config) - .map_err(|error| CliError::Config(format!("could not serialize plugin config: {error}")))?; - prune_plugin_defaults(&mut value); - let toml_value: toml::Value = serde_json::from_value(value).map_err(|error| { - CliError::Config(format!("could not convert plugin config to TOML: {error}")) - })?; - let rendered = toml::to_string_pretty(&toml_value) - .map_err(|error| CliError::Config(format!("could not render plugin TOML: {error}")))?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(path, rendered)?; - Ok(()) -} - -fn print_preview(config: &PluginConfig) -> Result<(), CliError> { - println!(); - println!( - "{} {}", - style("❯").green(), - style("plugins.toml preview").bold() - ); - println!("{}", style("─".repeat(58)).black().bright()); - let mut value = serde_json::to_value(config) - .map_err(|error| CliError::Config(format!("could not serialize plugin config: {error}")))?; - prune_plugin_defaults(&mut value); - let toml_value: toml::Value = serde_json::from_value(value).map_err(|error| { - CliError::Config(format!("could not convert plugin config to TOML: {error}")) - })?; - let rendered = toml::to_string_pretty(&toml_value) - .map_err(|error| CliError::Config(format!("could not render plugin TOML: {error}")))?; - print!("{rendered}"); - println!("{}", style("─".repeat(58)).black().bright()); - Ok(()) -} - -fn validate_config(config: &PluginConfig) -> Result<(), CliError> { - let report = validate_plugin_config(config); - if report.has_errors() { - let messages = report - .diagnostics - .into_iter() - .filter(|diagnostic| diagnostic.level == nemo_flow::plugin::DiagnosticLevel::Error) - .map(|diagnostic| diagnostic.message) - .collect::>() - .join("; "); - return Err(CliError::Config(format!( - "plugin validation failed: {messages}" - ))); - } - Ok(()) -} - fn edit_section( theme: &ColorfulTheme, config: &mut ObservabilityConfig, @@ -660,397 +546,6 @@ fn prompt_value( } } -fn ensure_observability_component(config: &mut PluginConfig) -> Result<(), CliError> { - if !config - .components - .iter() - .any(|component| component.kind == OBSERVABILITY_PLUGIN_KIND) - { - config.components.push(PluginComponentSpec { - kind: OBSERVABILITY_PLUGIN_KIND.to_string(), - enabled: true, - config: observability_config_map(&ObservabilityConfig::default())?, - }); - } - Ok(()) -} - -fn component_enabled(config: &PluginConfig) -> bool { - observability_component(config) - .map(|component| component.enabled) - .unwrap_or(true) -} - -fn set_component_enabled(config: &mut PluginConfig, enabled: bool) { - if let Some(component) = observability_component_mut(config) { - component.enabled = enabled; - } -} - -fn component_observability_config(config: &PluginConfig) -> Result { - observability_component(config) - .map(|component| serde_json::from_value(Value::Object(component.config.clone()))) - .transpose() - .map_err(|error| CliError::Config(format!("invalid observability plugin config: {error}")))? - .ok_or_else(|| CliError::Config("observability plugin component is missing".into())) -} - -fn config_with_observability( - config: &PluginConfig, - observability: &ObservabilityConfig, -) -> Result { - let mut config = config.clone(); - store_observability_config(&mut config, observability)?; - Ok(config) -} - -fn store_observability_config( - config: &mut PluginConfig, - observability: &ObservabilityConfig, -) -> Result<(), CliError> { - if let Some(component) = observability_component_mut(config) { - merge_observability_editor_config( - &mut component.config, - observability_config_map(observability)?, - ); - } - Ok(()) -} - -fn ensure_section(config: &mut ObservabilityConfig, section: EditorFieldSpec) { - if let Ok(Some(Value::Object(_))) = section_value(config, section) { - return; - } - let Some(default) = section.default_value() else { - return; - }; - let _ = set_struct_field(config, section.name, default); -} - -fn toggle_section(config: &mut ObservabilityConfig, section: EditorFieldSpec) { - ensure_section(config, section); - let enabled = section_enabled(config, section).unwrap_or(false); - let _ = set_section_field(config, section, "enabled", json!(!enabled)); -} - -fn reset_section(config: &mut ObservabilityConfig, section: EditorFieldSpec) { - let value = section.default_value().unwrap_or_else(|| json!({})); - let _ = set_struct_field(config, section.name, value); -} - -fn reset_selected_field( - config: &mut ObservabilityConfig, - section: EditorFieldSpec, - fields: &[EditorFieldSpec], - selected: usize, -) -> Result { - let offset = usize::from(section_has_enabled_toggle(section)); - let Some(index) = selected.checked_sub(offset) else { - return Ok(false); - }; - let Some(field) = fields.get(index) else { - return Ok(false); - }; - remove_section_field(config, section, field.name)?; - Ok(true) -} - -fn section_has_enabled_toggle(section: EditorFieldSpec) -> bool { - section.name != POLICY_SECTION - && section - .schema() - .and_then(|schema| schema.field("enabled")) - .is_some_and(|field| field.kind == EditorFieldKind::Boolean) -} - -fn section_enabled(config: &ObservabilityConfig, section: EditorFieldSpec) -> Option { - section_value(config, section) - .ok() - .flatten() - .and_then(|section| section.get("enabled").cloned()) - .and_then(|enabled| enabled.as_bool()) -} - -fn section_configured(config: &ObservabilityConfig, section: EditorFieldSpec) -> bool { - let Ok(Some(value)) = section_value(config, section) else { - return false; - }; - if section.optional { - return true; - } - section - .default_value() - .as_ref() - .is_none_or(|default| default != &value) -} - -fn section_field_configured( - config: &ObservabilityConfig, - section: EditorFieldSpec, - field: EditorFieldSpec, -) -> Result { - let Some(value) = section_field_value(config, section, field.name)? else { - return Ok(false); - }; - if field.optional { - return Ok(true); - } - Ok(default_field_value(section, field) - .as_ref() - .is_none_or(|default| default != &value)) -} - -fn section_field_value( - config: &ObservabilityConfig, - section: EditorFieldSpec, - field: &str, -) -> Result, CliError> { - Ok(section_value(config, section)? - .and_then(|section| section.as_object().cloned()) - .and_then(|section| section.get(field).cloned())) -} - -fn section_value( - config: &ObservabilityConfig, - section: EditorFieldSpec, -) -> Result, CliError> { - let value = serde_json::to_value(config).map_err(serde_error)?; - Ok(value - .as_object() - .and_then(|config| config.get(section.name)) - .filter(|section| !section.is_null()) - .cloned()) -} - -fn set_section_field( - config: &mut ObservabilityConfig, - section: EditorFieldSpec, - field: &str, - value: Value, -) -> Result<(), CliError> { - ensure_section(config, section); - let mut object = serde_json::to_value(&*config).map_err(serde_error)?; - let config_object = ensure_object(&mut object); - let section_object = config_object - .entry(section.name) - .or_insert_with(|| section.default_value().unwrap_or_else(|| json!({}))); - ensure_object(section_object).insert(field.to_string(), value); - *config = serde_json::from_value(object).map_err(serde_error)?; - Ok(()) -} - -fn remove_section_field( - config: &mut ObservabilityConfig, - section: EditorFieldSpec, - field: &str, -) -> Result<(), CliError> { - let mut object = serde_json::to_value(&*config).map_err(serde_error)?; - if let Some(section_object) = object - .as_object_mut() - .and_then(|config| config.get_mut(section.name)) - .and_then(Value::as_object_mut) - { - section_object.remove(field); - } - *config = serde_json::from_value(object).map_err(serde_error)?; - Ok(()) -} - -fn set_struct_field(target: &mut T, field: &str, value: Value) -> Result<(), CliError> -where - T: Serialize + DeserializeOwned, -{ - let mut object = serde_json::to_value(&*target).map_err(serde_error)?; - ensure_object(&mut object).insert(field.to_string(), value); - *target = serde_json::from_value(object).map_err(serde_error)?; - Ok(()) -} - -fn observability_component(config: &PluginConfig) -> Option<&PluginComponentSpec> { - config - .components - .iter() - .find(|component| component.kind == OBSERVABILITY_PLUGIN_KIND) -} - -fn observability_component_mut(config: &mut PluginConfig) -> Option<&mut PluginComponentSpec> { - config - .components - .iter_mut() - .find(|component| component.kind == OBSERVABILITY_PLUGIN_KIND) -} - -fn ensure_object(value: &mut Value) -> &mut Map { - if !value.is_object() { - *value = json!({}); - } - value.as_object_mut().expect("value initialized as object") -} - -fn observability_config_map(config: &ObservabilityConfig) -> Result, CliError> { - let value = serde_json::to_value(config).map_err(serde_error)?; - match value { - Value::Object(map) => Ok(map), - _ => Err(CliError::Config( - "observability config must serialize to an object".into(), - )), - } -} - -fn merge_observability_editor_config( - existing: &mut Map, - edited: Map, -) { - merge_known_editor_object( - existing, - edited, - &observability_editor_fields_with_version(), - ObservabilityConfig::editor_schema(), - ); -} - -fn merge_known_editor_object( - existing: &mut Map, - edited: Map, - known_keys: &[&str], - schema: &nemo_flow::config_editor::EditorSchema, -) { - for key in known_keys { - let Some(edited_value) = edited.get(*key) else { - existing.remove(*key); - continue; - }; - if let Some(field) = schema.field(key) - && field.kind == EditorFieldKind::Section - && let Some(nested_schema) = field.schema() - && let (Some(existing_object), Some(edited_object)) = ( - existing.get_mut(*key).and_then(Value::as_object_mut), - edited_value.as_object(), - ) - { - merge_known_editor_object( - existing_object, - edited_object.clone(), - &nested_editor_keys(nested_schema), - nested_schema, - ); - continue; - } - existing.insert((*key).to_string(), edited_value.clone()); - } -} - -fn observability_editor_fields_with_version() -> Vec<&'static str> { - let mut keys = vec!["version"]; - keys.extend( - ObservabilityConfig::editor_schema() - .fields - .iter() - .map(|field| field.name), - ); - keys -} - -fn nested_editor_keys(schema: &nemo_flow::config_editor::EditorSchema) -> Vec<&'static str> { - schema.fields.iter().map(|field| field.name).collect() -} - -fn prune_plugin_defaults(value: &mut Value) { - let Some(object) = value.as_object_mut() else { - return; - }; - remove_default_field( - object, - "policy", - serde_json::to_value(ConfigPolicy::default()).expect("policy default serializes"), - ); - if let Some(components) = object.get_mut("components").and_then(Value::as_array_mut) { - for component in components { - if let Some(component) = component.as_object_mut() - && component.get("enabled") == Some(&Value::Bool(true)) - { - component.remove("enabled"); - } - } - } -} - -fn remove_default_field(object: &mut Map, key: &str, default: Value) { - let Some(value) = object.get_mut(key) else { - return; - }; - remove_matching_defaults(value, &default); - if value == &default || value.as_object().is_some_and(|value| value.is_empty()) { - object.remove(key); - } -} - -fn remove_matching_defaults(value: &mut Value, default: &Value) { - let (Some(value), Some(default)) = (value.as_object_mut(), default.as_object()) else { - return; - }; - let default_keys = default.keys().cloned().collect::>(); - for key in default_keys { - if value.get(&key) == default.get(&key) { - value.remove(&key); - } - } -} - -fn serde_error(error: serde_json::Error) -> CliError { - CliError::Config(format!("invalid plugin editor value: {error}")) -} - -fn display_field_value(section: EditorFieldSpec, field: EditorFieldSpec, value: &Value) -> String { - if default_field_value(section, field) - .as_ref() - .is_some_and(|default| default == value) - { - format!("{} (default)", display_value(value)) - } else { - display_value(value) - } -} - -fn default_field_value(section: EditorFieldSpec, field: EditorFieldSpec) -> Option { - section - .default_value() - .and_then(|section| section.as_object().cloned()) - .and_then(|section| section.get(field.name).cloned()) -} - -fn display_value(value: &Value) -> String { - match value { - Value::String(value) => value.clone(), - Value::Bool(value) => value.to_string(), - Value::Number(value) => value.to_string(), - _ => serde_json::to_string(value).unwrap_or_else(|_| "".to_string()), - } -} - -fn observability_summary(config: &PluginConfig, observability: &ObservabilityConfig) -> String { - let enabled_sections = ObservabilityConfig::editor_schema() - .fields - .iter() - .filter(|section| section.name != POLICY_SECTION) - .filter(|section| section_enabled(observability, **section).unwrap_or(false)) - .map(|section| section.label) - .collect::>(); - format!( - "component {}, sections {}", - if component_enabled(config) { - "enabled" - } else { - "disabled" - }, - if enabled_sections.is_empty() { - "none".into() - } else { - enabled_sections.join(", ") - } - ) -} - fn editor_error(err: dialoguer::Error) -> CliError { match err { dialoguer::Error::IO(io_err) diff --git a/crates/cli/src/plugins/config_io.rs b/crates/cli/src/plugins/config_io.rs new file mode 100644 index 000000000..ad31143a3 --- /dev/null +++ b/crates/cli/src/plugins/config_io.rs @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Testable plugin configuration file and validation helpers. + +use std::path::{Path, PathBuf}; + +use console::style; +use nemo_flow::plugin::{ConfigPolicy, PluginConfig, validate_plugin_config}; +use serde_json::{Map, Value}; + +use crate::config::{ + PluginsEditCommand, global_plugin_config_path, project_plugin_config_path, + user_plugin_config_path, +}; +use crate::error::CliError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum TargetScope { + User, + Project, + Global, +} + +pub(super) fn target_scope(command: &PluginsEditCommand) -> Result { + let selected = [command.user, command.project, command.global] + .into_iter() + .filter(|selected| *selected) + .count(); + if selected > 1 { + return Err(CliError::Config( + "choose only one of --user, --project, or --global".into(), + )); + } + if command.project { + Ok(TargetScope::Project) + } else if command.global { + Ok(TargetScope::Global) + } else { + Ok(TargetScope::User) + } +} + +pub(super) fn target_path(scope: TargetScope) -> Result { + match scope { + TargetScope::User => user_plugin_config_path().ok_or_else(|| { + CliError::Config( + "cannot determine user config directory; set HOME or XDG_CONFIG_HOME".into(), + ) + }), + TargetScope::Project => { + let cwd = std::env::current_dir()?; + Ok(project_plugin_config_path(&cwd)) + } + TargetScope::Global => Ok(global_plugin_config_path()), + } +} + +pub(super) fn read_plugin_config(path: &Path) -> Result { + if !path.exists() { + return Ok(PluginConfig::default()); + } + let raw = std::fs::read_to_string(path)?; + let parsed = raw + .parse::() + .map(toml::Value::Table) + .map_err(|error| { + CliError::Config(format!( + "invalid plugin TOML in {}: {error}", + path.display() + )) + })?; + serde_json::from_value( + serde_json::to_value(parsed) + .map_err(|error| CliError::Config(format!("invalid plugin TOML shape: {error}")))?, + ) + .map_err(|error| CliError::Config(format!("invalid plugin config: {error}"))) +} + +pub(super) fn write_plugin_config(path: &Path, config: &PluginConfig) -> Result<(), CliError> { + let mut value = serde_json::to_value(config) + .map_err(|error| CliError::Config(format!("could not serialize plugin config: {error}")))?; + prune_plugin_defaults(&mut value); + let toml_value: toml::Value = serde_json::from_value(value).map_err(|error| { + CliError::Config(format!("could not convert plugin config to TOML: {error}")) + })?; + let rendered = toml::to_string_pretty(&toml_value) + .map_err(|error| CliError::Config(format!("could not render plugin TOML: {error}")))?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(path, rendered)?; + Ok(()) +} + +pub(super) fn print_preview(config: &PluginConfig) -> Result<(), CliError> { + println!(); + println!( + "{} {}", + style("❯").green(), + style("plugins.toml preview").bold() + ); + println!("{}", style("─".repeat(58)).black().bright()); + let mut value = serde_json::to_value(config) + .map_err(|error| CliError::Config(format!("could not serialize plugin config: {error}")))?; + prune_plugin_defaults(&mut value); + let toml_value: toml::Value = serde_json::from_value(value).map_err(|error| { + CliError::Config(format!("could not convert plugin config to TOML: {error}")) + })?; + let rendered = toml::to_string_pretty(&toml_value) + .map_err(|error| CliError::Config(format!("could not render plugin TOML: {error}")))?; + print!("{rendered}"); + println!("{}", style("─".repeat(58)).black().bright()); + Ok(()) +} + +pub(super) fn validate_config(config: &PluginConfig) -> Result<(), CliError> { + let report = validate_plugin_config(config); + if report.has_errors() { + let messages = report + .diagnostics + .into_iter() + .filter(|diagnostic| diagnostic.level == nemo_flow::plugin::DiagnosticLevel::Error) + .map(|diagnostic| diagnostic.message) + .collect::>() + .join("; "); + return Err(CliError::Config(format!( + "plugin validation failed: {messages}" + ))); + } + Ok(()) +} + +pub(super) fn prune_plugin_defaults(value: &mut Value) { + let Some(object) = value.as_object_mut() else { + return; + }; + remove_default_field( + object, + "policy", + serde_json::to_value(ConfigPolicy::default()).expect("policy default serializes"), + ); + if let Some(components) = object.get_mut("components").and_then(Value::as_array_mut) { + for component in components { + if let Some(component) = component.as_object_mut() + && component.get("enabled") == Some(&Value::Bool(true)) + { + component.remove("enabled"); + } + } + } +} + +pub(super) fn remove_default_field(object: &mut Map, key: &str, default: Value) { + let Some(value) = object.get_mut(key) else { + return; + }; + remove_matching_defaults(value, &default); + if value == &default || value.as_object().is_some_and(|value| value.is_empty()) { + object.remove(key); + } +} + +pub(super) fn remove_matching_defaults(value: &mut Value, default: &Value) { + let (Some(value), Some(default)) = (value.as_object_mut(), default.as_object()) else { + return; + }; + let default_keys = default.keys().cloned().collect::>(); + for key in default_keys { + if value.get(&key) == default.get(&key) { + value.remove(&key); + } + } +} diff --git a/crates/cli/src/plugins/editor_model.rs b/crates/cli/src/plugins/editor_model.rs new file mode 100644 index 000000000..0e526a359 --- /dev/null +++ b/crates/cli/src/plugins/editor_model.rs @@ -0,0 +1,385 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Testable Observability plugin editor state helpers. + +use nemo_flow::config_editor::{EditorConfig, EditorFieldKind, EditorFieldSpec}; +use nemo_flow::observability::plugin_component::{OBSERVABILITY_PLUGIN_KIND, ObservabilityConfig}; +use nemo_flow::plugin::{PluginComponentSpec, PluginConfig}; +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::{Map, Value, json}; + +use crate::error::CliError; + +pub(super) const POLICY_SECTION: &str = "policy"; + +pub(super) fn ensure_observability_component(config: &mut PluginConfig) -> Result<(), CliError> { + if !config + .components + .iter() + .any(|component| component.kind == OBSERVABILITY_PLUGIN_KIND) + { + config.components.push(PluginComponentSpec { + kind: OBSERVABILITY_PLUGIN_KIND.to_string(), + enabled: true, + config: observability_config_map(&ObservabilityConfig::default())?, + }); + } + Ok(()) +} + +pub(super) fn component_enabled(config: &PluginConfig) -> bool { + observability_component(config) + .map(|component| component.enabled) + .unwrap_or(true) +} + +pub(super) fn set_component_enabled(config: &mut PluginConfig, enabled: bool) { + if let Some(component) = observability_component_mut(config) { + component.enabled = enabled; + } +} + +pub(super) fn component_observability_config( + config: &PluginConfig, +) -> Result { + observability_component(config) + .map(|component| serde_json::from_value(Value::Object(component.config.clone()))) + .transpose() + .map_err(|error| CliError::Config(format!("invalid observability plugin config: {error}")))? + .ok_or_else(|| CliError::Config("observability plugin component is missing".into())) +} + +pub(super) fn config_with_observability( + config: &PluginConfig, + observability: &ObservabilityConfig, +) -> Result { + let mut config = config.clone(); + store_observability_config(&mut config, observability)?; + Ok(config) +} + +pub(super) fn store_observability_config( + config: &mut PluginConfig, + observability: &ObservabilityConfig, +) -> Result<(), CliError> { + if let Some(component) = observability_component_mut(config) { + merge_observability_editor_config( + &mut component.config, + observability_config_map(observability)?, + ); + } + Ok(()) +} + +pub(super) fn ensure_section(config: &mut ObservabilityConfig, section: EditorFieldSpec) { + if let Ok(Some(Value::Object(_))) = section_value(config, section) { + return; + } + let Some(default) = section.default_value() else { + return; + }; + let _ = set_struct_field(config, section.name, default); +} + +pub(super) fn toggle_section(config: &mut ObservabilityConfig, section: EditorFieldSpec) { + ensure_section(config, section); + let enabled = section_enabled(config, section).unwrap_or(false); + let _ = set_section_field(config, section, "enabled", json!(!enabled)); +} + +pub(super) fn reset_section(config: &mut ObservabilityConfig, section: EditorFieldSpec) { + let value = section.default_value().unwrap_or_else(|| json!({})); + let _ = set_struct_field(config, section.name, value); +} + +pub(super) fn reset_selected_field( + config: &mut ObservabilityConfig, + section: EditorFieldSpec, + fields: &[EditorFieldSpec], + selected: usize, +) -> Result { + let offset = usize::from(section_has_enabled_toggle(section)); + let Some(index) = selected.checked_sub(offset) else { + return Ok(false); + }; + let Some(field) = fields.get(index) else { + return Ok(false); + }; + remove_section_field(config, section, field.name)?; + Ok(true) +} + +pub(super) fn section_has_enabled_toggle(section: EditorFieldSpec) -> bool { + section.name != POLICY_SECTION + && section + .schema() + .and_then(|schema| schema.field("enabled")) + .is_some_and(|field| field.kind == EditorFieldKind::Boolean) +} + +pub(super) fn section_enabled( + config: &ObservabilityConfig, + section: EditorFieldSpec, +) -> Option { + section_value(config, section) + .ok() + .flatten() + .and_then(|section| section.get("enabled").cloned()) + .and_then(|enabled| enabled.as_bool()) +} + +pub(super) fn section_configured(config: &ObservabilityConfig, section: EditorFieldSpec) -> bool { + let Ok(Some(value)) = section_value(config, section) else { + return false; + }; + if section.optional { + return true; + } + section + .default_value() + .as_ref() + .is_none_or(|default| default != &value) +} + +pub(super) fn section_field_configured( + config: &ObservabilityConfig, + section: EditorFieldSpec, + field: EditorFieldSpec, +) -> Result { + let Some(value) = section_field_value(config, section, field.name)? else { + return Ok(false); + }; + if field.optional { + return Ok(true); + } + Ok(default_field_value(section, field) + .as_ref() + .is_none_or(|default| default != &value)) +} + +pub(super) fn section_field_value( + config: &ObservabilityConfig, + section: EditorFieldSpec, + field: &str, +) -> Result, CliError> { + Ok(section_value(config, section)? + .and_then(|section| section.as_object().cloned()) + .and_then(|section| section.get(field).cloned())) +} + +pub(super) fn section_value( + config: &ObservabilityConfig, + section: EditorFieldSpec, +) -> Result, CliError> { + let value = serde_json::to_value(config).map_err(serde_error)?; + Ok(value + .as_object() + .and_then(|config| config.get(section.name)) + .filter(|section| !section.is_null()) + .cloned()) +} + +pub(super) fn set_section_field( + config: &mut ObservabilityConfig, + section: EditorFieldSpec, + field: &str, + value: Value, +) -> Result<(), CliError> { + ensure_section(config, section); + let mut object = serde_json::to_value(&*config).map_err(serde_error)?; + let config_object = ensure_object(&mut object); + let section_object = config_object + .entry(section.name) + .or_insert_with(|| section.default_value().unwrap_or_else(|| json!({}))); + ensure_object(section_object).insert(field.to_string(), value); + *config = serde_json::from_value(object).map_err(serde_error)?; + Ok(()) +} + +pub(super) fn remove_section_field( + config: &mut ObservabilityConfig, + section: EditorFieldSpec, + field: &str, +) -> Result<(), CliError> { + let mut object = serde_json::to_value(&*config).map_err(serde_error)?; + if let Some(section_object) = object + .as_object_mut() + .and_then(|config| config.get_mut(section.name)) + .and_then(Value::as_object_mut) + { + section_object.remove(field); + } + *config = serde_json::from_value(object).map_err(serde_error)?; + Ok(()) +} + +pub(super) fn set_struct_field(target: &mut T, field: &str, value: Value) -> Result<(), CliError> +where + T: Serialize + DeserializeOwned, +{ + let mut object = serde_json::to_value(&*target).map_err(serde_error)?; + ensure_object(&mut object).insert(field.to_string(), value); + *target = serde_json::from_value(object).map_err(serde_error)?; + Ok(()) +} + +pub(super) fn observability_component(config: &PluginConfig) -> Option<&PluginComponentSpec> { + config + .components + .iter() + .find(|component| component.kind == OBSERVABILITY_PLUGIN_KIND) +} + +pub(super) fn observability_component_mut( + config: &mut PluginConfig, +) -> Option<&mut PluginComponentSpec> { + config + .components + .iter_mut() + .find(|component| component.kind == OBSERVABILITY_PLUGIN_KIND) +} + +pub(super) fn ensure_object(value: &mut Value) -> &mut Map { + if !value.is_object() { + *value = json!({}); + } + value.as_object_mut().expect("value initialized as object") +} + +pub(super) fn observability_config_map( + config: &ObservabilityConfig, +) -> Result, CliError> { + let value = serde_json::to_value(config).map_err(serde_error)?; + match value { + Value::Object(map) => Ok(map), + _ => Err(CliError::Config( + "observability config must serialize to an object".into(), + )), + } +} + +pub(super) fn merge_observability_editor_config( + existing: &mut Map, + edited: Map, +) { + merge_known_editor_object( + existing, + edited, + &observability_editor_fields_with_version(), + ObservabilityConfig::editor_schema(), + ); +} + +pub(super) fn merge_known_editor_object( + existing: &mut Map, + edited: Map, + known_keys: &[&str], + schema: &nemo_flow::config_editor::EditorSchema, +) { + for key in known_keys { + let Some(edited_value) = edited.get(*key) else { + existing.remove(*key); + continue; + }; + if let Some(field) = schema.field(key) + && field.kind == EditorFieldKind::Section + && let Some(nested_schema) = field.schema() + && let (Some(existing_object), Some(edited_object)) = ( + existing.get_mut(*key).and_then(Value::as_object_mut), + edited_value.as_object(), + ) + { + merge_known_editor_object( + existing_object, + edited_object.clone(), + &nested_editor_keys(nested_schema), + nested_schema, + ); + continue; + } + existing.insert((*key).to_string(), edited_value.clone()); + } +} + +pub(super) fn observability_editor_fields_with_version() -> Vec<&'static str> { + let mut keys = vec!["version"]; + keys.extend( + ObservabilityConfig::editor_schema() + .fields + .iter() + .map(|field| field.name), + ); + keys +} + +pub(super) fn nested_editor_keys( + schema: &nemo_flow::config_editor::EditorSchema, +) -> Vec<&'static str> { + schema.fields.iter().map(|field| field.name).collect() +} + +pub(super) fn serde_error(error: serde_json::Error) -> CliError { + CliError::Config(format!("invalid plugin editor value: {error}")) +} + +pub(super) fn display_field_value( + section: EditorFieldSpec, + field: EditorFieldSpec, + value: &Value, +) -> String { + if default_field_value(section, field) + .as_ref() + .is_some_and(|default| default == value) + { + format!("{} (default)", display_value(value)) + } else { + display_value(value) + } +} + +pub(super) fn default_field_value( + section: EditorFieldSpec, + field: EditorFieldSpec, +) -> Option { + section + .default_value() + .and_then(|section| section.as_object().cloned()) + .and_then(|section| section.get(field.name).cloned()) +} + +pub(super) fn display_value(value: &Value) -> String { + match value { + Value::String(value) => value.clone(), + Value::Bool(value) => value.to_string(), + Value::Number(value) => value.to_string(), + _ => serde_json::to_string(value).unwrap_or_else(|_| "".to_string()), + } +} + +pub(super) fn observability_summary( + config: &PluginConfig, + observability: &ObservabilityConfig, +) -> String { + let enabled_sections = ObservabilityConfig::editor_schema() + .fields + .iter() + .filter(|section| section.name != POLICY_SECTION) + .filter(|section| section_enabled(observability, **section).unwrap_or(false)) + .map(|section| section.label) + .collect::>(); + format!( + "component {}, sections {}", + if component_enabled(config) { + "enabled" + } else { + "disabled" + }, + if enabled_sections.is_empty() { + "none".into() + } else { + enabled_sections.join(", ") + } + ) +} diff --git a/crates/cli/src/setup.rs b/crates/cli/src/setup.rs index c3efc49b1..6f7f2ce48 100644 --- a/crates/cli/src/setup.rs +++ b/crates/cli/src/setup.rs @@ -6,282 +6,33 @@ //! Drives the required scope and agent prompts, then writes a `config.toml` to the chosen scope. Pure //! helpers (`detect_installed_agents`, `build_config`, `save_config`) are split out from the //! `dialoguer`-driven orchestrator so the data path can be unit-tested without a TTY. +//! +//! Keep this module focused on TTY and `dialoguer` orchestration. New testable setup behavior +//! should live in `setup/model.rs`, with focused unit tests, so Codecov does not depend on +//! exercising interactive prompt loops. use std::io::IsTerminal; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use dialoguer::theme::ColorfulTheme; use dialoguer::{Confirm, MultiSelect, Select}; -use toml_edit::{DocumentMut, Item, Table, value}; +use toml_edit::DocumentMut; use crate::config::CodingAgent; use crate::error::CliError; -use crate::installer::{hermes_hooks, hook_forward_command, merge_hermes_config}; - -/// Where the setup saves its output. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ConfigScope { - /// `./.nemo-flow/config.toml` (walked-up workspace dir). - Project, - /// `~/.config/nemo-flow/config.toml` (or `$XDG_CONFIG_HOME/nemo-flow/config.toml`). - Global, - /// Both project and global; project takes precedence per merge order. - Both, -} - -impl ConfigScope { - fn label(self) -> &'static str { - match self { - Self::Project => "project ./.nemo-flow/config.toml (recommended)", - Self::Global => "global ~/.config/nemo-flow/config.toml", - Self::Both => "both project overrides global", - } - } -} - -/// Resolved answers from setup. Built either by `prompt_user` (interactive) or by tests. -#[derive(Debug, Clone)] -pub(crate) struct SetupAnswers { - pub scope: ConfigScope, - pub agents: Vec, - /// Path recorded under `[agents.hermes].hooks_path` when hermes is selected. Set by `run` - /// from `hermes_hooks_path_for_scope` so the wizard preview shows the file the launcher - /// will reference. `None` when hermes wasn't selected. - pub hermes_hooks_path: Option, -} - -/// Scans `$PATH` for the supported coding-agent binaries and returns the ones present. -/// -/// The lookup uses the same set of executable names that `CodingAgent::infer` already recognizes; -/// detection is pure and deterministic given a fixed PATH so it can be exercised in tests by -/// constructing a tempdir with stub binaries and pointing `$PATH` at it. -pub(crate) fn detect_installed_agents() -> Vec { - detect_installed_agents_in(std::env::var_os("PATH").as_deref()) -} - -pub(crate) fn detect_installed_agents_in(path_var: Option<&std::ffi::OsStr>) -> Vec { - let Some(path_var) = path_var else { - return Vec::new(); - }; - // Pairs of (CodingAgent, exec name to look for on $PATH). - let candidates = [ - (CodingAgent::ClaudeCode, "claude"), - (CodingAgent::Codex, "codex"), - (CodingAgent::Cursor, "cursor-agent"), - (CodingAgent::Hermes, "hermes"), - ]; - candidates - .into_iter() - .filter_map(|(agent, exec)| { - let found = std::env::split_paths(path_var).any(|dir| { - let candidate = dir.join(exec); - candidate.is_file() - }); - found.then_some(agent) - }) - .collect() -} - -/// Builds the TOML document that represents the setup's answers. Pure and testable. -/// -/// The shape mirrors the runtime model: agents live under `[agents.]`. -/// Sections are only emitted when the user opted into the corresponding behavior so the resulting -/// file stays minimal. -pub(crate) fn build_config(answers: &SetupAnswers) -> DocumentMut { - let mut doc = DocumentMut::new(); - - if let Some(agents_table) = build_agents_table(answers) { - doc["agents"] = Item::Table(agents_table); - } - - doc -} - -fn build_agents_table(answers: &SetupAnswers) -> Option { - if answers.agents.is_empty() { - return None; - } - - let mut agents_table = Table::new(); - for agent in &answers.agents { - let (key, command) = agent_key_and_command(*agent); - let mut agent_table = Table::new(); - agent_table["command"] = value(command); - if matches!(agent, CodingAgent::Hermes) - && let Some(path) = answers.hermes_hooks_path.as_deref() - { - agent_table["hooks_path"] = value(path.display().to_string()); - } - agents_table.insert(key, Item::Table(agent_table)); - } - Some(agents_table) -} - -/// Writes the setup's TOML document to the scope-appropriate path(s). -/// -/// When `merge_scope` is `Some(agent)`, an existing `config.toml` at the target path is parsed -/// and only the single `[agents.]` block owned by THIS wizard run is replaced. Other -/// `[agents.*]` blocks and hand-edited shared sections such as `[plugins]` are preserved when -/// omitted from the wizard output. When `merge_scope` is `None`, the file is overwritten outright -/// with the wizard's full output (the user explicitly chose which agents to include). -/// -/// Returns the list of paths written. `home` and `cwd` are explicit so tests can drive this with -/// tempdirs. -pub(crate) fn save_config( - doc: &DocumentMut, - scope: ConfigScope, - cwd: &Path, - home: &Path, - merge_scope: Option, -) -> Result, CliError> { - let mut written = Vec::new(); - if matches!(scope, ConfigScope::Project | ConfigScope::Both) { - let project_dir = cwd.join(".nemo-flow"); - std::fs::create_dir_all(&project_dir)?; - let path = project_dir.join("config.toml"); - write_or_merge(&path, doc, merge_scope)?; - written.push(path); - } - if matches!(scope, ConfigScope::Global | ConfigScope::Both) { - let global_dir = global_config_dir(home); - std::fs::create_dir_all(&global_dir)?; - let path = global_dir.join("config.toml"); - write_or_merge(&path, doc, merge_scope)?; - written.push(path); - } - Ok(written) -} - -// Resolves the global nemo-flow config directory. Prefers `$XDG_CONFIG_HOME/nemo-flow` (matches -// `config::user_config_dir`), falling back to `/.config/nemo-flow`. Tests that pass a -// tempdir for `home` get hermetic paths unless they set XDG_CONFIG_HOME explicitly. -fn global_config_dir(home: &Path) -> PathBuf { - if let Some(base) = std::env::var_os("XDG_CONFIG_HOME") { - return PathBuf::from(base).join("nemo-flow"); - } - home.join(".config").join("nemo-flow") -} - -// Writes the wizard-built `doc` to `path`. When `merge_scope` is `Some(agent)` and the file -// already exists, preserves any `[agents.]` blocks while replacing the shared sections -// and the target agent's block. When `merge_scope` is `None`, just overwrites the file. -fn write_or_merge( - path: &Path, - doc: &DocumentMut, - merge_scope: Option, -) -> Result<(), CliError> { - let Some(agent) = merge_scope else { - std::fs::write(path, doc.to_string())?; - return Ok(()); - }; - if !path.exists() { - std::fs::write(path, doc.to_string())?; - return Ok(()); - } - let existing_raw = std::fs::read_to_string(path)?; - let mut existing: DocumentMut = existing_raw - .parse() - .map_err(|err| CliError::Config(format!("could not parse existing config: {err}")))?; - let agent_key = agent_key_and_command(agent).0; - // `plugins` is not wizard-owned (users may hand-edit it). Preserve on omission. - merge_section(&mut existing, doc, "plugins"); - merge_agents_entry(&mut existing, doc, agent_key); - std::fs::write(path, existing.to_string())?; - Ok(()) -} -// Copies a top-level section from `src` into `dst`, replacing any existing entry under the same -// key. If `src` does not contain the section, the existing entry in `dst` is left as-is. -// Use for shared/hand-edited sections the wizard does not own. -fn merge_section(dst: &mut DocumentMut, src: &DocumentMut, key: &str) { - if let Some(item) = src.get(key) { - dst[key] = item.clone(); - } -} +mod model; -// Replaces the single `[agents.]` block in `dst` with the one from `src`. If `src` does -// not contain that block, the existing entry in `dst` is left as-is. -fn merge_agents_entry(dst: &mut DocumentMut, src: &DocumentMut, agent_key: &str) { - let Some(src_agent) = src - .get("agents") - .and_then(|item| item.as_table()) - .and_then(|table| table.get(agent_key)) - else { - return; - }; - // Defensive: if the existing config has `agents = "literal"` or `agents = [...]` (anything - // not a table) the original `.as_table_mut().unwrap()` panicked. Replace any non-table - // value with a fresh table so a malformed user file degrades to an overwrite, not a crash. - let needs_init = dst - .get("agents") - .is_none_or(|item| item.as_table().is_none()); - if needs_init { - dst["agents"] = Item::Table(Table::new()); - } - let agents_table = dst["agents"] - .as_table_mut() - .expect("agents key is a table after the init guard above"); - agents_table.insert(agent_key, src_agent.clone()); -} +pub(crate) use self::model::reset; +use self::model::{ + ConfigScope, SetupAnswers, agent_key_and_command, build_config, detect_installed_agents, + hermes_hook_targets, hermes_hooks_path_for_scope, home_dir, install_hermes_hooks, + preview_paths, read_existing_defaults, save_config, +}; -/// Removes the project `config.toml` (or just one agent's block within it). -/// -/// `agent_hint = None` deletes the whole project config file. `agent_hint = Some(agent)` parses -/// the existing file and removes only `[agents.]`, leaving every other section intact. -/// In both cases this targets the *project* layer; global and system layers are left to direct -/// editing because they typically aren't owned by the wizard. -pub(crate) fn reset(agent_hint: Option) -> Result<(), CliError> { - let cwd = std::env::current_dir()?; - let path = cwd.join(".nemo-flow").join("config.toml"); - if !path.exists() { - println!(" No project config to reset at {}", path.display()); - return Ok(()); - } - match agent_hint { - None => { - std::fs::remove_file(&path)?; - println!(" ✓ Removed {}", path.display()); - println!(" Run `nemo-flow config` to set up again."); - } - Some(agent) => { - let agent_key = agent_key_and_command(agent).0; - let raw = std::fs::read_to_string(&path)?; - let mut doc: DocumentMut = raw.parse().map_err(|err| { - CliError::Config(format!("could not parse existing config: {err}")) - })?; - // Three reasons we have nothing to remove: no `[agents]` table at all, the `agents` - // key holds a non-table value, or the table is missing this specific agent's block. - // In every case we must report "nothing to reset" and skip the write — silently - // printing "✓ Removed" when nothing changed misleads the user about file state. - let Some(agents) = doc.get_mut("agents").and_then(Item::as_table_mut) else { - println!( - " No `[agents.{agent_key}]` block to reset in {}", - path.display() - ); - return Ok(()); - }; - if agents.remove(agent_key).is_none() { - println!( - " No `[agents.{agent_key}]` block to reset in {}", - path.display() - ); - return Ok(()); - } - // Remove the empty `[agents]` table itself so the file stays tidy when no agent - // entries remain. - if agents.is_empty() { - doc.remove("agents"); - } - std::fs::write(&path, doc.to_string())?; - println!(" ✓ Removed `[agents.{agent_key}]` from {}", path.display()); - } - } - Ok(()) -} +#[cfg(test)] +use self::model::{Defaults, detect_installed_agents_in, read_agents_from_doc, write_or_merge}; -/// Drives the interactive setup. Returns the answers so callers can either save them or feed -/// the resulting config back into a launch path. Errors when stdin isn't a TTY so non-interactive -/// callers fall back to explicit flags instead of hanging on a prompt that nobody will see. /// /// When `agent_hint` is `Some`, the agent multi-select is skipped — the user already declared /// intent by typing `nemo-flow claude` (or another agent name), so respect that and only ask @@ -341,132 +92,47 @@ pub(crate) fn prompt_user( }) } -/// Returns the path the wizard will record under `[agents.hermes].hooks_path` and write hermes -/// hooks to. For `Both` scope, the project path wins (matches the config-merge precedence). For -/// `Global` scope, the home path wins. Returns `None` when hermes is not in the selection. -pub(crate) fn hermes_hooks_path_for_scope( - agents: &[CodingAgent], - scope: ConfigScope, - cwd: &Path, - home: &Path, -) -> Option { - if !agents.contains(&CodingAgent::Hermes) { - return None; - } - match scope { - ConfigScope::Project | ConfigScope::Both => Some(cwd.join(".hermes").join("config.yaml")), - ConfigScope::Global => Some(home.join(".hermes").join("config.yaml")), - } -} - -/// Writes/merges `.hermes/config.yaml` hook config for every scope-applicable location so hermes -/// fires `nemo-flow hook-forward hermes` on every hook event after setup. Idempotent: existing -/// hook entries are preserved and our generated groups are appended only when missing. +/// Top-level setup entry point used by `nemo-flow config` and the easy-path fallback. +/// Detects agents, prompts the user, writes the config, prints a final summary. /// -/// Returns the list of paths actually written so callers can surface them to the user. -pub(crate) fn install_hermes_hooks( - scope: ConfigScope, - cwd: &Path, - home: &Path, -) -> Result, CliError> { - let generated = hermes_hooks(&hook_forward_command("nemo-flow", CodingAgent::Hermes)); - let mut written = Vec::new(); - for path in hermes_hook_targets(scope, cwd, home) { - let existing = match std::fs::read_to_string(&path) { - Ok(raw) => raw, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(error) => return Err(CliError::Io(error)), - }; - let merged = merge_hermes_config(&existing, generated.clone())?; - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent)?; - } - std::fs::write(&path, merged)?; - written.push(path); - } - Ok(written) -} +/// `agent_hint` carries the agent the user typed on the easy path (`nemo-flow claude`); when +/// `Some`, the agent multi-select is skipped because intent is already declared. `None` from +/// `nemo-flow config` asks the full set so users can configure multiple agents at once. +pub(crate) async fn run(agent_hint: Option) -> Result<(), CliError> { + let detected = detect_installed_agents(); + let mut answers = prompt_user(&detected, agent_hint)?; -fn hermes_hook_targets(scope: ConfigScope, cwd: &Path, home: &Path) -> Vec { - let mut targets = Vec::new(); - if matches!(scope, ConfigScope::Project | ConfigScope::Both) { - targets.push(cwd.join(".hermes").join("config.yaml")); - } - if matches!(scope, ConfigScope::Global | ConfigScope::Both) { - targets.push(home.join(".hermes").join("config.yaml")); - } - targets -} + let cwd = std::env::current_dir()?; + let home = home_dir().ok_or_else(|| { + CliError::Config("cannot determine home directory (set $HOME or $USERPROFILE)".into()) + })?; + answers.hermes_hooks_path = + hermes_hooks_path_for_scope(&answers.agents, answers.scope, &cwd, &home); -/// Pre-filled wizard defaults read from an existing `config.toml`. When the file is missing or -/// unparseable the defaults are all-empty and the wizard behaves like a first-run setup. -#[derive(Debug, Clone, Default)] -struct Defaults { - scope: Option, - agents: Vec, -} + let doc = build_config(&answers); + let mut preview_paths = preview_paths(answers.scope, &cwd, &home); + preview_paths.extend( + hermes_hook_targets(answers.scope, &cwd, &home) + .into_iter() + .filter(|_| answers.agents.contains(&CodingAgent::Hermes)), + ); -impl Defaults { - fn has_any(&self) -> bool { - self.scope.is_some() || !self.agents.is_empty() + if !confirm_summary(&preview_paths, &doc)? { + return Err(CliError::Config("setup cancelled — no config saved".into())); } -} - -/// Reads the highest-precedence existing config file and derives wizard defaults from it. -/// Workspace config wins over global; if both exist, scope defaults to `Both`. Missing or -/// malformed files yield `None` (the wizard then behaves as if no config existed). -fn read_existing_defaults() -> Option { - let cwd = std::env::current_dir().ok()?; - let home = home_dir(); - - let workspace_path = cwd.join(".nemo-flow").join("config.toml"); - let global_path = home - .as_ref() - .map(|h| global_config_dir(h).join("config.toml")); - - let workspace_exists = workspace_path.exists(); - let global_exists = global_path.as_ref().is_some_and(|p| p.exists()); - - let read_doc = - |path: &Path| -> Option { std::fs::read_to_string(path).ok()?.parse().ok() }; - - let doc = match (workspace_exists, global_exists) { - (true, _) => read_doc(&workspace_path)?, - (false, true) => read_doc(global_path.as_ref()?)?, - (false, false) => return None, - }; - - let scope = match (workspace_exists, global_exists) { - (true, true) => Some(ConfigScope::Both), - (true, false) => Some(ConfigScope::Project), - (false, true) => Some(ConfigScope::Global), - (false, false) => None, - }; - - Some(Defaults { - scope, - agents: read_agents_from_doc(&doc), - }) -} -fn read_agents_from_doc(doc: &DocumentMut) -> Vec { - let Some(table) = doc.get("agents").and_then(|i| i.as_table()) else { - return Vec::new(); - }; - let mut found = Vec::new(); - for (key, _) in table.iter() { - let agent = match key { - "claude" => Some(CodingAgent::ClaudeCode), - "codex" => Some(CodingAgent::Codex), - "cursor" => Some(CodingAgent::Cursor), - "hermes" => Some(CodingAgent::Hermes), - _ => None, - }; - if let Some(agent) = agent { - found.push(agent); - } + let mut written = save_config(&doc, answers.scope, &cwd, &home, agent_hint)?; + if answers.agents.contains(&CodingAgent::Hermes) { + written.extend(install_hermes_hooks(answers.scope, &cwd, &home)?); } - found + println!(); + println!(" ✓ Saved:"); + for path in &written { + println!(" {}", path.display()); + } + println!(" Configure observability with `nemo-flow plugins edit`."); + println!(); + Ok(()) } fn print_codex_api_key_guide() { @@ -608,75 +274,6 @@ fn setup_error(err: dialoguer::Error) -> CliError { } } -fn agent_key_and_command(agent: CodingAgent) -> (&'static str, &'static str) { - match agent { - CodingAgent::ClaudeCode => ("claude", "claude"), - CodingAgent::Codex => ("codex", "codex"), - CodingAgent::Cursor => ("cursor", "cursor-agent"), - CodingAgent::Hermes => ("hermes", "hermes"), - } -} - -/// Top-level setup entry point used by `nemo-flow config` and the easy-path fallback. -/// Detects agents, prompts the user, writes the config, prints a final summary. -/// -/// `agent_hint` carries the agent the user typed on the easy path (`nemo-flow claude`); when -/// `Some`, the agent multi-select is skipped because intent is already declared. `None` from -/// `nemo-flow config` asks the full set so users can configure multiple agents at once. -pub(crate) async fn run(agent_hint: Option) -> Result<(), CliError> { - let detected = detect_installed_agents(); - let mut answers = prompt_user(&detected, agent_hint)?; - - let cwd = std::env::current_dir()?; - let home = home_dir().ok_or_else(|| { - CliError::Config("cannot determine home directory (set $HOME or $USERPROFILE)".into()) - })?; - answers.hermes_hooks_path = - hermes_hooks_path_for_scope(&answers.agents, answers.scope, &cwd, &home); - - let doc = build_config(&answers); - let mut preview_paths = preview_paths(answers.scope, &cwd, &home); - preview_paths.extend( - hermes_hook_targets(answers.scope, &cwd, &home) - .into_iter() - .filter(|_| answers.agents.contains(&CodingAgent::Hermes)), - ); - - if !confirm_summary(&preview_paths, &doc)? { - return Err(CliError::Config("setup cancelled — no config saved".into())); - } - - let mut written = save_config(&doc, answers.scope, &cwd, &home, agent_hint)?; - if answers.agents.contains(&CodingAgent::Hermes) { - written.extend(install_hermes_hooks(answers.scope, &cwd, &home)?); - } - println!(); - println!(" ✓ Saved:"); - for path in &written { - println!(" {}", path.display()); - } - println!(" Configure observability with `nemo-flow plugins edit`."); - println!(); - Ok(()) -} - -fn preview_paths(scope: ConfigScope, cwd: &Path, home: &Path) -> Vec { - let mut paths = Vec::new(); - if matches!(scope, ConfigScope::Project | ConfigScope::Both) { - paths.push(cwd.join(".nemo-flow").join("config.toml")); - } - if matches!(scope, ConfigScope::Global | ConfigScope::Both) { - paths.push(global_config_dir(home).join("config.toml")); - } - paths -} - -fn home_dir() -> Option { - std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .map(PathBuf::from) -} - #[cfg(test)] #[path = "../tests/coverage/setup_tests.rs"] mod tests; diff --git a/crates/cli/src/setup/model.rs b/crates/cli/src/setup/model.rs new file mode 100644 index 000000000..0fed32280 --- /dev/null +++ b/crates/cli/src/setup/model.rs @@ -0,0 +1,425 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Testable setup configuration model and file helpers. + +use std::path::{Path, PathBuf}; + +use toml_edit::{DocumentMut, Item, Table, value}; + +use crate::config::CodingAgent; +use crate::error::CliError; +use crate::installer::{hermes_hooks, hook_forward_command, merge_hermes_config}; + +/// Where the setup saves its output. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ConfigScope { + /// `./.nemo-flow/config.toml` (walked-up workspace dir). + Project, + /// `~/.config/nemo-flow/config.toml` (or `$XDG_CONFIG_HOME/nemo-flow/config.toml`). + Global, + /// Both project and global; project takes precedence per merge order. + Both, +} + +impl ConfigScope { + pub(super) fn label(self) -> &'static str { + match self { + Self::Project => "project ./.nemo-flow/config.toml (recommended)", + Self::Global => "global ~/.config/nemo-flow/config.toml", + Self::Both => "both project overrides global", + } + } +} + +/// Resolved answers from setup. Built either by `prompt_user` (interactive) or by tests. +#[derive(Debug, Clone)] +pub(crate) struct SetupAnswers { + pub scope: ConfigScope, + pub agents: Vec, + /// Path recorded under `[agents.hermes].hooks_path` when hermes is selected. Set by `run` + /// from `hermes_hooks_path_for_scope` so the wizard preview shows the file the launcher + /// will reference. `None` when hermes wasn't selected. + pub hermes_hooks_path: Option, +} + +/// Scans `$PATH` for the supported coding-agent binaries and returns the ones present. +/// +/// The lookup uses the same set of executable names that `CodingAgent::infer` already recognizes; +/// detection is pure and deterministic given a fixed PATH so it can be exercised in tests by +/// constructing a tempdir with stub binaries and pointing `$PATH` at it. +pub(crate) fn detect_installed_agents() -> Vec { + detect_installed_agents_in(std::env::var_os("PATH").as_deref()) +} + +pub(crate) fn detect_installed_agents_in(path_var: Option<&std::ffi::OsStr>) -> Vec { + let Some(path_var) = path_var else { + return Vec::new(); + }; + // Pairs of (CodingAgent, exec name to look for on $PATH). + let candidates = [ + (CodingAgent::ClaudeCode, "claude"), + (CodingAgent::Codex, "codex"), + (CodingAgent::Cursor, "cursor-agent"), + (CodingAgent::Hermes, "hermes"), + ]; + candidates + .into_iter() + .filter_map(|(agent, exec)| { + let found = std::env::split_paths(path_var).any(|dir| { + let candidate = dir.join(exec); + candidate.is_file() + }); + found.then_some(agent) + }) + .collect() +} + +/// Builds the TOML document that represents the setup's answers. Pure and testable. +/// +/// The shape mirrors the runtime model: agents live under `[agents.]`. +/// Sections are only emitted when the user opted into the corresponding behavior so the resulting +/// file stays minimal. +pub(crate) fn build_config(answers: &SetupAnswers) -> DocumentMut { + let mut doc = DocumentMut::new(); + + if let Some(agents_table) = build_agents_table(answers) { + doc["agents"] = Item::Table(agents_table); + } + + doc +} + +pub(super) fn build_agents_table(answers: &SetupAnswers) -> Option
{ + if answers.agents.is_empty() { + return None; + } + + let mut agents_table = Table::new(); + for agent in &answers.agents { + let (key, command) = agent_key_and_command(*agent); + let mut agent_table = Table::new(); + agent_table["command"] = value(command); + if matches!(agent, CodingAgent::Hermes) + && let Some(path) = answers.hermes_hooks_path.as_deref() + { + agent_table["hooks_path"] = value(path.display().to_string()); + } + agents_table.insert(key, Item::Table(agent_table)); + } + Some(agents_table) +} + +/// Writes the setup's TOML document to the scope-appropriate path(s). +/// +/// When `merge_scope` is `Some(agent)`, an existing `config.toml` at the target path is parsed +/// and only the single `[agents.]` block owned by THIS wizard run is replaced. Other +/// `[agents.*]` blocks and hand-edited shared sections such as `[plugins]` are preserved when +/// omitted from the wizard output. When `merge_scope` is `None`, the file is overwritten outright +/// with the wizard's full output (the user explicitly chose which agents to include). +/// +/// Returns the list of paths written. `home` and `cwd` are explicit so tests can drive this with +/// tempdirs. +pub(crate) fn save_config( + doc: &DocumentMut, + scope: ConfigScope, + cwd: &Path, + home: &Path, + merge_scope: Option, +) -> Result, CliError> { + let mut written = Vec::new(); + if matches!(scope, ConfigScope::Project | ConfigScope::Both) { + let project_dir = cwd.join(".nemo-flow"); + std::fs::create_dir_all(&project_dir)?; + let path = project_dir.join("config.toml"); + write_or_merge(&path, doc, merge_scope)?; + written.push(path); + } + if matches!(scope, ConfigScope::Global | ConfigScope::Both) { + let global_dir = global_config_dir(home); + std::fs::create_dir_all(&global_dir)?; + let path = global_dir.join("config.toml"); + write_or_merge(&path, doc, merge_scope)?; + written.push(path); + } + Ok(written) +} + +// Resolves the global nemo-flow config directory. Prefers `$XDG_CONFIG_HOME/nemo-flow` (matches +// `config::user_config_dir`), falling back to `/.config/nemo-flow`. Tests that pass a +// tempdir for `home` get hermetic paths unless they set XDG_CONFIG_HOME explicitly. +pub(super) fn global_config_dir(home: &Path) -> PathBuf { + if let Some(base) = std::env::var_os("XDG_CONFIG_HOME") { + return PathBuf::from(base).join("nemo-flow"); + } + home.join(".config").join("nemo-flow") +} + +// Writes the wizard-built `doc` to `path`. When `merge_scope` is `Some(agent)` and the file +// already exists, preserves any `[agents.]` blocks while replacing the shared sections +// and the target agent's block. When `merge_scope` is `None`, just overwrites the file. +pub(super) fn write_or_merge( + path: &Path, + doc: &DocumentMut, + merge_scope: Option, +) -> Result<(), CliError> { + let Some(agent) = merge_scope else { + std::fs::write(path, doc.to_string())?; + return Ok(()); + }; + if !path.exists() { + std::fs::write(path, doc.to_string())?; + return Ok(()); + } + let existing_raw = std::fs::read_to_string(path)?; + let mut existing: DocumentMut = existing_raw + .parse() + .map_err(|err| CliError::Config(format!("could not parse existing config: {err}")))?; + let agent_key = agent_key_and_command(agent).0; + // `plugins` is not wizard-owned (users may hand-edit it). Preserve on omission. + merge_section(&mut existing, doc, "plugins"); + merge_agents_entry(&mut existing, doc, agent_key); + std::fs::write(path, existing.to_string())?; + Ok(()) +} + +// Copies a top-level section from `src` into `dst`, replacing any existing entry under the same +// key. If `src` does not contain the section, the existing entry in `dst` is left as-is. +// Use for shared/hand-edited sections the wizard does not own. +pub(super) fn merge_section(dst: &mut DocumentMut, src: &DocumentMut, key: &str) { + if let Some(item) = src.get(key) { + dst[key] = item.clone(); + } +} + +// Replaces the single `[agents.]` block in `dst` with the one from `src`. If `src` does +// not contain that block, the existing entry in `dst` is left as-is. +pub(super) fn merge_agents_entry(dst: &mut DocumentMut, src: &DocumentMut, agent_key: &str) { + let Some(src_agent) = src + .get("agents") + .and_then(|item| item.as_table()) + .and_then(|table| table.get(agent_key)) + else { + return; + }; + // Defensive: if the existing config has `agents = "literal"` or `agents = [...]` (anything + // not a table) the original `.as_table_mut().unwrap()` panicked. Replace any non-table + // value with a fresh table so a malformed user file degrades to an overwrite, not a crash. + let needs_init = dst + .get("agents") + .is_none_or(|item| item.as_table().is_none()); + if needs_init { + dst["agents"] = Item::Table(Table::new()); + } + let agents_table = dst["agents"] + .as_table_mut() + .expect("agents key is a table after the init guard above"); + agents_table.insert(agent_key, src_agent.clone()); +} + +/// Removes the project `config.toml` (or just one agent's block within it). +/// +/// `agent_hint = None` deletes the whole project config file. `agent_hint = Some(agent)` parses +/// the existing file and removes only `[agents.]`, leaving every other section intact. +/// In both cases this targets the *project* layer; global and system layers are left to direct +/// editing because they typically aren't owned by the wizard. +pub(crate) fn reset(agent_hint: Option) -> Result<(), CliError> { + let cwd = std::env::current_dir()?; + let path = cwd.join(".nemo-flow").join("config.toml"); + if !path.exists() { + println!(" No project config to reset at {}", path.display()); + return Ok(()); + } + match agent_hint { + None => { + std::fs::remove_file(&path)?; + println!(" ✓ Removed {}", path.display()); + println!(" Run `nemo-flow config` to set up again."); + } + Some(agent) => { + let agent_key = agent_key_and_command(agent).0; + let raw = std::fs::read_to_string(&path)?; + let mut doc: DocumentMut = raw.parse().map_err(|err| { + CliError::Config(format!("could not parse existing config: {err}")) + })?; + // Three reasons we have nothing to remove: no `[agents]` table at all, the `agents` + // key holds a non-table value, or the table is missing this specific agent's block. + // In every case we must report "nothing to reset" and skip the write — silently + // printing "✓ Removed" when nothing changed misleads the user about file state. + let Some(agents) = doc.get_mut("agents").and_then(Item::as_table_mut) else { + println!( + " No `[agents.{agent_key}]` block to reset in {}", + path.display() + ); + return Ok(()); + }; + if agents.remove(agent_key).is_none() { + println!( + " No `[agents.{agent_key}]` block to reset in {}", + path.display() + ); + return Ok(()); + } + // Remove the empty `[agents]` table itself so the file stays tidy when no agent + // entries remain. + if agents.is_empty() { + doc.remove("agents"); + } + std::fs::write(&path, doc.to_string())?; + println!(" ✓ Removed `[agents.{agent_key}]` from {}", path.display()); + } + } + Ok(()) +} + +/// Returns the Hermes hooks file path that should be recorded for the selected setup scope. +pub(crate) fn hermes_hooks_path_for_scope( + agents: &[CodingAgent], + scope: ConfigScope, + cwd: &Path, + home: &Path, +) -> Option { + if !agents.contains(&CodingAgent::Hermes) { + return None; + } + match scope { + ConfigScope::Project | ConfigScope::Both => Some(cwd.join(".hermes").join("config.yaml")), + ConfigScope::Global => Some(home.join(".hermes").join("config.yaml")), + } +} + +/// Writes/merges `.hermes/config.yaml` hook config for every scope-applicable location so hermes +/// fires `nemo-flow hook-forward hermes` on every hook event after setup. Idempotent: existing +/// hook entries are preserved and our generated groups are appended only when missing. +/// +/// Returns the list of paths actually written so callers can surface them to the user. +pub(crate) fn install_hermes_hooks( + scope: ConfigScope, + cwd: &Path, + home: &Path, +) -> Result, CliError> { + let generated = hermes_hooks(&hook_forward_command("nemo-flow", CodingAgent::Hermes)); + let mut written = Vec::new(); + for path in hermes_hook_targets(scope, cwd, home) { + let existing = match std::fs::read_to_string(&path) { + Ok(raw) => raw, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(CliError::Io(error)), + }; + let merged = merge_hermes_config(&existing, generated.clone())?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, merged)?; + written.push(path); + } + Ok(written) +} + +pub(super) fn hermes_hook_targets(scope: ConfigScope, cwd: &Path, home: &Path) -> Vec { + let mut targets = Vec::new(); + if matches!(scope, ConfigScope::Project | ConfigScope::Both) { + targets.push(cwd.join(".hermes").join("config.yaml")); + } + if matches!(scope, ConfigScope::Global | ConfigScope::Both) { + targets.push(home.join(".hermes").join("config.yaml")); + } + targets +} + +/// Pre-filled wizard defaults read from an existing `config.toml`. When the file is missing or +/// unparseable the defaults are all-empty and the wizard behaves like a first-run setup. +#[derive(Debug, Clone, Default)] +pub(super) struct Defaults { + pub(super) scope: Option, + pub(super) agents: Vec, +} + +impl Defaults { + pub(super) fn has_any(&self) -> bool { + self.scope.is_some() || !self.agents.is_empty() + } +} + +/// Reads the highest-precedence existing config file and derives wizard defaults from it. +/// Workspace config wins over global; if both exist, scope defaults to `Both`. Missing or +/// malformed files yield `None` (the wizard then behaves as if no config existed). +pub(super) fn read_existing_defaults() -> Option { + let cwd = std::env::current_dir().ok()?; + let home = home_dir(); + + let workspace_path = cwd.join(".nemo-flow").join("config.toml"); + let global_path = home + .as_ref() + .map(|h| global_config_dir(h).join("config.toml")); + + let workspace_exists = workspace_path.exists(); + let global_exists = global_path.as_ref().is_some_and(|p| p.exists()); + + let read_doc = + |path: &Path| -> Option { std::fs::read_to_string(path).ok()?.parse().ok() }; + + let doc = match (workspace_exists, global_exists) { + (true, _) => read_doc(&workspace_path)?, + (false, true) => read_doc(global_path.as_ref()?)?, + (false, false) => return None, + }; + + let scope = match (workspace_exists, global_exists) { + (true, true) => Some(ConfigScope::Both), + (true, false) => Some(ConfigScope::Project), + (false, true) => Some(ConfigScope::Global), + (false, false) => None, + }; + + Some(Defaults { + scope, + agents: read_agents_from_doc(&doc), + }) +} + +pub(super) fn read_agents_from_doc(doc: &DocumentMut) -> Vec { + let Some(table) = doc.get("agents").and_then(|i| i.as_table()) else { + return Vec::new(); + }; + let mut found = Vec::new(); + for (key, _) in table.iter() { + let agent = match key { + "claude" => Some(CodingAgent::ClaudeCode), + "codex" => Some(CodingAgent::Codex), + "cursor" => Some(CodingAgent::Cursor), + "hermes" => Some(CodingAgent::Hermes), + _ => None, + }; + if let Some(agent) = agent { + found.push(agent); + } + } + found +} + +pub(super) fn agent_key_and_command(agent: CodingAgent) -> (&'static str, &'static str) { + match agent { + CodingAgent::ClaudeCode => ("claude", "claude"), + CodingAgent::Codex => ("codex", "codex"), + CodingAgent::Cursor => ("cursor", "cursor-agent"), + CodingAgent::Hermes => ("hermes", "hermes"), + } +} + +pub(super) fn preview_paths(scope: ConfigScope, cwd: &Path, home: &Path) -> Vec { + let mut paths = Vec::new(); + if matches!(scope, ConfigScope::Project | ConfigScope::Both) { + paths.push(cwd.join(".nemo-flow").join("config.toml")); + } + if matches!(scope, ConfigScope::Global | ConfigScope::Both) { + paths.push(global_config_dir(home).join("config.toml")); + } + paths +} + +pub(super) fn home_dir() -> Option { + std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .map(PathBuf::from) +} diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index f692733fb..503a2f635 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -32,6 +32,71 @@ fn cli_version_exits_successfully() { assert!(String::from_utf8_lossy(&output.stdout).contains("nemo-flow ")); } +#[test] +fn cli_agents_json_emits_supported_agent_shapes() { + let temp = tempfile::tempdir().unwrap(); + let output = Command::new(gateway_bin()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("HOME", temp.path()) + .args(["agents", "--json"]) + .output() + .unwrap(); + + assert!(output.status.success()); + let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + let agents = parsed.as_array().unwrap(); + assert!(agents.iter().any(|agent| agent["name"] == "codex")); + assert!(agents.iter().all(|agent| agent["status"].is_string())); +} + +#[test] +fn cli_doctor_json_emits_versioned_report() { + let temp = tempfile::tempdir().unwrap(); + let output = Command::new(gateway_bin()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("HOME", temp.path()) + .args(["doctor", "--json"]) + .output() + .unwrap(); + + assert!(output.status.success()); + let parsed: serde_json::Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(parsed["schema_version"], 1); + assert!(parsed["environment"].is_object()); + assert!(parsed["configuration"].is_object()); + assert!(parsed["agents"].is_array()); +} + +#[test] +fn cli_completions_prints_script_for_requested_shell() { + let output = Command::new(gateway_bin()) + .args(["completions", "zsh"]) + .output() + .unwrap(); + + assert!(output.status.success()); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("#compdef nemo-flow") || stdout.contains("_nemo-flow")); +} + +#[test] +fn cli_plugins_edit_requires_tty() { + let temp = tempfile::tempdir().unwrap(); + let output = Command::new(gateway_bin()) + .env("XDG_CONFIG_HOME", temp.path().join("xdg")) + .env("HOME", temp.path()) + .args(["plugins", "edit", "--user"]) + .output() + .unwrap(); + + assert!(!output.status.success()); + assert!( + String::from_utf8_lossy(&output.stderr).contains("requires a TTY"), + "stderr was:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} + #[test] fn cli_help_lists_easy_path_agent_shortcuts() { let output = Command::new(gateway_bin()).arg("--help").output().unwrap(); diff --git a/crates/cli/tests/coverage/completions_install_tests.rs b/crates/cli/tests/coverage/completions_install_tests.rs index 9e9bb677b..807a59fc7 100644 --- a/crates/cli/tests/coverage/completions_install_tests.rs +++ b/crates/cli/tests/coverage/completions_install_tests.rs @@ -4,9 +4,12 @@ use super::*; use std::ffi::OsString; use std::path::PathBuf; +use std::sync::Mutex; use clap_complete::Shell; +static ENV_LOCK: Mutex<()> = Mutex::new(()); + #[test] fn zsh_uses_zdotdir_when_set() { let path = completion_path( @@ -76,3 +79,48 @@ fn detect_shell_rejects_missing_shell_env() { let error = detect_shell(None).unwrap_err().to_string(); assert!(error.contains("$SHELL is not set"), "error was: {error}"); } + +#[test] +fn write_atomic_creates_target_and_removes_temp_file() { + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("nemo-flow"); + + write_atomic(&target, b"complete -c nemo-flow").unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), b"complete -c nemo-flow"); + assert!(!target.with_file_name(".nemo-flow.tmp").exists()); +} + +#[test] +fn install_writes_detected_shell_completion() { + let _guard = ENV_LOCK.lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let old_home = std::env::var_os("HOME"); + let old_zdotdir = std::env::var_os("ZDOTDIR"); + let old_shell = std::env::var_os("SHELL"); + + unsafe { + std::env::set_var("HOME", temp.path()); + std::env::remove_var("ZDOTDIR"); + std::env::set_var("SHELL", "/bin/zsh"); + } + + let path = install(None).unwrap(); + + restore_env("HOME", old_home); + restore_env("ZDOTDIR", old_zdotdir); + restore_env("SHELL", old_shell); + + assert_eq!(path, temp.path().join(".zfunc/_nemo-flow")); + let script = std::fs::read_to_string(path).unwrap(); + assert!(script.contains("nemo-flow")); +} + +fn restore_env(key: &str, value: Option) { + unsafe { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } +} diff --git a/crates/cli/tests/coverage/doctor_tests.rs b/crates/cli/tests/coverage/doctor_tests.rs index baad81a7e..0927a940f 100644 --- a/crates/cli/tests/coverage/doctor_tests.rs +++ b/crates/cli/tests/coverage/doctor_tests.rs @@ -2,7 +2,46 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; +use std::ffi::OsString; use std::path::PathBuf; +use std::sync::Mutex; + +static ENV_LOCK: Mutex<()> = Mutex::new(()); + +struct EnvScope { + values: Vec<(&'static str, Option)>, +} + +impl EnvScope { + fn set(values: &[(&'static str, Option<&std::ffi::OsStr>)]) -> Self { + let previous = values + .iter() + .map(|(key, _)| (*key, std::env::var_os(key))) + .collect::>(); + for (key, value) in values { + unsafe { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + Self { values: previous } + } +} + +impl Drop for EnvScope { + fn drop(&mut self) { + for (key, value) in self.values.drain(..) { + unsafe { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } + } +} fn empty_report() -> DoctorReport { DoctorReport { @@ -235,6 +274,144 @@ fn check_dir_writable_does_not_create_missing_dir() { ); } +#[test] +fn layer_status_reports_missing_valid_invalid_and_non_directory_paths() { + let temp = tempfile::tempdir().unwrap(); + let missing = temp.path().join("missing.toml"); + assert_eq!(layer_status(&missing).status, Status::Info); + + let valid = temp.path().join("config.toml"); + std::fs::write(&valid, "[upstream]\nopenai_base_url = \"http://local\"\n").unwrap(); + let valid_layer = layer_status(&valid); + assert_eq!(valid_layer.status, Status::Pass); + assert!(valid_layer.active); + + let invalid = temp.path().join("invalid.toml"); + std::fs::write(&invalid, "[upstream\n").unwrap(); + let invalid_layer = layer_status(&invalid); + assert_eq!(invalid_layer.status, Status::Fail); + assert!(invalid_layer.details.contains("invalid TOML")); + + let dir = temp.path().join("config-dir"); + std::fs::create_dir(&dir).unwrap(); + let dir_layer = layer_status(&dir); + assert_eq!(dir_layer.status, Status::Fail); + assert!( + dir_layer.details.contains("unreadable") || dir_layer.details.contains("Is a directory") + ); +} + +#[test] +fn agent_helper_statuses_cover_configured_target_and_hook_paths() { + assert_eq!(command_executable("codex --full-auto"), "codex"); + assert_eq!(command_executable(""), ""); + assert_eq!( + agent_command_status(Some(std::path::Path::new("/bin/codex")), false, true), + Status::Warn + ); + assert_eq!(agent_command_status(None, true, false), Status::Fail); + assert_eq!( + combine_status(Status::Pass, Status::Warn, true), + Status::Warn + ); + assert_eq!( + combine_status(Status::Pass, Status::Warn, false), + Status::Pass + ); + + let mut agents = AgentConfigs::default(); + agents.hermes.hooks_path = Some(PathBuf::from("/tmp/hermes.yaml")); + assert!(agent_configured(CodingAgent::Hermes, &agents)); + assert_eq!(configured_agent_names(&agents), vec!["hermes".to_string()]); + + let temp = tempfile::tempdir().unwrap(); + let hook = temp.path().join("hooks.yaml"); + std::fs::write(&hook, "cmd: nemo-flow hook-forward hermes\n").unwrap(); + let (status, details) = hook_file_status(Ok(hook.clone()), CodingAgent::Hermes, true, "hooks"); + assert_eq!(status, Status::Pass); + assert!(details.contains(hook.to_str().unwrap())); + + std::fs::write(&hook, "cmd: custom\n").unwrap(); + let (status, details) = hook_file_status(Ok(hook.clone()), CodingAgent::Hermes, true, "hooks"); + assert_eq!(status, Status::Fail); + assert!(details.contains("missing NeMo Flow hook")); + let (status, _) = hook_file_status(Ok(hook), CodingAgent::Hermes, false, "hooks"); + assert_eq!(status, Status::Info); +} + +#[test] +fn collect_completions_reports_shell_specific_paths() { + let _guard = ENV_LOCK.lock().unwrap(); + let temp = tempfile::tempdir().unwrap(); + let zsh_completion = temp.path().join(".zfunc/_nemo-flow"); + std::fs::create_dir_all(zsh_completion.parent().unwrap()).unwrap(); + std::fs::write(&zsh_completion, "#compdef nemo-flow\n").unwrap(); + + let _env = EnvScope::set(&[("SHELL", Some(std::ffi::OsStr::new("/bin/zsh")))]); + let checks = collect_completions(Some(temp.path())); + assert_eq!(checks[0].status, Status::Pass); + assert!(checks[0].details.contains("_nemo-flow")); + + drop(_env); + let _env = EnvScope::set(&[("SHELL", Some(std::ffi::OsStr::new("/bin/fish")))]); + let checks = collect_completions(Some(temp.path())); + assert_eq!(checks[0].status, Status::Info); + assert!(checks[0].details.contains("nemo-flow.fish")); + + drop(_env); + let _env = EnvScope::set(&[("SHELL", None)]); + let checks = collect_completions(Some(temp.path())); + assert_eq!(checks[0].status, Status::Info); + assert!(checks[0].details.contains("no $SHELL")); +} + +#[test] +fn observability_component_helpers_cover_disabled_and_default_paths() { + let plugin = serde_json::json!({ + "version": 1, + "components": [{ + "kind": OBSERVABILITY_PLUGIN_KIND, + "enabled": true, + "config": { + "version": 1, + "atof": { "enabled": true }, + "openinference": { + "enabled": true, + "endpoint": "http://127.0.0.1:1" + } + } + }] + }); + let config = observability_component_config(&plugin).unwrap(); + assert!(section_enabled(config, "atof")); + assert_eq!(section_output_directory(config, "atof"), None); + assert_eq!( + section_endpoint(config, "openinference").as_deref(), + Some("http://127.0.0.1:1") + ); + assert!( + observability_component_config(&serde_json::json!({ + "components": [{ "kind": "other", "config": {} }] + })) + .is_none() + ); +} + +#[test] +fn check_directory_reports_pass_warn_and_fail() { + let temp = tempfile::tempdir().unwrap(); + let pass = check_directory("ATOF dir", temp.path()); + assert_eq!(pass.status, Status::Pass); + + let missing = check_directory("ATOF dir", &temp.path().join("missing")); + assert_eq!(missing.status, Status::Warn); + + let file = temp.path().join("file"); + std::fs::write(&file, "").unwrap(); + let fail = check_directory("ATOF dir", &file); + assert_eq!(fail.status, Status::Fail); +} + #[tokio::test] async fn collect_observability_warns_for_missing_atif_dir_without_creating_it() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/plugins_tests.rs b/crates/cli/tests/coverage/plugins_tests.rs index 3eda9d935..674fcd4e9 100644 --- a/crates/cli/tests/coverage/plugins_tests.rs +++ b/crates/cli/tests/coverage/plugins_tests.rs @@ -2,6 +2,42 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; +use crate::config::{global_plugin_config_path, project_plugin_config_path}; +use nemo_flow::observability::plugin_component::OBSERVABILITY_PLUGIN_KIND; +use nemo_flow::plugin::{ConfigPolicy, PluginComponentSpec, PluginConfig}; + +#[test] +fn target_scope_defaults_to_user_and_rejects_conflicts() { + assert_eq!( + target_scope(&PluginsEditCommand::default()).unwrap(), + TargetScope::User + ); + assert_eq!( + target_scope(&PluginsEditCommand { + project: true, + ..PluginsEditCommand::default() + }) + .unwrap(), + TargetScope::Project + ); + assert_eq!( + target_scope(&PluginsEditCommand { + global: true, + ..PluginsEditCommand::default() + }) + .unwrap(), + TargetScope::Global + ); + + let error = target_scope(&PluginsEditCommand { + user: true, + project: true, + ..PluginsEditCommand::default() + }) + .unwrap_err() + .to_string(); + assert!(error.contains("choose only one"), "error was: {error}"); +} #[test] fn typed_editor_model_contains_observability_sections() { @@ -156,3 +192,165 @@ fn editor_save_preserves_unknown_observability_fields() { assert_eq!(atof_config.get("filename"), Some(&json!("events.jsonl"))); assert!(!atof_config.contains_key("output_directory")); } + +#[test] +fn component_enablement_and_summary_track_config_state() { + let mut config = PluginConfig::default(); + ensure_observability_component(&mut config).unwrap(); + let mut observability = component_observability_config(&config).unwrap(); + + assert!(component_enabled(&config)); + assert_eq!( + observability_summary(&config, &observability), + "component enabled, sections none" + ); + + set_component_enabled(&mut config, false); + let atif = ObservabilityConfig::editor_schema().field("atif").unwrap(); + toggle_section(&mut observability, atif); + + assert!(!component_enabled(&config)); + assert_eq!( + observability_summary(&config, &observability), + "component disabled, sections ATIF" + ); +} + +#[test] +fn reset_selected_field_accounts_for_section_toggle_offset() { + let mut observability = ObservabilityConfig::default(); + let atof = ObservabilityConfig::editor_schema().field("atof").unwrap(); + let fields = atof.schema().unwrap().fields; + + set_section_field(&mut observability, atof, "output_directory", json!("logs")).unwrap(); + assert!( + section_field_value(&observability, atof, "output_directory") + .unwrap() + .is_some() + ); + + let output_directory_index = fields + .iter() + .position(|field| field.name == "output_directory") + .unwrap(); + assert!( + reset_selected_field(&mut observability, atof, fields, output_directory_index + 1,) + .unwrap() + ); + assert_eq!( + section_field_value(&observability, atof, "output_directory").unwrap(), + None + ); + assert!(!reset_selected_field(&mut observability, atof, fields, 0).unwrap()); +} + +#[test] +fn read_plugin_config_handles_missing_and_invalid_files() { + let temp = tempfile::tempdir().unwrap(); + let missing = temp.path().join("plugins.toml"); + let config = read_plugin_config(&missing).unwrap(); + assert!(config.components.is_empty()); + + std::fs::write(&missing, "components = [\n").unwrap(); + let error = read_plugin_config(&missing).unwrap_err().to_string(); + assert!(error.contains("invalid plugin TOML"), "error was: {error}"); +} + +#[test] +fn write_plugin_config_prunes_defaults_and_round_trips() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("plugins.toml"); + let mut config = PluginConfig::default(); + ensure_observability_component(&mut config).unwrap(); + + write_plugin_config(&path, &config).unwrap(); + + let rendered = std::fs::read_to_string(&path).unwrap(); + assert!(rendered.contains("kind = \"observability\"")); + assert!(!rendered.contains("enabled = true")); + let round_tripped = read_plugin_config(&path).unwrap(); + assert_eq!(round_tripped.components.len(), 1); + assert_eq!(round_tripped.components[0].kind, OBSERVABILITY_PLUGIN_KIND); +} + +#[test] +fn prune_plugin_defaults_removes_default_policy_and_enabled_true_only() { + let mut value = json!({ + "version": 1, + "policy": ConfigPolicy::default(), + "components": [ + { "kind": "observability", "enabled": true, "config": {} }, + { "kind": "other", "enabled": false, "config": {} } + ] + }); + + prune_plugin_defaults(&mut value); + + let object = value.as_object().unwrap(); + assert!(!object.contains_key("policy")); + let components = object["components"].as_array().unwrap(); + assert!(!components[0].as_object().unwrap().contains_key("enabled")); + assert_eq!(components[1]["enabled"], json!(false)); +} + +#[test] +fn validate_config_reports_plugin_diagnostics() { + let config = PluginConfig { + components: vec![PluginComponentSpec { + kind: OBSERVABILITY_PLUGIN_KIND.to_string(), + enabled: true, + config: json!({ + "version": 1, + "atof": { + "enabled": true, + "mode": "not-a-mode" + } + }) + .as_object() + .unwrap() + .clone(), + }], + ..PluginConfig::default() + }; + + let error = validate_config(&config).unwrap_err().to_string(); + + assert!( + error.contains("plugin validation failed"), + "error was: {error}" + ); + assert!(error.contains("ATOF mode"), "error was: {error}"); +} + +#[test] +fn display_helpers_render_scalars_json_and_defaults() { + assert_eq!(display_value(&json!("logs")), "logs"); + assert_eq!(display_value(&json!(true)), "true"); + assert_eq!(display_value(&json!(7)), "7"); + assert_eq!(display_value(&json!({ "a": 1 })), r#"{"a":1}"#); + + let atof = ObservabilityConfig::editor_schema().field("atof").unwrap(); + let mode = atof.schema().unwrap().field("mode").unwrap(); + assert_eq!( + display_field_value(atof, mode, &json!("append")), + "append (default)" + ); + assert_eq!( + display_field_value(atof, mode, &json!("overwrite")), + "overwrite" + ); +} + +#[test] +fn target_path_resolves_project_and_global_without_user_env() { + let cwd = std::env::current_dir().unwrap(); + + assert_eq!( + target_path(TargetScope::Project).unwrap(), + project_plugin_config_path(&cwd) + ); + assert_eq!( + target_path(TargetScope::Global).unwrap(), + global_plugin_config_path() + ); +} diff --git a/crates/cli/tests/coverage/server_tests.rs b/crates/cli/tests/coverage/server_tests.rs index c058d041d..c2e41136f 100644 --- a/crates/cli/tests/coverage/server_tests.rs +++ b/crates/cli/tests/coverage/server_tests.rs @@ -693,6 +693,39 @@ async fn models_route_forwards_get_requests() { assert_eq!(body["authorization"], json!("Bearer test")); } +#[tokio::test] +async fn gateway_forwards_anthropic_count_tokens_without_llm_codec() { + let upstream = spawn_anthropic_upstream().await; + let mut config = test_config(); + config.anthropic_base_url = upstream.url(); + let app = router(config); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/messages/count_tokens") + .header("content-type", "application/json") + .header("x-api-key", "sk-ant-test") + .body(Body::from( + json!({ + "model": "claude-test", + "messages": [{ "role": "user", "content": "hello" }] + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let body: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["path"], json!("/v1/messages/count_tokens")); + assert_eq!(body["x_api_key"], json!("sk-ant-test")); + assert_eq!(body["input_tokens"], json!(12)); +} + async fn wait_for_gateway(url: &str) { let client = reqwest::Client::new(); for _ in 0..50 { @@ -805,3 +838,26 @@ async fn spawn_models_upstream() -> TestServer { handle, } } + +async fn spawn_anthropic_upstream() -> TestServer { + async fn count_tokens(headers: HeaderMap, request: Request) -> impl IntoResponse { + Json(json!({ + "path": request.uri().path(), + "x_api_key": headers + .get("x-api-key") + .and_then(|value| value.to_str().ok()), + "input_tokens": 12 + })) + } + + let app = Router::new().route("/v1/messages/count_tokens", post(count_tokens)); + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let handle = tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + TestServer { + url: format!("http://{address}"), + handle, + } +} diff --git a/crates/cli/tests/coverage/setup_tests.rs b/crates/cli/tests/coverage/setup_tests.rs index f8d99015e..ef41798eb 100644 --- a/crates/cli/tests/coverage/setup_tests.rs +++ b/crates/cli/tests/coverage/setup_tests.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use super::*; +use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; // Tests that exercise the global-config write path must run serially with respect to each other @@ -47,6 +48,29 @@ impl<'a> Drop for XdgScope<'a> { } } +struct CwdScope<'a> { + _guard: std::sync::MutexGuard<'a, ()>, + prev: PathBuf, +} + +impl<'a> CwdScope<'a> { + fn enter(path: &std::path::Path) -> Self { + let guard = xdg_env_lock().lock().unwrap_or_else(|e| e.into_inner()); + let prev = std::env::current_dir().unwrap(); + std::env::set_current_dir(path).unwrap(); + Self { + _guard: guard, + prev, + } + } +} + +impl<'a> Drop for CwdScope<'a> { + fn drop(&mut self) { + std::env::set_current_dir(&self.prev).unwrap(); + } +} + // Stub-binary detection relies on the Unix executable bit. Windows-side agent presence checks // use a different mechanism (e.g. `.exe` extension matching), so this lookup test is gated to // Unix to keep cross-platform CI green; covering the Windows code path is left to a separate @@ -241,6 +265,91 @@ fn build_config_emits_hooks_path_for_hermes_when_set() { assert!(rendered.contains(r#"hooks_path = "/tmp/proj/.hermes/config.yaml""#)); } +#[test] +fn config_scope_labels_are_user_facing_and_stable() { + assert!( + ConfigScope::Project + .label() + .contains(".nemo-flow/config.toml") + ); + assert!( + ConfigScope::Global + .label() + .contains(".config/nemo-flow/config.toml") + ); + assert!( + ConfigScope::Both + .label() + .contains("project overrides global") + ); +} + +#[test] +fn hermes_hook_paths_follow_selected_scope() { + let cwd = PathBuf::from("/workspace"); + let home = PathBuf::from("/home/user"); + let agents = [CodingAgent::Hermes]; + + assert_eq!( + hermes_hooks_path_for_scope(&agents, ConfigScope::Project, &cwd, &home), + Some(PathBuf::from("/workspace/.hermes/config.yaml")) + ); + assert_eq!( + hermes_hooks_path_for_scope(&agents, ConfigScope::Both, &cwd, &home), + Some(PathBuf::from("/workspace/.hermes/config.yaml")) + ); + assert_eq!( + hermes_hooks_path_for_scope(&agents, ConfigScope::Global, &cwd, &home), + Some(PathBuf::from("/home/user/.hermes/config.yaml")) + ); + assert_eq!( + hermes_hooks_path_for_scope(&[], ConfigScope::Project, &cwd, &home), + None + ); + assert_eq!( + hermes_hook_targets(ConfigScope::Both, &cwd, &home), + vec![ + PathBuf::from("/workspace/.hermes/config.yaml"), + PathBuf::from("/home/user/.hermes/config.yaml") + ] + ); +} + +#[test] +fn existing_defaults_detects_scope_and_agents_from_docs() { + let empty = Defaults::default(); + assert!(!empty.has_any()); + assert!( + Defaults { + scope: Some(ConfigScope::Project), + agents: vec![] + } + .has_any() + ); + assert!( + Defaults { + scope: None, + agents: vec![CodingAgent::Codex] + } + .has_any() + ); + + let doc: DocumentMut = r#" +[agents.claude] +command = "claude" + +[agents.codex] +command = "codex" + +[agents.unknown] +command = "custom" +"# + .parse() + .unwrap(); + let agents = read_agents_from_doc(&doc); + assert_eq!(agents, vec![CodingAgent::ClaudeCode, CodingAgent::Codex]); +} + #[test] fn install_hermes_hooks_writes_yaml_and_merges_existing() { let cwd = tempfile::tempdir().unwrap(); @@ -267,3 +376,85 @@ fn install_hermes_hooks_writes_yaml_and_merges_existing() { let home_yaml = std::fs::read_to_string(home.path().join(".hermes/config.yaml")).unwrap(); assert!(home_yaml.contains("nemo-flow hook-forward hermes")); } + +#[test] +fn write_or_merge_recovers_from_non_table_agents_value() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("config.toml"); + std::fs::write( + &path, + r#" +agents = "not-a-table" + +[plugins] +enabled = true +"#, + ) + .unwrap(); + let doc = build_config(&SetupAnswers { + scope: ConfigScope::Project, + agents: vec![CodingAgent::Codex], + hermes_hooks_path: None, + }); + + write_or_merge(&path, &doc, Some(CodingAgent::Codex)).unwrap(); + + let merged = std::fs::read_to_string(path).unwrap(); + assert!(merged.contains("[agents.codex]")); + assert!(merged.contains(r#"command = "codex""#)); + assert!(merged.contains("[plugins]")); +} + +#[test] +fn reset_removes_whole_project_config_or_one_agent() { + let temp = tempfile::tempdir().unwrap(); + let _cwd = CwdScope::enter(temp.path()); + let config_dir = temp.path().join(".nemo-flow"); + std::fs::create_dir_all(&config_dir).unwrap(); + let path = config_dir.join("config.toml"); + std::fs::write( + &path, + r#" +[agents.claude] +command = "claude" + +[agents.codex] +command = "codex" +"#, + ) + .unwrap(); + + reset(Some(CodingAgent::ClaudeCode)).unwrap(); + + let scoped = std::fs::read_to_string(&path).unwrap(); + assert!(!scoped.contains("[agents.claude]")); + assert!(scoped.contains("[agents.codex]")); + + reset(None).unwrap(); + + assert!(!path.exists()); +} + +#[test] +fn reset_reports_missing_or_malformed_agent_blocks_without_rewriting() { + let temp = tempfile::tempdir().unwrap(); + let _cwd = CwdScope::enter(temp.path()); + let config_dir = temp.path().join(".nemo-flow"); + std::fs::create_dir_all(&config_dir).unwrap(); + let path = config_dir.join("config.toml"); + std::fs::write(&path, "agents = \"not-a-table\"\n").unwrap(); + + reset(Some(CodingAgent::Hermes)).unwrap(); + + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + "agents = \"not-a-table\"\n" + ); + + std::fs::write(&path, "not valid toml = [\n").unwrap(); + let error = reset(Some(CodingAgent::Hermes)).unwrap_err().to_string(); + assert!( + error.contains("could not parse existing config"), + "error was: {error}" + ); +} From ed3823e931494786b015bc6475890144607e5795 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Thu, 14 May 2026 10:44:00 -0400 Subject: [PATCH 2/2] fix: gate Unix-only setup test import Signed-off-by: Will Killian --- crates/cli/src/setup.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/cli/src/setup.rs b/crates/cli/src/setup.rs index 6f7f2ce48..7f9038b42 100644 --- a/crates/cli/src/setup.rs +++ b/crates/cli/src/setup.rs @@ -31,7 +31,10 @@ use self::model::{ }; #[cfg(test)] -use self::model::{Defaults, detect_installed_agents_in, read_agents_from_doc, write_or_merge}; +use self::model::{Defaults, read_agents_from_doc, write_or_merge}; + +#[cfg(all(test, unix))] +use self::model::detect_installed_agents_in; /// /// When `agent_hint` is `Some`, the agent multi-select is skipped — the user already declared