Skip to main content

spec_driven_docs/self_depend/
txn.rs

1//! The pin transaction: both facts move or neither does.
2//!
3//! Where the manager records two facts, a tag and its locked node, the tag
4//! is rewritten and the lock refreshed by the manager's own command, and a
5//! failure at either step puts both files back byte-identical. Where the
6//! manager records one fact, that one fact moves in place. Nothing here
7//! commits or pushes: what the transaction leaves behind is a diff.
8
9use std::process::{Command, Stdio};
10
11use camino::{Utf8Path, Utf8PathBuf};
12use semver::Version;
13use serde::Serialize;
14
15use crate::adapters::fs::write_atomic;
16use crate::error::AppError;
17use crate::self_depend::manager::{Detected, Manager};
18use crate::self_depend::pin;
19
20/// What one sync moved.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
22pub struct Moved {
23    /// The manager whose pin moved.
24    pub manager: Manager,
25    /// The version the pin named before, as the file spelled it.
26    pub from: String,
27    /// The version the pin names now, in the same spelling.
28    pub to: String,
29    /// Every file the transaction rewrote, relative to the project root.
30    pub files: Vec<Utf8PathBuf>,
31}
32
33/// One file's bytes before the transaction, or its absence.
34struct Snapshot {
35    path: Utf8PathBuf,
36    bytes: Option<Vec<u8>>,
37}
38
39impl Snapshot {
40    fn take(path: Utf8PathBuf) -> std::io::Result<Self> {
41        let bytes = match std::fs::read(&path) {
42            Ok(bytes) => Some(bytes),
43            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
44            Err(error) => return Err(error),
45        };
46        Ok(Self { path, bytes })
47    }
48
49    fn restore(&self) -> std::io::Result<()> {
50        self.bytes.as_ref().map_or_else(
51            || match std::fs::remove_file(&self.path) {
52                Ok(()) => Ok(()),
53                Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
54                Err(error) => Err(error),
55            },
56            |bytes| write_atomic(&self.path, bytes),
57        )
58    }
59}
60
61/// Move one manager's pin to `to`.
62///
63/// # Errors
64///
65/// [`AppError::Refused`] where the manager file no longer records the pin,
66/// or where the lock refresh fails; in the second case both files are put
67/// back before the error returns. [`AppError::Io`] where a file cannot be
68/// read or written.
69pub fn sync(target: &Utf8Path, detected: &Detected, to: &Version) -> Result<Moved, AppError> {
70    let file = detected.file.as_ref().ok_or_else(|| {
71        AppError::Refused(format!("{} names no file in this target", detected.manager))
72    })?;
73    let manager_path = target.join(file);
74    let text = std::fs::read_to_string(&manager_path)?;
75    let (moved_text, held) = pin::rewrite(detected.manager, &text, to)
76        .map_err(|reason| AppError::Refused(format!("{file}: {reason}")))?;
77    let spelled_to = if held.spelled.starts_with('v') {
78        format!("v{to}")
79    } else {
80        to.to_string()
81    };
82
83    let Some(lock) = detected.manager.lock_file() else {
84        write_atomic(&manager_path, moved_text.as_bytes())?;
85        return Ok(Moved {
86            manager: detected.manager,
87            from: held.spelled,
88            to: spelled_to,
89            files: vec![file.clone()],
90        });
91    };
92
93    // SATISFIES acquisition:the-pin-moves-in-one-transaction
94    let before_manager = Snapshot::take(manager_path.clone())?;
95    let before_lock = Snapshot::take(target.join(lock))?;
96    write_atomic(&manager_path, moved_text.as_bytes())?;
97    let outcome = refresh_lock(target, &text);
98    if let Err(error) = outcome {
99        before_manager.restore()?;
100        before_lock.restore()?;
101        return Err(error);
102    }
103    Ok(Moved {
104        manager: detected.manager,
105        from: held.spelled,
106        to: spelled_to,
107        files: vec![file.clone(), Utf8PathBuf::from(lock)],
108    })
109}
110
111/// Refresh this tool's node in the lock through the manager's own command.
112fn refresh_lock(target: &Utf8Path, flake: &str) -> Result<(), AppError> {
113    let input = pin::input_name(flake).ok_or_else(|| {
114        AppError::Refused("flake.nix names this tool at a URL no input attribute holds".to_string())
115    })?;
116    let output = Command::new("nix")
117        .args(["flake", "update", &input])
118        .current_dir(target)
119        .stdin(Stdio::null())
120        .stdout(Stdio::piped())
121        .stderr(Stdio::piped())
122        .output()
123        .map_err(|error| {
124            if error.kind() == std::io::ErrorKind::NotFound {
125                AppError::Refused(
126                    "nix is not on PATH, so the lock cannot follow the tag".to_string(),
127                )
128            } else {
129                AppError::Io(error)
130            }
131        })?;
132    if output.status.success() {
133        return Ok(());
134    }
135    let stderr = String::from_utf8_lossy(&output.stderr);
136    let mut tail: Vec<&str> = stderr.lines().rev().take(5).collect();
137    tail.reverse();
138    Err(AppError::Refused(format!(
139        "nix flake update {input} failed ({}); both files were put back: {}",
140        output.status,
141        tail.join(" | ")
142    )))
143}