thoughts_tool/config/
repo_mapping_manager.rs1use super::types::{RepoLocation, RepoMapping};
2use crate::utils::paths::{self, sanitize_dir_name};
3use anyhow::{Context, Result, bail};
4use atomicwrites::{AllowOverwrite, AtomicFile};
5use std::io::Write;
6use std::path::PathBuf;
7
8pub struct RepoMappingManager {
9 mapping_path: PathBuf,
10}
11
12impl RepoMappingManager {
13 pub fn new() -> Result<Self> {
14 let mapping_path = paths::get_repo_mapping_path()?;
15 Ok(Self { mapping_path })
16 }
17
18 pub fn load(&self) -> Result<RepoMapping> {
19 if !self.mapping_path.exists() {
20 let default = RepoMapping::default();
22 self.save(&default)?;
23 return Ok(default);
24 }
25
26 let contents = std::fs::read_to_string(&self.mapping_path)
27 .context("Failed to read repository mapping file")?;
28 let mapping: RepoMapping =
29 serde_json::from_str(&contents).context("Failed to parse repository mapping")?;
30 Ok(mapping)
31 }
32
33 pub fn save(&self, mapping: &RepoMapping) -> Result<()> {
34 if let Some(parent) = self.mapping_path.parent() {
36 paths::ensure_dir(parent)?;
37 }
38
39 let json = serde_json::to_string_pretty(mapping)?;
41 let af = AtomicFile::new(&self.mapping_path, AllowOverwrite);
42 af.write(|f| f.write_all(json.as_bytes()))?;
43
44 Ok(())
45 }
46
47 pub fn resolve_url(&self, url: &str) -> Result<Option<PathBuf>> {
49 let mapping = self.load()?;
50
51 let (base_url, subpath) = parse_url_and_subpath(url);
53
54 if let Some(location) = mapping.mappings.get(&base_url) {
55 let mut path = location.path.clone();
56 if let Some(sub) = subpath {
57 path = path.join(sub);
58 }
59 Ok(Some(path))
60 } else {
61 Ok(None)
62 }
63 }
64
65 pub fn add_mapping(&mut self, url: String, path: PathBuf, auto_managed: bool) -> Result<()> {
67 let mut mapping = self.load()?;
68
69 if !path.exists() {
71 bail!("Path does not exist: {}", path.display());
72 }
73
74 if !path.is_dir() {
75 bail!("Path is not a directory: {}", path.display());
76 }
77
78 let location = RepoLocation {
79 path,
80 auto_managed,
81 last_sync: None,
82 };
83
84 mapping.mappings.insert(url, location);
85 self.save(&mapping)?;
86 Ok(())
87 }
88
89 #[allow(dead_code)]
91 pub fn remove_mapping(&mut self, url: &str) -> Result<()> {
93 let mut mapping = self.load()?;
94 mapping.mappings.remove(url);
95 self.save(&mapping)?;
96 Ok(())
97 }
98
99 pub fn is_auto_managed(&self, url: &str) -> Result<bool> {
101 let mapping = self.load()?;
102 Ok(mapping
103 .mappings
104 .get(url)
105 .map(|loc| loc.auto_managed)
106 .unwrap_or(false))
107 }
108
109 pub fn get_default_clone_path(url: &str) -> Result<PathBuf> {
111 let home = dirs::home_dir()
112 .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
113
114 let (base_url, _sub) = parse_url_and_subpath(url);
115 let repo_name = sanitize_dir_name(&extract_repo_name_from_url(&base_url)?);
116 Ok(home.join(".thoughts").join("clones").join(repo_name))
117 }
118
119 pub fn update_sync_time(&mut self, url: &str) -> Result<()> {
121 let mut mapping = self.load()?;
122
123 if let Some(location) = mapping.mappings.get_mut(url) {
124 location.last_sync = Some(chrono::Utc::now());
125 self.save(&mapping)?;
126 }
127
128 Ok(())
129 }
130}
131
132pub fn parse_url_and_subpath(url: &str) -> (String, Option<String>) {
133 let parts: Vec<&str> = url.splitn(3, ':').collect();
139
140 if parts.len() == 3 {
141 let potential_subpath = parts[2];
143 let potential_base = format!("{}:{}", parts[0], parts[1]);
144
145 if (potential_base.contains('@') && potential_base.contains('/'))
147 || potential_base.ends_with(".git")
148 {
149 return (potential_base, Some(potential_subpath.to_string()));
151 }
152 }
153
154 (url.to_string(), None)
155}
156
157pub fn extract_repo_name_from_url(url: &str) -> Result<String> {
158 let url = url.trim_end_matches(".git");
159
160 if let Some(pos) = url.rfind('/') {
162 Ok(url[pos + 1..].to_string())
163 } else if let Some(pos) = url.rfind(':') {
164 if let Some(slash_pos) = url[pos + 1..].rfind('/') {
166 Ok(url[pos + 1 + slash_pos + 1..].to_string())
167 } else {
168 Ok(url[pos + 1..].to_string())
169 }
170 } else {
171 bail!("Cannot extract repository name from URL: {}", url)
172 }
173}
174
175pub fn extract_org_repo_from_url(url: &str) -> anyhow::Result<(String, String)> {
176 let url = url.trim_end_matches(".git");
178 if let Some(at_pos) = url.find('@')
180 && let Some(colon_pos) = url[at_pos..].find(':')
181 {
182 let path = &url[at_pos + colon_pos + 1..]; let mut it = path.split('/');
184 let org = it.next().ok_or_else(|| anyhow::anyhow!("No org"))?;
185 let repo = it.next().ok_or_else(|| anyhow::anyhow!("No repo"))?;
186 return Ok((org.into(), repo.into()));
187 }
188 if let Some(host_pos) = url.find("://") {
190 let path = &url[host_pos + 3..]; let mut it = path.split('/');
192 let _host = it.next().ok_or_else(|| anyhow::anyhow!("No host"))?;
193 let org = it.next().ok_or_else(|| anyhow::anyhow!("No org"))?;
194 let repo = it.next().ok_or_else(|| anyhow::anyhow!("No repo"))?;
195 return Ok((org.into(), repo.into()));
196 }
197 anyhow::bail!("Unsupported URL: {url}")
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203
204 #[test]
205 fn test_parse_url_and_subpath() {
206 let (url, sub) = parse_url_and_subpath("git@github.com:user/repo.git");
207 assert_eq!(url, "git@github.com:user/repo.git");
208 assert_eq!(sub, None);
209
210 let (url, sub) = parse_url_and_subpath("git@github.com:user/repo.git:docs/api");
211 assert_eq!(url, "git@github.com:user/repo.git");
212 assert_eq!(sub, Some("docs/api".to_string()));
213
214 let (url, sub) = parse_url_and_subpath("https://github.com/user/repo");
215 assert_eq!(url, "https://github.com/user/repo");
216 assert_eq!(sub, None);
217 }
218
219 #[test]
220 fn test_extract_repo_name() {
221 assert_eq!(
222 extract_repo_name_from_url("git@github.com:user/repo.git").unwrap(),
223 "repo"
224 );
225 assert_eq!(
226 extract_repo_name_from_url("https://github.com/user/repo").unwrap(),
227 "repo"
228 );
229 assert_eq!(
230 extract_repo_name_from_url("git@github.com:user/repo").unwrap(),
231 "repo"
232 );
233 }
234
235 #[test]
236 fn test_extract_org_repo() {
237 assert_eq!(
238 extract_org_repo_from_url("git@github.com:user/repo.git").unwrap(),
239 ("user".to_string(), "repo".to_string())
240 );
241 assert_eq!(
242 extract_org_repo_from_url("https://github.com/user/repo").unwrap(),
243 ("user".to_string(), "repo".to_string())
244 );
245 assert_eq!(
246 extract_org_repo_from_url("git@github.com:user/repo").unwrap(),
247 ("user".to_string(), "repo".to_string())
248 );
249 assert_eq!(
250 extract_org_repo_from_url("https://github.com/modelcontextprotocol/rust-sdk.git")
251 .unwrap(),
252 ("modelcontextprotocol".to_string(), "rust-sdk".to_string())
253 );
254 }
255
256 #[test]
257 fn test_default_clone_path_uses_base_url_for_subpath() {
258 let p =
259 RepoMappingManager::get_default_clone_path("git@github.com:org/repo.git:docs").unwrap();
260 assert!(p.ends_with(std::path::Path::new(".thoughts/clones/repo")));
261 }
262}