Skip to main content

workspace/
lib.rs

1// Unsafe is allowed only at the FFI boundaries listed in CONTRIBUTING.md
2// (CoW syscalls, statvfs), each with a SAFETY contract. Everything else —
3// locking, CAS, history, GC, presets — must stay pure safe Rust.
4#![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    /// List of forward-slash relative paths the agent needs the client to upload
44    pub want: Vec<String>,
45    /// List of forward-slash relative paths present remotely that should be deleted
46    pub delete_extraneous: Vec<String>,
47}
48
49/// Resolve a persistent workspace directory path based on a base root and project name.
50/// Example: `~/.farhand/workspaces/my-app-8a4b2e1f/`
51pub 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]); // 8 hex characters
56
57    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
71/// Ensure a persistent workspace exists for `project_name`.
72/// If the directory does not exist, but an existing base/seed workspace exists
73/// (e.g. for `repo:main` when creating `repo:feat`), clones it via APFS CoW.
74pub 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
99/// Returns the default workspaces root directory (`~/.farhand/workspaces`).
100pub 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
112/// Diff client manifest against the remote workspace files, obeying Section 5.1 deletion safety.
113pub 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            // Section 5.1 Deletion Safety Rule:
148            // Never delete files located in default ignored directories (node_modules, target, etc.)
149            // and never delete agent-internal state or history files (.farhand-state.json, .farhand-runs).
150            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
168/// Safely remove files listed in `to_delete` from `workspace_root` and prune empty parent folders.
169pub 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                    // Prune empty parent directories up to workspace root
192                    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; // Directory not empty
199                        }
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        // 1. Legitimate source file that exists remotely
316        fs::write(ws.join("src/old_deleted_file.rs"), b"old").unwrap();
317
318        // 2. Remote cached dependencies (must NOT be deleted)
319        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        // Client manifest only tracks new_file.rs (old_deleted_file.rs was removed locally)
323        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        // old_deleted_file.rs should be flagged for deletion
335        assert_eq!(diff.delete_extraneous, vec!["src/old_deleted_file.rs"]);
336
337        // CRITICAL: node_modules and target files MUST NOT be in delete_extraneous
338        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        // Test apply_deletions
345        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        // Verify node_modules and target survived untouched
350        assert!(ws.join("node_modules/react/index.js").exists());
351        assert!(ws.join("target/release/binary").exists());
352    }
353}