Skip to main content

leviath_cli/commands/setup/
plan.rs

1//! What setup decided, as plain data, and applying it.
2//!
3//! This is the contract between the wizard and the world, and the reason the
4//! terminal UI is a *front-end* rather than the feature itself. Everything the
5//! user chose lands in a [`SetupPlan`]; [`apply`] is the only thing that
6//! touches disk. The `--non-interactive` flag path builds the same struct, and
7//! a future mobile or web host would build it a third way with nothing
8//! downstream changing.
9//!
10//! Keeping it separate also means the interesting logic - what actually
11//! changes, and what to warn about - is testable without a terminal.
12
13use std::path::{Path, PathBuf};
14
15use crate::bundled::BundledAgent;
16use crate::config::Config;
17
18/// Everything `lev setup` decided to do.
19pub struct SetupPlan {
20    /// The config to write, fully resolved. MCP imports are already merged into
21    /// its `mcp_servers`.
22    pub config: Config,
23    /// Blueprints to install or update.
24    pub agents: Vec<&'static BundledAgent>,
25}
26
27/// What actually happened, for the closing summary.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Applied {
30    /// The config file that was written.
31    pub config_path: PathBuf,
32    /// Blueprints installed, by name.
33    pub agents_installed: Vec<String>,
34    /// Non-fatal problems worth telling the user about.
35    pub warnings: Vec<String>,
36}
37
38/// Write the config and install the chosen blueprints.
39///
40/// Config first, and it is the only fallible-and-fatal step: a blueprint that
41/// fails to install is reported as a warning rather than aborting, because a
42/// written config plus nine of ten agents is a far better place to leave
43/// someone than an abandoned run with nothing saved.
44pub fn apply(plan: &SetupPlan, config_path: &Path, agents_dir: &Path) -> anyhow::Result<Applied> {
45    plan.config.save_to_path_public(config_path)?;
46
47    let mut agents_installed = Vec::new();
48    let mut warnings = Vec::new();
49    for agent in &plan.agents {
50        match crate::bundled::install_bundled(agent, agents_dir) {
51            Ok(()) => agents_installed.push(agent.name.to_string()),
52            Err(e) => warnings.push(format!("could not install {}: {e}", agent.name)),
53        }
54    }
55
56    Ok(Applied {
57        config_path: config_path.to_path_buf(),
58        agents_installed,
59        warnings,
60    })
61}
62
63/// A human-readable list of what this plan changes against `before`, for the
64/// review screen. Empty means nothing would change.
65///
66/// Credentials are described as "set" / "changed" / "cleared" and never
67/// printed - the review screen is exactly the moment a shoulder-surfer is
68/// looking, and a key the user cannot read back is not a real loss when the
69/// wizard just verified it works.
70pub fn changes(before: &Config, plan: &SetupPlan) -> Vec<String> {
71    let after = &plan.config;
72    let mut out = Vec::new();
73
74    for provider in super::catalog::providers() {
75        let old = super::catalog::stored_credential(before, provider.id);
76        let new = super::catalog::stored_credential(after, provider.id);
77        let label = provider.display;
78        match (old, new) {
79            (None, Some(_)) => out.push(format!("{label}: credential set")),
80            (Some(_), None) => out.push(format!("{label}: credential cleared")),
81            (Some(a), Some(b)) if a != b => out.push(format!("{label}: credential changed")),
82            _ => {}
83        }
84    }
85
86    if before.providers.claude_code_enabled != after.providers.claude_code_enabled {
87        out.push(format!(
88            "Claude Code transport: {}",
89            if after.providers.claude_code_enabled {
90                "enabled"
91            } else {
92                "disabled"
93            }
94        ));
95    }
96    push_if_changed(
97        &mut out,
98        "default provider",
99        Some(&before.default_provider),
100        Some(&after.default_provider),
101    );
102    push_if_changed(
103        &mut out,
104        "default model",
105        before.default_model.as_ref(),
106        after.default_model.as_ref(),
107    );
108    push_if_changed(
109        &mut out,
110        "max concurrent inferences",
111        before.limits.max_concurrent_inferences.as_ref(),
112        after.limits.max_concurrent_inferences.as_ref(),
113    );
114    push_if_changed(
115        &mut out,
116        "max concurrent tools",
117        Some(&before.limits.max_concurrent_tools),
118        Some(&after.limits.max_concurrent_tools),
119    );
120    push_if_changed(
121        &mut out,
122        "default max iterations",
123        before.limits.default_max_iterations.as_ref(),
124        after.limits.default_max_iterations.as_ref(),
125    );
126    push_if_changed(
127        &mut out,
128        "exact token counting",
129        Some(&before.limits.exact_token_counting),
130        Some(&after.limits.exact_token_counting),
131    );
132    push_if_changed(
133        &mut out,
134        "batch tool hint",
135        Some(&before.batch_tool_hint),
136        Some(&after.batch_tool_hint),
137    );
138    push_if_changed(
139        &mut out,
140        "platform shell hint",
141        Some(&before.shell_hint),
142        Some(&after.shell_hint),
143    );
144    push_if_changed(
145        &mut out,
146        "stall timeout (seconds)",
147        Some(&before.limits.stall_timeout_secs),
148        Some(&after.limits.stall_timeout_secs),
149    );
150    push_if_changed(
151        &mut out,
152        "dead cycles before relief",
153        Some(&before.limits.dead_cycles_before_relief),
154        Some(&after.limits.dead_cycles_before_relief),
155    );
156    push_if_changed(
157        &mut out,
158        "finished run retention (seconds)",
159        Some(&before.limits.finished_retention_secs),
160        Some(&after.limits.finished_retention_secs),
161    );
162    push_if_changed(
163        &mut out,
164        "wedge timeout (seconds)",
165        Some(&before.limits.wedge_timeout_secs),
166        Some(&after.limits.wedge_timeout_secs),
167    );
168
169    let added = after
170        .mcp_servers
171        .len()
172        .saturating_sub(before.mcp_servers.len());
173    if added > 0 {
174        out.push(format!("MCP servers: {added} imported"));
175    }
176    if !plan.agents.is_empty() {
177        out.push(format!("agents: {} to install", plan.agents.len()));
178    }
179    out
180}
181
182/// Append a `field: old → new` line when the two differ.
183fn push_if_changed<T: PartialEq + std::fmt::Display>(
184    out: &mut Vec<String>,
185    label: &str,
186    before: Option<&T>,
187    after: Option<&T>,
188) {
189    let describe = |v: Option<&T>| match v {
190        Some(v) => v.to_string(),
191        None => "(unset)".to_string(),
192    };
193    if before != after {
194        out.push(format!(
195            "{label}: {} → {}",
196            describe(before),
197            describe(after)
198        ));
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use crate::bundled::BUNDLED_AGENTS;
206
207    fn plan_of(config: Config) -> SetupPlan {
208        SetupPlan {
209            config,
210            agents: Vec::new(),
211        }
212    }
213
214    // ─── apply ──────────────────────────────────────────────────────────────
215
216    #[test]
217    fn apply_writes_the_config_and_installs_the_chosen_agents() {
218        let dir = tempfile::tempdir().unwrap();
219        let config_path = dir.path().join("config.toml");
220        let agents_dir = dir.path().join("agents");
221        let mut config = Config::default();
222        config.providers.anthropic_api_key = Some("sk-ant-x".to_string());
223        let plan = SetupPlan {
224            config,
225            agents: vec![&BUNDLED_AGENTS[0]],
226        };
227
228        let applied = apply(&plan, &config_path, &agents_dir).unwrap();
229
230        assert_eq!(applied.config_path, config_path);
231        assert_eq!(applied.agents_installed, vec![BUNDLED_AGENTS[0].name]);
232        assert!(applied.warnings.is_empty());
233        let written = Config::load_from_path_public(&config_path).unwrap();
234        assert_eq!(
235            written.providers.anthropic_api_key.as_deref(),
236            Some("sk-ant-x")
237        );
238        assert!(
239            agents_dir
240                .join(BUNDLED_AGENTS[0].name)
241                .join("agent.leviath")
242                .exists()
243        );
244    }
245
246    #[test]
247    fn apply_with_nothing_to_install_still_writes_the_config() {
248        let dir = tempfile::tempdir().unwrap();
249        let config_path = dir.path().join("config.toml");
250
251        let applied = apply(
252            &plan_of(Config::default()),
253            &config_path,
254            &dir.path().join("agents"),
255        )
256        .unwrap();
257
258        assert!(applied.agents_installed.is_empty());
259        assert!(config_path.exists());
260    }
261
262    #[test]
263    fn a_blueprint_that_fails_to_install_warns_rather_than_aborting() {
264        // A written config and most of the agents beats an abandoned run that
265        // saved nothing.
266        let dir = tempfile::tempdir().unwrap();
267        let config_path = dir.path().join("config.toml");
268        // `agents_dir` is a file, so every install fails.
269        let agents_dir = dir.path().join("blocked");
270        std::fs::write(&agents_dir, "").unwrap();
271        let plan = SetupPlan {
272            config: Config::default(),
273            agents: vec![&BUNDLED_AGENTS[0]],
274        };
275
276        let applied = apply(&plan, &config_path, &agents_dir).unwrap();
277
278        assert!(applied.agents_installed.is_empty());
279        assert_eq!(applied.warnings.len(), 1);
280        assert!(applied.warnings[0].contains(BUNDLED_AGENTS[0].name));
281        assert!(config_path.exists(), "the config was still written");
282    }
283
284    #[test]
285    fn a_config_that_cannot_be_written_is_a_hard_error() {
286        // Nothing else in the plan matters if the config did not land.
287        let dir = tempfile::tempdir().unwrap();
288        let blocked = dir.path().join("not-a-dir");
289        std::fs::write(&blocked, "").unwrap();
290
291        let result = apply(
292            &plan_of(Config::default()),
293            &blocked.join("config.toml"),
294            &dir.path().join("agents"),
295        );
296
297        assert!(result.is_err());
298    }
299
300    // ─── changes ────────────────────────────────────────────────────────────
301
302    #[test]
303    fn an_unchanged_plan_lists_nothing() {
304        assert!(changes(&Config::default(), &plan_of(Config::default())).is_empty());
305    }
306
307    #[test]
308    fn credential_changes_are_described_but_never_printed() {
309        // The review screen is exactly when someone is reading over a shoulder.
310        let mut before = Config::default();
311        before.providers.openai_api_key = Some("sk-old-secret".to_string());
312        before.openrouter_api_key = Some("sk-or-doomed".to_string());
313        let mut after = before.clone();
314        after.providers.anthropic_api_key = Some("sk-ant-brand-new".to_string());
315        after.providers.openai_api_key = Some("sk-new-secret".to_string());
316        after.openrouter_api_key = None;
317
318        let lines = changes(&before, &plan_of(after));
319
320        assert!(lines.contains(&"Anthropic: credential set".to_string()));
321        assert!(lines.contains(&"OpenAI: credential changed".to_string()));
322        assert!(lines.contains(&"OpenRouter: credential cleared".to_string()));
323        for line in &lines {
324            assert!(!line.contains("secret"), "a credential leaked: {line}");
325            assert!(!line.contains("sk-"), "a credential leaked: {line}");
326        }
327    }
328
329    #[test]
330    fn an_unchanged_credential_is_not_listed() {
331        let mut before = Config::default();
332        before.providers.anthropic_api_key = Some("sk-ant-same".to_string());
333
334        assert!(changes(&before, &plan_of(before.clone())).is_empty());
335    }
336
337    #[test]
338    fn the_claude_code_toggle_is_reported_both_ways() {
339        let before = Config::default();
340        let mut on = before.clone();
341        on.providers.claude_code_enabled = true;
342
343        assert!(
344            changes(&before, &plan_of(on.clone()))
345                .contains(&"Claude Code transport: enabled".to_string())
346        );
347        assert!(
348            changes(&on, &plan_of(before)).contains(&"Claude Code transport: disabled".to_string())
349        );
350    }
351
352    #[test]
353    fn scalar_settings_are_shown_as_old_to_new() {
354        let before = Config::default();
355        let mut after = before.clone();
356        after.default_provider = "ollama".to_string();
357        after.default_model = Some("llama3".to_string());
358        after.limits.max_concurrent_inferences = Some(1);
359        after.limits.max_concurrent_tools = 4;
360        after.limits.default_max_iterations = None;
361        after.limits.exact_token_counting = true;
362        after.batch_tool_hint = false;
363        after.shell_hint = false;
364
365        let lines = changes(&before, &plan_of(after));
366
367        assert!(lines.contains(&"default provider: anthropic → ollama".to_string()));
368        assert!(lines.contains(&"default model: (unset) → llama3".to_string()));
369        assert!(lines.contains(&"max concurrent inferences: 8 → 1".to_string()));
370        assert!(lines.contains(&"max concurrent tools: 8 → 4".to_string()));
371        assert!(lines.contains(&"default max iterations: 50 → (unset)".to_string()));
372        assert!(lines.contains(&"exact token counting: false → true".to_string()));
373        assert!(lines.contains(&"batch tool hint: true → false".to_string()));
374        assert!(lines.contains(&"platform shell hint: true → false".to_string()));
375    }
376
377    #[test]
378    fn imported_servers_and_pending_agents_are_counted() {
379        let before = Config::default();
380        let mut after = before.clone();
381        after.mcp_servers = vec![
382            leviath_mcp::MCPServerConfig::stdio("a", "x", vec![]),
383            leviath_mcp::MCPServerConfig::stdio("b", "y", vec![]),
384        ];
385        let plan = SetupPlan {
386            config: after,
387            agents: vec![&BUNDLED_AGENTS[0], &BUNDLED_AGENTS[1]],
388        };
389
390        let lines = changes(&before, &plan);
391
392        assert!(lines.contains(&"MCP servers: 2 imported".to_string()));
393        assert!(lines.contains(&"agents: 2 to install".to_string()));
394    }
395
396    #[test]
397    fn removing_servers_is_not_reported_as_an_import() {
398        // `saturating_sub` must not turn a shrink into a bogus positive count.
399        let before = Config {
400            mcp_servers: vec![leviath_mcp::MCPServerConfig::stdio("a", "x", vec![])],
401            ..Config::default()
402        };
403        let after = Config::default();
404
405        let lines = changes(&before, &plan_of(after));
406
407        assert!(
408            lines.is_empty(),
409            "a shrink must not be reported as an import"
410        );
411    }
412}