Skip to main content

workspace/
state.rs

1use glob::glob;
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4use std::collections::BTreeMap;
5use std::fs;
6use std::path::Path;
7use std::time::SystemTime;
8
9pub const STATE_FILENAME: &str = ".farhand-state.json";
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct WorkspaceState {
13    #[serde(default = "default_version")]
14    pub version: u32,
15    #[serde(rename = "lastSuccessLockfileHash")]
16    pub last_success_lockfile_hash: String,
17    #[serde(rename = "lastInstalledAt")]
18    pub last_installed_at: SystemTime,
19    pub template: String,
20    /// The project name the client used, e.g. `my-repo:feat/payments`.
21    ///
22    /// The workspace *directory* name is sanitised and hash-suffixed
23    /// (`my-repo__feat-payments-1a2b3c4d`), so the original cannot be
24    /// recovered from it. Locks are keyed by this raw name, so GC and CLEAN
25    /// have to read it back from here or their lock check can never match.
26    #[serde(default)]
27    pub project: String,
28    /// Toolchain pins in force when the last successful install ran.
29    ///
30    /// Compared against the run's toolchain on the next build, so pinning
31    /// `-T node=22` re-runs the dependency hook instead of trusting a
32    /// `node_modules` built under 18.
33    #[serde(default)]
34    pub toolchain: BTreeMap<String, String>,
35}
36
37fn default_version() -> u32 {
38    1
39}
40
41/// Computes a composite SHA-256 hash of all files matching the declared lockfile patterns.
42///
43/// Matches patterns relative to `workspace_root` (e.g. `Cargo.lock`, `package-lock.json`, `**/package-lock.json`).
44/// Normalizes paths to wire paths to ensure identical hashes across platforms.
45pub fn compute_lockfiles_hash(workspace_root: &Path, lockfiles: &[String]) -> Option<String> {
46    let mut entries = Vec::new();
47
48    for pattern in lockfiles {
49        // The *pattern* needs normalising, not just the result. On Windows a
50        // `Path` renders with backslashes and `\` is glob's escape character,
51        // so the match found nothing and the hash came back empty — which
52        // made the install hook believe dependencies had never been fetched.
53        //
54        // Note this does *not* use `protocol::to_wire_path`, which is for
55        // relative wire paths: it trims the leading `/`, turning an absolute
56        // glob into a relative one that matches nothing on every platform.
57        let pattern_rel = pattern.trim_start_matches('/').replace('\\', "/");
58        let root_str = workspace_root.to_string_lossy().replace('\\', "/");
59        let pattern_str = format!("{}/{}", root_str.trim_end_matches('/'), pattern_rel);
60        if let Ok(paths) = glob(&pattern_str) {
61            for entry in paths.flatten() {
62                if entry.is_file() {
63                    if let Ok(bytes) = fs::read(&entry) {
64                        let mut file_hasher = Sha256::new();
65                        file_hasher.update(&bytes);
66                        let file_hash = hex::encode(file_hasher.finalize());
67                        let rel = entry.strip_prefix(workspace_root).unwrap_or(&entry);
68                        let wire_path = protocol::to_wire_path(rel);
69                        entries.push(format!("{wire_path}:{file_hash}"));
70                    }
71                }
72            }
73        }
74    }
75
76    if entries.is_empty() {
77        return None;
78    }
79
80    entries.sort();
81    let mut combined_hasher = Sha256::new();
82    combined_hasher.update(entries.join("|").as_bytes());
83    Some(hex::encode(combined_hasher.finalize()))
84}
85
86/// Reads `.farhand-state.json` from `workspace_dir` if present and valid.
87pub fn read_state(workspace_dir: &Path) -> Option<WorkspaceState> {
88    let state_file = workspace_dir.join(STATE_FILENAME);
89    let content = fs::read_to_string(state_file).ok()?;
90    serde_json::from_str(&content).ok()
91}
92
93/// Writes `WorkspaceState` to `.farhand-state.json` in `workspace_dir`.
94pub fn write_state(workspace_dir: &Path, state: &WorkspaceState) -> std::io::Result<()> {
95    let state_file = workspace_dir.join(STATE_FILENAME);
96    let json = serde_json::to_string_pretty(state)
97        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
98    fs::write(state_file, json)?;
99    Ok(())
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use tempfile::tempdir;
106
107    #[test]
108    fn test_compute_lockfiles_hash_single_and_multiple() {
109        let dir = tempdir().unwrap();
110        let cargo_lock = dir.path().join("Cargo.lock");
111        let pnpm_lock = dir.path().join("pnpm-lock.yaml");
112
113        fs::write(&cargo_lock, "version = 3\n").unwrap();
114        fs::write(&pnpm_lock, "lockfileVersion: 5.4\n").unwrap();
115
116        let hash_single = compute_lockfiles_hash(dir.path(), &["Cargo.lock".to_string()]);
117        assert!(hash_single.is_some());
118
119        let hash_both1 = compute_lockfiles_hash(
120            dir.path(),
121            &["Cargo.lock".to_string(), "pnpm-lock.yaml".to_string()],
122        );
123        let hash_both2 = compute_lockfiles_hash(
124            dir.path(),
125            &["pnpm-lock.yaml".to_string(), "Cargo.lock".to_string()],
126        );
127        assert_eq!(
128            hash_both1, hash_both2,
129            "sort order must produce identical composite hash"
130        );
131        assert_ne!(hash_single, hash_both1);
132    }
133
134    #[test]
135    fn test_compute_lockfiles_hash_nonexistent() {
136        let dir = tempdir().unwrap();
137        let hash = compute_lockfiles_hash(dir.path(), &["Cargo.lock".to_string()]);
138        assert!(hash.is_none());
139    }
140
141    #[test]
142    fn test_state_read_write_roundtrip() {
143        let dir = tempdir().unwrap();
144        assert!(read_state(dir.path()).is_none());
145
146        let state = WorkspaceState {
147            version: 1,
148            last_success_lockfile_hash: "abcd1234ef".to_string(),
149            last_installed_at: SystemTime::now(),
150            template: "npm".to_string(),
151            project: "my-repo:feat/payments".to_string(),
152            toolchain: std::collections::BTreeMap::new(),
153        };
154
155        write_state(dir.path(), &state).unwrap();
156        let loaded = read_state(dir.path()).unwrap();
157        assert_eq!(loaded.version, state.version);
158        assert_eq!(
159            loaded.last_success_lockfile_hash,
160            state.last_success_lockfile_hash
161        );
162        assert_eq!(loaded.template, state.template);
163        assert_eq!(loaded.project, state.project);
164    }
165}
166
167#[cfg(test)]
168mod lockfile_pattern_tests {
169    use super::*;
170    use tempfile::tempdir;
171
172    /// The *pattern* needs normalising, not just the result: on Windows
173    /// `Path` renders as `C:\ws\Cargo.lock` and `\` is glob's escape
174    /// character, so the match found nothing and the hash came back empty.
175    /// That made the install hook believe dependencies had never been fetched.
176    #[test]
177    fn a_plain_lockfile_is_found_and_hashed() {
178        let dir = tempdir().unwrap();
179        fs::write(dir.path().join("deps.lock"), "dep-version-1\n").unwrap();
180
181        let first = compute_lockfiles_hash(dir.path(), &["deps.lock".to_string()]);
182        assert!(first.is_some(), "a declared lockfile was not found");
183
184        // Changing it must change the hash, or the install hook would never
185        // re-run when dependencies actually move.
186        fs::write(dir.path().join("deps.lock"), "dep-version-2\n").unwrap();
187        let second = compute_lockfiles_hash(dir.path(), &["deps.lock".to_string()]);
188        assert!(second.is_some());
189        assert_ne!(first, second, "the hash did not track the file's content");
190    }
191
192    #[test]
193    fn a_leading_slash_pattern_still_resolves() {
194        let dir = tempdir().unwrap();
195        fs::write(dir.path().join("Cargo.lock"), "x\n").unwrap();
196        assert!(compute_lockfiles_hash(dir.path(), &["/Cargo.lock".to_string()]).is_some());
197    }
198}