use std::path::{Path, PathBuf};
use crate::bundled::BundledAgent;
use crate::config::Config;
pub struct SetupPlan {
pub config: Config,
pub agents: Vec<&'static BundledAgent>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Applied {
pub config_path: PathBuf,
pub agents_installed: Vec<String>,
pub warnings: Vec<String>,
}
pub fn apply(plan: &SetupPlan, config_path: &Path, agents_dir: &Path) -> anyhow::Result<Applied> {
plan.config.save_to_path_public(config_path)?;
let mut agents_installed = Vec::new();
let mut warnings = Vec::new();
for agent in &plan.agents {
match crate::bundled::install_bundled(agent, agents_dir) {
Ok(()) => agents_installed.push(agent.name.to_string()),
Err(e) => warnings.push(format!("could not install {}: {e}", agent.name)),
}
}
Ok(Applied {
config_path: config_path.to_path_buf(),
agents_installed,
warnings,
})
}
pub fn changes(before: &Config, plan: &SetupPlan) -> Vec<String> {
let after = &plan.config;
let mut out = Vec::new();
for provider in super::catalog::providers() {
let old = super::catalog::stored_credential(before, provider.id);
let new = super::catalog::stored_credential(after, provider.id);
let label = provider.display;
match (old, new) {
(None, Some(_)) => out.push(format!("{label}: credential set")),
(Some(_), None) => out.push(format!("{label}: credential cleared")),
(Some(a), Some(b)) if a != b => out.push(format!("{label}: credential changed")),
_ => {}
}
}
if before.providers.claude_code_enabled != after.providers.claude_code_enabled {
out.push(format!(
"Claude Code transport: {}",
if after.providers.claude_code_enabled {
"enabled"
} else {
"disabled"
}
));
}
push_if_changed(
&mut out,
"default provider",
Some(&before.default_provider),
Some(&after.default_provider),
);
push_if_changed(
&mut out,
"default model",
before.default_model.as_ref(),
after.default_model.as_ref(),
);
push_if_changed(
&mut out,
"max concurrent inferences",
before.limits.max_concurrent_inferences.as_ref(),
after.limits.max_concurrent_inferences.as_ref(),
);
push_if_changed(
&mut out,
"max concurrent tools",
Some(&before.limits.max_concurrent_tools),
Some(&after.limits.max_concurrent_tools),
);
push_if_changed(
&mut out,
"default max iterations",
before.limits.default_max_iterations.as_ref(),
after.limits.default_max_iterations.as_ref(),
);
push_if_changed(
&mut out,
"exact token counting",
Some(&before.limits.exact_token_counting),
Some(&after.limits.exact_token_counting),
);
push_if_changed(
&mut out,
"batch tool hint",
Some(&before.batch_tool_hint),
Some(&after.batch_tool_hint),
);
push_if_changed(
&mut out,
"platform shell hint",
Some(&before.shell_hint),
Some(&after.shell_hint),
);
push_if_changed(
&mut out,
"stall timeout (seconds)",
Some(&before.limits.stall_timeout_secs),
Some(&after.limits.stall_timeout_secs),
);
push_if_changed(
&mut out,
"dead cycles before relief",
Some(&before.limits.dead_cycles_before_relief),
Some(&after.limits.dead_cycles_before_relief),
);
push_if_changed(
&mut out,
"finished run retention (seconds)",
Some(&before.limits.finished_retention_secs),
Some(&after.limits.finished_retention_secs),
);
push_if_changed(
&mut out,
"wedge timeout (seconds)",
Some(&before.limits.wedge_timeout_secs),
Some(&after.limits.wedge_timeout_secs),
);
let added = after
.mcp_servers
.len()
.saturating_sub(before.mcp_servers.len());
if added > 0 {
out.push(format!("MCP servers: {added} imported"));
}
if !plan.agents.is_empty() {
out.push(format!("agents: {} to install", plan.agents.len()));
}
out
}
fn push_if_changed<T: PartialEq + std::fmt::Display>(
out: &mut Vec<String>,
label: &str,
before: Option<&T>,
after: Option<&T>,
) {
let describe = |v: Option<&T>| match v {
Some(v) => v.to_string(),
None => "(unset)".to_string(),
};
if before != after {
out.push(format!(
"{label}: {} → {}",
describe(before),
describe(after)
));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bundled::BUNDLED_AGENTS;
fn plan_of(config: Config) -> SetupPlan {
SetupPlan {
config,
agents: Vec::new(),
}
}
#[test]
fn apply_writes_the_config_and_installs_the_chosen_agents() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let agents_dir = dir.path().join("agents");
let mut config = Config::default();
config.providers.anthropic_api_key = Some("sk-ant-x".to_string());
let plan = SetupPlan {
config,
agents: vec![&BUNDLED_AGENTS[0]],
};
let applied = apply(&plan, &config_path, &agents_dir).unwrap();
assert_eq!(applied.config_path, config_path);
assert_eq!(applied.agents_installed, vec![BUNDLED_AGENTS[0].name]);
assert!(applied.warnings.is_empty());
let written = Config::load_from_path_public(&config_path).unwrap();
assert_eq!(
written.providers.anthropic_api_key.as_deref(),
Some("sk-ant-x")
);
assert!(
agents_dir
.join(BUNDLED_AGENTS[0].name)
.join("agent.leviath")
.exists()
);
}
#[test]
fn apply_with_nothing_to_install_still_writes_the_config() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let applied = apply(
&plan_of(Config::default()),
&config_path,
&dir.path().join("agents"),
)
.unwrap();
assert!(applied.agents_installed.is_empty());
assert!(config_path.exists());
}
#[test]
fn a_blueprint_that_fails_to_install_warns_rather_than_aborting() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let agents_dir = dir.path().join("blocked");
std::fs::write(&agents_dir, "").unwrap();
let plan = SetupPlan {
config: Config::default(),
agents: vec![&BUNDLED_AGENTS[0]],
};
let applied = apply(&plan, &config_path, &agents_dir).unwrap();
assert!(applied.agents_installed.is_empty());
assert_eq!(applied.warnings.len(), 1);
assert!(applied.warnings[0].contains(BUNDLED_AGENTS[0].name));
assert!(config_path.exists(), "the config was still written");
}
#[test]
fn a_config_that_cannot_be_written_is_a_hard_error() {
let dir = tempfile::tempdir().unwrap();
let blocked = dir.path().join("not-a-dir");
std::fs::write(&blocked, "").unwrap();
let result = apply(
&plan_of(Config::default()),
&blocked.join("config.toml"),
&dir.path().join("agents"),
);
assert!(result.is_err());
}
#[test]
fn an_unchanged_plan_lists_nothing() {
assert!(changes(&Config::default(), &plan_of(Config::default())).is_empty());
}
#[test]
fn credential_changes_are_described_but_never_printed() {
let mut before = Config::default();
before.providers.openai_api_key = Some("sk-old-secret".to_string());
before.openrouter_api_key = Some("sk-or-doomed".to_string());
let mut after = before.clone();
after.providers.anthropic_api_key = Some("sk-ant-brand-new".to_string());
after.providers.openai_api_key = Some("sk-new-secret".to_string());
after.openrouter_api_key = None;
let lines = changes(&before, &plan_of(after));
assert!(lines.contains(&"Anthropic: credential set".to_string()));
assert!(lines.contains(&"OpenAI: credential changed".to_string()));
assert!(lines.contains(&"OpenRouter: credential cleared".to_string()));
for line in &lines {
assert!(!line.contains("secret"), "a credential leaked: {line}");
assert!(!line.contains("sk-"), "a credential leaked: {line}");
}
}
#[test]
fn an_unchanged_credential_is_not_listed() {
let mut before = Config::default();
before.providers.anthropic_api_key = Some("sk-ant-same".to_string());
assert!(changes(&before, &plan_of(before.clone())).is_empty());
}
#[test]
fn the_claude_code_toggle_is_reported_both_ways() {
let before = Config::default();
let mut on = before.clone();
on.providers.claude_code_enabled = true;
assert!(
changes(&before, &plan_of(on.clone()))
.contains(&"Claude Code transport: enabled".to_string())
);
assert!(
changes(&on, &plan_of(before)).contains(&"Claude Code transport: disabled".to_string())
);
}
#[test]
fn scalar_settings_are_shown_as_old_to_new() {
let before = Config::default();
let mut after = before.clone();
after.default_provider = "ollama".to_string();
after.default_model = Some("llama3".to_string());
after.limits.max_concurrent_inferences = Some(1);
after.limits.max_concurrent_tools = 4;
after.limits.default_max_iterations = None;
after.limits.exact_token_counting = true;
after.batch_tool_hint = false;
after.shell_hint = false;
let lines = changes(&before, &plan_of(after));
assert!(lines.contains(&"default provider: anthropic → ollama".to_string()));
assert!(lines.contains(&"default model: (unset) → llama3".to_string()));
assert!(lines.contains(&"max concurrent inferences: 8 → 1".to_string()));
assert!(lines.contains(&"max concurrent tools: 8 → 4".to_string()));
assert!(lines.contains(&"default max iterations: 50 → (unset)".to_string()));
assert!(lines.contains(&"exact token counting: false → true".to_string()));
assert!(lines.contains(&"batch tool hint: true → false".to_string()));
assert!(lines.contains(&"platform shell hint: true → false".to_string()));
}
#[test]
fn imported_servers_and_pending_agents_are_counted() {
let before = Config::default();
let mut after = before.clone();
after.mcp_servers = vec![
leviath_mcp::MCPServerConfig::stdio("a", "x", vec![]),
leviath_mcp::MCPServerConfig::stdio("b", "y", vec![]),
];
let plan = SetupPlan {
config: after,
agents: vec![&BUNDLED_AGENTS[0], &BUNDLED_AGENTS[1]],
};
let lines = changes(&before, &plan);
assert!(lines.contains(&"MCP servers: 2 imported".to_string()));
assert!(lines.contains(&"agents: 2 to install".to_string()));
}
#[test]
fn removing_servers_is_not_reported_as_an_import() {
let before = Config {
mcp_servers: vec![leviath_mcp::MCPServerConfig::stdio("a", "x", vec![])],
..Config::default()
};
let after = Config::default();
let lines = changes(&before, &plan_of(after));
assert!(
lines.is_empty(),
"a shrink must not be reported as an import"
);
}
}