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_ok() {
234        return;
235    }
236    // Unconditionally, because `fs::copy` creates and truncates its destination before it
237    // writes a byte: a copy that fails partway — disk full, the volume going away, a
238    // permission revoked mid-write — leaves a half-written `dev-prune.new` behind. Nothing
239    // else ever removes it. `uninstall`'s sweep knows `*.exe.old`, the debris an update
240    // leaves, and has never known `*.new`, so the file would sit there until someone
241    // noticed it by hand.
242    let _ = fs::remove_file(&staging);
243}
244
245/// The `major.minor.patch` a binary reports for itself, if it can.
246pub(crate) fn binary_version(exe: &std::path::Path) -> Option<(u64, u64, u64)> {
247    let output = crate::spawn::command(exe).arg("--version").output().ok()?;
248    if !output.status.success() {
249        return None;
250    }
251    version_in_output(&String::from_utf8_lossy(&output.stdout))
252}
253
254/// The first `x.y.z` in a `--version` output, with or without the `v` this CLI prints.
255///
256/// Split out so it can be tested against real output. It has to accept `v1.7.0` because
257/// that is the only spelling `print_version_info` produces — the banner ends `v1.7.0`
258/// and the line under it reads `dev-prune (devp) v1.7.0`, and a bare `1.7.0` appears
259/// nowhere. Parsing the tokens without stripping that `v` answered `None` for every real
260/// dev-prune on the machine, and `doctor`'s "Other copies" check reads `None` as "not a
261/// dev-prune at all" — so it reported "none on PATH running a different version" however
262/// many stale copies were sitting there.
263fn version_in_output(text: &str) -> Option<(u64, u64, u64)> {
264    text.split_whitespace()
265        .find_map(|token| parse_version(token.strip_prefix('v').unwrap_or(token)))
266}
267
268/// Parse `x.y.z` into an orderable triple. Anything else — including the pre-release
269/// and build suffixes this project never publishes — answers `None`.
270pub(crate) fn parse_version(text: &str) -> Option<(u64, u64, u64)> {
271    let mut parts = text.split('.');
272    let triple = (
273        parts.next()?.parse().ok()?,
274        parts.next()?.parse().ok()?,
275        parts.next()?.parse().ok()?,
276    );
277    parts.next().is_none().then_some(triple)
278}
279
280/// Keep `dev-prune` and `devp` beside each other, whichever of the two is running.
281///
282/// The pair is one binary under two names, and either one can be the survivor. An upgrade
283/// that could not replace a running `devp` leaves a stale alias; an antivirus quarantine,
284/// a half-finished uninstall or a `Remove-Item` aimed at the wrong name leaves only
285/// `devp`. So this restores *the other* name in whichever direction is missing, rather
286/// than only ever creating `devp` — running either one puts the pair back.
287pub fn ensure_alias() -> Outcome {
288    // WinGet, Scoop and Homebrew each install into a directory they version and replace
289    // whole on upgrade, and each ships both names in the package itself — so there is
290    // nothing to create here, and creating it would be actively wrong twice over. The
291    // twin would be orphaned by the next upgrade, still on PATH, still running the old
292    // release; and writing a second executable beside a freshly downloaded unsigned
293    // binary on its first run is a behavioural malware signature. WinGet's own
294    // post-install validation flags exactly that, which is how this was found.
295    if crate::channel::Channel::detect().replaces_its_directory() {
296        return Outcome::AlreadyPresent;
297    }
298    let Ok(current_exe) = std::env::current_exe() else {
299        return Outcome::Failed("could not locate the running executable".to_string());
300    };
301    let Some(parent_dir) = current_exe.parent() else {
302        return Outcome::Failed("the running executable has no parent directory".to_string());
303    };
304
305    ensure_twin_of(&current_exe, parent_dir)
306}
307
308/// Whether a twin whose content differs from the running binary is the older of the two,
309/// and so safe to replace.
310///
311/// Content inequality says the pair differ, never which one is the upgrade. `dev-prune` is
312/// *usually* the newer — installers write it first and upgrades replace it first — but
313/// "usually" is not "always", and the direction gate in [`ensure_twin_of`] trusted it
314/// absolutely. An older `dev-prune` restored from a backup, or run out of a
315/// package-manager cache, would delete a newer `devp` and hard-link its own older content
316/// over it: a silent downgrade of the name the documentation tells people to type, reached
317/// through `devp doctor --fix` of all things. So ask the twin its version, exactly as
318/// [`refresh_managed_copy_if_stale`] does, and leave it alone unless it is genuinely
319/// behind. A copy that cannot state a version at all is not a working build of this CLI,
320/// so it is still replaced.
321///
322/// Takes the two versions rather than the two paths so the rule can be tested without a
323/// pair of real binaries that report different versions.
324fn twin_is_stale(theirs: Option<(u64, u64, u64)>, ours: Option<(u64, u64, u64)>) -> bool {
325    !matches!((theirs, ours), (Some(theirs), Some(ours)) if theirs >= ours)
326}
327
328/// The half of [`ensure_alias`] that takes its paths as arguments, so tests can drive both
329/// directions without being the binary they are testing.
330fn ensure_twin_of(current_exe: &std::path::Path, parent_dir: &std::path::Path) -> Outcome {
331    let running_as_alias = current_exe
332        .file_stem()
333        .and_then(|s| s.to_str())
334        .is_some_and(|stem| stem == "devp");
335
336    // `dev-prune` is the canonical name, and only it may overwrite its twin.
337    //
338    // Installers write `dev-prune` first and upgrades replace it first, so a stale `devp`
339    // is worth replacing — otherwise it silently runs the previous version. The reverse is
340    // not safe as a rule: an upgrade that replaced `dev-prune` and then failed on a running
341    // `devp` leaves exactly the state where the alias is the *older* binary, and refreshing
342    // from there would quietly reinstall the version the user just upgraded away from. So
343    // `devp` may only create a `dev-prune` that is missing outright.
344    //
345    // Direction is necessary but not sufficient: see [`twin_is_stale`] for the case where
346    // `dev-prune` itself is the older of the pair.
347    let (twin_name, may_refresh) = if running_as_alias {
348        (
349            if cfg!(windows) {
350                "dev-prune.exe"
351            } else {
352                "dev-prune"
353            },
354            false,
355        )
356    } else {
357        (if cfg!(windows) { "devp.exe" } else { "devp" }, true)
358    };
359    let twin_exe = parent_dir.join(twin_name);
360
361    if twin_exe.exists() {
362        if !may_refresh || same_contents(&twin_exe, current_exe) {
363            return Outcome::AlreadyPresent;
364        }
365        if !twin_is_stale(binary_version(&twin_exe), parse_version(constants::VERSION)) {
366            return Outcome::AlreadyPresent;
367        }
368        // Replacing a running executable fails on Windows; that is fine, the alias is
369        // simply refreshed by the next invocation that is not itself `devp`.
370        if fs::remove_file(&twin_exe).is_err() {
371            return Outcome::Skipped(format!(
372                "`{twin_name}` is in use and could not be refreshed — re-run `devp setup` \
373                 from a terminal that is not running it"
374            ));
375        }
376    }
377
378    if fs::hard_link(current_exe, &twin_exe).is_ok() {
379        return Outcome::Installed;
380    }
381
382    // The copy is the fallback for filesystems without hard links — but it must never
383    // run when the alias already exists, because the reason `hard_link` usually fails is
384    // that another process created it a moment ago, as a hard link to this very
385    // executable. `fs::copy` opens its destination with O_TRUNC, and truncating a hard
386    // link truncates the shared inode: the copy would empty the running binary and then
387    // copy zero bytes from it.
388    //
389    // That is not hypothetical. It is what turned every macOS CI run red. The 28
390    // integration tests launch at once, one wins the link, the losers fall through to
391    // here, and `target/debug/dev-prune` becomes a zero-byte file. macOS `posix_spawn`
392    // answers ENOEXEC by handing the file to `/bin/sh`, so every later invocation
393    // "succeeded" with exit 0 and printed nothing — for two hours the tests looked like
394    // 27 unrelated assertion failures.
395    if twin_exe.exists() {
396        return Outcome::AlreadyPresent;
397    }
398
399    // Stage beside and rename into place: copied straight onto the final name, the
400    // alias would exist-but-be-incomplete for the length of the copy, and a `devp`
401    // typed in that window executes a torn binary.
402    let staging = twin_exe.with_extension("new");
403    if fs::copy(current_exe, &staging).is_ok() && fs::rename(&staging, &twin_exe).is_ok() {
404        return Outcome::Installed;
405    }
406    let _ = fs::remove_file(&staging);
407    if twin_exe.exists() {
408        // A concurrent invocation won the rename; its alias serves exactly as well.
409        return Outcome::AlreadyPresent;
410    }
411    Outcome::Failed(format!(
412        "could not create `{}`",
413        output::clean_path(&twin_exe)
414    ))
415}
416
417/// Sameness test for two executables, cheap in the common case.
418///
419/// A mismatched length answers without reading either file. Equal length still reads
420/// both: an equal mtime used to short-circuit the check, on the assumption that only a
421/// hard link (or a copy that happened to preserve it) could produce one, but two
422/// unrelated files of the same length can share a modified-time by coincidence, and
423/// that shortcut treated them as identical without ever inspecting a byte.
424pub(crate) fn same_contents(a: &std::path::Path, b: &std::path::Path) -> bool {
425    let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) else {
426        return false;
427    };
428    if ma.len() != mb.len() {
429        return false;
430    }
431    match (fs::read(a), fs::read(b)) {
432        (Ok(ca), Ok(cb)) => ca == cb,
433        _ => false,
434    }
435}
436
437/// Path that `devp skill` and this module export `SKILL.md` to.
438pub fn skill_path() -> Result<PathBuf> {
439    Ok(Registry::config_dir()?.join("SKILL.md"))
440}
441
442/// Export the bundled `SKILL.md` so AI assistants have something to read.
443///
444/// Rewritten whenever it differs from the embedded copy, since an upgrade that changes
445/// the skill must not leave the previous version's instructions on disk.
446pub fn ensure_skill_file() -> Outcome {
447    match Registry::config_dir() {
448        Ok(dir) => ensure_skill_file_in(&dir),
449        Err(_) => Outcome::Failed("could not determine the config directory".to_string()),
450    }
451}
452
453fn ensure_skill_file_in(config_dir: &std::path::Path) -> Outcome {
454    let target = config_dir.join("SKILL.md");
455
456    if fs::read_to_string(&target).is_ok_and(|current| current == EMBEDDED_SKILL_MD) {
457        return Outcome::AlreadyPresent;
458    }
459
460    let _ = fs::create_dir_all(config_dir);
461    match fs::write(&target, EMBEDDED_SKILL_MD) {
462        Ok(()) => Outcome::Installed,
463        Err(e) => Outcome::Failed(format!(
464            "could not write {}: {e}",
465            output::clean_path(&target)
466        )),
467    }
468}
469
470/// The per-skill directories of AI coding agents that are installed under `home`.
471///
472/// Detection only — an agent's home directory is created by the agent, never by this
473/// pass. Today that is Claude Code, whose Agent Skills live at
474/// `~/.claude/skills/<name>/SKILL.md`. Assistants without an on-disk skill format get
475/// the onboarding prompt from `devp skill` instead.
476fn agent_skill_roots_under(home: &std::path::Path) -> Vec<PathBuf> {
477    let mut roots = Vec::new();
478    let claude = home.join(constants::CLAUDE_HOME_DIR);
479    if claude.is_dir() {
480        roots.push(
481            claude
482                .join(constants::AGENT_SKILLS_SUBDIR)
483                .join(constants::APP_NAME),
484        );
485    }
486    roots
487}
488
489/// The agent skill directories on this machine. Empty when no agent is installed.
490pub fn agent_skill_roots() -> Vec<PathBuf> {
491    dirs::home_dir()
492        .map(|home| agent_skill_roots_under(&home))
493        .unwrap_or_default()
494}
495
496/// Install the skill into every detected agent's skills directory.
497pub fn ensure_agent_skills() -> Outcome {
498    ensure_agent_skills_at(&agent_skill_roots())
499}
500
501/// Every managed copy of `SKILL.md` that is not the one this binary carries.
502///
503/// The copies are refreshed by [`ensure_integrations`], which runs only while
504/// auto-setup is on. With it off, an upgrade leaves the previous release's instructions
505/// sitting in the agent's skills directory, and the agent goes on describing flags that
506/// no longer exist — confidently, because nothing told it otherwise.
507pub fn stale_skill_copies() -> Vec<PathBuf> {
508    let mut copies: Vec<PathBuf> = skill_path().into_iter().collect();
509    copies.extend(agent_skill_roots().into_iter().map(|r| r.join("SKILL.md")));
510    stale_among(copies)
511}
512
513/// The ones of `copies` that exist and differ from the embedded skill.
514///
515/// A path that is not there is not stale — it is a copy this machine never had, and
516/// nothing that refreshes may create it.
517fn stale_among(copies: Vec<PathBuf>) -> Vec<PathBuf> {
518    copies
519        .into_iter()
520        .filter(|p| fs::read_to_string(p).is_ok_and(|current| current != EMBEDDED_SKILL_MD))
521        .collect()
522}
523
524/// Rewrite copies that are already on disk, and only those.
525///
526/// This is the half of [`ensure_skill_copies`] that is safe to run on a machine which
527/// turned auto-setup off: replacing the contents of a file that machine already
528/// consented to is maintenance, not a new integration.
529fn refresh_stale(stale: Vec<PathBuf>) {
530    for path in stale {
531        let _ = fs::write(path, EMBEDDED_SKILL_MD);
532    }
533}
534
535/// Rewrite every managed copy of `SKILL.md` from the embedded one.
536///
537/// Both copies, not just the export: the one an assistant actually reads is the one in
538/// its own skills directory, so repairing the export alone would report success and
539/// leave the stale instructions in the only place that matters.
540pub fn ensure_skill_copies() -> Outcome {
541    let mut installed = false;
542    for outcome in [ensure_skill_file(), ensure_agent_skills()] {
543        match outcome {
544            Outcome::Installed => installed = true,
545            // No agent on this machine is not a failure to repair.
546            Outcome::AlreadyPresent | Outcome::Skipped(_) => {}
547            failed => return failed,
548        }
549    }
550    if installed {
551        Outcome::Installed
552    } else {
553        Outcome::AlreadyPresent
554    }
555}
556
557fn ensure_agent_skills_at(roots: &[PathBuf]) -> Outcome {
558    if roots.is_empty() {
559        return Outcome::Skipped(
560            "no AI agent skills directory was found — `devp skill` prints import prompts instead"
561                .to_string(),
562        );
563    }
564    let mut installed = false;
565    for root in roots {
566        match ensure_skill_file_in(root) {
567            Outcome::Installed => installed = true,
568            Outcome::AlreadyPresent => {}
569            other => return other,
570        }
571    }
572    if installed {
573        Outcome::Installed
574    } else {
575        Outcome::AlreadyPresent
576    }
577}
578
579/// Make the managed pair reachable from a fresh shell.
580///
581/// This is the step that lets `pip install dev-prune` in a virtualenv survive the
582/// virtualenv: the binaries pip placed vanish with the environment, but the managed
583/// copy under `<config>/bin` does not, and after this step it is the one a new
584/// terminal finds. See [`crate::pathenv`] for what "reachable" means per platform.
585pub fn ensure_command_on_path() -> Outcome {
586    let managed = stable_exe_path();
587    let is_managed_copy =
588        managed_exe_path().is_ok_and(|expected| expected == managed) && managed.is_file();
589    if !is_managed_copy {
590        // No managed copy exists and none could be created — under `cargo test` the
591        // running executable is the harness, and cloning that would be wrong. There is
592        // nothing durable to put on PATH.
593        return Outcome::Skipped("no managed copy of the binary exists to put on PATH".to_string());
594    }
595    let Some(bin_dir) = managed.parent() else {
596        return Outcome::Failed("the managed binary has no parent directory".to_string());
597    };
598    // `devp` has to sit beside it, or the PATH entry only ever finds `dev-prune`.
599    if let Outcome::Failed(why) = ensure_twin_of(&managed, bin_dir) {
600        return Outcome::Failed(why);
601    }
602    crate::pathenv::ensure_reachable(bin_dir)
603}
604
605/// Write the icon assets and register `*.devprune.json` with the OS file manager.
606///
607/// Part of the automatic pass rather than a separate errand, because "the config file has
608/// an icon" is not a thing anybody thinks to go and ask for. Everything it writes lives
609/// under the config directory and the user's own XDG data directory, `devp uninstall`
610/// removes all of it, and it touches no editor settings, no PATH and no shell profile —
611/// so there is nothing here that needs to be asked about first.
612///
613/// Unlike the hooks and the scheduler, this has no opt-out switch of its own. Files
614/// dropped into the user's data directory are not a background process and not a change
615/// in behaviour; `auto_setup` already covers "install nothing at all".
616fn ensure_icons() -> Outcome {
617    if crate::commands::icon::is_registered() {
618        return Outcome::AlreadyPresent;
619    }
620    match crate::commands::icon::sync_app_directory() {
621        Ok(()) => Outcome::Installed,
622        Err(e) => Outcome::Failed(format!("{e:#}")),
623    }
624}
625
626/// Install the global Git hooks, unless git is absent or the slot belongs to someone else.
627///
628/// `chain` is `auto_hooks_chain`: with it on, a slot that belongs to husky is not a
629/// reason to skip, because dev-prune can install in front and forward every hook back.
630pub fn ensure_hooks(chain: bool) -> Outcome {
631    if !hook::git_available() {
632        return Outcome::Skipped(format!(
633            "\n    {}",
634            hook::GIT_MISSING_HELP.replace('\n', "\n    ")
635        ));
636    }
637
638    match hook::state() {
639        // "Installed" is not the question — "installed and pointing at a binary that
640        // still exists" is. A hook backgrounds itself and discards its own output, so
641        // one left pointing at a deleted npm cache dies silently on every commit and
642        // nothing ever registers again; this pass is the only thing that ever looks.
643        // Two reasons to rewrite a working install: it names a binary that is gone, or
644        // it predates the passthrough shims and is silently shadowing every repository's
645        // own `.git/hooks`. Neither reports itself — a hook discards its own output by
646        // design — so the upgrade pass is the only thing that will ever notice.
647        Ok(HookState::Active) if hook_target_is_dead() || hook::shims_incomplete() => {
648            match hook::install() {
649                Ok(()) => Outcome::Installed,
650                Err(e) => Outcome::Failed(format!("{e:#}")),
651            }
652        }
653        Ok(HookState::Active) => Outcome::AlreadyPresent,
654        // Drift is repaired here rather than reported: the setup pass already runs on
655        // install, on update and on a schedule, and a chain the user opted into is a
656        // chain they want kept current.
657        Ok(HookState::Chained { drifted, .. }) if !drifted.is_empty() => {
658            match hook::install_with(true) {
659                Ok(()) => Outcome::Installed,
660                Err(e) => Outcome::Failed(format!("{e:#}")),
661            }
662        }
663        Ok(HookState::Chained { .. }) if hook_target_is_dead() => match hook::install_with(true) {
664            Ok(()) => Outcome::Installed,
665            Err(e) => Outcome::Failed(format!("{e:#}")),
666        },
667        Ok(HookState::Chained { .. }) => Outcome::AlreadyPresent,
668        Ok(HookState::Foreign(_)) if chain => match hook::install_with(true) {
669            Ok(()) => Outcome::Installed,
670            Err(e) => Outcome::Failed(format!("{e:#}")),
671        },
672        Ok(HookState::Foreign(existing)) => Outcome::Skipped(format!(
673            "`core.hooksPath` is already set to `{existing}`, which belongs to another tool.\n    \
674             Git allows only one hooks directory, so dev-prune will not take the slot.\n    \
675             `devp hook install --chain` installs in front of it instead — dev-prune registers \
676             the repo, then hands every hook on to `{existing}`, and `devp hook uninstall` puts \
677             the original setting back (`devp config set auto_hooks_chain true` makes that \
678             the standing answer). Or skip it: `devp link .` does the same job by hand."
679        )),
680        Ok(HookState::Absent) => match hook::install() {
681            Ok(()) => Outcome::Installed,
682            Err(e) => Outcome::Failed(format!("{e:#}")),
683        },
684        Err(e) => Outcome::Failed(format!("{e:#}")),
685    }
686}
687
688/// Whether the installed hooks name a binary that no longer exists.
689fn hook_target_is_dead() -> bool {
690    hook::registered_exe_path().is_some_and(|exe| !exe.exists())
691}
692
693/// Install the OS scheduler if it is not already registered.
694pub fn ensure_daemon(interval_days: u64) -> Outcome {
695    match daemon::daemon_status() {
696        // A task whose binary has been deleted keeps reporting itself `Ready` and dies
697        // the instant it fires, every interval, with nowhere to complain. Re-register
698        // it against the stable path instead of counting the corpse as present.
699        Ok(daemon::DaemonStatus::Installed)
700            if daemon::registered_exe_path().is_some_and(|exe| !exe.exists()) =>
701        {
702            match daemon::install_daemon(interval_days) {
703                Ok(()) => Outcome::Installed,
704                Err(e) => Outcome::Failed(format!("{e:#}")),
705            }
706        }
707        // A task registered by a version that only knew the interactive logon flashes a
708        // console window at whoever is logged in every time it fires — the single most
709        // trust-destroying thing a background tool can do. Re-register it windowless;
710        // a machine whose scheduler refuses the sessionless logon remembers the refusal
711        // and is not asked again.
712        Ok(daemon::DaemonStatus::Installed) if daemon::wants_windowless_upgrade() => {
713            match daemon::install_daemon(interval_days) {
714                Ok(()) => Outcome::Installed,
715                Err(e) => Outcome::Failed(format!("{e:#}")),
716            }
717        }
718        // A task registered by a version that took Windows' power defaults never runs
719        // on a laptop that lives on battery, and a missed trigger is skipped rather
720        // than caught up. Patch the settings in place — an XML round-trip, not a
721        // reinstall, so the trigger time the user already has stays put. A scheduler
722        // that refuses remembers the refusal and is not asked again.
723        Ok(daemon::DaemonStatus::Installed) if daemon::wants_power_upgrade() => {
724            match daemon::apply_power_settings() {
725                Ok(()) => Outcome::Installed,
726                Err(e) => Outcome::Failed(format!("{e:#}")),
727            }
728        }
729        // A settled task still needs its windowless twin kept current: the twin
730        // is a copy of the binary, so an upgrade that replaced the binary would otherwise
731        // leave the daemon firing the previous release. No-op on the other platforms, and
732        // when no twin is in use.
733        Ok(daemon::DaemonStatus::Installed) => {
734            daemon::refresh_windowless_twin();
735            // The refresh above sources from the devpw beside the running binary, which
736            // is the *previous* delivery's copy after an upgrade the old console
737            // performed, and the scheduled pass itself runs devpw, so nothing devpw
738            // does can ever replace devpw. This per-version pass is the first place new
739            // console code runs without being asked, which makes it the one hook that
740            // can close that loop: reconcile the twin against the release itself. It
741            // stands down under `DEV_PRUNE_OFFLINE` and `version_lock`, and only ever
742            // moves an existing, stale twin forward.
743            match crate::commands::update::repair_windowless_twins() {
744                Outcome::Installed => Outcome::Installed,
745                Outcome::Failed(why) => Outcome::Failed(why),
746                _ => Outcome::AlreadyPresent,
747            }
748        }
749        Ok(daemon::DaemonStatus::NotInstalled) => match daemon::install_daemon(interval_days) {
750            Ok(()) => Outcome::Installed,
751            Err(e) => Outcome::Failed(format!("{e:#}")),
752        },
753        // `Unknown` means the query itself could not be answered — the scheduler may
754        // well be there. Installing over it would fail on every command from now on, so
755        // this reports and steps over instead of guessing. The platform backends are
756        // written to keep this case narrow: anything they can answer definitely, they do.
757        Ok(daemon::DaemonStatus::Unknown(why)) => {
758            Outcome::Skipped(format!("scheduler state could not be read — {why}"))
759        }
760        Err(e) => Outcome::Failed(format!("{e:#}")),
761    }
762}
763
764/// Whether unattended installation is permitted at all.
765///
766/// Both switches exist because these integrations write outside dev-prune's own config
767/// directory — a scheduled task, a global git setting — and there are places that must
768/// never happen unasked: container images, CI, and this project's own test suite.
769pub fn auto_setup_enabled(registry: &Registry) -> bool {
770    !no_auto_setup_requested() && registry.settings.auto_setup && unattended_environment().is_none()
771}
772
773/// The reason this looks like a machine nobody is sitting at, if it does.
774///
775/// `DEV_PRUNE_NO_AUTO_SETUP` and `auto_setup` are both switches you have to set *before*
776/// the first run — which is exactly the run that installs things, so in a container or a
777/// CI job the damage is done by the time there is anywhere to set them. Detecting the
778/// environment is the only opt-out that works on the first run, which is the only run
779/// that matters here.
780///
781/// Deliberately conservative: every signal below is one that CI providers and container
782/// runtimes set themselves, so a developer's own shell will not trip it. Someone who
783/// genuinely wants the integrations in CI can still ask in so many words with
784/// `devp setup`, which never consults this.
785pub fn unattended_environment() -> Option<&'static str> {
786    // Set by GitHub Actions, GitLab CI, CircleCI, Travis, Jenkins (via pipeline), Woodpecker
787    // and most others. `CI=true` is the closest thing this space has to a standard.
788    for var in [
789        "CI",
790        "CONTINUOUS_INTEGRATION",
791        "BUILD_NUMBER",
792        "GITHUB_ACTIONS",
793    ] {
794        if let Some(value) = std::env::var_os(var) {
795            // `CI=false` is set explicitly by some tools to mean "not CI", and honouring
796            // the word rather than the presence is what the user plainly meant.
797            let value = value.to_string_lossy();
798            if !value.is_empty() && !value.eq_ignore_ascii_case("false") {
799                return Some("this looks like a CI runner");
800            }
801        }
802    }
803
804    // Docker writes this marker into every container it builds from a Dockerfile;
805    // Podman and other OCI runtimes write the `container` variable instead.
806    #[cfg(unix)]
807    if std::path::Path::new("/.dockerenv").exists() {
808        return Some("this looks like a container");
809    }
810    if std::env::var_os("container").is_some() {
811        return Some("this looks like a container");
812    }
813
814    None
815}
816
817/// Whether this integration pass was asked for by name.
818///
819/// The only thing it decides is the `devp` twin. Writing a second executable beside the
820/// first is a self-installation, and doing it *unasked*, on the first run of a freshly
821/// downloaded unsigned binary, alongside registering a scheduled task, is a behavioural
822/// malware signature — it is what earned this package a `Validation-Defender-Error` on
823/// microsoft/winget-pkgs#422665. Asked for in so many words, the same write is an
824/// ordinary install step. Nothing else in the pass changes.
825#[derive(Clone, Copy, PartialEq, Eq)]
826pub enum Consent {
827    /// `devp setup`, or `devp doctor --fix`.
828    Explicit,
829    /// The pass that runs on its own when `auto_setup` is on.
830    Unattended,
831}
832
833/// Run an integration pass unless unattended installation is switched off.
834///
835/// Every caller that the user did not name explicitly goes through this. `devp setup`
836/// calls [`ensure_integrations`] with [`Consent::Explicit`]: asking for it in so many
837/// words is consent.
838pub fn ensure_integrations_if_enabled(registry: &Registry) -> Option<SetupReport> {
839    auto_setup_enabled(registry).then(|| ensure_integrations(registry, Consent::Unattended))
840}
841
842/// Run one integration pass, installing whatever is missing.
843///
844/// The two per-integration settings (`auto_daemon`, `auto_hooks`) are honoured here, so
845/// turning one off turns it off for every future pass as well as this one.
846pub fn ensure_integrations(registry: &Registry, consent: Consent) -> SetupReport {
847    let mut report = SetupReport::default();
848
849    // Only when asked. This is the twin *beside the running binary*, which on an
850    // unattended pass means beside whatever the delivery vehicle happened to be: npm's
851    // cache, a venv's `Scripts`, a Downloads folder. Every channel already ships both
852    // names as real files — the archives, the npm and PyPI packages, and two `[[bin]]`
853    // targets for `cargo install` — so there is normally nothing to create, and the one
854    // case left over is a manual install that skipped `dev-prune setup`, which `devp
855    // doctor` reports with a one-command fix.
856    //
857    // The pair that actually matters is not this one. `ensure_command_on_path` below
858    // keeps `dev-prune` and `devp` together in the managed `bin` directory, which is
859    // the directory on the user's PATH and the one `devp uninstall` knows about, and it
860    // runs on every pass.
861    if consent == Consent::Explicit {
862        report.push("dev-prune/devp pair", ensure_alias());
863    }
864    report.push("Command on PATH", ensure_command_on_path());
865    report.push("SKILL.md", ensure_skill_file());
866    // Only reported when an agent is actually installed: a machine without one would
867    // otherwise see a "skipped" warning about software it never had, on every install.
868    if !agent_skill_roots().is_empty() {
869        report.push("AI agent skills", ensure_agent_skills());
870    }
871    report.push("File icons", ensure_icons());
872
873    if registry.settings.auto_hooks {
874        report.push(
875            "Git hooks",
876            ensure_hooks(registry.settings.auto_hooks_chain),
877        );
878    } else {
879        report.push(
880            "Git hooks",
881            Outcome::Skipped("`auto_hooks` is false — enable with `devp hook install`".to_string()),
882        );
883    }
884
885    if registry.settings.auto_daemon {
886        report.push(
887            "Background scheduler",
888            ensure_daemon(registry.settings.check_interval_days),
889        );
890    } else {
891        report.push(
892            "Background scheduler",
893            Outcome::Skipped(
894                "`auto_daemon` is false — enable with `devp daemon install`".to_string(),
895            ),
896        );
897    }
898
899    report
900}
901
902// ── VS Code extension ────────────────────────────────────────────────────────
903
904/// Marker recording that the extension question was asked (or found already answered by
905/// an existing install). One file, no content: the offer is made once ever, whatever
906/// the answer was — a declined install must not be re-litigated on every upgrade.
907const VSCODE_OFFER_STAMP: &str = "vscode-ext-offered";
908
909/// A VS Code-compatible editor found on PATH.
910struct EditorCli {
911    /// The command to invoke — on Windows the `.cmd` launcher, because the entry on
912    /// PATH is a batch file, not an `.exe`, and `Command::new("code")` would miss it.
913    cli: String,
914    /// The editor's name as a person knows it, for the prompt and per-editor results.
915    label: &'static str,
916}
917
918/// Every VS Code-compatible editor on PATH, in the order listed here.
919///
920/// All of these forks keep the upstream CLI protocol (`--version`, `--list-extensions`,
921/// `--install-extension`), so one code path drives them all. What differs is the
922/// registry each one resolves an extension ID against: VS Code uses the Microsoft
923/// Marketplace, VSCodium/Windsurf/Positron/Kiro/Trae use OpenVSX, Cursor and
924/// Antigravity run their own mirrors. An ID install can therefore fail on a fork whose
925/// registry does not carry the extension yet — which is why the installer falls back
926/// to the `.vsix` from the GitHub release, the artifact every registry copy is built
927/// from.
928///
929/// The list is candidate CLI names, not a claim that any of them is installed: each is
930/// asked for its `--version` and dropped if it does not answer. Adding a fork therefore
931/// costs one failed spawn on a machine without it, and is what stops the extension
932/// offer from being a VS Code-only courtesy on an editor that is a VS Code build with a
933/// different name on the window.
934fn detect_vscode_editors() -> Vec<EditorCli> {
935    const CANDIDATES: &[(&str, &str)] = &[
936        ("code", "VS Code"),
937        ("code-insiders", "VS Code Insiders"),
938        ("codium", "VSCodium"),
939        ("codium-insiders", "VSCodium Insiders"),
940        ("cursor", "Cursor"),
941        ("windsurf", "Windsurf"),
942        ("antigravity", "Antigravity"),
943        ("trae", "Trae"),
944        ("positron", "Positron"),
945        ("kiro", "Kiro"),
946    ];
947    CANDIDATES
948        .iter()
949        .filter_map(|(name, label)| {
950            let cli = if cfg!(windows) {
951                format!("{name}.cmd")
952            } else {
953                (*name).to_string()
954            };
955            let responds = crate::spawn::command(&cli)
956                .arg("--version")
957                .stdin(std::process::Stdio::null())
958                .stdout(std::process::Stdio::null())
959                .stderr(std::process::Stdio::null())
960                .status()
961                .map(|s| s.success())
962                .unwrap_or(false);
963            responds.then_some(EditorCli { cli, label })
964        })
965        .collect()
966}
967
968fn vscode_extension_installed(cli: &str) -> bool {
969    crate::spawn::command(cli)
970        .arg("--list-extensions")
971        .stdin(std::process::Stdio::null())
972        .output()
973        .map(|out| {
974            String::from_utf8_lossy(&out.stdout).lines().any(|line| {
975                line.trim()
976                    .eq_ignore_ascii_case(constants::VSCODE_EXTENSION_ID)
977            })
978        })
979        .unwrap_or(false)
980}
981
982/// Download the `.vsix` from the newest extension release into the config directory.
983///
984/// The release asset is the source of truth for the extension — the Marketplace and
985/// OpenVSX listings are published from that exact file — so when an editor's registry
986/// cannot resolve the ID (a fork whose registry does not carry the extension),
987/// installing the release file directly gets the same bits through a channel every fork
988/// supports. Editors update a `.vsix`-installed extension from their registry once a
989/// newer listed version appears, so this install self-heals into the normal update flow.
990///
991/// Deliberately not `releases/latest`. The extension has its own tags and its own
992/// release page, and those releases are marked "not latest" so they cannot displace the
993/// binary release that `devp update` reads. The consequence is that the newest one has
994/// to be found by walking the listing for a [`VSCODE_RELEASE_TAG_PREFIX`] tag.
995///
996/// [`VSCODE_RELEASE_TAG_PREFIX`]: crate::constants::VSCODE_RELEASE_TAG_PREFIX
997fn download_release_vsix() -> Option<std::path::PathBuf> {
998    use std::time::Duration;
999
1000    if offline_requested() {
1001        return None;
1002    }
1003
1004    let fetch = |url: &str| {
1005        ureq::get(url)
1006            .header("User-Agent", &format!("dev-prune/{}", constants::VERSION))
1007            .header("Accept", "application/vnd.github+json")
1008            .config()
1009            .timeout_global(Some(Duration::from_secs(30)))
1010            .build()
1011            .call()
1012    };
1013
1014    let body = fetch(constants::RELEASES_LIST_API_URL)
1015        .ok()?
1016        .body_mut()
1017        .read_to_string()
1018        .ok()?;
1019    let json: serde_json::Value = serde_json::from_str(&body).ok()?;
1020    // GitHub returns this listing newest-first, so the first extension release found is
1021    // the current one. Draft releases carry no downloadable asset, and a pre-release of
1022    // the extension is one deliberately not being offered to people who did not ask.
1023    let asset = json.as_array()?.iter().find_map(|release| {
1024        let tag = release.get("tag_name")?.as_str()?;
1025        if !tag.starts_with(constants::VSCODE_RELEASE_TAG_PREFIX) {
1026            return None;
1027        }
1028        if release.get("draft")?.as_bool()? || release.get("prerelease")?.as_bool()? {
1029            return None;
1030        }
1031        release.get("assets")?.as_array()?.iter().find_map(|asset| {
1032            let name = asset.get("name")?.as_str()?;
1033            if !name.ends_with(".vsix") {
1034                return None;
1035            }
1036            let url = asset.get("browser_download_url")?.as_str()?;
1037            Some((name.to_string(), url.to_string()))
1038        })
1039    })?;
1040
1041    let bytes = fetch(&asset.1).ok()?.body_mut().read_to_vec().ok()?;
1042    // The config directory, not the shared system temp dir: on a multi-user machine
1043    // `%TEMP%`-style paths are predictable and writable by others, and the editor is
1044    // about to execute what this file contains. The caller deletes it after installing.
1045    let dir = Registry::config_dir().ok()?;
1046    fs::create_dir_all(&dir).ok()?;
1047    let path = dir.join(&asset.0);
1048    fs::write(&path, bytes).ok()?;
1049    Some(path)
1050}
1051
1052/// `<cli> --install-extension <arg>`, surfacing the editor's own output.
1053fn run_install(cli: &str, arg: &str) -> bool {
1054    crate::spawn::command(cli)
1055        .args(["--install-extension", arg])
1056        .stdin(std::process::Stdio::null())
1057        .status()
1058        .map(|s| s.success())
1059        .unwrap_or(false)
1060}
1061
1062/// Offer to install the editor extension, once ever, when a VS Code-family editor is
1063/// present.
1064///
1065/// This asks rather than installs because the editor is not dev-prune's territory the
1066/// way its own config directory is. Every gate below is a way of making sure a person
1067/// is actually there to answer: no marker yet, not a CI runner or container, both ends
1068/// of the terminal attached. When no editor is found nothing is written, so installing
1069/// one later and re-running `devp setup` still gets the one offer.
1070pub fn offer_vscode_extension() {
1071    use std::io::{IsTerminal, Write};
1072
1073    let Ok(config_dir) = Registry::config_dir() else {
1074        return;
1075    };
1076    if config_dir.join(VSCODE_OFFER_STAMP).exists() {
1077        return;
1078    }
1079    if no_auto_setup_requested()
1080        || unattended_environment().is_some()
1081        || !std::io::stdin().is_terminal()
1082        || !std::io::stdout().is_terminal()
1083    {
1084        return;
1085    }
1086    let editors = detect_vscode_editors();
1087    if editors.is_empty() {
1088        return;
1089    }
1090
1091    let write_marker = || {
1092        let _ = fs::create_dir_all(&config_dir);
1093        let _ = fs::write(config_dir.join(VSCODE_OFFER_STAMP), "");
1094    };
1095
1096    let missing: Vec<&EditorCli> = editors
1097        .iter()
1098        .filter(|e| !vscode_extension_installed(&e.cli))
1099        .collect();
1100    if missing.is_empty() {
1101        write_marker();
1102        return;
1103    }
1104
1105    let names = missing
1106        .iter()
1107        .map(|e| e.label)
1108        .collect::<Vec<_>>()
1109        .join(", ");
1110    println!();
1111    println!("{names} detected — install the dev-prune extension?");
1112    println!("  It validates .devprune.json and shows reclaimable space in the status bar.");
1113    // The listings and the source, before the question rather than after it. This is
1114    // the one prompt that defaults to yes, so the material someone would need in order
1115    // to say no has to be on screen at the moment they answer — not in a doc they would
1116    // have to go and look for.
1117    println!("    Marketplace: {}", constants::VSCODE_MARKETPLACE_URL);
1118    println!("    Open VSX:    {}", constants::OPENVSX_URL);
1119    println!("    Source:      {}", constants::REPO_URL);
1120    print!("  Install it? [Y/n] ");
1121    let _ = std::io::stdout().flush();
1122    let mut answer = String::new();
1123    if std::io::stdin().read_line(&mut answer).is_err() {
1124        return;
1125    }
1126    write_marker();
1127
1128    // A bare Enter accepts. Unlike the uninstall sweep — which deletes — the worst case
1129    // here is an extension the person removes in two clicks, and the gates above have
1130    // already established that a human with a VS Code-family editor is watching.
1131    if !matches!(answer.trim().to_lowercase().as_str(), "" | "y" | "yes") {
1132        output::print_info(&format!(
1133            "Skipped. Install it any time with `{} --install-extension {}`, or from {}.",
1134            missing[0].cli,
1135            constants::VSCODE_EXTENSION_ID,
1136            constants::VSCODE_MARKETPLACE_URL
1137        ));
1138        return;
1139    }
1140
1141    // Fetched at most once, shared by every editor whose registry install fails.
1142    let mut release_vsix: Option<Option<std::path::PathBuf>> = None;
1143    for editor in &missing {
1144        // The editor's own registry first: that install is the one the editor keeps
1145        // up to date by itself.
1146        if run_install(&editor.cli, constants::VSCODE_EXTENSION_ID) {
1147            output::print_success(&format!("{}: extension installed.", editor.label));
1148            continue;
1149        }
1150        // A fork whose registry does not carry the extension — install the `.vsix`
1151        // from the GitHub release instead.
1152        let vsix = release_vsix.get_or_insert_with(download_release_vsix);
1153        match vsix {
1154            Some(path) if run_install(&editor.cli, &path.to_string_lossy()) => {
1155                output::print_success(&format!(
1156                    "{}: extension installed from the GitHub release .vsix.",
1157                    editor.label
1158                ));
1159            }
1160            _ => {
1161                output::print_warning(&format!(
1162                    "{}: could not install it from here. Search the Extensions view for \"dev-prune\", or run `{} --install-extension {}` yourself.",
1163                    editor.label,
1164                    editor.cli,
1165                    constants::VSCODE_EXTENSION_ID
1166                ));
1167            }
1168        }
1169    }
1170    if let Some(Some(path)) = &release_vsix {
1171        let _ = fs::remove_file(path);
1172    }
1173}
1174
1175/// Record that a pass completed for this version.
1176fn write_stamp_in(config_dir: &std::path::Path) {
1177    let _ = fs::create_dir_all(config_dir);
1178    let _ = fs::write(config_dir.join(STAMP_FILE), constants::VERSION);
1179}
1180
1181fn write_stamp() {
1182    if let Ok(dir) = Registry::config_dir() {
1183        write_stamp_in(&dir);
1184    }
1185}
1186
1187fn setup_is_due_in(config_dir: &std::path::Path) -> bool {
1188    !fs::read_to_string(config_dir.join(STAMP_FILE))
1189        .is_ok_and(|stamp| stamp.trim() == constants::VERSION)
1190}
1191
1192/// What this machine has said about dev-prune installing things for itself.
1193///
1194/// Three answers, not two: "never asked" is the state a question can still be put in,
1195/// and collapsing it into either answer is how tools end up installing on a silence or
1196/// nagging on a refusal.
1197#[derive(Clone, Copy, PartialEq, Eq, Debug)]
1198pub enum SetupConsent {
1199    Granted,
1200    Declined,
1201    NeverAsked,
1202}
1203
1204const CONSENT_GRANTED: &str = "granted";
1205const CONSENT_DECLINED: &str = "declined";
1206
1207/// The release that introduced the consent question. A stamp older than this could
1208/// only have been written by a pass that had already installed the integrations; a
1209/// newer one is also written by the opted-out path, and so proves nothing.
1210const FIRST_CONSENT_VERSION: &str = "1.18.0";
1211
1212fn consent_state_in(config_dir: &std::path::Path) -> SetupConsent {
1213    match fs::read_to_string(config_dir.join(constants::SETUP_CONSENT_FILE)) {
1214        Ok(answer) if answer.trim() == CONSENT_GRANTED => return SetupConsent::Granted,
1215        Ok(answer) if answer.trim() == CONSENT_DECLINED => return SetupConsent::Declined,
1216        _ => {}
1217    }
1218    // A pre-1.18 stamp means the old flow already installed the integrations and the
1219    // person kept them — consent in deed if not in word, and re-asking would prompt
1220    // every existing user once for something they already have.
1221    match fs::read_to_string(config_dir.join(STAMP_FILE)) {
1222        Ok(stamp)
1223            if crate::commands::update::compare_versions(stamp.trim(), FIRST_CONSENT_VERSION)
1224                == Some(std::cmp::Ordering::Less) =>
1225        {
1226            SetupConsent::Granted
1227        }
1228        _ => SetupConsent::NeverAsked,
1229    }
1230}
1231
1232pub fn consent_state() -> SetupConsent {
1233    Registry::config_dir()
1234        .map(|dir| consent_state_in(&dir))
1235        .unwrap_or(SetupConsent::NeverAsked)
1236}
1237
1238fn record_consent_in(config_dir: &std::path::Path, answer: &str) {
1239    let _ = fs::create_dir_all(config_dir);
1240    let _ = fs::write(config_dir.join(constants::SETUP_CONSENT_FILE), answer);
1241}
1242
1243/// `devp setup` records this too: asking for the pass in so many words is also the
1244/// durable answer to the question the first run would otherwise ask.
1245pub fn record_consent_granted() {
1246    if let Ok(dir) = Registry::config_dir() {
1247        record_consent_in(&dir, CONSENT_GRANTED);
1248    }
1249}
1250
1251fn record_consent_declined() {
1252    if let Ok(dir) = Registry::config_dir() {
1253        record_consent_in(&dir, CONSENT_DECLINED);
1254    }
1255}
1256
1257/// Put the question back the way a fresh machine has it. `devp uninstall` calls this:
1258/// keeping a "granted" that outlives the things it granted would make the next upgrade
1259/// reinstall everything the uninstall just removed.
1260pub fn clear_setup_consent() {
1261    if let Ok(dir) = Registry::config_dir() {
1262        let _ = fs::remove_file(dir.join(constants::SETUP_CONSENT_FILE));
1263    }
1264}
1265
1266/// Whether the unattended pass is due: a fresh install, or the first run after an upgrade.
1267pub fn setup_is_due() -> bool {
1268    Registry::config_dir()
1269        .map(|dir| setup_is_due_in(&dir))
1270        .unwrap_or(false)
1271}
1272
1273/// Whether there is a human at this invocation who could see what was done and undo it.
1274///
1275/// The one question that gates everything dev-prune installs without being asked. CI
1276/// variables and containers answer it directly; a redirected stdin or stdout answers it
1277/// too, because output nobody reads is the same as no output — and an integration
1278/// installed silently is one nobody knows to remove.
1279fn a_person_is_present() -> bool {
1280    use std::io::IsTerminal;
1281    unattended_environment().is_none()
1282        && std::io::stdin().is_terminal()
1283        && std::io::stdout().is_terminal()
1284}
1285
1286/// The per-version pass — and, since 1.18.0, never before this machine has said yes.
1287///
1288/// Called at the top of every command that a human typed. It is deliberately not called
1289/// for the Git hook's `link --quiet` or the scheduler's `run --daemon`: those run without
1290/// a terminal, and an integration pass that nobody can see is one nobody can refuse.
1291pub fn auto_setup_if_due() {
1292    if !setup_is_due() {
1293        first_run_config_review();
1294        return;
1295    }
1296    // The same question `first_run_config_review` asks, asked one step earlier. It used
1297    // to be asked only about the *prompt*, never about the pass that installs a PATH
1298    // entry, a scheduled task and a git hook — so a binary run once by an automated
1299    // system, with its output captured, silently acquired persistence on that machine.
1300    // Nothing here is skipped permanently: the stamp is not written, so the first run a
1301    // person can actually see does the pass and reports it.
1302    if !a_person_is_present() {
1303        return;
1304    }
1305
1306    review_project_venv_install();
1307
1308    let Ok(registry) = Registry::load() else {
1309        return;
1310    };
1311    match consent_state() {
1312        SetupConsent::Granted => {
1313            // Made durable even when it was inferred from a pre-1.18 stamp, so the
1314            // inference runs once rather than on every later upgrade.
1315            record_consent_granted();
1316            run_consented_pass(&registry);
1317            first_run_config_review();
1318        }
1319        SetupConsent::Declined => {
1320            // The answer was no, and staying no costs nothing to honour: stamp so this
1321            // version's pass is settled, install nothing, and leave `devp setup` as
1322            // the standing way to change the answer. The settings review is still owed
1323            // when an upgrade adds a setting — declining the integrations was never a
1324            // vote on config defaults.
1325            write_stamp();
1326            first_run_config_review();
1327        }
1328        SetupConsent::NeverAsked => {
1329            if !auto_setup_enabled(&registry) {
1330                // Suppressed. Stamp anyway, so a machine that opted out does not
1331                // re-decide this on every single command; the consent marker stays
1332                // unwritten, so lifting the opt-out later asks rather than installs.
1333                //
1334                // The one thing opting out must not do is freeze the instructions AI
1335                // agents read at whichever version installed them, so any existing
1336                // copies are still brought up to date. See `run_consented_pass` for
1337                // the fuller reasoning.
1338                refresh_stale(stale_skill_copies());
1339                write_stamp();
1340                crate::commands::config::skip_config_review();
1341                return;
1342            }
1343            ask_first_run_consent();
1344        }
1345    }
1346}
1347
1348/// One integration pass for a machine that has already said yes, reported if it did
1349/// anything.
1350fn run_consented_pass(registry: &Registry) {
1351    let Some(report) = ensure_integrations_if_enabled(registry) else {
1352        // Suppressed. Stamp anyway, so a machine that opted out does not re-decide
1353        // this on every single command.
1354        //
1355        // The one thing opting out must not do is freeze the instructions AI agents
1356        // read at whichever version installed them. The stamp below is what would
1357        // freeze them: it is rewritten for every new version without a pass ever
1358        // running, so an existing `SKILL.md` here would never be reconsidered again,
1359        // and the agent would go on describing flags that were removed two releases
1360        // ago — confidently, because nothing told it otherwise.
1361        refresh_stale(stale_skill_copies());
1362        write_stamp();
1363        crate::commands::config::skip_config_review();
1364        return;
1365    };
1366    if report.changed_anything() || report.needs_attention() {
1367        output::print_header("dev-prune setup");
1368        report.print(false);
1369        if report.changed_anything() {
1370            output::print_info(
1371                "Run `devp setup --status` to review these, or `devp uninstall` to remove them.",
1372            );
1373        }
1374        println!();
1375    }
1376    write_stamp();
1377}
1378
1379/// Ask, on the first attended run, before installing anything at all.
1380///
1381/// The old order — install, then open the walkthrough — put this binary's fingerprint
1382/// (an unasked self-copy into a managed `bin`, plus a scheduled task, on the first run
1383/// of an unsigned download) squarely on the behaviour ML malware classifiers key on;
1384/// the 1.17.0 release exe was flagged as Trojan:Win32/Wacatac.B!ml for exactly that.
1385/// Asked first, the same installs are the answer to a question — and a sandbox that
1386/// runs the binary bare now sits at a prompt instead of recording persistence.
1387fn ask_first_run_consent() {
1388    use crate::commands::config::FirstRunDecision;
1389    match crate::commands::config::first_run_wizard() {
1390        Err(e) => {
1391            // The wizard's own failure must not decide the question either way:
1392            // nothing recorded, nothing stamped, asked again on the next command.
1393            output::print_warning(&format!("Could not run the first-run setup ({e:#})."));
1394        }
1395        Ok(FirstRunDecision::Accepted) => {
1396            record_consent_granted();
1397            // Reloaded, not reused: the walkthrough that just closed may have flipped
1398            // `auto_daemon` or `auto_hooks`, and this pass exists to honour that.
1399            let Ok(registry) = Registry::load() else {
1400                return;
1401            };
1402            run_consented_pass(&registry);
1403            crate::commands::config::skip_config_review();
1404            offer_vscode_extension();
1405            println!();
1406        }
1407        Ok(FirstRunDecision::Declined) => {
1408            record_consent_declined();
1409            write_stamp();
1410            crate::commands::config::skip_config_review();
1411            output::print_info(
1412                "Nothing was installed. `devp setup` installs the integrations whenever \
1413                 you want them; `devp config wizard` reopens the settings.",
1414            );
1415            println!();
1416        }
1417        Ok(FirstRunDecision::NoAnswer) => {
1418            // EOF is not an answer — it is nobody there after all. Every marker stays
1419            // unwritten, so the first run with a person on the other end is asked.
1420        }
1421    }
1422}
1423
1424/// Put the defaults in front of the user on a fresh install, and any setting an upgrade
1425/// added that they have never been shown.
1426///
1427/// Separate from the integration stamp on purpose. The integrations are re-checked after
1428/// every upgrade; the settings are not, except for the ones that did not exist last time
1429/// — being asked to reconfirm `idle_days` on each new version would be a nuisance, and a
1430/// nuisance is something people learn to dismiss without reading.
1431///
1432/// Every condition here is a way of asking "is there a person reading this?", because the
1433/// alternative to asking is a prompt written into a log nobody will read, on a run that
1434/// then blocks forever waiting for an answer.
1435fn first_run_config_review() {
1436    if !crate::commands::config::config_review_is_due() {
1437        return;
1438    }
1439
1440    // Deliberately not stamped here. The stamp records that somebody was *shown* the
1441    // declaration, and a cron run, a CI job or a container has nobody to show it to.
1442    // Writing it anyway meant the first unattended `devp` on a machine silently spent
1443    // the one screen that says what dev-prune will not delete — so the person who
1444    // installed it never saw it, and nothing ever offered again. Left unstamped, the
1445    // walkthrough waits for the first run with a human on the other end of it, which is
1446    // the only run it was ever for.
1447    if !a_person_is_present() {
1448        return;
1449    }
1450
1451    // Any error here is the wizard's own reporting; the command the user actually typed
1452    // still runs. A failed walkthrough must not become a failed `devp status`.
1453    if let Err(e) =
1454        crate::commands::config::run_wizard(false, crate::commands::config::Opened::OnItsOwn)
1455    {
1456        output::print_warning(&format!("Could not run the first-run setup ({e:#})."));
1457    }
1458    // Marked regardless of how it ended, including a deliberate quit. This is the one
1459    // caller that runs uninvited, and an unasked-for walkthrough that reappears on every
1460    // subsequent command is worse than one somebody dismissed once on purpose.
1461    crate::commands::config::skip_config_review();
1462    // Same first run, same person already answering questions — the one moment the
1463    // extension offer is a courtesy rather than an interruption.
1464    offer_vscode_extension();
1465    println!();
1466}
1467
1468/// Invalidate the stamp so the next human-run command performs a pass.
1469///
1470/// `uninstall` calls this in reverse — it writes the current stamp — so that removing the
1471/// integrations is not immediately undone by the next command.
1472pub fn suppress_next_auto_setup() {
1473    write_stamp();
1474}
1475
1476/// Say something, once, when this copy is running from inside a project's virtualenv.
1477///
1478/// This is the remedy at the source. By the time a prune pass refuses the environment,
1479/// the user is several days and one confusing error away from the moment they typed
1480/// `pip install dev-prune` with a project activated; saying it here, on the first run
1481/// after that install, is the only chance to explain it while the cause is still in
1482/// living memory. The refusal in the venv adapter stays as the failsafe, for a copy
1483/// installed before this check existed or by somebody who dismissed it.
1484///
1485/// Silent when `requirements.txt` already lists the tool. That is somebody who meant it,
1486/// and being told about a decision you made on purpose is what teaches people to stop
1487/// reading output.
1488fn review_project_venv_install() {
1489    let Ok(exe) = std::env::current_exe() else {
1490        return;
1491    };
1492    let Some(found) = crate::channel::project_venv_install(&exe) else {
1493        return;
1494    };
1495
1496    let requirements = found.project.join("requirements.txt");
1497    let recorded = crate::adapters::venv::requirement_names(&requirements, &mut Vec::new())
1498        .is_some_and(|names| names.iter().any(|n| crate::adapters::venv::is_dev_prune(n)));
1499    if recorded {
1500        return;
1501    }
1502
1503    output::print_header(&format!(
1504        "{} is installed inside this project's virtual environment",
1505        constants::APP_NAME
1506    ));
1507    println!("  running from  {}", exe.display());
1508    println!("  environment   {}", found.venv.display());
1509    println!("  project       {}", found.project.display());
1510    println!();
1511    output::print_info(
1512        "A tool install belongs outside a project: it outlives the environment, every \
1513         repository shares it, and it never has to appear in an application's \
1514         requirements file to stay out of the way.",
1515    );
1516
1517    if requirements.is_file() {
1518        println!();
1519        output::print_info(
1520            "Until that is fixed, a prune pass will decline this project's environment \
1521             — a package `requirements.txt` does not account for is a package nothing \
1522             can rebuild.",
1523        );
1524        println!();
1525        if record_in_requirements(&requirements) {
1526            return;
1527        }
1528    }
1529
1530    println!();
1531    println!("  Remove this copy and install it as a tool instead:");
1532    println!("    pip uninstall {}", constants::APP_NAME);
1533    println!("    uv tool install {}", constants::APP_NAME);
1534    println!("    # or: pipx install {}", constants::APP_NAME);
1535    report_other_copy(&exe);
1536    println!();
1537}
1538
1539/// Offer the other repair: declare the tool a dependency of this project, on purpose.
1540///
1541/// Default no, for the reason `devp restore` defaults no on a substituted interpreter —
1542/// this is the branch that writes into a file in somebody's repository, so a reflexive
1543/// Enter must not be what agrees to it. Returns whether the file was written, which is
1544/// also whether the removal instructions are still worth printing.
1545fn record_in_requirements(requirements: &std::path::Path) -> bool {
1546    use std::io::{IsTerminal, Write};
1547    if !std::io::stdin().is_terminal() {
1548        return false;
1549    }
1550    eprint!(
1551        "Record {} in requirements.txt instead, as a deliberate dev dependency? [y/N]: ",
1552        constants::APP_NAME
1553    );
1554    if std::io::stderr().flush().is_err() {
1555        return false;
1556    }
1557    let mut input = String::new();
1558    if std::io::stdin().read_line(&mut input).is_err() {
1559        return false;
1560    }
1561    if !matches!(input.trim().to_lowercase().as_str(), "y" | "yes") {
1562        return false;
1563    }
1564
1565    let Ok(existing) = fs::read_to_string(requirements) else {
1566        output::print_warning("Could not read requirements.txt, so nothing was changed.");
1567        return false;
1568    };
1569    // Requirements files without a trailing newline are common, and appending to one
1570    // blind would glue the pin onto the last requirement.
1571    let separator = if existing.is_empty() || existing.ends_with('\n') {
1572        ""
1573    } else {
1574        "\n"
1575    };
1576    let line = format!(
1577        "{separator}{}=={}\n",
1578        constants::APP_NAME,
1579        constants::VERSION
1580    );
1581    match std::fs::OpenOptions::new()
1582        .append(true)
1583        .open(requirements)
1584        .and_then(|mut f| f.write_all(line.as_bytes()))
1585    {
1586        Ok(()) => {
1587            output::print_success(&format!(
1588                "Added `{}=={}` to {}. The environment is prunable now.",
1589                constants::APP_NAME,
1590                constants::VERSION,
1591                requirements.display()
1592            ));
1593            true
1594        }
1595        Err(e) => {
1596            output::print_warning(&format!("Could not write requirements.txt ({e})."));
1597            false
1598        }
1599    }
1600}
1601
1602/// Name the copy that is already installed properly, if there is one.
1603///
1604/// "Uninstall this" reads very differently depending on whether it leaves the user with
1605/// no tool at all or with the one they already had — and on a machine where this mistake
1606/// happens there usually is one, because the working copy is what they were reaching for
1607/// in the first place.
1608fn report_other_copy(exe: &std::path::Path) {
1609    let names: [&str; 2] = if cfg!(windows) {
1610        ["dev-prune.exe", "devp.exe"]
1611    } else {
1612        ["dev-prune", "devp"]
1613    };
1614    let home = dirs::home_dir();
1615    let other = crate::channel::install_dirs(home.as_deref())
1616        .into_iter()
1617        .flat_map(|dir| names.iter().map(move |name| dir.join(name)))
1618        .find(|candidate| candidate.is_file() && candidate != exe);
1619
1620    if let Some(other) = other {
1621        println!();
1622        output::print_info(&format!(
1623            "You already have a copy outside this project, at `{}`, so removing this one \
1624             still leaves you a working `devp`.",
1625            other.display()
1626        ));
1627    }
1628}
1629
1630#[cfg(test)]
1631mod tests {
1632    use super::*;
1633
1634    #[test]
1635    fn a_report_with_only_present_items_is_silent() {
1636        let mut report = SetupReport::default();
1637        report.push("a", Outcome::AlreadyPresent);
1638        assert!(!report.changed_anything());
1639        assert!(!report.needs_attention());
1640    }
1641
1642    #[test]
1643    fn skipped_and_failed_both_ask_for_attention() {
1644        let mut skipped = SetupReport::default();
1645        skipped.push("a", Outcome::Skipped("no git".into()));
1646        assert!(skipped.needs_attention());
1647        assert!(!skipped.changed_anything());
1648
1649        let mut failed = SetupReport::default();
1650        failed.push("a", Outcome::Failed("boom".into()));
1651        assert!(failed.needs_attention());
1652    }
1653
1654    #[test]
1655    fn an_install_counts_as_a_change() {
1656        let mut report = SetupReport::default();
1657        report.push("a", Outcome::Installed);
1658        assert!(report.changed_anything());
1659    }
1660
1661    #[test]
1662    fn the_skill_export_lands_in_the_config_directory() {
1663        let dir = tempfile::TempDir::new().unwrap();
1664        assert_eq!(ensure_skill_file_in(dir.path()), Outcome::Installed);
1665        // A second pass finds byte-identical content and leaves it alone.
1666        assert_eq!(ensure_skill_file_in(dir.path()), Outcome::AlreadyPresent);
1667        let written = fs::read_to_string(dir.path().join("SKILL.md")).unwrap();
1668        assert_eq!(written, EMBEDDED_SKILL_MD);
1669    }
1670
1671    #[test]
1672    fn a_stale_skill_export_is_rewritten() {
1673        // An upgrade must not leave the previous version's instructions on disk.
1674        let dir = tempfile::TempDir::new().unwrap();
1675        fs::write(dir.path().join("SKILL.md"), "# an older version").unwrap();
1676        assert_eq!(ensure_skill_file_in(dir.path()), Outcome::Installed);
1677        let written = fs::read_to_string(dir.path().join("SKILL.md")).unwrap();
1678        assert_eq!(written, EMBEDDED_SKILL_MD);
1679    }
1680
1681    #[test]
1682    fn agent_skills_install_only_into_agent_homes_that_exist() {
1683        let home = tempfile::TempDir::new().unwrap();
1684        assert!(
1685            agent_skill_roots_under(home.path()).is_empty(),
1686            "a machine without an agent must detect nothing"
1687        );
1688
1689        fs::create_dir_all(home.path().join(constants::CLAUDE_HOME_DIR)).unwrap();
1690        let roots = agent_skill_roots_under(home.path());
1691        assert_eq!(roots.len(), 1);
1692
1693        assert_eq!(ensure_agent_skills_at(&roots), Outcome::Installed);
1694        let installed = home
1695            .path()
1696            .join(constants::CLAUDE_HOME_DIR)
1697            .join(constants::AGENT_SKILLS_SUBDIR)
1698            .join(constants::APP_NAME)
1699            .join("SKILL.md");
1700        assert_eq!(fs::read_to_string(&installed).unwrap(), EMBEDDED_SKILL_MD);
1701
1702        // A second pass finds it current and leaves it alone.
1703        assert_eq!(ensure_agent_skills_at(&roots), Outcome::AlreadyPresent);
1704    }
1705
1706    #[test]
1707    fn refreshing_rewrites_the_copies_that_exist_and_creates_none() {
1708        let dir = tempfile::TempDir::new().unwrap();
1709        let present = dir.path().join("SKILL.md");
1710        fs::write(&present, "# the instructions from two releases ago").unwrap();
1711        let never_installed = dir.path().join("no-agent-here").join("SKILL.md");
1712
1713        let stale = stale_among(vec![present.clone(), never_installed.clone()]);
1714        assert_eq!(stale, vec![present.clone()]);
1715
1716        refresh_stale(stale);
1717        assert_eq!(fs::read_to_string(&present).unwrap(), EMBEDDED_SKILL_MD);
1718        assert!(
1719            !never_installed.exists(),
1720            "a machine that never had a copy must not acquire one from a refresh"
1721        );
1722    }
1723
1724    #[test]
1725    fn no_detected_agent_is_a_skip_not_a_failure() {
1726        assert!(matches!(ensure_agent_skills_at(&[]), Outcome::Skipped(_)));
1727    }
1728
1729    #[test]
1730    fn the_stamp_gates_the_unattended_pass() {
1731        let dir = tempfile::TempDir::new().unwrap();
1732        assert!(setup_is_due_in(dir.path()), "a fresh install is due");
1733        write_stamp_in(dir.path());
1734        assert!(
1735            !setup_is_due_in(dir.path()),
1736            "the same version is not due twice"
1737        );
1738        fs::write(dir.path().join(STAMP_FILE), "0.0.1").unwrap();
1739        assert!(setup_is_due_in(dir.path()), "an upgrade is due again");
1740    }
1741
1742    /// The alias must never be written with a copy while it already exists.
1743    ///
1744    /// A hard link and its target share one inode, so `fs::copy` onto the alias empties
1745    /// the binary it was copied from. This reproduces the exact shape of that bug — link
1746    /// first, then ask for the alias again — and asserts the original still has its
1747    /// bytes. The real failure was silent: a zero-byte executable that macOS runs
1748    /// through `/bin/sh`, which exits 0 and prints nothing.
1749    #[test]
1750    fn refreshing_an_alias_that_is_a_hard_link_does_not_empty_the_binary() {
1751        let dir = tempfile::TempDir::new().unwrap();
1752        let binary = dir.path().join("dev-prune");
1753        let alias = dir.path().join("devp");
1754        fs::write(&binary, vec![b'M'; 4096]).unwrap();
1755
1756        if fs::hard_link(&binary, &alias).is_err() {
1757            return; // Filesystem without hard links; the hazard cannot arise.
1758        }
1759
1760        // What `ensure_alias` does when its `hard_link` loses the race: the alias is
1761        // already there, so it must stop rather than fall through to the copy.
1762        assert!(fs::hard_link(&binary, &alias).is_err(), "EEXIST expected");
1763        assert!(alias.exists(), "the guard's condition");
1764
1765        assert_eq!(
1766            fs::metadata(&binary).unwrap().len(),
1767            4096,
1768            "the running binary was truncated by refreshing its own alias"
1769        );
1770    }
1771
1772    /// The on-disk file name for one of the pair, on this platform.
1773    fn exe_name(stem: &str) -> String {
1774        if cfg!(windows) {
1775            format!("{stem}.exe")
1776        } else {
1777            stem.to_string()
1778        }
1779    }
1780
1781    #[test]
1782    fn dev_prune_creates_devp_beside_it() {
1783        let dir = tempfile::TempDir::new().unwrap();
1784        let canonical = dir.path().join(exe_name("dev-prune"));
1785        fs::write(&canonical, "the binary").unwrap();
1786
1787        assert_eq!(ensure_twin_of(&canonical, dir.path()), Outcome::Installed);
1788        let alias = dir.path().join(exe_name("devp"));
1789        assert!(alias.is_file(), "`devp` was not created");
1790        assert_eq!(fs::read_to_string(&alias).unwrap(), "the binary");
1791    }
1792
1793    /// The pair has to be recoverable from either side.
1794    ///
1795    /// Deleting `dev-prune` and leaving `devp` is not hypothetical: an antivirus
1796    /// quarantine, a half-finished uninstall, or a `Remove-Item` aimed at one name all
1797    /// produce it. Before this, `devp setup` reported the alias already present and did
1798    /// nothing, because the only direction it knew how to repair was the other one.
1799    #[test]
1800    fn devp_restores_a_missing_dev_prune() {
1801        let dir = tempfile::TempDir::new().unwrap();
1802        let alias = dir.path().join(exe_name("devp"));
1803        fs::write(&alias, "the binary").unwrap();
1804
1805        assert_eq!(ensure_twin_of(&alias, dir.path()), Outcome::Installed);
1806        let canonical = dir.path().join(exe_name("dev-prune"));
1807        assert!(canonical.is_file(), "`dev-prune` was not put back");
1808        assert_eq!(fs::read_to_string(&canonical).unwrap(), "the binary");
1809    }
1810
1811    /// `devp` may create `dev-prune`, never overwrite it.
1812    ///
1813    /// Repairing in both directions opens a downgrade: an upgrade replaces `dev-prune`
1814    /// first and can then fail on a `devp` that is running, which leaves the alias holding
1815    /// the *older* binary. If the alias were allowed to refresh its twin from there, the
1816    /// next `devp setup` would quietly reinstall the version the user just upgraded away
1817    /// from — and report it as a repair.
1818    #[test]
1819    fn devp_does_not_overwrite_an_existing_dev_prune() {
1820        let dir = tempfile::TempDir::new().unwrap();
1821        let alias = dir.path().join(exe_name("devp"));
1822        let canonical = dir.path().join(exe_name("dev-prune"));
1823        fs::write(&alias, "the previous version").unwrap();
1824        fs::write(&canonical, "the version just upgraded to").unwrap();
1825
1826        assert_eq!(
1827            ensure_twin_of(&alias, dir.path()),
1828            Outcome::AlreadyPresent,
1829            "`devp` must leave an existing `dev-prune` alone"
1830        );
1831        assert_eq!(
1832            fs::read_to_string(&canonical).unwrap(),
1833            "the version just upgraded to",
1834            "`devp` downgraded the binary it was supposed to leave alone"
1835        );
1836    }
1837
1838    /// The same downgrade, coming the other way — the direction the gate above lets
1839    /// through.
1840    ///
1841    /// `devp` is blocked from refreshing `dev-prune` outright, but `dev-prune` refreshing
1842    /// `devp` was gated on nothing but the two files differing, on the assumption that the
1843    /// canonical name is always the newer one. Restore a `dev-prune` from a backup, or run
1844    /// one out of a package-manager cache, and it is not — and `devp doctor --fix` runs
1845    /// `ensure_alias` from whichever binary is executing, so an older `dev-prune` would
1846    /// delete a newer `devp` and link its own content over it while reporting a repair.
1847    #[test]
1848    fn a_twin_that_is_not_behind_is_left_alone() {
1849        assert!(
1850            !twin_is_stale(Some((1, 12, 0)), Some((1, 11, 0))),
1851            "a newer twin must not be overwritten"
1852        );
1853        assert!(
1854            !twin_is_stale(Some((1, 11, 0)), Some((1, 11, 0))),
1855            "an equal twin has nothing to refresh"
1856        );
1857        assert!(
1858            twin_is_stale(Some((1, 10, 0)), Some((1, 11, 0))),
1859            "a genuinely older twin is what this refresh is for"
1860        );
1861        // Neither side able to state a version leaves the old content-only rule, which is
1862        // the only answer available: whatever the file is, it is not a working build of
1863        // this CLI, and leaving it would keep a broken `devp` on PATH forever.
1864        assert!(twin_is_stale(None, Some((1, 11, 0))));
1865        assert!(twin_is_stale(Some((1, 11, 0)), None));
1866    }
1867
1868    #[test]
1869    fn versions_parse_strictly_or_not_at_all() {
1870        assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
1871        assert_eq!(parse_version("10.0.0"), Some((10, 0, 0)));
1872        // Anything this project does not publish must answer None, because a None
1873        // means "replace the copy" and a mis-parse would order versions wrongly.
1874        assert_eq!(parse_version("1.2"), None);
1875        assert_eq!(parse_version("1.2.3.4"), None);
1876        assert_eq!(parse_version("1.2.3-rc1"), None);
1877        assert_eq!(parse_version("dev-prune"), None);
1878        // The version this binary was built with has to be parseable, or the refresh
1879        // logic can never decide anything.
1880        assert!(parse_version(constants::VERSION).is_some());
1881    }
1882
1883    #[test]
1884    fn the_version_this_cli_prints_is_one_this_cli_can_read_back() {
1885        // Not synthetic: this is the shape `print_version_info` writes, `v` and all,
1886        // down to the banner line that ends in the same token.
1887        let real = format!(
1888            "|_____|   v{v}
1889
1890dev-prune (devp) v{v}
1891  Compiler:        Rust 1.88+ (edition 2024)
1892",
1893            v = constants::VERSION
1894        );
1895        assert_eq!(
1896            version_in_output(&real),
1897            parse_version(constants::VERSION),
1898            "binary_version could not read this binary's own --version output"
1899        );
1900        // Something that is not this CLI still has to answer None, because doctor uses
1901        // that to mean "leave this file alone".
1902        assert_eq!(version_in_output("git version 2.51.0.windows.1"), None);
1903        assert_eq!(version_in_output("some other tool"), None);
1904    }
1905
1906    #[test]
1907    fn ordering_of_version_triples_matches_semver() {
1908        assert!(parse_version("1.1.0") > parse_version("1.0.9"));
1909        assert!(parse_version("2.0.0") > parse_version("1.99.99"));
1910        assert!(parse_version("1.0.10") > parse_version("1.0.9"));
1911    }
1912
1913    #[test]
1914    fn the_exported_skill_is_the_one_the_binary_was_built_with() {
1915        // `SKILL.md` is embedded, so a doc edit ships only if the binary is rebuilt.
1916        // Guard the two properties every consumer of it depends on.
1917        assert!(EMBEDDED_SKILL_MD.starts_with("---"), "needs frontmatter");
1918        assert!(
1919            !EMBEDDED_SKILL_MD.contains("file:///"),
1920            "SKILL.md is written to every user's machine — it must not contain \
1921             absolute paths from the author's checkout"
1922        );
1923    }
1924
1925    #[test]
1926    fn a_fresh_machine_has_never_been_asked() {
1927        let dir = tempfile::TempDir::new().unwrap();
1928        assert_eq!(consent_state_in(dir.path()), SetupConsent::NeverAsked);
1929    }
1930
1931    #[test]
1932    fn a_recorded_answer_is_read_back() {
1933        let dir = tempfile::TempDir::new().unwrap();
1934        record_consent_in(dir.path(), CONSENT_GRANTED);
1935        assert_eq!(consent_state_in(dir.path()), SetupConsent::Granted);
1936        record_consent_in(dir.path(), CONSENT_DECLINED);
1937        assert_eq!(consent_state_in(dir.path()), SetupConsent::Declined);
1938    }
1939
1940    #[test]
1941    fn a_garbled_marker_means_the_question_is_still_open() {
1942        // Better to ask twice than to install on the strength of a corrupt file.
1943        let dir = tempfile::TempDir::new().unwrap();
1944        record_consent_in(dir.path(), "maybe?");
1945        assert_eq!(consent_state_in(dir.path()), SetupConsent::NeverAsked);
1946    }
1947
1948    #[test]
1949    fn a_pre_consent_stamp_counts_as_granted() {
1950        // The old flow only ever stamped after installing, so a 1.17 stamp is proof
1951        // the integrations are already on this machine.
1952        let dir = tempfile::TempDir::new().unwrap();
1953        fs::write(dir.path().join(STAMP_FILE), "1.17.0\n").unwrap();
1954        assert_eq!(consent_state_in(dir.path()), SetupConsent::Granted);
1955    }
1956
1957    #[test]
1958    fn a_post_consent_stamp_proves_nothing() {
1959        // From 1.18.0 on, the opted-out path writes the stamp too — treating it as a
1960        // yes would silently grant consent on the exact machines that withheld it.
1961        let dir = tempfile::TempDir::new().unwrap();
1962        // Not `constants::VERSION`: until the release that ships this bumps it past
1963        // FIRST_CONSENT_VERSION, the current version is itself a pre-consent one.
1964        for stamp in [FIRST_CONSENT_VERSION, "1.18.1", "2.0.0", "garbage"] {
1965            fs::write(dir.path().join(STAMP_FILE), stamp).unwrap();
1966            assert_eq!(
1967                consent_state_in(dir.path()),
1968                SetupConsent::NeverAsked,
1969                "stamp {stamp:?} must not imply consent"
1970            );
1971        }
1972    }
1973
1974    #[test]
1975    fn an_explicit_answer_outranks_the_stamp() {
1976        let dir = tempfile::TempDir::new().unwrap();
1977        fs::write(dir.path().join(STAMP_FILE), "1.17.0").unwrap();
1978        record_consent_in(dir.path(), CONSENT_DECLINED);
1979        assert_eq!(consent_state_in(dir.path()), SetupConsent::Declined);
1980    }
1981
1982    #[test]
1983    fn clearing_consent_reopens_the_question() {
1984        let dir = tempfile::TempDir::new().unwrap();
1985        record_consent_in(dir.path(), CONSENT_GRANTED);
1986        fs::remove_file(dir.path().join(constants::SETUP_CONSENT_FILE)).unwrap();
1987        assert_eq!(consent_state_in(dir.path()), SetupConsent::NeverAsked);
1988    }
1989}