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 this, argv-style.
366    Run(Vec<String>),
367    /// There is nothing to run here. Tell the user this instead.
368    Advise(String),
369}
370
371/// Everything `lev update` intends to do, as plain data.
372///
373/// Built before anything happens and rendered before anything happens, so the
374/// report, the JSON and the actions can never disagree about what was planned.
375pub struct UpdatePlan {
376    /// How this copy was installed.
377    pub method: InstallMethod,
378    /// The binary step.
379    pub binary: BinaryStep,
380    /// Every bundled blueprint and what would happen to it.
381    pub agents: Vec<(&'static BundledAgent, AgentAction)>,
382    /// The migrations that apply to the config as it stands.
383    pub migrations: Vec<&'static Migration>,
384    /// What reading the config found.
385    pub config: ConfigState,
386}
387
388/// What the plan found when it read the config file.
389///
390/// One value rather than a `Config` and an error beside it, because those two
391/// only ever come in two of the four combinations and the other two would be
392/// arms nothing could reach.
393///
394/// The config is carried rather than re-read at write time so there is exactly
395/// one read: re-opening the file would add an error arm only a race could take,
396/// and applying a migration to a document nobody has looked at since the report
397/// was printed is exactly the surprise this command exists to avoid.
398pub enum ConfigState {
399    /// The config as it stands, for the migrations to be applied to. Boxed
400    /// because a `Config` is far larger than the message beside it.
401    Loaded(Box<Config>),
402    /// It could not be read, and this is why.
403    Unreadable(String),
404}
405
406/// The upgrade step for an install method: a command, or the reason there
407/// isn't one.
408pub fn binary_step(method: &InstallMethod) -> BinaryStep {
409    match method {
410        InstallMethod::Homebrew { formula } => BinaryStep::Run(vec![
411            "brew".to_string(),
412            "upgrade".to_string(),
413            formula.clone(),
414        ]),
415        InstallMethod::Scoop { package } => BinaryStep::Run(vec![
416            "scoop".to_string(),
417            "update".to_string(),
418            package.clone(),
419        ]),
420        // Deliberately not run. `cargo install` rebuilds the whole workspace
421        // from source, which is minutes of CPU nobody asked for by typing
422        // `lev update`.
423        InstallMethod::Cargo => BinaryStep::Advise(
424            "this copy was built by `cargo install`. Update it with \
425             `cargo install leviath-cli` - that is a full compile, so it is not \
426             something to start for you."
427                .to_string(),
428        ),
429        // One shell, one pipeline. `LEVIATH_CHANNEL=beta curl ... | sh` is the
430        // form to never generate: the assignment belongs to `curl`, the piped
431        // shell never sees it, and the installer silently takes stable.
432        InstallMethod::Script { channel } => BinaryStep::Run(vec![
433            "sh".to_string(),
434            "-c".to_string(),
435            format!(
436                "curl -fsSL {INSTALL_URL} | sh -s -- --channel {}",
437                channel.id()
438            ),
439        ]),
440        InstallMethod::Unknown { path } => BinaryStep::Advise(format!(
441            "`lev` is at {}, which is not where any installer Leviath ships puts it. \
442             Update it the way you installed it, or re-install with \
443             `curl -fsSL {INSTALL_URL} | sh`.",
444            path.display()
445        )),
446    }
447}
448
449/// The config as `lev update` needs to see it: parsed, and the document behind
450/// it.
451struct LoadedConfig {
452    config: Config,
453    raw: toml::Table,
454}
455
456/// Read the config file both ways.
457fn load_config(path: &Path) -> anyhow::Result<LoadedConfig> {
458    let config = Config::load_from_path_public(path)?;
459    let raw = match std::fs::read_to_string(path) {
460        // `expect`: `load_from_path_public` above parsed this same text as
461        // TOML, so a document that reaches here is a document that parses.
462        Ok(text) => toml::from_str::<toml::Table>(&text).expect("the config parsed a moment ago"),
463        // No file at all, which loads as the defaults and an empty document.
464        Err(_) => toml::Table::new(),
465    };
466    Ok(LoadedConfig { config, raw })
467}
468
469/// Work out everything the command would do, without doing any of it.
470pub fn plan(args: &UpdateArgs, env: &UpdateEnv) -> UpdatePlan {
471    let method = detect(
472        &env.exe,
473        env.home.as_deref(),
474        env.brew_prefix.as_deref(),
475        args.channel,
476    );
477    let binary = binary_step(&method);
478    let agents = plan_agent_actions(&env.agents_dir);
479
480    // A config that will not parse is reported, not fatal: the binary step is
481    // the part of this command that matters most and it does not need one.
482    let (migrations, config) = match load_config(&env.config_path) {
483        Ok(loaded) => (
484            env.migrations
485                .iter()
486                .filter(|m| (m.applies)(&loaded.config, &loaded.raw))
487                .collect(),
488            ConfigState::Loaded(Box::new(loaded.config)),
489        ),
490        Err(e) => (Vec::new(), ConfigState::Unreadable(e.to_string())),
491    };
492
493    UpdatePlan {
494        method,
495        binary,
496        agents,
497        migrations,
498        config,
499    }
500}
501
502// ─── Rendering ────────────────────────────────────────────────────────────────
503
504/// The blueprints this plan would change.
505fn changing(plan: &UpdatePlan) -> Vec<&(&'static BundledAgent, AgentAction)> {
506    plan.agents.iter().filter(|(_, a)| a.is_change()).collect()
507}
508
509/// Render the plan the way `lev update` prints it.
510///
511/// Pure, so the tests assert the text exactly - everything that varies between
512/// machines is already in the [`UpdatePlan`].
513pub fn format_plan(plan: &UpdatePlan, version: &str) -> String {
514    let mut out = format!(
515        "\nlev {version}, installed with {}\n\n",
516        plan.method.describe()
517    );
518
519    match &plan.binary {
520        BinaryStep::Run(argv) => out.push_str(&format!("  binary   {}\n", argv.join(" "))),
521        BinaryStep::Advise(text) => out.push_str(&format!("  binary   {text}\n")),
522    }
523
524    let changes = changing(plan);
525    match changes.is_empty() {
526        true => out.push_str(&format!(
527            "  agents   all {} bundled blueprints are up to date\n",
528            plan.agents.len()
529        )),
530        false => {
531            out.push_str(&format!(
532                "  agents   {} of {} would change\n",
533                changes.len(),
534                plan.agents.len()
535            ));
536            for (agent, action) in &changes {
537                out.push_str(&format!(
538                    "             {} - {}\n",
539                    agent.name,
540                    action.label(agent.version)
541                ));
542            }
543        }
544    }
545
546    match (&plan.config, plan.migrations.is_empty()) {
547        (ConfigState::Unreadable(e), _) => {
548            out.push_str(&format!("  config   could not be read: {e}\n"))
549        }
550        (ConfigState::Loaded(_), true) => out.push_str("  config   nothing to migrate\n"),
551        (ConfigState::Loaded(_), false) => {
552            out.push_str(&format!(
553                "  config   {} migration(s)\n",
554                plan.migrations.len()
555            ));
556            for migration in &plan.migrations {
557                out.push_str(&format!(
558                    "             {} - {}\n",
559                    migration.name, migration.description
560                ));
561            }
562        }
563    }
564    out
565}
566
567/// The plan as JSON. Built by hand, like `lev tools` and `lev doctor`, so the
568/// shape is explicit and does not move when a type gains a field.
569pub fn plan_json(plan: &UpdatePlan, version: &str) -> serde_json::Value {
570    let binary = match &plan.binary {
571        BinaryStep::Run(argv) => serde_json::json!({ "action": "run", "command": argv }),
572        BinaryStep::Advise(text) => serde_json::json!({ "action": "advise", "message": text }),
573    };
574    let agents: Vec<serde_json::Value> = plan
575        .agents
576        .iter()
577        .map(|(agent, action)| {
578            serde_json::json!({
579                "name": agent.name,
580                "version": agent.version,
581                "change": action.label(agent.version),
582                "changes": action.is_change(),
583                "preselected": action.preselect(),
584            })
585        })
586        .collect();
587    let migrations: Vec<serde_json::Value> = plan
588        .migrations
589        .iter()
590        .map(|m| serde_json::json!({ "name": m.name, "description": m.description }))
591        .collect();
592    serde_json::json!({
593        "version": version,
594        "install_method": plan.method.id(),
595        "channel": plan.method.channel().map(Channel::id),
596        "binary": binary,
597        "agents": agents,
598        "migrations": migrations,
599        "config_error": match &plan.config {
600            ConfigState::Unreadable(e) => serde_json::Value::String(e.clone()),
601            ConfigState::Loaded(_) => serde_json::Value::Null,
602        },
603    })
604}
605
606// ─── Arguments and seams ──────────────────────────────────────────────────────
607
608/// Arguments for `lev update`.
609#[derive(Args, Debug, Clone, Default)]
610pub struct UpdateArgs {
611    /// Report what would happen and change nothing.
612    #[arg(long)]
613    pub check: bool,
614
615    /// Answer yes to the binary upgrade and the config write without asking.
616    /// It deliberately does not install blueprints: see `--install-agents`.
617    #[arg(long)]
618    pub yes: bool,
619
620    /// Install the bundled blueprints without asking. A copy you edited
621    /// locally is still asked about on its own, since installing destroys it.
622    #[arg(long)]
623    pub install_agents: bool,
624
625    /// The channel to install, for the install-script method, which records
626    /// none. Ignored by Homebrew and Scoop, whose package name already says.
627    #[arg(long, value_name = "CHANNEL")]
628    pub channel: Option<Channel>,
629
630    /// Walk the whole flow, but print each action instead of performing it.
631    #[arg(long)]
632    pub dry_run: bool,
633
634    /// Print the plan as JSON and change nothing.
635    #[arg(long)]
636    pub json: bool,
637}
638
639/// Runs the upgrade command: argv in, success or the reason it failed out.
640///
641/// Injected rather than called directly so the tests assert *which* command
642/// would run without spawning anything. Mirrors the `BrowserOpener` and
643/// `SeedCommandRunner` seams.
644pub type CommandRunner = Arc<dyn Fn(&[String]) -> anyhow::Result<()> + Send + Sync>;
645
646/// Asks the user a yes/no question. Injected for the same reason: a unit test
647/// has no terminal, and which questions a flag answers on the user's behalf -
648/// and, for a blueprint they edited, which ones no flag answers - is something
649/// the tests have to be able to prove rather than describe.
650pub type Confirm = Arc<dyn Fn(&str) -> bool + Send + Sync>;
651
652/// The real I/O `lev update` depends on, injected so the command logic is
653/// testable without a package manager, a terminal, or the real home directory.
654pub struct UpdateEnv {
655    /// The running binary, symlinks already resolved. Resolving matters: a
656    /// Homebrew `bin/lev` is a symlink into the Cellar, and the Cellar path is
657    /// the one that names the formula.
658    pub exe: PathBuf,
659    /// The user's home directory, when one resolves.
660    pub home: Option<PathBuf>,
661    /// What `brew --prefix` answered, when it was asked and answered.
662    pub brew_prefix: Option<PathBuf>,
663    /// Where the bundled blueprints are installed.
664    pub agents_dir: PathBuf,
665    /// The config file to read and, with permission, rewrite.
666    pub config_path: PathBuf,
667    /// How to run the upgrade command.
668    pub runner: CommandRunner,
669    /// How to ask a yes/no question.
670    pub confirm: Confirm,
671    /// The migrations to consider, in order. Production passes [`MIGRATIONS`].
672    pub migrations: &'static [Migration],
673}
674
675// ─── Execution ────────────────────────────────────────────────────────────────
676
677/// Ask, unless `--yes` has already answered.
678fn agreed(args: &UpdateArgs, env: &UpdateEnv, question: &str) -> bool {
679    args.yes || (env.confirm)(question)
680}
681
682/// Step one: the binary.
683fn update_binary(args: &UpdateArgs, env: &UpdateEnv, plan: &UpdatePlan) -> anyhow::Result<()> {
684    let argv = match &plan.binary {
685        BinaryStep::Advise(text) => {
686            println!("  {text}");
687            return Ok(());
688        }
689        BinaryStep::Run(argv) => argv,
690    };
691    let shown = argv.join(" ");
692    if !agreed(args, env, &format!("Run `{shown}`?")) {
693        println!("  left the binary alone");
694        return Ok(());
695    }
696    if args.dry_run {
697        println!("  would run: {shown}");
698        return Ok(());
699    }
700    (env.runner)(argv)
701}
702
703/// Step two: the bundled blueprints.
704///
705/// Nothing here is a default. The whole list is printed first, and the agents
706/// directory is not written to until someone has said yes to it: `--yes` is
707/// deliberately not enough, because "update my binary without stopping to ask"
708/// and "replace the blueprints in my agents directory" are different requests
709/// and `lev setup` already treats them that way. `--install-agents` is how a
710/// script says the second one.
711///
712/// A copy the user edited is named as edited and asked about on its own, and no
713/// flag covers it: [`install_bundled`] removes the destination directory first,
714/// so a bulk yes would take their edits and any file they added with it.
715///
716/// A failed install is a warning, not an abort, for the same reason `lev setup`
717/// treats it that way: an updated binary plus most of the blueprints is a far
718/// better place to leave someone than a command that gave up in the middle.
719fn update_agents(args: &UpdateArgs, env: &UpdateEnv, plan: &UpdatePlan) {
720    let changes = changing(plan);
721    if changes.is_empty() {
722        println!("  every bundled blueprint is already up to date");
723        return;
724    }
725
726    // Seen before agreed to, always.
727    for (agent, action) in &changes {
728        println!("    {} - {}", agent.name, action.label(agent.version));
729    }
730    let clean = changes.iter().filter(|(_, a)| a.preselect()).count();
731    let edited = changes.len() - clean;
732    if edited > 0 {
733        println!(
734            "  {edited} of these you have edited locally. Installing removes the directory \
735             first, so your edits and any file you added go with it - each is asked about \
736             on its own."
737        );
738    }
739
740    // One question for the whole clean set, rather than one per blueprint: the
741    // list above is already the detail, and seven prompts in a row is how a
742    // person stops reading them.
743    let install_clean = match clean {
744        0 => false,
745        n => args.install_agents || (env.confirm)(&format!("Install these {n} blueprint(s)?")),
746    };
747
748    for (agent, action) in changes {
749        let ok = match action.preselect() {
750            true => install_clean,
751            false => (env.confirm)(&format!(
752                "{} - {}. Overwrite your edited copy?",
753                agent.name,
754                action.label(agent.version)
755            )),
756        };
757        if !ok {
758            println!("  skipped {}", agent.name);
759            continue;
760        }
761        if args.dry_run {
762            println!("  would install {} {}", agent.name, agent.version);
763            continue;
764        }
765        match install_bundled(agent, &env.agents_dir) {
766            Ok(()) => println!("  installed {} {}", agent.name, agent.version),
767            Err(e) => println!("  could not install {}: {e}", agent.name),
768        }
769    }
770}
771
772/// Step three: the config.
773///
774/// Nothing is written before the user has seen, line by line, what changed.
775fn migrate_config(args: &UpdateArgs, env: &UpdateEnv, plan: &UpdatePlan) -> anyhow::Result<()> {
776    let config = match &plan.config {
777        ConfigState::Unreadable(e) => {
778            println!("  the config could not be read, so it was left alone: {e}");
779            return Ok(());
780        }
781        ConfigState::Loaded(config) => config,
782    };
783    if plan.migrations.is_empty() {
784        println!("  the config needs no changes");
785        return Ok(());
786    }
787
788    let mut config = config.as_ref().clone();
789    let mut changed = Vec::new();
790    for migration in &plan.migrations {
791        for line in (migration.apply)(&mut config) {
792            changed.push(format!("{}: {line}", migration.name));
793        }
794    }
795    for line in &changed {
796        println!("    - {line}");
797    }
798
799    let path = env.config_path.display();
800    if !agreed(args, env, &format!("Write these changes to {path}?")) {
801        println!("  config left as it is");
802        return Ok(());
803    }
804    if args.dry_run {
805        println!("  would write {path}");
806        return Ok(());
807    }
808    config.save_to_path_public(&env.config_path)?;
809    println!("  wrote {path}");
810    Ok(())
811}
812
813/// Run `lev update` against an injected environment.
814pub fn execute_with(args: &UpdateArgs, env: &UpdateEnv, version: &str) -> anyhow::Result<()> {
815    let plan = plan(args, env);
816
817    if args.json {
818        println!(
819            "{}",
820            serde_json::to_string_pretty(&plan_json(&plan, version))
821                .expect("a plan is plain data and always serializes")
822        );
823        return Ok(());
824    }
825
826    print!("{}", format_plan(&plan, version));
827    if args.check {
828        return Ok(());
829    }
830
831    println!("\nbinary");
832    update_binary(args, env, &plan)?;
833    println!("\nblueprints");
834    update_agents(args, env, &plan);
835    println!("\nconfig");
836    migrate_config(args, env, &plan)?;
837    Ok(())
838}
839
840#[cfg(test)]
841mod tests;