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