leviath_cli/commands/setup/
plan.rs1use std::path::{Path, PathBuf};
14
15use crate::bundled::BundledAgent;
16use crate::config::Config;
17
18pub struct SetupPlan {
20 pub config: Config,
23 pub agents: Vec<&'static BundledAgent>,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Applied {
30 pub config_path: PathBuf,
31 pub agents_installed: Vec<String>,
32 pub warnings: Vec<String>,
34}
35
36pub fn apply(plan: &SetupPlan, config_path: &Path, agents_dir: &Path) -> anyhow::Result<Applied> {
43 plan.config.save_to_path_public(config_path)?;
44
45 let mut agents_installed = Vec::new();
46 let mut warnings = Vec::new();
47 for agent in &plan.agents {
48 match crate::bundled::install_bundled(agent, agents_dir) {
49 Ok(()) => agents_installed.push(agent.name.to_string()),
50 Err(e) => warnings.push(format!("could not install {}: {e}", agent.name)),
51 }
52 }
53
54 Ok(Applied {
55 config_path: config_path.to_path_buf(),
56 agents_installed,
57 warnings,
58 })
59}
60
61pub fn changes(before: &Config, plan: &SetupPlan) -> Vec<String> {
69 let after = &plan.config;
70 let mut out = Vec::new();
71
72 for provider in super::catalog::providers() {
73 let old = super::catalog::stored_credential(before, provider.id);
74 let new = super::catalog::stored_credential(after, provider.id);
75 let label = provider.display;
76 match (old, new) {
77 (None, Some(_)) => out.push(format!("{label}: credential set")),
78 (Some(_), None) => out.push(format!("{label}: credential cleared")),
79 (Some(a), Some(b)) if a != b => out.push(format!("{label}: credential changed")),
80 _ => {}
81 }
82 }
83
84 if before.providers.claude_code_enabled != after.providers.claude_code_enabled {
85 out.push(format!(
86 "Claude Code transport: {}",
87 if after.providers.claude_code_enabled {
88 "enabled"
89 } else {
90 "disabled"
91 }
92 ));
93 }
94 push_if_changed(
95 &mut out,
96 "default provider",
97 Some(&before.default_provider),
98 Some(&after.default_provider),
99 );
100 push_if_changed(
101 &mut out,
102 "default model",
103 before.default_model.as_ref(),
104 after.default_model.as_ref(),
105 );
106 push_if_changed(
107 &mut out,
108 "max concurrent inferences",
109 before.limits.max_concurrent_inferences.as_ref(),
110 after.limits.max_concurrent_inferences.as_ref(),
111 );
112 push_if_changed(
113 &mut out,
114 "max concurrent tools",
115 Some(&before.limits.max_concurrent_tools),
116 Some(&after.limits.max_concurrent_tools),
117 );
118 push_if_changed(
119 &mut out,
120 "default max iterations",
121 before.limits.default_max_iterations.as_ref(),
122 after.limits.default_max_iterations.as_ref(),
123 );
124 push_if_changed(
125 &mut out,
126 "exact token counting",
127 Some(&before.limits.exact_token_counting),
128 Some(&after.limits.exact_token_counting),
129 );
130 push_if_changed(
131 &mut out,
132 "batch tool hint",
133 Some(&before.batch_tool_hint),
134 Some(&after.batch_tool_hint),
135 );
136
137 let added = after
138 .mcp_servers
139 .len()
140 .saturating_sub(before.mcp_servers.len());
141 if added > 0 {
142 out.push(format!("MCP servers: {added} imported"));
143 }
144 if !plan.agents.is_empty() {
145 out.push(format!("agents: {} to install", plan.agents.len()));
146 }
147 out
148}
149
150fn push_if_changed<T: PartialEq + std::fmt::Display>(
152 out: &mut Vec<String>,
153 label: &str,
154 before: Option<&T>,
155 after: Option<&T>,
156) {
157 let describe = |v: Option<&T>| match v {
158 Some(v) => v.to_string(),
159 None => "(unset)".to_string(),
160 };
161 if before != after {
162 out.push(format!(
163 "{label}: {} → {}",
164 describe(before),
165 describe(after)
166 ));
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use crate::bundled::BUNDLED_AGENTS;
174
175 fn plan_of(config: Config) -> SetupPlan {
176 SetupPlan {
177 config,
178 agents: Vec::new(),
179 }
180 }
181
182 #[test]
185 fn apply_writes_the_config_and_installs_the_chosen_agents() {
186 let dir = tempfile::tempdir().unwrap();
187 let config_path = dir.path().join("config.toml");
188 let agents_dir = dir.path().join("agents");
189 let mut config = Config::default();
190 config.providers.anthropic_api_key = Some("sk-ant-x".to_string());
191 let plan = SetupPlan {
192 config,
193 agents: vec![&BUNDLED_AGENTS[0]],
194 };
195
196 let applied = apply(&plan, &config_path, &agents_dir).unwrap();
197
198 assert_eq!(applied.config_path, config_path);
199 assert_eq!(applied.agents_installed, vec![BUNDLED_AGENTS[0].name]);
200 assert!(applied.warnings.is_empty());
201 let written = Config::load_from_path_public(&config_path).unwrap();
202 assert_eq!(
203 written.providers.anthropic_api_key.as_deref(),
204 Some("sk-ant-x")
205 );
206 assert!(
207 agents_dir
208 .join(BUNDLED_AGENTS[0].name)
209 .join("agent.leviath")
210 .exists()
211 );
212 }
213
214 #[test]
215 fn apply_with_nothing_to_install_still_writes_the_config() {
216 let dir = tempfile::tempdir().unwrap();
217 let config_path = dir.path().join("config.toml");
218
219 let applied = apply(
220 &plan_of(Config::default()),
221 &config_path,
222 &dir.path().join("agents"),
223 )
224 .unwrap();
225
226 assert!(applied.agents_installed.is_empty());
227 assert!(config_path.exists());
228 }
229
230 #[test]
231 fn a_blueprint_that_fails_to_install_warns_rather_than_aborting() {
232 let dir = tempfile::tempdir().unwrap();
235 let config_path = dir.path().join("config.toml");
236 let agents_dir = dir.path().join("blocked");
238 std::fs::write(&agents_dir, "").unwrap();
239 let plan = SetupPlan {
240 config: Config::default(),
241 agents: vec![&BUNDLED_AGENTS[0]],
242 };
243
244 let applied = apply(&plan, &config_path, &agents_dir).unwrap();
245
246 assert!(applied.agents_installed.is_empty());
247 assert_eq!(applied.warnings.len(), 1);
248 assert!(applied.warnings[0].contains(BUNDLED_AGENTS[0].name));
249 assert!(config_path.exists(), "the config was still written");
250 }
251
252 #[test]
253 fn a_config_that_cannot_be_written_is_a_hard_error() {
254 let dir = tempfile::tempdir().unwrap();
256 let blocked = dir.path().join("not-a-dir");
257 std::fs::write(&blocked, "").unwrap();
258
259 let result = apply(
260 &plan_of(Config::default()),
261 &blocked.join("config.toml"),
262 &dir.path().join("agents"),
263 );
264
265 assert!(result.is_err());
266 }
267
268 #[test]
271 fn an_unchanged_plan_lists_nothing() {
272 assert!(changes(&Config::default(), &plan_of(Config::default())).is_empty());
273 }
274
275 #[test]
276 fn credential_changes_are_described_but_never_printed() {
277 let mut before = Config::default();
279 before.providers.openai_api_key = Some("sk-old-secret".to_string());
280 before.openrouter_api_key = Some("sk-or-doomed".to_string());
281 let mut after = before.clone();
282 after.providers.anthropic_api_key = Some("sk-ant-brand-new".to_string());
283 after.providers.openai_api_key = Some("sk-new-secret".to_string());
284 after.openrouter_api_key = None;
285
286 let lines = changes(&before, &plan_of(after));
287
288 assert!(lines.contains(&"Anthropic: credential set".to_string()));
289 assert!(lines.contains(&"OpenAI: credential changed".to_string()));
290 assert!(lines.contains(&"OpenRouter: credential cleared".to_string()));
291 for line in &lines {
292 assert!(!line.contains("secret"), "a credential leaked: {line}");
293 assert!(!line.contains("sk-"), "a credential leaked: {line}");
294 }
295 }
296
297 #[test]
298 fn an_unchanged_credential_is_not_listed() {
299 let mut before = Config::default();
300 before.providers.anthropic_api_key = Some("sk-ant-same".to_string());
301
302 assert!(changes(&before, &plan_of(before.clone())).is_empty());
303 }
304
305 #[test]
306 fn the_claude_code_toggle_is_reported_both_ways() {
307 let before = Config::default();
308 let mut on = before.clone();
309 on.providers.claude_code_enabled = true;
310
311 assert!(
312 changes(&before, &plan_of(on.clone()))
313 .contains(&"Claude Code transport: enabled".to_string())
314 );
315 assert!(
316 changes(&on, &plan_of(before)).contains(&"Claude Code transport: disabled".to_string())
317 );
318 }
319
320 #[test]
321 fn scalar_settings_are_shown_as_old_to_new() {
322 let before = Config::default();
323 let mut after = before.clone();
324 after.default_provider = "ollama".to_string();
325 after.default_model = Some("llama3".to_string());
326 after.limits.max_concurrent_inferences = Some(1);
327 after.limits.max_concurrent_tools = 4;
328 after.limits.default_max_iterations = None;
329 after.limits.exact_token_counting = true;
330 after.batch_tool_hint = false;
331
332 let lines = changes(&before, &plan_of(after));
333
334 assert!(lines.contains(&"default provider: anthropic → ollama".to_string()));
335 assert!(lines.contains(&"default model: (unset) → llama3".to_string()));
336 assert!(lines.contains(&"max concurrent inferences: 8 → 1".to_string()));
337 assert!(lines.contains(&"max concurrent tools: 8 → 4".to_string()));
338 assert!(lines.contains(&"default max iterations: 50 → (unset)".to_string()));
339 assert!(lines.contains(&"exact token counting: false → true".to_string()));
340 assert!(lines.contains(&"batch tool hint: true → false".to_string()));
341 }
342
343 #[test]
344 fn imported_servers_and_pending_agents_are_counted() {
345 let before = Config::default();
346 let mut after = before.clone();
347 after.mcp_servers = vec![
348 leviath_mcp::MCPServerConfig::stdio("a", "x", vec![]),
349 leviath_mcp::MCPServerConfig::stdio("b", "y", vec![]),
350 ];
351 let plan = SetupPlan {
352 config: after,
353 agents: vec![&BUNDLED_AGENTS[0], &BUNDLED_AGENTS[1]],
354 };
355
356 let lines = changes(&before, &plan);
357
358 assert!(lines.contains(&"MCP servers: 2 imported".to_string()));
359 assert!(lines.contains(&"agents: 2 to install".to_string()));
360 }
361
362 #[test]
363 fn removing_servers_is_not_reported_as_an_import() {
364 let before = Config {
366 mcp_servers: vec![leviath_mcp::MCPServerConfig::stdio("a", "x", vec![])],
367 ..Config::default()
368 };
369 let after = Config::default();
370
371 let lines = changes(&before, &plan_of(after));
372
373 assert!(
374 lines.is_empty(),
375 "a shrink must not be reported as an import"
376 );
377 }
378}