Skip to main content

dev_prune/
setup.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Idempotent installation of dev-prune's integrations.
5//
6// dev-prune is only really installed once the parts that let it work without being
7// thought about are in place: the `devp` alias, the managed pair on the user's PATH,
8// the exported `SKILL.md` that AI assistants read (installed into the agent's own
9// skills directory where one exists), the Git hooks that keep the registry current,
10// and the OS scheduler that runs the passes. Each one here is created **only when it
11// is missing**, which is
12// what makes it safe to run on every install, reinstall and upgrade — and it does run
13// on each of those, through the version stamp written at the end of a completed pass.
14//
15// Nothing in here is fatal. A machine without `git`, a `core.hooksPath` that belongs to
16// husky, a locked-down scheduler: each is reported and stepped over, because none of
17// them should stop `devp init` from registering repositories.
18
19use std::fs;
20use std::path::PathBuf;
21
22use anyhow::Result;
23
24use crate::commands::hook::{self, HookState};
25use crate::commands::skill::EMBEDDED_SKILL_MD;
26use crate::config::Registry;
27use crate::constants;
28use crate::daemon;
29use crate::output;
30
31/// File in the config directory recording the version whose last integration pass
32/// completed. A missing or older stamp is what triggers the automatic pass, so a fresh
33/// install and an upgrade both self-heal exactly once.
34const STAMP_FILE: &str = "setup-stamp";
35
36/// Environment variable that suppresses the automatic pass entirely.
37///
38/// For images, CI and anyone who wants the binary and nothing else. `devp setup` still
39/// works when it is set — this only governs the unattended pass.
40pub const ENV_NO_AUTO_SETUP: &str = "DEV_PRUNE_NO_AUTO_SETUP";
41
42/// Whether the suppression variable is set — by presence, so `=1`, `=true` and even an
43/// empty value all count.
44///
45/// The one predicate every consumer must share. The doctor note used to answer only for
46/// the literal `=1`, so a machine with `=true` had setup switched off with nothing
47/// anywhere saying so.
48pub fn no_auto_setup_requested() -> bool {
49    std::env::var_os(ENV_NO_AUTO_SETUP).is_some()
50}
51
52/// What one integration did during a pass.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum Outcome {
55    /// It was missing and is now in place.
56    Installed,
57    /// It was already in place and was left alone.
58    AlreadyPresent,
59    /// It could not be installed for a reason that is the user's call, not an error.
60    Skipped(String),
61    /// It failed. The pass continues; the reason is reported.
62    Failed(String),
63}
64
65/// The result of one integration pass.
66#[derive(Debug, Default)]
67pub struct SetupReport {
68    items: Vec<(&'static str, Outcome)>,
69}
70
71impl SetupReport {
72    fn push(&mut self, name: &'static str, outcome: Outcome) {
73        self.items.push((name, outcome));
74    }
75
76    /// Whether anything at all was created by this pass.
77    pub fn changed_anything(&self) -> bool {
78        self.items
79            .iter()
80            .any(|(_, o)| matches!(o, Outcome::Installed))
81    }
82
83    /// Whether anything needs the user's attention.
84    pub fn needs_attention(&self) -> bool {
85        self.items
86            .iter()
87            .any(|(_, o)| matches!(o, Outcome::Skipped(_) | Outcome::Failed(_)))
88    }
89
90    /// Print the report.
91    ///
92    /// `verbose` is for the explicit `devp setup`, where "already installed" is the
93    /// answer the user asked for. The automatic pass passes `false` and stays silent
94    /// about everything that was already fine.
95    pub fn print(&self, verbose: bool) {
96        for (name, outcome) in &self.items {
97            match outcome {
98                Outcome::Installed => output::print_success(&format!("{name}: installed.")),
99                Outcome::AlreadyPresent if verbose => {
100                    output::print_info(&format!("{name}: already installed."));
101                }
102                Outcome::AlreadyPresent => {}
103                Outcome::Skipped(why) => {
104                    output::print_warning(&format!("{name}: skipped — {why}"));
105                }
106                Outcome::Failed(why) => {
107                    output::print_error(&format!("{name}: failed — {why}"));
108                }
109            }
110        }
111    }
112}
113
114/// Where the installers put the binary, and the one directory nothing else owns.
115///
116/// Public because it is also what the PATH step registers and what `uninstall` must
117/// take back out again.
118pub fn managed_bin_dir() -> Result<PathBuf> {
119    Ok(Registry::config_dir()?.join("bin"))
120}
121
122fn managed_exe_path() -> Result<PathBuf> {
123    let name = if cfg!(windows) {
124        "dev-prune.exe"
125    } else {
126        "dev-prune"
127    };
128    Ok(managed_bin_dir()?.join(name))
129}
130
131/// Absolute path to a copy of this binary that will still be there next week.
132///
133/// Anything that writes a path down for later — the OS scheduler, the git hooks — has to
134/// use this instead of [`std::env::current_exe`]. dev-prune ships through npm and PyPI as
135/// well as the installers, so the running executable is often somewhere a package manager
136/// owns and will delete: npm's `_npx` cache, uv's ephemeral tool environment, or
137/// `target/debug` during development. An entry recorded there breaks the moment that
138/// directory goes, and neither of these has anywhere to complain — the scheduled task
139/// fails silently every interval, and the hook discards its own output by design. The
140/// only symptom is that nothing ever happens again.
141///
142/// `<config>/bin` is where `install.sh` and `install.ps1` put the binary and nothing else
143/// deletes, so prefer the copy there. When there is none, put one there: the binary that
144/// is running right now is precisely the one that is going to be missing later.
145pub fn stable_exe_path() -> PathBuf {
146    let current = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("dev-prune"));
147    let Ok(managed) = managed_exe_path() else {
148        return current;
149    };
150    if managed == current {
151        return managed;
152    }
153    if managed.is_file() {
154        refresh_managed_copy_if_stale(&current, &managed);
155        return managed;
156    }
157
158    // Only ever clone something that is actually this CLI. `current_exe()` under `cargo
159    // test` is the test harness, and copying that into the config directory would be both
160    // wrong and slow.
161    if !is_this_cli(&current) {
162        return current;
163    }
164
165    let Some(parent) = managed.parent() else {
166        return current;
167    };
168    if fs::create_dir_all(parent).is_err() {
169        return current;
170    }
171    // Hard link where the filesystem allows it — that also keeps the bytes alive when the
172    // package manager deletes the directory the original came from.
173    if fs::hard_link(&current, &managed).is_ok() {
174        return managed;
175    }
176
177    // The same hazard `ensure_alias` documents, through a narrower window: the check at the
178    // top of this function saw no managed copy, but another process created one — as a hard
179    // link to `current` — before the link above ran. `fs::copy` opens its destination with
180    // O_TRUNC, and truncating a hard link empties the shared inode, so the copy would
181    // destroy the very binary it is copying.
182    if managed.is_file() {
183        return managed;
184    }
185
186    // Stage beside and rename into place. A copy straight onto the final name has a
187    // window where the file exists but is incomplete — and this path is what the
188    // scheduler and hooks get registered against, so a process killed mid-copy would
189    // leave a torn binary that every later pass happily points at.
190    let staging = managed.with_extension("new");
191    if fs::copy(&current, &staging).is_ok() && fs::rename(&staging, &managed).is_ok() {
192        return managed;
193    }
194    let _ = fs::remove_file(&staging);
195    // The rename loses only to a concurrent invocation that installed its own copy,
196    // which serves exactly as well.
197    if managed.is_file() { managed } else { current }
198}
199
200/// Whether this path names one of the CLI's own binaries, by file stem.
201fn is_this_cli(path: &std::path::Path) -> bool {
202    path.file_stem()
203        .and_then(|s| s.to_str())
204        .is_some_and(|stem| stem == "dev-prune" || stem == "devp")
205}
206
207/// Replace the managed copy when it is an older release than the binary running now.
208///
209/// The scheduler and the hooks point at the managed copy precisely because it outlives
210/// package-manager caches — which also means an upgrade through cargo, npm or uv changes
211/// the running binary but not the one the integrations run, and the machine quietly
212/// keeps pruning with the previous version forever.
213///
214/// Staleness is decided by asking the copy its version, not by mtime or content: an
215/// *older* binary running out of a stale npx cache must not overwrite a newer managed
216/// copy, and content inequality cannot say which of the two is the upgrade. A copy that
217/// cannot state a version at all is replaced too — whatever it is, it is not a working
218/// build of this CLI.
219fn refresh_managed_copy_if_stale(current: &std::path::Path, managed: &std::path::Path) {
220    if !is_this_cli(current) || same_contents(managed, current) {
221        return;
222    }
223    match (binary_version(managed), parse_version(constants::VERSION)) {
224        (Some(theirs), Some(ours)) if theirs >= ours => return,
225        _ => {}
226    }
227    // Write beside and rename into place, so a scheduler firing mid-copy never runs a
228    // torn binary. A managed copy that is itself running cannot be renamed over on
229    // Windows; the refresh simply waits for a pass when it is not.
230    let staging = managed.with_extension("new");
231    if fs::copy(current, &staging).is_ok() && fs::rename(&staging, managed).is_err() {
232        let _ = fs::remove_file(&staging);
233    }
234}
235
236/// The `major.minor.patch` a binary reports for itself, if it can.
237fn binary_version(exe: &std::path::Path) -> Option<(u64, u64, u64)> {
238    let output = std::process::Command::new(exe)
239        .arg("--version")
240        .output()
241        .ok()?;
242    if !output.status.success() {
243        return None;
244    }
245    String::from_utf8_lossy(&output.stdout)
246        .split_whitespace()
247        .find_map(parse_version)
248}
249
250/// Parse `x.y.z` into an orderable triple. Anything else — including the pre-release
251/// and build suffixes this project never publishes — answers `None`.
252fn parse_version(text: &str) -> Option<(u64, u64, u64)> {
253    let mut parts = text.split('.');
254    let triple = (
255        parts.next()?.parse().ok()?,
256        parts.next()?.parse().ok()?,
257        parts.next()?.parse().ok()?,
258    );
259    parts.next().is_none().then_some(triple)
260}
261
262/// Keep `dev-prune` and `devp` beside each other, whichever of the two is running.
263///
264/// The pair is one binary under two names, and either one can be the survivor. An upgrade
265/// that could not replace a running `devp` leaves a stale alias; an antivirus quarantine,
266/// a half-finished uninstall or a `Remove-Item` aimed at the wrong name leaves only
267/// `devp`. So this restores *the other* name in whichever direction is missing, rather
268/// than only ever creating `devp` — running either one puts the pair back.
269pub fn ensure_alias() -> Outcome {
270    let Ok(current_exe) = std::env::current_exe() else {
271        return Outcome::Failed("could not locate the running executable".to_string());
272    };
273    let Some(parent_dir) = current_exe.parent() else {
274        return Outcome::Failed("the running executable has no parent directory".to_string());
275    };
276
277    ensure_twin_of(&current_exe, parent_dir)
278}
279
280/// The half of [`ensure_alias`] that takes its paths as arguments, so tests can drive both
281/// directions without being the binary they are testing.
282fn ensure_twin_of(current_exe: &std::path::Path, parent_dir: &std::path::Path) -> Outcome {
283    let running_as_alias = current_exe
284        .file_stem()
285        .and_then(|s| s.to_str())
286        .is_some_and(|stem| stem == "devp");
287
288    // `dev-prune` is the canonical name, and only it may overwrite its twin.
289    //
290    // Installers write `dev-prune` first and upgrades replace it first, so it is never the
291    // older of the two — a stale `devp` is worth replacing, because otherwise it silently
292    // runs the previous version. The reverse is not safe: an upgrade that replaced
293    // `dev-prune` and then failed on a running `devp` leaves exactly the state where the
294    // alias is the *older* binary, and refreshing from there would quietly reinstall the
295    // version the user just upgraded away from. So `devp` may only create a `dev-prune`
296    // that is missing outright.
297    let (twin_name, may_refresh) = if running_as_alias {
298        (
299            if cfg!(windows) {
300                "dev-prune.exe"
301            } else {
302                "dev-prune"
303            },
304            false,
305        )
306    } else {
307        (if cfg!(windows) { "devp.exe" } else { "devp" }, true)
308    };
309    let twin_exe = parent_dir.join(twin_name);
310
311    if twin_exe.exists() {
312        if !may_refresh || same_contents(&twin_exe, current_exe) {
313            return Outcome::AlreadyPresent;
314        }
315        // Replacing a running executable fails on Windows; that is fine, the alias is
316        // simply refreshed by the next invocation that is not itself `devp`.
317        if fs::remove_file(&twin_exe).is_err() {
318            return Outcome::Skipped(format!(
319                "`{twin_name}` is in use and could not be refreshed — re-run `devp setup` \
320                 from a terminal that is not running it"
321            ));
322        }
323    }
324
325    if fs::hard_link(current_exe, &twin_exe).is_ok() {
326        return Outcome::Installed;
327    }
328
329    // The copy is the fallback for filesystems without hard links — but it must never
330    // run when the alias already exists, because the reason `hard_link` usually fails is
331    // that another process created it a moment ago, as a hard link to this very
332    // executable. `fs::copy` opens its destination with O_TRUNC, and truncating a hard
333    // link truncates the shared inode: the copy would empty the running binary and then
334    // copy zero bytes from it.
335    //
336    // That is not hypothetical. It is what turned every macOS CI run red. The 28
337    // integration tests launch at once, one wins the link, the losers fall through to
338    // here, and `target/debug/dev-prune` becomes a zero-byte file. macOS `posix_spawn`
339    // answers ENOEXEC by handing the file to `/bin/sh`, so every later invocation
340    // "succeeded" with exit 0 and printed nothing — for two hours the tests looked like
341    // 27 unrelated assertion failures.
342    if twin_exe.exists() {
343        return Outcome::AlreadyPresent;
344    }
345
346    // Stage beside and rename into place: copied straight onto the final name, the
347    // alias would exist-but-be-incomplete for the length of the copy, and a `devp`
348    // typed in that window executes a torn binary.
349    let staging = twin_exe.with_extension("new");
350    if fs::copy(current_exe, &staging).is_ok() && fs::rename(&staging, &twin_exe).is_ok() {
351        return Outcome::Installed;
352    }
353    let _ = fs::remove_file(&staging);
354    if twin_exe.exists() {
355        // A concurrent invocation won the rename; its alias serves exactly as well.
356        return Outcome::AlreadyPresent;
357    }
358    Outcome::Failed(format!(
359        "could not create `{}`",
360        output::clean_path(&twin_exe)
361    ))
362}
363
364/// Sameness test for two executables, cheap in the common case.
365///
366/// A hard link makes size and mtime equal by construction, so the usual layout answers
367/// without reading either file. When only the mtime differs — the alias came from the
368/// copy fallback, which does not preserve timestamps — the bytes themselves decide,
369/// because calling that pair "different" made every single invocation delete and
370/// recreate an alias whose content never changed.
371fn same_contents(a: &std::path::Path, b: &std::path::Path) -> bool {
372    let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) else {
373        return false;
374    };
375    if ma.len() != mb.len() {
376        return false;
377    }
378    if ma.modified().ok() == mb.modified().ok() {
379        return true;
380    }
381    match (fs::read(a), fs::read(b)) {
382        (Ok(ca), Ok(cb)) => ca == cb,
383        _ => false,
384    }
385}
386
387/// Path that `devp skill` and this module export `SKILL.md` to.
388pub fn skill_path() -> Result<PathBuf> {
389    Ok(Registry::config_dir()?.join("SKILL.md"))
390}
391
392/// Export the bundled `SKILL.md` so AI assistants have something to read.
393///
394/// Rewritten whenever it differs from the embedded copy, since an upgrade that changes
395/// the skill must not leave the previous version's instructions on disk.
396pub fn ensure_skill_file() -> Outcome {
397    match Registry::config_dir() {
398        Ok(dir) => ensure_skill_file_in(&dir),
399        Err(_) => Outcome::Failed("could not determine the config directory".to_string()),
400    }
401}
402
403fn ensure_skill_file_in(config_dir: &std::path::Path) -> Outcome {
404    let target = config_dir.join("SKILL.md");
405
406    if fs::read_to_string(&target).is_ok_and(|current| current == EMBEDDED_SKILL_MD) {
407        return Outcome::AlreadyPresent;
408    }
409
410    let _ = fs::create_dir_all(config_dir);
411    match fs::write(&target, EMBEDDED_SKILL_MD) {
412        Ok(()) => Outcome::Installed,
413        Err(e) => Outcome::Failed(format!(
414            "could not write {}: {e}",
415            output::clean_path(&target)
416        )),
417    }
418}
419
420/// The per-skill directories of AI coding agents that are installed under `home`.
421///
422/// Detection only — an agent's home directory is created by the agent, never by this
423/// pass. Today that is Claude Code, whose Agent Skills live at
424/// `~/.claude/skills/<name>/SKILL.md`. Assistants without an on-disk skill format get
425/// the onboarding prompt from `devp skill` instead.
426fn agent_skill_roots_under(home: &std::path::Path) -> Vec<PathBuf> {
427    let mut roots = Vec::new();
428    let claude = home.join(constants::CLAUDE_HOME_DIR);
429    if claude.is_dir() {
430        roots.push(
431            claude
432                .join(constants::AGENT_SKILLS_SUBDIR)
433                .join(constants::APP_NAME),
434        );
435    }
436    roots
437}
438
439/// The agent skill directories on this machine. Empty when no agent is installed.
440pub fn agent_skill_roots() -> Vec<PathBuf> {
441    dirs::home_dir()
442        .map(|home| agent_skill_roots_under(&home))
443        .unwrap_or_default()
444}
445
446/// Install the skill into every detected agent's skills directory.
447pub fn ensure_agent_skills() -> Outcome {
448    ensure_agent_skills_at(&agent_skill_roots())
449}
450
451fn ensure_agent_skills_at(roots: &[PathBuf]) -> Outcome {
452    if roots.is_empty() {
453        return Outcome::Skipped(
454            "no AI agent skills directory was found — `devp skill` prints import prompts instead"
455                .to_string(),
456        );
457    }
458    let mut installed = false;
459    for root in roots {
460        match ensure_skill_file_in(root) {
461            Outcome::Installed => installed = true,
462            Outcome::AlreadyPresent => {}
463            other => return other,
464        }
465    }
466    if installed {
467        Outcome::Installed
468    } else {
469        Outcome::AlreadyPresent
470    }
471}
472
473/// Make the managed pair reachable from a fresh shell.
474///
475/// This is the step that lets `pip install dev-prune` in a virtualenv survive the
476/// virtualenv: the binaries pip placed vanish with the environment, but the managed
477/// copy under `<config>/bin` does not, and after this step it is the one a new
478/// terminal finds. See [`crate::pathenv`] for what "reachable" means per platform.
479pub fn ensure_command_on_path() -> Outcome {
480    let managed = stable_exe_path();
481    let is_managed_copy =
482        managed_exe_path().is_ok_and(|expected| expected == managed) && managed.is_file();
483    if !is_managed_copy {
484        // No managed copy exists and none could be created — under `cargo test` the
485        // running executable is the harness, and cloning that would be wrong. There is
486        // nothing durable to put on PATH.
487        return Outcome::Skipped("no managed copy of the binary exists to put on PATH".to_string());
488    }
489    let Some(bin_dir) = managed.parent() else {
490        return Outcome::Failed("the managed binary has no parent directory".to_string());
491    };
492    // `devp` has to sit beside it, or the PATH entry only ever finds `dev-prune`.
493    if let Outcome::Failed(why) = ensure_twin_of(&managed, bin_dir) {
494        return Outcome::Failed(why);
495    }
496    crate::pathenv::ensure_reachable(bin_dir)
497}
498
499/// Write the icon assets and register `*.devprune.json` with the OS file manager.
500///
501/// Part of the automatic pass rather than a separate errand, because "the config file has
502/// an icon" is not a thing anybody thinks to go and ask for. Everything it writes lives
503/// under the config directory and the user's own XDG data directory, `devp uninstall`
504/// removes all of it, and it touches no editor settings, no PATH and no shell profile —
505/// so there is nothing here that needs to be asked about first.
506///
507/// Unlike the hooks and the scheduler, this has no opt-out switch of its own. Files
508/// dropped into the user's data directory are not a background process and not a change
509/// in behaviour; `auto_setup` already covers "install nothing at all".
510fn ensure_icons() -> Outcome {
511    if crate::commands::icon::is_registered() {
512        return Outcome::AlreadyPresent;
513    }
514    match crate::commands::icon::sync_app_directory() {
515        Ok(()) => Outcome::Installed,
516        Err(e) => Outcome::Failed(format!("{e:#}")),
517    }
518}
519
520/// Install the global Git hooks, unless git is absent or the slot belongs to someone else.
521///
522/// `chain` is `auto_hooks_chain`: with it on, a slot that belongs to husky is not a
523/// reason to skip, because dev-prune can install in front and forward every hook back.
524pub fn ensure_hooks(chain: bool) -> Outcome {
525    if !hook::git_available() {
526        return Outcome::Skipped(format!(
527            "\n    {}",
528            hook::GIT_MISSING_HELP.replace('\n', "\n    ")
529        ));
530    }
531
532    match hook::state() {
533        // "Installed" is not the question — "installed and pointing at a binary that
534        // still exists" is. A hook backgrounds itself and discards its own output, so
535        // one left pointing at a deleted npm cache dies silently on every commit and
536        // nothing ever registers again; this pass is the only thing that ever looks.
537        Ok(HookState::Active) if hook_target_is_dead() => match hook::install() {
538            Ok(()) => Outcome::Installed,
539            Err(e) => Outcome::Failed(format!("{e:#}")),
540        },
541        Ok(HookState::Active) => Outcome::AlreadyPresent,
542        // Drift is repaired here rather than reported: the setup pass already runs on
543        // install, on update and on a schedule, and a chain the user opted into is a
544        // chain they want kept current.
545        Ok(HookState::Chained { drifted, .. }) if !drifted.is_empty() => {
546            match hook::install_with(true) {
547                Ok(()) => Outcome::Installed,
548                Err(e) => Outcome::Failed(format!("{e:#}")),
549            }
550        }
551        Ok(HookState::Chained { .. }) if hook_target_is_dead() => match hook::install_with(true) {
552            Ok(()) => Outcome::Installed,
553            Err(e) => Outcome::Failed(format!("{e:#}")),
554        },
555        Ok(HookState::Chained { .. }) => Outcome::AlreadyPresent,
556        Ok(HookState::Foreign(_)) if chain => match hook::install_with(true) {
557            Ok(()) => Outcome::Installed,
558            Err(e) => Outcome::Failed(format!("{e:#}")),
559        },
560        Ok(HookState::Foreign(existing)) => Outcome::Skipped(format!(
561            "`core.hooksPath` is already set to `{existing}`, which belongs to another tool.\n    \
562             Git allows only one hooks directory, so dev-prune will not take the slot.\n    \
563             `devp hook install --chain` installs in front of it instead — dev-prune registers \
564             the repo, then hands every hook on to `{existing}`, and `devp hook uninstall` puts \
565             the original setting back (`devp config set auto_hooks_chain true` makes that \
566             the standing answer). Or skip it: `devp link .` does the same job by hand."
567        )),
568        Ok(HookState::Absent) => match hook::install() {
569            Ok(()) => Outcome::Installed,
570            Err(e) => Outcome::Failed(format!("{e:#}")),
571        },
572        Err(e) => Outcome::Failed(format!("{e:#}")),
573    }
574}
575
576/// Whether the installed hooks name a binary that no longer exists.
577fn hook_target_is_dead() -> bool {
578    hook::registered_exe_path().is_some_and(|exe| !exe.exists())
579}
580
581/// Install the OS scheduler if it is not already registered.
582pub fn ensure_daemon(interval_days: u64) -> Outcome {
583    match daemon::daemon_status() {
584        // A task whose binary has been deleted keeps reporting itself `Ready` and dies
585        // the instant it fires, every interval, with nowhere to complain. Re-register
586        // it against the stable path instead of counting the corpse as present.
587        Ok(daemon::DaemonStatus::Installed)
588            if daemon::registered_exe_path().is_some_and(|exe| !exe.exists()) =>
589        {
590            match daemon::install_daemon(interval_days) {
591                Ok(()) => Outcome::Installed,
592                Err(e) => Outcome::Failed(format!("{e:#}")),
593            }
594        }
595        Ok(daemon::DaemonStatus::Installed) => Outcome::AlreadyPresent,
596        Ok(daemon::DaemonStatus::NotInstalled) => match daemon::install_daemon(interval_days) {
597            Ok(()) => Outcome::Installed,
598            Err(e) => Outcome::Failed(format!("{e:#}")),
599        },
600        // `Unknown` means the query itself could not be answered — the scheduler may
601        // well be there. Installing over it would fail on every command from now on, so
602        // this reports and steps over instead of guessing. The platform backends are
603        // written to keep this case narrow: anything they can answer definitely, they do.
604        Ok(daemon::DaemonStatus::Unknown(why)) => {
605            Outcome::Skipped(format!("scheduler state could not be read — {why}"))
606        }
607        Err(e) => Outcome::Failed(format!("{e:#}")),
608    }
609}
610
611/// Whether unattended installation is permitted at all.
612///
613/// Both switches exist because these integrations write outside dev-prune's own config
614/// directory — a scheduled task, a global git setting — and there are places that must
615/// never happen unasked: container images, CI, and this project's own test suite.
616pub fn auto_setup_enabled(registry: &Registry) -> bool {
617    !no_auto_setup_requested() && registry.settings.auto_setup && unattended_environment().is_none()
618}
619
620/// The reason this looks like a machine nobody is sitting at, if it does.
621///
622/// `DEV_PRUNE_NO_AUTO_SETUP` and `auto_setup` are both switches you have to set *before*
623/// the first run — which is exactly the run that installs things, so in a container or a
624/// CI job the damage is done by the time there is anywhere to set them. Detecting the
625/// environment is the only opt-out that works on the first run, which is the only run
626/// that matters here.
627///
628/// Deliberately conservative: every signal below is one that CI providers and container
629/// runtimes set themselves, so a developer's own shell will not trip it. Someone who
630/// genuinely wants the integrations in CI can still ask in so many words with
631/// `devp setup`, which never consults this.
632pub fn unattended_environment() -> Option<&'static str> {
633    // Set by GitHub Actions, GitLab CI, CircleCI, Travis, Jenkins (via pipeline), Woodpecker
634    // and most others. `CI=true` is the closest thing this space has to a standard.
635    for var in [
636        "CI",
637        "CONTINUOUS_INTEGRATION",
638        "BUILD_NUMBER",
639        "GITHUB_ACTIONS",
640    ] {
641        if let Some(value) = std::env::var_os(var) {
642            // `CI=false` is set explicitly by some tools to mean "not CI", and honouring
643            // the word rather than the presence is what the user plainly meant.
644            let value = value.to_string_lossy();
645            if !value.is_empty() && !value.eq_ignore_ascii_case("false") {
646                return Some("this looks like a CI runner");
647            }
648        }
649    }
650
651    // Docker writes this marker into every container it builds from a Dockerfile;
652    // Podman and other OCI runtimes write the `container` variable instead.
653    #[cfg(unix)]
654    if std::path::Path::new("/.dockerenv").exists() {
655        return Some("this looks like a container");
656    }
657    if std::env::var_os("container").is_some() {
658        return Some("this looks like a container");
659    }
660
661    None
662}
663
664/// Run an integration pass unless unattended installation is switched off.
665///
666/// Every caller that the user did not name explicitly goes through this. `devp setup`
667/// calls [`ensure_integrations`] directly: asking for it in so many words is consent.
668pub fn ensure_integrations_if_enabled(registry: &Registry) -> Option<SetupReport> {
669    auto_setup_enabled(registry).then(|| ensure_integrations(registry))
670}
671
672/// Run one integration pass, installing whatever is missing.
673///
674/// The two per-integration settings (`auto_daemon`, `auto_hooks`) are honoured here, so
675/// turning one off turns it off for every future pass as well as this one.
676pub fn ensure_integrations(registry: &Registry) -> SetupReport {
677    let mut report = SetupReport::default();
678
679    report.push("dev-prune/devp pair", ensure_alias());
680    report.push("Command on PATH", ensure_command_on_path());
681    report.push("SKILL.md", ensure_skill_file());
682    // Only reported when an agent is actually installed: a machine without one would
683    // otherwise see a "skipped" warning about software it never had, on every install.
684    if !agent_skill_roots().is_empty() {
685        report.push("AI agent skills", ensure_agent_skills());
686    }
687    report.push("File icons", ensure_icons());
688
689    if registry.settings.auto_hooks {
690        report.push(
691            "Git hooks",
692            ensure_hooks(registry.settings.auto_hooks_chain),
693        );
694    } else {
695        report.push(
696            "Git hooks",
697            Outcome::Skipped("`auto_hooks` is false — enable with `devp hook install`".to_string()),
698        );
699    }
700
701    if registry.settings.auto_daemon {
702        report.push(
703            "Background scheduler",
704            ensure_daemon(registry.settings.check_interval_days),
705        );
706    } else {
707        report.push(
708            "Background scheduler",
709            Outcome::Skipped(
710                "`auto_daemon` is false — enable with `devp daemon install`".to_string(),
711            ),
712        );
713    }
714
715    report
716}
717
718// ── VS Code extension ────────────────────────────────────────────────────────
719
720/// Marker recording that the extension question was asked (or found already answered by
721/// an existing install). One file, no content: the offer is made once ever, whatever
722/// the answer was — a declined install must not be re-litigated on every upgrade.
723const VSCODE_OFFER_STAMP: &str = "vscode-ext-offered";
724
725/// A VS Code-compatible editor found on PATH.
726struct EditorCli {
727    /// The command to invoke — on Windows the `.cmd` launcher, because the entry on
728    /// PATH is a batch file, not an `.exe`, and `Command::new("code")` would miss it.
729    cli: String,
730    /// The editor's name as a person knows it, for the prompt and per-editor results.
731    label: &'static str,
732}
733
734/// Every VS Code-compatible editor on PATH, in the order listed here.
735///
736/// All of these forks keep the upstream CLI protocol (`--version`, `--list-extensions`,
737/// `--install-extension`), so one code path drives them all. What differs is the
738/// registry each one resolves an extension ID against: VS Code uses the Microsoft
739/// Marketplace, VSCodium/Windsurf/Positron/Kiro use OpenVSX, Cursor runs its own
740/// mirror. An ID install can therefore fail on a fork whose registry does not carry
741/// the extension yet — which is why the installer falls back to the `.vsix` from the
742/// GitHub release, the artifact every registry copy is built from.
743fn detect_vscode_editors() -> Vec<EditorCli> {
744    const CANDIDATES: &[(&str, &str)] = &[
745        ("code", "VS Code"),
746        ("code-insiders", "VS Code Insiders"),
747        ("codium", "VSCodium"),
748        ("codium-insiders", "VSCodium Insiders"),
749        ("cursor", "Cursor"),
750        ("windsurf", "Windsurf"),
751        ("positron", "Positron"),
752        ("kiro", "Kiro"),
753    ];
754    CANDIDATES
755        .iter()
756        .filter_map(|(name, label)| {
757            let cli = if cfg!(windows) {
758                format!("{name}.cmd")
759            } else {
760                (*name).to_string()
761            };
762            let responds = std::process::Command::new(&cli)
763                .arg("--version")
764                .stdin(std::process::Stdio::null())
765                .stdout(std::process::Stdio::null())
766                .stderr(std::process::Stdio::null())
767                .status()
768                .map(|s| s.success())
769                .unwrap_or(false);
770            responds.then_some(EditorCli { cli, label })
771        })
772        .collect()
773}
774
775fn vscode_extension_installed(cli: &str) -> bool {
776    std::process::Command::new(cli)
777        .arg("--list-extensions")
778        .stdin(std::process::Stdio::null())
779        .output()
780        .map(|out| {
781            String::from_utf8_lossy(&out.stdout).lines().any(|line| {
782                line.trim()
783                    .eq_ignore_ascii_case(constants::VSCODE_EXTENSION_ID)
784            })
785        })
786        .unwrap_or(false)
787}
788
789/// Download the `.vsix` attached to the latest GitHub release into the temp directory.
790///
791/// The release asset is the source of truth for the extension — the Marketplace and
792/// OpenVSX listings are built from it — so when an editor's registry cannot resolve the
793/// ID (a fork whose registry does not carry the extension), installing the release file
794/// directly gets the same bits through a channel every fork supports. Editors update a
795/// `.vsix`-installed extension from their registry once a newer listed version appears,
796/// so this install self-heals into the normal update flow.
797fn download_release_vsix() -> Option<std::path::PathBuf> {
798    use std::time::Duration;
799
800    let fetch = |url: &str| {
801        ureq::get(url)
802            .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
803            .header("Accept", "application/vnd.github+json")
804            .config()
805            .timeout_global(Some(Duration::from_secs(30)))
806            .build()
807            .call()
808    };
809
810    let body = fetch(constants::LATEST_RELEASE_API_URL)
811        .ok()?
812        .body_mut()
813        .read_to_string()
814        .ok()?;
815    let json: serde_json::Value = serde_json::from_str(&body).ok()?;
816    let asset = json.get("assets")?.as_array()?.iter().find_map(|asset| {
817        let name = asset.get("name")?.as_str()?;
818        if !name.ends_with(".vsix") {
819            return None;
820        }
821        let url = asset.get("browser_download_url")?.as_str()?;
822        Some((name.to_string(), url.to_string()))
823    })?;
824
825    let bytes = fetch(&asset.1).ok()?.body_mut().read_to_vec().ok()?;
826    let path = std::env::temp_dir().join(&asset.0);
827    fs::write(&path, bytes).ok()?;
828    Some(path)
829}
830
831/// `<cli> --install-extension <arg>`, surfacing the editor's own output.
832fn run_install(cli: &str, arg: &str) -> bool {
833    std::process::Command::new(cli)
834        .args(["--install-extension", arg])
835        .stdin(std::process::Stdio::null())
836        .status()
837        .map(|s| s.success())
838        .unwrap_or(false)
839}
840
841/// Offer to install the editor extension, once ever, when a VS Code-family editor is
842/// present.
843///
844/// This asks rather than installs because the editor is not dev-prune's territory the
845/// way its own config directory is. Every gate below is a way of making sure a person
846/// is actually there to answer: no marker yet, not a CI runner or container, both ends
847/// of the terminal attached. When no editor is found nothing is written, so installing
848/// one later and re-running `devp setup` still gets the one offer.
849pub fn offer_vscode_extension() {
850    use std::io::{IsTerminal, Write};
851
852    let Ok(config_dir) = Registry::config_dir() else {
853        return;
854    };
855    if config_dir.join(VSCODE_OFFER_STAMP).exists() {
856        return;
857    }
858    if no_auto_setup_requested()
859        || unattended_environment().is_some()
860        || !std::io::stdin().is_terminal()
861        || !std::io::stdout().is_terminal()
862    {
863        return;
864    }
865    let editors = detect_vscode_editors();
866    if editors.is_empty() {
867        return;
868    }
869
870    let write_marker = || {
871        let _ = fs::create_dir_all(&config_dir);
872        let _ = fs::write(config_dir.join(VSCODE_OFFER_STAMP), "");
873    };
874
875    let missing: Vec<&EditorCli> = editors
876        .iter()
877        .filter(|e| !vscode_extension_installed(&e.cli))
878        .collect();
879    if missing.is_empty() {
880        write_marker();
881        return;
882    }
883
884    let names = missing
885        .iter()
886        .map(|e| e.label)
887        .collect::<Vec<_>>()
888        .join(", ");
889    println!();
890    print!(
891        "{names} detected — install the dev-prune extension? It validates .devprune.json and shows reclaimable space in the status bar. [Y/n] "
892    );
893    let _ = std::io::stdout().flush();
894    let mut answer = String::new();
895    if std::io::stdin().read_line(&mut answer).is_err() {
896        return;
897    }
898    write_marker();
899
900    if matches!(answer.trim().to_lowercase().as_str(), "n" | "no") {
901        output::print_info(&format!(
902            "Skipped. Install it any time with `{} --install-extension {}`.",
903            missing[0].cli,
904            constants::VSCODE_EXTENSION_ID
905        ));
906        return;
907    }
908
909    // Fetched at most once, shared by every editor whose registry install fails.
910    let mut release_vsix: Option<Option<std::path::PathBuf>> = None;
911    for editor in &missing {
912        // The editor's own registry first: that install is the one the editor keeps
913        // up to date by itself.
914        if run_install(&editor.cli, constants::VSCODE_EXTENSION_ID) {
915            output::print_success(&format!("{}: extension installed.", editor.label));
916            continue;
917        }
918        // A fork whose registry does not carry the extension — install the `.vsix`
919        // from the GitHub release instead.
920        let vsix = release_vsix.get_or_insert_with(download_release_vsix);
921        match vsix {
922            Some(path) if run_install(&editor.cli, &path.to_string_lossy()) => {
923                output::print_success(&format!(
924                    "{}: extension installed from the GitHub release .vsix.",
925                    editor.label
926                ));
927            }
928            _ => {
929                output::print_warning(&format!(
930                    "{}: could not install it from here. Search the Extensions view for \"dev-prune\", or run `{} --install-extension {}` yourself.",
931                    editor.label,
932                    editor.cli,
933                    constants::VSCODE_EXTENSION_ID
934                ));
935            }
936        }
937    }
938}
939
940/// Record that a pass completed for this version.
941fn write_stamp_in(config_dir: &std::path::Path) {
942    let _ = fs::create_dir_all(config_dir);
943    let _ = fs::write(config_dir.join(STAMP_FILE), constants::VERSION);
944}
945
946fn write_stamp() {
947    if let Ok(dir) = Registry::config_dir() {
948        write_stamp_in(&dir);
949    }
950}
951
952fn setup_is_due_in(config_dir: &std::path::Path) -> bool {
953    !fs::read_to_string(config_dir.join(STAMP_FILE))
954        .is_ok_and(|stamp| stamp.trim() == constants::VERSION)
955}
956
957/// Whether the unattended pass is due: a fresh install, or the first run after an upgrade.
958pub fn setup_is_due() -> bool {
959    Registry::config_dir()
960        .map(|dir| setup_is_due_in(&dir))
961        .unwrap_or(false)
962}
963
964/// The unattended pass, run at most once per installed version.
965///
966/// Called at the top of every command that a human typed. It is deliberately not called
967/// for the Git hook's `link --quiet` or the scheduler's `run --daemon`: those run without
968/// a terminal, and an integration pass that nobody can see is one nobody can refuse.
969pub fn auto_setup_if_due() {
970    if !setup_is_due() {
971        first_run_config_review();
972        return;
973    }
974
975    let Ok(registry) = Registry::load() else {
976        return;
977    };
978    let Some(report) = ensure_integrations_if_enabled(&registry) else {
979        // Suppressed. Stamp anyway, so a machine that opted out does not re-decide
980        // this on every single command.
981        write_stamp();
982        crate::commands::config::skip_config_review();
983        return;
984    };
985    if report.changed_anything() || report.needs_attention() {
986        output::print_header("dev-prune setup");
987        report.print(false);
988        if report.changed_anything() {
989            output::print_info(
990                "Run `devp setup --status` to review these, or `devp uninstall` to remove them.",
991            );
992        }
993        println!();
994    }
995    write_stamp();
996    first_run_config_review();
997}
998
999/// Put the defaults in front of the user, once, on a fresh install.
1000///
1001/// Separate from the integration stamp on purpose. The integrations are re-checked after
1002/// every upgrade; the settings are not — being asked to reconfirm `idle_days` on each new
1003/// version would be a nuisance, and the marker only disappears when the config directory
1004/// does.
1005///
1006/// Every condition here is a way of asking "is there a person reading this?", because the
1007/// alternative to asking is a prompt written into a log nobody will read, on a run that
1008/// then blocks forever waiting for an answer.
1009fn first_run_config_review() {
1010    if !crate::commands::config::config_review_is_due() {
1011        return;
1012    }
1013
1014    use std::io::IsTerminal;
1015    if unattended_environment().is_some()
1016        || !std::io::stdin().is_terminal()
1017        || !std::io::stdout().is_terminal()
1018    {
1019        crate::commands::config::skip_config_review();
1020        return;
1021    }
1022
1023    // Any error here is the wizard's own reporting; the command the user actually typed
1024    // still runs. A failed walkthrough must not become a failed `devp status`.
1025    if let Err(e) = crate::commands::config::run_wizard() {
1026        output::print_warning(&format!("Could not run the first-run setup ({e:#})."));
1027        crate::commands::config::skip_config_review();
1028    }
1029    // Same first run, same person already answering questions — the one moment the
1030    // extension offer is a courtesy rather than an interruption.
1031    offer_vscode_extension();
1032    println!();
1033}
1034
1035/// Invalidate the stamp so the next human-run command performs a pass.
1036///
1037/// `uninstall` calls this in reverse — it writes the current stamp — so that removing the
1038/// integrations is not immediately undone by the next command.
1039pub fn suppress_next_auto_setup() {
1040    write_stamp();
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045    use super::*;
1046
1047    #[test]
1048    fn a_report_with_only_present_items_is_silent() {
1049        let mut report = SetupReport::default();
1050        report.push("a", Outcome::AlreadyPresent);
1051        assert!(!report.changed_anything());
1052        assert!(!report.needs_attention());
1053    }
1054
1055    #[test]
1056    fn skipped_and_failed_both_ask_for_attention() {
1057        let mut skipped = SetupReport::default();
1058        skipped.push("a", Outcome::Skipped("no git".into()));
1059        assert!(skipped.needs_attention());
1060        assert!(!skipped.changed_anything());
1061
1062        let mut failed = SetupReport::default();
1063        failed.push("a", Outcome::Failed("boom".into()));
1064        assert!(failed.needs_attention());
1065    }
1066
1067    #[test]
1068    fn an_install_counts_as_a_change() {
1069        let mut report = SetupReport::default();
1070        report.push("a", Outcome::Installed);
1071        assert!(report.changed_anything());
1072    }
1073
1074    #[test]
1075    fn the_skill_export_lands_in_the_config_directory() {
1076        let dir = tempfile::TempDir::new().unwrap();
1077        assert_eq!(ensure_skill_file_in(dir.path()), Outcome::Installed);
1078        // A second pass finds byte-identical content and leaves it alone.
1079        assert_eq!(ensure_skill_file_in(dir.path()), Outcome::AlreadyPresent);
1080        let written = fs::read_to_string(dir.path().join("SKILL.md")).unwrap();
1081        assert_eq!(written, EMBEDDED_SKILL_MD);
1082    }
1083
1084    #[test]
1085    fn a_stale_skill_export_is_rewritten() {
1086        // An upgrade must not leave the previous version's instructions on disk.
1087        let dir = tempfile::TempDir::new().unwrap();
1088        fs::write(dir.path().join("SKILL.md"), "# an older version").unwrap();
1089        assert_eq!(ensure_skill_file_in(dir.path()), Outcome::Installed);
1090        let written = fs::read_to_string(dir.path().join("SKILL.md")).unwrap();
1091        assert_eq!(written, EMBEDDED_SKILL_MD);
1092    }
1093
1094    #[test]
1095    fn agent_skills_install_only_into_agent_homes_that_exist() {
1096        let home = tempfile::TempDir::new().unwrap();
1097        assert!(
1098            agent_skill_roots_under(home.path()).is_empty(),
1099            "a machine without an agent must detect nothing"
1100        );
1101
1102        fs::create_dir_all(home.path().join(constants::CLAUDE_HOME_DIR)).unwrap();
1103        let roots = agent_skill_roots_under(home.path());
1104        assert_eq!(roots.len(), 1);
1105
1106        assert_eq!(ensure_agent_skills_at(&roots), Outcome::Installed);
1107        let installed = home
1108            .path()
1109            .join(constants::CLAUDE_HOME_DIR)
1110            .join(constants::AGENT_SKILLS_SUBDIR)
1111            .join(constants::APP_NAME)
1112            .join("SKILL.md");
1113        assert_eq!(fs::read_to_string(&installed).unwrap(), EMBEDDED_SKILL_MD);
1114
1115        // A second pass finds it current and leaves it alone.
1116        assert_eq!(ensure_agent_skills_at(&roots), Outcome::AlreadyPresent);
1117    }
1118
1119    #[test]
1120    fn no_detected_agent_is_a_skip_not_a_failure() {
1121        assert!(matches!(ensure_agent_skills_at(&[]), Outcome::Skipped(_)));
1122    }
1123
1124    #[test]
1125    fn the_stamp_gates_the_unattended_pass() {
1126        let dir = tempfile::TempDir::new().unwrap();
1127        assert!(setup_is_due_in(dir.path()), "a fresh install is due");
1128        write_stamp_in(dir.path());
1129        assert!(
1130            !setup_is_due_in(dir.path()),
1131            "the same version is not due twice"
1132        );
1133        fs::write(dir.path().join(STAMP_FILE), "0.0.1").unwrap();
1134        assert!(setup_is_due_in(dir.path()), "an upgrade is due again");
1135    }
1136
1137    /// The alias must never be written with a copy while it already exists.
1138    ///
1139    /// A hard link and its target share one inode, so `fs::copy` onto the alias empties
1140    /// the binary it was copied from. This reproduces the exact shape of that bug — link
1141    /// first, then ask for the alias again — and asserts the original still has its
1142    /// bytes. The real failure was silent: a zero-byte executable that macOS runs
1143    /// through `/bin/sh`, which exits 0 and prints nothing.
1144    #[test]
1145    fn refreshing_an_alias_that_is_a_hard_link_does_not_empty_the_binary() {
1146        let dir = tempfile::TempDir::new().unwrap();
1147        let binary = dir.path().join("dev-prune");
1148        let alias = dir.path().join("devp");
1149        fs::write(&binary, vec![b'M'; 4096]).unwrap();
1150
1151        if fs::hard_link(&binary, &alias).is_err() {
1152            return; // Filesystem without hard links; the hazard cannot arise.
1153        }
1154
1155        // What `ensure_alias` does when its `hard_link` loses the race: the alias is
1156        // already there, so it must stop rather than fall through to the copy.
1157        assert!(fs::hard_link(&binary, &alias).is_err(), "EEXIST expected");
1158        assert!(alias.exists(), "the guard's condition");
1159
1160        assert_eq!(
1161            fs::metadata(&binary).unwrap().len(),
1162            4096,
1163            "the running binary was truncated by refreshing its own alias"
1164        );
1165    }
1166
1167    /// The on-disk file name for one of the pair, on this platform.
1168    fn exe_name(stem: &str) -> String {
1169        if cfg!(windows) {
1170            format!("{stem}.exe")
1171        } else {
1172            stem.to_string()
1173        }
1174    }
1175
1176    #[test]
1177    fn dev_prune_creates_devp_beside_it() {
1178        let dir = tempfile::TempDir::new().unwrap();
1179        let canonical = dir.path().join(exe_name("dev-prune"));
1180        fs::write(&canonical, "the binary").unwrap();
1181
1182        assert_eq!(ensure_twin_of(&canonical, dir.path()), Outcome::Installed);
1183        let alias = dir.path().join(exe_name("devp"));
1184        assert!(alias.is_file(), "`devp` was not created");
1185        assert_eq!(fs::read_to_string(&alias).unwrap(), "the binary");
1186    }
1187
1188    /// The pair has to be recoverable from either side.
1189    ///
1190    /// Deleting `dev-prune` and leaving `devp` is not hypothetical: an antivirus
1191    /// quarantine, a half-finished uninstall, or a `Remove-Item` aimed at one name all
1192    /// produce it. Before this, `devp setup` reported the alias already present and did
1193    /// nothing, because the only direction it knew how to repair was the other one.
1194    #[test]
1195    fn devp_restores_a_missing_dev_prune() {
1196        let dir = tempfile::TempDir::new().unwrap();
1197        let alias = dir.path().join(exe_name("devp"));
1198        fs::write(&alias, "the binary").unwrap();
1199
1200        assert_eq!(ensure_twin_of(&alias, dir.path()), Outcome::Installed);
1201        let canonical = dir.path().join(exe_name("dev-prune"));
1202        assert!(canonical.is_file(), "`dev-prune` was not put back");
1203        assert_eq!(fs::read_to_string(&canonical).unwrap(), "the binary");
1204    }
1205
1206    /// `devp` may create `dev-prune`, never overwrite it.
1207    ///
1208    /// Repairing in both directions opens a downgrade: an upgrade replaces `dev-prune`
1209    /// first and can then fail on a `devp` that is running, which leaves the alias holding
1210    /// the *older* binary. If the alias were allowed to refresh its twin from there, the
1211    /// next `devp setup` would quietly reinstall the version the user just upgraded away
1212    /// from — and report it as a repair.
1213    #[test]
1214    fn devp_does_not_overwrite_an_existing_dev_prune() {
1215        let dir = tempfile::TempDir::new().unwrap();
1216        let alias = dir.path().join(exe_name("devp"));
1217        let canonical = dir.path().join(exe_name("dev-prune"));
1218        fs::write(&alias, "the previous version").unwrap();
1219        fs::write(&canonical, "the version just upgraded to").unwrap();
1220
1221        assert_eq!(
1222            ensure_twin_of(&alias, dir.path()),
1223            Outcome::AlreadyPresent,
1224            "`devp` must leave an existing `dev-prune` alone"
1225        );
1226        assert_eq!(
1227            fs::read_to_string(&canonical).unwrap(),
1228            "the version just upgraded to",
1229            "`devp` downgraded the binary it was supposed to leave alone"
1230        );
1231    }
1232
1233    #[test]
1234    fn versions_parse_strictly_or_not_at_all() {
1235        assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
1236        assert_eq!(parse_version("10.0.0"), Some((10, 0, 0)));
1237        // Anything this project does not publish must answer None, because a None
1238        // means "replace the copy" and a mis-parse would order versions wrongly.
1239        assert_eq!(parse_version("1.2"), None);
1240        assert_eq!(parse_version("1.2.3.4"), None);
1241        assert_eq!(parse_version("1.2.3-rc1"), None);
1242        assert_eq!(parse_version("dev-prune"), None);
1243        // The version this binary was built with has to be parseable, or the refresh
1244        // logic can never decide anything.
1245        assert!(parse_version(constants::VERSION).is_some());
1246    }
1247
1248    #[test]
1249    fn ordering_of_version_triples_matches_semver() {
1250        assert!(parse_version("1.1.0") > parse_version("1.0.9"));
1251        assert!(parse_version("2.0.0") > parse_version("1.99.99"));
1252        assert!(parse_version("1.0.10") > parse_version("1.0.9"));
1253    }
1254
1255    #[test]
1256    fn the_exported_skill_is_the_one_the_binary_was_built_with() {
1257        // `SKILL.md` is embedded, so a doc edit ships only if the binary is rebuilt.
1258        // Guard the two properties every consumer of it depends on.
1259        assert!(EMBEDDED_SKILL_MD.starts_with("---"), "needs frontmatter");
1260        assert!(
1261            !EMBEDDED_SKILL_MD.contains("file:///"),
1262            "SKILL.md is written to every user's machine — it must not contain \
1263             absolute paths from the author's checkout"
1264        );
1265    }
1266}