Skip to main content

leviath_cli/commands/
update.rs

1//! `lev update` - bring this copy of Leviath up to date, then everything that
2//! shipped with it.
3//!
4//! Three steps, in the order that matters: the binary, the bundled blueprints,
5//! and the config file. The binary first because the other two are decided by
6//! what the *new* binary ships, and a user who updates the agents against the
7//! old one has done half a job.
8//!
9//! All three run every time. The binary step is never a reason to skip the
10//! other two, and this is the whole point: `brew upgrade` and `scoop update`
11//! hand over a new binary and say nothing about the blueprints in the user's
12//! agents directory or the config beside them, so anyone who has ever updated
13//! that way is carrying blueprints from whenever they last ran `lev setup`. A
14//! binary that needs no update is not evidence that anything else is current,
15//! which is why "already up to date" is a sentence this command never says on
16//! its own.
17//!
18//! # Why the install method is detected rather than guessed
19//!
20//! Every channel installs the same `CARGO_PKG_VERSION`: the `-alpha` and
21//! `-beta` suffixes live in the tap manifests the release workflow bumps, not
22//! in `Cargo.toml`. So the version string says nothing about which channel this
23//! binary came from, and nothing about which installer put it there. What does
24//! say something is *where the file is*: a Homebrew Cellar path carries the
25//! formula name, and the formula name carries the channel; a Scoop `apps` path
26//! carries the package the same way; `~/.cargo/bin` means somebody compiled it.
27//!
28//! The hosted install script is the one method that records nothing. It writes
29//! a plain binary into an ordinary directory and keeps no receipt, so a copy
30//! that came from it is indistinguishable from any other loose binary. That is
31//! what [`UpdateArgs::channel`] is for, and why the script arm defaults to
32//! `stable` and says so rather than inferring a channel it cannot know.
33//!
34//! # Seams
35//!
36//! Running the upgrade and asking the user a question are both injected (see
37//! [`UpdateEnv`]), so the tests assert the command that *would* run without a
38//! single process being spawned - the same shape `lev mcp` uses for the browser
39//! and the daemon uses for seed commands.
40
41use std::path::{Path, PathBuf};
42use std::sync::Arc;
43
44use clap::Args;
45
46use crate::bundled::{AgentAction, BundledAgent, install_bundled, plan_agent_actions};
47use crate::config::Config;
48
49/// Where the hosted installer lives. One constant, because the invocation below
50/// is easy to get subtly wrong and there must be exactly one copy of it.
51const INSTALL_URL: &str = "https://leviath.dev/install.sh";
52
53/// `lev update --help`.
54pub const UPDATE_LONG_ABOUT: &str = "\
55Update Leviath, then offer to bring everything else up to date with it.
56
57The binary is updated with the installer that put it there, which is worked out
58from where the file is rather than guessed from the version string (every
59channel ships the same version number, so the string cannot tell you):
60
61  Homebrew   a Cellar path names the formula, and the formula names the
62             channel: `brew upgrade leviath-beta`
63  Scoop      the same, from the package under scoop/apps
64  cargo      says to run `cargo install leviath-cli` and stops. Updating it
65             means a long compile, which is not something to start unasked
66  script     re-runs the hosted installer for a channel. The install script
67             keeps no record of the channel it used, so this defaults to
68             stable - pass --channel to say otherwise
69
70The blueprints and the config are checked every time, whatever the binary step
71did. `brew upgrade` on its own leaves both behind, and a binary that was
72already current is not a reason to stop looking: an install can be months
73behind on its blueprints with a `lev` that needs no update at all.
74
75Nothing is written to your agents directory without a yes. The whole list is
76printed first, then one confirmation covers it; --install-agents is how a
77script says yes. --yes alone is not enough, because updating a binary and
78replacing the blueprints in your agents directory are different requests. A
79copy you edited is named as edited and asked about on its own, and no flag
80covers it: installing removes the directory and takes your edits with it.
81
82Config migrations are described line by line before anything is written, and
83then asked about.
84
85--check and --json report the plan and change nothing. --dry-run walks the
86whole flow, prompts and all, and prints what each step would do instead of
87doing it.";
88
89// ─── Channels ─────────────────────────────────────────────────────────────────
90
91/// A release channel.
92#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
93#[value(rename_all = "lowercase")]
94pub enum Channel {
95    /// The weekly stable release. What crates.io and `brew install leviath` track.
96    Stable,
97    /// The promoted build a week ahead of stable.
98    Beta,
99    /// The nightly build.
100    Alpha,
101}
102
103impl Channel {
104    /// The name the install script and the docs use.
105    pub fn id(self) -> &'static str {
106        match self {
107            Self::Stable => "stable",
108            Self::Beta => "beta",
109            Self::Alpha => "alpha",
110        }
111    }
112
113    /// The Homebrew formula and Scoop package for this channel. They share a
114    /// naming scheme on purpose, so one function answers for both.
115    pub fn package(self) -> &'static str {
116        match self {
117            Self::Stable => "leviath",
118            Self::Beta => "leviath-beta",
119            Self::Alpha => "leviath-alpha",
120        }
121    }
122
123    /// The channel a package name carries, or `None` for a name this build does
124    /// not ship (someone's own formula, or one from a future channel).
125    pub fn from_package(name: &str) -> Option<Self> {
126        [Self::Stable, Self::Beta, Self::Alpha]
127            .into_iter()
128            .find(|c| c.package() == name)
129    }
130}
131
132// ─── Install-method detection ─────────────────────────────────────────────────
133
134/// How this copy of `lev` got onto the machine.
135#[derive(Debug, Clone, PartialEq, Eq)]
136pub enum InstallMethod {
137    /// Homebrew, under the named formula.
138    Homebrew {
139        /// The formula, which is also what carries the channel.
140        formula: String,
141    },
142    /// Scoop, under the named package.
143    Scoop {
144        /// The package, which carries the channel the same way a formula does.
145        package: String,
146    },
147    /// `cargo install`, so the binary was compiled locally.
148    Cargo,
149    /// The hosted install script, or something else that dropped a plain binary
150    /// where the install script puts one.
151    Script {
152        /// The channel to re-install. Never detected: see the module docs.
153        channel: Channel,
154    },
155    /// Somewhere no supported installer writes.
156    Unknown {
157        /// Where the binary actually is, so the report can say it.
158        path: PathBuf,
159    },
160}
161
162impl InstallMethod {
163    /// The short name used in `--json`.
164    pub fn id(&self) -> &'static str {
165        match self {
166            Self::Homebrew { .. } => "homebrew",
167            Self::Scoop { .. } => "scoop",
168            Self::Cargo => "cargo",
169            Self::Script { .. } => "script",
170            Self::Unknown { .. } => "unknown",
171        }
172    }
173
174    /// The channel this install tracks, where that is knowable.
175    ///
176    /// `cargo install leviath-cli` resolves crates.io, and each stable deploy
177    /// publishes there from the same commit the binaries were built at, so a
178    /// cargo install is a stable install by construction.
179    pub fn channel(&self) -> Option<Channel> {
180        match self {
181            Self::Homebrew { formula } => Channel::from_package(formula),
182            Self::Scoop { package } => Channel::from_package(package),
183            Self::Cargo => Some(Channel::Stable),
184            Self::Script { channel } => Some(*channel),
185            Self::Unknown { .. } => None,
186        }
187    }
188
189    /// The one-line description the report opens with.
190    pub fn describe(&self) -> String {
191        let channel = match self.channel() {
192            Some(c) => format!(", {} channel", c.id()),
193            // A formula this build does not ship, or a path nothing claims.
194            None => String::new(),
195        };
196        match self {
197            Self::Homebrew { formula } => format!("Homebrew (formula {formula}{channel})"),
198            Self::Scoop { package } => format!("Scoop (package {package}{channel})"),
199            Self::Cargo => format!("cargo install (crates.io{channel})"),
200            Self::Script { .. } => format!("the install script ({INSTALL_URL}{channel})"),
201            Self::Unknown { path } => {
202                format!("something else - the binary is at {}", path.display())
203            }
204        }
205    }
206}
207
208/// The component of `path` immediately after the first one equal to `marker`.
209///
210/// This is how both package managers record what they installed: Homebrew lays
211/// a binary out as `<prefix>/Cellar/<formula>/<version>/bin/lev`, and Scoop as
212/// `<root>/apps/<package>/current/lev.exe`. The name in that slot is the real
213/// answer to "which channel is this", and it is on disk rather than inferred.
214fn component_after(path: &Path, marker: &str) -> Option<String> {
215    let mut components = path
216        .components()
217        .map(|c| c.as_os_str().to_string_lossy().into_owned());
218    components.by_ref().find(|c| c == marker)?;
219    components.next()
220}
221
222/// Whether `path` has a component equal to `marker`, ignoring case.
223///
224/// Scoop's root is a user-chosen directory that is conventionally but not
225/// reliably lowercase, and Windows paths are case-insensitive anyway.
226fn has_component(path: &Path, marker: &str) -> bool {
227    path.components()
228        .any(|c| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(marker))
229}
230
231/// Homebrew prefixes that mean Homebrew and nothing else, for the case where
232/// the binary is the `bin/lev` symlink rather than the Cellar path behind it.
233///
234/// `/usr/local` is deliberately absent even though it is Homebrew's own prefix
235/// on Intel macOS: it is also where the install script and a hand-unpacked
236/// tarball put a binary, so treating everything under it as Homebrew would send
237/// a script install to `brew upgrade`. Under that prefix the Cellar component is
238/// the only evidence that counts.
239const UNAMBIGUOUS_BREW_PREFIXES: &[&str] = &["/opt/homebrew", "/home/linuxbrew/.linuxbrew"];
240
241/// Absolute directories the installers write to. The Linux installer hard-codes
242/// `/usr/local/bin`; `/usr/bin` is where the manual tarball instructions end up
243/// for anyone who moved it there instead.
244const SCRIPT_DESTINATIONS: &[&str] = &["/usr/local/bin", "/usr/bin"];
245
246/// Every directory a plain-binary install lands in, including the two that are
247/// home-relative: `~/.local/bin`, and the `%LOCALAPPDATA%\Leviath\bin` that
248/// `install.ps1` writes on Windows.
249///
250/// A loose binary in one of these is a script install. A loose binary anywhere
251/// else is not something to guess about, because re-running an installer aims
252/// at a fixed destination and would leave the copy actually on `PATH` untouched.
253fn script_destinations(home: Option<&Path>) -> Vec<PathBuf> {
254    let mut dirs: Vec<PathBuf> = SCRIPT_DESTINATIONS.iter().map(PathBuf::from).collect();
255    if let Some(home) = home {
256        dirs.push(home.join(".local").join("bin"));
257        dirs.push(
258            home.join("AppData")
259                .join("Local")
260                .join("Leviath")
261                .join("bin"),
262        );
263    }
264    dirs
265}
266
267/// Work out how `exe` was installed.
268///
269/// Pure over its inputs - the resolved executable path, the home directory, the
270/// answer `brew --prefix` gave (if it was asked and answered), and the channel
271/// the user named - so every arm is testable without a Homebrew, a Scoop or a
272/// second machine.
273pub fn detect(
274    exe: &Path,
275    home: Option<&Path>,
276    brew_prefix: Option<&Path>,
277    requested: Option<Channel>,
278) -> InstallMethod {
279    // The channel to fall back on where the path does not name one.
280    let channel = requested.unwrap_or(Channel::Stable);
281
282    // Homebrew, from the strongest evidence down. A Cellar path names the
283    // formula outright; a prefix only says "Homebrew put this here".
284    if let Some(formula) = component_after(exe, "Cellar") {
285        return InstallMethod::Homebrew { formula };
286    }
287    let under_brew = UNAMBIGUOUS_BREW_PREFIXES.iter().any(|p| exe.starts_with(p))
288        || brew_prefix.is_some_and(|p| exe.starts_with(p) && !is_ambiguous_prefix(p));
289    if under_brew {
290        return InstallMethod::Homebrew {
291            formula: channel.package().to_string(),
292        };
293    }
294
295    // Scoop, the same two ways round.
296    if has_component(exe, "scoop") {
297        let package = component_after(exe, "apps").unwrap_or_else(|| channel.package().to_string());
298        return InstallMethod::Scoop { package };
299    }
300
301    // A cargo install, which is the one method that cannot be updated in place.
302    let cargo_bin = home.map(|h| h.join(".cargo").join("bin"));
303    if cargo_bin.is_some_and(|dir| exe.starts_with(dir)) {
304        return InstallMethod::Cargo;
305    }
306
307    let parent = exe.parent();
308    let script_dir = script_destinations(home)
309        .iter()
310        .any(|d| parent == Some(d.as_path()));
311    match script_dir {
312        true => InstallMethod::Script { channel },
313        false => InstallMethod::Unknown {
314            path: exe.to_path_buf(),
315        },
316    }
317}
318
319/// Whether a prefix is too general to be evidence of anything on its own. See
320/// [`UNAMBIGUOUS_BREW_PREFIXES`] for why `/usr/local` is the case that matters.
321fn is_ambiguous_prefix(prefix: &Path) -> bool {
322    matches!(
323        prefix.to_string_lossy().trim_end_matches('/'),
324        "/usr/local" | "/usr" | "" | "/"
325    )
326}
327
328// ─── Config migrations ────────────────────────────────────────────────────────
329
330/// One config change `lev update` knows how to make on the user's behalf.
331///
332/// The mechanism exists so that a future incompatibility - a key that moved, a
333/// value whose meaning changed - is either fixed automatically or at least
334/// explained at the moment the user updates into it, rather than surfacing as a
335/// broken run days later. [`MIGRATIONS`] is empty today because no shipped
336/// version has changed a key's name or meaning; the tests drive the machinery
337/// with a sample so the wiring is proven rather than assumed.
338pub struct Migration {
339    /// A short stable name, shown in the report and in `--json`.
340    pub name: &'static str,
341    /// What it changes and why, in one line.
342    pub description: &'static str,
343    /// Whether this config needs it.
344    ///
345    /// Gets the parsed [`Config`] *and* the raw document, because the two see
346    /// different things: a key serde no longer reads vanishes from the parsed
347    /// value entirely, and a key that is still read but now means something
348    /// else is only visible there.
349    pub applies: fn(&Config, &toml::Table) -> bool,
350    /// Make the change, returning one line per thing it did.
351    pub apply: fn(&mut Config) -> Vec<String>,
352}
353
354/// The migrations this build knows about, oldest first.
355///
356/// Empty, and honestly so: nothing in a released `config.toml` has to change to
357/// work with this version. Adding one is adding an entry here.
358pub const MIGRATIONS: &[Migration] = &[];
359
360// ─── The plan ─────────────────────────────────────────────────────────────────
361
362/// What to do about the binary.
363#[derive(Debug, Clone, PartialEq, Eq)]
364pub enum BinaryStep {
365    /// Run these, argv-style, in order, stopping at the first failure.
366    ///
367    /// A sequence rather than one command because a package manager will not
368    /// see a release published minutes ago until its own index is refreshed,
369    /// so the refresh and the upgrade are two commands that only make sense
370    /// together.
371    Run(Vec<Vec<String>>),
372    /// There is nothing to run here. Tell the user this instead.
373    Advise(String),
374}
375
376/// Everything `lev update` intends to do, as plain data.
377///
378/// Built before anything happens and rendered before anything happens, so the
379/// report, the JSON and the actions can never disagree about what was planned.
380pub struct UpdatePlan {
381    /// How this copy was installed.
382    pub method: InstallMethod,
383    /// The binary step.
384    pub binary: BinaryStep,
385    /// Every bundled blueprint and what would happen to it.
386    pub agents: Vec<(&'static BundledAgent, AgentAction)>,
387    /// The migrations that apply to the config as it stands.
388    pub migrations: Vec<&'static Migration>,
389    /// What reading the config found.
390    pub config: ConfigState,
391}
392
393/// What the plan found when it read the config file.
394///
395/// One value rather than a `Config` and an error beside it, because those two
396/// only ever come in two of the four combinations and the other two would be
397/// arms nothing could reach.
398///
399/// The config is carried rather than re-read at write time so there is exactly
400/// one read: re-opening the file would add an error arm only a race could take,
401/// and applying a migration to a document nobody has looked at since the report
402/// was printed is exactly the surprise this command exists to avoid.
403pub enum ConfigState {
404    /// The config as it stands, for the migrations to be applied to. Boxed
405    /// because a `Config` is far larger than the message beside it.
406    Loaded(Box<Config>),
407    /// It could not be read, and this is why.
408    Unreadable(String),
409}
410
411/// One line naming every command a [`BinaryStep::Run`] will run, in order.
412///
413/// Joined with `&&` because that is both how the sequence behaves (each
414/// command only runs if the one before it succeeded) and what a user would
415/// paste into a shell to do it themselves.
416pub fn render_commands(commands: &[Vec<String>]) -> String {
417    commands
418        .iter()
419        .map(|argv| argv.join(" "))
420        .collect::<Vec<_>>()
421        .join(" && ")
422}
423
424/// The upgrade step for an install method: the commands to run, or the reason
425/// there are none.
426pub fn binary_step(method: &InstallMethod) -> BinaryStep {
427    match method {
428        // `brew update` first, every time. Homebrew upgrades against the tap
429        // metadata it already has, so a formula published minutes ago is
430        // invisible to `brew upgrade` on its own and the command cheerfully
431        // reports the installed version as the latest. That is what sent
432        // people to `brew update && brew upgrade leviath` by hand. The formula
433        // name carries the channel, so this is the same two steps for
434        // `leviath`, `leviath-alpha` and `leviath-beta`.
435        InstallMethod::Homebrew { formula } => BinaryStep::Run(vec![
436            vec!["brew".to_string(), "update".to_string()],
437            vec!["brew".to_string(), "upgrade".to_string(), formula.clone()],
438        ]),
439        // Scoop has the same shape: a bare `scoop update` refreshes the
440        // buckets, and `scoop update <app>` upgrades against whatever the
441        // buckets already said.
442        InstallMethod::Scoop { package } => BinaryStep::Run(vec![
443            vec!["scoop".to_string(), "update".to_string()],
444            vec!["scoop".to_string(), "update".to_string(), package.clone()],
445        ]),
446        // Deliberately not run. `cargo install` rebuilds the whole workspace
447        // from source, which is minutes of CPU nobody asked for by typing
448        // `lev update`.
449        InstallMethod::Cargo => BinaryStep::Advise(
450            "this copy was built by `cargo install`. Update it with \
451             `cargo install leviath-cli` - that is a full compile, so it is not \
452             something to start for you."
453                .to_string(),
454        ),
455        // One shell, one pipeline. `LEVIATH_CHANNEL=beta curl ... | sh` is the
456        // form to never generate: the assignment belongs to `curl`, the piped
457        // shell never sees it, and the installer silently takes stable.
458        InstallMethod::Script { channel } => BinaryStep::Run(vec![vec![
459            "sh".to_string(),
460            "-c".to_string(),
461            format!(
462                "curl -fsSL {INSTALL_URL} | sh -s -- --channel {}",
463                channel.id()
464            ),
465        ]]),
466        InstallMethod::Unknown { path } => BinaryStep::Advise(format!(
467            "`lev` is at {}, which is not where any installer Leviath ships puts it. \
468             Update it the way you installed it, or re-install with \
469             `curl -fsSL {INSTALL_URL} | sh`.",
470            path.display()
471        )),
472    }
473}
474
475/// The config as `lev update` needs to see it: parsed, and the document behind
476/// it.
477struct LoadedConfig {
478    config: Config,
479    raw: toml::Table,
480}
481
482/// Read the config file both ways.
483fn load_config(path: &Path) -> anyhow::Result<LoadedConfig> {
484    let config = Config::load_from_path_public(path)?;
485    let raw = match std::fs::read_to_string(path) {
486        // `expect`: `load_from_path_public` above parsed this same text as
487        // TOML, so a document that reaches here is a document that parses.
488        Ok(text) => toml::from_str::<toml::Table>(&text).expect("the config parsed a moment ago"),
489        // No file at all, which loads as the defaults and an empty document.
490        Err(_) => toml::Table::new(),
491    };
492    Ok(LoadedConfig { config, raw })
493}
494
495/// Work out everything the command would do, without doing any of it.
496pub fn plan(args: &UpdateArgs, env: &UpdateEnv) -> UpdatePlan {
497    let method = detect(
498        &env.exe,
499        env.home.as_deref(),
500        env.brew_prefix.as_deref(),
501        args.channel,
502    );
503    let binary = binary_step(&method);
504    let agents = plan_agent_actions(&env.agents_dir);
505
506    // A config that will not parse is reported, not fatal: the binary step is
507    // the part of this command that matters most and it does not need one.
508    let (migrations, config) = match load_config(&env.config_path) {
509        Ok(loaded) => (
510            env.migrations
511                .iter()
512                .filter(|m| (m.applies)(&loaded.config, &loaded.raw))
513                .collect(),
514            ConfigState::Loaded(Box::new(loaded.config)),
515        ),
516        Err(e) => (Vec::new(), ConfigState::Unreadable(e.to_string())),
517    };
518
519    UpdatePlan {
520        method,
521        binary,
522        agents,
523        migrations,
524        config,
525    }
526}
527
528// ─── Rendering ────────────────────────────────────────────────────────────────
529
530/// The blueprints this plan would change.
531fn changing(plan: &UpdatePlan) -> Vec<&(&'static BundledAgent, AgentAction)> {
532    plan.agents.iter().filter(|(_, a)| a.is_change()).collect()
533}
534
535/// Render the plan the way `lev update` prints it.
536///
537/// Pure, so the tests assert the text exactly - everything that varies between
538/// machines is already in the [`UpdatePlan`].
539pub fn format_plan(plan: &UpdatePlan, version: &str) -> String {
540    let mut out = format!(
541        "\nlev {version}, installed with {}\n\n",
542        plan.method.describe()
543    );
544
545    match &plan.binary {
546        BinaryStep::Run(commands) => {
547            out.push_str(&format!("  binary   {}\n", render_commands(commands)));
548        }
549        BinaryStep::Advise(text) => out.push_str(&format!("  binary   {text}\n")),
550    }
551
552    let changes = changing(plan);
553    match changes.is_empty() {
554        true => out.push_str(&format!(
555            "  agents   all {} bundled blueprints are up to date\n",
556            plan.agents.len()
557        )),
558        false => {
559            out.push_str(&format!(
560                "  agents   {} of {} would change\n",
561                changes.len(),
562                plan.agents.len()
563            ));
564            for (agent, action) in &changes {
565                out.push_str(&format!(
566                    "             {} - {}\n",
567                    agent.name,
568                    action.label(agent.version)
569                ));
570            }
571        }
572    }
573
574    match (&plan.config, plan.migrations.is_empty()) {
575        (ConfigState::Unreadable(e), _) => {
576            out.push_str(&format!("  config   could not be read: {e}\n"))
577        }
578        (ConfigState::Loaded(_), true) => out.push_str("  config   nothing to migrate\n"),
579        (ConfigState::Loaded(_), false) => {
580            out.push_str(&format!(
581                "  config   {} migration(s)\n",
582                plan.migrations.len()
583            ));
584            for migration in &plan.migrations {
585                out.push_str(&format!(
586                    "             {} - {}\n",
587                    migration.name, migration.description
588                ));
589            }
590        }
591    }
592    out
593}
594
595/// The plan as JSON. Built by hand, like `lev tools` and `lev doctor`, so the
596/// shape is explicit and does not move when a type gains a field.
597pub fn plan_json(plan: &UpdatePlan, version: &str) -> serde_json::Value {
598    let binary = match &plan.binary {
599        // `commands` is a list of argv lists. The old `command` key held a
600        // single argv and is kept alongside it, holding the last command (the
601        // upgrade itself), so a script reading it still sees the step that
602        // does the work rather than the index refresh in front of it.
603        BinaryStep::Run(commands) => serde_json::json!({
604            "action": "run",
605            "commands": commands,
606            "command": commands.last(),
607        }),
608        BinaryStep::Advise(text) => serde_json::json!({ "action": "advise", "message": text }),
609    };
610    let agents: Vec<serde_json::Value> = plan
611        .agents
612        .iter()
613        .map(|(agent, action)| {
614            serde_json::json!({
615                "name": agent.name,
616                "version": agent.version,
617                "change": action.label(agent.version),
618                "changes": action.is_change(),
619                "preselected": action.preselect(),
620            })
621        })
622        .collect();
623    let migrations: Vec<serde_json::Value> = plan
624        .migrations
625        .iter()
626        .map(|m| serde_json::json!({ "name": m.name, "description": m.description }))
627        .collect();
628    serde_json::json!({
629        "version": version,
630        "install_method": plan.method.id(),
631        "channel": plan.method.channel().map(Channel::id),
632        "binary": binary,
633        "agents": agents,
634        "migrations": migrations,
635        "config_error": match &plan.config {
636            ConfigState::Unreadable(e) => serde_json::Value::String(e.clone()),
637            ConfigState::Loaded(_) => serde_json::Value::Null,
638        },
639    })
640}
641
642// ─── Arguments and seams ──────────────────────────────────────────────────────
643
644/// Arguments for `lev update`.
645#[derive(Args, Debug, Clone, Default)]
646pub struct UpdateArgs {
647    /// Report what would happen and change nothing.
648    #[arg(long)]
649    pub check: bool,
650
651    /// Answer yes to the binary upgrade and the config write without asking.
652    /// It deliberately does not install blueprints: see `--install-agents`.
653    #[arg(long)]
654    pub yes: bool,
655
656    /// Install the bundled blueprints without asking. A copy you edited
657    /// locally is still asked about on its own, since installing destroys it.
658    #[arg(long)]
659    pub install_agents: bool,
660
661    /// The channel to install, for the install-script method, which records
662    /// none. Ignored by Homebrew and Scoop, whose package name already says.
663    #[arg(long, value_name = "CHANNEL")]
664    pub channel: Option<Channel>,
665
666    /// Walk the whole flow, but print each action instead of performing it.
667    #[arg(long)]
668    pub dry_run: bool,
669
670    /// Print the plan as JSON and change nothing.
671    #[arg(long)]
672    pub json: bool,
673}
674
675/// Runs the upgrade command: argv in, success or the reason it failed out.
676///
677/// Injected rather than called directly so the tests assert *which* command
678/// would run without spawning anything. Mirrors the `BrowserOpener` and
679/// `SeedCommandRunner` seams.
680pub type CommandRunner = Arc<dyn Fn(&[String]) -> anyhow::Result<()> + Send + Sync>;
681
682/// Asks the user a yes/no question. Injected for the same reason: a unit test
683/// has no terminal, and which questions a flag answers on the user's behalf -
684/// and, for a blueprint they edited, which ones no flag answers - is something
685/// the tests have to be able to prove rather than describe.
686pub type Confirm = Arc<dyn Fn(&str) -> bool + Send + Sync>;
687
688/// The real I/O `lev update` depends on, injected so the command logic is
689/// testable without a package manager, a terminal, or the real home directory.
690pub struct UpdateEnv {
691    /// The running binary, symlinks already resolved. Resolving matters: a
692    /// Homebrew `bin/lev` is a symlink into the Cellar, and the Cellar path is
693    /// the one that names the formula.
694    pub exe: PathBuf,
695    /// The user's home directory, when one resolves.
696    pub home: Option<PathBuf>,
697    /// What `brew --prefix` answered, when it was asked and answered.
698    pub brew_prefix: Option<PathBuf>,
699    /// Where the bundled blueprints are installed.
700    pub agents_dir: PathBuf,
701    /// The config file to read and, with permission, rewrite.
702    pub config_path: PathBuf,
703    /// How to run the upgrade command.
704    pub runner: CommandRunner,
705    /// How to ask a yes/no question.
706    pub confirm: Confirm,
707    /// The migrations to consider, in order. Production passes [`MIGRATIONS`].
708    pub migrations: &'static [Migration],
709}
710
711// ─── Execution ────────────────────────────────────────────────────────────────
712
713/// Ask, unless `--yes` has already answered.
714fn agreed(args: &UpdateArgs, env: &UpdateEnv, question: &str) -> bool {
715    args.yes || (env.confirm)(question)
716}
717
718/// Step one: the binary.
719fn update_binary(args: &UpdateArgs, env: &UpdateEnv, plan: &UpdatePlan) -> anyhow::Result<()> {
720    let commands = match &plan.binary {
721        BinaryStep::Advise(text) => {
722            println!("  {text}");
723            return Ok(());
724        }
725        BinaryStep::Run(commands) => commands,
726    };
727    // Asked about as one question, because they are one action: refreshing a
728    // package index without then upgrading would be a strange thing to agree
729    // to on its own.
730    let shown = render_commands(commands);
731    if !agreed(args, env, &format!("Run `{shown}`?")) {
732        println!("  left the binary alone");
733        return Ok(());
734    }
735    if args.dry_run {
736        println!("  would run: {shown}");
737        return Ok(());
738    }
739    for argv in commands {
740        (env.runner)(argv)?;
741    }
742    Ok(())
743}
744
745/// Step two: the bundled blueprints.
746///
747/// Nothing here is a default. The whole list is printed first, and the agents
748/// directory is not written to until someone has said yes to it: `--yes` is
749/// deliberately not enough, because "update my binary without stopping to ask"
750/// and "replace the blueprints in my agents directory" are different requests
751/// and `lev setup` already treats them that way. `--install-agents` is how a
752/// script says the second one.
753///
754/// A copy the user edited is named as edited and asked about on its own, and no
755/// flag covers it: [`install_bundled`] removes the destination directory first,
756/// so a bulk yes would take their edits and any file they added with it.
757///
758/// A failed install is a warning, not an abort, for the same reason `lev setup`
759/// treats it that way: an updated binary plus most of the blueprints is a far
760/// better place to leave someone than a command that gave up in the middle.
761fn update_agents(args: &UpdateArgs, env: &UpdateEnv, plan: &UpdatePlan) {
762    let changes = changing(plan);
763    if changes.is_empty() {
764        println!("  every bundled blueprint is already up to date");
765        return;
766    }
767
768    // Seen before agreed to, always.
769    for (agent, action) in &changes {
770        println!("    {} - {}", agent.name, action.label(agent.version));
771    }
772    let clean = changes.iter().filter(|(_, a)| a.preselect()).count();
773    let edited = changes.len() - clean;
774    if edited > 0 {
775        println!(
776            "  {edited} of these you have edited locally. Installing removes the directory \
777             first, so your edits and any file you added go with it - each is asked about \
778             on its own."
779        );
780    }
781
782    // One question for the whole clean set, rather than one per blueprint: the
783    // list above is already the detail, and seven prompts in a row is how a
784    // person stops reading them.
785    let install_clean = match clean {
786        0 => false,
787        n => args.install_agents || (env.confirm)(&format!("Install these {n} blueprint(s)?")),
788    };
789
790    for (agent, action) in changes {
791        let ok = match action.preselect() {
792            true => install_clean,
793            false => (env.confirm)(&format!(
794                "{} - {}. Overwrite your edited copy?",
795                agent.name,
796                action.label(agent.version)
797            )),
798        };
799        if !ok {
800            println!("  skipped {}", agent.name);
801            continue;
802        }
803        if args.dry_run {
804            println!("  would install {} {}", agent.name, agent.version);
805            continue;
806        }
807        match install_bundled(agent, &env.agents_dir) {
808            Ok(()) => println!("  installed {} {}", agent.name, agent.version),
809            Err(e) => println!("  could not install {}: {e}", agent.name),
810        }
811    }
812}
813
814/// Step three: the config.
815///
816/// Nothing is written before the user has seen, line by line, what changed.
817fn migrate_config(args: &UpdateArgs, env: &UpdateEnv, plan: &UpdatePlan) -> anyhow::Result<()> {
818    let config = match &plan.config {
819        ConfigState::Unreadable(e) => {
820            println!("  the config could not be read, so it was left alone: {e}");
821            return Ok(());
822        }
823        ConfigState::Loaded(config) => config,
824    };
825    if plan.migrations.is_empty() {
826        println!("  the config needs no changes");
827        return Ok(());
828    }
829
830    let mut config = config.as_ref().clone();
831    let mut changed = Vec::new();
832    for migration in &plan.migrations {
833        for line in (migration.apply)(&mut config) {
834            changed.push(format!("{}: {line}", migration.name));
835        }
836    }
837    for line in &changed {
838        println!("    - {line}");
839    }
840
841    let path = env.config_path.display();
842    if !agreed(args, env, &format!("Write these changes to {path}?")) {
843        println!("  config left as it is");
844        return Ok(());
845    }
846    if args.dry_run {
847        println!("  would write {path}");
848        return Ok(());
849    }
850    config.save_to_path_public(&env.config_path)?;
851    println!("  wrote {path}");
852    Ok(())
853}
854
855/// Run `lev update` against an injected environment.
856pub fn execute_with(args: &UpdateArgs, env: &UpdateEnv, version: &str) -> anyhow::Result<()> {
857    let plan = plan(args, env);
858
859    if args.json {
860        println!(
861            "{}",
862            serde_json::to_string_pretty(&plan_json(&plan, version))
863                .expect("a plan is plain data and always serializes")
864        );
865        return Ok(());
866    }
867
868    print!("{}", format_plan(&plan, version));
869    if args.check {
870        return Ok(());
871    }
872
873    println!("\nbinary");
874    update_binary(args, env, &plan)?;
875    println!("\nblueprints");
876    update_agents(args, env, &plan);
877    println!("\nconfig");
878    migrate_config(args, env, &plan)?;
879    Ok(())
880}
881
882#[cfg(test)]
883mod tests;