diff --git a/crates/am/src/alias.rs b/crates/am/src/alias.rs index 51405f10..488337dd 100644 --- a/crates/am/src/alias.rs +++ b/crates/am/src/alias.rs @@ -29,7 +29,7 @@ impl Display for AliasName { } } -#[derive(Debug, Deserialize, Default, Serialize, Clone)] +#[derive(Debug, Deserialize, Default, Serialize, Clone, PartialEq)] pub struct AliasSet(BTreeMap); impl AsRef> for AliasSet { diff --git a/crates/am/src/bin/am.rs b/crates/am/src/bin/am.rs index c2b97360..feaa7b17 100644 --- a/crates/am/src/bin/am.rs +++ b/crates/am/src/bin/am.rs @@ -15,7 +15,7 @@ use amoxide::{ prompt::{ask_user, Answer}, trust::compute_file_hash, update::{update, AppModel}, - AliasTarget, Message, + AliasTarget, Echo, Message, }; fn setup_logging() { @@ -175,7 +175,7 @@ fn main() -> anyhow::Result<()> { None => Message::ToggleProfiles(ordered), }; let result = update(&mut model, msg)?; - execute_effects(&mut model, &result.effects)?; + execute_effects(&mut model, result.effects)?; model.save_config()?; return Ok(()); } @@ -185,7 +185,7 @@ fn main() -> anyhow::Result<()> { { ProfileAction::Add { name } => { let result = update(&mut model, Message::CreateProfile(name.clone()))?; - execute_effects(&mut model, &result.effects)?; + execute_effects(&mut model, result.effects)?; model.save_config()?; return Ok(()); } @@ -204,7 +204,7 @@ fn main() -> anyhow::Result<()> { None => Message::ToggleProfiles(ordered), }; let result = update(&mut model, msg)?; - execute_effects(&mut model, &result.effects)?; + execute_effects(&mut model, result.effects)?; model.save_config()?; return Ok(()); } @@ -241,7 +241,7 @@ fn main() -> anyhow::Result<()> { } } let result = update(&mut model, Message::RemoveProfile(name.clone()))?; - execute_effects(&mut model, &result.effects)?; + execute_effects(&mut model, result.effects)?; model.save_config()?; return Ok(()); } @@ -367,18 +367,18 @@ fn main() -> anyhow::Result<()> { if answer == Answer::Yes { let result = update(&mut model, Message::Trust)?; - execute_effects(&mut model, &result.effects)?; + execute_effects(&mut model, result.effects)?; // The shell wrapper calls `am sync` after this, which loads // the aliases and shows the load message. } else { let result = update(&mut model, Message::Untrust { forget: false })?; - execute_effects(&mut model, &result.effects)?; + execute_effects(&mut model, result.effects)?; } return Ok(()); } Commands::Untrust { forget } => { let result = update(&mut model, Message::Untrust { forget: *forget })?; - execute_effects(&mut model, &result.effects)?; + execute_effects(&mut model, result.effects)?; return Ok(()); } Commands::Init { shell, force } => Message::InitShell(shell.clone(), *force), @@ -386,16 +386,16 @@ fn main() -> anyhow::Result<()> { }; let result = update(&mut model, message)?; - execute_effects(&mut model, &result.effects)?; + execute_effects(&mut model, result.effects)?; if let Some(msg) = result.next { let follow_up = update(&mut model, msg)?; - execute_effects(&mut model, &follow_up.effects)?; + execute_effects(&mut model, follow_up.effects)?; } Ok(()) } -fn execute_effects(model: &mut AppModel, effects: &[Effect]) -> anyhow::Result<()> { +fn execute_effects(model: &mut AppModel, effects: Vec) -> anyhow::Result<()> { let has_local_mutation = effects.iter().any(|e| { matches!( e, @@ -411,14 +411,31 @@ fn execute_effects(model: &mut AppModel, effects: &[Effect]) -> anyhow::Result<( Effect::SaveConfig => model.save_config()?, Effect::SaveSession => model.save_session()?, Effect::SaveProfiles => model.save_profiles()?, - Effect::AddLocalAlias { name, cmd, raw } => add_local_alias(name, cmd, *raw)?, - Effect::RemoveLocalAlias { name } => remove_local_alias(name)?, + Effect::AddLocalAlias { name, cmd, raw } => add_local_alias(&name, &cmd, raw)?, + Effect::RemoveLocalAlias { name } => remove_local_alias(&name)?, Effect::AddLocalSubcommand { key, long_subcommands, - } => add_local_subcommand(key, long_subcommands)?, - Effect::RemoveLocalSubcommand { key } => remove_local_subcommand(key)?, + } => add_local_subcommand(&key, &long_subcommands)?, + Effect::RemoveLocalSubcommand { key } => remove_local_subcommand(&key)?, Effect::Print(text) => println!("{text}"), + Effect::PrintLines(lines) => { + let output: String = lines + .into_iter() + .filter_map(|e| match e { + Echo::Line(s) => Some(s), + Echo::Silent => None, + }) + .collect::>() + .join("\n"); + if !output.is_empty() { + print!("{output}"); + } + } + Effect::RenderSync(outcome) => { + let echo_lines = outcome.render(&model.config.logging); + execute_effects(model, vec![Effect::PrintLines(echo_lines)])?; + } Effect::SaveSecurity => model.save_security()?, } } diff --git a/crates/am/src/config.rs b/crates/am/src/config.rs index fd3f667a..76ed704f 100644 --- a/crates/am/src/config.rs +++ b/crates/am/src/config.rs @@ -7,12 +7,12 @@ use crate::{AliasDetail, AliasName, AliasSet, TomlAlias}; const CONFIG_FILE: &str = "config.toml"; -#[derive(Debug, Default, Deserialize, Serialize, Clone)] +#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)] pub struct ShellsTomlConfig { pub fish: Option, } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] pub struct FishConfig { #[serde(default)] pub use_abbr: bool, @@ -26,6 +26,25 @@ pub struct Config { pub subcommands: SubcommandSet, #[serde(default)] pub shell: ShellsTomlConfig, + #[serde(default)] + pub logging: LoggingConfig, +} + +#[derive(Debug, Default, Deserialize, Serialize, Clone)] +pub struct LoggingConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_loading: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_unloading: Option, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(rename_all = "lowercase")] +pub enum LogVerbosity { + Off, + Short, + Verbose, } impl Config { @@ -207,4 +226,34 @@ mod tests { let config = Config::load_from(dir.path()).unwrap(); assert!(config.shell.fish.is_none()); } + + #[test] + fn test_logging_config_roundtrip() { + let dir = tempfile::tempdir().unwrap(); + let toml_str = r#" +[logging] +project_loading = "short" +project_unloading = "off" +"#; + std::fs::write(dir.path().join("config.toml"), toml_str).unwrap(); + let config = Config::load_from(dir.path()).unwrap(); + assert!(matches!( + config.logging.project_loading, + Some(LogVerbosity::Short) + )); + assert!(matches!( + config.logging.project_unloading, + Some(LogVerbosity::Off) + )); + } + + #[test] + fn test_logging_config_defaults_to_none() { + let dir = tempfile::tempdir().unwrap(); + let toml_str = "[aliases]\n"; + std::fs::write(dir.path().join("config.toml"), toml_str).unwrap(); + let config = Config::load_from(dir.path()).unwrap(); + assert!(config.logging.project_loading.is_none()); + assert!(config.logging.project_unloading.is_none()); + } } diff --git a/crates/am/src/effects.rs b/crates/am/src/effects.rs index 23b3f7d9..cb3a14e3 100644 --- a/crates/am/src/effects.rs +++ b/crates/am/src/effects.rs @@ -1,4 +1,37 @@ +use crate::config::LogVerbosity; +use crate::sync_outcome::SyncOutcome; + #[derive(Debug, Clone, PartialEq)] +pub enum Echo { + Silent, + Line(String), +} + +impl Echo { + /// Choose output based on verbosity. Closures are lazy — no work done for suppressed tiers. + pub fn from_verbosity( + verbosity: &LogVerbosity, + short: impl FnOnce() -> String, + verbose: impl FnOnce() -> String, + ) -> Self { + match verbosity { + LogVerbosity::Off => Self::Silent, + LogVerbosity::Short => Self::Line(short()), + LogVerbosity::Verbose => Self::Line(verbose()), + } + } + + /// Functional shell output — always emitted regardless of verbosity. + pub fn always(s: String) -> Self { + if s.is_empty() { + Self::Silent + } else { + Self::Line(s) + } + } +} + +#[derive(Debug, PartialEq)] pub enum Effect { SaveConfig, SaveSession, @@ -19,6 +52,8 @@ pub enum Effect { key: String, }, Print(String), + PrintLines(Vec), + RenderSync(SyncOutcome), SaveSecurity, } @@ -47,11 +82,59 @@ pub fn execute_effect(model: &mut AppModel, effect: &Effect) -> anyhow::Result<( Effect::RemoveLocalSubcommand { key } => { model.save_project_subcommand_remove(key)?; } - Effect::Print(_) => {} // caller's responsibility + Effect::Print(_) => {} // caller's responsibility + Effect::PrintLines(_) => {} // caller's responsibility, like Print + Effect::RenderSync(_) => {} // caller's responsibility } Ok(()) } +#[cfg(test)] +mod echo_tests { + use super::*; + use crate::config::LogVerbosity; + + #[test] + fn echo_from_verbosity_off_returns_silent() { + let echo = Echo::from_verbosity( + &LogVerbosity::Off, + || "short".to_string(), + || "verbose".to_string(), + ); + assert!(matches!(echo, Echo::Silent)); + } + + #[test] + fn echo_from_verbosity_short_returns_short_line() { + let echo = Echo::from_verbosity( + &LogVerbosity::Short, + || "short msg".to_string(), + || panic!("verbose closure should not be called"), + ); + assert!(matches!(echo, Echo::Line(s) if s == "short msg")); + } + + #[test] + fn echo_from_verbosity_verbose_returns_verbose_line() { + let echo = Echo::from_verbosity( + &LogVerbosity::Verbose, + || panic!("short closure should not be called"), + || "verbose msg".to_string(), + ); + assert!(matches!(echo, Echo::Line(s) if s == "verbose msg")); + } + + #[test] + fn echo_always_empty_is_silent() { + assert!(matches!(Echo::always(String::new()), Echo::Silent)); + } + + #[test] + fn echo_always_non_empty_is_line() { + assert!(matches!(Echo::always("hello".into()), Echo::Line(s) if s == "hello")); + } +} + #[cfg(all(test, feature = "test-util"))] mod tests { use super::*; diff --git a/crates/am/src/lib.rs b/crates/am/src/lib.rs index 425e23a6..b1d12694 100644 --- a/crates/am/src/lib.rs +++ b/crates/am/src/lib.rs @@ -21,6 +21,7 @@ pub mod setup; pub mod shell; pub mod status; pub mod subcommand; +pub mod sync_outcome; pub mod trust; pub mod update; diff --git a/crates/am/src/precedence/diff.rs b/crates/am/src/precedence/diff.rs index 47e99b7d..25e2c8d7 100644 --- a/crates/am/src/precedence/diff.rs +++ b/crates/am/src/precedence/diff.rs @@ -75,6 +75,24 @@ impl PrecedenceDiff { ) } + /// Like [`Self::change_summary`] but with project-unload prefix and labels. + /// + /// Uses "added" for aliases that gained a new definition (profile/global + /// taking over) and "unloaded" for aliases that are gone entirely. + pub fn unload_summary(&self) -> Option { + let added: Vec<&str> = self + .added + .iter() + .chain(self.changed.iter()) + .map(|e| e.name.as_str()) + .collect(); + let removed: Vec<&str> = self.removed.iter().map(|s| s.as_str()).collect(); + format_change_summary( + "am: .aliases unloaded", + &[("added", &added), ("unloaded", &removed)], + ) + } + /// Render this diff into shell code using the given adapter. /// /// Emission order: @@ -205,4 +223,47 @@ mod tests { let out = PrecedenceDiff::default().render(shell.as_ref()); assert!(out.is_empty()); } + + #[test] + fn unload_summary_uses_project_prefix_and_labels() { + let project = aset(&[("b", "make build"), ("t", "cargo test")]); + let diff = Precedence::new() + .with_project(&project, &SubcommandSet::new()) + .with_shell_state_from_env(Some("b|0000000,t|1111111"), None) + .resolve(); + + // All aliases removed (no global/profile to take over) + let summary = diff.unload_summary(); + assert!(summary.is_some()); + let msg = summary.unwrap(); + assert!(msg.starts_with("am: .aliases unloaded"), "got: {msg}"); + assert!(msg.contains("unloaded"), "got: {msg}"); + } + + #[test] + fn unload_summary_shows_added_for_takeover() { + let global = aset(&[("b", "global build")]); + let _project = aset(&[("b", "project build"), ("t", "cargo test")]); + + // Shell had both from project. Now project is gone, global takes over b. + // Simulate: resolve with only global + old shell state that had both + let diff_after = Precedence::new() + .with_global(&global, &SubcommandSet::new()) + .with_shell_state_from_env(Some("b|0000000,t|1111111"), None) + .resolve(); + + let summary = diff_after.unload_summary(); + assert!(summary.is_some()); + let msg = summary.unwrap(); + assert!(msg.starts_with("am: .aliases unloaded"), "got: {msg}"); + // b is "added" (global takes over), t is "unloaded" (gone) + assert!(msg.contains("added"), "expected 'added' in: {msg}"); + assert!(msg.contains("unloaded"), "expected 'unloaded' in: {msg}"); + } + + #[test] + fn unload_summary_returns_none_when_nothing_changed() { + let diff = PrecedenceDiff::default(); + assert!(diff.unload_summary().is_none()); + } } diff --git a/crates/am/src/sync_outcome.rs b/crates/am/src/sync_outcome.rs new file mode 100644 index 00000000..3051c61e --- /dev/null +++ b/crates/am/src/sync_outcome.rs @@ -0,0 +1,281 @@ +use crate::config::{LogVerbosity, LoggingConfig, ShellsTomlConfig}; +use crate::effects::Echo; +use crate::env_vars; +use crate::precedence::PrecedenceDiff; +use crate::shell::Shell; +use crate::subcommand::SubcommandSet; +use crate::trust::render_load_lines; +use crate::AliasSet; + +#[derive(Debug, PartialEq)] +pub enum ProjectTransition { + FreshLoad { + aliases: AliasSet, + subcommands: SubcommandSet, + }, + Unloaded, + None, +} + +#[derive(Debug, PartialEq)] +pub enum PathUpdate { + Set(String), + Unset, + Unchanged, +} + +#[derive(Debug, PartialEq)] +pub struct SyncOutcome { + shell: Shell, + shell_cfg: ShellsTomlConfig, + quiet: bool, + transition: ProjectTransition, + diff: PrecedenceDiff, + security_warnings: Vec, + path_update: PathUpdate, +} + +pub struct SyncOutcomeBuilder { + shell: Shell, + shell_cfg: ShellsTomlConfig, + quiet: bool, + transition: ProjectTransition, + diff: PrecedenceDiff, + security_warnings: Vec, + path_update: PathUpdate, +} + +impl SyncOutcomeBuilder { + pub fn transition(mut self, transition: ProjectTransition) -> Self { + self.transition = transition; + self + } + + pub fn diff(mut self, diff: PrecedenceDiff) -> Self { + self.diff = diff; + self + } + + pub fn security_warning(mut self, warning: String) -> Self { + self.security_warnings.push(warning); + self + } + + pub fn path_update(mut self, path_update: PathUpdate) -> Self { + self.path_update = path_update; + self + } + + pub fn build(self) -> SyncOutcome { + SyncOutcome { + shell: self.shell, + shell_cfg: self.shell_cfg, + quiet: self.quiet, + transition: self.transition, + diff: self.diff, + security_warnings: self.security_warnings, + path_update: self.path_update, + } + } +} + +impl SyncOutcome { + pub fn builder(shell: Shell, shell_cfg: ShellsTomlConfig, quiet: bool) -> SyncOutcomeBuilder { + SyncOutcomeBuilder { + shell, + shell_cfg, + quiet, + transition: ProjectTransition::None, + diff: PrecedenceDiff::default(), + security_warnings: Vec::new(), + path_update: PathUpdate::Unchanged, + } + } +} + +impl SyncOutcome { + pub fn render(&self, logging: &LoggingConfig) -> Vec { + let shell_impl = + self.shell + .clone() + .as_shell(&self.shell_cfg, Default::default(), Default::default()); + let mut lines = Vec::new(); + + // Security warnings (unless quiet) + if !self.quiet { + for warn in &self.security_warnings { + lines.push(Echo::Line(shell_impl.echo(warn))); + } + } + + // Human-readable transition message (unless quiet) + if !self.quiet { + match &self.transition { + ProjectTransition::FreshLoad { + aliases, + subcommands, + } => { + let verbosity = logging + .project_loading + .as_ref() + .unwrap_or(&LogVerbosity::Verbose); + lines.extend(render_load_lines( + aliases, + subcommands, + verbosity, + shell_impl.as_ref(), + )); + } + ProjectTransition::Unloaded => { + let verbosity = logging + .project_unloading + .as_ref() + .unwrap_or(&LogVerbosity::Verbose); + lines.push(Echo::from_verbosity( + verbosity, + || shell_impl.echo("am: .aliases unloaded"), + || { + let msg = self + .diff + .unload_summary() + .unwrap_or_else(|| "am: .aliases unloaded".to_string()); + shell_impl.echo(&msg) + }, + )); + } + ProjectTransition::None => { + if let Some(msg) = self.diff.change_summary() { + lines.push(Echo::Line(shell_impl.echo(&msg))); + } + } + } + } + + // Functional shell commands (always — these ARE the program output) + let rendered = self.diff.render(shell_impl.as_ref()); + if !rendered.is_empty() { + for line in rendered.lines() { + lines.push(Echo::always(line.to_string())); + } + } + + // Path tracking (always) + match &self.path_update { + PathUpdate::Set(p) => { + lines.push(Echo::Line(shell_impl.set_env(env_vars::AM_PROJECT_PATH, p))); + } + PathUpdate::Unset => { + lines.push(Echo::Line(shell_impl.unset_env(env_vars::AM_PROJECT_PATH))); + } + PathUpdate::Unchanged => {} + } + + lines + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_outcome( + transition: ProjectTransition, + quiet: bool, + path_update: PathUpdate, + ) -> SyncOutcome { + SyncOutcome::builder(Shell::Fish, ShellsTomlConfig::default(), quiet) + .transition(transition) + .path_update(path_update) + .build() + } + + #[test] + fn render_quiet_suppresses_all_messages() { + let outcome = make_outcome(ProjectTransition::Unloaded, true, PathUpdate::Unchanged); + let logging = LoggingConfig::default(); + let lines = outcome.render(&logging); + assert!(lines.iter().all(|l| matches!(l, Echo::Silent))); + } + + #[test] + fn render_unloaded_off_produces_silent() { + let outcome = make_outcome(ProjectTransition::Unloaded, false, PathUpdate::Unchanged); + let logging = LoggingConfig { + project_loading: None, + project_unloading: Some(LogVerbosity::Off), + }; + let lines = outcome.render(&logging); + assert!(lines.iter().all(|l| matches!(l, Echo::Silent))); + } + + #[test] + fn render_unloaded_short_produces_message() { + let outcome = make_outcome(ProjectTransition::Unloaded, false, PathUpdate::Unchanged); + let logging = LoggingConfig { + project_loading: None, + project_unloading: Some(LogVerbosity::Short), + }; + let lines = outcome.render(&logging); + let text_lines: Vec<&str> = lines + .iter() + .filter_map(|l| match l { + Echo::Line(s) => Some(s.as_str()), + _ => None, + }) + .collect(); + assert!(text_lines + .iter() + .any(|s| s.contains("am: .aliases unloaded"))); + } + + #[test] + fn render_path_set_emits_set_env() { + let outcome = make_outcome( + ProjectTransition::None, + true, + PathUpdate::Set("/project/.aliases".into()), + ); + let logging = LoggingConfig::default(); + let lines = outcome.render(&logging); + let text_lines: Vec<&str> = lines + .iter() + .filter_map(|l| match l { + Echo::Line(s) => Some(s.as_str()), + _ => None, + }) + .collect(); + assert!(text_lines.iter().any(|s| s.contains("_AM_PROJECT_PATH"))); + } + + #[test] + fn render_path_unset_emits_unset_env() { + let outcome = make_outcome(ProjectTransition::None, true, PathUpdate::Unset); + let logging = LoggingConfig::default(); + let lines = outcome.render(&logging); + let text_lines: Vec<&str> = lines + .iter() + .filter_map(|l| match l { + Echo::Line(s) => Some(s.as_str()), + _ => None, + }) + .collect(); + assert!(text_lines.iter().any(|s| s.contains("_AM_PROJECT_PATH"))); + } + + #[test] + fn render_security_warnings_unless_quiet() { + let outcome = SyncOutcome::builder(Shell::Fish, ShellsTomlConfig::default(), false) + .security_warning("am: .aliases found but not trusted.".into()) + .build(); + let logging = LoggingConfig::default(); + let lines = outcome.render(&logging); + let text_lines: Vec<&str> = lines + .iter() + .filter_map(|l| match l { + Echo::Line(s) => Some(s.as_str()), + _ => None, + }) + .collect(); + assert!(text_lines.iter().any(|s| s.contains("not trusted"))); + } +} diff --git a/crates/am/src/trust.rs b/crates/am/src/trust.rs index 8311abcb..dd0173d3 100644 --- a/crates/am/src/trust.rs +++ b/crates/am/src/trust.rs @@ -1,6 +1,8 @@ use std::path::{Path, PathBuf}; -use crate::project::ProjectAliases; +use crate::effects::Echo; +use crate::shell::ShellAdapter; +use crate::{project::ProjectAliases, LogVerbosity}; /// Trust state for a discovered project `.aliases` file. #[derive(Debug)] @@ -55,46 +57,63 @@ pub fn compute_short_hash(content: &[u8]) -> String { blake3::hash(content).to_hex()[..7].to_string() } -/// Render the "loaded" info message shown on cd into a trusted directory. +/// Render the "loaded" info as individual shell echo statements. /// -/// Alias names are right-padded so `->` and commands align in columns. -/// Subcommand wrapper programs are listed with their short→long expansions. -pub fn render_load_message( +/// Returns one `Echo` per visual line, each wrapped in the shell's echo command. +/// This ensures correct cross-platform behavior (PowerShell `Write-Host` vs Unix `printf`). +pub fn render_load_lines( aliases: &crate::AliasSet, subcommands: &crate::subcommand::SubcommandSet, -) -> String { - let mut lines = vec!["am: loaded .aliases".to_string()]; - - // Find max alias name length for column alignment - let max_name_len = aliases - .iter() - .map(|(name, _)| name.as_ref().len()) - .max() - .unwrap_or(0); - - for (alias_name, alias_value) in aliases.iter() { - let name = alias_name.as_ref(); - let cmd = alias_value.command(); - let padded = format!("{:width$}", name, width = max_name_len); - lines.push(format!(" {padded} \u{2192} {cmd}")); - } - - let subcmd_groups = subcommands.group_by_program(); - for (program, entries) in &subcmd_groups { - lines.push(format!(" {program} (subcommands):")); - for entry in entries { - let shorts = entry.short_subcommands.join(" "); - let longs = entry.long_subcommands.join(" "); - lines.push(format!(" {shorts} \u{2192} {longs}")); + verbosity: &LogVerbosity, + shell: &dyn ShellAdapter, +) -> Vec { + match verbosity { + LogVerbosity::Off => vec![Echo::Silent], + LogVerbosity::Short => { + let names: Vec<&str> = aliases.iter().map(|(n, _)| n.as_ref()).collect(); + vec![Echo::Line( + shell.echo(&format!("am: loaded .aliases: {}", names.join(", "))), + )] + } + LogVerbosity::Verbose => { + let mut lines = vec![Echo::Line(shell.echo("am: loaded .aliases"))]; + let max_len = aliases + .iter() + .map(|(name, _)| name.as_ref().len()) + .max() + .unwrap_or(0); + for (alias_name, alias_value) in aliases.iter() { + let name = alias_name.as_ref(); + let cmd = alias_value.command(); + let padded = format!("{:width$}", name, width = max_len); + lines.push(Echo::Line( + shell.echo(&format!(" {padded} \u{2192} {cmd}")), + )); + } + let subcmd_groups = subcommands.group_by_program(); + for (program, entries) in &subcmd_groups { + lines.push(Echo::Line( + shell.echo(&format!(" {program} (subcommands):")), + )); + for entry in entries { + let shorts = entry.short_subcommands.join(" "); + let longs = entry.long_subcommands.join(" "); + lines.push(Echo::Line( + shell.echo(&format!(" {shorts} \u{2192} {longs}")), + )); + } + } + lines } } - - lines.join("\n") } #[cfg(test)] mod tests { use super::*; + use crate::config::LogVerbosity; + use crate::config::ShellsTomlConfig; + use crate::shell::Shell; use crate::{AliasName, AliasSet, TomlAlias}; fn test_aliases() -> AliasSet { @@ -159,32 +178,71 @@ mod tests { assert_eq!(hash, expected); } + fn test_shell() -> Box { + Shell::Fish.as_shell( + &ShellsTomlConfig::default(), + Default::default(), + Default::default(), + ) + } + #[test] - fn render_load_message_columnar_alignment() { + fn render_load_lines_off_returns_silent() { let aliases = test_aliases(); - let msg = render_load_message(&aliases, &Default::default()); - assert!(msg.starts_with("am: loaded .aliases\n")); - // All arrows should be at the same column - let arrow_positions: Vec = msg - .lines() - .skip(1) - .filter_map(|line| line.find('\u{2192}')) - .collect(); - assert!(!arrow_positions.is_empty()); - let first = arrow_positions[0]; - assert!( - arrow_positions.iter().all(|&p| p == first), - "Arrows not aligned: {arrow_positions:?}" + let lines = render_load_lines( + &aliases, + &Default::default(), + &LogVerbosity::Off, + test_shell().as_ref(), + ); + assert!(lines.iter().all(|l| matches!(l, crate::Echo::Silent))); + } + + #[test] + fn render_load_lines_short_single_line() { + let aliases = test_aliases(); + let lines = render_load_lines( + &aliases, + &Default::default(), + &LogVerbosity::Short, + test_shell().as_ref(), ); + assert_eq!(lines.len(), 1); + match &lines[0] { + crate::Echo::Line(s) => { + assert!(s.contains("am: loaded .aliases"), "got: {s}"); + assert!(s.contains("b"), "got: {s}"); + assert!(s.contains("t"), "got: {s}"); + } + _ => panic!("expected Echo::Line"), + } } #[test] - fn render_load_message_contains_all_aliases() { + fn render_load_lines_verbose_multi_line() { let aliases = test_aliases(); - let msg = render_load_message(&aliases, &Default::default()); - assert!(msg.contains("make build")); - assert!(msg.contains("cargo test")); - assert!(msg.contains("cargo build")); + let lines = render_load_lines( + &aliases, + &Default::default(), + &LogVerbosity::Verbose, + test_shell().as_ref(), + ); + // Header + one line per alias (3 aliases in test_aliases) + assert!( + lines.len() >= 4, + "expected at least 4 lines, got {}", + lines.len() + ); + let line_strs: Vec<&str> = lines + .iter() + .filter_map(|l| match l { + crate::Echo::Line(s) => Some(s.as_str()), + _ => None, + }) + .collect(); + assert!(line_strs[0].contains("am: loaded .aliases")); + assert!(line_strs.iter().any(|s| s.contains("make build"))); + assert!(line_strs.iter().any(|s| s.contains("cargo test"))); } #[test] diff --git a/crates/am/src/update.rs b/crates/am/src/update.rs index 63ed1a81..a41d5401 100644 --- a/crates/am/src/update.rs +++ b/crates/am/src/update.rs @@ -10,6 +10,7 @@ use crate::shell::bash; use crate::shell::zsh; use crate::shell::Shell; use crate::shell::ShellContext; +use crate::sync_outcome::{PathUpdate, ProjectTransition, SyncOutcome}; use crate::trust::ProjectTrust; use crate::{profile, AliasDisplayFilter, AliasTarget, Message, Profile}; @@ -19,17 +20,17 @@ pub struct UpdateResult { } impl UpdateResult { - pub fn new(message: Message, effects: &[Effect]) -> Self { + pub fn new(message: Message, effects: Vec) -> Self { Self { next: Some(message), - effects: effects.to_vec(), + effects, } } - pub fn with_effects(effects: &[Effect]) -> Self { + pub fn with_effects(effects: Vec) -> Self { Self { next: None, - effects: effects.to_vec(), + effects, } } @@ -195,7 +196,7 @@ pub fn update(model: &mut AppModel, message: Message) -> Result Result Result { @@ -392,7 +393,7 @@ pub fn update(model: &mut AppModel, message: Message) -> Result { let profile = resolve_profile_mut(model, &target)?; @@ -487,7 +488,7 @@ pub fn update(model: &mut AppModel, message: Message) -> Result { let mut effects: Vec = pairs @@ -502,7 +503,7 @@ pub fn update(model: &mut AppModel, message: Message) -> Result { let profile = resolve_profile_mut(model, &target)?; @@ -516,7 +517,7 @@ pub fn update(model: &mut AppModel, message: Message) -> Result Result Result Result = Vec::new(); + let mut security_warnings = Vec::new(); let mut security_changed = false; let mut include_project = false; match model.project_trust() { @@ -687,17 +682,19 @@ pub fn update(model: &mut AppModel, message: Message) -> Result { if show_warn { - lines.push(shell_impl.echo( - "am: .aliases found but not trusted. Run 'am trust' to review and allow.", - )); + security_warnings.push( + "am: .aliases found but not trusted. Run 'am trust' to review and allow." + .to_string(), + ); } } Some(crate::trust::ProjectTrust::Tampered(_)) => { security_changed = true; if show_warn { - lines.push(shell_impl.echo( - "am: .aliases was modified since last trusted. Run 'am trust' to review and allow.", - )); + security_warnings.push( + "am: .aliases was modified since last trusted. Run 'am trust' to review and allow." + .to_string(), + ); } } Some(crate::trust::ProjectTrust::Untrusted(_)) | None => {} @@ -712,9 +709,6 @@ pub fn update(model: &mut AppModel, message: Message) -> Result Result {} - (_, Some(cur)) => { - lines.push(shell_impl.set_env(env_vars::AM_PROJECT_PATH, cur)); - } - (Some(_), None) => { - lines.push(shell_impl.unset_env(env_vars::AM_PROJECT_PATH)); - } - (None, None) => {} - } + let path_update = match (prev_project_path.as_deref(), current_path_str.as_deref()) { + (Some(prev), Some(cur)) if prev == cur => PathUpdate::Unchanged, + (_, Some(cur)) => PathUpdate::Set(cur.to_string()), + (Some(_), None) => PathUpdate::Unset, + (None, None) => PathUpdate::Unchanged, + }; - let joined = lines - .into_iter() - .filter(|l| !l.is_empty()) - .collect::>() - .join("\n"); - if !joined.is_empty() { - print!("{joined}"); + let mut builder = SyncOutcome::builder(shell, model.config.shell.clone(), quiet) + .transition(transition) + .diff(diff) + .path_update(path_update); + for warning in security_warnings { + builder = builder.security_warning(warning); } + let outcome = builder.build(); if security_changed { - Ok(UpdateResult::effect(Effect::SaveSecurity)) + Ok(UpdateResult::with_effects(vec![ + Effect::RenderSync(outcome), + Effect::SaveSecurity, + ])) } else { - Ok(UpdateResult::done()) + Ok(UpdateResult::effect(Effect::RenderSync(outcome))) } } Message::ToggleProfiles(names) => { @@ -793,7 +776,7 @@ pub fn update(model: &mut AppModel, message: Message) -> Result { for name in &names { @@ -816,7 +799,7 @@ pub fn update(model: &mut AppModel, message: Message) -> Result { model @@ -824,7 +807,7 @@ pub fn update(model: &mut AppModel, message: Message) -> Result Result { let path = model @@ -887,14 +870,14 @@ pub fn update(model: &mut AppModel, message: Message) -> Result Result Option<(String, bool)> { @@ -1303,7 +1286,7 @@ mod tests { fn update_result_new_has_message_and_effects() { let r = UpdateResult::new( Message::ListProfiles { used: false }, - &[Effect::SaveConfig, Effect::SaveProfiles], + vec![Effect::SaveConfig, Effect::SaveProfiles], ); assert!(r.next.is_some()); assert_eq!(r.effects.len(), 2); @@ -1311,7 +1294,7 @@ mod tests { #[test] fn update_result_with_effects_has_effects_and_no_message() { - let r = UpdateResult::with_effects(&[Effect::SaveConfig, Effect::SaveProfiles]); + let r = UpdateResult::with_effects(vec![Effect::SaveConfig, Effect::SaveProfiles]); assert!(r.next.is_none()); assert_eq!(r.effects.len(), 2); } diff --git a/website/advanced/config-files.md b/website/advanced/config-files.md index a7b2b706..5d8d450e 100644 --- a/website/advanced/config-files.md +++ b/website/advanced/config-files.md @@ -66,6 +66,37 @@ To enable this setting, edit `~/.config/amoxide/config.toml` manually and add th am init -f fish | source ``` +## `config.toml` — Logging + +Controls the verbosity of messages shown when navigating into and out of directories with project aliases. + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `project_loading` | string | `"verbose"` | Message shown when entering a project with trusted `.aliases` | +| `project_unloading` | string | `"verbose"` | Message shown when leaving a project | + +Accepted values for both keys: + +| Value | Effect | +|-------|--------| +| `"off"` | No message is shown | +| `"short"` | One-line summary (loading: `am: loaded .aliases: b, t`; unloading: `am: .aliases unloaded`) | +| `"verbose"` | Full detail (loading: aligned alias table; unloading: change summary with added/unloaded counts) | + +```toml +[logging] +project_loading = "verbose" +project_unloading = "short" +``` + +To suppress all navigation messages: + +```toml +[logging] +project_loading = "off" +project_unloading = "off" +``` + ## `profiles.toml` — Profile Definitions ```toml diff --git a/website/usage/project-aliases.md b/website/usage/project-aliases.md index 29d9c4cb..33313f7a 100644 --- a/website/usage/project-aliases.md +++ b/website/usage/project-aliases.md @@ -99,7 +99,11 @@ When you use `am` itself to modify the file — via `am add -l` or `am remove -l ### Load and unload messages -When aliases are loaded, you see which commands became available: +When you `cd` into a directory with a trusted `.aliases` file, amoxide shows which commands became available. When you leave, it reports what changed. Both messages can be configured independently. + +#### Loading (cd into a project) + +The default (`"verbose"`) shows an aligned table: ``` am: loaded .aliases @@ -107,12 +111,42 @@ am: loaded .aliases t → cargo test ``` -When you leave the project: +Set to `"short"` for a compact one-liner: + +``` +am: loaded .aliases: b, t +``` + +Set to `"off"` to suppress the message entirely. + +#### Unloading (cd out of a project) + +The default (`"verbose"`) shows a change summary including which aliases were unloaded and which were added back (e.g. a profile alias becoming active after the project alias that was shadowing it is removed): ``` -am: unloaded .aliases: b, t +am: .aliases unloaded — 2 added: i, t | 2 unloaded: docs, f ``` +Set to `"short"` for a brief confirmation: + +``` +am: .aliases unloaded +``` + +Set to `"off"` to suppress the message entirely. + +#### Configuring verbosity + +Add a `[logging]` section to `~/.config/amoxide/config.toml`: + +```toml +[logging] +project_loading = "verbose" # "off" | "short" | "verbose" +project_unloading = "short" # "off" | "short" | "verbose" +``` + +Both default to `"verbose"` if omitted. See [Config File Reference](/advanced/config-files#config-toml-logging) for the full reference. Introduced in [#109](https://github.com/sassman/amoxide-rs/issues/109). + These messages only appear when entering or leaving the directory containing the `.aliases` file — not when navigating subdirectories within the same project. ## How It Works