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(¤t, &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(¤t, &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, ¤t_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(¤t_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(¤t_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(®istry) 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}