From db21ceb157d36ebb575887fb02d17d13cce73b23 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sat, 1 Aug 2026 18:54:23 -0400 Subject: [PATCH 1/2] test(cli): raise component coverage above 92 percent Signed-off-by: Will Killian --- codecov.yml | 11 +- crates/cli/src/commands/configure/editor.rs | 445 +----- .../src/commands/configure/editor/prompt.rs | 461 ++++++ crates/cli/src/commands/configure/wizard.rs | 295 +--- .../src/commands/configure/wizard/prompt.rs | 299 ++++ crates/cli/src/commands/mod.rs | 85 +- crates/cli/src/plugins/dynamic_editor.rs | 419 +----- .../cli/src/plugins/dynamic_editor/prompt.rs | 401 +++++ crates/cli/src/plugins/mod.rs | 1301 +---------------- crates/cli/src/plugins/prompt.rs | 1266 ++++++++++++++++ crates/cli/src/process/launcher.rs | 14 +- crates/cli/src/server/mod.rs | 87 +- crates/cli/tests/cli_tests.rs | 77 +- .../commands/configure_editor_tests.rs | 76 + .../cli/tests/coverage/shared/config_tests.rs | 105 ++ .../tests/coverage/shared/installer_tests.rs | 11 + .../shared/plugins_lifecycle_tests.rs | 384 ++++- .../coverage/shared/plugins_schema_tests.rs | 26 +- .../tests/coverage/shared/plugins_tests.rs | 475 +++++- .../cli/tests/coverage/shared/server_tests.rs | 150 ++ 20 files changed, 3762 insertions(+), 2626 deletions(-) create mode 100644 crates/cli/src/commands/configure/editor/prompt.rs create mode 100644 crates/cli/src/commands/configure/wizard/prompt.rs create mode 100644 crates/cli/src/plugins/dynamic_editor/prompt.rs create mode 100644 crates/cli/src/plugins/prompt.rs diff --git a/codecov.yml b/codecov.yml index 55b332a21..c52334c14 100644 --- a/codecov.yml +++ b/codecov.yml @@ -135,11 +135,12 @@ ignore: - "**/examples/**" - "**/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/mod.rs" - - "crates/cli/src/plugins/dynamic_editor.rs" - - "crates/cli/src/commands/configure/wizard.rs" + # CLI TTY shells are exercised by smoke tests, while deterministic state, + # validation, and persistence behavior remains in the CLI component. + - "crates/cli/src/commands/configure/editor/prompt.rs" + - "crates/cli/src/commands/configure/wizard/prompt.rs" + - "crates/cli/src/plugins/prompt.rs" + - "crates/cli/src/plugins/dynamic_editor/prompt.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/commands/configure/editor.rs b/crates/cli/src/commands/configure/editor.rs index 5be154746..93504a700 100644 --- a/crates/cli/src/commands/configure/editor.rs +++ b/crates/cli/src/commands/configure/editor.rs @@ -3,21 +3,19 @@ //! Interactive editor for the non-agent sections of Relay's `config.toml`. -use std::io::IsTerminal; use std::path::{Path, PathBuf}; -use dialoguer::theme::ColorfulTheme; -use dialoguer::{Input, Password, Select}; use nemo_relay::logging::MAX_FILE_SINK_QUEUE_ENTRIES; use toml_edit::{ArrayOfTables, DocumentMut, Item, Table, Value, value}; use super::ConfigEditCommand; use crate::error::CliError; -const EDIT_CANCELLED_MESSAGE: &str = "configuration edit cancelled — no config saved"; const LOG_LEVELS: &[&str] = &["error", "warn", "info", "debug", "trace"]; const LOG_FORMATS: &[&str] = &["human", "jsonl"]; +mod prompt; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum TargetScope { User, @@ -41,39 +39,7 @@ pub(super) fn edit( command: ConfigEditCommand, explicit_path: Option, ) -> Result<(), CliError> { - ensure_tty()?; - let (scope, path) = resolve_edit_target(&command, explicit_path)?; - let mut document = ConfigDocument::read(path)?; - let theme = ColorfulTheme::default(); - - crate::banner::print_intro(); - println!(" Editing config at {}", document.path().display()); - println!(" Secrets are never displayed. Choose Save to write changes."); - println!(); - - loop { - let choices = [ - format!("Gateway limits ({})", document.gateway_summary()), - format!("Provider upstreams ({})", document.upstream_summary()), - format!("Operational logging ({})", document.logging_summary()), - "Preview".into(), - "Save".into(), - "Cancel".into(), - ]; - match select(&theme, "config.toml", &choices)? { - 0 => edit_gateway(&theme, &mut document)?, - 1 => edit_upstream(&theme, &mut document)?, - 2 => edit_logging(&theme, &mut document)?, - 3 => print_preview(&document), - 4 => { - document.write(scope)?; - println!(" ✓ Saved {}", document.path().display()); - return Ok(()); - } - 5 => return Err(CliError::Config(EDIT_CANCELLED_MESSAGE.into())), - _ => unreachable!("select returns an in-range index"), - } - } + prompt::edit(command, explicit_path) } fn resolve_edit_target( @@ -92,10 +58,6 @@ fn resolve_edit_target( Ok((scope, path)) } -fn ensure_tty() -> Result<(), CliError> { - ensure_tty_with(std::io::stdin().is_terminal()) -} - fn ensure_tty_with(stdin_is_terminal: bool) -> Result<(), CliError> { if stdin_is_terminal { Ok(()) @@ -106,407 +68,6 @@ fn ensure_tty_with(stdin_is_terminal: bool) -> Result<(), CliError> { } } -fn select(theme: &ColorfulTheme, prompt: &str, choices: &[String]) -> Result { - Select::with_theme(theme) - .with_prompt(prompt) - .items(choices) - .default(0) - .interact() - .map_err(prompt_error) -} - -fn choose_action(theme: &ColorfulTheme, configured: bool) -> Result { - let choices = if configured { - vec!["Set or replace".into(), "Clear".into(), "Back".into()] - } else { - vec!["Set".into(), "Back".into()] - }; - select(theme, "Action", &choices) -} - -fn edit_gateway(theme: &ColorfulTheme, document: &mut ConfigDocument) -> Result<(), CliError> { - loop { - let choices = [ - format!( - "Maximum hook payload bytes: {}", - document.integer_summary("gateway", "max_hook_payload_bytes") - ), - format!( - "Maximum passthrough body bytes: {}", - document.integer_summary("gateway", "max_passthrough_body_bytes") - ), - "Back".into(), - ]; - match select(theme, "Gateway limits", &choices)? { - 0 => edit_positive_integer(theme, document, "gateway", "max_hook_payload_bytes")?, - 1 => edit_positive_integer(theme, document, "gateway", "max_passthrough_body_bytes")?, - 2 => return Ok(()), - _ => unreachable!(), - } - } -} - -fn edit_upstream(theme: &ColorfulTheme, document: &mut ConfigDocument) -> Result<(), CliError> { - loop { - let choices = [ - format!( - "OpenAI base URL: {}", - document.string_summary("upstream", "openai_base_url") - ), - format!( - "OpenAI authorization header: {}", - document.secret_summary("openai_auth_header") - ), - format!( - "Anthropic base URL: {}", - document.string_summary("upstream", "anthropic_base_url") - ), - format!( - "Anthropic authorization header: {}", - document.secret_summary("anthropic_auth_header") - ), - "Back".into(), - ]; - match select(theme, "Provider upstreams", &choices)? { - 0 => edit_string(theme, document, "upstream", "openai_base_url")?, - 1 => edit_secret(theme, document, "openai_auth_header")?, - 2 => edit_string(theme, document, "upstream", "anthropic_base_url")?, - 3 => edit_secret(theme, document, "anthropic_auth_header")?, - 4 => return Ok(()), - _ => unreachable!(), - } - } -} - -fn edit_logging(theme: &ColorfulTheme, document: &mut ConfigDocument) -> Result<(), CliError> { - loop { - let choices = [ - format!("Level: {}", document.string_summary("logging", "level")), - format!( - "Stderr format: {}", - document.string_summary("logging", "stderr_format") - ), - format!( - "Flush interval (ms): {}", - document.integer_summary("logging", "flush_interval_millis") - ), - format!("File sinks ({})", document.sink_count()), - "Back".into(), - ]; - match select(theme, "Operational logging", &choices)? { - 0 => edit_enum(theme, document, "logging", "level", LOG_LEVELS)?, - 1 => edit_enum(theme, document, "logging", "stderr_format", LOG_FORMATS)?, - 2 => edit_nonnegative_integer(theme, document, "logging", "flush_interval_millis")?, - 3 => edit_sinks(theme, document)?, - 4 => return Ok(()), - _ => unreachable!(), - } - } -} - -fn edit_positive_integer( - theme: &ColorfulTheme, - document: &mut ConfigDocument, - section: &str, - key: &str, -) -> Result<(), CliError> { - let configured = document.has_key(section, key); - match choose_action(theme, configured)? { - 0 => { - let value = prompt_u64(theme, "Value in bytes", document.integer(section, key))?; - document.set_positive_integer(section, key, value)?; - } - 1 if configured => document.clear_key(section, key)?, - _ => {} - } - Ok(()) -} - -fn edit_nonnegative_integer( - theme: &ColorfulTheme, - document: &mut ConfigDocument, - section: &str, - key: &str, -) -> Result<(), CliError> { - let configured = document.has_key(section, key); - match choose_action(theme, configured)? { - 0 => { - let value = prompt_u64( - theme, - "Milliseconds (0 flushes on shutdown)", - document.integer(section, key), - )?; - document.set_integer(section, key, value)?; - } - 1 if configured => document.clear_key(section, key)?, - _ => {} - } - Ok(()) -} - -fn edit_string( - theme: &ColorfulTheme, - document: &mut ConfigDocument, - section: &str, - key: &str, -) -> Result<(), CliError> { - let configured = document.has_key(section, key); - match choose_action(theme, configured)? { - 0 => { - let default = document.string(section, key).unwrap_or_default(); - let value = Input::::with_theme(theme) - .with_prompt("Value") - .with_initial_text(default) - .validate_with(|value: &String| { - if value.trim().is_empty() { - Err("value must not be empty; use Clear to remove it") - } else { - Ok(()) - } - }) - .interact_text() - .map_err(prompt_error)?; - document.set_string(section, key, value)?; - } - 1 if configured => document.clear_key(section, key)?, - _ => {} - } - Ok(()) -} - -fn edit_secret( - theme: &ColorfulTheme, - document: &mut ConfigDocument, - key: &str, -) -> Result<(), CliError> { - let configured = document.has_key("upstream", key); - match choose_action(theme, configured)? { - 0 => { - let value = Password::with_theme(theme) - .with_prompt("Authorization header value") - .allow_empty_password(false) - .interact() - .map_err(prompt_error)?; - document.set_auth_header(key, value)?; - } - 1 if configured => document.clear_key("upstream", key)?, - _ => {} - } - Ok(()) -} - -fn edit_enum( - theme: &ColorfulTheme, - document: &mut ConfigDocument, - section: &str, - key: &str, - values: &[&str], -) -> Result<(), CliError> { - let configured = document.has_key(section, key); - match choose_action(theme, configured)? { - 0 => { - let current = document.string(section, key); - let default = current - .as_deref() - .and_then(|current| values.iter().position(|value| *value == current)) - .unwrap_or(0); - let selected = Select::with_theme(theme) - .with_prompt("Value") - .items(values) - .default(default) - .interact() - .map_err(prompt_error)?; - document.set_enum(section, key, values[selected], values)?; - } - 1 if configured => document.clear_key(section, key)?, - _ => {} - } - Ok(()) -} - -fn edit_sinks(theme: &ColorfulTheme, document: &mut ConfigDocument) -> Result<(), CliError> { - loop { - let mut choices = document - .sink_labels() - .into_iter() - .map(|label| format!("Edit {label}")) - .collect::>(); - let sink_count = choices.len(); - choices.push("Add file sink".into()); - choices.push("Back".into()); - match select(theme, "File sinks", &choices)? { - index if index < sink_count => edit_sink(theme, document, index)?, - index if index == sink_count => { - let path = Input::::with_theme(theme) - .with_prompt("File path") - .validate_with(|value: &String| { - if value.trim().is_empty() { - Err("value must not be empty".to_owned()) - } else { - Ok(()) - } - }) - .interact_text() - .map_err(prompt_error)?; - document.add_sink(path)?; - } - _ => return Ok(()), - } - } -} - -fn edit_sink( - theme: &ColorfulTheme, - document: &mut ConfigDocument, - index: usize, -) -> Result<(), CliError> { - loop { - let choices = [ - format!("Path: {}", document.sink_string_summary(index, "path")), - format!("Level: {}", document.sink_string_summary(index, "level")), - format!("Format: {}", document.sink_string_summary(index, "format")), - format!( - "Queue capacity: {}", - document.sink_integer_summary(index, "queue_capacity") - ), - format!("Rotation: {}", document.sink_rotation_summary(index)), - "Remove sink".into(), - "Back".into(), - ]; - match select(theme, "File sink", &choices)? { - 0 => edit_sink_path(theme, document, index)?, - 1 => edit_sink_enum(theme, document, index, "level", LOG_LEVELS)?, - 2 => edit_sink_enum(theme, document, index, "format", LOG_FORMATS)?, - 3 => edit_sink_queue_capacity(theme, document, index)?, - 4 => edit_sink_rotation(theme, document, index)?, - 5 => { - document.remove_sink(index)?; - return Ok(()); - } - _ => return Ok(()), - } - } -} - -fn edit_sink_path( - theme: &ColorfulTheme, - document: &mut ConfigDocument, - index: usize, -) -> Result<(), CliError> { - let current = document.sink_string(index, "path").unwrap_or_default(); - let value = Input::::with_theme(theme) - .with_prompt("File path") - .with_initial_text(current) - .validate_with(|value: &String| { - if value.trim().is_empty() { - Err("value must not be empty".to_owned()) - } else { - Ok(()) - } - }) - .interact_text() - .map_err(prompt_error)?; - document.set_sink_string(index, "path", value) -} - -fn edit_sink_enum( - theme: &ColorfulTheme, - document: &mut ConfigDocument, - index: usize, - key: &str, - values: &[&str], -) -> Result<(), CliError> { - let configured = document.sink_has_key(index, key)?; - match choose_action(theme, configured)? { - 0 => { - let default = document - .sink_string(index, key) - .as_deref() - .and_then(|current| values.iter().position(|value| *value == current)) - .unwrap_or(0); - let selected = Select::with_theme(theme) - .with_prompt("Value") - .items(values) - .default(default) - .interact() - .map_err(prompt_error)?; - document.set_sink_enum(index, key, values[selected], values)?; - } - 1 if configured => document.clear_sink_key(index, key)?, - _ => {} - } - Ok(()) -} - -fn edit_sink_queue_capacity( - theme: &ColorfulTheme, - document: &mut ConfigDocument, - index: usize, -) -> Result<(), CliError> { - let configured = document.sink_has_key(index, "queue_capacity")?; - match choose_action(theme, configured)? { - 0 => { - let value = prompt_u64( - theme, - "Queue entries", - document.sink_integer(index, "queue_capacity"), - )?; - document.set_sink_queue_capacity(index, value)?; - } - 1 if configured => document.clear_sink_key(index, "queue_capacity")?, - _ => {} - } - Ok(()) -} - -fn edit_sink_rotation( - theme: &ColorfulTheme, - document: &mut ConfigDocument, - index: usize, -) -> Result<(), CliError> { - let configured = document.sink_has_key(index, "max_file_size_bytes")? - || document.sink_has_key(index, "retained_files")?; - match choose_action(theme, configured)? { - 0 => { - let size = prompt_u64( - theme, - "Maximum file size in bytes", - document.sink_integer(index, "max_file_size_bytes"), - )?; - let retained = prompt_u64( - theme, - "Retained backup files", - document.sink_integer(index, "retained_files"), - )?; - document.set_sink_rotation(index, size, retained)?; - } - 1 if configured => document.clear_sink_rotation(index)?, - _ => {} - } - Ok(()) -} - -fn prompt_u64(theme: &ColorfulTheme, prompt: &str, current: Option) -> Result { - let mut input = Input::::with_theme(theme).with_prompt(prompt); - if let Some(current) = current { - input = input.with_initial_text(current.to_string()); - } - input.interact_text().map_err(prompt_error) -} - -fn prompt_error(error: dialoguer::Error) -> CliError { - CliError::Config(format!("configuration edit error: {error}")) -} - -fn print_preview(document: &ConfigDocument) { - println!(); - println!(" ─── Preview ─────────────────────────────────────────────"); - for line in document.preview().lines() { - println!(" {line}"); - } - println!(); -} - struct ConfigDocument { path: PathBuf, document: DocumentMut, diff --git a/crates/cli/src/commands/configure/editor/prompt.rs b/crates/cli/src/commands/configure/editor/prompt.rs new file mode 100644 index 000000000..3f843c9d4 --- /dev/null +++ b/crates/cli/src/commands/configure/editor/prompt.rs @@ -0,0 +1,461 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Terminal-only prompt adapter for the interactive config editor. + +use std::io::IsTerminal; +use std::path::PathBuf; + +use dialoguer::theme::ColorfulTheme; +use dialoguer::{Input, Password, Select}; + +use super::{ + ConfigDocument, ConfigEditCommand, LOG_FORMATS, LOG_LEVELS, ensure_tty_with, + resolve_edit_target, +}; +use crate::error::CliError; + +const EDIT_CANCELLED_MESSAGE: &str = "configuration edit cancelled — no config saved"; +pub(super) fn edit( + command: ConfigEditCommand, + explicit_path: Option, +) -> Result<(), CliError> { + ensure_tty()?; + let (scope, path) = resolve_edit_target(&command, explicit_path)?; + let mut document = ConfigDocument::read(path)?; + let theme = ColorfulTheme::default(); + + crate::banner::print_intro(); + println!(" Editing config at {}", document.path().display()); + println!(" Secrets are never displayed. Choose Save to write changes."); + println!(); + + loop { + let choices = [ + format!("Gateway limits ({})", document.gateway_summary()), + format!("Provider upstreams ({})", document.upstream_summary()), + format!("Operational logging ({})", document.logging_summary()), + "Preview".into(), + "Save".into(), + "Cancel".into(), + ]; + match select(&theme, "config.toml", &choices)? { + 0 => edit_gateway(&theme, &mut document)?, + 1 => edit_upstream(&theme, &mut document)?, + 2 => edit_logging(&theme, &mut document)?, + 3 => print_preview(&document), + 4 => { + document.write(scope)?; + println!(" ✓ Saved {}", document.path().display()); + return Ok(()); + } + 5 => return Err(CliError::Config(EDIT_CANCELLED_MESSAGE.into())), + _ => unreachable!("select returns an in-range index"), + } + } +} + +fn ensure_tty() -> Result<(), CliError> { + ensure_tty_with(std::io::stdin().is_terminal()) +} + +fn select(theme: &ColorfulTheme, prompt: &str, choices: &[String]) -> Result { + Select::with_theme(theme) + .with_prompt(prompt) + .items(choices) + .default(0) + .interact() + .map_err(prompt_error) +} + +fn choose_action(theme: &ColorfulTheme, configured: bool) -> Result { + let choices = if configured { + vec!["Set or replace".into(), "Clear".into(), "Back".into()] + } else { + vec!["Set".into(), "Back".into()] + }; + select(theme, "Action", &choices) +} + +fn edit_gateway(theme: &ColorfulTheme, document: &mut ConfigDocument) -> Result<(), CliError> { + loop { + let choices = [ + format!( + "Maximum hook payload bytes: {}", + document.integer_summary("gateway", "max_hook_payload_bytes") + ), + format!( + "Maximum passthrough body bytes: {}", + document.integer_summary("gateway", "max_passthrough_body_bytes") + ), + "Back".into(), + ]; + match select(theme, "Gateway limits", &choices)? { + 0 => edit_positive_integer(theme, document, "gateway", "max_hook_payload_bytes")?, + 1 => edit_positive_integer(theme, document, "gateway", "max_passthrough_body_bytes")?, + 2 => return Ok(()), + _ => unreachable!(), + } + } +} + +fn edit_upstream(theme: &ColorfulTheme, document: &mut ConfigDocument) -> Result<(), CliError> { + loop { + let choices = [ + format!( + "OpenAI base URL: {}", + document.string_summary("upstream", "openai_base_url") + ), + format!( + "OpenAI authorization header: {}", + document.secret_summary("openai_auth_header") + ), + format!( + "Anthropic base URL: {}", + document.string_summary("upstream", "anthropic_base_url") + ), + format!( + "Anthropic authorization header: {}", + document.secret_summary("anthropic_auth_header") + ), + "Back".into(), + ]; + match select(theme, "Provider upstreams", &choices)? { + 0 => edit_string(theme, document, "upstream", "openai_base_url")?, + 1 => edit_secret(theme, document, "openai_auth_header")?, + 2 => edit_string(theme, document, "upstream", "anthropic_base_url")?, + 3 => edit_secret(theme, document, "anthropic_auth_header")?, + 4 => return Ok(()), + _ => unreachable!(), + } + } +} + +fn edit_logging(theme: &ColorfulTheme, document: &mut ConfigDocument) -> Result<(), CliError> { + loop { + let choices = [ + format!("Level: {}", document.string_summary("logging", "level")), + format!( + "Stderr format: {}", + document.string_summary("logging", "stderr_format") + ), + format!( + "Flush interval (ms): {}", + document.integer_summary("logging", "flush_interval_millis") + ), + format!("File sinks ({})", document.sink_count()), + "Back".into(), + ]; + match select(theme, "Operational logging", &choices)? { + 0 => edit_enum(theme, document, "logging", "level", LOG_LEVELS)?, + 1 => edit_enum(theme, document, "logging", "stderr_format", LOG_FORMATS)?, + 2 => edit_nonnegative_integer(theme, document, "logging", "flush_interval_millis")?, + 3 => edit_sinks(theme, document)?, + 4 => return Ok(()), + _ => unreachable!(), + } + } +} + +fn edit_positive_integer( + theme: &ColorfulTheme, + document: &mut ConfigDocument, + section: &str, + key: &str, +) -> Result<(), CliError> { + let configured = document.has_key(section, key); + match choose_action(theme, configured)? { + 0 => { + let value = prompt_u64(theme, "Value in bytes", document.integer(section, key))?; + document.set_positive_integer(section, key, value)?; + } + 1 if configured => document.clear_key(section, key)?, + _ => {} + } + Ok(()) +} + +fn edit_nonnegative_integer( + theme: &ColorfulTheme, + document: &mut ConfigDocument, + section: &str, + key: &str, +) -> Result<(), CliError> { + let configured = document.has_key(section, key); + match choose_action(theme, configured)? { + 0 => { + let value = prompt_u64( + theme, + "Milliseconds (0 flushes on shutdown)", + document.integer(section, key), + )?; + document.set_integer(section, key, value)?; + } + 1 if configured => document.clear_key(section, key)?, + _ => {} + } + Ok(()) +} + +fn edit_string( + theme: &ColorfulTheme, + document: &mut ConfigDocument, + section: &str, + key: &str, +) -> Result<(), CliError> { + let configured = document.has_key(section, key); + match choose_action(theme, configured)? { + 0 => { + let default = document.string(section, key).unwrap_or_default(); + let value = Input::::with_theme(theme) + .with_prompt("Value") + .with_initial_text(default) + .validate_with(|value: &String| { + if value.trim().is_empty() { + Err("value must not be empty; use Clear to remove it") + } else { + Ok(()) + } + }) + .interact_text() + .map_err(prompt_error)?; + document.set_string(section, key, value)?; + } + 1 if configured => document.clear_key(section, key)?, + _ => {} + } + Ok(()) +} + +fn edit_secret( + theme: &ColorfulTheme, + document: &mut ConfigDocument, + key: &str, +) -> Result<(), CliError> { + let configured = document.has_key("upstream", key); + match choose_action(theme, configured)? { + 0 => { + let value = Password::with_theme(theme) + .with_prompt("Authorization header value") + .allow_empty_password(false) + .interact() + .map_err(prompt_error)?; + document.set_auth_header(key, value)?; + } + 1 if configured => document.clear_key("upstream", key)?, + _ => {} + } + Ok(()) +} + +fn edit_enum( + theme: &ColorfulTheme, + document: &mut ConfigDocument, + section: &str, + key: &str, + values: &[&str], +) -> Result<(), CliError> { + let configured = document.has_key(section, key); + match choose_action(theme, configured)? { + 0 => { + let current = document.string(section, key); + let default = current + .as_deref() + .and_then(|current| values.iter().position(|value| *value == current)) + .unwrap_or(0); + let selected = Select::with_theme(theme) + .with_prompt("Value") + .items(values) + .default(default) + .interact() + .map_err(prompt_error)?; + document.set_enum(section, key, values[selected], values)?; + } + 1 if configured => document.clear_key(section, key)?, + _ => {} + } + Ok(()) +} + +fn edit_sinks(theme: &ColorfulTheme, document: &mut ConfigDocument) -> Result<(), CliError> { + loop { + let mut choices = document + .sink_labels() + .into_iter() + .map(|label| format!("Edit {label}")) + .collect::>(); + let sink_count = choices.len(); + choices.push("Add file sink".into()); + choices.push("Back".into()); + match select(theme, "File sinks", &choices)? { + index if index < sink_count => edit_sink(theme, document, index)?, + index if index == sink_count => { + let path = Input::::with_theme(theme) + .with_prompt("File path") + .validate_with(|value: &String| { + if value.trim().is_empty() { + Err("value must not be empty".to_owned()) + } else { + Ok(()) + } + }) + .interact_text() + .map_err(prompt_error)?; + document.add_sink(path)?; + } + _ => return Ok(()), + } + } +} + +fn edit_sink( + theme: &ColorfulTheme, + document: &mut ConfigDocument, + index: usize, +) -> Result<(), CliError> { + loop { + let choices = [ + format!("Path: {}", document.sink_string_summary(index, "path")), + format!("Level: {}", document.sink_string_summary(index, "level")), + format!("Format: {}", document.sink_string_summary(index, "format")), + format!( + "Queue capacity: {}", + document.sink_integer_summary(index, "queue_capacity") + ), + format!("Rotation: {}", document.sink_rotation_summary(index)), + "Remove sink".into(), + "Back".into(), + ]; + match select(theme, "File sink", &choices)? { + 0 => edit_sink_path(theme, document, index)?, + 1 => edit_sink_enum(theme, document, index, "level", LOG_LEVELS)?, + 2 => edit_sink_enum(theme, document, index, "format", LOG_FORMATS)?, + 3 => edit_sink_queue_capacity(theme, document, index)?, + 4 => edit_sink_rotation(theme, document, index)?, + 5 => { + document.remove_sink(index)?; + return Ok(()); + } + _ => return Ok(()), + } + } +} + +fn edit_sink_path( + theme: &ColorfulTheme, + document: &mut ConfigDocument, + index: usize, +) -> Result<(), CliError> { + let current = document.sink_string(index, "path").unwrap_or_default(); + let value = Input::::with_theme(theme) + .with_prompt("File path") + .with_initial_text(current) + .validate_with(|value: &String| { + if value.trim().is_empty() { + Err("value must not be empty".to_owned()) + } else { + Ok(()) + } + }) + .interact_text() + .map_err(prompt_error)?; + document.set_sink_string(index, "path", value) +} + +fn edit_sink_enum( + theme: &ColorfulTheme, + document: &mut ConfigDocument, + index: usize, + key: &str, + values: &[&str], +) -> Result<(), CliError> { + let configured = document.sink_has_key(index, key)?; + match choose_action(theme, configured)? { + 0 => { + let default = document + .sink_string(index, key) + .as_deref() + .and_then(|current| values.iter().position(|value| *value == current)) + .unwrap_or(0); + let selected = Select::with_theme(theme) + .with_prompt("Value") + .items(values) + .default(default) + .interact() + .map_err(prompt_error)?; + document.set_sink_enum(index, key, values[selected], values)?; + } + 1 if configured => document.clear_sink_key(index, key)?, + _ => {} + } + Ok(()) +} + +fn edit_sink_queue_capacity( + theme: &ColorfulTheme, + document: &mut ConfigDocument, + index: usize, +) -> Result<(), CliError> { + let configured = document.sink_has_key(index, "queue_capacity")?; + match choose_action(theme, configured)? { + 0 => { + let value = prompt_u64( + theme, + "Queue entries", + document.sink_integer(index, "queue_capacity"), + )?; + document.set_sink_queue_capacity(index, value)?; + } + 1 if configured => document.clear_sink_key(index, "queue_capacity")?, + _ => {} + } + Ok(()) +} + +fn edit_sink_rotation( + theme: &ColorfulTheme, + document: &mut ConfigDocument, + index: usize, +) -> Result<(), CliError> { + let configured = document.sink_has_key(index, "max_file_size_bytes")? + || document.sink_has_key(index, "retained_files")?; + match choose_action(theme, configured)? { + 0 => { + let size = prompt_u64( + theme, + "Maximum file size in bytes", + document.sink_integer(index, "max_file_size_bytes"), + )?; + let retained = prompt_u64( + theme, + "Retained backup files", + document.sink_integer(index, "retained_files"), + )?; + document.set_sink_rotation(index, size, retained)?; + } + 1 if configured => document.clear_sink_rotation(index)?, + _ => {} + } + Ok(()) +} + +fn prompt_u64(theme: &ColorfulTheme, prompt: &str, current: Option) -> Result { + let mut input = Input::::with_theme(theme).with_prompt(prompt); + if let Some(current) = current { + input = input.with_initial_text(current.to_string()); + } + input.interact_text().map_err(prompt_error) +} + +fn prompt_error(error: dialoguer::Error) -> CliError { + CliError::Config(format!("configuration edit error: {error}")) +} + +fn print_preview(document: &ConfigDocument) { + println!(); + println!(" ─── Preview ─────────────────────────────────────────────"); + for line in document.preview().lines() { + println!(" {line}"); + } + println!(); +} diff --git a/crates/cli/src/commands/configure/wizard.rs b/crates/cli/src/commands/configure/wizard.rs index 7a26335d6..3b3a418d3 100644 --- a/crates/cli/src/commands/configure/wizard.rs +++ b/crates/cli/src/commands/configure/wizard.rs @@ -3,90 +3,32 @@ //! First-run setup for `nemo-relay` configuration. //! -//! 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. +//! Coordinates first-run setup while terminal-only interaction lives in `wizard/prompt.rs`. -use std::io::IsTerminal; use std::path::PathBuf; -use dialoguer::theme::ColorfulTheme; -use dialoguer::{Confirm, MultiSelect, Select}; +#[cfg(test)] use toml_edit::DocumentMut; +#[cfg(test)] use self::model::{ - ConfigScope, SetupAnswers, agent_key_and_command, build_config, detect_installed_agents, - home_dir, plugins_edit_command_for_scope, plugins_resume_command, preview_paths, - read_existing_defaults, save_config, + ConfigScope, build_config, plugins_edit_command_for_scope, plugins_resume_command, + preview_paths, save_config, }; use super::model; use crate::agents::CodingAgent; use crate::error::CliError; #[cfg(test)] -use self::model::{Defaults, global_config_dir, read_agents_from_doc, reset, write_or_merge}; +use self::model::{ + Defaults, SetupAnswers, global_config_dir, read_agents_from_doc, read_existing_defaults, reset, + write_or_merge, +}; #[cfg(test)] use self::model::detect_installed_agents_in; -/// -/// When `agent_hint` is `Some`, the agent multi-select is skipped — the user already declared -/// intent by typing `nemo-relay claude` (or another agent name), so respect that and only ask -/// scope and agents. To set up multiple agents, the user re-runs `nemo-relay config` later. -pub(crate) fn prompt_user( - detected_agents: &[CodingAgent], - agent_hint: Option, -) -> Result { - ensure_tty()?; - let defaults = read_existing_defaults().unwrap_or_default(); - crate::banner::print_intro(); - match agent_hint { - Some(agent) => { - let (name, _) = agent_key_and_command(agent); - println!(" Setting up {name}."); - println!(" Re-run `nemo-relay config` later to configure additional agents."); - } - None => { - println!(" Let's set up your coding agent."); - println!(" This runs once. Re-run later with `nemo-relay config`."); - } - } - // Only print the detected-agents listing for the unscoped wizard (`nemo-relay config`), - // where the user is about to pick from the multi-select. When the agent was already chosen - // via the easy-path shortcut (`nemo-relay codex`), listing the other two agents is noise. - if agent_hint.is_none() { - println!(); - print_detected_agents(detected_agents); - } - if defaults.has_any() { - println!(); - println!(" Existing config detected — current values are pre-selected."); - } - println!(); - // Keybinding hint shown once: dialoguer's MultiSelect needs SPACE to toggle and ENTER to - // confirm, but doesn't surface that itself. Without this line, users hit Enter expecting - // to check a box and the prompt confirms with the wrong selection. - println!( - " Tip: ↑/↓ to move, SPACE to toggle a checkbox, ENTER to confirm. Defaults are pre-selected." - ); - println!(); - - let theme = ColorfulTheme::default(); - let scope = ask_scope(&theme, defaults.scope)?; - let agents = match agent_hint { - Some(agent) => vec![agent], - None => ask_agents(&theme, detected_agents, &defaults.agents)?, - }; - if agents.contains(&CodingAgent::Codex) { - print_codex_api_key_guide(); - } - - Ok(SetupAnswers { scope, agents }) -} +mod prompt; /// Top-level setup entry point used by `nemo-relay config` and the easy-path fallback. /// Detects agents, prompts the user, writes the config, prints a final summary. @@ -98,77 +40,7 @@ pub(crate) async fn run( agent_hint: Option, explicit_plugin_path: Option, ) -> Result<(), CliError> { - let detected = detect_installed_agents(); - let 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()) - })?; - let doc = build_config(&answers); - let preview_paths = preview_paths(answers.scope, &cwd, &home); - - if !confirm_summary(&preview_paths, &doc)? { - return Err(CliError::Config("setup cancelled — no config saved".into())); - } - - let written = save_config(&doc, answers.scope, &cwd, &home, agent_hint)?; - println!(); - println!(" ✓ Saved:"); - for path in &written { - println!(" {}", path.display()); - } - println!(); - continue_to_plugins(answers.scope, explicit_plugin_path) -} - -/// After the base config is saved, offers to continue into plugin configuration in-process. -/// -/// Prompts once. On acceptance it runs the existing plugin editor targeting an explicit runtime -/// plugin path when present, otherwise the scope derived from base setup (project for -/// `Project`/`Both`, user for `Global`). On decline it reports that the base config was saved, -/// that plugin setup was skipped, and prints the command to resume later. Prompt interruption is -/// treated as a skip; other prompt or editor failures surface an error that makes clear the base -/// config remains saved. The saved `config.toml` is never rolled back here. -fn continue_to_plugins( - scope: ConfigScope, - explicit_plugin_path: Option, -) -> Result<(), CliError> { - let resume_command = plugins_resume_command(scope, explicit_plugin_path.as_deref()); - let proceed = match Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt("Configure Relay plugins now?") - .default(true) - .interact() - { - Ok(proceed) => proceed, - Err(error) if plugin_prompt_was_interrupted(&error) => { - print_plugins_skipped(&resume_command); - return Ok(()); - } - Err(error) => { - return Err(CliError::Config(format!( - "plugin setup did not complete; base configuration remains saved. \ - Resume with `{}`. Cause: {error}", - resume_command - ))); - } - }; - if !proceed { - print_plugins_skipped(&resume_command); - return Ok(()); - } - let result = crate::plugins::edit(plugins_edit_command_for_scope(scope, explicit_plugin_path)); - result.map_err(|error| { - let cause = match error { - CliError::Config(message) => message, - other => other.to_string(), - }; - CliError::Config(format!( - "plugin setup did not complete; base configuration remains saved. \ - Resume with `{}`. Cause: {cause}", - resume_command - )) - }) + prompt::run(agent_hint, explicit_plugin_path).await } fn plugin_prompt_was_interrupted(error: &dialoguer::Error) -> bool { @@ -182,151 +54,6 @@ fn plugin_prompt_was_interrupted(error: &dialoguer::Error) -> bool { ) } -fn print_plugins_skipped(resume_command: &str) { - println!(); - println!(" Base configuration saved. Plugin configuration skipped."); - println!(" Configure plugins later with `{resume_command}`."); - println!(); -} - -fn print_codex_api_key_guide() { - // Codex supports two auth flows (see `codex-rs/login/src/auth/manager.rs`): - // 1. ChatGPT-Plus PKCE OAuth via `codex --login` → tokens stored in `~/.codex/auth.json` - // 2. OpenAI API key via `OPENAI_API_KEY` env var - // The gateway routes to the correct upstream automatically: ChatGPT OAuth goes to - // `chatgpt.com/backend-api/codex`, API key goes to `api.openai.com`. - println!(); - println!(" ℹ Codex sends Responses-API requests through the gateway."); - println!(" Authentication (pick one):"); - println!(" • ChatGPT-Plus login: codex --login (uses ~/.codex/auth.json)"); - println!(" • OpenAI API key: export OPENAI_API_KEY=sk-..."); - println!(" When OPENAI_API_KEY is set the gateway uses it; otherwise the"); - println!(" ChatGPT-Plus OAuth token is forwarded to the ChatGPT backend."); - println!(); -} - -fn ensure_tty() -> Result<(), CliError> { - if !std::io::stdin().is_terminal() { - return Err(CliError::Config( - "interactive setup requires a TTY; pass `--config ` or set up \ - `.nemo-relay/config.toml` manually" - .into(), - )); - } - Ok(()) -} - -fn print_detected_agents(detected: &[CodingAgent]) { - println!(" Detected agents on $PATH:"); - for agent in detected { - let (name, _) = agent_key_and_command(*agent); - println!(" ✓ {name}"); - } - if detected.is_empty() { - println!(" (none — you can still add agents later)"); - } -} - -fn ask_scope( - theme: &ColorfulTheme, - existing: Option, -) -> Result { - let options = [ConfigScope::Project, ConfigScope::Global, ConfigScope::Both]; - let labels: Vec<&str> = options.iter().map(|s| s.label()).collect(); - // Start on the user's existing scope if there is one (so re-running the wizard doesn't - // accidentally relocate their config), else `Project` per the design default. - let default_idx = existing - .and_then(|s| options.iter().position(|opt| *opt == s)) - .unwrap_or(0); - let idx = Select::with_theme(theme) - .with_prompt("Save config where?") - .items(&labels) - .default(default_idx) - .interact() - .map_err(setup_error)?; - Ok(options[idx]) -} - -fn ask_agents( - theme: &ColorfulTheme, - detected: &[CodingAgent], - configured: &[CodingAgent], -) -> Result, CliError> { - let all_supported = [ - CodingAgent::ClaudeCode, - CodingAgent::Codex, - CodingAgent::Hermes, - ]; - let labels: Vec = all_supported - .iter() - .map(|a| { - let (name, _) = agent_key_and_command(*a); - name.to_string() - }) - .collect(); - // Pre-check: union of "already in the existing config" and "detected on $PATH". The existing - // entries take precedence — if the user previously deselected an agent that's on PATH, we - // shouldn't re-check it for them. On first run (no existing config), this falls back to - // pre-checking everything detected. - let defaults: Vec = if configured.is_empty() { - all_supported.iter().map(|a| detected.contains(a)).collect() - } else { - all_supported - .iter() - .map(|a| configured.contains(a)) - .collect() - }; - let selected_idx = MultiSelect::with_theme(theme) - .with_prompt("Which agents to observe?") - .items(&labels) - .defaults(&defaults) - .interact() - .map_err(setup_error)?; - Ok(selected_idx.into_iter().map(|i| all_supported[i]).collect()) -} - -/// Confirms the summary with the user before writing the file. Returns true if the user accepted. -/// Shows both the destination path(s) and the exact TOML body about to be written so the user -/// can verify what they're committing to instead of confirming a path blind. -pub(crate) fn confirm_summary( - written_paths: &[PathBuf], - doc: &DocumentMut, -) -> Result { - println!(); - println!(" ─── Summary ─────────────────────────────────────────────"); - println!(" Will write to:"); - for path in written_paths { - println!(" {}", path.display()); - } - println!(); - println!(" Contents:"); - for line in doc.to_string().lines() { - println!(" {line}"); - } - println!(); - Confirm::with_theme(&ColorfulTheme::default()) - .with_prompt("Looks good?") - .default(true) - .interact() - .map_err(setup_error) -} - -fn setup_error(err: dialoguer::Error) -> CliError { - // dialoguer errors are mostly IO. Translate cancellation (Ctrl-C, EOF on stdin) into a - // friendly "cancelled" message; surface anything else as the raw error. - match err { - dialoguer::Error::IO(io_err) - if matches!( - io_err.kind(), - std::io::ErrorKind::Interrupted | std::io::ErrorKind::UnexpectedEof - ) => - { - CliError::Config("setup cancelled — no config saved".into()) - } - other => CliError::Config(format!("setup error: {other}")), - } -} - #[cfg(test)] #[path = "../../../tests/coverage/shared/setup_tests.rs"] mod tests; diff --git a/crates/cli/src/commands/configure/wizard/prompt.rs b/crates/cli/src/commands/configure/wizard/prompt.rs new file mode 100644 index 000000000..4c9d01192 --- /dev/null +++ b/crates/cli/src/commands/configure/wizard/prompt.rs @@ -0,0 +1,299 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Terminal-only prompt adapter for first-run configuration. + +use std::io::IsTerminal; +use std::path::PathBuf; + +use dialoguer::theme::ColorfulTheme; +use dialoguer::{Confirm, MultiSelect, Select}; +use toml_edit::DocumentMut; + +use super::model::{ + ConfigScope, SetupAnswers, agent_key_and_command, build_config, detect_installed_agents, + home_dir, plugins_edit_command_for_scope, plugins_resume_command, preview_paths, + read_existing_defaults, save_config, +}; +use crate::agents::CodingAgent; +use crate::error::CliError; + +/// Prompts for the configuration scope and agents selected by the user. +/// +/// When `agent_hint` is present, the agent picker is skipped because the command already +/// identified the requested agent. +pub(crate) fn prompt_user( + detected_agents: &[CodingAgent], + agent_hint: Option, +) -> Result { + ensure_tty()?; + let defaults = read_existing_defaults().unwrap_or_default(); + crate::banner::print_intro(); + match agent_hint { + Some(agent) => { + let (name, _) = agent_key_and_command(agent); + println!(" Setting up {name}."); + println!(" Re-run `nemo-relay config` later to configure additional agents."); + } + None => { + println!(" Let's set up your coding agent."); + println!(" This runs once. Re-run later with `nemo-relay config`."); + } + } + // Only print the detected-agents listing for the unscoped wizard (`nemo-relay config`), + // where the user is about to pick from the multi-select. When the agent was already chosen + // via the easy-path shortcut (`nemo-relay codex`), listing the other two agents is noise. + if agent_hint.is_none() { + println!(); + print_detected_agents(detected_agents); + } + if defaults.has_any() { + println!(); + println!(" Existing config detected — current values are pre-selected."); + } + println!(); + // Keybinding hint shown once: dialoguer's MultiSelect needs SPACE to toggle and ENTER to + // confirm, but doesn't surface that itself. Without this line, users hit Enter expecting + // to check a box and the prompt confirms with the wrong selection. + println!( + " Tip: ↑/↓ to move, SPACE to toggle a checkbox, ENTER to confirm. Defaults are pre-selected." + ); + println!(); + + let theme = ColorfulTheme::default(); + let scope = ask_scope(&theme, defaults.scope)?; + let agents = match agent_hint { + Some(agent) => vec![agent], + None => ask_agents(&theme, detected_agents, &defaults.agents)?, + }; + if agents.contains(&CodingAgent::Codex) { + print_codex_api_key_guide(); + } + + Ok(SetupAnswers { scope, agents }) +} + +pub(super) async fn run( + agent_hint: Option, + explicit_plugin_path: Option, +) -> Result<(), CliError> { + let detected = detect_installed_agents(); + let 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()) + })?; + let doc = build_config(&answers); + let preview_paths = preview_paths(answers.scope, &cwd, &home); + + if !confirm_summary(&preview_paths, &doc)? { + return Err(CliError::Config("setup cancelled — no config saved".into())); + } + + let written = save_config(&doc, answers.scope, &cwd, &home, agent_hint)?; + println!(); + println!(" ✓ Saved:"); + for path in &written { + println!(" {}", path.display()); + } + println!(); + continue_to_plugins(answers.scope, explicit_plugin_path) +} + +/// After the base config is saved, offers to continue into plugin configuration in-process. +/// +/// Prompts once. On acceptance it runs the existing plugin editor targeting an explicit runtime +/// plugin path when present, otherwise the scope derived from base setup (project for +/// `Project`/`Both`, user for `Global`). On decline it reports that the base config was saved, +/// that plugin setup was skipped, and prints the command to resume later. Prompt interruption is +/// treated as a skip; other prompt or editor failures surface an error that makes clear the base +/// config remains saved. The saved `config.toml` is never rolled back here. +fn continue_to_plugins( + scope: ConfigScope, + explicit_plugin_path: Option, +) -> Result<(), CliError> { + let resume_command = plugins_resume_command(scope, explicit_plugin_path.as_deref()); + let proceed = match confirm_plugin_setup() { + Ok(proceed) => proceed, + Err(error) if super::plugin_prompt_was_interrupted(&error) => { + print_plugins_skipped(&resume_command); + return Ok(()); + } + Err(error) => { + return Err(CliError::Config(format!( + "plugin setup did not complete; base configuration remains saved. \ + Resume with `{}`. Cause: {error}", + resume_command + ))); + } + }; + if !proceed { + print_plugins_skipped(&resume_command); + return Ok(()); + } + let result = crate::plugins::edit(plugins_edit_command_for_scope(scope, explicit_plugin_path)); + result.map_err(|error| { + let cause = match error { + CliError::Config(message) => message, + other => other.to_string(), + }; + CliError::Config(format!( + "plugin setup did not complete; base configuration remains saved. \ + Resume with `{}`. Cause: {cause}", + resume_command + )) + }) +} + +pub(super) fn confirm_plugin_setup() -> Result { + Confirm::with_theme(&ColorfulTheme::default()) + .with_prompt("Configure Relay plugins now?") + .default(true) + .interact() +} + +pub(super) fn print_plugins_skipped(resume_command: &str) { + println!(); + println!(" Base configuration saved. Plugin configuration skipped."); + println!(" Configure plugins later with `{resume_command}`."); + println!(); +} + +fn print_codex_api_key_guide() { + // Codex supports two auth flows (see `codex-rs/login/src/auth/manager.rs`): + // 1. ChatGPT-Plus PKCE OAuth via `codex --login` → tokens stored in `~/.codex/auth.json` + // 2. OpenAI API key via `OPENAI_API_KEY` env var + // The gateway routes to the correct upstream automatically: ChatGPT OAuth goes to + // `chatgpt.com/backend-api/codex`, API key goes to `api.openai.com`. + println!(); + println!(" ℹ Codex sends Responses-API requests through the gateway."); + println!(" Authentication (pick one):"); + println!(" • ChatGPT-Plus login: codex --login (uses ~/.codex/auth.json)"); + println!(" • OpenAI API key: export OPENAI_API_KEY=sk-..."); + println!(" When OPENAI_API_KEY is set the gateway uses it; otherwise the"); + println!(" ChatGPT-Plus OAuth token is forwarded to the ChatGPT backend."); + println!(); +} + +fn ensure_tty() -> Result<(), CliError> { + if !std::io::stdin().is_terminal() { + return Err(CliError::Config( + "interactive setup requires a TTY; pass `--config ` or set up \ + `.nemo-relay/config.toml` manually" + .into(), + )); + } + Ok(()) +} + +fn print_detected_agents(detected: &[CodingAgent]) { + println!(" Detected agents on $PATH:"); + for agent in detected { + let (name, _) = agent_key_and_command(*agent); + println!(" ✓ {name}"); + } + if detected.is_empty() { + println!(" (none — you can still add agents later)"); + } +} + +fn ask_scope( + theme: &ColorfulTheme, + existing: Option, +) -> Result { + let options = [ConfigScope::Project, ConfigScope::Global, ConfigScope::Both]; + let labels: Vec<&str> = options.iter().map(|s| s.label()).collect(); + // Start on the user's existing scope if there is one (so re-running the wizard doesn't + // accidentally relocate their config), else `Project` per the design default. + let default_idx = existing + .and_then(|s| options.iter().position(|opt| *opt == s)) + .unwrap_or(0); + let idx = Select::with_theme(theme) + .with_prompt("Save config where?") + .items(&labels) + .default(default_idx) + .interact() + .map_err(setup_error)?; + Ok(options[idx]) +} + +fn ask_agents( + theme: &ColorfulTheme, + detected: &[CodingAgent], + configured: &[CodingAgent], +) -> Result, CliError> { + let all_supported = [ + CodingAgent::ClaudeCode, + CodingAgent::Codex, + CodingAgent::Hermes, + ]; + let labels: Vec = all_supported + .iter() + .map(|a| { + let (name, _) = agent_key_and_command(*a); + name.to_string() + }) + .collect(); + // Pre-check: union of "already in the existing config" and "detected on $PATH". The existing + // entries take precedence — if the user previously deselected an agent that's on PATH, we + // shouldn't re-check it for them. On first run (no existing config), this falls back to + // pre-checking everything detected. + let defaults: Vec = if configured.is_empty() { + all_supported.iter().map(|a| detected.contains(a)).collect() + } else { + all_supported + .iter() + .map(|a| configured.contains(a)) + .collect() + }; + let selected_idx = MultiSelect::with_theme(theme) + .with_prompt("Which agents to observe?") + .items(&labels) + .defaults(&defaults) + .interact() + .map_err(setup_error)?; + Ok(selected_idx.into_iter().map(|i| all_supported[i]).collect()) +} + +/// Confirms the summary with the user before writing the file. Returns true if the user accepted. +/// Shows both the destination path(s) and the exact TOML body about to be written so the user +/// can verify what they're committing to instead of confirming a path blind. +pub(crate) fn confirm_summary( + written_paths: &[PathBuf], + doc: &DocumentMut, +) -> Result { + println!(); + println!(" ─── Summary ─────────────────────────────────────────────"); + println!(" Will write to:"); + for path in written_paths { + println!(" {}", path.display()); + } + println!(); + println!(" Contents:"); + for line in doc.to_string().lines() { + println!(" {line}"); + } + println!(); + Confirm::with_theme(&ColorfulTheme::default()) + .with_prompt("Looks good?") + .default(true) + .interact() + .map_err(setup_error) +} + +fn setup_error(err: dialoguer::Error) -> CliError { + // dialoguer errors are mostly IO. Translate cancellation (Ctrl-C, EOF on stdin) into a + // friendly "cancelled" message; surface anything else as the raw error. + match err { + dialoguer::Error::IO(io_err) + if matches!( + io_err.kind(), + std::io::ErrorKind::Interrupted | std::io::ErrorKind::UnexpectedEof + ) => + { + CliError::Config("setup cancelled — no config saved".into()) + } + other => CliError::Config(format!("setup error: {other}")), + } +} diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index 1a5e158f8..8ce414493 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -53,55 +53,58 @@ pub(crate) async fn run(bootstrap_shutdown_token: Option) -> ExitCode { // Dispatches CLI subcommands while keeping the no-subcommand path as server mode. `run` inherits // top-level server flags so transparent launch can share config parsing with daemon startup. -async fn dispatch(bootstrap_shutdown_token: Option) -> Result { - let cli = Cli::parse(); - let command_name = cli - .command - .as_ref() - .map(Command::log_name) - .unwrap_or("default"); - - let initialize_logging = match cli.command.as_ref() { +fn configure_logging( + cli: &Cli, +) -> Result, error::CliError> { + let initialize = match cli.command.as_ref() { Some(command) => !command.skips_logging(), None => { cli.server.to_runtime().requested_daemon_mode() || runtime_configuration::any_config_file_exists() } }; - let _logging = if initialize_logging { - let user_only = matches!(cli.command.as_ref(), Some(Command::Mcp)); - let explicit_config = if user_only { - None - } else { - match cli.command.as_ref() { - Some(Command::Run(command)) => { - command.config.as_deref().or(cli.server.config.as_deref()) - } - _ => cli.server.config.as_deref(), - } - }; - let mut logging_fallback_error = None; - let config = match cli.logging.resolve(explicit_config, user_only) { - Ok(config) => config, - Err(error) if matches!(cli.command.as_ref(), Some(Command::Doctor(_))) => { - logging_fallback_error = Some(error); - nemo_relay::logging::LoggingConfig::default() - } - Err(error) => return Err(error), - }; - let runtime = nemo_relay::logging::LoggingRuntime::configure(config)?; - if let Some(error) = logging_fallback_error { - log::warn!( - target: "nemo_relay.cli", - event = "doctor_logging_fallback", - error_kind = error.log_kind(); - "Doctor fell back to default logging after resolution failure" - ); + if !initialize { + return Ok(None); + } + + let user_only = matches!(cli.command.as_ref(), Some(Command::Mcp)); + let explicit_config = match (user_only, cli.command.as_ref()) { + (true, _) => None, + (false, Some(Command::Run(command))) => { + command.config.as_deref().or(cli.server.config.as_deref()) } - Some(runtime) - } else { - None + (false, _) => cli.server.config.as_deref(), }; + let mut fallback_error = None; + let config = match cli.logging.resolve(explicit_config, user_only) { + Ok(config) => config, + Err(error) if matches!(cli.command.as_ref(), Some(Command::Doctor(_))) => { + fallback_error = Some(error); + nemo_relay::logging::LoggingConfig::default() + } + Err(error) => return Err(error), + }; + let runtime = nemo_relay::logging::LoggingRuntime::configure(config)?; + if let Some(error) = fallback_error { + log::warn!( + target: "nemo_relay.cli", + event = "doctor_logging_fallback", + error_kind = error.log_kind(); + "Doctor fell back to default logging after resolution failure" + ); + } + Ok(Some(runtime)) +} + +async fn dispatch(bootstrap_shutdown_token: Option) -> Result { + let cli = Cli::parse(); + let command_name = cli + .command + .as_ref() + .map(Command::log_name) + .unwrap_or("default"); + + let _logging = configure_logging(&cli)?; log::info!( target: "nemo_relay.cli", diff --git a/crates/cli/src/plugins/dynamic_editor.rs b/crates/cli/src/plugins/dynamic_editor.rs index e31df9673..1d04a50e6 100644 --- a/crates/cli/src/plugins/dynamic_editor.rs +++ b/crates/cli/src/plugins/dynamic_editor.rs @@ -6,9 +6,8 @@ use std::collections::HashSet; use dialoguer::theme::ColorfulTheme; -use dialoguer::{Input, Password, Select}; use nemo_relay::plugin::dynamic::DynamicPluginManifest; -use serde_json::{Map, Number, Value}; +use serde_json::{Map, Value}; use crate::error::CliError; @@ -23,6 +22,8 @@ use super::{ const REDACTED: &str = ""; +mod prompt; + #[derive(Debug)] pub(super) struct DynamicPluginEditorState { document_index: usize, @@ -149,6 +150,11 @@ impl DynamicPluginEditorState { .unwrap_or_default() } + #[cfg(test)] + pub(super) fn editor_fields(&self) -> &[DynamicConfigField] { + self.schema.as_ref().map_or(&[], |schema| schema.fields()) + } + #[cfg(test)] pub(super) fn reset_top_level_field(&mut self, key: &str) -> Result<(), CliError> { let field = self @@ -354,7 +360,7 @@ fn load_config_schema( } #[derive(Debug, Clone, Copy)] -enum DynamicMenuAction { +pub(super) enum DynamicMenuAction { EditField(usize), EditRawConfig, ResetPlugin, @@ -365,45 +371,10 @@ pub(super) fn edit_dynamic_plugin( theme: &ColorfulTheme, state: &mut DynamicPluginEditorState, ) -> Result<(), CliError> { - if let Some(description) = &state.description { - println!(" {}", super::single_line_text(description)); - } - let fields = state - .schema - .as_ref() - .map(|schema| schema.fields().to_vec()) - .unwrap_or_default(); - if state.schema.is_none() || fields.is_empty() { - edit_dynamic_root_menu(theme, state, &fields) - } else { - let prompt = state - .editor_title - .clone() - .unwrap_or_else(|| state.label.clone()); - edit_dynamic_fields_menu(theme, state, &fields, &[], prompt) - } + prompt::edit_dynamic_plugin(theme, state) } -fn edit_dynamic_root_menu( - theme: &ColorfulTheme, - state: &mut DynamicPluginEditorState, - fields: &[DynamicConfigField], -) -> Result<(), CliError> { - let mut selected_index = 0; - loop { - let (items, actions) = dynamic_root_menu_items(state, fields); - - let selection = prompt_menu(theme, state.label(), &items, selected_index)?; - if let Some(selected) = menu_response_index(&selection) { - selected_index = selected; - } - if handle_dynamic_root_menu_response(theme, state, &actions, selection)? { - return Ok(()); - } - } -} - -fn dynamic_root_menu_items( +pub(super) fn dynamic_root_menu_items( state: &DynamicPluginEditorState, fields: &[DynamicConfigField], ) -> (Vec, Vec) { @@ -426,96 +397,7 @@ fn dynamic_root_menu_items( (items, actions) } -fn handle_dynamic_root_menu_response( - theme: &ColorfulTheme, - state: &mut DynamicPluginEditorState, - actions: &[DynamicMenuAction], - selection: MenuResponse, -) -> Result { - match selection { - MenuResponse::Selected(selected) => match actions.get(selected).copied() { - Some(DynamicMenuAction::EditRawConfig) => { - prompt_raw_config(theme, state)?; - Ok(false) - } - Some(DynamicMenuAction::ResetPlugin) => { - state.reset(); - Ok(false) - } - Some(DynamicMenuAction::Back) | None => Ok(true), - Some(DynamicMenuAction::EditField(_)) => { - println!(" Select Edit raw configuration to modify settings."); - Ok(false) - } - }, - MenuResponse::Shortcut(MenuShortcut::Reset, selected) => { - if matches!(actions.get(selected), Some(DynamicMenuAction::ResetPlugin)) { - state.reset(); - } else { - println!(" Select Reset plugin configuration to remove config."); - } - Ok(false) - } - MenuResponse::Shortcut(MenuShortcut::Clear, selected) => { - if matches!( - actions.get(selected), - Some(DynamicMenuAction::EditRawConfig) - ) { - state.set_raw_config(Map::new()); - } - Ok(false) - } - MenuResponse::Shortcut(MenuShortcut::Help, _) => { - super::print_editor_help(); - Ok(false) - } - MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { - println!(" Preview and save are available from the main plugins.toml menu."); - Ok(false) - } - MenuResponse::Cancel => Ok(true), - } -} - -fn edit_dynamic_fields_menu( - theme: &ColorfulTheme, - state: &mut DynamicPluginEditorState, - fields: &[DynamicConfigField], - parent_path: &[String], - prompt: String, -) -> Result<(), CliError> { - let mut selected_index = 0; - loop { - let (items, actions) = dynamic_field_menu_items(state, fields, parent_path); - let selection = prompt_menu(theme, &prompt, &items, selected_index)?; - if let Some(selected) = menu_response_index(&selection) { - selected_index = selected; - } - match selection { - MenuResponse::Selected(selected) => match actions.get(selected).copied() { - Some(DynamicMenuAction::EditField(index)) => { - edit_dynamic_field(theme, state, &fields[index], parent_path)?; - } - Some(DynamicMenuAction::ResetPlugin) => state.reset(), - Some(DynamicMenuAction::Back) | None => return Ok(()), - Some(DynamicMenuAction::EditRawConfig) => unreachable!(), - }, - MenuResponse::Shortcut(MenuShortcut::Reset, selected) => { - reset_dynamic_selection(state, fields, parent_path, &actions, selected); - } - MenuResponse::Shortcut(MenuShortcut::Clear, selected) => { - clear_dynamic_selection(state, fields, parent_path, &actions, selected); - } - MenuResponse::Shortcut(MenuShortcut::Help, _) => super::print_editor_help(), - MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { - println!(" Preview and save are available from the main plugins.toml menu."); - } - MenuResponse::Cancel => return Ok(()), - } - } -} - -fn dynamic_field_menu_items( +pub(super) fn dynamic_field_menu_items( state: &DynamicPluginEditorState, fields: &[DynamicConfigField], parent_path: &[String], @@ -558,265 +440,7 @@ fn dynamic_field_menu_items( (items, actions) } -fn edit_dynamic_field( - theme: &ColorfulTheme, - state: &mut DynamicPluginEditorState, - field: &DynamicConfigField, - parent_path: &[String], -) -> Result<(), CliError> { - if let Some(description) = &field.description { - println!(" {}", super::single_line_text(description)); - } - let path = field_path(parent_path, field); - if let DynamicConfigFieldKind::Object { fields } = &field.kind { - return edit_dynamic_fields_menu(theme, state, fields, &path, field.title.clone()); - } - if let Some(value) = prompt_dynamic_value(theme, state, field, &path)? { - state.set_field(&path, value); - } - Ok(()) -} - -fn prompt_dynamic_value( - theme: &ColorfulTheme, - state: &DynamicPluginEditorState, - field: &DynamicConfigField, - path: &[String], -) -> Result, CliError> { - let current = state.field_value(path); - match &field.kind { - DynamicConfigFieldKind::Boolean => { - let values = ["false", "true"]; - let default = current - .and_then(Value::as_bool) - .or_else(|| field.default.as_ref().and_then(Value::as_bool)) - .map(usize::from) - .unwrap_or(0); - let selected = Select::with_theme(theme) - .with_prompt(super::single_line_text(&field.title)) - .items(&values) - .default(default) - .interact() - .map_err(editor_error)?; - Ok(Some(Value::Bool(selected == 1))) - } - DynamicConfigFieldKind::String { secret } => { - prompt_dynamic_string(theme, field, current, *secret, None) - } - DynamicConfigFieldKind::StringEnum { options, secret } => { - if *secret { - prompt_dynamic_string(theme, field, current, true, Some(options)) - } else { - let default = current - .and_then(Value::as_str) - .or_else(|| field.default.as_ref().and_then(Value::as_str)) - .and_then(|value| options.iter().position(|option| option == value)) - .unwrap_or(0); - let selected = Select::with_theme(theme) - .with_prompt(super::single_line_text(&field.title)) - .items(options) - .default(default) - .interact() - .map_err(editor_error)?; - Ok(Some(Value::String(options[selected].clone()))) - } - } - DynamicConfigFieldKind::Integer => { - let initial = current - .or(field.default.as_ref()) - .map(json_text) - .unwrap_or_default(); - let value: String = Input::with_theme(theme) - .with_prompt(super::single_line_text(&field.title)) - .with_initial_text(initial) - .interact_text() - .map_err(editor_error)?; - let value = value.trim().parse::().map_err(|error| { - CliError::Config(format!("{} must be an integer: {error}", field.key)) - })?; - Ok(Some(Value::Number(value.into()))) - } - DynamicConfigFieldKind::Number => { - let initial = current - .or(field.default.as_ref()) - .map(json_text) - .unwrap_or_default(); - let value: String = Input::with_theme(theme) - .with_prompt(super::single_line_text(&field.title)) - .with_initial_text(initial) - .interact_text() - .map_err(editor_error)?; - let parsed = value.trim().parse::().map_err(|error| { - CliError::Config(format!("{} must be a number: {error}", field.key)) - })?; - let number = Number::from_f64(parsed).ok_or_else(|| { - CliError::Config(format!("{} must be a finite number", field.key)) - })?; - Ok(Some(Value::Number(number))) - } - DynamicConfigFieldKind::StringMap => { - let (current, redacted_config, secrets, hidden) = state.field_value_for_raw_edit(path); - let Some(value) = prompt_json_value( - theme, - field, - current.as_ref(), - Value::Object(Map::new()), - hidden, - )? - else { - return Ok(None); - }; - let value = state.restore_raw_field_edit(path, value, redacted_config, &secrets)?; - let object = value - .as_object() - .ok_or_else(|| CliError::Config(format!("{} must be a JSON object", field.key)))?; - if object.values().any(|value| !value.is_string()) { - return Err(CliError::Config(format!( - "{} must contain only string values", - field.key - ))); - } - Ok(Some(value)) - } - DynamicConfigFieldKind::RawJson => { - let fallback = field.default.clone().unwrap_or(Value::Null); - let (current, redacted_config, secrets, hidden) = state.field_value_for_raw_edit(path); - let Some(value) = prompt_json_value(theme, field, current.as_ref(), fallback, hidden)? - else { - return Ok(None); - }; - let value = state.restore_raw_field_edit(path, value, redacted_config, &secrets)?; - Ok(Some(value)) - } - DynamicConfigFieldKind::Object { .. } => unreachable!(), - } -} - -fn prompt_dynamic_string( - theme: &ColorfulTheme, - field: &DynamicConfigField, - current: Option<&Value>, - secret: bool, - options: Option<&[String]>, -) -> Result, CliError> { - if secret { - let title = super::single_line_text(&field.title); - let value = Password::with_theme(theme) - .with_prompt(format!("New {} (blank preserves the current value)", title)) - .allow_empty_password(true) - .report(false) - .interact() - .map_err(editor_error)?; - if value.is_empty() { - return Ok(None); - } - if options.is_some_and(|options| !options.iter().any(|option| option == &value)) { - return Err(CliError::Config(format!( - "{} must be one of the schema enum values", - field.key - ))); - } - return Ok(Some(Value::String(value))); - } - let initial = current - .and_then(Value::as_str) - .or_else(|| field.default.as_ref().and_then(Value::as_str)) - .unwrap_or_default(); - let value: String = Input::with_theme(theme) - .with_prompt(super::single_line_text(&field.title)) - .with_initial_text(initial) - .interact_text() - .map_err(editor_error)?; - Ok(Some(Value::String(value))) -} - -fn prompt_json_value( - theme: &ColorfulTheme, - field: &DynamicConfigField, - current: Option<&Value>, - fallback: Value, - hidden: bool, -) -> Result, CliError> { - let initial = current.or(field.default.as_ref()).unwrap_or(&fallback); - let prompt = format!("{} as JSON", super::single_line_text(&field.title)); - let value = if hidden { - if current.is_some() { - println!(" Current redacted JSON: {}", json_text(initial)); - } - let value = Password::with_theme(theme) - .with_prompt(format!("New {prompt} (blank preserves the current value)")) - .allow_empty_password(true) - .report(false) - .interact() - .map_err(editor_error)?; - if value.is_empty() { - return Ok(None); - } - value - } else { - Input::with_theme(theme) - .with_prompt(prompt) - .with_initial_text(json_text(initial)) - .interact_text() - .map_err(editor_error)? - }; - serde_json::from_str(value.trim()) - .map_err(|error| CliError::Config(format!("invalid JSON for {}: {error}", field.key))) - .map(Some) -} - -fn prompt_raw_config( - theme: &ColorfulTheme, - state: &mut DynamicPluginEditorState, -) -> Result<(), CliError> { - let original = Value::Object(state.config.clone().unwrap_or_default()); - let (initial, secrets, hidden) = state - .schema - .as_ref() - .map(|schema| { - let (redacted, secrets) = schema.redact_for_edit(&original); - (redacted, secrets, schema.has_secrets()) - }) - .unwrap_or_else(|| (original, SecretEditValues::new(), false)); - let value = if hidden { - println!(" Current redacted JSON: {}", json_text(&initial)); - let value = Password::with_theme(theme) - .with_prompt("New configuration as JSON object (blank preserves the current value)") - .allow_empty_password(true) - .report(false) - .interact() - .map_err(editor_error)?; - if value.is_empty() { - return Ok(()); - } - value - } else { - Input::with_theme(theme) - .with_prompt("Configuration as JSON object") - .with_initial_text(json_text(&initial)) - .interact_text() - .map_err(editor_error)? - }; - let value: Value = serde_json::from_str(value.trim()) - .map_err(|error| CliError::Config(format!("invalid JSON configuration: {error}")))?; - let value = match &state.schema { - Some(schema) => schema.restore_edit_secrets(&value, &secrets)?, - None => value, - }; - let object = value.as_object().cloned().ok_or_else(|| { - CliError::Config(format!( - "dynamic plugin '{}' configuration must be a JSON object", - state.plugin_id - )) - })?; - if let Some(schema) = &state.schema { - schema.validate(&value)?; - } - state.set_raw_config(object); - Ok(()) -} - -fn reset_dynamic_selection( +pub(super) fn reset_dynamic_selection( state: &mut DynamicPluginEditorState, fields: &[DynamicConfigField], parent_path: &[String], @@ -833,7 +457,7 @@ fn reset_dynamic_selection( } } -fn clear_dynamic_selection( +pub(super) fn clear_dynamic_selection( state: &mut DynamicPluginEditorState, fields: &[DynamicConfigField], parent_path: &[String], @@ -848,7 +472,7 @@ fn clear_dynamic_selection( } } -fn field_path(parent_path: &[String], field: &DynamicConfigField) -> Vec { +pub(super) fn field_path(parent_path: &[String], field: &DynamicConfigField) -> Vec { let mut path = parent_path.to_vec(); path.push(field.key.clone()); path @@ -862,7 +486,10 @@ fn field_is_secret(field: &DynamicConfigField) -> bool { ) } -fn value_at_path<'a>(config: Option<&'a Map>, path: &[String]) -> Option<&'a Value> { +pub(super) fn value_at_path<'a>( + config: Option<&'a Map>, + path: &[String], +) -> Option<&'a Value> { let (first, rest) = path.split_first()?; let mut value = config?.get(first)?; for segment in rest { @@ -871,7 +498,11 @@ fn value_at_path<'a>(config: Option<&'a Map>, path: &[String]) -> Some(value) } -fn set_value_at_path(config: &mut Option>, path: &[String], value: Value) { +pub(super) fn set_value_at_path( + config: &mut Option>, + path: &[String], + value: Value, +) { let Some((last, parents)) = path.split_last() else { return; }; @@ -890,7 +521,7 @@ fn set_value_at_path(config: &mut Option>, path: &[String], v object.insert(last.clone(), value); } -fn remove_value_at_path(config: &mut Map, path: &[String]) -> bool { +pub(super) fn remove_value_at_path(config: &mut Map, path: &[String]) -> bool { let Some((first, rest)) = path.split_first() else { return config.is_empty(); }; diff --git a/crates/cli/src/plugins/dynamic_editor/prompt.rs b/crates/cli/src/plugins/dynamic_editor/prompt.rs new file mode 100644 index 000000000..ca43e99c6 --- /dev/null +++ b/crates/cli/src/plugins/dynamic_editor/prompt.rs @@ -0,0 +1,401 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Terminal-only prompt adapter for dynamic plugin configuration. + +use dialoguer::theme::ColorfulTheme; +use dialoguer::{Input, Password, Select}; +use serde_json::{Map, Number, Value}; + +use super::*; +use crate::error::CliError; +use crate::plugins::{print_editor_help, single_line_text}; + +pub(super) fn edit_dynamic_plugin( + theme: &ColorfulTheme, + state: &mut DynamicPluginEditorState, +) -> Result<(), CliError> { + if let Some(description) = &state.description { + println!(" {}", single_line_text(description)); + } + let fields = state + .schema + .as_ref() + .map(|schema| schema.fields().to_vec()) + .unwrap_or_default(); + if state.schema.is_none() || fields.is_empty() { + edit_dynamic_root_menu(theme, state, &fields) + } else { + let prompt = state + .editor_title + .clone() + .unwrap_or_else(|| state.label.clone()); + edit_dynamic_fields_menu(theme, state, &fields, &[], prompt) + } +} + +fn edit_dynamic_root_menu( + theme: &ColorfulTheme, + state: &mut DynamicPluginEditorState, + fields: &[DynamicConfigField], +) -> Result<(), CliError> { + let mut selected_index = 0; + loop { + let (items, actions) = dynamic_root_menu_items(state, fields); + + let selection = prompt_menu(theme, state.label(), &items, selected_index)?; + if let Some(selected) = menu_response_index(&selection) { + selected_index = selected; + } + if handle_dynamic_root_menu_response(theme, state, &actions, selection)? { + return Ok(()); + } + } +} + +fn handle_dynamic_root_menu_response( + theme: &ColorfulTheme, + state: &mut DynamicPluginEditorState, + actions: &[DynamicMenuAction], + selection: MenuResponse, +) -> Result { + match selection { + MenuResponse::Selected(selected) => match actions.get(selected).copied() { + Some(DynamicMenuAction::EditRawConfig) => { + prompt_raw_config(theme, state)?; + Ok(false) + } + Some(DynamicMenuAction::ResetPlugin) => { + state.reset(); + Ok(false) + } + Some(DynamicMenuAction::Back) | None => Ok(true), + Some(DynamicMenuAction::EditField(_)) => { + println!(" Select Edit raw configuration to modify settings."); + Ok(false) + } + }, + MenuResponse::Shortcut(MenuShortcut::Reset, selected) => { + if matches!(actions.get(selected), Some(DynamicMenuAction::ResetPlugin)) { + state.reset(); + } else { + println!(" Select Reset plugin configuration to remove config."); + } + Ok(false) + } + MenuResponse::Shortcut(MenuShortcut::Clear, selected) => { + if matches!( + actions.get(selected), + Some(DynamicMenuAction::EditRawConfig) + ) { + state.set_raw_config(Map::new()); + } + Ok(false) + } + MenuResponse::Shortcut(MenuShortcut::Help, _) => { + print_editor_help(); + Ok(false) + } + MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { + println!(" Preview and save are available from the main plugins.toml menu."); + Ok(false) + } + MenuResponse::Cancel => Ok(true), + } +} + +fn edit_dynamic_fields_menu( + theme: &ColorfulTheme, + state: &mut DynamicPluginEditorState, + fields: &[DynamicConfigField], + parent_path: &[String], + prompt: String, +) -> Result<(), CliError> { + let mut selected_index = 0; + loop { + let (items, actions) = dynamic_field_menu_items(state, fields, parent_path); + let selection = prompt_menu(theme, &prompt, &items, selected_index)?; + if let Some(selected) = menu_response_index(&selection) { + selected_index = selected; + } + match selection { + MenuResponse::Selected(selected) => match actions.get(selected).copied() { + Some(DynamicMenuAction::EditField(index)) => { + edit_dynamic_field(theme, state, &fields[index], parent_path)?; + } + Some(DynamicMenuAction::ResetPlugin) => state.reset(), + Some(DynamicMenuAction::Back) | None => return Ok(()), + Some(DynamicMenuAction::EditRawConfig) => unreachable!(), + }, + MenuResponse::Shortcut(MenuShortcut::Reset, selected) => { + reset_dynamic_selection(state, fields, parent_path, &actions, selected); + } + MenuResponse::Shortcut(MenuShortcut::Clear, selected) => { + clear_dynamic_selection(state, fields, parent_path, &actions, selected); + } + MenuResponse::Shortcut(MenuShortcut::Help, _) => print_editor_help(), + MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { + println!(" Preview and save are available from the main plugins.toml menu."); + } + MenuResponse::Cancel => return Ok(()), + } + } +} + +fn edit_dynamic_field( + theme: &ColorfulTheme, + state: &mut DynamicPluginEditorState, + field: &DynamicConfigField, + parent_path: &[String], +) -> Result<(), CliError> { + if let Some(description) = &field.description { + println!(" {}", single_line_text(description)); + } + let path = field_path(parent_path, field); + if let DynamicConfigFieldKind::Object { fields } = &field.kind { + return edit_dynamic_fields_menu(theme, state, fields, &path, field.title.clone()); + } + if let Some(value) = prompt_dynamic_value(theme, state, field, &path)? { + state.set_field(&path, value); + } + Ok(()) +} + +fn prompt_dynamic_value( + theme: &ColorfulTheme, + state: &DynamicPluginEditorState, + field: &DynamicConfigField, + path: &[String], +) -> Result, CliError> { + let current = state.field_value(path); + match &field.kind { + DynamicConfigFieldKind::Boolean => { + let values = ["false", "true"]; + let default = current + .and_then(Value::as_bool) + .or_else(|| field.default.as_ref().and_then(Value::as_bool)) + .map(usize::from) + .unwrap_or(0); + let selected = Select::with_theme(theme) + .with_prompt(single_line_text(&field.title)) + .items(&values) + .default(default) + .interact() + .map_err(editor_error)?; + Ok(Some(Value::Bool(selected == 1))) + } + DynamicConfigFieldKind::String { secret } => { + prompt_dynamic_string(theme, field, current, *secret, None) + } + DynamicConfigFieldKind::StringEnum { options, secret } => { + if *secret { + prompt_dynamic_string(theme, field, current, true, Some(options)) + } else { + let default = current + .and_then(Value::as_str) + .or_else(|| field.default.as_ref().and_then(Value::as_str)) + .and_then(|value| options.iter().position(|option| option == value)) + .unwrap_or(0); + let selected = Select::with_theme(theme) + .with_prompt(single_line_text(&field.title)) + .items(options) + .default(default) + .interact() + .map_err(editor_error)?; + Ok(Some(Value::String(options[selected].clone()))) + } + } + DynamicConfigFieldKind::Integer => { + let initial = current + .or(field.default.as_ref()) + .map(json_text) + .unwrap_or_default(); + let value: String = Input::with_theme(theme) + .with_prompt(single_line_text(&field.title)) + .with_initial_text(initial) + .interact_text() + .map_err(editor_error)?; + let value = value.trim().parse::().map_err(|error| { + CliError::Config(format!("{} must be an integer: {error}", field.key)) + })?; + Ok(Some(Value::Number(value.into()))) + } + DynamicConfigFieldKind::Number => { + let initial = current + .or(field.default.as_ref()) + .map(json_text) + .unwrap_or_default(); + let value: String = Input::with_theme(theme) + .with_prompt(single_line_text(&field.title)) + .with_initial_text(initial) + .interact_text() + .map_err(editor_error)?; + let parsed = value.trim().parse::().map_err(|error| { + CliError::Config(format!("{} must be a number: {error}", field.key)) + })?; + let number = Number::from_f64(parsed).ok_or_else(|| { + CliError::Config(format!("{} must be a finite number", field.key)) + })?; + Ok(Some(Value::Number(number))) + } + DynamicConfigFieldKind::StringMap => { + let (current, redacted_config, secrets, hidden) = state.field_value_for_raw_edit(path); + let Some(value) = prompt_json_value( + theme, + field, + current.as_ref(), + Value::Object(Map::new()), + hidden, + )? + else { + return Ok(None); + }; + let value = state.restore_raw_field_edit(path, value, redacted_config, &secrets)?; + let object = value + .as_object() + .ok_or_else(|| CliError::Config(format!("{} must be a JSON object", field.key)))?; + if object.values().any(|value| !value.is_string()) { + return Err(CliError::Config(format!( + "{} must contain only string values", + field.key + ))); + } + Ok(Some(value)) + } + DynamicConfigFieldKind::RawJson => { + let fallback = field.default.clone().unwrap_or(Value::Null); + let (current, redacted_config, secrets, hidden) = state.field_value_for_raw_edit(path); + let Some(value) = prompt_json_value(theme, field, current.as_ref(), fallback, hidden)? + else { + return Ok(None); + }; + let value = state.restore_raw_field_edit(path, value, redacted_config, &secrets)?; + Ok(Some(value)) + } + DynamicConfigFieldKind::Object { .. } => unreachable!(), + } +} + +fn prompt_dynamic_string( + theme: &ColorfulTheme, + field: &DynamicConfigField, + current: Option<&Value>, + secret: bool, + options: Option<&[String]>, +) -> Result, CliError> { + if secret { + let title = single_line_text(&field.title); + let value = Password::with_theme(theme) + .with_prompt(format!("New {} (blank preserves the current value)", title)) + .allow_empty_password(true) + .report(false) + .interact() + .map_err(editor_error)?; + if value.is_empty() { + return Ok(None); + } + if options.is_some_and(|options| !options.iter().any(|option| option == &value)) { + return Err(CliError::Config(format!( + "{} must be one of the schema enum values", + field.key + ))); + } + return Ok(Some(Value::String(value))); + } + let initial = current + .and_then(Value::as_str) + .or_else(|| field.default.as_ref().and_then(Value::as_str)) + .unwrap_or_default(); + let value: String = Input::with_theme(theme) + .with_prompt(single_line_text(&field.title)) + .with_initial_text(initial) + .interact_text() + .map_err(editor_error)?; + Ok(Some(Value::String(value))) +} + +fn prompt_json_value( + theme: &ColorfulTheme, + field: &DynamicConfigField, + current: Option<&Value>, + fallback: Value, + hidden: bool, +) -> Result, CliError> { + let initial = current.or(field.default.as_ref()).unwrap_or(&fallback); + let prompt = format!("{} as JSON", single_line_text(&field.title)); + let value = if hidden { + if current.is_some() { + println!(" Current redacted JSON: {}", json_text(initial)); + } + let value = Password::with_theme(theme) + .with_prompt(format!("New {prompt} (blank preserves the current value)")) + .allow_empty_password(true) + .report(false) + .interact() + .map_err(editor_error)?; + if value.is_empty() { + return Ok(None); + } + value + } else { + Input::with_theme(theme) + .with_prompt(prompt) + .with_initial_text(json_text(initial)) + .interact_text() + .map_err(editor_error)? + }; + serde_json::from_str(value.trim()) + .map_err(|error| CliError::Config(format!("invalid JSON for {}: {error}", field.key))) + .map(Some) +} + +fn prompt_raw_config( + theme: &ColorfulTheme, + state: &mut DynamicPluginEditorState, +) -> Result<(), CliError> { + let original = Value::Object(state.config.clone().unwrap_or_default()); + let (initial, secrets, hidden) = state + .schema + .as_ref() + .map(|schema| { + let (redacted, secrets) = schema.redact_for_edit(&original); + (redacted, secrets, schema.has_secrets()) + }) + .unwrap_or_else(|| (original, SecretEditValues::new(), false)); + let value = if hidden { + println!(" Current redacted JSON: {}", json_text(&initial)); + let value = Password::with_theme(theme) + .with_prompt("New configuration as JSON object (blank preserves the current value)") + .allow_empty_password(true) + .report(false) + .interact() + .map_err(editor_error)?; + if value.is_empty() { + return Ok(()); + } + value + } else { + Input::with_theme(theme) + .with_prompt("Configuration as JSON object") + .with_initial_text(json_text(&initial)) + .interact_text() + .map_err(editor_error)? + }; + let value: Value = serde_json::from_str(value.trim()) + .map_err(|error| CliError::Config(format!("invalid JSON configuration: {error}")))?; + let value = match &state.schema { + Some(schema) => schema.restore_edit_secrets(&value, &secrets)?, + None => value, + }; + let object = value.as_object().cloned().ok_or_else(|| { + CliError::Config(format!( + "dynamic plugin '{}' configuration must be a JSON object", + state.plugin_id + )) + })?; + if let Some(schema) = &state.schema { + schema.validate(&value)?; + } + state.set_raw_config(object); + Ok(()) +} diff --git a/crates/cli/src/plugins/mod.rs b/crates/cli/src/plugins/mod.rs index 61b542e2d..c76012c26 100644 --- a/crates/cli/src/plugins/mod.rs +++ b/crates/cli/src/plugins/mod.rs @@ -1,18 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Interactive plugin configuration editing. +//! Plugin configuration state and deterministic editor behavior. //! -//! 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. +//! Terminal-only interaction lives in `plugins/prompt.rs`. -use std::io::IsTerminal; use std::path::{Path, PathBuf}; -use console::{Key, Term, style, truncate_str}; +use console::{Key, style, truncate_str}; use dialoguer::theme::ColorfulTheme; -use dialoguer::{Input, Select}; use nemo_relay::config_editor::{EditorFieldKind, EditorFieldSpec}; use serde_json::{Value, json}; @@ -24,6 +20,7 @@ mod editor_model; pub(crate) mod lifecycle; pub(crate) mod policy; pub(crate) mod pricing; +mod prompt; pub(crate) mod schema; mod types; @@ -32,6 +29,10 @@ pub(crate) use types::*; use self::config_io::*; use self::dynamic_editor::*; use self::editor_model::*; +use self::prompt::{editor_error, print_editor_help, prompt_menu}; + +#[cfg(test)] +use self::prompt::menu_error; const PLUGIN_EDIT_CANCELLED_MESSAGE: &str = "plugin edit cancelled; no plugin changes saved"; @@ -103,46 +104,7 @@ fn print_save_success(path: &Path) { } pub(crate) fn edit(command: PluginsEditRequest) -> Result<(), CliError> { - ensure_tty()?; - let (scope, path) = resolve_edit_target(command)?; - let mut document = PluginConfigDocument::read(&path)?; - ensure_observability_component(document.config_mut())?; - ensure_adaptive_component(document.config_mut())?; - let mut components = editable_components(document.config())?; - let mut dynamic_plugins = load_dynamic_plugin_states(&document)?; - - let theme = ColorfulTheme::default(); - crate::banner::print_intro(); - println!( - " Editing plugin config at {}", - single_line_text(&path.display().to_string()) - ); - println!(" Tip: ↑/↓ or j/k to move, PageUp/PageDown to scroll, SPACE/ENTER to select."); - println!(); - let mut selected_index = 0; - loop { - let dynamic_rows = dynamic_plugins - .iter() - .map(|plugin| (plugin.label().to_owned(), plugin.menu_summary())) - .collect::>(); - let (items, actions) = plugin_menu_items(&components, &dynamic_rows, &path); - let selection = prompt_menu(&theme, "plugins.toml", &items, selected_index)?; - if let Some(selected) = menu_response_index(&selection) { - selected_index = selected; - } - if handle_menu_response( - &theme, - &mut document, - &mut components, - &mut dynamic_plugins, - &actions, - selection, - scope, - )? == EditLoopControl::Finish - { - return Ok(()); - } - } + prompt::edit(command) } pub(crate) fn resolve_edit_target( @@ -156,109 +118,6 @@ pub(crate) fn resolve_edit_target( Ok((scope, path)) } -fn handle_menu_response( - theme: &ColorfulTheme, - document: &mut PluginConfigDocument, - components: &mut [EditableComponent], - dynamic_plugins: &mut [DynamicPluginEditorState], - actions: &[MenuAction], - selection: MenuResponse, - scope: TargetScope, -) -> Result { - match selection { - MenuResponse::Selected(selection) => handle_menu_action( - theme, - document, - components, - dynamic_plugins, - actions.get(selection).copied(), - scope, - ), - MenuResponse::Shortcut(MenuShortcut::Preview, _) => { - preview_document(document, components, dynamic_plugins)?; - Ok(EditLoopControl::Continue) - } - MenuResponse::Shortcut(MenuShortcut::Save, _) => { - save_document(document, components, dynamic_plugins, scope) - } - MenuResponse::Shortcut(MenuShortcut::Help, _) => { - print_editor_help(); - Ok(EditLoopControl::Continue) - } - MenuResponse::Shortcut( - shortcut @ (MenuShortcut::Reset | MenuShortcut::Clear), - selected, - ) => handle_reset_or_clear_shortcut(components, actions.get(selected).copied(), shortcut), - MenuResponse::Cancel => Err(cancelled_error()), - } -} - -fn handle_menu_action( - theme: &ColorfulTheme, - document: &mut PluginConfigDocument, - components: &mut [EditableComponent], - dynamic_plugins: &mut [DynamicPluginEditorState], - action: Option, - scope: TargetScope, -) -> Result { - match action { - Some(MenuAction::EditComponent(component_index)) => { - if let Some(component) = components.get_mut(component_index) { - edit_component(theme, component)?; - } - Ok(EditLoopControl::Continue) - } - Some(MenuAction::EditDynamic(dynamic_index)) => { - if let Some(plugin) = dynamic_plugins.get_mut(dynamic_index) { - edit_dynamic_plugin(theme, plugin)?; - } - Ok(EditLoopControl::Continue) - } - Some(MenuAction::Preview) => { - preview_document(document, components, dynamic_plugins)?; - Ok(EditLoopControl::Continue) - } - Some(MenuAction::Save) => save_document(document, components, dynamic_plugins, scope), - Some(MenuAction::Cancel) | None => Err(cancelled_error()), - } -} - -fn edit_component( - theme: &ColorfulTheme, - component: &mut EditableComponent, -) -> Result<(), CliError> { - let mut selected_index = 0; - loop { - let (items, actions) = component_menu_items(component); - let selection = prompt_menu(theme, component.label(), &items, selected_index)?; - if let Some(selected) = menu_response_index(&selection) { - selected_index = selected; - } - match selection { - MenuResponse::Selected(selected) => match actions.get(selected).copied() { - Some(ComponentMenuAction::Toggle) => component.toggle_enabled(), - Some(ComponentMenuAction::EditField(field_index)) => { - if let Some(field) = component.fields().get(field_index) { - edit_component_field(theme, component, *field)?; - } - } - Some(ComponentMenuAction::Back) | None => return Ok(()), - }, - MenuResponse::Shortcut(MenuShortcut::Reset, selected) => { - reset_component_menu_item(component, actions.get(selected).copied())?; - } - MenuResponse::Shortcut(MenuShortcut::Clear, selected) => { - clear_component_menu_item(component, actions.get(selected).copied())?; - } - MenuResponse::Shortcut(MenuShortcut::Help, _) => print_editor_help(), - MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { - println!(" Preview and save are available from the main plugins.toml menu."); - } - MenuResponse::Cancel => return Ok(()), - } - } -} - fn preview_document( document: &PluginConfigDocument, components: &[EditableComponent], @@ -358,37 +217,6 @@ fn cancelled_error() -> CliError { CliError::Config(PLUGIN_EDIT_CANCELLED_MESSAGE.into()) } -fn edit_component_field( - theme: &ColorfulTheme, - component: &mut EditableComponent, - field: EditorFieldSpec, -) -> Result<(), CliError> { - match component { - EditableComponent::Observability(state) => { - edit_section(theme, &mut state.config, field)?; - state.mark_config_touched(); - } - EditableComponent::Adaptive(state) => { - edit_config_field(theme, &mut state.config, field)?; - state.mark_config_touched(); - } - EditableComponent::NemoGuardrails(state) => { - edit_config_field(theme, &mut state.config, field)?; - state.mark_config_touched(); - } - EditableComponent::PiiRedaction(state) => { - edit_config_field(theme, &mut state.config, field)?; - state.mark_config_touched(); - } - #[cfg(feature = "switchyard")] - EditableComponent::Switchyard(state) => { - edit_config_field(theme, &mut state.config, field)?; - state.mark_config_touched(); - } - } - Ok(()) -} - fn menu_response_index(response: &MenuResponse) -> Option { match response { MenuResponse::Selected(index) @@ -404,75 +232,16 @@ fn menu_response_index(response: &MenuResponse) -> Option { } } -fn prompt_menu( - theme: &ColorfulTheme, - prompt: &str, - items: &[MenuItem], - default: usize, -) -> Result { - if items.is_empty() { - return Err(CliError::Config(format!("{prompt} menu has no items"))); - } - let term = Term::stderr(); - let mut selected = default.min(items.len() - 1); - let mut rendered_lines = 0; - loop { - if rendered_lines > 0 { - term.clear_last_lines(rendered_lines).map_err(menu_error)?; - } - let (rows, columns) = term.size(); - let viewport = menu_viewport(items.len(), selected, usize::from(rows)); - let lines = render_menu_for_size( - theme, - prompt, - items, - selected, - usize::from(rows), - usize::from(columns), - ); - rendered_lines = lines.len(); - for line in &lines { - term.write_line(line).map_err(menu_error)?; - } - term.flush().map_err(menu_error)?; - let key = term.read_key().map_err(menu_error)?; - if let Some(next) = - menu_selection_after_key(&key, selected, items.len(), viewport.page_size) - { - selected = next; - continue; - } - match key { - Key::Enter | Key::Char(' ') => { - clear_menu(&term, rendered_lines)?; - return Ok(MenuResponse::Selected(selected)); - } - Key::Char('p') => { - clear_menu(&term, rendered_lines)?; - return Ok(MenuResponse::Shortcut(MenuShortcut::Preview, selected)); - } - Key::Char('s') => { - clear_menu(&term, rendered_lines)?; - return Ok(MenuResponse::Shortcut(MenuShortcut::Save, selected)); - } - Key::Char('r') => { - clear_menu(&term, rendered_lines)?; - return Ok(MenuResponse::Shortcut(MenuShortcut::Reset, selected)); - } - Key::Backspace | Key::Del => { - clear_menu(&term, rendered_lines)?; - return Ok(MenuResponse::Shortcut(MenuShortcut::Clear, selected)); - } - Key::Char('?') => { - clear_menu(&term, rendered_lines)?; - return Ok(MenuResponse::Shortcut(MenuShortcut::Help, selected)); - } - Key::Escape | Key::CtrlC | Key::Char('q') => { - clear_menu(&term, rendered_lines)?; - return Ok(MenuResponse::Cancel); - } - _ => {} - } +fn menu_response_for_key(key: &Key, selected: usize) -> Option { + match key { + Key::Enter | Key::Char(' ') => Some(MenuResponse::Selected(selected)), + Key::Char('p') => Some(MenuResponse::Shortcut(MenuShortcut::Preview, selected)), + Key::Char('s') => Some(MenuResponse::Shortcut(MenuShortcut::Save, selected)), + Key::Char('r') => Some(MenuResponse::Shortcut(MenuShortcut::Reset, selected)), + Key::Backspace | Key::Del => Some(MenuResponse::Shortcut(MenuShortcut::Clear, selected)), + Key::Char('?') => Some(MenuResponse::Shortcut(MenuShortcut::Help, selected)), + Key::Escape | Key::CtrlC | Key::Char('q') => Some(MenuResponse::Cancel), + _ => None, } } @@ -634,119 +403,6 @@ fn single_line_text(value: &str) -> String { .collect() } -fn clear_menu(term: &Term, rendered_lines: usize) -> Result<(), CliError> { - if rendered_lines > 0 { - term.clear_last_lines(rendered_lines).map_err(menu_error)?; - } - Ok(()) -} - -fn menu_error(error: std::io::Error) -> CliError { - if matches!( - error.kind(), - std::io::ErrorKind::Interrupted | std::io::ErrorKind::UnexpectedEof - ) { - CliError::Config(PLUGIN_EDIT_CANCELLED_MESSAGE.into()) - } else { - CliError::Config(format!("plugin editor terminal error: {error}")) - } -} - -fn print_editor_help() { - println!(); - println!( - "{} {}", - style("?").yellow(), - style("Plugin editor keys").bold() - ); - println!(" {} move", style("↑/↓ or j/k").cyan()); - println!( - " {} move by page or jump to an end", - style("PageUp/PageDown, Home/End").cyan() - ); - println!( - " {} select/toggle the highlighted item", - style("Enter/Space").cyan() - ); - println!( - " {} reset the highlighted field or section", - style("r").cyan() - ); - println!( - " {} clear the highlighted optional field", - style("Backspace/Del").cyan() - ); - println!( - " {} preview TOML from the main menu", - style("p").cyan() - ); - println!( - " {} save from the main menu", - style("s").cyan() - ); - println!(" {} go back/cancel", style("q or Esc").cyan()); -} - -fn ensure_tty() -> Result<(), CliError> { - if !std::io::stdin().is_terminal() - || !std::io::stdout().is_terminal() - || !std::io::stderr().is_terminal() - { - return Err(CliError::Config( - "interactive plugin editing requires a TTY".into(), - )); - } - Ok(()) -} - -fn edit_section( - theme: &ColorfulTheme, - config: &mut T, - section: EditorFieldSpec, -) -> Result<(), CliError> -where - T: SerializeConfig, -{ - let fields = section - .schema() - .ok_or_else(|| CliError::Config(format!("{} is not an editable section", section.name)))? - .fields; - let mut selected_index = 0; - loop { - let items = section_menu_items(config, section, fields)?; - let selection = prompt_menu(theme, section.name, &items, selected_index)?; - if let Some(selected) = menu_response_index(&selection) { - selected_index = selected; - } - let selection = match selection { - MenuResponse::Selected(selection) => selection, - MenuResponse::Shortcut(MenuShortcut::Help, _) => { - print_editor_help(); - continue; - } - MenuResponse::Shortcut(MenuShortcut::Reset, selected) => { - reset_selected_item(config, section, fields, selected)?; - continue; - } - MenuResponse::Shortcut(MenuShortcut::Clear, selected) => { - if reset_selected_field(config, section, fields, selected)? { - continue; - } - println!(" Select a field to clear."); - continue; - } - MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { - println!(" Preview and save are available from the main plugins.toml menu."); - continue; - } - MenuResponse::Cancel => return Ok(()), - }; - if !edit_selected_section_item(theme, config, section, fields, selection)? { - return Ok(()); - } - } -} - fn section_menu_items( config: &T, section: EditorFieldSpec, @@ -820,393 +476,12 @@ where Ok(()) } -fn edit_selected_section_item( - theme: &ColorfulTheme, - config: &mut T, - section: EditorFieldSpec, - fields: &[EditorFieldSpec], - selection: usize, -) -> Result -where - T: SerializeConfig, -{ - if section_has_enabled_toggle(section) && selection == 0 { - toggle_section(config, section); - return Ok(true); - } - let index = selected_field_index(section, selection); - if let Some(field) = fields.get(index) { - edit_field(theme, config, section, field)?; - return Ok(true); - } - if index == fields.len() { - reset_section(config, section); - return Ok(true); - } - Ok(false) -} - -fn edit_field( - theme: &ColorfulTheme, - config: &mut T, - section: EditorFieldSpec, - field: &EditorFieldSpec, -) -> Result<(), CliError> -where - T: SerializeConfig, -{ - if field.kind == EditorFieldKind::Section { - edit_nested_section(theme, config, section, *field)?; - return Ok(()); - } - let current = section_field_value(config, section, field.name)?; - if field.kind == EditorFieldKind::List { - let item = field.list_item.ok_or_else(|| { - CliError::Config(format!("{} does not describe its list entries", field.name)) - })?; - let default = section_field_default(section, *field); - let mut items = current - .or_else(|| default.clone()) - .unwrap_or_else(|| json!([])); - if edit_list_value( - theme, - &format!("{}.{}", section.name, field.name), - &mut items, - default, - item, - )? { - set_section_field(config, section, field.name, items)?; - } - return Ok(()); - } - if field.kind == EditorFieldKind::StringMap { - let default = section_field_default(section, *field); - let mut entries = current - .or_else(|| default.clone()) - .unwrap_or_else(|| json!({})); - if edit_string_map_value( - theme, - &format!("{}.{}", section.name, field.name), - &mut entries, - default, - )? { - set_section_field(config, section, field.name, entries)?; - } - return Ok(()); - } - if field.kind == EditorFieldKind::TaggedUnion { - let tagged_union = field.tagged_union.ok_or_else(|| { - CliError::Config(format!("{} does not describe its variants", field.name)) - })?; - let default = section_field_default(section, *field); - match edit_tagged_union_field( - theme, - &format!("{}.{}", section.name, field.name), - current, - default, - tagged_union, - )? { - TaggedUnionFieldEdit::Set(value) => { - set_section_field(config, section, field.name, value)?; - } - TaggedUnionFieldEdit::Reset => remove_section_field(config, section, field.name)?, - TaggedUnionFieldEdit::Unchanged => {} - } - return Ok(()); - } - let actions = [ - MenuItem::new("Set value"), - MenuItem::new(shortcut_label( - "Reset to default/none", - "r, Backspace, Delete", - )), - MenuItem::new(shortcut_label("Back", "q")), - ]; - let action = prompt_menu( - theme, - &format!( - "{}.{}, current {}", - section.name, - field.name, - current - .as_ref() - .map(|value| display_field_value(section, *field, value)) - .unwrap_or_else(|| "(default)".to_string()) - ), - &actions, - 0, - )?; - match action { - MenuResponse::Selected(0) => { - let value = prompt_value(theme, field, current.as_ref())?; - set_section_field(config, section, field.name, value)?; - } - MenuResponse::Selected(1) - | MenuResponse::Shortcut(MenuShortcut::Reset | MenuShortcut::Clear, _) => { - remove_section_field(config, section, field.name)? - } - MenuResponse::Shortcut(MenuShortcut::Help, _) => print_editor_help(), - MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { - println!(" Preview and save are available from the main plugins.toml menu."); - } - _ => {} - } - Ok(()) -} - -fn edit_list_value( - theme: &ColorfulTheme, - prompt: &str, - value: &mut Value, - default: Option, - item: &nemo_relay::config_editor::EditorListItemSpec, -) -> Result { - if !value.is_array() { - *value = default.clone().unwrap_or_else(|| json!([])); - } - let original = value.clone(); - let mut selected_index = 0; - loop { - let entries = value.as_array().expect("list value is an array"); - let mut menu = vec![MenuItem::new("Add item")]; - menu.extend(entries.iter().enumerate().map(|(index, entry)| { - MenuItem::new(format!( - "Edit item {}: {}", - index + 1, - editor_item_label(entry, item) - )) - })); - menu.push(MenuItem::new(shortcut_label("Back", "q"))); - let selection = prompt_menu(theme, prompt, &menu, selected_index)?; - if let Some(selected) = menu_response_index(&selection) { - selected_index = selected; - } - match selection { - MenuResponse::Selected(0) => { - let mut entry = new_editor_item(theme, item)?; - edit_editor_item( - theme, - &format!("{prompt}[{}]", entries.len()), - &mut entry, - item, - )?; - value - .as_array_mut() - .expect("list value is an array") - .push(entry); - } - MenuResponse::Selected(index) if index <= entries.len() => { - edit_existing_list_item(theme, prompt, value, index - 1, item)?; - } - MenuResponse::Cancel | MenuResponse::Selected(_) => return Ok(*value != original), - MenuResponse::Shortcut(MenuShortcut::Help, _) => print_editor_help(), - MenuResponse::Shortcut(shortcut @ (MenuShortcut::Reset | MenuShortcut::Clear), _) => { - *value = collection_shortcut_value(default.as_ref(), json!([]), shortcut) - } - MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { - println!(" Preview and save are available from the main plugins.toml menu."); - } - } - } -} - -fn edit_string_map_value( - theme: &ColorfulTheme, - prompt: &str, - value: &mut Value, - default: Option, -) -> Result { - if !value.is_object() { - *value = default.clone().unwrap_or_else(|| json!({})); - } - let original = value.clone(); - let mut selected_index = 0; - loop { - let entries = value.as_object().expect("string map value is an object"); - let keys = entries.keys().cloned().collect::>(); - let mut menu = vec![MenuItem::new("Add entry")]; - menu.extend(keys.iter().map(|key| { - MenuItem::new(format!( - "Edit {key}: {}", - entries.get(key).map(display_value).unwrap_or_default() - )) - })); - menu.push(MenuItem::new(shortcut_label("Back", "q"))); - let selection = prompt_menu(theme, prompt, &menu, selected_index)?; - if let Some(selected) = menu_response_index(&selection) { - selected_index = selected; - } - match selection { - MenuResponse::Selected(0) => { - let key: String = Input::with_theme(theme) - .with_prompt("Entry key") - .interact_text() - .map_err(editor_error)?; - if key.trim().is_empty() { - println!(" Entry key must not be empty."); - continue; - } - let key = key.trim().to_owned(); - if string_map_entry_exists(value, &key) { - println!(" Entry already exists; select it to edit."); - continue; - } - let entry: String = Input::with_theme(theme) - .with_prompt("Entry value") - .interact_text() - .map_err(editor_error)?; - value - .as_object_mut() - .expect("string map value is an object") - .insert(key, Value::String(entry)); - } - MenuResponse::Selected(index) if index <= keys.len() => { - edit_existing_string_map_entry(theme, prompt, value, &keys[index - 1])?; - } - MenuResponse::Cancel | MenuResponse::Selected(_) => return Ok(*value != original), - MenuResponse::Shortcut(MenuShortcut::Help, _) => print_editor_help(), - MenuResponse::Shortcut(shortcut @ (MenuShortcut::Reset | MenuShortcut::Clear), _) => { - *value = collection_shortcut_value(default.as_ref(), json!({}), shortcut) - } - MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { - println!(" Preview and save are available from the main plugins.toml menu."); - } - } - } -} - fn string_map_entry_exists(value: &Value, key: &str) -> bool { value .as_object() .is_some_and(|entries| entries.contains_key(key.trim())) } -fn edit_existing_string_map_entry( - theme: &ColorfulTheme, - prompt: &str, - value: &mut Value, - key: &str, -) -> Result<(), CliError> { - let actions = [ - MenuItem::new("Edit value"), - MenuItem::new("Remove entry"), - MenuItem::new(shortcut_label("Back", "q")), - ]; - match prompt_menu(theme, &format!("{prompt}.{key}"), &actions, 0)? { - MenuResponse::Selected(0) => { - let current = value - .as_object() - .and_then(|entries| entries.get(key)) - .and_then(Value::as_str) - .unwrap_or_default(); - let entry: String = Input::with_theme(theme) - .with_prompt("Entry value") - .with_initial_text(current) - .interact_text() - .map_err(editor_error)?; - value - .as_object_mut() - .expect("string map value is an object") - .insert(key.to_owned(), Value::String(entry)); - } - MenuResponse::Selected(1) | MenuResponse::Shortcut(MenuShortcut::Clear, _) => { - value - .as_object_mut() - .expect("string map value is an object") - .remove(key); - } - _ => {} - } - Ok(()) -} - -fn edit_existing_list_item( - theme: &ColorfulTheme, - prompt: &str, - value: &mut Value, - index: usize, - item: &nemo_relay::config_editor::EditorListItemSpec, -) -> Result<(), CliError> { - let actions = [ - MenuItem::new("Edit item"), - MenuItem::new("Remove item"), - MenuItem::new(shortcut_label("Back", "q")), - ]; - match prompt_menu(theme, &format!("{prompt}[{}]", index + 1), &actions, 0)? { - MenuResponse::Selected(0) => { - if let Some(entry) = value - .as_array_mut() - .and_then(|entries| entries.get_mut(index)) - { - edit_editor_item(theme, &format!("{prompt}[{}]", index + 1), entry, item)?; - } - } - MenuResponse::Selected(1) | MenuResponse::Shortcut(MenuShortcut::Clear, _) => { - value - .as_array_mut() - .expect("list value is an array") - .remove(index); - } - _ => {} - } - Ok(()) -} - -fn new_editor_item( - theme: &ColorfulTheme, - item: &nemo_relay::config_editor::EditorListItemSpec, -) -> Result { - if let Some(tagged_union) = item.tagged_union { - return new_tagged_union_value(theme, tagged_union); - } - Ok(item.default.map(|default| default()).unwrap_or(Value::Null)) -} - -fn edit_editor_item( - theme: &ColorfulTheme, - prompt: &str, - value: &mut Value, - item: &nemo_relay::config_editor::EditorListItemSpec, -) -> Result<(), CliError> { - if let Some(tagged_union) = item.tagged_union { - return edit_tagged_union_payload(theme, prompt, value, tagged_union); - } - - match item.kind { - EditorFieldKind::Section => { - let schema = item - .schema - .ok_or_else(|| CliError::Config("list item has no schema".into()))?( - ); - edit_value_section(theme, prompt, value, schema, None)?; - } - EditorFieldKind::List => { - let nested = item.list_item.ok_or_else(|| { - CliError::Config("nested list item has no entry description".into()) - })?; - let _ = edit_list_value(theme, prompt, value, None, nested)?; - } - EditorFieldKind::StringMap => { - let _ = edit_string_map_value(theme, prompt, value, None)?; - } - kind => { - let field = EditorFieldSpec { - name: "item", - label: "item", - kind, - enum_values: &[], - optional: false, - nested_schema: None, - nested_default: None, - list_item: None, - tagged_union: None, - }; - *value = prompt_value(theme, &field, Some(value))?; - } - } - Ok(()) -} - fn editor_item_label( value: &Value, item: &nemo_relay::config_editor::EditorListItemSpec, @@ -1232,59 +507,6 @@ fn tagged_union_variant_value( .ok_or_else(|| CliError::Config("tagged union variant does not exist".into())) } -fn select_tagged_union_variant( - theme: &ColorfulTheme, - tagged_union: &nemo_relay::config_editor::EditorTaggedUnionSpec, -) -> Result { - if tagged_union.variants.is_empty() { - return Err(CliError::Config("tagged union has no variants".into())); - } - Select::with_theme(theme) - .with_prompt("Variant type") - .items( - &tagged_union - .variants - .iter() - .map(|variant| variant.label) - .collect::>(), - ) - .default(0) - .interact() - .map_err(editor_error) -} - -fn new_tagged_union_value( - theme: &ColorfulTheme, - tagged_union: &nemo_relay::config_editor::EditorTaggedUnionSpec, -) -> Result { - tagged_union_variant_value( - tagged_union, - select_tagged_union_variant(theme, tagged_union)?, - ) -} - -fn edit_tagged_union_payload( - theme: &ColorfulTheme, - prompt: &str, - value: &mut Value, - tagged_union: &nemo_relay::config_editor::EditorTaggedUnionSpec, -) -> Result<(), CliError> { - if !value.is_object() { - *value = new_tagged_union_value(theme, tagged_union)?; - } - let tag = value - .get(tagged_union.discriminator) - .and_then(Value::as_str) - .ok_or_else(|| CliError::Config("tagged union has no discriminator value".into()))?; - let variant = tagged_union - .variants - .iter() - .find(|variant| variant.tag == tag) - .ok_or_else(|| CliError::Config(format!("unknown tagged union type {tag:?}")))?; - edit_value_section(theme, prompt, value, (variant.schema)(), None)?; - Ok(()) -} - #[derive(Debug, PartialEq)] enum TaggedUnionFieldEdit { Set(Value), @@ -1336,55 +558,6 @@ impl TaggedUnionFieldState { } } -fn edit_tagged_union_field( - theme: &ColorfulTheme, - prompt: &str, - current: Option, - default: Option, - tagged_union: &nemo_relay::config_editor::EditorTaggedUnionSpec, -) -> Result { - let mut state = TaggedUnionFieldState::new(current, default); - loop { - let actions = [ - MenuItem::new("Edit fields"), - MenuItem::new("Change variant"), - MenuItem::new(shortcut_label( - "Reset to default/none", - "r, Backspace, Delete", - )), - MenuItem::new(shortcut_label("Back", "q")), - ]; - match prompt_menu( - theme, - &format!("{prompt}, current {}", display_value(state.value())), - &actions, - 0, - )? { - MenuResponse::Selected(0) => { - edit_tagged_union_payload(theme, prompt, state.value_mut(), tagged_union)?; - } - MenuResponse::Selected(1) => { - state.change_variant( - tagged_union, - select_tagged_union_variant(theme, tagged_union)?, - )?; - edit_tagged_union_payload(theme, prompt, state.value_mut(), tagged_union)?; - } - MenuResponse::Selected(2) - | MenuResponse::Shortcut(MenuShortcut::Reset | MenuShortcut::Clear, _) => { - return Ok(state.reset()); - } - MenuResponse::Shortcut(MenuShortcut::Help, _) => print_editor_help(), - MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { - println!(" Preview and save are available from the main plugins.toml menu."); - } - MenuResponse::Cancel | MenuResponse::Selected(_) => { - return Ok(state.finish()); - } - } - } -} - fn collection_shortcut_value( default: Option<&Value>, empty: Value, @@ -1397,141 +570,6 @@ fn collection_shortcut_value( } } -fn edit_config_field( - theme: &ColorfulTheme, - config: &mut T, - field: EditorFieldSpec, -) -> Result<(), CliError> -where - T: Default + SerializeConfig, -{ - if field.kind == EditorFieldKind::Section { - let mut value = config_field_value(config, field.name)? - .or_else(|| field.default_value()) - .unwrap_or_else(|| json!({})); - let schema = field.schema().ok_or_else(|| { - CliError::Config(format!("{} is not an editable section", field.name)) - })?; - if edit_value_section(theme, field.name, &mut value, schema, field.default_value())? { - store_edited_config_section(config, field, value)?; - } - return Ok(()); - } - - if field.kind == EditorFieldKind::List { - let item = field.list_item.ok_or_else(|| { - CliError::Config(format!("{} does not describe its list entries", field.name)) - })?; - let default = default_config_field_value::(field).or_else(|| field.default_value()); - let mut items = config_field_value(config, field.name)? - .or_else(|| default.clone()) - .unwrap_or_else(|| json!([])); - if edit_list_value(theme, field.name, &mut items, default, item)? { - set_struct_field(config, field.name, items)?; - } - return Ok(()); - } - - if field.kind == EditorFieldKind::StringMap { - let default = default_config_field_value::(field).or_else(|| field.default_value()); - let mut entries = config_field_value(config, field.name)? - .or_else(|| default.clone()) - .unwrap_or_else(|| json!({})); - if edit_string_map_value(theme, field.name, &mut entries, default)? { - set_struct_field(config, field.name, entries)?; - } - return Ok(()); - } - - if field.kind == EditorFieldKind::TaggedUnion { - let tagged_union = field.tagged_union.ok_or_else(|| { - CliError::Config(format!("{} does not describe its variants", field.name)) - })?; - let default = default_config_field_value::(field).or_else(|| field.default_value()); - match edit_tagged_union_field( - theme, - field.name, - config_field_value(config, field.name)?, - default, - tagged_union, - )? { - TaggedUnionFieldEdit::Set(value) => set_struct_field(config, field.name, value)?, - TaggedUnionFieldEdit::Reset => reset_config_field(config, field)?, - TaggedUnionFieldEdit::Unchanged => {} - } - return Ok(()); - } - - let current = config_field_value(config, field.name)?; - let actions = [ - MenuItem::new("Set value"), - MenuItem::new(shortcut_label( - "Reset to default/none", - "r, Backspace, Delete", - )), - MenuItem::new(shortcut_label("Back", "q")), - ]; - let action = prompt_menu( - theme, - &format!( - "{}, current {}", - field.label, - current - .as_ref() - .map(display_value) - .or_else(|| default_config_field_value::(field) - .map(|value| { format!("{} (default)", display_value(&value)) })) - .unwrap_or_else(|| "(default)".to_string()) - ), - &actions, - 0, - )?; - match action { - MenuResponse::Selected(0) => { - let value = prompt_value(theme, &field, current.as_ref())?; - set_struct_field(config, field.name, value)?; - } - MenuResponse::Selected(1) - | MenuResponse::Shortcut(MenuShortcut::Reset | MenuShortcut::Clear, _) => { - reset_config_field(config, field)? - } - MenuResponse::Shortcut(MenuShortcut::Help, _) => print_editor_help(), - MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { - println!(" Preview and save are available from the main plugins.toml menu."); - } - _ => {} - } - Ok(()) -} - -fn edit_nested_section( - theme: &ColorfulTheme, - config: &mut T, - section: EditorFieldSpec, - field: EditorFieldSpec, -) -> Result<(), CliError> -where - T: SerializeConfig, -{ - let mut value = section_field_value(config, section, field.name)? - .or_else(|| section_field_default(section, field)) - .unwrap_or_else(|| json!({})); - let schema = field - .schema() - .ok_or_else(|| CliError::Config(format!("{} is not an editable section", field.name)))?; - let default = section_field_default(section, field); - if edit_value_section( - theme, - &format!("{}.{}", section.name, field.name), - &mut value, - schema, - default, - )? { - store_edited_section_field(config, section, field, value)?; - } - Ok(()) -} - fn section_field_default(section: EditorFieldSpec, field: EditorFieldSpec) -> Option { default_field_value(section, field).or_else(|| field.default_value()) } @@ -1567,51 +605,6 @@ where } } -fn edit_value_section( - theme: &ColorfulTheme, - prompt: &str, - value: &mut Value, - schema: &nemo_relay::config_editor::EditorSchema, - default: Option, -) -> Result { - ensure_object(value); - let original = value.clone(); - let mut selected_index = 0; - loop { - let items = value_section_menu_items(value, schema, default.as_ref())?; - let selection = prompt_menu(theme, prompt, &items, selected_index)?; - if let Some(selected) = menu_response_index(&selection) { - selected_index = selected; - } - let selection = match selection { - MenuResponse::Selected(selection) => selection, - MenuResponse::Shortcut(MenuShortcut::Help, _) => { - print_editor_help(); - continue; - } - MenuResponse::Shortcut(MenuShortcut::Reset, selected) => { - reset_value_section_item(value, schema, default.as_ref(), selected); - continue; - } - MenuResponse::Shortcut(MenuShortcut::Clear, selected) => { - if clear_value_field(value, schema, selected) { - continue; - } - println!(" Select a field to clear."); - continue; - } - MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { - println!(" Preview and save are available from the main plugins.toml menu."); - continue; - } - MenuResponse::Cancel => return Ok(*value != original), - }; - if !edit_selected_value_item(theme, prompt, value, schema, default.as_ref(), selection)? { - return Ok(*value != original); - } - } -} - fn value_section_menu_items( value: &Value, schema: &nemo_relay::config_editor::EditorSchema, @@ -1647,156 +640,6 @@ fn value_field_menu_item( ))) } -fn edit_selected_value_item( - theme: &ColorfulTheme, - prompt: &str, - value: &mut Value, - schema: &nemo_relay::config_editor::EditorSchema, - default: Option<&Value>, - selection: usize, -) -> Result { - if let Some(field) = schema.fields.get(selection) { - edit_value_field(theme, prompt, value, *field, default)?; - return Ok(true); - } - if selection == schema.fields.len() { - *value = default.cloned().unwrap_or_else(|| json!({})); - ensure_object(value); - return Ok(true); - } - Ok(false) -} - -fn edit_value_field( - theme: &ColorfulTheme, - prompt: &str, - value: &mut Value, - field: EditorFieldSpec, - default: Option<&Value>, -) -> Result<(), CliError> { - if field.kind == EditorFieldKind::Section { - let nested_default = value_field_default(default, field); - let mut nested_value = value_field_value(value, field.name) - .or_else(|| nested_default.clone()) - .unwrap_or_else(|| json!({})); - let nested_schema = field.schema().ok_or_else(|| { - CliError::Config(format!("{} is not an editable section", field.name)) - })?; - if edit_value_section( - theme, - &format!("{prompt}.{}", field.name), - &mut nested_value, - nested_schema, - nested_default, - )? { - store_edited_value_section(value, field, nested_value); - } - return Ok(()); - } - - if field.kind == EditorFieldKind::List { - let item = field.list_item.ok_or_else(|| { - CliError::Config(format!("{} does not describe its list entries", field.name)) - })?; - let field_default = value_field_default(default, field); - let mut items = value_field_value(value, field.name) - .or_else(|| field_default.clone()) - .unwrap_or_else(|| json!([])); - if edit_list_value( - theme, - &format!("{prompt}.{}", field.name), - &mut items, - field_default, - item, - )? { - set_value_field(value, field.name, items); - } - return Ok(()); - } - - if field.kind == EditorFieldKind::StringMap { - let field_default = value_field_default(default, field); - let mut entries = value_field_value(value, field.name) - .or_else(|| field_default.clone()) - .unwrap_or_else(|| json!({})); - if edit_string_map_value( - theme, - &format!("{prompt}.{}", field.name), - &mut entries, - field_default, - )? { - set_value_field(value, field.name, entries); - } - return Ok(()); - } - - if field.kind == EditorFieldKind::TaggedUnion { - let tagged_union = field.tagged_union.ok_or_else(|| { - CliError::Config(format!("{} does not describe its variants", field.name)) - })?; - let field_default = value_field_default(default, field); - match edit_tagged_union_field( - theme, - &format!("{prompt}.{}", field.name), - value_field_value(value, field.name), - field_default.clone(), - tagged_union, - )? { - TaggedUnionFieldEdit::Set(tagged_value) => { - set_value_field(value, field.name, tagged_value); - } - TaggedUnionFieldEdit::Reset => reset_value_field(value, field, default), - TaggedUnionFieldEdit::Unchanged => {} - } - return Ok(()); - } - - let current = value_field_value(value, field.name); - let actions = [ - MenuItem::new("Set value"), - MenuItem::new(shortcut_label( - "Reset to default/none", - "r, Backspace, Delete", - )), - MenuItem::new(shortcut_label("Back", "q")), - ]; - let action = prompt_menu( - theme, - &format!( - "{prompt}.{}, current {}", - field.name, - current - .as_ref() - .map(|value| { - display_value_with_default(value, value_field_default(default, field)) - }) - .or_else(|| { - value_field_default(default, field) - .map(|value| format!("{} (default)", display_value(&value))) - }) - .unwrap_or_else(|| "(default)".to_string()) - ), - &actions, - 0, - )?; - match action { - MenuResponse::Selected(0) => { - let field_value = prompt_value(theme, &field, current.as_ref())?; - set_value_field(value, field.name, field_value); - } - MenuResponse::Selected(1) - | MenuResponse::Shortcut(MenuShortcut::Reset | MenuShortcut::Clear, _) => { - reset_value_field(value, field, default) - } - MenuResponse::Shortcut(MenuShortcut::Help, _) => print_editor_help(), - MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { - println!(" Preview and save are available from the main plugins.toml menu."); - } - _ => {} - } - Ok(()) -} - fn reset_value_section_item( value: &mut Value, schema: &nemo_relay::config_editor::EditorSchema, @@ -1896,98 +739,6 @@ trait SerializeConfig: serde::Serialize + serde::de::DeserializeOwned {} impl SerializeConfig for T where T: serde::Serialize + serde::de::DeserializeOwned {} -fn prompt_value( - theme: &ColorfulTheme, - field: &EditorFieldSpec, - current: Option<&Value>, -) -> Result { - match field.kind { - EditorFieldKind::Boolean => { - let values = ["false", "true"]; - let default_idx = current - .and_then(Value::as_bool) - .map(usize::from) - .unwrap_or(0); - let idx = Select::with_theme(theme) - .with_prompt(field.label) - .items(&values) - .default(default_idx) - .interact() - .map_err(editor_error)?; - Ok(json!(idx == 1)) - } - EditorFieldKind::Integer => { - let initial = current.map(display_value).unwrap_or_default(); - let value: String = Input::with_theme(theme) - .with_prompt(field.label) - .with_initial_text(initial) - .interact_text() - .map_err(editor_error)?; - let parsed = value.trim().parse::().map_err(|error| { - CliError::Config(format!("{} must be an integer: {error}", field.name)) - })?; - Ok(json!(parsed)) - } - EditorFieldKind::Float => { - let initial = current.map(display_value).unwrap_or_default(); - let value: String = Input::with_theme(theme) - .with_prompt(field.label) - .with_initial_text(initial) - .interact_text() - .map_err(editor_error)?; - parse_float_value(field, &value) - } - EditorFieldKind::StringMap | EditorFieldKind::Json => { - let initial = current.map(display_value).unwrap_or_else(|| { - if matches!(field.name, "tool_definitions" | "learners") { - "[]".to_string() - } else { - "{}".to_string() - } - }); - let value: String = Input::with_theme(theme) - .with_prompt(format!("{} as JSON", field.label)) - .with_initial_text(initial) - .interact_text() - .map_err(editor_error)?; - serde_json::from_str(value.trim()).map_err(|error| { - CliError::Config(format!("invalid JSON for {}: {error}", field.name)) - }) - } - EditorFieldKind::Enum => { - let values = field.enum_values; - let default_idx = current - .and_then(Value::as_str) - .and_then(|value| values.iter().position(|candidate| *candidate == value)) - .unwrap_or(0); - let idx = Select::with_theme(theme) - .with_prompt(field.label) - .items(values) - .default(default_idx) - .interact() - .map_err(editor_error)?; - Ok(json!(values[idx])) - } - EditorFieldKind::String => { - let initial = current.and_then(Value::as_str).unwrap_or_default(); - let value: String = Input::with_theme(theme) - .with_prompt(field.label) - .with_initial_text(initial) - .interact_text() - .map_err(editor_error)?; - Ok(json!(value)) - } - EditorFieldKind::Section => Err(CliError::Config(format!( - "{} is a nested section and cannot be edited as a scalar", - field.name - ))), - EditorFieldKind::List | EditorFieldKind::TaggedUnion => Err(CliError::Config(format!( - "{} is a structured value and cannot be edited as a scalar", - field.name - ))), - } -} - fn parse_float_value(field: &EditorFieldSpec, value: &str) -> Result { let value = value.trim(); let parsed = value @@ -2002,20 +753,6 @@ fn parse_float_value(field: &EditorFieldSpec, value: &str) -> Result CliError { - match err { - dialoguer::Error::IO(io_err) - if matches!( - io_err.kind(), - std::io::ErrorKind::Interrupted | std::io::ErrorKind::UnexpectedEof - ) => - { - CliError::Config(PLUGIN_EDIT_CANCELLED_MESSAGE.into()) - } - other => CliError::Config(format!("plugin edit error: {other}")), - } -} - #[cfg(test)] #[path = "../../tests/coverage/shared/plugins_tests.rs"] mod tests; diff --git a/crates/cli/src/plugins/prompt.rs b/crates/cli/src/plugins/prompt.rs new file mode 100644 index 000000000..be1c4e64c --- /dev/null +++ b/crates/cli/src/plugins/prompt.rs @@ -0,0 +1,1266 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Terminal-only prompt adapter for plugin configuration. + +use std::io::IsTerminal; + +use console::Term; +use dialoguer::theme::ColorfulTheme; +use dialoguer::{Input, Select}; + +use super::*; + +pub(crate) fn edit(command: PluginsEditRequest) -> Result<(), CliError> { + ensure_tty()?; + let (scope, path) = resolve_edit_target(command)?; + let mut document = PluginConfigDocument::read(&path)?; + ensure_observability_component(document.config_mut())?; + ensure_adaptive_component(document.config_mut())?; + let mut components = editable_components(document.config())?; + let mut dynamic_plugins = load_dynamic_plugin_states(&document)?; + + let theme = ColorfulTheme::default(); + crate::banner::print_intro(); + println!( + " Editing plugin config at {}", + single_line_text(&path.display().to_string()) + ); + println!(" Tip: ↑/↓ or j/k to move, PageUp/PageDown to scroll, SPACE/ENTER to select."); + println!(); + let mut selected_index = 0; + loop { + let dynamic_rows = dynamic_plugins + .iter() + .map(|plugin| (plugin.label().to_owned(), plugin.menu_summary())) + .collect::>(); + let (items, actions) = plugin_menu_items(&components, &dynamic_rows, &path); + let selection = prompt_menu(&theme, "plugins.toml", &items, selected_index)?; + if let Some(selected) = menu_response_index(&selection) { + selected_index = selected; + } + if handle_menu_response( + &theme, + &mut document, + &mut components, + &mut dynamic_plugins, + &actions, + selection, + scope, + )? == EditLoopControl::Finish + { + return Ok(()); + } + } +} + +fn handle_menu_response( + theme: &ColorfulTheme, + document: &mut PluginConfigDocument, + components: &mut [EditableComponent], + dynamic_plugins: &mut [DynamicPluginEditorState], + actions: &[MenuAction], + selection: MenuResponse, + scope: TargetScope, +) -> Result { + match selection { + MenuResponse::Selected(selection) => handle_menu_action( + theme, + document, + components, + dynamic_plugins, + actions.get(selection).copied(), + scope, + ), + MenuResponse::Shortcut(MenuShortcut::Preview, _) => { + preview_document(document, components, dynamic_plugins)?; + Ok(EditLoopControl::Continue) + } + MenuResponse::Shortcut(MenuShortcut::Save, _) => { + save_document(document, components, dynamic_plugins, scope) + } + MenuResponse::Shortcut(MenuShortcut::Help, _) => { + print_editor_help(); + Ok(EditLoopControl::Continue) + } + MenuResponse::Shortcut( + shortcut @ (MenuShortcut::Reset | MenuShortcut::Clear), + selected, + ) => handle_reset_or_clear_shortcut(components, actions.get(selected).copied(), shortcut), + MenuResponse::Cancel => Err(cancelled_error()), + } +} + +fn handle_menu_action( + theme: &ColorfulTheme, + document: &mut PluginConfigDocument, + components: &mut [EditableComponent], + dynamic_plugins: &mut [DynamicPluginEditorState], + action: Option, + scope: TargetScope, +) -> Result { + match action { + Some(MenuAction::EditComponent(component_index)) => { + if let Some(component) = components.get_mut(component_index) { + edit_component(theme, component)?; + } + Ok(EditLoopControl::Continue) + } + Some(MenuAction::EditDynamic(dynamic_index)) => { + if let Some(plugin) = dynamic_plugins.get_mut(dynamic_index) { + edit_dynamic_plugin(theme, plugin)?; + } + Ok(EditLoopControl::Continue) + } + Some(MenuAction::Preview) => { + preview_document(document, components, dynamic_plugins)?; + Ok(EditLoopControl::Continue) + } + Some(MenuAction::Save) => save_document(document, components, dynamic_plugins, scope), + Some(MenuAction::Cancel) | None => Err(cancelled_error()), + } +} + +fn edit_component( + theme: &ColorfulTheme, + component: &mut EditableComponent, +) -> Result<(), CliError> { + let mut selected_index = 0; + loop { + let (items, actions) = component_menu_items(component); + let selection = prompt_menu(theme, component.label(), &items, selected_index)?; + if let Some(selected) = menu_response_index(&selection) { + selected_index = selected; + } + match selection { + MenuResponse::Selected(selected) => match actions.get(selected).copied() { + Some(ComponentMenuAction::Toggle) => component.toggle_enabled(), + Some(ComponentMenuAction::EditField(field_index)) => { + if let Some(field) = component.fields().get(field_index) { + edit_component_field(theme, component, *field)?; + } + } + Some(ComponentMenuAction::Back) | None => return Ok(()), + }, + MenuResponse::Shortcut(MenuShortcut::Reset, selected) => { + reset_component_menu_item(component, actions.get(selected).copied())?; + } + MenuResponse::Shortcut(MenuShortcut::Clear, selected) => { + clear_component_menu_item(component, actions.get(selected).copied())?; + } + MenuResponse::Shortcut(MenuShortcut::Help, _) => print_editor_help(), + MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { + println!(" Preview and save are available from the main plugins.toml menu."); + } + MenuResponse::Cancel => return Ok(()), + } + } +} + +fn edit_component_field( + theme: &ColorfulTheme, + component: &mut EditableComponent, + field: EditorFieldSpec, +) -> Result<(), CliError> { + match component { + EditableComponent::Observability(state) => { + edit_section(theme, &mut state.config, field)?; + state.mark_config_touched(); + } + EditableComponent::Adaptive(state) => { + edit_config_field(theme, &mut state.config, field)?; + state.mark_config_touched(); + } + EditableComponent::NemoGuardrails(state) => { + edit_config_field(theme, &mut state.config, field)?; + state.mark_config_touched(); + } + EditableComponent::PiiRedaction(state) => { + edit_config_field(theme, &mut state.config, field)?; + state.mark_config_touched(); + } + #[cfg(feature = "switchyard")] + EditableComponent::Switchyard(state) => { + edit_config_field(theme, &mut state.config, field)?; + state.mark_config_touched(); + } + } + Ok(()) +} + +pub(super) fn prompt_menu( + theme: &ColorfulTheme, + prompt: &str, + items: &[MenuItem], + default: usize, +) -> Result { + if items.is_empty() { + return Err(CliError::Config(format!("{prompt} menu has no items"))); + } + let term = Term::stderr(); + let mut selected = default.min(items.len() - 1); + let mut rendered_lines = 0; + loop { + if rendered_lines > 0 { + term.clear_last_lines(rendered_lines).map_err(menu_error)?; + } + let (rows, columns) = term.size(); + let viewport = menu_viewport(items.len(), selected, usize::from(rows)); + let lines = render_menu_for_size( + theme, + prompt, + items, + selected, + usize::from(rows), + usize::from(columns), + ); + rendered_lines = lines.len(); + for line in &lines { + term.write_line(line).map_err(menu_error)?; + } + term.flush().map_err(menu_error)?; + let key = term.read_key().map_err(menu_error)?; + if let Some(next) = + menu_selection_after_key(&key, selected, items.len(), viewport.page_size) + { + selected = next; + continue; + } + if let Some(response) = menu_response_for_key(&key, selected) { + clear_menu(&term, rendered_lines)?; + return Ok(response); + } + } +} + +fn clear_menu(term: &Term, rendered_lines: usize) -> Result<(), CliError> { + if rendered_lines > 0 { + term.clear_last_lines(rendered_lines).map_err(menu_error)?; + } + Ok(()) +} + +pub(super) fn menu_error(error: std::io::Error) -> CliError { + if matches!( + error.kind(), + std::io::ErrorKind::Interrupted | std::io::ErrorKind::UnexpectedEof + ) { + CliError::Config(PLUGIN_EDIT_CANCELLED_MESSAGE.into()) + } else { + CliError::Config(format!("plugin editor terminal error: {error}")) + } +} + +pub(super) fn print_editor_help() { + println!(); + println!( + "{} {}", + style("?").yellow(), + style("Plugin editor keys").bold() + ); + println!(" {} move", style("↑/↓ or j/k").cyan()); + println!( + " {} move by page or jump to an end", + style("PageUp/PageDown, Home/End").cyan() + ); + println!( + " {} select/toggle the highlighted item", + style("Enter/Space").cyan() + ); + println!( + " {} reset the highlighted field or section", + style("r").cyan() + ); + println!( + " {} clear the highlighted optional field", + style("Backspace/Del").cyan() + ); + println!( + " {} preview TOML from the main menu", + style("p").cyan() + ); + println!( + " {} save from the main menu", + style("s").cyan() + ); + println!(" {} go back/cancel", style("q or Esc").cyan()); +} + +fn ensure_tty() -> Result<(), CliError> { + if !std::io::stdin().is_terminal() + || !std::io::stdout().is_terminal() + || !std::io::stderr().is_terminal() + { + return Err(CliError::Config( + "interactive plugin editing requires a TTY".into(), + )); + } + Ok(()) +} + +fn edit_section( + theme: &ColorfulTheme, + config: &mut T, + section: EditorFieldSpec, +) -> Result<(), CliError> +where + T: SerializeConfig, +{ + let fields = section + .schema() + .ok_or_else(|| CliError::Config(format!("{} is not an editable section", section.name)))? + .fields; + let mut selected_index = 0; + loop { + let items = section_menu_items(config, section, fields)?; + let selection = prompt_menu(theme, section.name, &items, selected_index)?; + if let Some(selected) = menu_response_index(&selection) { + selected_index = selected; + } + let selection = match selection { + MenuResponse::Selected(selection) => selection, + MenuResponse::Shortcut(MenuShortcut::Help, _) => { + print_editor_help(); + continue; + } + MenuResponse::Shortcut(MenuShortcut::Reset, selected) => { + reset_selected_item(config, section, fields, selected)?; + continue; + } + MenuResponse::Shortcut(MenuShortcut::Clear, selected) => { + if reset_selected_field(config, section, fields, selected)? { + continue; + } + println!(" Select a field to clear."); + continue; + } + MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { + println!(" Preview and save are available from the main plugins.toml menu."); + continue; + } + MenuResponse::Cancel => return Ok(()), + }; + if !edit_selected_section_item(theme, config, section, fields, selection)? { + return Ok(()); + } + } +} + +fn edit_selected_section_item( + theme: &ColorfulTheme, + config: &mut T, + section: EditorFieldSpec, + fields: &[EditorFieldSpec], + selection: usize, +) -> Result +where + T: SerializeConfig, +{ + if section_has_enabled_toggle(section) && selection == 0 { + toggle_section(config, section); + return Ok(true); + } + let index = selected_field_index(section, selection); + if let Some(field) = fields.get(index) { + edit_field(theme, config, section, field)?; + return Ok(true); + } + if index == fields.len() { + reset_section(config, section); + return Ok(true); + } + Ok(false) +} + +fn edit_field( + theme: &ColorfulTheme, + config: &mut T, + section: EditorFieldSpec, + field: &EditorFieldSpec, +) -> Result<(), CliError> +where + T: SerializeConfig, +{ + if field.kind == EditorFieldKind::Section { + edit_nested_section(theme, config, section, *field)?; + return Ok(()); + } + let current = section_field_value(config, section, field.name)?; + if field.kind == EditorFieldKind::List { + let item = field.list_item.ok_or_else(|| { + CliError::Config(format!("{} does not describe its list entries", field.name)) + })?; + let default = section_field_default(section, *field); + let mut items = current + .or_else(|| default.clone()) + .unwrap_or_else(|| json!([])); + if edit_list_value( + theme, + &format!("{}.{}", section.name, field.name), + &mut items, + default, + item, + )? { + set_section_field(config, section, field.name, items)?; + } + return Ok(()); + } + if field.kind == EditorFieldKind::StringMap { + let default = section_field_default(section, *field); + let mut entries = current + .or_else(|| default.clone()) + .unwrap_or_else(|| json!({})); + if edit_string_map_value( + theme, + &format!("{}.{}", section.name, field.name), + &mut entries, + default, + )? { + set_section_field(config, section, field.name, entries)?; + } + return Ok(()); + } + if field.kind == EditorFieldKind::TaggedUnion { + let tagged_union = field.tagged_union.ok_or_else(|| { + CliError::Config(format!("{} does not describe its variants", field.name)) + })?; + let default = section_field_default(section, *field); + match edit_tagged_union_field( + theme, + &format!("{}.{}", section.name, field.name), + current, + default, + tagged_union, + )? { + TaggedUnionFieldEdit::Set(value) => { + set_section_field(config, section, field.name, value)?; + } + TaggedUnionFieldEdit::Reset => remove_section_field(config, section, field.name)?, + TaggedUnionFieldEdit::Unchanged => {} + } + return Ok(()); + } + let actions = [ + MenuItem::new("Set value"), + MenuItem::new(shortcut_label( + "Reset to default/none", + "r, Backspace, Delete", + )), + MenuItem::new(shortcut_label("Back", "q")), + ]; + let action = prompt_menu( + theme, + &format!( + "{}.{}, current {}", + section.name, + field.name, + current + .as_ref() + .map(|value| display_field_value(section, *field, value)) + .unwrap_or_else(|| "(default)".to_string()) + ), + &actions, + 0, + )?; + match action { + MenuResponse::Selected(0) => { + let value = prompt_value(theme, field, current.as_ref())?; + set_section_field(config, section, field.name, value)?; + } + MenuResponse::Selected(1) + | MenuResponse::Shortcut(MenuShortcut::Reset | MenuShortcut::Clear, _) => { + remove_section_field(config, section, field.name)? + } + MenuResponse::Shortcut(MenuShortcut::Help, _) => print_editor_help(), + MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { + println!(" Preview and save are available from the main plugins.toml menu."); + } + _ => {} + } + Ok(()) +} + +fn edit_list_value( + theme: &ColorfulTheme, + prompt: &str, + value: &mut Value, + default: Option, + item: &nemo_relay::config_editor::EditorListItemSpec, +) -> Result { + if !value.is_array() { + *value = default.clone().unwrap_or_else(|| json!([])); + } + let original = value.clone(); + let mut selected_index = 0; + loop { + let entries = value.as_array().expect("list value is an array"); + let mut menu = vec![MenuItem::new("Add item")]; + menu.extend(entries.iter().enumerate().map(|(index, entry)| { + MenuItem::new(format!( + "Edit item {}: {}", + index + 1, + editor_item_label(entry, item) + )) + })); + menu.push(MenuItem::new(shortcut_label("Back", "q"))); + let selection = prompt_menu(theme, prompt, &menu, selected_index)?; + if let Some(selected) = menu_response_index(&selection) { + selected_index = selected; + } + match selection { + MenuResponse::Selected(0) => { + let mut entry = new_editor_item(theme, item)?; + edit_editor_item( + theme, + &format!("{prompt}[{}]", entries.len()), + &mut entry, + item, + )?; + value + .as_array_mut() + .expect("list value is an array") + .push(entry); + } + MenuResponse::Selected(index) if index <= entries.len() => { + edit_existing_list_item(theme, prompt, value, index - 1, item)?; + } + MenuResponse::Cancel | MenuResponse::Selected(_) => return Ok(*value != original), + MenuResponse::Shortcut(MenuShortcut::Help, _) => print_editor_help(), + MenuResponse::Shortcut(shortcut @ (MenuShortcut::Reset | MenuShortcut::Clear), _) => { + *value = collection_shortcut_value(default.as_ref(), json!([]), shortcut) + } + MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { + println!(" Preview and save are available from the main plugins.toml menu."); + } + } + } +} + +fn edit_string_map_value( + theme: &ColorfulTheme, + prompt: &str, + value: &mut Value, + default: Option, +) -> Result { + if !value.is_object() { + *value = default.clone().unwrap_or_else(|| json!({})); + } + let original = value.clone(); + let mut selected_index = 0; + loop { + let entries = value.as_object().expect("string map value is an object"); + let keys = entries.keys().cloned().collect::>(); + let mut menu = vec![MenuItem::new("Add entry")]; + menu.extend(keys.iter().map(|key| { + MenuItem::new(format!( + "Edit {key}: {}", + entries.get(key).map(display_value).unwrap_or_default() + )) + })); + menu.push(MenuItem::new(shortcut_label("Back", "q"))); + let selection = prompt_menu(theme, prompt, &menu, selected_index)?; + if let Some(selected) = menu_response_index(&selection) { + selected_index = selected; + } + match selection { + MenuResponse::Selected(0) => { + let key: String = Input::with_theme(theme) + .with_prompt("Entry key") + .interact_text() + .map_err(editor_error)?; + if key.trim().is_empty() { + println!(" Entry key must not be empty."); + continue; + } + let key = key.trim().to_owned(); + if string_map_entry_exists(value, &key) { + println!(" Entry already exists; select it to edit."); + continue; + } + let entry: String = Input::with_theme(theme) + .with_prompt("Entry value") + .interact_text() + .map_err(editor_error)?; + value + .as_object_mut() + .expect("string map value is an object") + .insert(key, Value::String(entry)); + } + MenuResponse::Selected(index) if index <= keys.len() => { + edit_existing_string_map_entry(theme, prompt, value, &keys[index - 1])?; + } + MenuResponse::Cancel | MenuResponse::Selected(_) => return Ok(*value != original), + MenuResponse::Shortcut(MenuShortcut::Help, _) => print_editor_help(), + MenuResponse::Shortcut(shortcut @ (MenuShortcut::Reset | MenuShortcut::Clear), _) => { + *value = collection_shortcut_value(default.as_ref(), json!({}), shortcut) + } + MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { + println!(" Preview and save are available from the main plugins.toml menu."); + } + } + } +} + +fn edit_existing_string_map_entry( + theme: &ColorfulTheme, + prompt: &str, + value: &mut Value, + key: &str, +) -> Result<(), CliError> { + let actions = [ + MenuItem::new("Edit value"), + MenuItem::new("Remove entry"), + MenuItem::new(shortcut_label("Back", "q")), + ]; + match prompt_menu(theme, &format!("{prompt}.{key}"), &actions, 0)? { + MenuResponse::Selected(0) => { + let current = value + .as_object() + .and_then(|entries| entries.get(key)) + .and_then(Value::as_str) + .unwrap_or_default(); + let entry: String = Input::with_theme(theme) + .with_prompt("Entry value") + .with_initial_text(current) + .interact_text() + .map_err(editor_error)?; + value + .as_object_mut() + .expect("string map value is an object") + .insert(key.to_owned(), Value::String(entry)); + } + MenuResponse::Selected(1) | MenuResponse::Shortcut(MenuShortcut::Clear, _) => { + value + .as_object_mut() + .expect("string map value is an object") + .remove(key); + } + _ => {} + } + Ok(()) +} + +fn edit_existing_list_item( + theme: &ColorfulTheme, + prompt: &str, + value: &mut Value, + index: usize, + item: &nemo_relay::config_editor::EditorListItemSpec, +) -> Result<(), CliError> { + let actions = [ + MenuItem::new("Edit item"), + MenuItem::new("Remove item"), + MenuItem::new(shortcut_label("Back", "q")), + ]; + match prompt_menu(theme, &format!("{prompt}[{}]", index + 1), &actions, 0)? { + MenuResponse::Selected(0) => { + if let Some(entry) = value + .as_array_mut() + .and_then(|entries| entries.get_mut(index)) + { + edit_editor_item(theme, &format!("{prompt}[{}]", index + 1), entry, item)?; + } + } + MenuResponse::Selected(1) | MenuResponse::Shortcut(MenuShortcut::Clear, _) => { + value + .as_array_mut() + .expect("list value is an array") + .remove(index); + } + _ => {} + } + Ok(()) +} + +fn new_editor_item( + theme: &ColorfulTheme, + item: &nemo_relay::config_editor::EditorListItemSpec, +) -> Result { + if let Some(tagged_union) = item.tagged_union { + return new_tagged_union_value(theme, tagged_union); + } + Ok(item.default.map(|default| default()).unwrap_or(Value::Null)) +} + +fn edit_editor_item( + theme: &ColorfulTheme, + prompt: &str, + value: &mut Value, + item: &nemo_relay::config_editor::EditorListItemSpec, +) -> Result<(), CliError> { + if let Some(tagged_union) = item.tagged_union { + return edit_tagged_union_payload(theme, prompt, value, tagged_union); + } + + match item.kind { + EditorFieldKind::Section => { + let schema = item + .schema + .ok_or_else(|| CliError::Config("list item has no schema".into()))?( + ); + edit_value_section(theme, prompt, value, schema, None)?; + } + EditorFieldKind::List => { + let nested = item.list_item.ok_or_else(|| { + CliError::Config("nested list item has no entry description".into()) + })?; + let _ = edit_list_value(theme, prompt, value, None, nested)?; + } + EditorFieldKind::StringMap => { + let _ = edit_string_map_value(theme, prompt, value, None)?; + } + kind => { + let field = EditorFieldSpec { + name: "item", + label: "item", + kind, + enum_values: &[], + optional: false, + nested_schema: None, + nested_default: None, + list_item: None, + tagged_union: None, + }; + *value = prompt_value(theme, &field, Some(value))?; + } + } + Ok(()) +} + +fn select_tagged_union_variant( + theme: &ColorfulTheme, + tagged_union: &nemo_relay::config_editor::EditorTaggedUnionSpec, +) -> Result { + if tagged_union.variants.is_empty() { + return Err(CliError::Config("tagged union has no variants".into())); + } + Select::with_theme(theme) + .with_prompt("Variant type") + .items( + &tagged_union + .variants + .iter() + .map(|variant| variant.label) + .collect::>(), + ) + .default(0) + .interact() + .map_err(editor_error) +} + +fn new_tagged_union_value( + theme: &ColorfulTheme, + tagged_union: &nemo_relay::config_editor::EditorTaggedUnionSpec, +) -> Result { + tagged_union_variant_value( + tagged_union, + select_tagged_union_variant(theme, tagged_union)?, + ) +} + +fn edit_tagged_union_payload( + theme: &ColorfulTheme, + prompt: &str, + value: &mut Value, + tagged_union: &nemo_relay::config_editor::EditorTaggedUnionSpec, +) -> Result<(), CliError> { + if !value.is_object() { + *value = new_tagged_union_value(theme, tagged_union)?; + } + let tag = value + .get(tagged_union.discriminator) + .and_then(Value::as_str) + .ok_or_else(|| CliError::Config("tagged union has no discriminator value".into()))?; + let variant = tagged_union + .variants + .iter() + .find(|variant| variant.tag == tag) + .ok_or_else(|| CliError::Config(format!("unknown tagged union type {tag:?}")))?; + edit_value_section(theme, prompt, value, (variant.schema)(), None)?; + Ok(()) +} + +fn edit_tagged_union_field( + theme: &ColorfulTheme, + prompt: &str, + current: Option, + default: Option, + tagged_union: &nemo_relay::config_editor::EditorTaggedUnionSpec, +) -> Result { + let mut state = TaggedUnionFieldState::new(current, default); + loop { + let actions = [ + MenuItem::new("Edit fields"), + MenuItem::new("Change variant"), + MenuItem::new(shortcut_label( + "Reset to default/none", + "r, Backspace, Delete", + )), + MenuItem::new(shortcut_label("Back", "q")), + ]; + match prompt_menu( + theme, + &format!("{prompt}, current {}", display_value(state.value())), + &actions, + 0, + )? { + MenuResponse::Selected(0) => { + edit_tagged_union_payload(theme, prompt, state.value_mut(), tagged_union)?; + } + MenuResponse::Selected(1) => { + state.change_variant( + tagged_union, + select_tagged_union_variant(theme, tagged_union)?, + )?; + edit_tagged_union_payload(theme, prompt, state.value_mut(), tagged_union)?; + } + MenuResponse::Selected(2) + | MenuResponse::Shortcut(MenuShortcut::Reset | MenuShortcut::Clear, _) => { + return Ok(state.reset()); + } + MenuResponse::Shortcut(MenuShortcut::Help, _) => print_editor_help(), + MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { + println!(" Preview and save are available from the main plugins.toml menu."); + } + MenuResponse::Cancel | MenuResponse::Selected(_) => { + return Ok(state.finish()); + } + } + } +} + +fn edit_config_field( + theme: &ColorfulTheme, + config: &mut T, + field: EditorFieldSpec, +) -> Result<(), CliError> +where + T: Default + SerializeConfig, +{ + if field.kind == EditorFieldKind::Section { + let mut value = config_field_value(config, field.name)? + .or_else(|| field.default_value()) + .unwrap_or_else(|| json!({})); + let schema = field.schema().ok_or_else(|| { + CliError::Config(format!("{} is not an editable section", field.name)) + })?; + if edit_value_section(theme, field.name, &mut value, schema, field.default_value())? { + store_edited_config_section(config, field, value)?; + } + return Ok(()); + } + + if field.kind == EditorFieldKind::List { + let item = field.list_item.ok_or_else(|| { + CliError::Config(format!("{} does not describe its list entries", field.name)) + })?; + let default = default_config_field_value::(field).or_else(|| field.default_value()); + let mut items = config_field_value(config, field.name)? + .or_else(|| default.clone()) + .unwrap_or_else(|| json!([])); + if edit_list_value(theme, field.name, &mut items, default, item)? { + set_struct_field(config, field.name, items)?; + } + return Ok(()); + } + + if field.kind == EditorFieldKind::StringMap { + let default = default_config_field_value::(field).or_else(|| field.default_value()); + let mut entries = config_field_value(config, field.name)? + .or_else(|| default.clone()) + .unwrap_or_else(|| json!({})); + if edit_string_map_value(theme, field.name, &mut entries, default)? { + set_struct_field(config, field.name, entries)?; + } + return Ok(()); + } + + if field.kind == EditorFieldKind::TaggedUnion { + let tagged_union = field.tagged_union.ok_or_else(|| { + CliError::Config(format!("{} does not describe its variants", field.name)) + })?; + let default = default_config_field_value::(field).or_else(|| field.default_value()); + match edit_tagged_union_field( + theme, + field.name, + config_field_value(config, field.name)?, + default, + tagged_union, + )? { + TaggedUnionFieldEdit::Set(value) => set_struct_field(config, field.name, value)?, + TaggedUnionFieldEdit::Reset => reset_config_field(config, field)?, + TaggedUnionFieldEdit::Unchanged => {} + } + return Ok(()); + } + + let current = config_field_value(config, field.name)?; + let actions = [ + MenuItem::new("Set value"), + MenuItem::new(shortcut_label( + "Reset to default/none", + "r, Backspace, Delete", + )), + MenuItem::new(shortcut_label("Back", "q")), + ]; + let action = prompt_menu( + theme, + &format!( + "{}, current {}", + field.label, + current + .as_ref() + .map(display_value) + .or_else(|| default_config_field_value::(field) + .map(|value| { format!("{} (default)", display_value(&value)) })) + .unwrap_or_else(|| "(default)".to_string()) + ), + &actions, + 0, + )?; + match action { + MenuResponse::Selected(0) => { + let value = prompt_value(theme, &field, current.as_ref())?; + set_struct_field(config, field.name, value)?; + } + MenuResponse::Selected(1) + | MenuResponse::Shortcut(MenuShortcut::Reset | MenuShortcut::Clear, _) => { + reset_config_field(config, field)? + } + MenuResponse::Shortcut(MenuShortcut::Help, _) => print_editor_help(), + MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { + println!(" Preview and save are available from the main plugins.toml menu."); + } + _ => {} + } + Ok(()) +} + +fn edit_nested_section( + theme: &ColorfulTheme, + config: &mut T, + section: EditorFieldSpec, + field: EditorFieldSpec, +) -> Result<(), CliError> +where + T: SerializeConfig, +{ + let mut value = section_field_value(config, section, field.name)? + .or_else(|| section_field_default(section, field)) + .unwrap_or_else(|| json!({})); + let schema = field + .schema() + .ok_or_else(|| CliError::Config(format!("{} is not an editable section", field.name)))?; + let default = section_field_default(section, field); + if edit_value_section( + theme, + &format!("{}.{}", section.name, field.name), + &mut value, + schema, + default, + )? { + store_edited_section_field(config, section, field, value)?; + } + Ok(()) +} + +fn edit_value_section( + theme: &ColorfulTheme, + prompt: &str, + value: &mut Value, + schema: &nemo_relay::config_editor::EditorSchema, + default: Option, +) -> Result { + ensure_object(value); + let original = value.clone(); + let mut selected_index = 0; + loop { + let items = value_section_menu_items(value, schema, default.as_ref())?; + let selection = prompt_menu(theme, prompt, &items, selected_index)?; + if let Some(selected) = menu_response_index(&selection) { + selected_index = selected; + } + let selection = match selection { + MenuResponse::Selected(selection) => selection, + MenuResponse::Shortcut(MenuShortcut::Help, _) => { + print_editor_help(); + continue; + } + MenuResponse::Shortcut(MenuShortcut::Reset, selected) => { + reset_value_section_item(value, schema, default.as_ref(), selected); + continue; + } + MenuResponse::Shortcut(MenuShortcut::Clear, selected) => { + if clear_value_field(value, schema, selected) { + continue; + } + println!(" Select a field to clear."); + continue; + } + MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { + println!(" Preview and save are available from the main plugins.toml menu."); + continue; + } + MenuResponse::Cancel => return Ok(*value != original), + }; + if !edit_selected_value_item(theme, prompt, value, schema, default.as_ref(), selection)? { + return Ok(*value != original); + } + } +} + +fn edit_selected_value_item( + theme: &ColorfulTheme, + prompt: &str, + value: &mut Value, + schema: &nemo_relay::config_editor::EditorSchema, + default: Option<&Value>, + selection: usize, +) -> Result { + if let Some(field) = schema.fields.get(selection) { + edit_value_field(theme, prompt, value, *field, default)?; + return Ok(true); + } + if selection == schema.fields.len() { + *value = default.cloned().unwrap_or_else(|| json!({})); + ensure_object(value); + return Ok(true); + } + Ok(false) +} + +fn edit_value_field( + theme: &ColorfulTheme, + prompt: &str, + value: &mut Value, + field: EditorFieldSpec, + default: Option<&Value>, +) -> Result<(), CliError> { + if field.kind == EditorFieldKind::Section { + let nested_default = value_field_default(default, field); + let mut nested_value = value_field_value(value, field.name) + .or_else(|| nested_default.clone()) + .unwrap_or_else(|| json!({})); + let nested_schema = field.schema().ok_or_else(|| { + CliError::Config(format!("{} is not an editable section", field.name)) + })?; + if edit_value_section( + theme, + &format!("{prompt}.{}", field.name), + &mut nested_value, + nested_schema, + nested_default, + )? { + store_edited_value_section(value, field, nested_value); + } + return Ok(()); + } + + if field.kind == EditorFieldKind::List { + let item = field.list_item.ok_or_else(|| { + CliError::Config(format!("{} does not describe its list entries", field.name)) + })?; + let field_default = value_field_default(default, field); + let mut items = value_field_value(value, field.name) + .or_else(|| field_default.clone()) + .unwrap_or_else(|| json!([])); + if edit_list_value( + theme, + &format!("{prompt}.{}", field.name), + &mut items, + field_default, + item, + )? { + set_value_field(value, field.name, items); + } + return Ok(()); + } + + if field.kind == EditorFieldKind::StringMap { + let field_default = value_field_default(default, field); + let mut entries = value_field_value(value, field.name) + .or_else(|| field_default.clone()) + .unwrap_or_else(|| json!({})); + if edit_string_map_value( + theme, + &format!("{prompt}.{}", field.name), + &mut entries, + field_default, + )? { + set_value_field(value, field.name, entries); + } + return Ok(()); + } + + if field.kind == EditorFieldKind::TaggedUnion { + let tagged_union = field.tagged_union.ok_or_else(|| { + CliError::Config(format!("{} does not describe its variants", field.name)) + })?; + let field_default = value_field_default(default, field); + match edit_tagged_union_field( + theme, + &format!("{prompt}.{}", field.name), + value_field_value(value, field.name), + field_default.clone(), + tagged_union, + )? { + TaggedUnionFieldEdit::Set(tagged_value) => { + set_value_field(value, field.name, tagged_value); + } + TaggedUnionFieldEdit::Reset => reset_value_field(value, field, default), + TaggedUnionFieldEdit::Unchanged => {} + } + return Ok(()); + } + + let current = value_field_value(value, field.name); + let actions = [ + MenuItem::new("Set value"), + MenuItem::new(shortcut_label( + "Reset to default/none", + "r, Backspace, Delete", + )), + MenuItem::new(shortcut_label("Back", "q")), + ]; + let action = prompt_menu( + theme, + &format!( + "{prompt}.{}, current {}", + field.name, + current + .as_ref() + .map(|value| { + display_value_with_default(value, value_field_default(default, field)) + }) + .or_else(|| { + value_field_default(default, field) + .map(|value| format!("{} (default)", display_value(&value))) + }) + .unwrap_or_else(|| "(default)".to_string()) + ), + &actions, + 0, + )?; + match action { + MenuResponse::Selected(0) => { + let field_value = prompt_value(theme, &field, current.as_ref())?; + set_value_field(value, field.name, field_value); + } + MenuResponse::Selected(1) + | MenuResponse::Shortcut(MenuShortcut::Reset | MenuShortcut::Clear, _) => { + reset_value_field(value, field, default) + } + MenuResponse::Shortcut(MenuShortcut::Help, _) => print_editor_help(), + MenuResponse::Shortcut(MenuShortcut::Preview | MenuShortcut::Save, _) => { + println!(" Preview and save are available from the main plugins.toml menu."); + } + _ => {} + } + Ok(()) +} + +fn prompt_value( + theme: &ColorfulTheme, + field: &EditorFieldSpec, + current: Option<&Value>, +) -> Result { + match field.kind { + EditorFieldKind::Boolean => { + let values = ["false", "true"]; + let default_idx = current + .and_then(Value::as_bool) + .map(usize::from) + .unwrap_or(0); + let idx = Select::with_theme(theme) + .with_prompt(field.label) + .items(&values) + .default(default_idx) + .interact() + .map_err(editor_error)?; + Ok(json!(idx == 1)) + } + EditorFieldKind::Integer => { + let initial = current.map(display_value).unwrap_or_default(); + let value: String = Input::with_theme(theme) + .with_prompt(field.label) + .with_initial_text(initial) + .interact_text() + .map_err(editor_error)?; + let parsed = value.trim().parse::().map_err(|error| { + CliError::Config(format!("{} must be an integer: {error}", field.name)) + })?; + Ok(json!(parsed)) + } + EditorFieldKind::Float => { + let initial = current.map(display_value).unwrap_or_default(); + let value: String = Input::with_theme(theme) + .with_prompt(field.label) + .with_initial_text(initial) + .interact_text() + .map_err(editor_error)?; + parse_float_value(field, &value) + } + EditorFieldKind::StringMap | EditorFieldKind::Json => { + let initial = current.map(display_value).unwrap_or_else(|| { + if matches!(field.name, "tool_definitions" | "learners") { + "[]".to_string() + } else { + "{}".to_string() + } + }); + let value: String = Input::with_theme(theme) + .with_prompt(format!("{} as JSON", field.label)) + .with_initial_text(initial) + .interact_text() + .map_err(editor_error)?; + serde_json::from_str(value.trim()).map_err(|error| { + CliError::Config(format!("invalid JSON for {}: {error}", field.name)) + }) + } + EditorFieldKind::Enum => { + let values = field.enum_values; + let default_idx = current + .and_then(Value::as_str) + .and_then(|value| values.iter().position(|candidate| *candidate == value)) + .unwrap_or(0); + let idx = Select::with_theme(theme) + .with_prompt(field.label) + .items(values) + .default(default_idx) + .interact() + .map_err(editor_error)?; + Ok(json!(values[idx])) + } + EditorFieldKind::String => { + let initial = current.and_then(Value::as_str).unwrap_or_default(); + let value: String = Input::with_theme(theme) + .with_prompt(field.label) + .with_initial_text(initial) + .interact_text() + .map_err(editor_error)?; + Ok(json!(value)) + } + EditorFieldKind::Section => Err(CliError::Config(format!( + "{} is a nested section and cannot be edited as a scalar", + field.name + ))), + EditorFieldKind::List | EditorFieldKind::TaggedUnion => Err(CliError::Config(format!( + "{} is a structured value and cannot be edited as a scalar", + field.name + ))), + } +} + +pub(super) fn editor_error(err: dialoguer::Error) -> CliError { + match err { + dialoguer::Error::IO(io_err) + if matches!( + io_err.kind(), + std::io::ErrorKind::Interrupted | std::io::ErrorKind::UnexpectedEof + ) => + { + CliError::Config(PLUGIN_EDIT_CANCELLED_MESSAGE.into()) + } + other => CliError::Config(format!("plugin edit error: {other}")), + } +} diff --git a/crates/cli/src/process/launcher.rs b/crates/cli/src/process/launcher.rs index 81ed72569..e716d9da3 100644 --- a/crates/cli/src/process/launcher.rs +++ b/crates/cli/src/process/launcher.rs @@ -706,6 +706,13 @@ pub(crate) fn exporter_destinations(config: &GatewayConfig) -> Vec { fn observability_exporter_destinations(config: &ObservabilityConfig) -> Vec { let mut destinations = Vec::new(); + append_atof_destinations(&mut destinations, config); + append_atif_destinations(&mut destinations, config); + append_opentelemetry_destinations(&mut destinations, config); + destinations +} + +fn append_atof_destinations(destinations: &mut Vec, config: &ObservabilityConfig) { if let Some(section) = config.atof.as_ref().filter(|section| section.enabled) { for sink in §ion.sinks { match sink { @@ -727,6 +734,9 @@ fn observability_exporter_destinations(config: &ObservabilityConfig) -> Vec, config: &ObservabilityConfig) { if let Some(section) = config.atif.as_ref().filter(|section| section.enabled) { if section.storage.is_empty() { let directory = section @@ -746,6 +756,9 @@ fn observability_exporter_destinations(config: &ObservabilityConfig) -> Vec, config: &ObservabilityConfig) { if let Some(section) = config .opentelemetry .as_ref() @@ -763,7 +776,6 @@ fn observability_exporter_destinations(config: &ObservabilityConfig) -> Vec, + flush_result: &Result<(), CliError>, + clear_result: &Result<(), CliError>, + instance_id: &str, +) { + for (component, result) in [ + ("sessions", close_result), + ("subscribers", flush_result), + ("plugins", clear_result), + ] { + let Err(error) = result else { + continue; + }; + log::error!( + target: "nemo_relay.server", + event = "server_teardown_failed", + instance_id, + component, + error_kind = error.log_kind(); + "Gateway server teardown failed" + ); + } +} + async fn shutdown_signal() { #[cfg(unix)] { diff --git a/crates/cli/tests/cli_tests.rs b/crates/cli/tests/cli_tests.rs index b74e2e8dc..6ae8c4421 100644 --- a/crates/cli/tests/cli_tests.rs +++ b/crates/cli/tests/cli_tests.rs @@ -723,29 +723,7 @@ fn cli_internal_hermes_install_writes_mcp_hooks_trust_and_doctor_ready_state() { ); let config_path = hermes_home.join("config.yaml"); - let config: serde_json::Value = - serde_yaml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap(); - let server = &config["mcp_servers"]["nemo-relay"]; - assert_eq!(server["command"], gateway_bin()); - assert_eq!(server["args"], serde_json::json!(["mcp"])); - assert_eq!(server["env"]["NEMO_RELAY_GATEWAY_BIND"], "127.0.0.1:47632"); - assert_eq!(server["env"]["OPENAI_API_KEY"], "${OPENAI_API_KEY}"); - assert!( - !std::fs::read_to_string(&config_path) - .unwrap() - .contains("not-written-to-config") - ); - let command = config["hooks"]["on_session_start"][0]["command"] - .as_str() - .unwrap(); - assert!(command.contains("hook-forward hermes")); - let approvals: serde_json::Value = serde_json::from_str( - &std::fs::read_to_string(hermes_home.join("shell-hooks-allowlist.json")).unwrap(), - ) - .unwrap(); - let approvals = approvals["approvals"].as_array().unwrap(); - assert_eq!(approvals.len(), 13); - assert!(approvals.iter().all(|entry| entry["command"] == command)); + assert_hermes_install_config(&config_path, &hermes_home); let relay_config_dir = xdg.join("nemo-relay"); std::fs::create_dir_all(&relay_config_dir).unwrap(); @@ -774,14 +752,7 @@ fn cli_internal_hermes_install_writes_mcp_hooks_trust_and_doctor_ready_state() { String::from_utf8_lossy(&doctor.stderr) ); let report: serde_json::Value = serde_json::from_slice(&doctor.stdout).unwrap(); - assert_eq!(report["agents"][0]["name"], "hermes"); - assert_eq!(report["agents"][0]["status"], "pass"); - assert!( - report["agents"][0]["annotation"] - .as_str() - .unwrap() - .contains("MCP lifecycle") - ); + assert_hermes_doctor_report(&report); let uninstall = Command::new(gateway_bin()) .args(["uninstall", "hermes"]) @@ -801,6 +772,45 @@ fn cli_internal_hermes_install_writes_mcp_hooks_trust_and_doctor_ready_state() { assert!(!hermes_home.join(".nemo-relay-generation").exists()); } +#[cfg(unix)] +fn assert_hermes_install_config(config_path: &std::path::Path, hermes_home: &std::path::Path) { + let config: serde_json::Value = + serde_yaml::from_str(&std::fs::read_to_string(config_path).unwrap()).unwrap(); + let server = &config["mcp_servers"]["nemo-relay"]; + assert_eq!(server["command"], gateway_bin()); + assert_eq!(server["args"], serde_json::json!(["mcp"])); + assert_eq!(server["env"]["NEMO_RELAY_GATEWAY_BIND"], "127.0.0.1:47632"); + assert_eq!(server["env"]["OPENAI_API_KEY"], "${OPENAI_API_KEY}"); + assert!( + !std::fs::read_to_string(config_path) + .unwrap() + .contains("not-written-to-config") + ); + let command = config["hooks"]["on_session_start"][0]["command"] + .as_str() + .unwrap(); + assert!(command.contains("hook-forward hermes")); + let approvals: serde_json::Value = serde_json::from_str( + &std::fs::read_to_string(hermes_home.join("shell-hooks-allowlist.json")).unwrap(), + ) + .unwrap(); + let approvals = approvals["approvals"].as_array().unwrap(); + assert_eq!(approvals.len(), 13); + assert!(approvals.iter().all(|entry| entry["command"] == command)); +} + +#[cfg(unix)] +fn assert_hermes_doctor_report(report: &serde_json::Value) { + assert_eq!(report["agents"][0]["name"], "hermes"); + assert_eq!(report["agents"][0]["status"], "pass"); + assert!( + report["agents"][0]["annotation"] + .as_str() + .unwrap() + .contains("MCP lifecycle") + ); +} + fn start_mcp_client(temp: &std::path::Path, bind: SocketAddr) -> (Child, ChildStdin) { start_mcp_client_with_idle_timeout(temp, bind, "1") } @@ -1971,6 +1981,7 @@ fn cli_plugins_list_json_emits_empty_versioned_success_output() { let config_path = temp.path().join("config.toml"); std::fs::write(&config_path, "").unwrap(); let output = Command::new(gateway_bin()) + .current_dir(temp.path()) .env("XDG_CONFIG_HOME", temp.path().join("xdg")) .env("HOME", temp.path()) .args([ @@ -2088,6 +2099,7 @@ allowed = false .unwrap(); let output = Command::new(gateway_bin()) + .current_dir(temp.path()) .env("XDG_CONFIG_HOME", &xdg) .env("HOME", temp.path()) .args(["plugins", "validate"]) @@ -3513,6 +3525,7 @@ command = "hermes --yolo chat" .unwrap(); let output = Command::new(gateway_bin()) + .current_dir(temp.path()) .env("XDG_CONFIG_HOME", &xdg) .env("HOME", temp.path()) .args([ @@ -3891,6 +3904,7 @@ finally: .unwrap(); let output = Command::new("python3") + .current_dir(temp.path()) .arg(&driver) .arg(gateway_bin()) .arg(&config) @@ -3986,6 +4000,7 @@ fn assert_non_tty_signal_forwarding( ) { let pids = root.join(format!("agent-pids-{signal_name}")); let mut relay = Command::new(gateway_bin()) + .current_dir(root) .args([ "--config", config.to_str().unwrap(), diff --git a/crates/cli/tests/coverage/commands/configure_editor_tests.rs b/crates/cli/tests/coverage/commands/configure_editor_tests.rs index fa0648ce6..ce23eac42 100644 --- a/crates/cli/tests/coverage/commands/configure_editor_tests.rs +++ b/crates/cli/tests/coverage/commands/configure_editor_tests.rs @@ -300,4 +300,80 @@ fn noninteractive_editor_guard_is_deterministic() { error, "configuration error: interactive configuration editing requires a TTY" ); + assert!(ensure_tty_with(true).is_ok()); +} + +#[test] +fn document_accessors_cover_inline_values_defaults_and_invalid_shapes() { + let mut document = document( + "gateway = { max_hook_payload_bytes = 64, max_passthrough_body_bytes = -1 }\nupstream = { openai_base_url = \"https://example.test\", openai_auth_header = 7 }\nlogging = { level = 9 }\n", + ); + + assert_eq!(document.path(), Path::new("config.toml")); + assert_eq!(document.gateway_summary(), "configured"); + assert_eq!(document.upstream_summary(), "configured"); + assert_eq!(document.logging_summary(), "configured"); + assert_eq!( + document.integer_summary("gateway", "max_hook_payload_bytes"), + "64" + ); + assert_eq!( + document.integer_summary("gateway", "max_passthrough_body_bytes"), + "invalid" + ); + assert_eq!( + document.string_summary("upstream", "openai_base_url"), + "https://example.test" + ); + assert_eq!(document.string_summary("logging", "level"), "invalid"); + assert_eq!(document.string_summary("logging", "missing"), "unset"); + assert_eq!(document.secret_summary("anthropic_auth_header"), "unset"); + + document + .set_string("upstream", "openai_base_url", "https://changed.test".into()) + .unwrap(); + assert_eq!( + document.string("upstream", "openai_base_url").as_deref(), + Some("https://changed.test") + ); + document.clear_key("missing", "value").unwrap(); +} + +#[test] +fn sink_accessors_report_invalid_and_incomplete_entries() { + let mut document = document( + "[[logging.sinks]]\npath = 7\nlevel = \"debug\"\nqueue_capacity = -1\nmax_file_size_bytes = 1024\n", + ); + + assert_eq!(document.sink_labels(), ["sink 1 (invalid path)"]); + assert_eq!(document.sink_string_summary(0, "path"), "invalid"); + assert_eq!(document.sink_string_summary(0, "level"), "debug"); + assert_eq!(document.sink_string_summary(0, "format"), "unset"); + assert_eq!( + document.sink_integer_summary(0, "queue_capacity"), + "invalid" + ); + assert_eq!(document.sink_integer_summary(0, "retained_files"), "unset"); + assert_eq!(document.sink_rotation_summary(0), "incomplete"); + + document + .set_sink_string(0, "path", "relay.log".into()) + .unwrap(); + document.clear_sink_key(0, "level").unwrap(); + assert_eq!( + document.sink_string(0, "path").as_deref(), + Some("relay.log") + ); +} + +#[test] +fn project_path_defaults_to_start_when_no_ancestor_config_exists() { + let root = tempfile::tempdir().unwrap(); + let nested = root.path().join("a/b"); + std::fs::create_dir_all(&nested).unwrap(); + + assert_eq!( + project_config_path(&nested), + nested.join(".nemo-relay/config.toml") + ); } diff --git a/crates/cli/tests/coverage/shared/config_tests.rs b/crates/cli/tests/coverage/shared/config_tests.rs index cf74749b9..b72d5a956 100644 --- a/crates/cli/tests/coverage/shared/config_tests.rs +++ b/crates/cli/tests/coverage/shared/config_tests.rs @@ -4148,3 +4148,108 @@ level = "error" } } } + +#[test] +fn configuration_value_helpers_cover_empty_and_invalid_shapes() { + let source = Path::new("plugins.toml"); + let mut scalar = toml::Value::String("not a table".into()); + let resolved = + resolve_dynamic_plugin_refs(source, &mut scalar, &mut std::collections::HashSet::new()) + .unwrap(); + assert!(resolved.dynamic_plugins.is_empty()); + assert_eq!( + resolved.dynamic_plugin_policy, + DynamicPluginHostPolicy::default() + ); + + let value: toml::Value = toml::from_str( + r#" +[plugins] +dynamic = [] +[plugins.policy] +rules = [] +[other] +enabled = true +"#, + ) + .unwrap(); + let cleaned = remove_dynamic_plugin_sections(value); + assert!(cleaned.get("plugins").is_none()); + assert_eq!(cleaned["other"]["enabled"].as_bool(), Some(true)); + assert_eq!(plugin_toml_runtime_value(json!({})), None); + assert_eq!( + plugin_toml_runtime_value(json!({"enabled": true})), + Some(json!({"enabled": true})) + ); + + assert!(validate_auth_header("AUTH", " ".into()).is_err()); + assert!(parse_env_body_limit("LIMIT", "not-a-number").is_err()); + assert_eq!(parse_env_body_limit("LIMIT", "17").unwrap(), 17); +} + +#[test] +fn logging_path_and_sink_helpers_cover_lexical_fallbacks() { + let temp = tempfile::tempdir().unwrap(); + let existing_parent = temp.path().join("logs"); + std::fs::create_dir_all(&existing_parent).unwrap(); + let canonical = std::fs::canonicalize(&existing_parent) + .unwrap() + .join("relay.log"); + assert_eq!( + logging_path_identity(&existing_parent.join("relay.log")), + canonical + ); + + let lexical = logging_path_identity(&temp.path().join("missing/../nested/relay.log")); + assert!(lexical.ends_with("nested/relay.log")); + assert_eq!( + normalize_path_components(Path::new("a/./b/../c")), + PathBuf::from("a/c") + ); + assert_eq!( + normalize_path_components(Path::new("single")), + PathBuf::from("single") + ); + + let path = temp.path().join("coalesced.log"); + let lower = toml::Value::Table(toml::Table::from_iter([ + ( + "path".into(), + toml::Value::String(path.display().to_string()), + ), + ("level".into(), toml::Value::String("info".into())), + ])); + let higher = toml::Value::Table(toml::Table::from_iter([ + ( + "path".into(), + toml::Value::String(path.display().to_string()), + ), + ("format".into(), toml::Value::String("json".into())), + ])); + let coalesced = coalesce_logging_sinks(vec![lower, higher]); + assert_eq!(coalesced.len(), 1); + assert_eq!(coalesced[0]["level"].as_str(), Some("info")); + assert_eq!(coalesced[0]["format"].as_str(), Some("json")); + + let mut invalid_higher: toml::Value = toml::from_str("[logging]\nsinks = 'invalid'").unwrap(); + let lower = toml::Value::Table(toml::Table::new()); + merge_logging_sinks_by_path(&lower, &mut invalid_higher); + assert_eq!(invalid_higher["logging"]["sinks"].as_str(), Some("invalid")); +} + +#[test] +fn dynamic_plugin_identity_allows_worker_without_manifest() { + let component = ActiveDynamicPluginComponent { + plugin_id: "acme.manual-worker".into(), + kind: DynamicPluginKind::Worker, + lifecycle_generation: 7, + manifest_ref: None, + environment_ref: None, + config: serde_json::Map::new(), + activation_snapshot: None, + }; + let identity = dynamic_plugin_bootstrap_identity(&component).unwrap(); + assert_eq!(identity["plugin_id"], "acme.manual-worker"); + assert_eq!(identity["manifest"], Value::Null); + assert_eq!(identity["lifecycle_generation"], 7); +} diff --git a/crates/cli/tests/coverage/shared/installer_tests.rs b/crates/cli/tests/coverage/shared/installer_tests.rs index 96ff9475c..f77f7d74d 100644 --- a/crates/cli/tests/coverage/shared/installer_tests.rs +++ b/crates/cli/tests/coverage/shared/installer_tests.rs @@ -580,6 +580,13 @@ fn packaged_plugin_hooks_use_expected_forwarding_commands() { fn packaged_plugin_manifests_use_stable_plugin_name_and_version() { let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("../../integrations/coding-agents"); + + assert_agent_plugin_manifests(&root); + assert_agent_mcp_manifests(&root); + assert_agent_marketplace_manifests(&root); +} + +fn assert_agent_plugin_manifests(root: &std::path::Path) { let claude_path = root.join("claude-code/.claude-plugin/plugin.json"); let claude = serde_json::from_str::(&std::fs::read_to_string(&claude_path).unwrap()).unwrap(); @@ -595,7 +602,9 @@ fn packaged_plugin_manifests_use_stable_plugin_name_and_version() { assert_eq!(codex["version"], json!(env!("CARGO_PKG_VERSION"))); assert!(codex.get("hooks").is_none()); assert_eq!(codex["mcpServers"], json!("./.mcp.json")); +} +fn assert_agent_mcp_manifests(root: &std::path::Path) { let codex_mcp_path = root.join("codex/.mcp.json"); let codex_mcp = serde_json::from_str::(&std::fs::read_to_string(&codex_mcp_path).unwrap()).unwrap(); @@ -628,7 +637,9 @@ fn packaged_plugin_manifests_use_stable_plugin_name_and_version() { json!({"NEMO_RELAY_GATEWAY_BIND": "127.0.0.1:47632"}) ); assert_eq!(claude_server["alwaysLoad"], json!(true)); +} +fn assert_agent_marketplace_manifests(root: &std::path::Path) { let codex_marketplace_path = root.join("../../.agents/plugins/marketplace.json"); let codex_marketplace = serde_json::from_str::(&std::fs::read_to_string(&codex_marketplace_path).unwrap()) diff --git a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs index 4770cedc4..efcef6488 100644 --- a/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_lifecycle_tests.rs @@ -1918,21 +1918,7 @@ fn add_provisions_persists_and_removes_managed_python_environment() { .as_deref() .expect("managed environment should be persisted"); let environment_path = PathBuf::from(environment_ref); - assert!(environment_path.is_absolute()); - let expected_environment_name = Sha256::digest(b"acme.python") - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::(); - assert_eq!( - environment_path.file_name(), - Some(OsStr::new(&expected_environment_name)) - ); - assert!( - environment_path - .parent() - .is_some_and(|parent| parent.ends_with(".dynamic-plugin-environments")) - ); - assert!(environment::environment_python_path(&environment_path).is_file()); + assert_managed_environment_path(&environment_path); assert_eq!( added.record.status.validation.environment, DynamicPluginCheckState::Valid @@ -1956,39 +1942,8 @@ fn add_provisions_persists_and_removes_managed_python_environment() { inspect["data"]["source"]["environment_ref"], serde_json::json!(environment_ref) ); - let calls = runner.calls(); - assert_eq!(calls.len(), 2); - assert_eq!( - calls[0].0, - OsString::from(if cfg!(windows) { "python" } else { "python3" }) - ); - assert_eq!( - calls[0].1, - vec![ - OsString::from("-m"), - OsString::from("venv"), - environment_path.as_os_str().to_owned(), - ] - ); - assert_eq!( - PathBuf::from(&calls[1].0), - environment::environment_python_path(&environment_path) - ); - assert_eq!( - calls[1].1, - vec![ - OsString::from("-m"), - OsString::from("pip"), - OsString::from("install"), - plugin_dir.canonicalize().unwrap().into_os_string(), - ] - ); - assert!( - !calls[1] - .1 - .iter() - .any(|arg| arg == "-e" || arg == "--editable") - ); + assert_python_environment_runner_calls(&runner.calls(), &environment_path, &plugin_dir); + enable( PluginsEnableRequest { id: "acme.python".into(), @@ -2031,6 +1986,63 @@ fn add_provisions_persists_and_removes_managed_python_environment() { assert!(!stale_marker.exists()); } +fn assert_managed_environment_path(environment_path: &Path) { + assert!(environment_path.is_absolute()); + let expected_environment_name = Sha256::digest(b"acme.python") + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + assert_eq!( + environment_path.file_name(), + Some(OsStr::new(&expected_environment_name)) + ); + assert!( + environment_path + .parent() + .is_some_and(|parent| parent.ends_with(".dynamic-plugin-environments")) + ); + assert!(environment::environment_python_path(environment_path).is_file()); +} + +fn assert_python_environment_runner_calls( + calls: &[(OsString, Vec)], + environment_path: &Path, + plugin_dir: &Path, +) { + assert_eq!(calls.len(), 2); + assert_eq!( + calls[0].0, + OsString::from(if cfg!(windows) { "python" } else { "python3" }) + ); + assert_eq!( + calls[0].1, + vec![ + OsString::from("-m"), + OsString::from("venv"), + environment_path.as_os_str().to_owned(), + ] + ); + assert_eq!( + PathBuf::from(&calls[1].0), + environment::environment_python_path(environment_path) + ); + assert_eq!( + calls[1].1, + vec![ + OsString::from("-m"), + OsString::from("pip"), + OsString::from("install"), + plugin_dir.canonicalize().unwrap().into_os_string(), + ] + ); + assert!( + !calls[1] + .1 + .iter() + .any(|arg| arg == "-e" || arg == "--editable") + ); +} + #[test] fn add_rolls_back_python_environment_when_installation_fails() { let temp = tempfile::tempdir().unwrap(); @@ -4244,3 +4256,279 @@ fn inspect_distinguishes_empty_host_config_from_missing_host_config() { 0 ); } + +fn required_lifecycle_record( + temp: &tempfile::TempDir, + plugin_id: &str, +) -> ScopedDynamicPluginRecord { + let plugin_dir = temp.path().join(plugin_id); + std::fs::create_dir_all(&plugin_dir).unwrap(); + write_dynamic_manifest(&plugin_dir, plugin_id); + let server = GatewayOverrides::default(); + add( + PluginsAddRequest { + scope: ConfigurationScope::Project, + path: plugin_dir, + }, + &server, + ) + .unwrap(); + let scopes = load_scoped_registries(None).unwrap(); + let mut entry = find_record_by_id(&scopes, plugin_id).unwrap().unwrap(); + entry.record.status.startup_class = + Some(nemo_relay::plugin::dynamic::DynamicPluginStartupClass::Required); + entry +} + +#[test] +fn lifecycle_helpers_cover_environment_manifest_scope_and_restore_paths() { + let temp = tempfile::tempdir().unwrap(); + let plugin_id = "acme.lifecycle-helpers"; + assert_eq!( + environment_last_error(plugin_id, DynamicPluginCheckState::Valid, None), + None + ); + let missing = environment_last_error(plugin_id, DynamicPluginCheckState::Invalid, None) + .expect("invalid environment should produce a diagnostic"); + assert_eq!(missing.code, "environment_failed"); + assert!(missing.message.contains("has no lifecycle-managed")); + let unavailable = environment_last_error( + plugin_id, + DynamicPluginCheckState::Invalid, + Some("managed/python"), + ) + .expect("invalid referenced environment should produce a diagnostic"); + assert!( + unavailable + .message + .contains("managed/python is unavailable") + ); + + let mut scopes = Vec::new(); + let plugins_path = temp.path().join("plugins.toml"); + let state_path = temp.path().join("state.json"); + let first = ensure_scope( + &mut scopes, + RegistryScope::Project, + plugins_path.clone(), + state_path.clone(), + ); + let existing = ensure_scope( + &mut scopes, + RegistryScope::Project, + plugins_path.clone(), + state_path, + ); + assert_eq!((first, existing, scopes.len()), (0, 0, 1)); + assert!(!scope_flags_selected(&ConfigurationScope::Default)); + assert!(scope_flags_selected(&ConfigurationScope::Project)); + + restore_plugins_toml(&plugins_path, Some(b"[plugins]\n")).unwrap(); + assert_eq!(std::fs::read(&plugins_path).unwrap(), b"[plugins]\n"); + restore_plugins_toml(&plugins_path, None).unwrap(); + assert!(!plugins_path.exists()); + restore_plugins_toml(&plugins_path, None).unwrap(); +} + +#[test] +fn manifest_helpers_report_missing_and_invalid_sources() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let _cwd = CurrentDirGuard::enter(temp.path()); + let mut entry = required_lifecycle_record(&temp, "acme.manifest-helpers"); + entry.record.source.manifest_ref = None; + let error = manifest_ref_from_record(&entry.record).unwrap_err(); + assert!(error.to_string().contains("has no manifest_ref")); + + let missing = temp.path().join("missing-manifest.toml"); + let error = load_manifest_for_action("inspect", &missing).unwrap_err(); + assert!(error.to_string().contains("dynamic plugin inspect failed")); +} + +#[test] +fn required_startup_failure_reports_policy_trust_and_environment_failures() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let _cwd = CurrentDirGuard::enter(temp.path()); + let mut entry = required_lifecycle_record(&temp, "acme.required-checks"); + + entry.record.status.validation.policy_satisfied = DynamicPluginCheckState::Invalid; + let failure = required_startup_failure(&entry, &[]).unwrap(); + assert!(failure.contains("blocked by host policy")); + + entry.record.status.validation.policy_satisfied = DynamicPluginCheckState::Valid; + entry.record.status.validation.integrity = DynamicPluginCheckState::Invalid; + let failure = required_startup_failure(&entry, &[]).unwrap(); + assert!(failure.contains("trust verification failed")); + + entry.record.status.validation.integrity = DynamicPluginCheckState::Valid; + entry.record.status.validation.environment = DynamicPluginCheckState::Invalid; + let failure = required_startup_failure(&entry, &[]).unwrap(); + assert!(failure.contains("environment is unavailable")); +} + +#[test] +fn required_startup_failure_reports_custom_and_manifest_diagnostics() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let _cwd = CurrentDirGuard::enter(temp.path()); + let mut entry = required_lifecycle_record(&temp, "acme.required-manifest"); + entry.record.status.validation.policy_satisfied = DynamicPluginCheckState::Invalid; + entry.record.status.last_error = Some(DynamicPluginFailure { + phase: DynamicPluginFailurePhase::Validation, + code: "custom_failure".into(), + message: "custom lifecycle diagnostic".into(), + }); + let failure = required_startup_failure(&entry, &[]).unwrap(); + assert!(failure.contains("custom lifecycle diagnostic")); + + entry.record.status.last_error = None; + entry.record.status.validation.policy_satisfied = DynamicPluginCheckState::Valid; + entry.record.source.manifest_ref = None; + let failure = required_startup_failure(&entry, &[]).unwrap(); + assert!(failure.contains("has no manifest_ref")); + + let missing = temp.path().join("removed-manifest.toml"); + entry.record.source.manifest_ref = Some(missing.display().to_string()); + let failure = required_startup_failure(&entry, &[]).unwrap(); + assert!(failure.contains("is no longer available")); + + let invalid = temp.path().join("invalid-manifest.toml"); + std::fs::write(&invalid, b"not valid TOML = [").unwrap(); + entry.record.source.manifest_ref = Some(invalid.display().to_string()); + let failure = required_startup_failure(&entry, &[]).unwrap(); + assert!(failure.contains("is unreadable")); +} + +#[test] +fn required_startup_failure_accepts_optional_and_readable_plugins() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let _cwd = CurrentDirGuard::enter(temp.path()); + let mut entry = required_lifecycle_record(&temp, "acme.required-ready"); + assert_eq!(required_startup_failure(&entry, &[]), None); + + entry.record.status.startup_class = + Some(nemo_relay::plugin::dynamic::DynamicPluginStartupClass::Optional); + entry.record.status.validation.policy_satisfied = DynamicPluginCheckState::Invalid; + assert_eq!(required_startup_failure(&entry, &[]), None); +} + +#[test] +fn lifecycle_commands_cover_json_and_human_output_paths() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let _cwd = CurrentDirGuard::enter(temp.path()); + let server = GatewayOverrides::default(); + + list( + PluginsListRequest { + all: false, + json: false, + }, + &server, + ) + .unwrap(); + list( + PluginsListRequest { + all: false, + json: true, + }, + &server, + ) + .unwrap(); + + let plugin_dir = temp.path().join("output-plugin"); + std::fs::create_dir_all(&plugin_dir).unwrap(); + let manifest_path = write_dynamic_manifest(&plugin_dir, "acme.output"); + add( + PluginsAddRequest { + scope: ConfigurationScope::Project, + path: plugin_dir, + }, + &server, + ) + .unwrap(); + + validate( + PluginsValidateRequest { + target: manifest_path.display().to_string(), + json: false, + }, + &server, + ) + .unwrap(); + validate( + PluginsValidateRequest { + target: manifest_path.display().to_string(), + json: true, + }, + &server, + ) + .unwrap(); + validate( + PluginsValidateRequest { + target: "acme.output".into(), + json: false, + }, + &server, + ) + .unwrap(); + validate( + PluginsValidateRequest { + target: "acme.output".into(), + json: true, + }, + &server, + ) + .unwrap(); + list( + PluginsListRequest { + all: false, + json: false, + }, + &server, + ) + .unwrap(); + list( + PluginsListRequest { + all: false, + json: true, + }, + &server, + ) + .unwrap(); + inspect( + PluginsInspectRequest { + id: "acme.output".into(), + json: false, + }, + &server, + ) + .unwrap(); + inspect( + PluginsInspectRequest { + id: "acme.output".into(), + json: true, + }, + &server, + ) + .unwrap(); +} + +#[test] +fn validate_rejects_a_missing_path_target() { + let temp = tempfile::tempdir().unwrap(); + let _env = EnvScope::hermetic(&temp); + let _cwd = CurrentDirGuard::enter(temp.path()); + let missing = temp.path().join("missing").join("relay-plugin.toml"); + let error = validate( + PluginsValidateRequest { + target: missing.display().to_string(), + json: false, + }, + &GatewayOverrides::default(), + ) + .unwrap_err(); + assert!(error.to_string().contains("does not exist")); +} diff --git a/crates/cli/tests/coverage/shared/plugins_schema_tests.rs b/crates/cli/tests/coverage/shared/plugins_schema_tests.rs index e126f62e7..9b1f9fbec 100644 --- a/crates/cli/tests/coverage/shared/plugins_schema_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_schema_tests.rs @@ -272,14 +272,21 @@ fn maps_native_nested_map_and_raw_controls() { "union": {"oneOf": [{"type": "string"}, {"type": "number"}]} } })); - let field = |key: &str| { - loaded - .fields() - .iter() - .find(|field| field.key == key) - .unwrap() - }; + assert_native_raw_and_scalar_fields(&loaded); + assert_native_choice_and_nested_fields(&loaded); + assert!(loaded.editor().title.is_none()); +} +fn native_config_field<'a>(schema: &'a PluginConfigSchema, key: &str) -> &'a DynamicConfigField { + schema + .fields() + .iter() + .find(|field| field.key == key) + .unwrap() +} + +fn assert_native_raw_and_scalar_fields(schema: &PluginConfigSchema) { + let field = |key| native_config_field(schema, key); assert!(matches!( field("array").kind, DynamicConfigFieldKind::RawJson @@ -307,6 +314,10 @@ fn maps_native_nested_map_and_raw_controls() { assert_eq!(field("enabled").title, "Enabled"); assert_eq!(field("enabled").default, Some(json!(true))); assert!(field("enabled").required); +} + +fn assert_native_choice_and_nested_fields(schema: &PluginConfigSchema) { + let field = |key| native_config_field(schema, key); assert!(matches!( field("choice").kind, DynamicConfigFieldKind::StringEnum { ref options, secret: false } @@ -320,7 +331,6 @@ fn maps_native_nested_map_and_raw_controls() { && fields[0].description.as_deref() == Some("Weight") && matches!(fields[0].kind, DynamicConfigFieldKind::Number) )); - assert!(loaded.editor().title.is_none()); } #[test] diff --git a/crates/cli/tests/coverage/shared/plugins_tests.rs b/crates/cli/tests/coverage/shared/plugins_tests.rs index cd6cc9abf..3deed9031 100644 --- a/crates/cli/tests/coverage/shared/plugins_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_tests.rs @@ -320,6 +320,12 @@ fn typed_editor_model_contains_nemo_guardrails_options() { #[test] fn typed_editor_model_contains_pii_redaction_options() { let schema = PiiRedactionConfig::editor_schema(); + assert_pii_root_editor_fields(schema); + assert_pii_builtin_editor_fields(schema.field("builtin").unwrap().schema().unwrap()); + assert_pii_local_editor_fields(schema.field("local").unwrap().schema().unwrap()); +} + +fn assert_pii_root_editor_fields(schema: &EditorSchema) { assert!(!schema.fields.iter().any(|field| field.name == "version")); assert_eq!( schema.field("mode").unwrap().enum_values, @@ -331,8 +337,9 @@ fn typed_editor_model_contains_pii_redaction_options() { schema.field("tool_output").unwrap().kind, EditorFieldKind::Boolean ); +} - let builtin = schema.field("builtin").unwrap().schema().unwrap(); +fn assert_pii_builtin_editor_fields(builtin: &EditorSchema) { assert_eq!(builtin.field("preset").unwrap().kind, EditorFieldKind::Enum); assert!( builtin @@ -398,8 +405,9 @@ fn typed_editor_model_contains_pii_redaction_options() { builtin.field("unmasked_suffix").unwrap().kind, EditorFieldKind::Integer ); +} - let local = schema.field("local").unwrap().schema().unwrap(); +fn assert_pii_local_editor_fields(local: &EditorSchema) { assert_eq!( local.field("backend").unwrap().kind, EditorFieldKind::String @@ -588,6 +596,216 @@ fn component_field_clear_only_removes_optional_fields() { assert!(!pii_redaction.field_configured(input)); } +#[test] +fn menu_keys_cover_selection_shortcuts_and_cancellation() { + assert_eq!( + menu_response_for_key(&Key::Enter, 2), + Some(MenuResponse::Selected(2)) + ); + assert_eq!( + menu_response_for_key(&Key::Char(' '), 3), + Some(MenuResponse::Selected(3)) + ); + assert_eq!( + menu_response_for_key(&Key::Char('p'), 1), + Some(MenuResponse::Shortcut(MenuShortcut::Preview, 1)) + ); + assert_eq!( + menu_response_for_key(&Key::Char('s'), 1), + Some(MenuResponse::Shortcut(MenuShortcut::Save, 1)) + ); + assert_eq!( + menu_response_for_key(&Key::Char('r'), 1), + Some(MenuResponse::Shortcut(MenuShortcut::Reset, 1)) + ); + assert_eq!( + menu_response_for_key(&Key::Backspace, 1), + Some(MenuResponse::Shortcut(MenuShortcut::Clear, 1)) + ); + assert_eq!( + menu_response_for_key(&Key::Char('?'), 1), + Some(MenuResponse::Shortcut(MenuShortcut::Help, 1)) + ); + assert_eq!( + menu_response_for_key(&Key::Escape, 1), + Some(MenuResponse::Cancel) + ); + assert_eq!(menu_response_for_key(&Key::Char('x'), 1), None); +} + +#[test] +fn section_menu_helpers_render_defaults_and_reset_sections() { + let mut config = ObservabilityConfig::default(); + let section = ObservabilityConfig::editor_schema().field("atof").unwrap(); + let fields = section.schema().unwrap().fields; + + let items = section_menu_items(&config, section, fields).unwrap(); + assert_eq!(items.len(), fields.len() + 3); + assert!(items.last().unwrap().label.contains("Back")); + assert_eq!(selected_field_index(section, 1), 0); + assert_eq!(reset_section_index(section, fields), fields.len() + 1); + + ensure_section(&mut config, section); + reset_selected_item( + &mut config, + section, + fields, + reset_section_index(section, fields), + ) + .unwrap(); + assert!(section_configured(&config, section)); +} + +#[test] +fn value_menu_helpers_store_reset_and_clear_nested_values() { + static FIELDS: [EditorFieldSpec; 2] = [ + EditorFieldSpec { + name: "optional", + label: "Optional", + kind: EditorFieldKind::String, + enum_values: &[], + optional: true, + nested_schema: None, + nested_default: None, + list_item: None, + tagged_union: None, + }, + EditorFieldSpec { + name: "required", + label: "Required", + kind: EditorFieldKind::String, + enum_values: &[], + optional: false, + nested_schema: None, + nested_default: None, + list_item: None, + tagged_union: None, + }, + ]; + static SCHEMA: EditorSchema = EditorSchema { fields: &FIELDS }; + let optional = FIELDS[0]; + let required = FIELDS[1]; + let mut value = json!({}); + set_value_field(&mut value, optional.name, json!("configured")); + set_value_field(&mut value, required.name, Value::Null); + + let items = value_section_menu_items(&value, &SCHEMA, None).unwrap(); + assert_eq!(items.len(), SCHEMA.fields.len() + 2); + assert!(value_field_configured(&value, optional, None)); + assert!(clear_value_field( + &mut value, + &SCHEMA, + SCHEMA + .fields + .iter() + .position(|field| field.name == optional.name) + .unwrap() + )); + assert!(!value_field_configured(&value, optional, None)); + assert!(!clear_value_field(&mut value, &SCHEMA, SCHEMA.fields.len())); + + reset_value_section_item( + &mut value, + &SCHEMA, + Some(&json!({"restored": true})), + SCHEMA.fields.len(), + ); + assert_eq!(value, json!({"restored": true})); +} + +#[test] +fn editor_storage_helpers_preserve_nonempty_values_and_prune_empty_sections() { + let field = EditorFieldSpec { + name: "section", + label: "Section", + kind: EditorFieldKind::Section, + enum_values: &[], + optional: true, + nested_schema: None, + nested_default: None, + list_item: None, + tagged_union: None, + }; + let mut config = json!({}); + + store_edited_config_section(&mut config, field, json!({"enabled": true})).unwrap(); + assert!(config_field_value(&config, field.name).unwrap().is_some()); + store_edited_config_section(&mut config, field, json!({})).unwrap(); + assert!(config_field_value(&config, field.name).unwrap().is_none()); + + let mut target = json!({"atof": {"enabled": true}}); + store_edited_value_section(&mut target, field, json!({})); + assert!(value_field_value(&target, field.name).is_none()); + store_edited_value_section(&mut target, field, json!({"enabled": true})); + assert_eq!( + value_field_value(&target, field.name), + Some(json!({"enabled": true})) + ); +} + +#[test] +fn component_shortcut_fallbacks_are_safe_noops() { + let mut config = PluginConfig::default(); + ensure_observability_component(&mut config).unwrap(); + let mut components = editable_components(&config).unwrap(); + + assert_eq!( + handle_reset_or_clear_shortcut(&mut components, None, MenuShortcut::Reset).unwrap(), + EditLoopControl::Continue + ); + reset_component_menu_item(&mut components[0], None).unwrap(); + clear_component_menu_item(&mut components[0], Some(ComponentMenuAction::Back)).unwrap(); +} + +#[test] +fn editable_component_dispatch_covers_every_component_variant() { + let config = PluginConfig::default(); + let mut components = editable_components(&config).unwrap(); + + for component in &mut components { + assert!(!component.label().is_empty()); + assert!(!component.fields().is_empty()); + assert!(!component.summary().is_empty()); + component.toggle_enabled(); + component.set_enabled(true); + component.reset_enabled(); + let optional = *component + .fields() + .iter() + .find(|field| field.optional) + .expect("every editable component exposes an optional field"); + component.reset_field(optional).unwrap(); + assert!(component.clear_field(optional).unwrap()); + } + + let rendered = config_with_editable_components(&config, &components).unwrap(); + let mut stored = config.clone(); + store_editable_components(&mut stored, &components).unwrap(); + assert_eq!( + serde_json::to_value(rendered).unwrap(), + serde_json::to_value(stored).unwrap() + ); +} + +#[test] +fn editor_model_object_and_schema_helpers_cover_fallbacks() { + let mut scalar = json!("replace me"); + ensure_object(&mut scalar).insert("enabled".into(), json!(true)); + assert_eq!(scalar, json!({"enabled": true})); + + let fields = observability_editor_fields_with_version(); + assert_eq!(fields.first(), Some(&"version")); + let nested = nested_editor_keys(ObservabilityConfig::editor_schema()); + assert!(nested.contains(&"atof")); + + let error = serde_json::from_str::("{").unwrap_err(); + assert!( + serde_error(error) + .to_string() + .contains("invalid plugin editor value") + ); +} + #[test] fn menu_viewport_keeps_selection_visible_and_pages() { let first = menu_viewport(20, 0, 8); @@ -1660,6 +1878,10 @@ value = "preserve-host-section" document.write().unwrap(); let rendered = std::fs::read_to_string(&path).unwrap(); let root = rendered.parse::().unwrap(); + assert_preserved_plugin_document(&root); +} + +fn assert_preserved_plugin_document(root: &toml::Table) { assert_eq!(root["host_setting"].as_str(), Some("preserve-me")); assert_eq!( root["host"]["extra"]["value"].as_str(), @@ -1846,6 +2068,131 @@ config = {} let document = PluginConfigDocument::read(&path).unwrap(); let mut states = load_dynamic_plugin_states(&document).unwrap(); + assert_dynamic_editor_initial_states(&states); + + states[2].clear_top_level_field("optional"); + states[2].reset_top_level_field("optional").unwrap(); + assert_eq!(states[2].config(), None); + + let mut preview = document.clone(); + for state in &states { + state.apply_to_document(&mut preview, true).unwrap(); + } + assert_dynamic_editor_redacted_preview(&preview.render().unwrap()); + + states[0].reset_top_level_field("retries").unwrap(); + assert_eq!(states[0].config().unwrap().get("retries"), Some(&json!(3))); + assert_eq!( + states[0].config().unwrap().get("unknown"), + Some(&json!({"nested": "keep"})) + ); + let mut touched = document.clone(); + states[0].apply_to_document(&mut touched, false).unwrap(); + assert_dynamic_editor_touched_document(&touched); + for field in ["token", "retries", "unknown", "observed_at", "records"] { + states[0].clear_top_level_field(field); + } + assert_eq!(states[0].config(), Some(&Map::new())); + + states[0].reset(); + let mut persisted = document.clone(); + for state in &states { + state.apply_to_document(&mut persisted, false).unwrap(); + } + assert_dynamic_editor_persisted_document(&persisted); +} + +#[test] +fn dynamic_editor_menu_actions_reset_clear_and_render_fields() { + let temp = tempfile::tempdir().unwrap(); + write_editor_dynamic_manifest( + &temp.path().join("plugin"), + "acme.menu", + Some("Menu Plugin"), + Some(&json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["mode"], + "properties": { + "mode": {"type": "string", "default": "safe"}, + "token": {"type": "string", "writeOnly": true, "default": "secret"} + } + })), + ); + let path = temp.path().join("plugins.toml"); + std::fs::write( + &path, + "[[plugins.dynamic]]\nmanifest = \"./plugin/relay-plugin.toml\"\nconfig = { mode = \"fast\" }\n", + ) + .unwrap(); + let document = PluginConfigDocument::read(&path).unwrap(); + let mut states = load_dynamic_plugin_states(&document).unwrap(); + let state = &mut states[0]; + let fields = state.editor_fields().to_vec(); + + let (items, actions) = dynamic_field_menu_items(state, &fields, &[]); + assert_eq!(items.len(), fields.len() + 2); + assert!(items.iter().any(|item| item.label.contains("[required]"))); + assert!(items.iter().any(|item| item.label.contains(""))); + + let mode = fields.iter().position(|field| field.key == "mode").unwrap(); + reset_dynamic_selection(state, &fields, &[], &actions, mode); + assert_eq!(state.config().unwrap().get("mode"), Some(&json!("safe"))); + clear_dynamic_selection(state, &fields, &[], &actions, mode); + assert!(!state.config().unwrap().contains_key("mode")); + + let reset = actions + .iter() + .position(|action| matches!(action, DynamicMenuAction::ResetPlugin)) + .unwrap(); + reset_dynamic_selection(state, &fields, &[], &actions, reset); + assert_eq!(state.config(), None); +} + +#[test] +fn dynamic_editor_raw_menu_and_nested_value_paths_are_deterministic() { + let temp = tempfile::tempdir().unwrap(); + write_editor_dynamic_manifest(&temp.path().join("plugin"), "acme.raw-menu", None, None); + let path = temp.path().join("plugins.toml"); + std::fs::write( + &path, + "[[plugins.dynamic]]\nmanifest = \"./plugin/relay-plugin.toml\"\n", + ) + .unwrap(); + let document = PluginConfigDocument::read(&path).unwrap(); + let states = load_dynamic_plugin_states(&document).unwrap(); + let (items, actions) = dynamic_root_menu_items(&states[0], &[]); + assert_eq!(items.len(), 3); + assert!(matches!(actions[0], DynamicMenuAction::EditRawConfig)); + + let mut config = None; + let path = vec!["outer".to_owned(), "inner".to_owned()]; + set_value_at_path(&mut config, &path, json!(7)); + assert_eq!(value_at_path(config.as_ref(), &path), Some(&json!(7))); + assert_eq!(value_at_path(config.as_ref(), &[]), None); + assert!(remove_value_at_path(config.as_mut().unwrap(), &path)); + set_value_at_path(&mut config, &[], json!(9)); + assert!(config.as_ref().unwrap().is_empty()); +} + +#[test] +fn dynamic_editor_rejects_duplicate_plugin_ids() { + let temp = tempfile::tempdir().unwrap(); + write_editor_dynamic_manifest(&temp.path().join("plugin"), "acme.duplicate", None, None); + let path = temp.path().join("plugins.toml"); + std::fs::write( + &path, + "[[plugins.dynamic]]\nmanifest = \"./plugin/relay-plugin.toml\"\n\n[[plugins.dynamic]]\nmanifest = \"./plugin/relay-plugin.toml\"\n", + ) + .unwrap(); + let document = PluginConfigDocument::read(&path).unwrap(); + let error = load_dynamic_plugin_states(&document) + .unwrap_err() + .to_string(); + assert!(error.contains("declared more than once"), "{error}"); +} + +fn assert_dynamic_editor_initial_states(states: &[DynamicPluginEditorState]) { assert_eq!(states.len(), 4); assert_eq!(states[0].label(), "Structured Plugin (acme.structured)"); assert_eq!(states[1].label(), "acme.raw"); @@ -1863,16 +2210,9 @@ config = {} let labels = states[0].top_level_field_labels(); assert!(labels.iter().any(|label| label.contains(""))); assert!(labels.iter().all(|label| !label.contains("super-secret"))); +} - states[2].clear_top_level_field("optional"); - states[2].reset_top_level_field("optional").unwrap(); - assert_eq!(states[2].config(), None); - - let mut preview = document.clone(); - for state in &states { - state.apply_to_document(&mut preview, true).unwrap(); - } - let rendered = preview.render().unwrap(); +fn assert_dynamic_editor_redacted_preview(rendered: &str) { assert!(rendered.contains("")); assert!(!rendered.contains("super-secret")); assert!(!rendered.contains("nested-secret")); @@ -1890,15 +2230,9 @@ config = {} .get("config") .is_none() ); +} - states[0].reset_top_level_field("retries").unwrap(); - assert_eq!(states[0].config().unwrap().get("retries"), Some(&json!(3))); - assert_eq!( - states[0].config().unwrap().get("unknown"), - Some(&json!({"nested": "keep"})) - ); - let mut touched = document.clone(); - states[0].apply_to_document(&mut touched, false).unwrap(); +fn assert_dynamic_editor_touched_document(touched: &PluginConfigDocument) { assert_eq!( touched.dynamic_entries().unwrap()[0] .config @@ -1912,18 +2246,9 @@ config = {} touched_root["plugins"]["dynamic"].as_array().unwrap()[0]["config"]["observed_at"] .is_datetime() ); - states[0].clear_top_level_field("token"); - states[0].clear_top_level_field("retries"); - states[0].clear_top_level_field("unknown"); - states[0].clear_top_level_field("observed_at"); - states[0].clear_top_level_field("records"); - assert_eq!(states[0].config(), Some(&Map::new())); +} - states[0].reset(); - let mut persisted = document.clone(); - for state in &states { - state.apply_to_document(&mut persisted, false).unwrap(); - } +fn assert_dynamic_editor_persisted_document(persisted: &PluginConfigDocument) { let entries = persisted.dynamic_entries().unwrap(); assert_eq!(entries[0].config, None); assert_eq!(entries[2].config, None); @@ -1996,6 +2321,96 @@ fn plugin_config_document_reports_invalid_dynamic_entries_and_indexes() { ); } +#[test] +fn plugin_config_document_reports_each_dynamic_entry_shape_error() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("plugins.toml"); + + std::fs::write(&path, "[plugins]\n").unwrap(); + let mut document = PluginConfigDocument::read(&path).unwrap(); + assert!(document.dynamic_entries().unwrap().is_empty()); + assert!(document.remove_dynamic_config(0).is_err()); + + std::fs::write(&path, "[plugins]\ndynamic = [1]\n").unwrap(); + let mut document = PluginConfigDocument::read(&path).unwrap(); + assert!( + document + .dynamic_entries() + .unwrap_err() + .to_string() + .contains("must be a table") + ); + assert!( + document + .remove_dynamic_config(0) + .unwrap_err() + .to_string() + .contains("must be a table") + ); + + std::fs::write(&path, "[[plugins.dynamic]]\nconfig = {}\n").unwrap(); + let document = PluginConfigDocument::read(&path).unwrap(); + assert!( + document + .dynamic_entries() + .unwrap_err() + .to_string() + .contains("manifest must be a string") + ); + + std::fs::write( + &path, + "[[plugins.dynamic]]\nmanifest = 'plugin.toml'\nconfig = 'invalid'\n", + ) + .unwrap(); + let document = PluginConfigDocument::read(&path).unwrap(); + assert!( + document + .dynamic_entries() + .unwrap_err() + .to_string() + .contains("config must be a table") + ); +} + +#[test] +fn dynamic_config_patching_covers_add_replace_remove_and_object_diff() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("plugins.toml"); + std::fs::write(&path, "[[plugins.dynamic]]\nmanifest = 'plugin.toml'\n").unwrap(); + let mut document = PluginConfigDocument::read(&path).unwrap(); + + let first = Map::from_iter([("first".into(), json!(1))]); + document + .patch_dynamic_config(0, None, Some(first.clone())) + .unwrap(); + document.remove_dynamic_config(0).unwrap(); + document + .patch_dynamic_config(0, Some(&Map::new()), Some(first.clone())) + .unwrap(); + + let updated = Map::from_iter([("first".into(), json!(2)), ("second".into(), json!(true))]); + document + .patch_dynamic_config(0, Some(&first), Some(updated.clone())) + .unwrap(); + assert_eq!(document.dynamic_entries().unwrap()[0].config, Some(updated)); + document.patch_dynamic_config(0, None, None).unwrap(); + assert_eq!(document.dynamic_entries().unwrap()[0].config, None); +} + +#[test] +fn remove_dynamic_plugin_reference_covers_absent_container_paths() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("plugins.toml"); + assert!(!remove_dynamic_plugin_reference(&path, "acme.missing", None).unwrap()); + + std::fs::write(&path, "version = 1\n").unwrap(); + assert!(!remove_dynamic_plugin_reference(&path, "acme.missing", None).unwrap()); + + std::fs::write(&path, "[plugins]\npolicy = {}\n").unwrap(); + assert!(!remove_dynamic_plugin_reference(&path, "acme.missing", None).unwrap()); +} + #[test] fn write_plugin_config_prunes_defaults_and_round_trips() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/cli/tests/coverage/shared/server_tests.rs b/crates/cli/tests/coverage/shared/server_tests.rs index b51cc44bf..e25c048c7 100644 --- a/crates/cli/tests/coverage/shared/server_tests.rs +++ b/crates/cli/tests/coverage/shared/server_tests.rs @@ -673,6 +673,36 @@ fn readiness_file_is_published_atomically_with_gateway_identity() { assert!(!path.with_extension("json.tmp").exists()); } +#[tokio::test] +async fn bind_listener_reports_an_actionable_address_conflict() { + let occupied = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = occupied.local_addr().unwrap(); + let error = bind_listener(address).await.unwrap_err(); + let message = error.to_string(); + assert!(message.contains("port is already in use")); + assert!(message.contains("ephemeral port")); +} + +#[test] +fn readiness_file_reports_write_and_publish_failures() { + let temp = tempfile::tempdir().unwrap(); + let address = "127.0.0.1:4040".parse().unwrap(); + + let missing_parent = temp.path().join("missing").join("ready.json"); + let error = write_ready_file(&missing_parent, address, "write-failure").unwrap_err(); + assert!(error.to_string().contains("failed to write readiness file")); + + let directory_target = temp.path().join("ready.json"); + std::fs::create_dir(&directory_target).unwrap(); + let error = write_ready_file(&directory_target, address, "publish-failure").unwrap_err(); + assert!( + error + .to_string() + .contains("failed to publish readiness file") + ); + assert!(!temp.path().join("ready.json.tmp").exists()); +} + #[tokio::test] async fn serve_listener_honors_plugin_idle_timeout_env() { let _guard = PLUGIN_CONFIG_TEST_LOCK.lock().await; @@ -2116,6 +2146,126 @@ async fn static_only_cli_configuration_keeps_the_legacy_lifecycle() { let _ = deregister_plugin(GENERIC_TEST_PLUGIN_KIND); } +#[test] +fn plugin_component_setup_errors_render_every_diagnostic_variant() { + let adaptive = PluginComponentSetupError::Adaptive("adaptive failure".into()); + assert_eq!(adaptive.check_name(), "Adaptive plugin"); + assert_eq!( + adaptive.diagnostic_details(), + "registration failed: adaptive failure" + ); + assert_eq!( + adaptive.to_string(), + "adaptive plugin registration failed: adaptive failure" + ); + + let pii = PluginComponentSetupError::PiiRedaction("pii failure".into()); + assert_eq!(pii.check_name(), "PII redaction plugin"); + assert_eq!(pii.diagnostic_details(), "registration failed: pii failure"); + assert_eq!( + pii.to_string(), + "PII redaction plugin registration failed: pii failure" + ); + + #[cfg(feature = "switchyard")] + { + let switchyard = PluginComponentSetupError::Switchyard("registration".into()); + assert_eq!(switchyard.check_name(), "Switchyard plugin"); + assert!(switchyard.to_string().contains("registration failed")); + + let atof = PluginComponentSetupError::SwitchyardAtof("atof ordering".into()); + assert_eq!(atof.check_name(), "Switchyard ATOF"); + assert_eq!(atof.diagnostic_details(), "atof ordering"); + assert!(atof.to_string().contains("ATOF validation failed")); + + let cache = PluginComponentSetupError::SwitchyardResponseCache("cache ordering".into()); + assert_eq!(cache.check_name(), "Switchyard response cache"); + assert_eq!(cache.diagnostic_details(), "cache ordering"); + assert!( + cache + .to_string() + .contains("response-cache validation failed") + ); + } +} + +fn dynamic_component_without_manifest( + plugin_id: &str, + kind: DynamicPluginKind, +) -> ActiveDynamicPluginComponent { + ActiveDynamicPluginComponent { + plugin_id: plugin_id.into(), + kind, + lifecycle_generation: 1, + manifest_ref: None, + environment_ref: None, + config: Map::new(), + activation_snapshot: None, + } +} + +#[tokio::test] +async fn plugin_activation_covers_empty_invalid_and_missing_manifest_paths() { + let _guard = PLUGIN_CONFIG_TEST_LOCK.lock().await; + let inactive = PluginActivation::initialize(None, Vec::new()) + .await + .unwrap(); + assert!(!inactive.active); + inactive.clear().unwrap(); + + let invalid = PluginActivation::initialize( + Some(json!("not a plugin config")), + vec![dynamic_component_without_manifest( + "acme.invalid-config", + DynamicPluginKind::Worker, + )], + ) + .await + .err() + .expect("invalid config should fail activation"); + assert!(invalid.to_string().contains("invalid plugin config")); + + let native = PluginActivation::initialize( + None, + vec![dynamic_component_without_manifest( + "acme.native-missing", + DynamicPluginKind::RustDynamic, + )], + ) + .await + .err() + .expect("native plugin without a manifest should fail activation"); + assert!(native.to_string().contains("native dynamic plugin")); + + let worker = PluginActivation::initialize( + None, + vec![dynamic_component_without_manifest( + "acme.worker-missing", + DynamicPluginKind::Worker, + )], + ) + .await + .err() + .expect("worker plugin without a manifest should fail activation"); + assert!(worker.to_string().contains("worker dynamic plugin")); +} + +#[tokio::test] +async fn shutdown_future_helpers_cover_receiver_combinations() { + let (shutdown_tx, shutdown_rx) = oneshot::channel(); + let shutdown = server_shutdown_future(Some(ShutdownMode::Receiver(shutdown_rx)), None).unwrap(); + shutdown_tx.send(()).unwrap(); + shutdown.await; + + let (bootstrap_tx, bootstrap_rx) = oneshot::channel(); + let shutdown = combine_shutdown_futures(None, Some(bootstrap_rx)).unwrap(); + bootstrap_tx.send(()).unwrap(); + shutdown.await; + + let ready: ShutdownFuture = Box::pin(async {}); + combine_shutdown_futures(Some(ready), None).unwrap().await; +} + #[cfg(feature = "switchyard")] #[test] fn switchyard_must_run_before_response_cache() { From 44ff2effee4384de69350c3ba33f839ae7142775 Mon Sep 17 00:00:00 2001 From: Will Killian Date: Sun, 2 Aug 2026 10:30:01 -0400 Subject: [PATCH 2/2] fix(cli): preserve config during wizard reruns Signed-off-by: Will Killian --- crates/cli/src/commands/configure/editor.rs | 7 ++++ .../src/commands/configure/editor/prompt.rs | 32 +++++++------------ crates/cli/src/commands/configure/model.rs | 25 ++++++++++----- crates/cli/src/plugins/mod.rs | 7 +++- .../commands/configure_editor_tests.rs | 4 ++- .../coverage/shared/plugins_schema_tests.rs | 2 +- .../tests/coverage/shared/plugins_tests.rs | 30 ++++++++++++++--- .../cli/tests/coverage/shared/setup_tests.rs | 14 +++++--- 8 files changed, 80 insertions(+), 41 deletions(-) diff --git a/crates/cli/src/commands/configure/editor.rs b/crates/cli/src/commands/configure/editor.rs index 93504a700..4969fb946 100644 --- a/crates/cli/src/commands/configure/editor.rs +++ b/crates/cli/src/commands/configure/editor.rs @@ -556,11 +556,18 @@ fn target_path(scope: TargetScope) -> Result { } fn project_config_path(start: &Path) -> PathBuf { + project_config_path_with_boundary(start, None) +} + +fn project_config_path_with_boundary(start: &Path, boundary: Option<&Path>) -> PathBuf { for ancestor in start.ancestors() { let candidate = ancestor.join(".nemo-relay/config.toml"); if candidate.exists() { return candidate; } + if boundary == Some(ancestor) { + break; + } } start.join(".nemo-relay/config.toml") } diff --git a/crates/cli/src/commands/configure/editor/prompt.rs b/crates/cli/src/commands/configure/editor/prompt.rs index 3f843c9d4..bb2f62465 100644 --- a/crates/cli/src/commands/configure/editor/prompt.rs +++ b/crates/cli/src/commands/configure/editor/prompt.rs @@ -15,6 +15,14 @@ use super::{ }; use crate::error::CliError; +fn require_nonempty(value: &str) -> Result<(), &'static str> { + if value.trim().is_empty() { + Err("value must not be empty") + } else { + Ok(()) + } +} + const EDIT_CANCELLED_MESSAGE: &str = "configuration edit cancelled — no config saved"; pub(super) fn edit( command: ConfigEditCommand, @@ -210,13 +218,7 @@ fn edit_string( let value = Input::::with_theme(theme) .with_prompt("Value") .with_initial_text(default) - .validate_with(|value: &String| { - if value.trim().is_empty() { - Err("value must not be empty; use Clear to remove it") - } else { - Ok(()) - } - }) + .validate_with(|value: &String| require_nonempty(value)) .interact_text() .map_err(prompt_error)?; document.set_string(section, key, value)?; @@ -292,13 +294,7 @@ fn edit_sinks(theme: &ColorfulTheme, document: &mut ConfigDocument) -> Result<() index if index == sink_count => { let path = Input::::with_theme(theme) .with_prompt("File path") - .validate_with(|value: &String| { - if value.trim().is_empty() { - Err("value must not be empty".to_owned()) - } else { - Ok(()) - } - }) + .validate_with(|value: &String| require_nonempty(value)) .interact_text() .map_err(prompt_error)?; document.add_sink(path)?; @@ -350,13 +346,7 @@ fn edit_sink_path( let value = Input::::with_theme(theme) .with_prompt("File path") .with_initial_text(current) - .validate_with(|value: &String| { - if value.trim().is_empty() { - Err("value must not be empty".to_owned()) - } else { - Ok(()) - } - }) + .validate_with(|value: &String| require_nonempty(value)) .interact_text() .map_err(prompt_error)?; document.set_sink_string(index, "path", value) diff --git a/crates/cli/src/commands/configure/model.rs b/crates/cli/src/commands/configure/model.rs index 273be01fc..3062f093d 100644 --- a/crates/cli/src/commands/configure/model.rs +++ b/crates/cli/src/commands/configure/model.rs @@ -132,8 +132,8 @@ pub(crate) fn build_agents_table(answers: &SetupAnswers) -> Option { /// 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 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). +/// `None`, the full `[agents]` table is replaced while unrelated configuration sections are +/// preserved. /// /// Returns the list of paths written. `home` and `cwd` are explicit so tests can drive this with /// tempdirs. @@ -174,16 +174,13 @@ pub(crate) fn global_config_dir(home: &Path) -> PathBuf { // 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. +// and the target agent's block. When `merge_scope` is `None`, replaces the complete agents table +// while preserving sections owned by other configuration commands. pub(crate) 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(()); @@ -192,10 +189,22 @@ pub(crate) fn write_or_merge( let mut existing: DocumentMut = existing_raw .parse() .map_err(|err| CliError::Config(format!("could not parse existing config: {err}")))?; + existing.remove("plugins"); + let Some(agent) = merge_scope else { + match doc.get("agents") { + Some(agents) => { + existing["agents"] = agents.clone(); + } + None => { + existing.remove("agents"); + } + } + std::fs::write(path, existing.to_string())?; + return Ok(()); + }; let agent_key = agent_key_and_command(agent).0; // Remove the legacy plugin configuration block so the merged config remains loadable after // plugin configuration moved to plugins.toml. - existing.remove("plugins"); merge_agents_entry(&mut existing, doc, agent_key); std::fs::write(path, existing.to_string())?; Ok(()) diff --git a/crates/cli/src/plugins/mod.rs b/crates/cli/src/plugins/mod.rs index c76012c26..ae856c28b 100644 --- a/crates/cli/src/plugins/mod.rs +++ b/crates/cli/src/plugins/mod.rs @@ -564,7 +564,12 @@ fn collection_shortcut_value( shortcut: MenuShortcut, ) -> Value { match shortcut { - MenuShortcut::Reset => default.cloned().unwrap_or(empty), + MenuShortcut::Reset => default + .filter(|value| { + value.is_array() == empty.is_array() && value.is_object() == empty.is_object() + }) + .cloned() + .unwrap_or(empty), MenuShortcut::Clear => empty, _ => unreachable!("only reset and clear shortcuts are collection shortcuts"), } diff --git a/crates/cli/tests/coverage/commands/configure_editor_tests.rs b/crates/cli/tests/coverage/commands/configure_editor_tests.rs index ce23eac42..7feeeadbb 100644 --- a/crates/cli/tests/coverage/commands/configure_editor_tests.rs +++ b/crates/cli/tests/coverage/commands/configure_editor_tests.rs @@ -337,6 +337,7 @@ fn document_accessors_cover_inline_values_defaults_and_invalid_shapes() { Some("https://changed.test") ); document.clear_key("missing", "value").unwrap(); + assert!(!document.has_key("missing", "value")); } #[test] @@ -360,6 +361,7 @@ fn sink_accessors_report_invalid_and_incomplete_entries() { .set_sink_string(0, "path", "relay.log".into()) .unwrap(); document.clear_sink_key(0, "level").unwrap(); + assert!(!document.sink_has_key(0, "level").unwrap()); assert_eq!( document.sink_string(0, "path").as_deref(), Some("relay.log") @@ -373,7 +375,7 @@ fn project_path_defaults_to_start_when_no_ancestor_config_exists() { std::fs::create_dir_all(&nested).unwrap(); assert_eq!( - project_config_path(&nested), + project_config_path_with_boundary(&nested, Some(root.path())), nested.join(".nemo-relay/config.toml") ); } diff --git a/crates/cli/tests/coverage/shared/plugins_schema_tests.rs b/crates/cli/tests/coverage/shared/plugins_schema_tests.rs index 9b1f9fbec..7d024e7b0 100644 --- a/crates/cli/tests/coverage/shared/plugins_schema_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_schema_tests.rs @@ -282,7 +282,7 @@ fn native_config_field<'a>(schema: &'a PluginConfigSchema, key: &str) -> &'a Dyn .fields() .iter() .find(|field| field.key == key) - .unwrap() + .unwrap_or_else(|| panic!("missing native config field {key:?}")) } fn assert_native_raw_and_scalar_fields(schema: &PluginConfigSchema) { diff --git a/crates/cli/tests/coverage/shared/plugins_tests.rs b/crates/cli/tests/coverage/shared/plugins_tests.rs index 3deed9031..7f87c91e0 100644 --- a/crates/cli/tests/coverage/shared/plugins_tests.rs +++ b/crates/cli/tests/coverage/shared/plugins_tests.rs @@ -630,6 +630,18 @@ fn menu_keys_cover_selection_shortcuts_and_cancellation() { menu_response_for_key(&Key::Escape, 1), Some(MenuResponse::Cancel) ); + assert_eq!( + menu_response_for_key(&Key::Del, 1), + Some(MenuResponse::Shortcut(MenuShortcut::Clear, 1)) + ); + assert_eq!( + menu_response_for_key(&Key::CtrlC, 1), + Some(MenuResponse::Cancel) + ); + assert_eq!( + menu_response_for_key(&Key::Char('q'), 1), + Some(MenuResponse::Cancel) + ); assert_eq!(menu_response_for_key(&Key::Char('x'), 1), None); } @@ -748,6 +760,7 @@ fn component_shortcut_fallbacks_are_safe_noops() { let mut config = PluginConfig::default(); ensure_observability_component(&mut config).unwrap(); let mut components = editable_components(&config).unwrap(); + let before = format!("{components:?}"); assert_eq!( handle_reset_or_clear_shortcut(&mut components, None, MenuShortcut::Reset).unwrap(), @@ -755,6 +768,7 @@ fn component_shortcut_fallbacks_are_safe_noops() { ); reset_component_menu_item(&mut components[0], None).unwrap(); clear_component_menu_item(&mut components[0], Some(ComponentMenuAction::Back)).unwrap(); + assert_eq!(format!("{components:?}"), before); } #[test] @@ -2166,11 +2180,11 @@ fn dynamic_editor_raw_menu_and_nested_value_paths_are_deterministic() { assert!(matches!(actions[0], DynamicMenuAction::EditRawConfig)); let mut config = None; - let path = vec!["outer".to_owned(), "inner".to_owned()]; - set_value_at_path(&mut config, &path, json!(7)); - assert_eq!(value_at_path(config.as_ref(), &path), Some(&json!(7))); + let field_path = vec!["outer".to_owned(), "inner".to_owned()]; + set_value_at_path(&mut config, &field_path, json!(7)); + assert_eq!(value_at_path(config.as_ref(), &field_path), Some(&json!(7))); assert_eq!(value_at_path(config.as_ref(), &[]), None); - assert!(remove_value_at_path(config.as_mut().unwrap(), &path)); + assert!(remove_value_at_path(config.as_mut().unwrap(), &field_path)); set_value_at_path(&mut config, &[], json!(9)); assert!(config.as_ref().unwrap().is_empty()); } @@ -3043,6 +3057,14 @@ fn collection_shortcuts_reset_to_defaults_and_clear_to_empty() { collection_shortcut_value(Some(&default), json!([]), MenuShortcut::Clear), json!([]) ); + assert_eq!( + collection_shortcut_value( + Some(&json!({"malformed": true})), + json!([]), + MenuShortcut::Reset + ), + json!([]) + ); } #[test] diff --git a/crates/cli/tests/coverage/shared/setup_tests.rs b/crates/cli/tests/coverage/shared/setup_tests.rs index a08ee621c..b16c318b6 100644 --- a/crates/cli/tests/coverage/shared/setup_tests.rs +++ b/crates/cli/tests/coverage/shared/setup_tests.rs @@ -395,10 +395,14 @@ config = { version = 1, components = [] } } #[test] -fn write_or_merge_overwrites_without_merge_scope_and_reports_malformed_existing_config() { +fn write_or_merge_replaces_agents_without_merge_scope_and_preserves_other_sections() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("config.toml"); - std::fs::write(&path, "[agents.codex]\ncommand = \"old\"\n").unwrap(); + std::fs::write( + &path, + "[agents.codex]\ncommand = \"old\"\n\n[upstream]\nopenai_base_url = \"https://example.test\"\n", + ) + .unwrap(); let doc = build_config(&SetupAnswers { scope: ConfigScope::Project, agents: vec![CodingAgent::Hermes], @@ -408,11 +412,11 @@ fn write_or_merge_overwrites_without_merge_scope_and_reports_malformed_existing_ let overwritten = std::fs::read_to_string(&path).unwrap(); assert!(!overwritten.contains("[agents.codex]")); assert!(overwritten.contains("[agents.hermes]")); + assert!(overwritten.contains("[upstream]")); + assert!(overwritten.contains("https://example.test")); std::fs::write(&path, "[agents.codex\n").unwrap(); - let error = write_or_merge(&path, &doc, Some(CodingAgent::Hermes)) - .unwrap_err() - .to_string(); + let error = write_or_merge(&path, &doc, None).unwrap_err().to_string(); assert!(error.contains("could not parse existing config")); }