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    pub config_path: PathBuf,
31    pub agents_installed: Vec<String>,
32    /// Non-fatal problems worth telling the user about.
33    pub warnings: Vec<String>,
34}
35
36/// Write the config and install the chosen blueprints.
37///
38/// Config first, and it is the only fallible-and-fatal step: a blueprint that
39/// fails to install is reported as a warning rather than aborting, because a
40/// written config plus nine of ten agents is a far better place to leave
41/// someone than an abandoned run with nothing saved.
42pub 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
61/// A human-readable list of what this plan changes against `before`, for the
62/// review screen. Empty means nothing would change.
63///
64/// Credentials are described as "set" / "changed" / "cleared" and never
65/// printed - the review screen is exactly the moment a shoulder-surfer is
66/// looking, and a key the user cannot read back is not a real loss when the
67/// wizard just verified it works.
68pub 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    push_if_changed(
137        &mut out,
138        "platform shell hint",
139        Some(&before.shell_hint),
140        Some(&after.shell_hint),
141    );
142    push_if_changed(
143        &mut out,
144        "stall timeout (seconds)",
145        Some(&before.limits.stall_timeout_secs),
146        Some(&after.limits.stall_timeout_secs),
147    );
148    push_if_changed(
149        &mut out,
150        "dead cycles before relief",
151        Some(&before.limits.dead_cycles_before_relief),
152        Some(&after.limits.dead_cycles_before_relief),
153    );
154    push_if_changed(
155        &mut out,
156        "finished run retention (seconds)",
157        Some(&before.limits.finished_retention_secs),
158        Some(&after.limits.finished_retention_secs),
159    );
160    push_if_changed(
161        &mut out,
162        "wedge timeout (seconds)",
163        Some(&before.limits.wedge_timeout_secs),
164        Some(&after.limits.wedge_timeout_secs),
165    );
166
167    let added = after
168        .mcp_servers
169        .len()
170        .saturating_sub(before.mcp_servers.len());
171    if added > 0 {
172        out.push(format!("MCP servers: {added} imported"));
173    }
174    if !plan.agents.is_empty() {
175        out.push(format!("agents: {} to install", plan.agents.len()));
176    }
177    out
178}
179
180/// Append a `field: old → new` line when the two differ.
181fn push_if_changed<T: PartialEq + std::fmt::Display>(
182    out: &mut Vec<String>,
183    label: &str,
184    before: Option<&T>,
185    after: Option<&T>,
186) {
187    let describe = |v: Option<&T>| match v {
188        Some(v) => v.to_string(),
189        None => "(unset)".to_string(),
190    };
191    if before != after {
192        out.push(format!(
193            "{label}: {} → {}",
194            describe(before),
195            describe(after)
196        ));
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::bundled::BUNDLED_AGENTS;
204
205    fn plan_of(config: Config) -> SetupPlan {
206        SetupPlan {
207            config,
208            agents: Vec::new(),
209        }
210    }
211
212    // ─── apply ──────────────────────────────────────────────────────────────
213
214    #[test]
215    fn apply_writes_the_config_and_installs_the_chosen_agents() {
216        let dir = tempfile::tempdir().unwrap();
217        let config_path = dir.path().join("config.toml");
218        let agents_dir = dir.path().join("agents");
219        let mut config = Config::default();
220        config.providers.anthropic_api_key = Some("sk-ant-x".to_string());
221        let plan = SetupPlan {
222            config,
223            agents: vec![&BUNDLED_AGENTS[0]],
224        };
225
226        let applied = apply(&plan, &config_path, &agents_dir).unwrap();
227
228        assert_eq!(applied.config_path, config_path);
229        assert_eq!(applied.agents_installed, vec![BUNDLED_AGENTS[0].name]);
230        assert!(applied.warnings.is_empty());
231        let written = Config::load_from_path_public(&config_path).unwrap();
232        assert_eq!(
233            written.providers.anthropic_api_key.as_deref(),
234            Some("sk-ant-x")
235        );
236        assert!(
237            agents_dir
238                .join(BUNDLED_AGENTS[0].name)
239                .join("agent.leviath")
240                .exists()
241        );
242    }
243
244    #[test]
245    fn apply_with_nothing_to_install_still_writes_the_config() {
246        let dir = tempfile::tempdir().unwrap();
247        let config_path = dir.path().join("config.toml");
248
249        let applied = apply(
250            &plan_of(Config::default()),
251            &config_path,
252            &dir.path().join("agents"),
253        )
254        .unwrap();
255
256        assert!(applied.agents_installed.is_empty());
257        assert!(config_path.exists());
258    }
259
260    #[test]
261    fn a_blueprint_that_fails_to_install_warns_rather_than_aborting() {
262        // A written config and most of the agents beats an abandoned run that
263        // saved nothing.
264        let dir = tempfile::tempdir().unwrap();
265        let config_path = dir.path().join("config.toml");
266        // `agents_dir` is a file, so every install fails.
267        let agents_dir = dir.path().join("blocked");
268        std::fs::write(&agents_dir, "").unwrap();
269        let plan = SetupPlan {
270            config: Config::default(),
271            agents: vec![&BUNDLED_AGENTS[0]],
272        };
273
274        let applied = apply(&plan, &config_path, &agents_dir).unwrap();
275
276        assert!(applied.agents_installed.is_empty());
277        assert_eq!(applied.warnings.len(), 1);
278        assert!(applied.warnings[0].contains(BUNDLED_AGENTS[0].name));
279        assert!(config_path.exists(), "the config was still written");
280    }
281
282    #[test]
283    fn a_config_that_cannot_be_written_is_a_hard_error() {
284        // Nothing else in the plan matters if the config did not land.
285        let dir = tempfile::tempdir().unwrap();
286        let blocked = dir.path().join("not-a-dir");
287        std::fs::write(&blocked, "").unwrap();
288
289        let result = apply(
290            &plan_of(Config::default()),
291            &blocked.join("config.toml"),
292            &dir.path().join("agents"),
293        );
294
295        assert!(result.is_err());
296    }
297
298    // ─── changes ────────────────────────────────────────────────────────────
299
300    #[test]
301    fn an_unchanged_plan_lists_nothing() {
302        assert!(changes(&Config::default(), &plan_of(Config::default())).is_empty());
303    }
304
305    #[test]
306    fn credential_changes_are_described_but_never_printed() {
307        // The review screen is exactly when someone is reading over a shoulder.
308        let mut before = Config::default();
309        before.providers.openai_api_key = Some("sk-old-secret".to_string());
310        before.openrouter_api_key = Some("sk-or-doomed".to_string());
311        let mut after = before.clone();
312        after.providers.anthropic_api_key = Some("sk-ant-brand-new".to_string());
313        after.providers.openai_api_key = Some("sk-new-secret".to_string());
314        after.openrouter_api_key = None;
315
316        let lines = changes(&before, &plan_of(after));
317
318        assert!(lines.contains(&"Anthropic: credential set".to_string()));
319        assert!(lines.contains(&"OpenAI: credential changed".to_string()));
320        assert!(lines.contains(&"OpenRouter: credential cleared".to_string()));
321        for line in &lines {
322            assert!(!line.contains("secret"), "a credential leaked: {line}");
323            assert!(!line.contains("sk-"), "a credential leaked: {line}");
324        }
325    }
326
327    #[test]
328    fn an_unchanged_credential_is_not_listed() {
329        let mut before = Config::default();
330        before.providers.anthropic_api_key = Some("sk-ant-same".to_string());
331
332        assert!(changes(&before, &plan_of(before.clone())).is_empty());
333    }
334
335    #[test]
336    fn the_claude_code_toggle_is_reported_both_ways() {
337        let before = Config::default();
338        let mut on = before.clone();
339        on.providers.claude_code_enabled = true;
340
341        assert!(
342            changes(&before, &plan_of(on.clone()))
343                .contains(&"Claude Code transport: enabled".to_string())
344        );
345        assert!(
346            changes(&on, &plan_of(before)).contains(&"Claude Code transport: disabled".to_string())
347        );
348    }
349
350    #[test]
351    fn scalar_settings_are_shown_as_old_to_new() {
352        let before = Config::default();
353        let mut after = before.clone();
354        after.default_provider = "ollama".to_string();
355        after.default_model = Some("llama3".to_string());
356        after.limits.max_concurrent_inferences = Some(1);
357        after.limits.max_concurrent_tools = 4;
358        after.limits.default_max_iterations = None;
359        after.limits.exact_token_counting = true;
360        after.batch_tool_hint = false;
361        after.shell_hint = false;
362
363        let lines = changes(&before, &plan_of(after));
364
365        assert!(lines.contains(&"default provider: anthropic → ollama".to_string()));
366        assert!(lines.contains(&"default model: (unset) → llama3".to_string()));
367        assert!(lines.contains(&"max concurrent inferences: 8 → 1".to_string()));
368        assert!(lines.contains(&"max concurrent tools: 8 → 4".to_string()));
369        assert!(lines.contains(&"default max iterations: 50 → (unset)".to_string()));
370        assert!(lines.contains(&"exact token counting: false → true".to_string()));
371        assert!(lines.contains(&"batch tool hint: true → false".to_string()));
372        assert!(lines.contains(&"platform shell hint: true → false".to_string()));
373    }
374
375    #[test]
376    fn imported_servers_and_pending_agents_are_counted() {
377        let before = Config::default();
378        let mut after = before.clone();
379        after.mcp_servers = vec![
380            leviath_mcp::MCPServerConfig::stdio("a", "x", vec![]),
381            leviath_mcp::MCPServerConfig::stdio("b", "y", vec![]),
382        ];
383        let plan = SetupPlan {
384            config: after,
385            agents: vec![&BUNDLED_AGENTS[0], &BUNDLED_AGENTS[1]],
386        };
387
388        let lines = changes(&before, &plan);
389
390        assert!(lines.contains(&"MCP servers: 2 imported".to_string()));
391        assert!(lines.contains(&"agents: 2 to install".to_string()));
392    }
393
394    #[test]
395    fn removing_servers_is_not_reported_as_an_import() {
396        // `saturating_sub` must not turn a shrink into a bogus positive count.
397        let before = Config {
398            mcp_servers: vec![leviath_mcp::MCPServerConfig::stdio("a", "x", vec![])],
399            ..Config::default()
400        };
401        let after = Config::default();
402
403        let lines = changes(&before, &plan_of(after));
404
405        assert!(
406            lines.is_empty(),
407            "a shrink must not be reported as an import"
408        );
409    }
410}