Skip to main content

dev_prune/
setup.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Copyright 2026 VKrishna04
5//
6// Licensed under the Apache License, Version 2.0 (the "License");
7// you may not use this file except in compliance with the License.
8// You may obtain a copy of the License at
9//
10//     http://www.apache.org/licenses/LICENSE-2.0
11//
12// Unless required by applicable law or agreed to in writing, software
13// distributed under the License is distributed on an "AS IS" BASIS,
14// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15// See the License for the specific language governing permissions and
16// limitations under the License.
17
18//! Idempotent installation of dev-prune's integrations.
19//!
20//! dev-prune is only really installed once the parts that let it work without being
21//! thought about are in place: the `devp` alias, the exported `SKILL.md` that AI
22//! assistants read, the Git hooks that keep the registry current, and the OS scheduler
23//! that runs the passes. Each one here is created **only when it is missing**, which is
24//! what makes it safe to run on every install, reinstall and upgrade — and it does run
25//! on each of those, through the version stamp written at the end of a completed pass.
26//!
27//! Nothing in here is fatal. A machine without `git`, a `core.hooksPath` that belongs to
28//! husky, a locked-down scheduler: each is reported and stepped over, because none of
29//! them should stop `devp init` from registering repositories.
30
31use std::fs;
32use std::path::PathBuf;
33
34use anyhow::Result;
35
36use crate::commands::hook::{self, HookState};
37use crate::commands::skill::EMBEDDED_SKILL_MD;
38use crate::config::Registry;
39use crate::constants;
40use crate::daemon;
41use crate::output;
42
43/// File in the config directory recording the version whose last integration pass
44/// completed. A missing or older stamp is what triggers the automatic pass, so a fresh
45/// install and an upgrade both self-heal exactly once.
46const STAMP_FILE: &str = "setup-stamp";
47
48/// Environment variable that suppresses the automatic pass entirely.
49///
50/// For images, CI and anyone who wants the binary and nothing else. `devp setup` still
51/// works when it is set — this only governs the unattended pass.
52pub const ENV_NO_AUTO_SETUP: &str = "DEV_PRUNE_NO_AUTO_SETUP";
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.
117fn managed_exe_path() -> Result<PathBuf> {
118    let name = if cfg!(windows) {
119        "dev-prune.exe"
120    } else {
121        "dev-prune"
122    };
123    Ok(Registry::config_dir()?.join("bin").join(name))
124}
125
126/// Absolute path to a copy of this binary that will still be there next week.
127///
128/// Anything that writes a path down for later — the OS scheduler, the git hooks — has to
129/// use this instead of [`std::env::current_exe`]. dev-prune ships through npm and PyPI as
130/// well as the installers, so the running executable is often somewhere a package manager
131/// owns and will delete: npm's `_npx` cache, uv's ephemeral tool environment, or
132/// `target/debug` during development. An entry recorded there breaks the moment that
133/// directory goes, and neither of these has anywhere to complain — the scheduled task
134/// fails silently every interval, and the hook discards its own output by design. The
135/// only symptom is that nothing ever happens again.
136///
137/// `<config>/bin` is where `install.sh` and `install.ps1` put the binary and nothing else
138/// deletes, so prefer the copy there. When there is none, put one there: the binary that
139/// is running right now is precisely the one that is going to be missing later.
140pub fn stable_exe_path() -> PathBuf {
141    let current = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("dev-prune"));
142    let Ok(managed) = managed_exe_path() else {
143        return current;
144    };
145    if managed == current || managed.is_file() {
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    let is_cli = current
153        .file_stem()
154        .and_then(|s| s.to_str())
155        .is_some_and(|stem| stem == "dev-prune" || stem == "devp");
156    if !is_cli {
157        return current;
158    }
159
160    let Some(parent) = managed.parent() else {
161        return current;
162    };
163    if fs::create_dir_all(parent).is_err() {
164        return current;
165    }
166    // Hard link where the filesystem allows it — that also keeps the bytes alive when the
167    // package manager deletes the directory the original came from.
168    if fs::hard_link(&current, &managed).is_ok() {
169        return managed;
170    }
171
172    // The same hazard `ensure_alias` documents, through a narrower window: the check at the
173    // top of this function saw no managed copy, but another process created one — as a hard
174    // link to `current` — before the link above ran. `fs::copy` opens its destination with
175    // O_TRUNC, and truncating a hard link empties the shared inode, so the copy would
176    // destroy the very binary it is copying.
177    if managed.is_file() {
178        return managed;
179    }
180
181    if fs::copy(&current, &managed).is_ok() {
182        managed
183    } else {
184        current
185    }
186}
187
188/// Create the `devp` alias next to the real binary, and keep it current.
189///
190/// A stale alias is worse than a missing one: it silently runs the previous version
191/// after an upgrade that replaced only `dev-prune`. So the alias is replaced whenever it
192/// no longer matches the binary that is running.
193pub fn ensure_alias() -> Outcome {
194    let Ok(current_exe) = std::env::current_exe() else {
195        return Outcome::Failed("could not locate the running executable".to_string());
196    };
197    let Some(parent_dir) = current_exe.parent() else {
198        return Outcome::Failed("the running executable has no parent directory".to_string());
199    };
200
201    let alias_name = if cfg!(windows) { "devp.exe" } else { "devp" };
202    let alias_exe = parent_dir.join(alias_name);
203
204    // Invoked *as* `devp`: the alias is the binary, there is nothing to link.
205    if alias_exe == current_exe {
206        return Outcome::AlreadyPresent;
207    }
208
209    if alias_exe.exists() {
210        if same_contents(&alias_exe, &current_exe) {
211            return Outcome::AlreadyPresent;
212        }
213        // Replacing a running executable fails on Windows; that is fine, the alias is
214        // simply refreshed by the next invocation that is not itself `devp`.
215        if fs::remove_file(&alias_exe).is_err() {
216            return Outcome::Skipped(format!(
217                "`{alias_name}` is in use and could not be refreshed — re-run `devp setup` \
218                 from a terminal that is not running it"
219            ));
220        }
221    }
222
223    if fs::hard_link(&current_exe, &alias_exe).is_ok() {
224        return Outcome::Installed;
225    }
226
227    // The copy is the fallback for filesystems without hard links — but it must never
228    // run when the alias already exists, because the reason `hard_link` usually fails is
229    // that another process created it a moment ago, as a hard link to this very
230    // executable. `fs::copy` opens its destination with O_TRUNC, and truncating a hard
231    // link truncates the shared inode: the copy would empty the running binary and then
232    // copy zero bytes from it.
233    //
234    // That is not hypothetical. It is what turned every macOS CI run red. The 28
235    // integration tests launch at once, one wins the link, the losers fall through to
236    // here, and `target/debug/dev-prune` becomes a zero-byte file. macOS `posix_spawn`
237    // answers ENOEXEC by handing the file to `/bin/sh`, so every later invocation
238    // "succeeded" with exit 0 and printed nothing — for two hours the tests looked like
239    // 27 unrelated assertion failures.
240    if alias_exe.exists() {
241        return Outcome::AlreadyPresent;
242    }
243
244    if fs::copy(&current_exe, &alias_exe).is_ok() {
245        Outcome::Installed
246    } else {
247        Outcome::Failed(format!(
248            "could not create `{}`",
249            output::clean_path(&alias_exe)
250        ))
251    }
252}
253
254/// Cheap sameness test for two executables: same size and same modification time.
255///
256/// A hard link makes both true by construction, so the common case answers correctly
257/// without hashing megabytes on every invocation.
258fn same_contents(a: &std::path::Path, b: &std::path::Path) -> bool {
259    let (Ok(ma), Ok(mb)) = (fs::metadata(a), fs::metadata(b)) else {
260        return false;
261    };
262    ma.len() == mb.len() && ma.modified().ok() == mb.modified().ok()
263}
264
265/// Path that `devp skill` and this module export `SKILL.md` to.
266pub fn skill_path() -> Result<PathBuf> {
267    Ok(Registry::config_dir()?.join("SKILL.md"))
268}
269
270/// Export the bundled `SKILL.md` so AI assistants have something to read.
271///
272/// Rewritten whenever it differs from the embedded copy, since an upgrade that changes
273/// the skill must not leave the previous version's instructions on disk.
274pub fn ensure_skill_file() -> Outcome {
275    match Registry::config_dir() {
276        Ok(dir) => ensure_skill_file_in(&dir),
277        Err(_) => Outcome::Failed("could not determine the config directory".to_string()),
278    }
279}
280
281fn ensure_skill_file_in(config_dir: &std::path::Path) -> Outcome {
282    let target = config_dir.join("SKILL.md");
283
284    if fs::read_to_string(&target).is_ok_and(|current| current == EMBEDDED_SKILL_MD) {
285        return Outcome::AlreadyPresent;
286    }
287
288    let _ = fs::create_dir_all(config_dir);
289    match fs::write(&target, EMBEDDED_SKILL_MD) {
290        Ok(()) => Outcome::Installed,
291        Err(e) => Outcome::Failed(format!(
292            "could not write {}: {e}",
293            output::clean_path(&target)
294        )),
295    }
296}
297
298/// Write the icon assets and register `*.devprune.json` with the OS file manager.
299///
300/// Part of the automatic pass rather than a separate errand, because "the config file has
301/// an icon" is not a thing anybody thinks to go and ask for. Everything it writes lives
302/// under the config directory and the user's own XDG data directory, `devp uninstall`
303/// removes all of it, and it touches no editor settings, no PATH and no shell profile —
304/// so there is nothing here that needs to be asked about first.
305///
306/// Unlike the hooks and the scheduler, this has no opt-out switch of its own. Files
307/// dropped into the user's data directory are not a background process and not a change
308/// in behaviour; `auto_setup` already covers "install nothing at all".
309fn ensure_icons() -> Outcome {
310    if crate::commands::icon::is_registered() {
311        return Outcome::AlreadyPresent;
312    }
313    match crate::commands::icon::sync_app_directory() {
314        Ok(()) => Outcome::Installed,
315        Err(e) => Outcome::Failed(format!("{e:#}")),
316    }
317}
318
319/// Install the global Git hooks, unless git is absent or the slot belongs to someone else.
320///
321/// `chain` is `auto_hooks_chain`: with it on, a slot that belongs to husky is not a
322/// reason to skip, because dev-prune can install in front and forward every hook back.
323pub fn ensure_hooks(chain: bool) -> Outcome {
324    if !hook::git_available() {
325        return Outcome::Skipped(format!(
326            "\n    {}",
327            hook::GIT_MISSING_HELP.replace('\n', "\n    ")
328        ));
329    }
330
331    match hook::state() {
332        Ok(HookState::Active) => Outcome::AlreadyPresent,
333        // Drift is repaired here rather than reported: the setup pass already runs on
334        // install, on update and on a schedule, and a chain the user opted into is a
335        // chain they want kept current.
336        Ok(HookState::Chained { drifted, .. }) if !drifted.is_empty() => {
337            match hook::install_with(true) {
338                Ok(()) => Outcome::Installed,
339                Err(e) => Outcome::Failed(format!("{e:#}")),
340            }
341        }
342        Ok(HookState::Chained { .. }) => Outcome::AlreadyPresent,
343        Ok(HookState::Foreign(_)) if chain => match hook::install_with(true) {
344            Ok(()) => Outcome::Installed,
345            Err(e) => Outcome::Failed(format!("{e:#}")),
346        },
347        Ok(HookState::Foreign(existing)) => Outcome::Skipped(format!(
348            "`core.hooksPath` is already set to `{existing}`, which belongs to another tool.\n    \
349             Git allows only one hooks directory, so dev-prune will not take the slot.\n    \
350             `devp hook install --chain` installs in front of it instead — dev-prune registers \
351             the repo, then hands every hook on to `{existing}`, and `devp hook uninstall` puts \
352             the original setting back (`devp config set auto_hooks_chain true` makes that \
353             the standing answer). Or skip it: `devp link .` does the same job by hand."
354        )),
355        Ok(HookState::Absent) => match hook::install() {
356            Ok(()) => Outcome::Installed,
357            Err(e) => Outcome::Failed(format!("{e:#}")),
358        },
359        Err(e) => Outcome::Failed(format!("{e:#}")),
360    }
361}
362
363/// Install the OS scheduler if it is not already registered.
364pub fn ensure_daemon(interval_days: u64) -> Outcome {
365    match daemon::daemon_status() {
366        Ok(daemon::DaemonStatus::Installed) => Outcome::AlreadyPresent,
367        Ok(daemon::DaemonStatus::NotInstalled) => match daemon::install_daemon(interval_days) {
368            Ok(()) => Outcome::Installed,
369            Err(e) => Outcome::Failed(format!("{e:#}")),
370        },
371        // `Unknown` means the query itself could not be answered — the scheduler may
372        // well be there. Installing over it would fail on every command from now on, so
373        // this reports and steps over instead of guessing. The platform backends are
374        // written to keep this case narrow: anything they can answer definitely, they do.
375        Ok(daemon::DaemonStatus::Unknown(why)) => {
376            Outcome::Skipped(format!("scheduler state could not be read — {why}"))
377        }
378        Err(e) => Outcome::Failed(format!("{e:#}")),
379    }
380}
381
382/// Whether unattended installation is permitted at all.
383///
384/// Both switches exist because these integrations write outside dev-prune's own config
385/// directory — a scheduled task, a global git setting — and there are places that must
386/// never happen unasked: container images, CI, and this project's own test suite.
387pub fn auto_setup_enabled(registry: &Registry) -> bool {
388    std::env::var_os(ENV_NO_AUTO_SETUP).is_none()
389        && registry.settings.auto_setup
390        && unattended_environment().is_none()
391}
392
393/// The reason this looks like a machine nobody is sitting at, if it does.
394///
395/// `DEV_PRUNE_NO_AUTO_SETUP` and `auto_setup` are both switches you have to set *before*
396/// the first run — which is exactly the run that installs things, so in a container or a
397/// CI job the damage is done by the time there is anywhere to set them. Detecting the
398/// environment is the only opt-out that works on the first run, which is the only run
399/// that matters here.
400///
401/// Deliberately conservative: every signal below is one that CI providers and container
402/// runtimes set themselves, so a developer's own shell will not trip it. Someone who
403/// genuinely wants the integrations in CI can still ask in so many words with
404/// `devp setup`, which never consults this.
405pub fn unattended_environment() -> Option<&'static str> {
406    // Set by GitHub Actions, GitLab CI, CircleCI, Travis, Jenkins (via pipeline), Woodpecker
407    // and most others. `CI=true` is the closest thing this space has to a standard.
408    for var in [
409        "CI",
410        "CONTINUOUS_INTEGRATION",
411        "BUILD_NUMBER",
412        "GITHUB_ACTIONS",
413    ] {
414        if let Some(value) = std::env::var_os(var) {
415            // `CI=false` is set explicitly by some tools to mean "not CI", and honouring
416            // the word rather than the presence is what the user plainly meant.
417            let value = value.to_string_lossy();
418            if !value.is_empty() && !value.eq_ignore_ascii_case("false") {
419                return Some("this looks like a CI runner");
420            }
421        }
422    }
423
424    // Docker writes this marker into every container it builds from a Dockerfile;
425    // Podman and other OCI runtimes write the `container` variable instead.
426    #[cfg(unix)]
427    if std::path::Path::new("/.dockerenv").exists() {
428        return Some("this looks like a container");
429    }
430    if std::env::var_os("container").is_some() {
431        return Some("this looks like a container");
432    }
433
434    None
435}
436
437/// Run an integration pass unless unattended installation is switched off.
438///
439/// Every caller that the user did not name explicitly goes through this. `devp setup`
440/// calls [`ensure_integrations`] directly: asking for it in so many words is consent.
441pub fn ensure_integrations_if_enabled(registry: &Registry) -> Option<SetupReport> {
442    auto_setup_enabled(registry).then(|| ensure_integrations(registry))
443}
444
445/// Run one integration pass, installing whatever is missing.
446///
447/// The two per-integration settings (`auto_daemon`, `auto_hooks`) are honoured here, so
448/// turning one off turns it off for every future pass as well as this one.
449pub fn ensure_integrations(registry: &Registry) -> SetupReport {
450    let mut report = SetupReport::default();
451
452    report.push("devp alias", ensure_alias());
453    report.push("SKILL.md", ensure_skill_file());
454    report.push("File icons", ensure_icons());
455
456    if registry.settings.auto_hooks {
457        report.push(
458            "Git hooks",
459            ensure_hooks(registry.settings.auto_hooks_chain),
460        );
461    } else {
462        report.push(
463            "Git hooks",
464            Outcome::Skipped("`auto_hooks` is false — enable with `devp hook install`".to_string()),
465        );
466    }
467
468    if registry.settings.auto_daemon {
469        report.push(
470            "Background scheduler",
471            ensure_daemon(registry.settings.check_interval_days),
472        );
473    } else {
474        report.push(
475            "Background scheduler",
476            Outcome::Skipped(
477                "`auto_daemon` is false — enable with `devp daemon install`".to_string(),
478            ),
479        );
480    }
481
482    report
483}
484
485/// Record that a pass completed for this version.
486fn write_stamp_in(config_dir: &std::path::Path) {
487    let _ = fs::create_dir_all(config_dir);
488    let _ = fs::write(config_dir.join(STAMP_FILE), constants::VERSION);
489}
490
491fn write_stamp() {
492    if let Ok(dir) = Registry::config_dir() {
493        write_stamp_in(&dir);
494    }
495}
496
497fn setup_is_due_in(config_dir: &std::path::Path) -> bool {
498    !fs::read_to_string(config_dir.join(STAMP_FILE))
499        .is_ok_and(|stamp| stamp.trim() == constants::VERSION)
500}
501
502/// Whether the unattended pass is due: a fresh install, or the first run after an upgrade.
503pub fn setup_is_due() -> bool {
504    Registry::config_dir()
505        .map(|dir| setup_is_due_in(&dir))
506        .unwrap_or(false)
507}
508
509/// The unattended pass, run at most once per installed version.
510///
511/// Called at the top of every command that a human typed. It is deliberately not called
512/// for the Git hook's `link --quiet` or the scheduler's `run --daemon`: those run without
513/// a terminal, and an integration pass that nobody can see is one nobody can refuse.
514pub fn auto_setup_if_due() {
515    if !setup_is_due() {
516        first_run_config_review();
517        return;
518    }
519
520    let Ok(registry) = Registry::load() else {
521        return;
522    };
523    let Some(report) = ensure_integrations_if_enabled(&registry) else {
524        // Suppressed. Stamp anyway, so a machine that opted out does not re-decide
525        // this on every single command.
526        write_stamp();
527        crate::commands::config::skip_config_review();
528        return;
529    };
530    if report.changed_anything() || report.needs_attention() {
531        output::print_header("dev-prune setup");
532        report.print(false);
533        if report.changed_anything() {
534            output::print_info(
535                "Run `devp setup --status` to review these, or `devp uninstall` to remove them.",
536            );
537        }
538        println!();
539    }
540    write_stamp();
541    first_run_config_review();
542}
543
544/// Put the defaults in front of the user, once, on a fresh install.
545///
546/// Separate from the integration stamp on purpose. The integrations are re-checked after
547/// every upgrade; the settings are not — being asked to reconfirm `idle_days` on each new
548/// version would be a nuisance, and the marker only disappears when the config directory
549/// does.
550///
551/// Every condition here is a way of asking "is there a person reading this?", because the
552/// alternative to asking is a prompt written into a log nobody will read, on a run that
553/// then blocks forever waiting for an answer.
554fn first_run_config_review() {
555    if !crate::commands::config::config_review_is_due() {
556        return;
557    }
558
559    use std::io::IsTerminal;
560    if unattended_environment().is_some()
561        || !std::io::stdin().is_terminal()
562        || !std::io::stdout().is_terminal()
563    {
564        crate::commands::config::skip_config_review();
565        return;
566    }
567
568    // Any error here is the wizard's own reporting; the command the user actually typed
569    // still runs. A failed walkthrough must not become a failed `devp status`.
570    if let Err(e) = crate::commands::config::run_wizard() {
571        output::print_warning(&format!("Could not run the first-run setup ({e:#})."));
572        crate::commands::config::skip_config_review();
573    }
574    println!();
575}
576
577/// Invalidate the stamp so the next human-run command performs a pass.
578///
579/// `uninstall` calls this in reverse — it writes the current stamp — so that removing the
580/// integrations is not immediately undone by the next command.
581pub fn suppress_next_auto_setup() {
582    write_stamp();
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    #[test]
590    fn a_report_with_only_present_items_is_silent() {
591        let mut report = SetupReport::default();
592        report.push("a", Outcome::AlreadyPresent);
593        assert!(!report.changed_anything());
594        assert!(!report.needs_attention());
595    }
596
597    #[test]
598    fn skipped_and_failed_both_ask_for_attention() {
599        let mut skipped = SetupReport::default();
600        skipped.push("a", Outcome::Skipped("no git".into()));
601        assert!(skipped.needs_attention());
602        assert!(!skipped.changed_anything());
603
604        let mut failed = SetupReport::default();
605        failed.push("a", Outcome::Failed("boom".into()));
606        assert!(failed.needs_attention());
607    }
608
609    #[test]
610    fn an_install_counts_as_a_change() {
611        let mut report = SetupReport::default();
612        report.push("a", Outcome::Installed);
613        assert!(report.changed_anything());
614    }
615
616    #[test]
617    fn the_skill_export_lands_in_the_config_directory() {
618        let dir = tempfile::TempDir::new().unwrap();
619        assert_eq!(ensure_skill_file_in(dir.path()), Outcome::Installed);
620        // A second pass finds byte-identical content and leaves it alone.
621        assert_eq!(ensure_skill_file_in(dir.path()), Outcome::AlreadyPresent);
622        let written = fs::read_to_string(dir.path().join("SKILL.md")).unwrap();
623        assert_eq!(written, EMBEDDED_SKILL_MD);
624    }
625
626    #[test]
627    fn a_stale_skill_export_is_rewritten() {
628        // An upgrade must not leave the previous version's instructions on disk.
629        let dir = tempfile::TempDir::new().unwrap();
630        fs::write(dir.path().join("SKILL.md"), "# an older version").unwrap();
631        assert_eq!(ensure_skill_file_in(dir.path()), Outcome::Installed);
632        let written = fs::read_to_string(dir.path().join("SKILL.md")).unwrap();
633        assert_eq!(written, EMBEDDED_SKILL_MD);
634    }
635
636    #[test]
637    fn the_stamp_gates_the_unattended_pass() {
638        let dir = tempfile::TempDir::new().unwrap();
639        assert!(setup_is_due_in(dir.path()), "a fresh install is due");
640        write_stamp_in(dir.path());
641        assert!(
642            !setup_is_due_in(dir.path()),
643            "the same version is not due twice"
644        );
645        fs::write(dir.path().join(STAMP_FILE), "0.0.1").unwrap();
646        assert!(setup_is_due_in(dir.path()), "an upgrade is due again");
647    }
648
649    /// The alias must never be written with a copy while it already exists.
650    ///
651    /// A hard link and its target share one inode, so `fs::copy` onto the alias empties
652    /// the binary it was copied from. This reproduces the exact shape of that bug — link
653    /// first, then ask for the alias again — and asserts the original still has its
654    /// bytes. The real failure was silent: a zero-byte executable that macOS runs
655    /// through `/bin/sh`, which exits 0 and prints nothing.
656    #[test]
657    fn refreshing_an_alias_that_is_a_hard_link_does_not_empty_the_binary() {
658        let dir = tempfile::TempDir::new().unwrap();
659        let binary = dir.path().join("dev-prune");
660        let alias = dir.path().join("devp");
661        fs::write(&binary, vec![b'M'; 4096]).unwrap();
662
663        if fs::hard_link(&binary, &alias).is_err() {
664            return; // Filesystem without hard links; the hazard cannot arise.
665        }
666
667        // What `ensure_alias` does when its `hard_link` loses the race: the alias is
668        // already there, so it must stop rather than fall through to the copy.
669        assert!(fs::hard_link(&binary, &alias).is_err(), "EEXIST expected");
670        assert!(alias.exists(), "the guard's condition");
671
672        assert_eq!(
673            fs::metadata(&binary).unwrap().len(),
674            4096,
675            "the running binary was truncated by refreshing its own alias"
676        );
677    }
678
679    #[test]
680    fn the_exported_skill_is_the_one_the_binary_was_built_with() {
681        // `SKILL.md` is embedded, so a doc edit ships only if the binary is rebuilt.
682        // Guard the two properties every consumer of it depends on.
683        assert!(EMBEDDED_SKILL_MD.starts_with("---"), "needs frontmatter");
684        assert!(
685            !EMBEDDED_SKILL_MD.contains("file:///"),
686            "SKILL.md is written to every user's machine — it must not contain \
687             absolute paths from the author's checkout"
688        );
689    }
690}