Skip to main content

workspace/
state.rs

1use glob::glob;
2use serde::{Deserialize, Serialize};
3use sha2::{Digest, Sha256};
4use std::fs;
5use std::path::Path;
6use std::time::SystemTime;
7
8pub const STATE_FILENAME: &str = ".farhand-state.json";
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
11pub struct WorkspaceState {
12    #[serde(default = "default_version")]
13    pub version: u32,
14    #[serde(rename = "lastSuccessLockfileHash")]
15    pub last_success_lockfile_hash: String,
16    #[serde(rename = "lastInstalledAt")]
17    pub last_installed_at: SystemTime,
18    pub template: String,
19}
20
21fn default_version() -> u32 {
22    1
23}
24
25/// Computes a composite SHA-256 hash of all files matching the declared lockfile patterns.
26///
27/// Matches patterns relative to `workspace_root` (e.g. `Cargo.lock`, `package-lock.json`, `**/package-lock.json`).
28/// Normalizes paths to wire paths to ensure identical hashes across platforms.
29pub fn compute_lockfiles_hash(workspace_root: &Path, lockfiles: &[String]) -> Option<String> {
30    let mut entries = Vec::new();
31
32    for pattern in lockfiles {
33        let pattern_clean = pattern.trim_start_matches('/');
34        let full_pattern = workspace_root.join(pattern_clean);
35        if let Ok(paths) = glob(&full_pattern.to_string_lossy()) {
36            for entry in paths.flatten() {
37                if entry.is_file() {
38                    if let Ok(bytes) = fs::read(&entry) {
39                        let mut file_hasher = Sha256::new();
40                        file_hasher.update(&bytes);
41                        let file_hash = hex::encode(file_hasher.finalize());
42                        let rel = entry.strip_prefix(workspace_root).unwrap_or(&entry);
43                        let wire_path = protocol::to_wire_path(rel);
44                        entries.push(format!("{}:{}", wire_path, file_hash));
45                    }
46                }
47            }
48        }
49    }
50
51    if entries.is_empty() {
52        return None;
53    }
54
55    entries.sort();
56    let mut combined_hasher = Sha256::new();
57    combined_hasher.update(entries.join("|").as_bytes());
58    Some(hex::encode(combined_hasher.finalize()))
59}
60
61/// Reads `.farhand-state.json` from `workspace_dir` if present and valid.
62pub fn read_state(workspace_dir: &Path) -> Option<WorkspaceState> {
63    let state_file = workspace_dir.join(STATE_FILENAME);
64    let content = fs::read_to_string(state_file).ok()?;
65    serde_json::from_str(&content).ok()
66}
67
68/// Writes `WorkspaceState` to `.farhand-state.json` in `workspace_dir`.
69pub fn write_state(workspace_dir: &Path, state: &WorkspaceState) -> std::io::Result<()> {
70    let state_file = workspace_dir.join(STATE_FILENAME);
71    let json = serde_json::to_string_pretty(state)
72        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
73    fs::write(state_file, json)?;
74    Ok(())
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use tempfile::tempdir;
81
82    #[test]
83    fn test_compute_lockfiles_hash_single_and_multiple() {
84        let dir = tempdir().unwrap();
85        let cargo_lock = dir.path().join("Cargo.lock");
86        let pnpm_lock = dir.path().join("pnpm-lock.yaml");
87
88        fs::write(&cargo_lock, "version = 3\n").unwrap();
89        fs::write(&pnpm_lock, "lockfileVersion: 5.4\n").unwrap();
90
91        let hash_single = compute_lockfiles_hash(dir.path(), &["Cargo.lock".to_string()]);
92        assert!(hash_single.is_some());
93
94        let hash_both1 = compute_lockfiles_hash(
95            dir.path(),
96            &["Cargo.lock".to_string(), "pnpm-lock.yaml".to_string()],
97        );
98        let hash_both2 = compute_lockfiles_hash(
99            dir.path(),
100            &["pnpm-lock.yaml".to_string(), "Cargo.lock".to_string()],
101        );
102        assert_eq!(
103            hash_both1, hash_both2,
104            "sort order must produce identical composite hash"
105        );
106        assert_ne!(hash_single, hash_both1);
107    }
108
109    #[test]
110    fn test_compute_lockfiles_hash_nonexistent() {
111        let dir = tempdir().unwrap();
112        let hash = compute_lockfiles_hash(dir.path(), &["Cargo.lock".to_string()]);
113        assert!(hash.is_none());
114    }
115
116    #[test]
117    fn test_state_read_write_roundtrip() {
118        let dir = tempdir().unwrap();
119        assert!(read_state(dir.path()).is_none());
120
121        let state = WorkspaceState {
122            version: 1,
123            last_success_lockfile_hash: "abcd1234ef".to_string(),
124            last_installed_at: SystemTime::now(),
125            template: "npm".to_string(),
126        };
127
128        write_state(dir.path(), &state).unwrap();
129        let loaded = read_state(dir.path()).unwrap();
130        assert_eq!(loaded.version, state.version);
131        assert_eq!(
132            loaded.last_success_lockfile_hash,
133            state.last_success_lockfile_hash
134        );
135        assert_eq!(loaded.template, state.template);
136    }
137}