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
25pub 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
61pub 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
68pub 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}