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
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
150/// Append a `field: old → new` line when the two differ.
151fn 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    // ─── apply ──────────────────────────────────────────────────────────────
183
184    #[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        // A written config and most of the agents beats an abandoned run that
233        // saved nothing.
234        let dir = tempfile::tempdir().unwrap();
235        let config_path = dir.path().join("config.toml");
236        // `agents_dir` is a file, so every install fails.
237        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        // Nothing else in the plan matters if the config did not land.
255        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    // ─── changes ────────────────────────────────────────────────────────────
269
270    #[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        // The review screen is exactly when someone is reading over a shoulder.
278        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        // `saturating_sub` must not turn a shrink into a bogus positive count.
365        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}