Skip to main content

thoughts_tool/utils/
paths.rs

1use anyhow::Result;
2use anyhow::anyhow;
3use dirs;
4use std::io::ErrorKind;
5use std::path::Path;
6use std::path::PathBuf;
7
8/// Expand tilde (~) in paths to home directory
9pub fn expand_path(path: &Path) -> Result<PathBuf> {
10    let path_str = path.to_string_lossy();
11
12    if let Some(stripped) = path_str.strip_prefix("~/") {
13        let home = dirs::home_dir()
14            .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
15        Ok(home.join(stripped))
16    } else if path_str == "~" {
17        dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))
18    } else {
19        Ok(path.to_path_buf())
20    }
21}
22
23/// Ensure a directory exists, creating it if necessary
24pub fn ensure_dir(path: &Path) -> Result<()> {
25    match std::fs::metadata(path) {
26        Ok(metadata) if metadata.is_dir() => Ok(()),
27        Ok(_) => Err(anyhow!(
28            "Path exists but is not a directory: {}",
29            path.display()
30        )),
31        Err(error) if error.kind() == ErrorKind::NotFound => match std::fs::create_dir_all(path) {
32            Ok(()) => Ok(()),
33            Err(create_error) if create_error.kind() == ErrorKind::AlreadyExists => {
34                match std::fs::metadata(path) {
35                    Ok(metadata) if metadata.is_dir() => Ok(()),
36                    Ok(_) => Err(anyhow!(
37                        "Path exists but is not a directory: {}",
38                        path.display()
39                    )),
40                    Err(_) => Err(anyhow!(create_error).context(format!(
41                        "Path already exists but could not be accessed as a directory: {}",
42                        path.display()
43                    ))),
44                }
45            }
46            Err(create_error) => Err(anyhow!(create_error)
47                .context(format!("Failed to create directory: {}", path.display()))),
48        },
49        Err(error) => Err(anyhow!(error).context(format!(
50            "Failed to access directory path: {}",
51            path.display()
52        ))),
53    }
54}
55
56/// Sanitize a directory name for use in filesystem
57pub fn sanitize_dir_name(name: &str) -> String {
58    name.chars()
59        .map(|c| match c {
60            '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
61            _ => c,
62        })
63        .collect()
64}
65
66// Add after line 50 (after sanitize_dir_name function)
67
68/// Get the repository configuration file path
69pub fn get_repo_config_path(repo_root: &Path) -> PathBuf {
70    repo_root.join(".thoughts").join("config.json")
71}
72
73/// Get external metadata directory for personal metadata about other repos
74#[cfg(target_os = "macos")]
75pub fn get_external_metadata_dir() -> Result<PathBuf> {
76    let home =
77        dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
78    Ok(home.join(".thoughts").join("data").join("external"))
79}
80
81/// Get local metadata file path for a repository
82// TODO(2): Implement local metadata caching
83pub fn get_local_metadata_path(repo_root: &Path) -> PathBuf {
84    repo_root.join(".thoughts").join("data").join("local.json")
85}
86
87/// Get rules file path for a repository
88// TODO(2): Implement repository-specific rules system
89pub fn get_repo_rules_path(repo_root: &Path) -> PathBuf {
90    repo_root.join(".thoughts").join("rules.json")
91}
92
93/// Get the XDG config home directory.
94///
95/// Returns `$XDG_CONFIG_HOME` if set, otherwise `~/.config`.
96fn xdg_config_home() -> Result<PathBuf> {
97    if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME") {
98        return Ok(PathBuf::from(dir));
99    }
100    let home =
101        dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
102    Ok(home.join(".config"))
103}
104
105/// Get the repository mapping file path.
106///
107/// Returns the location at `~/.config/agentic/repos.json`.
108pub fn get_repo_mapping_path() -> Result<PathBuf> {
109    Ok(xdg_config_home()?.join("agentic").join("repos.json"))
110}
111
112/// Get the legacy repository mapping file path.
113///
114/// Returns the old location at `~/.thoughts/repos.json` for migration purposes.
115pub fn get_legacy_repo_mapping_path() -> Result<PathBuf> {
116    let home =
117        dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
118    Ok(home.join(".thoughts").join("repos.json"))
119}
120
121/// Get the personal config path (for deprecation warnings)
122pub fn get_personal_config_path() -> Result<PathBuf> {
123    let home =
124        dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
125    Ok(home.join(".thoughts").join("config.json"))
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use serial_test::serial;
132
133    #[test]
134    #[serial]
135    fn test_expand_path() {
136        // Test tilde expansion
137        let home = dirs::home_dir().unwrap();
138        assert_eq!(expand_path(Path::new("~/test")).unwrap(), home.join("test"));
139        assert_eq!(expand_path(Path::new("~")).unwrap(), home);
140
141        // Test absolute path
142        assert_eq!(
143            expand_path(Path::new("/tmp/test")).unwrap(),
144            PathBuf::from("/tmp/test")
145        );
146
147        // Test relative path
148        assert_eq!(
149            expand_path(Path::new("test")).unwrap(),
150            PathBuf::from("test")
151        );
152    }
153
154    #[test]
155    fn test_sanitize_dir_name() {
156        assert_eq!(sanitize_dir_name("normal-name_123"), "normal-name_123");
157        assert_eq!(
158            sanitize_dir_name("bad/name:with*chars?"),
159            "bad_name_with_chars_"
160        );
161    }
162
163    #[test]
164    fn test_ensure_dir_creates_missing_directory() {
165        let temp = tempfile::tempdir().unwrap();
166        let path = temp.path().join("new-dir");
167
168        ensure_dir(&path).unwrap();
169
170        assert!(path.is_dir());
171    }
172
173    #[test]
174    fn test_ensure_dir_rejects_existing_file() {
175        let temp = tempfile::tempdir().unwrap();
176        let path = temp.path().join("file");
177        std::fs::write(&path, "not a directory").unwrap();
178
179        let error = ensure_dir(&path).unwrap_err().to_string();
180
181        assert!(error.contains("Path exists but is not a directory"));
182        assert!(error.contains(&path.display().to_string()));
183    }
184}