thoughts_tool/utils/
paths.rs1use anyhow::Result;
2use anyhow::anyhow;
3use dirs;
4use std::io::ErrorKind;
5use std::path::Path;
6use std::path::PathBuf;
7
8pub 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
23pub 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
56pub fn sanitize_dir_name(name: &str) -> String {
58 name.chars()
59 .map(|c| match c {
60 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
61 _ => c,
62 })
63 .collect()
64}
65
66pub fn get_repo_config_path(repo_root: &Path) -> PathBuf {
70 repo_root.join(".thoughts").join("config.json")
71}
72
73#[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
81pub fn get_local_metadata_path(repo_root: &Path) -> PathBuf {
84 repo_root.join(".thoughts").join("data").join("local.json")
85}
86
87pub fn get_repo_rules_path(repo_root: &Path) -> PathBuf {
90 repo_root.join(".thoughts").join("rules.json")
91}
92
93fn 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
105pub fn get_repo_mapping_path() -> Result<PathBuf> {
109 Ok(xdg_config_home()?.join("agentic").join("repos.json"))
110}
111
112pub 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
121pub 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 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 assert_eq!(
143 expand_path(Path::new("/tmp/test")).unwrap(),
144 PathBuf::from("/tmp/test")
145 );
146
147 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}