Skip to main content

agtrace_core/
path.rs

1use agtrace_types::{ProjectHash, RepositoryHash};
2use sha2::{Digest, Sha256};
3use std::path::{Path, PathBuf};
4
5pub type Result<T> = std::result::Result<T, Error>;
6
7#[derive(Debug)]
8pub enum Error {
9    Io(std::io::Error),
10    Config(String),
11}
12
13impl std::fmt::Display for Error {
14    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15        match self {
16            Error::Io(err) => write!(f, "IO error: {}", err),
17            Error::Config(msg) => write!(f, "Config error: {}", msg),
18        }
19    }
20}
21
22impl std::error::Error for Error {
23    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
24        match self {
25            Error::Io(err) => Some(err),
26            Error::Config(_) => None,
27        }
28    }
29}
30
31impl From<std::io::Error> for Error {
32    fn from(err: std::io::Error) -> Self {
33        Error::Io(err)
34    }
35}
36
37/// Resolve the workspace data directory path based on priority:
38/// 1. Explicit path (with tilde expansion)
39/// 2. AGTRACE_PATH environment variable (with tilde expansion)
40/// 3. System data directory (recommended default)
41/// 4. ~/.agtrace (fallback for systems without standard data directory)
42pub fn resolve_workspace_path(explicit_path: Option<&str>) -> Result<PathBuf> {
43    // Priority 1: Explicit path
44    if let Some(path) = explicit_path {
45        return Ok(expand_tilde(path));
46    }
47
48    // Priority 2: AGTRACE_PATH environment variable
49    if let Ok(env_path) = std::env::var("AGTRACE_PATH") {
50        return Ok(expand_tilde(&env_path));
51    }
52
53    // Priority 3: System data directory (recommended default)
54    if let Some(data_dir) = dirs::data_dir() {
55        return Ok(data_dir.join("agtrace"));
56    }
57
58    // Priority 4: Fallback to ~/.agtrace (last resort for systems without standard data directory)
59    if let Some(home) = std::env::var_os("HOME") {
60        return Ok(PathBuf::from(home).join(".agtrace"));
61    }
62
63    // This should never happen, but provide a working directory fallback
64    Err(Error::Config(
65        "Could not determine workspace path: no HOME directory or system data directory found"
66            .to_string(),
67    ))
68}
69
70/// Expand tilde (~) in paths to the user's home directory
71pub fn expand_tilde(path: &str) -> PathBuf {
72    if let Some(stripped) = path.strip_prefix("~/")
73        && let Some(home) = std::env::var_os("HOME")
74    {
75        return PathBuf::from(home).join(stripped);
76    }
77    PathBuf::from(path)
78}
79
80/// Calculate project_hash from project_root using SHA256
81///
82/// This function canonicalizes the path before hashing to ensure consistency
83/// across symlinks and different path representations.
84/// For example, `/var/folders/...` and `/private/var/folders/...` will produce
85/// the same hash on macOS where `/var` is a symlink to `/private/var`.
86pub fn project_hash_from_root(project_root: &str) -> ProjectHash {
87    // Normalize path to resolve symlinks and relative paths
88    let normalized = normalize_path(Path::new(project_root));
89    let path_str = normalized.to_string_lossy();
90
91    let mut hasher = Sha256::new();
92    hasher.update(path_str.as_bytes());
93    ProjectHash::new(format!("{:x}", hasher.finalize()))
94}
95
96/// Normalize a path for comparison (resolve to absolute, canonicalize if possible)
97pub fn normalize_path(path: &Path) -> PathBuf {
98    path.canonicalize().unwrap_or_else(|_| {
99        if path.is_absolute() {
100            path.to_path_buf()
101        } else {
102            std::env::current_dir()
103                .map(|cwd| cwd.join(path))
104                .unwrap_or_else(|_| path.to_path_buf())
105        }
106    })
107}
108
109/// Check if two paths are equivalent after normalization
110pub fn paths_equal(path1: &Path, path2: &Path) -> bool {
111    normalize_path(path1) == normalize_path(path2)
112}
113
114/// Discover project root based on priority:
115/// 1. explicit_project_root (--project-root flag)
116/// 2. AGTRACE_PROJECT_ROOT environment variable
117/// 3. Current working directory
118pub fn discover_project_root(explicit_project_root: Option<&str>) -> Result<PathBuf> {
119    if let Some(root) = explicit_project_root {
120        return Ok(PathBuf::from(root));
121    }
122
123    if let Ok(env_root) = std::env::var("AGTRACE_PROJECT_ROOT") {
124        return Ok(PathBuf::from(env_root));
125    }
126
127    let cwd = std::env::current_dir()?;
128    Ok(cwd)
129}
130
131/// Resolve effective project hash based on explicit hash or all_projects flag
132pub fn resolve_effective_project_hash(
133    explicit_hash: Option<&ProjectHash>,
134    all_projects: bool,
135) -> Result<(Option<ProjectHash>, bool)> {
136    if let Some(hash) = explicit_hash {
137        Ok((Some(hash.clone()), false))
138    } else if all_projects {
139        Ok((None, true))
140    } else {
141        let project_root_path = discover_project_root(None)?;
142        let current_project_hash = project_hash_from_root(&project_root_path.to_string_lossy());
143        Ok((Some(current_project_hash), false))
144    }
145}
146
147/// Generate unique project hash from log file path
148/// Used only for sessions without discoverable project_root (orphaned sessions)
149pub fn project_hash_from_log_path(log_path: &Path) -> ProjectHash {
150    let mut hasher = Sha256::new();
151    hasher.update(log_path.to_string_lossy().as_bytes());
152    ProjectHash::new(format!("{:x}", hasher.finalize()))
153}
154
155/// Calculate repository hash from a directory path for git worktree support.
156///
157/// Returns Some(RepositoryHash) if the path is inside a git repository.
158/// The hash is computed from the git common directory (shared .git),
159/// so all worktrees of the same repository return the same hash.
160/// Returns None for non-git directories.
161pub fn repository_hash_from_path(path: &Path) -> Option<RepositoryHash> {
162    use std::process::Command;
163
164    let git_common_dir = Command::new("git")
165        .args(["rev-parse", "--git-common-dir"])
166        .current_dir(path)
167        .output()
168        .ok()?;
169
170    if !git_common_dir.status.success() {
171        return None;
172    }
173
174    let common_dir_str = String::from_utf8_lossy(&git_common_dir.stdout);
175    let common_dir_path = Path::new(common_dir_str.trim());
176
177    // Normalize to absolute path
178    let normalized = normalize_path(common_dir_path);
179    let path_str = normalized.to_string_lossy();
180
181    let mut hasher = Sha256::new();
182    hasher.update(path_str.as_bytes());
183    Some(RepositoryHash::new(format!("{:x}", hasher.finalize())))
184}