dev_prune/receipt.rs
1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4//! What this project's own installer did, written down beside the binary it installed.
5//!
6//! Three programs used to derive the same facts independently — `install.sh`,
7//! `install.ps1` and this binary each worked out which copy is the managed one, what
8//! the last install actually wrote, and whether the `devp` twin and the PATH entry came
9//! from us or were already there. Three derivations of one truth is how they drift, and
10//! the drift is invisible until an uninstall removes a PATH entry it never added.
11//!
12//! So the installer writes it down. `<bindir>/install.json` sits next to the binary, is
13//! created by whichever installer performed the install, and survives the shell that ran
14//! it — which is the whole point, because a shell variable does not.
15//!
16//! It is deliberately **not** a source of truth about the machine. [`Channel::detect`]
17//! stays the classifier: a receipt cannot describe a copy that arrived through `cargo
18//! install`, and a receipt that is missing means only that no installer of ours wrote
19//! one — never that the binary is unmanaged. Everything here is therefore advisory, read
20//! with `Option`, and reported rather than acted on.
21//!
22//! [`Channel::detect`]: crate::channel::Channel::detect
23
24use std::path::{Path, PathBuf};
25
26use anyhow::{Context, Result};
27use serde::{Deserialize, Serialize};
28
29/// The current on-disk shape.
30///
31/// Bumped only when a field changes meaning. A reader that finds a higher number than it
32/// knows ignores the file rather than guessing — the receipt is advisory, so "ignore it"
33/// is always a safe answer, and it is the only answer that cannot corrupt anything.
34pub const SCHEMA: u32 = 1;
35
36/// One install, as the installer that performed it saw it.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct Receipt {
39 /// On-disk shape. See [`SCHEMA`].
40 pub schema: u32,
41 /// The version that was written, exactly as the installer resolved it.
42 pub version: String,
43 /// Which channel performed it — `installer` for both scripts, or the label of the
44 /// channel a later in-place upgrade came through.
45 pub channel: String,
46 /// Which program wrote this file: `install.sh`, `install.ps1`, or `devp`.
47 pub installed_by: String,
48 /// When, in UTC, RFC 3339. Absolute — never "today".
49 pub installed_at: String,
50 /// Absolute path of the binary that was installed.
51 pub exe: String,
52 /// Whether the installer also wrote the `devp` twin beside it.
53 pub alias: bool,
54 /// Whether the directory is on PATH because one of our installers put it there, on
55 /// this run or an earlier one. False when PATH was left alone on request, and false
56 /// when whatever makes `devp` resolve is something neither script wrote.
57 pub path_entry: bool,
58}
59
60/// Where the receipt lives: beside the managed binary, in the one directory no package
61/// manager owns.
62pub fn path() -> Result<PathBuf> {
63 Ok(crate::setup::managed_bin_dir()?.join(crate::constants::INSTALL_RECEIPT_FILE))
64}
65
66/// Read the receipt, or `None` if there is not one worth trusting.
67///
68/// Every failure is a `None`: absent, unreadable, malformed, or written by a future
69/// version that means something different by these fields. Nothing here is load-bearing
70/// enough to be worth an error path, and a caller forced to handle one would only end up
71/// ignoring it.
72pub fn load() -> Option<Receipt> {
73 load_from(&path().ok()?)
74}
75
76fn load_from(file: &Path) -> Option<Receipt> {
77 let text = std::fs::read_to_string(file).ok()?;
78 let receipt: Receipt = serde_json::from_str(&text).ok()?;
79 (receipt.schema <= SCHEMA).then_some(receipt)
80}
81
82/// Write the receipt, replacing any previous one.
83///
84/// Atomic, like every other state file this program writes: staged beside the target and
85/// renamed over it, so a write that dies half-way leaves the old receipt intact rather
86/// than a truncated one that parses as nothing.
87pub fn write(receipt: &Receipt) -> Result<()> {
88 write_to(&path()?, receipt)
89}
90
91fn write_to(file: &Path, receipt: &Receipt) -> Result<()> {
92 let body = serde_json::to_string_pretty(receipt)?;
93 if let Some(dir) = file.parent() {
94 std::fs::create_dir_all(dir)
95 .with_context(|| format!("could not create {}", dir.display()))?;
96 }
97 let staged = file.with_extension("json.new");
98 std::fs::write(&staged, format!("{body}\n"))
99 .with_context(|| format!("could not write {}", staged.display()))?;
100 std::fs::rename(&staged, file).inspect_err(|_| {
101 let _ = std::fs::remove_file(&staged);
102 })?;
103 Ok(())
104}
105
106/// Move an existing receipt forward after an in-place upgrade.
107///
108/// Only ever an update: a missing receipt stays missing. `devp update --install` can
109/// replace a managed copy that some *other* manager installed, and inventing a receipt
110/// there would claim an installer ran when none did — exactly the drift this file exists
111/// to prevent. Best-effort by design; an upgrade does not fail because a note about it
112/// could not be written.
113pub fn refresh_after_upgrade(version: &str) {
114 let Ok(file) = path() else { return };
115 let Some(mut receipt) = load_from(&file) else {
116 return;
117 };
118 receipt.version = version.to_string();
119 receipt.installed_at = chrono::Utc::now().to_rfc3339();
120 receipt.installed_by = "devp".to_string();
121 let _ = write_to(&file, &receipt);
122}
123
124/// One line for `devp doctor` and `devp install`: what the installer wrote, and when.
125///
126/// The date is the useful half. "Install channel: install script" is already on the
127/// screen from [`Channel::detect`]; what nothing else can answer is whether that copy
128/// arrived last week or two years ago.
129///
130/// [`Channel::detect`]: crate::channel::Channel::detect
131pub fn summary(receipt: &Receipt) -> String {
132 let when = chrono::DateTime::parse_from_rfc3339(&receipt.installed_at)
133 .map(|d| d.format("%Y-%m-%d").to_string())
134 .unwrap_or_else(|_| receipt.installed_at.clone());
135 format!("v{} by {} on {when}", receipt.version, receipt.installed_by)
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 fn sample() -> Receipt {
143 Receipt {
144 schema: SCHEMA,
145 version: "1.9.0".to_string(),
146 channel: "installer".to_string(),
147 installed_by: "install.sh".to_string(),
148 installed_at: "2026-08-25T09:14:02Z".to_string(),
149 exe: "/home/k/.config/dev-prune/bin/dev-prune".to_string(),
150 alias: true,
151 path_entry: true,
152 }
153 }
154
155 #[test]
156 fn a_receipt_survives_a_round_trip() {
157 let dir = tempfile::tempdir().unwrap();
158 let file = dir.path().join("install.json");
159 write_to(&file, &sample()).unwrap();
160 let back = load_from(&file).unwrap();
161 assert_eq!(back.version, "1.9.0");
162 assert_eq!(back.installed_by, "install.sh");
163 assert!(back.alias);
164 assert!(back.path_entry);
165 }
166
167 #[test]
168 fn a_newer_schema_is_ignored_rather_than_guessed_at() {
169 let dir = tempfile::tempdir().unwrap();
170 let file = dir.path().join("install.json");
171 let mut future = sample();
172 future.schema = SCHEMA + 1;
173 write_to(&file, &future).unwrap();
174 assert!(load_from(&file).is_none());
175 }
176
177 #[test]
178 fn nothing_and_nonsense_both_read_as_absent() {
179 let dir = tempfile::tempdir().unwrap();
180 assert!(load_from(&dir.path().join("install.json")).is_none());
181 let junk = dir.path().join("junk.json");
182 std::fs::write(&junk, "not json at all").unwrap();
183 assert!(load_from(&junk).is_none());
184 }
185
186 #[test]
187 fn the_summary_shortens_the_timestamp_to_a_date() {
188 assert_eq!(summary(&sample()), "v1.9.0 by install.sh on 2026-08-25");
189 }
190
191 #[test]
192 fn an_unparseable_timestamp_is_printed_as_it_was_written() {
193 let mut odd = sample();
194 odd.installed_at = "sometime".to_string();
195 assert_eq!(summary(&odd), "v1.9.0 by install.sh on sometime");
196 }
197
198 // The two installers write this file with `printf` and `Set-Content`, not with serde,
199 // so the field names are a contract between three programs in three languages. A
200 // rename on this side would be invisible until someone's receipt stopped parsing.
201 #[test]
202 fn the_field_names_are_what_the_shell_installers_write() {
203 let json = serde_json::to_string(&sample()).unwrap();
204 for key in [
205 "schema",
206 "version",
207 "channel",
208 "installed_by",
209 "installed_at",
210 "exe",
211 "alias",
212 "path_entry",
213 ] {
214 assert!(json.contains(&format!("\"{key}\"")), "missing {key}");
215 }
216 }
217}