Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/am/src/alias.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AliasName, TomlAlias>);

impl AsRef<BTreeMap<AliasName, TomlAlias>> for AliasSet {
Expand Down
47 changes: 32 additions & 15 deletions crates/am/src/bin/am.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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(());
}
Expand All @@ -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(());
}
Expand All @@ -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(());
}
Expand Down Expand Up @@ -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(());
}
Expand Down Expand Up @@ -367,35 +367,35 @@ 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),
Commands::Sync { shell, quiet } => Message::Sync(shell.clone(), *quiet),
};

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<Effect>) -> anyhow::Result<()> {
let has_local_mutation = effects.iter().any(|e| {
matches!(
e,
Expand All @@ -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::<Vec<_>>()
.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()?,
}
}
Expand Down
53 changes: 51 additions & 2 deletions crates/am/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<FishConfig>,
}

#[derive(Debug, Deserialize, Serialize, Clone)]
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
pub struct FishConfig {
#[serde(default)]
pub use_abbr: bool,
Expand All @@ -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<LogVerbosity>,

#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_unloading: Option<LogVerbosity>,
}

#[derive(Debug, Deserialize, Serialize, Clone)]
#[serde(rename_all = "lowercase")]
pub enum LogVerbosity {
Off,
Short,
Verbose,
}

impl Config {
Expand Down Expand Up @@ -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());
}
}
85 changes: 84 additions & 1 deletion crates/am/src/effects.rs
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -19,6 +52,8 @@ pub enum Effect {
key: String,
},
Print(String),
PrintLines(Vec<Echo>),
RenderSync(SyncOutcome),
SaveSecurity,
}

Expand Down Expand Up @@ -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::*;
Expand Down
1 change: 1 addition & 0 deletions crates/am/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
61 changes: 61 additions & 0 deletions crates/am/src/precedence/diff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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:
Expand Down Expand Up @@ -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());
}
}
Loading
Loading