1#![deny(unsafe_code)]
5
6use fileset::FilesetError;
7use protocol::{FileEntry, ManifestPayload};
8use sha2::{Digest, Sha256};
9use std::collections::HashMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13pub mod presets;
14pub use presets::{detect_preset_outputs, resolve_artifact_paths, Preset, DEFAULT_PRESETS};
15
16pub mod lock;
17pub use lock::WorkspaceLockManager;
18
19pub mod state;
20pub use state::{compute_lockfiles_hash, read_state, write_state, WorkspaceState};
21
22pub mod history;
23pub use history::{format_rfc3339, get_recent_runs, save_run, MAX_RUNS_RETAINED, RUNS_DIR};
24
25pub mod cow;
26pub use cow::{cow_clone_dir, cow_clone_file, find_seed_workspace, parse_base_project_name};
27
28pub mod cas;
29pub use cas::CasStore;
30
31pub mod gc;
32pub use gc::{
33 calculate_dir_size, gc_cas, get_workspace_last_used, run_emergency_disk_gc,
34 run_garbage_collection, scan_workspaces, touch_workspace, trim_workspace_caches, CasGcReport,
35 GcReport, WorkspaceMetadata,
36};
37
38pub mod disk;
39pub use disk::{get_disk_space, DiskSpace};
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct DiffResult {
43 pub want: Vec<String>,
45 pub delete_extraneous: Vec<String>,
47}
48
49pub fn resolve_workspace_dir(base_dir: &Path, project_name: &str) -> PathBuf {
52 let mut hasher = Sha256::new();
53 hasher.update(project_name.as_bytes());
54 let hash = hasher.finalize();
55 let short_hash = hex::encode(&hash[..4]); let clean_name: String = project_name
58 .chars()
59 .map(|c| {
60 if c.is_alphanumeric() || c == '-' || c == '_' {
61 c
62 } else {
63 '_'
64 }
65 })
66 .collect();
67
68 base_dir.join(format!("{}-{}", clean_name, short_hash))
69}
70
71pub fn ensure_workspace_dir(base_dir: &Path, project_name: &str) -> std::io::Result<PathBuf> {
75 let ws_dir = resolve_workspace_dir(base_dir, project_name);
76 if ws_dir.is_dir() {
77 touch_workspace(&ws_dir, project_name);
78 return Ok(ws_dir);
79 }
80
81 if let Some(seed_dir) = find_seed_workspace(base_dir, project_name) {
82 tracing::info!(
83 "Forking new branch workspace for '{}' from seed '{}' via APFS CoW...",
84 project_name,
85 seed_dir.display()
86 );
87 if let Err(e) = cow_clone_dir(&seed_dir, &ws_dir) {
88 tracing::warn!("CoW clone failed ({}); creating clean directory", e);
89 fs::create_dir_all(&ws_dir)?;
90 }
91 } else {
92 fs::create_dir_all(&ws_dir)?;
93 }
94
95 touch_workspace(&ws_dir, project_name);
96 Ok(ws_dir)
97}
98
99pub fn default_workspaces_dir() -> PathBuf {
101 if let Ok(override_dir) = std::env::var("FARHAND_WORKDIR") {
102 return PathBuf::from(override_dir);
103 }
104
105 if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
106 PathBuf::from(home).join(".farhand").join("workspaces")
107 } else {
108 std::env::temp_dir().join("farhand").join("workspaces")
109 }
110}
111
112pub fn diff_manifests(
114 workspace_root: &Path,
115 client_manifest: &ManifestPayload,
116 extra_ignores: &[String],
117) -> Result<DiffResult, FilesetError> {
118 let mut client_map: HashMap<&str, &FileEntry> =
119 HashMap::with_capacity(client_manifest.files.len());
120 for f in &client_manifest.files {
121 client_map.insert(&f.path, f);
122 }
123
124 let remote_files = if workspace_root.exists() {
125 fileset::scan(workspace_root, extra_ignores)?
126 } else {
127 HashMap::new()
128 };
129
130 let mut want = Vec::new();
131 for (rel_path, client_entry) in &client_map {
132 match remote_files.get(*rel_path) {
133 Some(remote_meta) => {
134 if remote_meta.hash != client_entry.hash {
135 want.push((*rel_path).to_string());
136 }
137 }
138 None => {
139 want.push((*rel_path).to_string());
140 }
141 }
142 }
143
144 let mut delete_extraneous = Vec::new();
145 for rel_path in remote_files.keys() {
146 if !client_map.contains_key(rel_path.as_str()) {
147 if !fileset::is_default_ignored(rel_path)
151 && rel_path != state::STATE_FILENAME
152 && !rel_path.starts_with(".farhand-")
153 {
154 delete_extraneous.push(rel_path.clone());
155 }
156 }
157 }
158
159 want.sort();
160 delete_extraneous.sort();
161
162 Ok(DiffResult {
163 want,
164 delete_extraneous,
165 })
166}
167
168pub fn apply_deletions(workspace_root: &Path, to_delete: &[String]) -> std::io::Result<usize> {
170 if !workspace_root.exists() {
171 return Ok(0);
172 }
173
174 let canonical_root = workspace_root.canonicalize()?;
175 let mut count = 0;
176
177 for rel_path in to_delete {
178 let safe_rel = match protocol::from_wire_path(rel_path) {
179 Ok(p) => p,
180 Err(_) => continue,
181 };
182
183 let target = canonical_root.join(&safe_rel);
184 if target.exists() {
185 if let Ok(canonical_target) = target.canonicalize() {
186 if canonical_target.starts_with(&canonical_root)
187 && canonical_target != canonical_root
188 && fs::remove_file(&canonical_target).is_ok()
189 {
190 count += 1;
191 let mut parent = canonical_target.parent();
193 while let Some(p) = parent {
194 if p == canonical_root {
195 break;
196 }
197 if fs::remove_dir(p).is_err() {
198 break; }
200 parent = p.parent();
201 }
202 }
203 }
204 }
205 }
206
207 Ok(count)
208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use tempfile::tempdir;
214
215 #[test]
216 fn test_resolve_workspace_dir() {
217 let base = Path::new("/var/farhand/workspaces");
218 let dir1 = resolve_workspace_dir(base, "my-app");
219 let dir2 = resolve_workspace_dir(base, "my-app");
220 assert_eq!(dir1, dir2);
221 assert_eq!(dir1.parent(), Some(base));
222 assert!(dir1
223 .file_name()
224 .and_then(|n| n.to_str())
225 .is_some_and(|s| s.starts_with("my-app-")));
226
227 let dir3 = resolve_workspace_dir(base, "other-project");
228 assert_ne!(dir1, dir3);
229 }
230
231 #[test]
232 fn test_diff_fresh_workspace() {
233 let dir = tempdir().unwrap();
234 let ws = dir.path().join("ws");
235
236 let manifest = ManifestPayload {
237 files: vec![
238 FileEntry {
239 path: "src/main.rs".into(),
240 hash: "hash1".into(),
241 size: 10,
242 mode: 0o644,
243 },
244 FileEntry {
245 path: "Cargo.toml".into(),
246 hash: "hash2".into(),
247 size: 20,
248 mode: 0o644,
249 },
250 ],
251 };
252
253 let diff = diff_manifests(&ws, &manifest, &[]).unwrap();
254 assert_eq!(diff.want, vec!["Cargo.toml", "src/main.rs"]);
255 assert!(diff.delete_extraneous.is_empty());
256 }
257
258 #[test]
259 fn test_diff_unchanged_workspace() {
260 let dir = tempdir().unwrap();
261 let ws = dir.path().join("ws");
262 fs::create_dir_all(ws.join("src")).unwrap();
263
264 fs::write(ws.join("src/main.rs"), b"fn main() {}").unwrap();
265 let hash = fileset::hash_file(&ws.join("src/main.rs")).unwrap();
266
267 let manifest = ManifestPayload {
268 files: vec![FileEntry {
269 path: "src/main.rs".into(),
270 hash,
271 size: 12,
272 mode: 0o644,
273 }],
274 };
275
276 let diff = diff_manifests(&ws, &manifest, &[]).unwrap();
277 assert!(
278 diff.want.is_empty(),
279 "Unchanged files should not be in want: {:?}",
280 diff.want
281 );
282 assert!(diff.delete_extraneous.is_empty());
283 }
284
285 #[test]
286 fn test_diff_modified_file() {
287 let dir = tempdir().unwrap();
288 let ws = dir.path().join("ws");
289 fs::create_dir_all(&ws).unwrap();
290
291 fs::write(ws.join("file.txt"), b"remote content").unwrap();
292
293 let manifest = ManifestPayload {
294 files: vec![FileEntry {
295 path: "file.txt".into(),
296 hash: "different_local_hash".into(),
297 size: 20,
298 mode: 0o644,
299 }],
300 };
301
302 let diff = diff_manifests(&ws, &manifest, &[]).unwrap();
303 assert_eq!(diff.want, vec!["file.txt"]);
304 assert!(diff.delete_extraneous.is_empty());
305 }
306
307 #[test]
308 fn test_deletion_safety_rule_section_5_1() {
309 let dir = tempdir().unwrap();
310 let ws = dir.path().join("ws");
311 fs::create_dir_all(ws.join("src")).unwrap();
312 fs::create_dir_all(ws.join("node_modules/react")).unwrap();
313 fs::create_dir_all(ws.join("target/release")).unwrap();
314
315 fs::write(ws.join("src/old_deleted_file.rs"), b"old").unwrap();
317
318 fs::write(ws.join("node_modules/react/index.js"), b"react").unwrap();
320 fs::write(ws.join("target/release/binary"), b"elf").unwrap();
321
322 let manifest = ManifestPayload {
324 files: vec![FileEntry {
325 path: "src/new_file.rs".into(),
326 hash: "new_hash".into(),
327 size: 10,
328 mode: 0o644,
329 }],
330 };
331
332 let diff = diff_manifests(&ws, &manifest, &[]).unwrap();
333
334 assert_eq!(diff.delete_extraneous, vec!["src/old_deleted_file.rs"]);
336
337 assert!(!diff
339 .delete_extraneous
340 .iter()
341 .any(|p| p.contains("node_modules")));
342 assert!(!diff.delete_extraneous.iter().any(|p| p.contains("target")));
343
344 let deleted = apply_deletions(&ws, &diff.delete_extraneous).unwrap();
346 assert_eq!(deleted, 1);
347 assert!(!ws.join("src/old_deleted_file.rs").exists());
348
349 assert!(ws.join("node_modules/react/index.js").exists());
351 assert!(ws.join("target/release/binary").exists());
352 }
353}