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 exported `SKILL.md` that AI
8//! assistants read, the Git hooks that keep the registry current, and the OS scheduler
9//! that runs the passes. Each one here is created **only when it is missing**, which is
10//! what makes it safe to run on every install, reinstall and upgrade — and it does run
11//! on each of those, through the version stamp written at the end of a completed pass.
12//!
13//! Nothing in here is fatal. A machine without `git`, a `core.hooksPath` that belongs to
14//! husky, a locked-down scheduler: each is reported and stepped over, because none of
15//! them should stop `devp init` from registering repositories.
16
17use std::fs;
18use std::path::PathBuf;
19
20use anyhow::Result;
21
22use crate::commands::hook::{self, HookState};
23use crate::commands::skill::EMBEDDED_SKILL_MD;
24use crate::config::Registry;
25use crate::constants;
26use crate::daemon;
27use crate::output;
28
29/// File in the config directory recording the version whose last integration pass
30/// completed. A missing or older stamp is what triggers the automatic pass, so a fresh
31/// install and an upgrade both self-heal exactly once.
32const STAMP_FILE: &str = "setup-stamp";
33
34/// Environment variable that suppresses the automatic pass entirely.
35///
36/// For images, CI and anyone who wants the binary and nothing else. `devp setup` still
37/// works when it is set — this only governs the unattended pass.
38pub const ENV_NO_AUTO_SETUP: &str = "DEV_PRUNE_NO_AUTO_SETUP";
39
40/// Whether the suppression variable is set — by presence, so `=1`, `=true` and even an
41/// empty value all count.
42///
43/// The one predicate every consumer must share. The doctor note used to answer only for
44/// the literal `=1`, so a machine with `=true` had setup switched off with nothing
45/// anywhere saying so.
46pub fn no_auto_setup_requested() -> bool {
47    std::env::var_os(ENV_NO_AUTO_SETUP).is_some()
48}
49
50/// What one integration did during a pass.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub enum Outcome {
53    /// It was missing and is now in place.
54    Installed,
55    /// It was already in place and was left alone.
56    AlreadyPresent,
57    /// It could not be installed for a reason that is the user's call, not an error.
58    Skipped(String),
59    /// It failed. The pass continues; the reason is reported.
60    Failed(String),
61}
62
63/// The result of one integration pass.
64#[derive(Debug, Default)]
65pub struct SetupReport {
66    items: Vec<(&'static str, Outcome)>,
67}
68
69impl SetupReport {
70    fn push(&mut self, name: &'static str, outcome: Outcome) {
71        self.items.push((name, outcome));
72    }
73
74    /// Whether anything at all was created by this pass.
75    pub fn changed_anything(&self) -> bool {
76        self.items
77            .iter()
78            .any(|(_, o)| matches!(o, Outcome::Installed))
79    }
80
81    /// Whether anything needs the user's attention.
82    pub fn needs_attention(&self) -> bool {
83        self.items
84            .iter()
85            .any(|(_, o)| matches!(o, Outcome::Skipped(_) | Outcome::Failed(_)))
86    }
87
88    /// Print the report.
89    ///
90    /// `verbose` is for the explicit `devp setup`, where "already installed" is the
91    /// answer the user asked for. The automatic pass passes `false` and stays silent
92    /// about everything that was already fine.
93    pub fn print(&self, verbose: bool) {
94        for (name, outcome) in &self.items {
95            match outcome {
96                Outcome::Installed => output::print_success(&format!("{name}: installed.")),
97                Outcome::AlreadyPresent if verbose => {
98                    output::print_info(&format!("{name}: already installed."));
99                }
100                Outcome::AlreadyPresent => {}
101                Outcome::Skipped(why) => {
102                    output::print_warning(&format!("{name}: skipped — {why}"));
103                }
104                Outcome::Failed(why) => {
105                    output::print_error(&format!("{name}: failed — {why}"));
106                }
107            }
108        }
109    }
110}
111
112/// Where the installers put the binary, and the one directory nothing else owns.
113fn managed_exe_path() -> Result<PathBuf> {
114    let name = if cfg!(windows) {
115        "dev-prune.exe"
116    } else {
117        "dev-prune"
118    };
119    Ok(Registry::config_dir()?.join("bin").join(name))
120}
121
122/// Absolute path to a copy of this binary that will still be there next week.
123///
124/// Anything that writes a path down for later — the OS scheduler, the git hooks — has to
125/// use this instead of [`std::env::current_exe`]. dev-prune ships through npm and PyPI as
126/// well as the installers, so the running executable is often somewhere a package manager
127/// owns and will delete: npm's `_npx` cache, uv's ephemeral tool environment, or
128/// `target/debug` during development. An entry recorded there breaks the moment that
129/// directory goes, and neither of these has anywhere to complain — the scheduled task
130/// fails silently every interval, and the hook discards its own output by design. The
131/// only symptom is that nothing ever happens again.
132///
133/// `<config>/bin` is where `install.sh` and `install.ps1` put the binary and nothing else
134/// deletes, so prefer the copy there. When there is none, put one there: the binary that
135/// is running right now is precisely the one that is going to be missing later.
136pub fn stable_exe_path() -> PathBuf {
137    let current = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("dev-prune"));
138    let Ok(managed) = managed_exe_path() else {
139        return current;
140    };
141    if managed == current {
142        return managed;
143    }
144    if managed.is_file() {
145        refresh_managed_copy_if_stale(&current, &managed);
146        return managed;
147    }
148
149    // Only ever clone something that is actually this CLI. `current_exe()` under `cargo
150    // test` is the test harness, and copying that into the config directory would be both
151    // wrong and slow.
152    if !is_this_cli(&current) {
153        return current;
154    }
155
156    let Some(parent) = managed.parent() else {
157        return current;
158    };
159    if fs::create_dir_all(parent).is_err() {
160        return current;
161    }
162    // Hard link where the filesystem allows it — that also keeps the bytes alive when the
163    // package manager deletes the directory the original came from.
164    if fs::hard_link(&current, &managed).is_ok() {
165        return managed;
166    }
167
168    // The same hazard `ensure_alias` documents, through a narrower window: the check at the
169    // top of this function saw no managed copy, but another process created one — as a hard
170    // link to `current` — before the link above ran. `fs::copy` opens its destination with
171    // O_TRUNC, and truncating a hard link empties the shared inode, so the copy would
172    // destroy the very binary it is copying.
173    if managed.is_file() {
174        return managed;
175    }
176
177    // Stage beside and rename into place. A copy straight onto the final name has a
178    // window where the file exists but is incomplete — and this path is what the
179    // scheduler and hooks get registered against, so a process killed mid-copy would
180    // leave a torn binary that every later pass happily points at.
181    let staging = managed.with_extension("new");
182    if fs::copy(&current, &staging).is_ok() && fs::rename(&staging, &managed).is_ok() {
183        return managed;
184    }
185    let _ = fs::remove_file(&staging);
186    // The rename loses only to a concurrent invocation that installed its own copy,
187    // which serves exactly as well.
188    if managed.is_file() { managed } else { current }
189}
190
191/// Whether this path names one of the CLI's own binaries, by file stem.
192fn is_this_cli(path: &std::path::Path) -> bool {
193    path.file_stem()
194        .and_then(|s| s.to_str())
195        .is_some_and(|stem| stem == "dev-prune" || stem == "devp")
196}
197
198/// Replace the managed copy when it is an older release than the binary running now.
199///
200/// The scheduler and the hooks point at the managed copy precisely because it outlives
201/// package-manager caches — which also means an upgrade through cargo, npm or uv changes
202/// the running binary but not the one the integrations run, and the machine quietly
203/// keeps pruning with the previous version forever.
204///
205/// Staleness is decided by asking the copy its version, not by mtime or content: an
206/// *older* binary running out of a stale npx cache must not overwrite a newer managed
207/// copy, and content inequality cannot say which of the two is the upgrade. A copy that
208/// cannot state a version at all is replaced too — whatever it is, it is not a working
209/// build of this CLI.
210fn refresh_managed_copy_if_stale(current: &std::path::Path, managed: &std::path::Path) {
211    if !is_this_cli(current) || same_contents(managed, current) {
212        return;
213    }
214    match (binary_version(managed), parse_version(constants::VERSION)) {
215        (Some(theirs), Some(ours)) if theirs >= ours => return,
216        _ => {}
217    }
218    // Write beside and rename into place, so a scheduler firing mid-copy never runs a
219    // torn binary. A managed copy that is itself running cannot be renamed over on
220    // Windows; the refresh simply waits for a pass when it is not.
221    let staging = managed.with_extension("new");
222    if fs::copy(current, &staging).is_ok() && fs::rename(&staging, managed).is_err() {
223        let _ = fs::remove_file(&staging);
224    }
225}
226
227/// The `major.minor.patch` a binary reports for itself, if it can.
228fn binary_version(exe: &std::path::Path) -> Option<(u64, u64, u64)> {
229    let output = std::process::Command::new(exe)
230        .arg("--version")
231        .output()
232        .ok()?;
233    if !output.status.success() {
234        return None;
235    }
236    String::from_utf8_lossy(&output.stdout)
237        .split_whitespace()
238        .find_map(parse_version)
239}
240
241/// Parse `x.y.z` into an orderable triple. Anything else — including the pre-release
242/// and build suffixes this project never publishes — answers `None`.
243fn parse_version(text: &str) -> Option<(u64, u64, u64)> {
244    let mut parts = text.split('.');
245    let triple = (
246        parts.next()?.parse().ok()?,
247        parts.next()?.parse().ok()?,
248        parts.next()?.parse().ok()?,
249    );
250    parts.next().is_none().then_some(triple)
251}
252
253/// Keep `dev-prune` and `devp` beside each other, whichever of the two is running.
254///
255/// The pair is one binary under two names, and either one can be the survivor. An upgrade
256/// that could not replace a running `devp` leaves a stale alias; an antivirus quarantine,
257/// a half-finished uninstall or a `Remove-Item` aimed at the wrong name leaves only
258/// `devp`. So this restores *the other* name in whichever direction is missing, rather
259/// than only ever creating `devp` — running either one puts the pair back.
260pub fn ensure_alias() -> Outcome {
261    let Ok(current_exe) = std::env::current_exe() else {
262        return Outcome::Failed("could not locate the running executable".to_string());
263    };
264    let Some(parent_dir) = current_exe.parent() else {
265        return Outcome::Failed("the running executable has no parent directory".to_string());
266    };
267
268    ensure_twin_of(&current_exe, parent_dir)
269}
270
271/// The half of [`ensure_alias`] that takes its paths as arguments, so tests can drive both
272/// directions without being the binary they are testing.
273fn ensure_twin_of(current_exe: &std::path::Path, parent_dir: &std::path::Path) -> Outcome {
274    let running_as_alias = current_exe
275        .file_stem()
276        .and_then(|s| s.to_str())
277        .is_some_and(|stem| stem == "devp");
278
279    // `dev-prune` is the canonical name, and only it may overwrite its twin.
280    //
281    // Installers write `dev-prune` first and upgrades replace it first, so it is never the
282    // older of the two — a stale `devp` is worth replacing, because otherwise it silently
283    // runs the previous version. The reverse is not safe: an upgrade that replaced
284    // `dev-prune` and then failed on a running `devp` leaves exactly the state where the
285    // alias is the *older* binary, and refreshing from there would quietly reinstall the
286    // version the user just upgraded away from. So `devp` may only create a `dev-prune`
287    // that is missing outright.
288    let (twin_name, may_refresh) = if running_as_alias {
289        (
290            if cfg!(windows) {
291                "dev-prune.exe"
292            } else {
293                "dev-prune"
294            },
295            false,
296        )
297    } else {
298        (if cfg!(windows) { "devp.exe" } else { "devp" }, true)
299    };
300    let twin_exe = parent_dir.join(twin_name);
301
302    if twin_exe.exists() {
303        if !may_refresh || same_contents(&twin_exe, current_exe) {
304            return Outcome::AlreadyPresent;
305        }
306        // Replacing a running executable fails on Windows; that is fine, the alias is
307        // simply refreshed by the next invocation that is not itself `devp`.
308        if fs::remove_file(&twin_exe).is_err() {
309            return Outcome::Skipped(format!(
310                "`{twin_name}` is in use and could not be refreshed — re-run `devp setup` \
311                 from a terminal that is not running it"
312            ));
313        }
314    }
315
316    if fs::hard_link(current_exe, &twin_exe).is_ok() {
317        return Outcome::Installed;
318    }
319
320    // The copy is the fallback for filesystems without hard links — but it must never
321    // run when the alias already exists, because the reason `hard_link` usually fails is
322    // that another process created it a moment ago, as a hard link to this very
323    // executable. `fs::copy` opens its destination with O_TRUNC, and truncating a hard
324    // link truncates the shared inode: the copy would empty the running binary and then
325    // copy zero bytes from it.
326    //
327    // That is not hypothetical. It is what turned every macOS CI run red. The 28
328    // integration tests launch at once, one wins the link, the losers fall through to
329    // here, and `target/debug/dev-prune` becomes a zero-byte file. macOS `posix_spawn`
330    // answers ENOEXEC by handing the file to `/bin/sh`, so every later invocation
331    // "succeeded" with exit 0 and printed nothing — for two hours the tests looked like
332    // 27 unrelated assertion failures.
333    if twin_exe.exists() {
334        return Outcome::AlreadyPresent;
335    }
336
337    // Stage beside and rename into place: copied straight onto the final name, the
338    // alias would exist-but-be-incomplete for the length of the copy, and a `devp`
339    // typed in that window executes a torn binary.
340    let staging = twin_exe.with_extension("new");
341    if fs::copy(current_exe, &staging).is_ok() && fs::rename(&staging, &twin_exe).is_ok() {
342        return Outcome::Installed;
343    }
344    let _ = fs::remove_file(&staging);
345    if twin_exe.exists() {
346        // A concurrent invocation won the rename; its alias serves exactly as well.
347        return Outcome::AlreadyPresent;
348    }
349    Outcome::Failed(format!(
350        "could not create `{}`",
351        output::clean_path(&twin_exe)
352    ))
353}
354
355/// Sameness test for two executables, cheap in the common case.
356///
357/// A hard link makes size and mtime equal by construction, so the usual layout answers
358/// without reading either file. When only the mtime differs — the alias came from the
359/// copy fallback, which does not preserve timestamps — the bytes themselves decide,
360/// because calling that pair "different" made every single invocation delete and
361/// recreate an alias whose content never changed.
362fn same_contents(a: &std::path::Path, b: &std::path::Path) -> bool {
363    let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) else {
364        return false;
365    };
366    if ma.len() != mb.len() {
367        return false;
368    }
369    if ma.modified().ok() == mb.modified().ok() {
370        return true;
371    }
372    match (fs::read(a), fs::read(b)) {
373        (Ok(ca), Ok(cb)) => ca == cb,
374        _ => false,
375    }
376}
377
378/// Path that `devp skill` and this module export `SKILL.md` to.
379pub fn skill_path() -> Result<PathBuf> {
380    Ok(Registry::config_dir()?.join("SKILL.md"))
381}
382
383/// Export the bundled `SKILL.md` so AI assistants have something to read.
384///
385/// Rewritten whenever it differs from the embedded copy, since an upgrade that changes
386/// the skill must not leave the previous version's instructions on disk.
387pub fn ensure_skill_file() -> Outcome {
388    match Registry::config_dir() {
389        Ok(dir) => ensure_skill_file_in(&dir),
390        Err(_) => Outcome::Failed("could not determine the config directory".to_string()),
391    }
392}
393
394fn ensure_skill_file_in(config_dir: &std::path::Path) -> Outcome {
395    let target = config_dir.join("SKILL.md");
396
397    if fs::read_to_string(&target).is_ok_and(|current| current == EMBEDDED_SKILL_MD) {
398        return Outcome::AlreadyPresent;
399    }
400
401    let _ = fs::create_dir_all(config_dir);
402    match fs::write(&target, EMBEDDED_SKILL_MD) {
403        Ok(()) => Outcome::Installed,
404        Err(e) => Outcome::Failed(format!(
405            "could not write {}: {e}",
406            output::clean_path(&target)
407        )),
408    }
409}
410
411/// Write the icon assets and register `*.devprune.json` with the OS file manager.
412///
413/// Part of the automatic pass rather than a separate errand, because "the config file has
414/// an icon" is not a thing anybody thinks to go and ask for. Everything it writes lives
415/// under the config directory and the user's own XDG data directory, `devp uninstall`
416/// removes all of it, and it touches no editor settings, no PATH and no shell profile —
417/// so there is nothing here that needs to be asked about first.
418///
419/// Unlike the hooks and the scheduler, this has no opt-out switch of its own. Files
420/// dropped into the user's data directory are not a background process and not a change
421/// in behaviour; `auto_setup` already covers "install nothing at all".
422fn ensure_icons() -> Outcome {
423    if crate::commands::icon::is_registered() {
424        return Outcome::AlreadyPresent;
425    }
426    match crate::commands::icon::sync_app_directory() {
427        Ok(()) => Outcome::Installed,
428        Err(e) => Outcome::Failed(format!("{e:#}")),
429    }
430}
431
432/// Install the global Git hooks, unless git is absent or the slot belongs to someone else.
433///
434/// `chain` is `auto_hooks_chain`: with it on, a slot that belongs to husky is not a
435/// reason to skip, because dev-prune can install in front and forward every hook back.
436pub fn ensure_hooks(chain: bool) -> Outcome {
437    if !hook::git_available() {
438        return Outcome::Skipped(format!(
439            "\n    {}",
440            hook::GIT_MISSING_HELP.replace('\n', "\n    ")
441        ));
442    }
443
444    match hook::state() {
445        // "Installed" is not the question — "installed and pointing at a binary that
446        // still exists" is. A hook backgrounds itself and discards its own output, so
447        // one left pointing at a deleted npm cache dies silently on every commit and
448        // nothing ever registers again; this pass is the only thing that ever looks.
449        Ok(HookState::Active) if hook_target_is_dead() => match hook::install() {
450            Ok(()) => Outcome::Installed,
451            Err(e) => Outcome::Failed(format!("{e:#}")),
452        },
453        Ok(HookState::Active) => Outcome::AlreadyPresent,
454        // Drift is repaired here rather than reported: the setup pass already runs on
455        // install, on update and on a schedule, and a chain the user opted into is a
456        // chain they want kept current.
457        Ok(HookState::Chained { drifted, .. }) if !drifted.is_empty() => {
458            match hook::install_with(true) {
459                Ok(()) => Outcome::Installed,
460                Err(e) => Outcome::Failed(format!("{e:#}")),
461            }
462        }
463        Ok(HookState::Chained { .. }) if hook_target_is_dead() => match hook::install_with(true) {
464            Ok(()) => Outcome::Installed,
465            Err(e) => Outcome::Failed(format!("{e:#}")),
466        },
467        Ok(HookState::Chained { .. }) => Outcome::AlreadyPresent,
468        Ok(HookState::Foreign(_)) if chain => match hook::install_with(true) {
469            Ok(()) => Outcome::Installed,
470            Err(e) => Outcome::Failed(format!("{e:#}")),
471        },
472        Ok(HookState::Foreign(existing)) => Outcome::Skipped(format!(
473            "`core.hooksPath` is already set to `{existing}`, which belongs to another tool.\n    \
474             Git allows only one hooks directory, so dev-prune will not take the slot.\n    \
475             `devp hook install --chain` installs in front of it instead — dev-prune registers \
476             the repo, then hands every hook on to `{existing}`, and `devp hook uninstall` puts \
477             the original setting back (`devp config set auto_hooks_chain true` makes that \
478             the standing answer). Or skip it: `devp link .` does the same job by hand."
479        )),
480        Ok(HookState::Absent) => match hook::install() {
481            Ok(()) => Outcome::Installed,
482            Err(e) => Outcome::Failed(format!("{e:#}")),
483        },
484        Err(e) => Outcome::Failed(format!("{e:#}")),
485    }
486}
487
488/// Whether the installed hooks name a binary that no longer exists.
489fn hook_target_is_dead() -> bool {
490    hook::registered_exe_path().is_some_and(|exe| !exe.exists())
491}
492
493/// Install the OS scheduler if it is not already registered.
494pub fn ensure_daemon(interval_days: u64) -> Outcome {
495    match daemon::daemon_status() {
496        // A task whose binary has been deleted keeps reporting itself `Ready` and dies
497        // the instant it fires, every interval, with nowhere to complain. Re-register
498        // it against the stable path instead of counting the corpse as present.
499        Ok(daemon::DaemonStatus::Installed)
500            if daemon::registered_exe_path().is_some_and(|exe| !exe.exists()) =>
501        {
502            match daemon::install_daemon(interval_days) {
503                Ok(()) => Outcome::Installed,
504                Err(e) => Outcome::Failed(format!("{e:#}")),
505            }
506        }
507        Ok(daemon::DaemonStatus::Installed) => Outcome::AlreadyPresent,
508        Ok(daemon::DaemonStatus::NotInstalled) => match daemon::install_daemon(interval_days) {
509            Ok(()) => Outcome::Installed,
510            Err(e) => Outcome::Failed(format!("{e:#}")),
511        },
512        // `Unknown` means the query itself could not be answered — the scheduler may
513        // well be there. Installing over it would fail on every command from now on, so
514        // this reports and steps over instead of guessing. The platform backends are
515        // written to keep this case narrow: anything they can answer definitely, they do.
516        Ok(daemon::DaemonStatus::Unknown(why)) => {
517            Outcome::Skipped(format!("scheduler state could not be read — {why}"))
518        }
519        Err(e) => Outcome::Failed(format!("{e:#}")),
520    }
521}
522
523/// Whether unattended installation is permitted at all.
524///
525/// Both switches exist because these integrations write outside dev-prune's own config
526/// directory — a scheduled task, a global git setting — and there are places that must
527/// never happen unasked: container images, CI, and this project's own test suite.
528pub fn auto_setup_enabled(registry: &Registry) -> bool {
529    !no_auto_setup_requested() && registry.settings.auto_setup && unattended_environment().is_none()
530}
531
532/// The reason this looks like a machine nobody is sitting at, if it does.
533///
534/// `DEV_PRUNE_NO_AUTO_SETUP` and `auto_setup` are both switches you have to set *before*
535/// the first run — which is exactly the run that installs things, so in a container or a
536/// CI job the damage is done by the time there is anywhere to set them. Detecting the
537/// environment is the only opt-out that works on the first run, which is the only run
538/// that matters here.
539///
540/// Deliberately conservative: every signal below is one that CI providers and container
541/// runtimes set themselves, so a developer's own shell will not trip it. Someone who
542/// genuinely wants the integrations in CI can still ask in so many words with
543/// `devp setup`, which never consults this.
544pub fn unattended_environment() -> Option<&'static str> {
545    // Set by GitHub Actions, GitLab CI, CircleCI, Travis, Jenkins (via pipeline), Woodpecker
546    // and most others. `CI=true` is the closest thing this space has to a standard.
547    for var in [
548        "CI",
549        "CONTINUOUS_INTEGRATION",
550        "BUILD_NUMBER",
551        "GITHUB_ACTIONS",
552    ] {
553        if let Some(value) = std::env::var_os(var) {
554            // `CI=false` is set explicitly by some tools to mean "not CI", and honouring
555            // the word rather than the presence is what the user plainly meant.
556            let value = value.to_string_lossy();
557            if !value.is_empty() && !value.eq_ignore_ascii_case("false") {
558                return Some("this looks like a CI runner");
559            }
560        }
561    }
562
563    // Docker writes this marker into every container it builds from a Dockerfile;
564    // Podman and other OCI runtimes write the `container` variable instead.
565    #[cfg(unix)]
566    if std::path::Path::new("/.dockerenv").exists() {
567        return Some("this looks like a container");
568    }
569    if std::env::var_os("container").is_some() {
570        return Some("this looks like a container");
571    }
572
573    None
574}
575
576/// Run an integration pass unless unattended installation is switched off.
577///
578/// Every caller that the user did not name explicitly goes through this. `devp setup`
579/// calls [`ensure_integrations`] directly: asking for it in so many words is consent.
580pub fn ensure_integrations_if_enabled(registry: &Registry) -> Option<SetupReport> {
581    auto_setup_enabled(registry).then(|| ensure_integrations(registry))
582}
583
584/// Run one integration pass, installing whatever is missing.
585///
586/// The two per-integration settings (`auto_daemon`, `auto_hooks`) are honoured here, so
587/// turning one off turns it off for every future pass as well as this one.
588pub fn ensure_integrations(registry: &Registry) -> SetupReport {
589    let mut report = SetupReport::default();
590
591    report.push("dev-prune/devp pair", ensure_alias());
592    report.push("SKILL.md", ensure_skill_file());
593    report.push("File icons", ensure_icons());
594
595    if registry.settings.auto_hooks {
596        report.push(
597            "Git hooks",
598            ensure_hooks(registry.settings.auto_hooks_chain),
599        );
600    } else {
601        report.push(
602            "Git hooks",
603            Outcome::Skipped("`auto_hooks` is false — enable with `devp hook install`".to_string()),
604        );
605    }
606
607    if registry.settings.auto_daemon {
608        report.push(
609            "Background scheduler",
610            ensure_daemon(registry.settings.check_interval_days),
611        );
612    } else {
613        report.push(
614            "Background scheduler",
615            Outcome::Skipped(
616                "`auto_daemon` is false — enable with `devp daemon install`".to_string(),
617            ),
618        );
619    }
620
621    report
622}
623
624/// Record that a pass completed for this version.
625fn write_stamp_in(config_dir: &std::path::Path) {
626    let _ = fs::create_dir_all(config_dir);
627    let _ = fs::write(config_dir.join(STAMP_FILE), constants::VERSION);
628}
629
630fn write_stamp() {
631    if let Ok(dir) = Registry::config_dir() {
632        write_stamp_in(&dir);
633    }
634}
635
636fn setup_is_due_in(config_dir: &std::path::Path) -> bool {
637    !fs::read_to_string(config_dir.join(STAMP_FILE))
638        .is_ok_and(|stamp| stamp.trim() == constants::VERSION)
639}
640
641/// Whether the unattended pass is due: a fresh install, or the first run after an upgrade.
642pub fn setup_is_due() -> bool {
643    Registry::config_dir()
644        .map(|dir| setup_is_due_in(&dir))
645        .unwrap_or(false)
646}
647
648/// The unattended pass, run at most once per installed version.
649///
650/// Called at the top of every command that a human typed. It is deliberately not called
651/// for the Git hook's `link --quiet` or the scheduler's `run --daemon`: those run without
652/// a terminal, and an integration pass that nobody can see is one nobody can refuse.
653pub fn auto_setup_if_due() {
654    if !setup_is_due() {
655        first_run_config_review();
656        return;
657    }
658
659    let Ok(registry) = Registry::load() else {
660        return;
661    };
662    let Some(report) = ensure_integrations_if_enabled(&registry) else {
663        // Suppressed. Stamp anyway, so a machine that opted out does not re-decide
664        // this on every single command.
665        write_stamp();
666        crate::commands::config::skip_config_review();
667        return;
668    };
669    if report.changed_anything() || report.needs_attention() {
670        output::print_header("dev-prune setup");
671        report.print(false);
672        if report.changed_anything() {
673            output::print_info(
674                "Run `devp setup --status` to review these, or `devp uninstall` to remove them.",
675            );
676        }
677        println!();
678    }
679    write_stamp();
680    first_run_config_review();
681}
682
683/// Put the defaults in front of the user, once, on a fresh install.
684///
685/// Separate from the integration stamp on purpose. The integrations are re-checked after
686/// every upgrade; the settings are not — being asked to reconfirm `idle_days` on each new
687/// version would be a nuisance, and the marker only disappears when the config directory
688/// does.
689///
690/// Every condition here is a way of asking "is there a person reading this?", because the
691/// alternative to asking is a prompt written into a log nobody will read, on a run that
692/// then blocks forever waiting for an answer.
693fn first_run_config_review() {
694    if !crate::commands::config::config_review_is_due() {
695        return;
696    }
697
698    use std::io::IsTerminal;
699    if unattended_environment().is_some()
700        || !std::io::stdin().is_terminal()
701        || !std::io::stdout().is_terminal()
702    {
703        crate::commands::config::skip_config_review();
704        return;
705    }
706
707    // Any error here is the wizard's own reporting; the command the user actually typed
708    // still runs. A failed walkthrough must not become a failed `devp status`.
709    if let Err(e) = crate::commands::config::run_wizard() {
710        output::print_warning(&format!("Could not run the first-run setup ({e:#})."));
711        crate::commands::config::skip_config_review();
712    }
713    println!();
714}
715
716/// Invalidate the stamp so the next human-run command performs a pass.
717///
718/// `uninstall` calls this in reverse — it writes the current stamp — so that removing the
719/// integrations is not immediately undone by the next command.
720pub fn suppress_next_auto_setup() {
721    write_stamp();
722}
723
724#[cfg(test)]
725mod tests {
726    use super::*;
727
728    #[test]
729    fn a_report_with_only_present_items_is_silent() {
730        let mut report = SetupReport::default();
731        report.push("a", Outcome::AlreadyPresent);
732        assert!(!report.changed_anything());
733        assert!(!report.needs_attention());
734    }
735
736    #[test]
737    fn skipped_and_failed_both_ask_for_attention() {
738        let mut skipped = SetupReport::default();
739        skipped.push("a", Outcome::Skipped("no git".into()));
740        assert!(skipped.needs_attention());
741        assert!(!skipped.changed_anything());
742
743        let mut failed = SetupReport::default();
744        failed.push("a", Outcome::Failed("boom".into()));
745        assert!(failed.needs_attention());
746    }
747
748    #[test]
749    fn an_install_counts_as_a_change() {
750        let mut report = SetupReport::default();
751        report.push("a", Outcome::Installed);
752        assert!(report.changed_anything());
753    }
754
755    #[test]
756    fn the_skill_export_lands_in_the_config_directory() {
757        let dir = tempfile::TempDir::new().unwrap();
758        assert_eq!(ensure_skill_file_in(dir.path()), Outcome::Installed);
759        // A second pass finds byte-identical content and leaves it alone.
760        assert_eq!(ensure_skill_file_in(dir.path()), Outcome::AlreadyPresent);
761        let written = fs::read_to_string(dir.path().join("SKILL.md")).unwrap();
762        assert_eq!(written, EMBEDDED_SKILL_MD);
763    }
764
765    #[test]
766    fn a_stale_skill_export_is_rewritten() {
767        // An upgrade must not leave the previous version's instructions on disk.
768        let dir = tempfile::TempDir::new().unwrap();
769        fs::write(dir.path().join("SKILL.md"), "# an older version").unwrap();
770        assert_eq!(ensure_skill_file_in(dir.path()), Outcome::Installed);
771        let written = fs::read_to_string(dir.path().join("SKILL.md")).unwrap();
772        assert_eq!(written, EMBEDDED_SKILL_MD);
773    }
774
775    #[test]
776    fn the_stamp_gates_the_unattended_pass() {
777        let dir = tempfile::TempDir::new().unwrap();
778        assert!(setup_is_due_in(dir.path()), "a fresh install is due");
779        write_stamp_in(dir.path());
780        assert!(
781            !setup_is_due_in(dir.path()),
782            "the same version is not due twice"
783        );
784        fs::write(dir.path().join(STAMP_FILE), "0.0.1").unwrap();
785        assert!(setup_is_due_in(dir.path()), "an upgrade is due again");
786    }
787
788    /// The alias must never be written with a copy while it already exists.
789    ///
790    /// A hard link and its target share one inode, so `fs::copy` onto the alias empties
791    /// the binary it was copied from. This reproduces the exact shape of that bug — link
792    /// first, then ask for the alias again — and asserts the original still has its
793    /// bytes. The real failure was silent: a zero-byte executable that macOS runs
794    /// through `/bin/sh`, which exits 0 and prints nothing.
795    #[test]
796    fn refreshing_an_alias_that_is_a_hard_link_does_not_empty_the_binary() {
797        let dir = tempfile::TempDir::new().unwrap();
798        let binary = dir.path().join("dev-prune");
799        let alias = dir.path().join("devp");
800        fs::write(&binary, vec![b'M'; 4096]).unwrap();
801
802        if fs::hard_link(&binary, &alias).is_err() {
803            return; // Filesystem without hard links; the hazard cannot arise.
804        }
805
806        // What `ensure_alias` does when its `hard_link` loses the race: the alias is
807        // already there, so it must stop rather than fall through to the copy.
808        assert!(fs::hard_link(&binary, &alias).is_err(), "EEXIST expected");
809        assert!(alias.exists(), "the guard's condition");
810
811        assert_eq!(
812            fs::metadata(&binary).unwrap().len(),
813            4096,
814            "the running binary was truncated by refreshing its own alias"
815        );
816    }
817
818    /// The on-disk file name for one of the pair, on this platform.
819    fn exe_name(stem: &str) -> String {
820        if cfg!(windows) {
821            format!("{stem}.exe")
822        } else {
823            stem.to_string()
824        }
825    }
826
827    #[test]
828    fn dev_prune_creates_devp_beside_it() {
829        let dir = tempfile::TempDir::new().unwrap();
830        let canonical = dir.path().join(exe_name("dev-prune"));
831        fs::write(&canonical, "the binary").unwrap();
832
833        assert_eq!(ensure_twin_of(&canonical, dir.path()), Outcome::Installed);
834        let alias = dir.path().join(exe_name("devp"));
835        assert!(alias.is_file(), "`devp` was not created");
836        assert_eq!(fs::read_to_string(&alias).unwrap(), "the binary");
837    }
838
839    /// The pair has to be recoverable from either side.
840    ///
841    /// Deleting `dev-prune` and leaving `devp` is not hypothetical: an antivirus
842    /// quarantine, a half-finished uninstall, or a `Remove-Item` aimed at one name all
843    /// produce it. Before this, `devp setup` reported the alias already present and did
844    /// nothing, because the only direction it knew how to repair was the other one.
845    #[test]
846    fn devp_restores_a_missing_dev_prune() {
847        let dir = tempfile::TempDir::new().unwrap();
848        let alias = dir.path().join(exe_name("devp"));
849        fs::write(&alias, "the binary").unwrap();
850
851        assert_eq!(ensure_twin_of(&alias, dir.path()), Outcome::Installed);
852        let canonical = dir.path().join(exe_name("dev-prune"));
853        assert!(canonical.is_file(), "`dev-prune` was not put back");
854        assert_eq!(fs::read_to_string(&canonical).unwrap(), "the binary");
855    }
856
857    /// `devp` may create `dev-prune`, never overwrite it.
858    ///
859    /// Repairing in both directions opens a downgrade: an upgrade replaces `dev-prune`
860    /// first and can then fail on a `devp` that is running, which leaves the alias holding
861    /// the *older* binary. If the alias were allowed to refresh its twin from there, the
862    /// next `devp setup` would quietly reinstall the version the user just upgraded away
863    /// from — and report it as a repair.
864    #[test]
865    fn devp_does_not_overwrite_an_existing_dev_prune() {
866        let dir = tempfile::TempDir::new().unwrap();
867        let alias = dir.path().join(exe_name("devp"));
868        let canonical = dir.path().join(exe_name("dev-prune"));
869        fs::write(&alias, "the previous version").unwrap();
870        fs::write(&canonical, "the version just upgraded to").unwrap();
871
872        assert_eq!(
873            ensure_twin_of(&alias, dir.path()),
874            Outcome::AlreadyPresent,
875            "`devp` must leave an existing `dev-prune` alone"
876        );
877        assert_eq!(
878            fs::read_to_string(&canonical).unwrap(),
879            "the version just upgraded to",
880            "`devp` downgraded the binary it was supposed to leave alone"
881        );
882    }
883
884    #[test]
885    fn versions_parse_strictly_or_not_at_all() {
886        assert_eq!(parse_version("1.2.3"), Some((1, 2, 3)));
887        assert_eq!(parse_version("10.0.0"), Some((10, 0, 0)));
888        // Anything this project does not publish must answer None, because a None
889        // means "replace the copy" and a mis-parse would order versions wrongly.
890        assert_eq!(parse_version("1.2"), None);
891        assert_eq!(parse_version("1.2.3.4"), None);
892        assert_eq!(parse_version("1.2.3-rc1"), None);
893        assert_eq!(parse_version("dev-prune"), None);
894        // The version this binary was built with has to be parseable, or the refresh
895        // logic can never decide anything.
896        assert!(parse_version(constants::VERSION).is_some());
897    }
898
899    #[test]
900    fn ordering_of_version_triples_matches_semver() {
901        assert!(parse_version("1.1.0") > parse_version("1.0.9"));
902        assert!(parse_version("2.0.0") > parse_version("1.99.99"));
903        assert!(parse_version("1.0.10") > parse_version("1.0.9"));
904    }
905
906    #[test]
907    fn the_exported_skill_is_the_one_the_binary_was_built_with() {
908        // `SKILL.md` is embedded, so a doc edit ships only if the binary is rebuilt.
909        // Guard the two properties every consumer of it depends on.
910        assert!(EMBEDDED_SKILL_MD.starts_with("---"), "needs frontmatter");
911        assert!(
912            !EMBEDDED_SKILL_MD.contains("file:///"),
913            "SKILL.md is written to every user's machine — it must not contain \
914             absolute paths from the author's checkout"
915        );
916    }
917}