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;