forge_core_utils/
path.rs

1use std::path::{Path, PathBuf};
2
3/// Directory name for storing images in worktrees
4pub const FORGE_IMAGES_DIR: &str = ".forge-images";
5
6/// Convert absolute paths to relative paths based on worktree path
7/// This is a robust implementation that handles symlinks and edge cases
8pub fn make_path_relative(path: &str, worktree_path: &str) -> String {
9    tracing::debug!("Making path relative: {} -> {}", path, worktree_path);
10
11    let path_obj = normalize_macos_private_alias(Path::new(&path));
12    let worktree_path_obj = normalize_macos_private_alias(Path::new(worktree_path));
13
14    // If path is already relative, return as is
15    if path_obj.is_relative() {
16        return path.to_string();
17    }
18
19    if let Ok(relative_path) = path_obj.strip_prefix(&worktree_path_obj) {
20        let result = relative_path.to_string_lossy().to_string();
21        tracing::debug!("Successfully made relative: '{}' -> '{}'", path, result);
22        if result.is_empty() {
23            return ".".to_string();
24        }
25        return result;
26    }
27
28    if !path_obj.exists() || !worktree_path_obj.exists() {
29        return path.to_string();
30    }
31
32    // canonicalize may fail if paths don't exist
33    let canonical_path = std::fs::canonicalize(&path_obj);
34    let canonical_worktree = std::fs::canonicalize(&worktree_path_obj);
35
36    match (canonical_path, canonical_worktree) {
37        (Ok(canon_path), Ok(canon_worktree)) => {
38            tracing::debug!(
39                "Trying canonical path resolution: '{}' -> '{}', '{}' -> '{}'",
40                path,
41                canon_path.display(),
42                worktree_path,
43                canon_worktree.display()
44            );
45
46            // Detect cross-root scenario (e.g., worktree in /var/tmp, main repo in /home)
47            // This is expected behavior when paths don't share a common root
48            if !canon_path.starts_with(&canon_worktree) {
49                tracing::debug!(
50                    "Path is outside worktree root (cross-root scenario): '{}' not under '{}', returning absolute path",
51                    canon_path.display(),
52                    canon_worktree.display()
53                );
54                return path.to_string();
55            }
56
57            match canon_path.strip_prefix(&canon_worktree) {
58                Ok(relative_path) => {
59                    let result = relative_path.to_string_lossy().to_string();
60                    tracing::debug!(
61                        "Successfully made relative with canonical paths: '{}' -> '{}'",
62                        path,
63                        result
64                    );
65                    if result.is_empty() {
66                        return ".".to_string();
67                    }
68                    result
69                }
70                Err(e) => {
71                    tracing::warn!(
72                        "Failed to make canonical path relative: '{}' relative to '{}', error: {}, returning original",
73                        canon_path.display(),
74                        canon_worktree.display(),
75                        e
76                    );
77                    path.to_string()
78                }
79            }
80        }
81        _ => {
82            tracing::debug!(
83                "Could not canonicalize paths (paths may not exist): '{}', '{}', returning original",
84                path,
85                worktree_path
86            );
87            path.to_string()
88        }
89    }
90}
91
92/// Normalize macOS prefix /private/var/ and /private/tmp/ to their public aliases without resolving paths.
93/// This allows prefix normalization to work when the full paths don't exist.
94fn normalize_macos_private_alias<P: AsRef<Path>>(p: P) -> PathBuf {
95    let p = p.as_ref();
96    if cfg!(target_os = "macos")
97        && let Some(s) = p.to_str()
98    {
99        if s == "/private/var" {
100            return PathBuf::from("/var");
101        }
102        if let Some(rest) = s.strip_prefix("/private/var/") {
103            return PathBuf::from(format!("/var/{rest}"));
104        }
105        if s == "/private/tmp" {
106            return PathBuf::from("/tmp");
107        }
108        if let Some(rest) = s.strip_prefix("/private/tmp/") {
109            return PathBuf::from(format!("/tmp/{rest}"));
110        }
111    }
112    p.to_path_buf()
113}
114
115pub fn get_automagik_forge_temp_dir() -> std::path::PathBuf {
116    let dir_name = if cfg!(debug_assertions) {
117        "automagik-forge-dev"
118    } else {
119        "automagik-forge"
120    };
121
122    if cfg!(target_os = "macos") {
123        // macOS already uses /var/folders/... which is persistent storage
124        std::env::temp_dir().join(dir_name)
125    } else if cfg!(target_os = "linux") {
126        // Linux: use /var/tmp instead of /tmp to avoid RAM usage
127        std::path::PathBuf::from("/var/tmp").join(dir_name)
128    } else {
129        // Windows and other platforms: use temp dir with automagik-forge subdirectory
130        std::env::temp_dir().join(dir_name)
131    }
132}
133
134/// Expand leading ~ to user's home directory.
135pub fn expand_tilde(path_str: &str) -> std::path::PathBuf {
136    shellexpand::tilde(path_str).as_ref().into()
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn test_make_path_relative() {
145        // Test with relative path (should remain unchanged)
146        assert_eq!(
147            make_path_relative("src/main.rs", "/tmp/test-worktree"),
148            "src/main.rs"
149        );
150
151        // Test with absolute path (should become relative if possible)
152        let test_worktree = "/tmp/test-worktree";
153        let absolute_path = format!("{test_worktree}/src/main.rs");
154        let result = make_path_relative(&absolute_path, test_worktree);
155        assert_eq!(result, "src/main.rs");
156
157        // Test with path outside worktree (should return original)
158        assert_eq!(
159            make_path_relative("/other/path/file.js", "/tmp/test-worktree"),
160            "/other/path/file.js"
161        );
162    }
163
164    #[cfg(target_os = "macos")]
165    #[test]
166    fn test_make_path_relative_macos_private_alias() {
167        // Simulate a worktree under /var with a path reported under /private/var
168        let worktree = "/var/folders/zz/abc123/T/automagik-forge-dev/worktrees/af-test";
169        let path_under_private = format!(
170            "/private/var{}/hello-world.txt",
171            worktree.strip_prefix("/var").unwrap()
172        );
173        assert_eq!(
174            make_path_relative(&path_under_private, worktree),
175            "hello-world.txt"
176        );
177
178        // Also handle the inverse: worktree under /private and path under /var
179        let worktree_private = format!("/private{worktree}");
180        let path_under_var = format!("{worktree}/hello-world.txt");
181        assert_eq!(
182            make_path_relative(&path_under_var, &worktree_private),
183            "hello-world.txt"
184        );
185    }
186}