Skip to main content

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}