Skip to main content

thoughts_tool/config/
repo_mapping_manager.rs

1use 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            // First time - create empty mapping
21            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        // Ensure directory exists
35        if let Some(parent) = self.mapping_path.parent() {
36            paths::ensure_dir(parent)?;
37        }
38
39        // Atomic write for safety
40        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    /// Resolve a git URL to its local path
48    pub fn resolve_url(&self, url: &str) -> Result<Option<PathBuf>> {
49        let mapping = self.load()?;
50
51        // Handle subdirectory URLs (git@github.com:user/repo.git:docs/api)
52        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    /// Add a URL-to-path mapping
66    pub fn add_mapping(&mut self, url: String, path: PathBuf, auto_managed: bool) -> Result<()> {
67        let mut mapping = self.load()?;
68
69        // Basic validation
70        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    /// Remove a URL mapping
90    #[allow(dead_code)]
91    // TODO(2): Add "thoughts mount unmap" command for cleanup
92    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    /// Check if a URL is auto-managed
100    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    /// Get default clone path for a URL
110    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    /// Update last sync time
120    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    // Look for a subpath pattern: URL followed by :path
134    // For SSH URLs like git@github.com:user/repo.git, the first colon is part of the URL
135    // So we look for a pattern where we have a second colon after .git or end of repo name
136
137    // First, check if this looks like it might have a subpath
138    let parts: Vec<&str> = url.splitn(3, ':').collect();
139
140    if parts.len() == 3 {
141        // Potential subpath - verify it's not part of the URL
142        let potential_subpath = parts[2];
143        let potential_base = format!("{}:{}", parts[0], parts[1]);
144
145        // Check if the base looks like a complete URL
146        if (potential_base.contains('@') && potential_base.contains('/'))
147            || potential_base.ends_with(".git")
148        {
149            // This looks like URL:subpath format
150            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    // Handle different URL formats
161    if let Some(pos) = url.rfind('/') {
162        Ok(url[pos + 1..].to_string())
163    } else if let Some(pos) = url.rfind(':') {
164        // SSH format like git@github.com:user/repo
165        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    // Normalize
177    let url = url.trim_end_matches(".git");
178    // SSH: git@github.com:org/repo
179    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..]; // org/repo
183        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    // HTTPS: https://github.com/org/repo
189    if let Some(host_pos) = url.find("://") {
190        let path = &url[host_pos + 3..]; // host/org/repo
191        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}