use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
pub const SCHEMA: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Receipt {
pub schema: u32,
pub version: String,
pub channel: String,
pub installed_by: String,
pub installed_at: String,
pub exe: String,
pub alias: bool,
pub path_entry: bool,
}
pub fn path() -> Result<PathBuf> {
Ok(crate::setup::managed_bin_dir()?.join(crate::constants::INSTALL_RECEIPT_FILE))
}
pub fn load() -> Option<Receipt> {
load_from(&path().ok()?)
}
fn load_from(file: &Path) -> Option<Receipt> {
let text = std::fs::read_to_string(file).ok()?;
let receipt: Receipt = serde_json::from_str(&text).ok()?;
(receipt.schema <= SCHEMA).then_some(receipt)
}
pub fn write(receipt: &Receipt) -> Result<()> {
write_to(&path()?, receipt)
}
fn write_to(file: &Path, receipt: &Receipt) -> Result<()> {
let body = serde_json::to_string_pretty(receipt)?;
if let Some(dir) = file.parent() {
std::fs::create_dir_all(dir)
.with_context(|| format!("could not create {}", dir.display()))?;
}
let staged = file.with_extension("json.new");
std::fs::write(&staged, format!("{body}\n"))
.with_context(|| format!("could not write {}", staged.display()))?;
std::fs::rename(&staged, file).inspect_err(|_| {
let _ = std::fs::remove_file(&staged);
})?;
Ok(())
}
pub fn refresh_after_upgrade(version: &str) {
let Ok(file) = path() else { return };
let Some(mut receipt) = load_from(&file) else {
return;
};
receipt.version = version.to_string();
receipt.installed_at = chrono::Utc::now().to_rfc3339();
receipt.installed_by = "devp".to_string();
let _ = write_to(&file, &receipt);
}
pub fn summary(receipt: &Receipt) -> String {
let when = chrono::DateTime::parse_from_rfc3339(&receipt.installed_at)
.map(|d| d.format("%Y-%m-%d").to_string())
.unwrap_or_else(|_| receipt.installed_at.clone());
format!("v{} by {} on {when}", receipt.version, receipt.installed_by)
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Receipt {
Receipt {
schema: SCHEMA,
version: "1.9.0".to_string(),
channel: "installer".to_string(),
installed_by: "install.sh".to_string(),
installed_at: "2026-08-25T09:14:02Z".to_string(),
exe: "/home/k/.config/dev-prune/bin/dev-prune".to_string(),
alias: true,
path_entry: true,
}
}
#[test]
fn a_receipt_survives_a_round_trip() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("install.json");
write_to(&file, &sample()).unwrap();
let back = load_from(&file).unwrap();
assert_eq!(back.version, "1.9.0");
assert_eq!(back.installed_by, "install.sh");
assert!(back.alias);
assert!(back.path_entry);
}
#[test]
fn a_newer_schema_is_ignored_rather_than_guessed_at() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("install.json");
let mut future = sample();
future.schema = SCHEMA + 1;
write_to(&file, &future).unwrap();
assert!(load_from(&file).is_none());
}
#[test]
fn nothing_and_nonsense_both_read_as_absent() {
let dir = tempfile::tempdir().unwrap();
assert!(load_from(&dir.path().join("install.json")).is_none());
let junk = dir.path().join("junk.json");
std::fs::write(&junk, "not json at all").unwrap();
assert!(load_from(&junk).is_none());
}
#[test]
fn the_summary_shortens_the_timestamp_to_a_date() {
assert_eq!(summary(&sample()), "v1.9.0 by install.sh on 2026-08-25");
}
#[test]
fn an_unparseable_timestamp_is_printed_as_it_was_written() {
let mut odd = sample();
odd.installed_at = "sometime".to_string();
assert_eq!(summary(&odd), "v1.9.0 by install.sh on sometime");
}
#[test]
fn the_field_names_are_what_the_shell_installers_write() {
let json = serde_json::to_string(&sample()).unwrap();
for key in [
"schema",
"version",
"channel",
"installed_by",
"installed_at",
"exe",
"alias",
"path_entry",
] {
assert!(json.contains(&format!("\"{key}\"")), "missing {key}");
}
}
}