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 #[serde(default)]
27 pub project: String,
28 #[serde(default)]
34 pub toolchain: BTreeMap<String, String>,
35}
36
37fn default_version() -> u32 {
38 1
39}
40
41pub fn compute_lockfiles_hash(workspace_root: &Path, lockfiles: &[String]) -> Option<String> {
46 let mut entries = Vec::new();
47
48 for pattern in lockfiles {
49 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
86pub 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
93pub 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 #[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 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}