Skip to main content

thoughts_tool/config/
repo_mapping_manager.rs

1use super::types::RepoLocation;
2use super::types::RepoMapping;
3use crate::config::validation::canonical_reference_instance_key;
4use crate::config::validation::canonical_reference_key;
5use crate::config::validation::normalize_encoded_ref_key_for_identity;
6use crate::git::ref_key::encode_ref_key;
7use crate::repo_identity::RepoIdentity;
8use crate::repo_identity::RepoIdentityKey;
9use crate::repo_identity::parse_url_and_subpath as identity_parse_url_and_subpath;
10use crate::utils::locks::FileLock;
11use crate::utils::paths::sanitize_dir_name;
12use crate::utils::paths::{self};
13use anyhow::Context;
14use anyhow::Result;
15use anyhow::bail;
16use atomicwrites::AllowOverwrite;
17use atomicwrites::AtomicFile;
18use std::io::ErrorKind;
19use std::io::Write;
20use std::path::Component;
21use std::path::Path;
22use std::path::PathBuf;
23
24const REFERENCE_MAPPING_MARKER: &str = "#thoughts-ref=";
25
26/// Indicates how a URL was resolved to a mapping.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum UrlResolutionKind {
29    /// The URL matched exactly as stored in repos.json
30    Exact,
31    /// The URL matched via canonical identity comparison (different scheme/format)
32    CanonicalFallback,
33}
34
35/// Details about a resolved URL mapping.
36#[derive(Debug, Clone)]
37pub struct ResolvedUrl {
38    /// The key in repos.json that matched
39    pub matched_url: String,
40    /// How the match was found
41    pub resolution: UrlResolutionKind,
42    /// The location details (cloned)
43    pub location: RepoLocation,
44}
45
46pub struct RepoMappingManager {
47    mapping_path: PathBuf,
48}
49
50/// Compute the lock path for a given mapping file path.
51///
52/// This is extracted as a standalone function so it can be used both in
53/// `RepoMappingManager::lock_path()` and `migrate_legacy_repos_json_if_needed()`.
54fn lock_path_for_mapping_path(mapping_path: &Path) -> PathBuf {
55    let name = mapping_path
56        .file_name()
57        .unwrap_or_default()
58        .to_string_lossy();
59    mapping_path.with_file_name(format!("{name}.lock"))
60}
61
62/// Migrate legacy repos.json to the new location, if needed.
63///
64/// Returns `Ok(true)` if migration was performed, `Ok(false)` otherwise.
65///
66/// # Safety Properties
67///
68/// - Acquires the same lock used by all RMW operations.
69/// - Re-checks `mapping_path.exists()` under lock to close the TOCTOU window.
70/// - Writes via `AtomicFile` for atomic visibility.
71fn migrate_legacy_repos_json_if_needed(mapping_path: &Path, legacy_path: &Path) -> Result<bool> {
72    let _lock = FileLock::lock_exclusive(lock_path_for_mapping_path(mapping_path))?;
73
74    // Re-check under lock to close the TOCTOU window.
75    if mapping_path.exists() || !legacy_path.exists() {
76        return Ok(false);
77    }
78
79    if let Some(parent) = mapping_path.parent() {
80        paths::ensure_dir(parent)?;
81    }
82
83    let bytes = std::fs::read(legacy_path).with_context(|| {
84        format!(
85            "Failed to read legacy repos.json at {}",
86            legacy_path.display()
87        )
88    })?;
89
90    let af = AtomicFile::new(mapping_path, AllowOverwrite);
91    af.write(|f| f.write_all(&bytes))
92        .context("Failed to migrate repos.json from legacy location")?;
93
94    tracing::info!(
95        "Migrated repos.json from {} to {}",
96        legacy_path.display(),
97        mapping_path.display()
98    );
99
100    Ok(true)
101}
102
103impl RepoMappingManager {
104    pub fn new() -> Result<Self> {
105        let mapping_path = paths::get_repo_mapping_path()?;
106
107        // Check for legacy location migration (fast-path guard), then migrate under lock.
108        if !mapping_path.exists()
109            && let Ok(legacy_path) = paths::get_legacy_repo_mapping_path()
110            && legacy_path.exists()
111        {
112            // Performs the lock + recheck + atomic write.
113            let _ = migrate_legacy_repos_json_if_needed(&mapping_path, &legacy_path)?;
114        }
115
116        Ok(Self { mapping_path })
117    }
118
119    /// Get the lock file path for repos.json RMW operations.
120    fn lock_path(&self) -> PathBuf {
121        lock_path_for_mapping_path(&self.mapping_path)
122    }
123
124    pub fn load(&self) -> Result<RepoMapping> {
125        let contents = match std::fs::read_to_string(&self.mapping_path) {
126            Ok(c) => c,
127            Err(e) if e.kind() == ErrorKind::NotFound => {
128                // Pure read: do not create the file here.
129                return Ok(RepoMapping::default());
130            }
131            Err(e) => {
132                return Err(e).context("Failed to read repository mapping file");
133            }
134        };
135
136        serde_json::from_str(&contents).context("Failed to parse repository mapping")
137    }
138
139    pub fn save(&self, mapping: &RepoMapping) -> Result<()> {
140        // Ensure directory exists
141        if let Some(parent) = self.mapping_path.parent() {
142            paths::ensure_dir(parent)?;
143        }
144
145        // Atomic write for safety
146        let json = serde_json::to_string_pretty(mapping)?;
147        let af = AtomicFile::new(&self.mapping_path, AllowOverwrite);
148        af.write(|f| f.write_all(json.as_bytes()))?;
149
150        Ok(())
151    }
152
153    /// Load the mapping while holding an exclusive lock.
154    ///
155    /// Returns the mapping and the lock guard. The lock is released when the
156    /// guard is dropped, so callers should hold it until after `save()`.
157    ///
158    /// Use this for read-modify-write operations to prevent concurrent updates
159    /// from losing changes.
160    pub fn load_locked(&self) -> Result<(RepoMapping, FileLock)> {
161        let lock = FileLock::lock_exclusive(self.lock_path())?;
162        let mapping = self.load()?;
163        Ok((mapping, lock))
164    }
165
166    /// Resolve a git URL with detailed resolution information.
167    ///
168    /// Returns the matched URL key, resolution kind, location, and optional subpath.
169    pub fn resolve_url_with_details(
170        &self,
171        url: &str,
172    ) -> Result<Option<(ResolvedUrl, Option<String>)>> {
173        let mapping = self.load()?; // read-only; atomic writes make this safe
174        let (base_url, subpath) = parse_url_and_subpath(url);
175
176        // Try exact match first
177        if let Some(loc) = mapping.mappings.get(&base_url) {
178            return Ok(Some((
179                ResolvedUrl {
180                    matched_url: base_url,
181                    resolution: UrlResolutionKind::Exact,
182                    location: loc.clone(),
183                },
184                subpath,
185            )));
186        }
187
188        // Canonical fallback: parse target URL and find a matching key
189        let target_key = match RepoIdentity::parse(&base_url) {
190            Ok(id) => id.canonical_key(),
191            Err(_) => return Ok(None),
192        };
193
194        let mut matches: Vec<(String, RepoLocation)> = mapping
195            .mappings
196            .iter()
197            .filter_map(|(k, v)| {
198                let (k_base, _) = parse_url_and_subpath(k);
199                let key = RepoIdentity::parse(&k_base).ok()?.canonical_key();
200                (key == target_key).then(|| (k.clone(), v.clone()))
201            })
202            .collect();
203
204        // Sort for deterministic selection
205        matches.sort_by(|a, b| a.0.cmp(&b.0));
206
207        if let Some((matched_url, location)) = matches.into_iter().next() {
208            return Ok(Some((
209                ResolvedUrl {
210                    matched_url,
211                    resolution: UrlResolutionKind::CanonicalFallback,
212                    location,
213                },
214                subpath,
215            )));
216        }
217
218        Ok(None)
219    }
220
221    /// Resolve a git URL to its local path.
222    ///
223    /// Uses exact match first, then falls back to canonical identity matching
224    /// to handle URL scheme variants (SSH vs HTTPS).
225    pub fn resolve_url(&self, url: &str) -> Result<Option<PathBuf>> {
226        if let Some((resolved, subpath)) = self.resolve_url_with_details(url)? {
227            let mut p = resolved.location.path.clone();
228            if let Some(ref sub) = subpath {
229                validate_subpath(sub)?;
230                p = p.join(sub);
231            }
232            return Ok(Some(p));
233        }
234        Ok(None)
235    }
236
237    pub fn resolve_reference_url(
238        &self,
239        url: &str,
240        ref_name: Option<&str>,
241    ) -> Result<Option<PathBuf>> {
242        let mapping = self.load()?;
243        let (_, subpath) = parse_url_and_subpath(url);
244
245        let matches = Self::matching_reference_storage_keys(&mapping, url, ref_name)?;
246
247        if let Some(stored_key) = matches.first()
248            && let Some(location) = mapping.mappings.get(stored_key)
249        {
250            let mut p = location.path.clone();
251            if let Some(ref sub) = subpath {
252                validate_subpath(sub)?;
253                p = p.join(sub);
254            }
255            return Ok(Some(p));
256        }
257
258        Ok(None)
259    }
260
261    /// Add a URL-to-path mapping with identity-based upsert.
262    ///
263    /// If a mapping with the same canonical identity already exists,
264    /// it will be replaced (preserving any existing last_sync time).
265    /// This prevents duplicate entries for SSH vs HTTPS variants.
266    pub fn add_mapping(&mut self, url: String, path: PathBuf, auto_managed: bool) -> Result<()> {
267        let _lock = FileLock::lock_exclusive(self.lock_path())?;
268        let mut mapping = self.load()?; // safe under lock for RMW
269
270        // Basic validation
271        if !path.exists() {
272            bail!("Path does not exist: {}", path.display());
273        }
274
275        if !path.is_dir() {
276            bail!("Path is not a directory: {}", path.display());
277        }
278
279        let (base_url, _) = parse_url_and_subpath(&url);
280        let new_key = RepoIdentity::parse(&base_url)?.canonical_key();
281
282        // Find all existing entries with the same canonical identity
283        let matching_urls: Vec<String> = mapping
284            .mappings
285            .keys()
286            .filter_map(|k| {
287                let (k_base, _) = parse_url_and_subpath(k);
288                let key = RepoIdentity::parse(&k_base).ok()?.canonical_key();
289                (key == new_key).then(|| k.clone())
290            })
291            .collect();
292
293        // Preserve last_sync from any existing entry
294        let preserved_last_sync = matching_urls
295            .iter()
296            .filter_map(|k| mapping.mappings.get(k).and_then(|loc| loc.last_sync))
297            .max();
298
299        // Remove all matching entries
300        for k in matching_urls {
301            mapping.mappings.remove(&k);
302        }
303
304        // Insert the new mapping
305        mapping.mappings.insert(
306            base_url,
307            RepoLocation {
308                path,
309                auto_managed,
310                last_sync: preserved_last_sync,
311            },
312        );
313
314        self.save(&mapping)?;
315        Ok(())
316    }
317
318    pub fn add_reference_mapping(
319        &mut self,
320        url: String,
321        ref_name: Option<&str>,
322        path: PathBuf,
323        auto_managed: bool,
324    ) -> Result<()> {
325        let _lock = FileLock::lock_exclusive(self.lock_path())?;
326        let mut mapping = self.load()?;
327
328        if !path.exists() {
329            bail!("Path does not exist: {}", path.display());
330        }
331
332        if !path.is_dir() {
333            bail!("Path is not a directory: {}", path.display());
334        }
335
336        let storage_key = reference_mapping_storage_key(&url, ref_name)?;
337        let matching_urls = Self::matching_reference_storage_keys(&mapping, &url, ref_name)?;
338
339        let preserved_last_sync = matching_urls
340            .iter()
341            .filter_map(|k| mapping.mappings.get(k).and_then(|loc| loc.last_sync))
342            .max();
343
344        for k in matching_urls {
345            mapping.mappings.remove(&k);
346        }
347
348        mapping.mappings.insert(
349            storage_key,
350            RepoLocation {
351                path,
352                auto_managed,
353                last_sync: preserved_last_sync,
354            },
355        );
356
357        self.save(&mapping)?;
358        Ok(())
359    }
360
361    /// Remove a URL mapping
362    #[allow(dead_code)]
363    // TODO(2): Add "thoughts mount unmap" command for cleanup
364    pub fn remove_mapping(&mut self, url: &str) -> Result<()> {
365        let _lock = FileLock::lock_exclusive(self.lock_path())?;
366        let mut mapping = self.load()?;
367        mapping.mappings.remove(url);
368        self.save(&mapping)?;
369        Ok(())
370    }
371
372    /// Check if a URL is auto-managed
373    pub fn is_auto_managed(&self, url: &str) -> Result<bool> {
374        let mapping = self.load()?;
375        Ok(mapping
376            .mappings
377            .get(url)
378            .map(|loc| loc.auto_managed)
379            .unwrap_or(false))
380    }
381
382    pub fn is_reference_auto_managed(&self, url: &str, ref_name: Option<&str>) -> Result<bool> {
383        let mapping = self.load()?;
384        let keys = Self::matching_reference_storage_keys(&mapping, url, ref_name)?;
385        Ok(keys
386            .first()
387            .and_then(|key| mapping.mappings.get(key))
388            .map(|loc| loc.auto_managed)
389            .unwrap_or(false))
390    }
391
392    /// Get default clone path for a URL using hierarchical layout.
393    ///
394    /// Returns `~/.thoughts/clones/{host}/{org_path}/{repo}` with sanitized directory names.
395    pub fn get_default_clone_path(url: &str) -> Result<PathBuf> {
396        let home = dirs::home_dir()
397            .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
398
399        let (base_url, _sub) = parse_url_and_subpath(url);
400        let id = RepoIdentity::parse(&base_url)?;
401        let key = id.canonical_key(); // use canonical for stable paths across case/scheme
402
403        let mut p = home
404            .join(".thoughts")
405            .join("clones")
406            .join(sanitize_dir_name(&key.host));
407        for seg in key.org_path.split('/') {
408            if !seg.is_empty() {
409                p = p.join(sanitize_dir_name(seg));
410            }
411        }
412        p = p.join(sanitize_dir_name(&key.repo));
413        Ok(p)
414    }
415
416    pub fn get_default_reference_clone_path(url: &str, ref_name: Option<&str>) -> Result<PathBuf> {
417        let mut path = Self::get_default_clone_path(url)?;
418        if let Some(ref_name) = ref_name {
419            let ref_key = encode_ref_key(ref_name)?;
420            let repo_dir = path
421                .file_name()
422                .ok_or_else(|| anyhow::anyhow!("Default clone path had no repository segment"))?
423                .to_string_lossy()
424                .to_string();
425            path.set_file_name(format!("{}@{}", sanitize_dir_name(&repo_dir), ref_key));
426        }
427        Ok(path)
428    }
429
430    /// Update last sync time for a URL.
431    ///
432    /// Uses canonical fallback to update the correct entry even if the URL
433    /// scheme differs from what's stored.
434    pub fn update_sync_time(&mut self, url: &str) -> Result<()> {
435        let _lock = FileLock::lock_exclusive(self.lock_path())?;
436        let mut mapping = self.load()?;
437        let now = chrono::Utc::now();
438
439        // Prefer exact base_url key
440        let (base_url, _) = parse_url_and_subpath(url);
441        if let Some(loc) = mapping.mappings.get_mut(&base_url) {
442            loc.last_sync = Some(now);
443            self.save(&mapping)?;
444            return Ok(());
445        }
446
447        // Fall back to canonical match
448        // Note: We need to find the matching key without holding a mutable borrow
449        let target_key = RepoIdentity::parse(&base_url)?.canonical_key();
450
451        // TODO(2): If repos.json contains multiple entries with the same canonical identity (legacy
452        // duplicates), the selection below is nondeterministic due to HashMap iteration order.
453        // Consider sorting (as in `resolve_url_with_details`) or updating all matches.
454        let matched_key: Option<String> = mapping
455            .mappings
456            .keys()
457            .filter_map(|k| {
458                let (k_base, _) = parse_url_and_subpath(k);
459                let key = RepoIdentity::parse(&k_base).ok()?.canonical_key();
460                (key == target_key).then(|| k.clone())
461            })
462            .next();
463
464        if let Some(key) = matched_key
465            && let Some(loc) = mapping.mappings.get_mut(&key)
466        {
467            loc.last_sync = Some(now);
468            self.save(&mapping)?;
469        }
470
471        Ok(())
472    }
473
474    pub fn update_reference_sync_time(&mut self, url: &str, ref_name: Option<&str>) -> Result<()> {
475        let _lock = FileLock::lock_exclusive(self.lock_path())?;
476        let mut mapping = self.load()?;
477        let now = chrono::Utc::now();
478
479        let keys = Self::matching_reference_storage_keys(&mapping, url, ref_name)?;
480        if let Some(key) = keys.first()
481            && let Some(loc) = mapping.mappings.get_mut(key)
482        {
483            loc.last_sync = Some(now);
484            self.save(&mapping)?;
485        }
486
487        Ok(())
488    }
489
490    /// Get the canonical identity key for a URL, if parseable.
491    pub fn get_canonical_key(url: &str) -> Option<RepoIdentityKey> {
492        let (base, _) = parse_url_and_subpath(url);
493        RepoIdentity::parse(&base).ok().map(|id| id.canonical_key())
494    }
495
496    fn matching_reference_storage_keys(
497        mapping: &RepoMapping,
498        url: &str,
499        ref_name: Option<&str>,
500    ) -> Result<Vec<String>> {
501        let wanted = canonical_reference_instance_key(url, ref_name)?;
502        let mut keys: Vec<String> = mapping
503            .mappings
504            .keys()
505            .filter_map(|stored_key| {
506                let (stored_url, stored_ref_key) = parse_reference_mapping_storage_key(stored_key);
507                let (host, org_path, repo) = canonical_reference_key(&stored_url).ok()?;
508                let normalized_stored_ref_key = stored_ref_key
509                    .as_deref()
510                    .map(|ref_key| normalize_encoded_ref_key_for_identity(ref_key).into_owned());
511                let actual = (host, org_path, repo, normalized_stored_ref_key);
512                (actual == wanted).then(|| stored_key.clone())
513            })
514            .collect();
515        keys.sort();
516        Ok(keys)
517    }
518}
519
520fn reference_mapping_storage_key(url: &str, ref_name: Option<&str>) -> Result<String> {
521    let (base_url, _) = parse_url_and_subpath(url);
522    match ref_name {
523        Some(ref_name) => Ok(format!(
524            "{}{REFERENCE_MAPPING_MARKER}{}",
525            base_url,
526            encode_ref_key(ref_name)?
527        )),
528        None => Ok(base_url),
529    }
530}
531
532pub fn parse_reference_mapping_storage_key(stored_key: &str) -> (String, Option<String>) {
533    match stored_key.split_once(REFERENCE_MAPPING_MARKER) {
534        Some((base_url, ref_key)) => (base_url.to_string(), Some(ref_key.to_string())),
535        None => (stored_key.to_string(), None),
536    }
537}
538
539/// Parse a URL into (base_url, optional_subpath).
540///
541/// Delegates to the repo_identity module for robust port-aware parsing.
542pub fn parse_url_and_subpath(url: &str) -> (String, Option<String>) {
543    identity_parse_url_and_subpath(url)
544}
545
546/// Validate a subpath to prevent directory traversal attacks.
547///
548/// Rejects absolute paths and paths containing ".." components that could
549/// escape the repository root directory.
550fn validate_subpath(subpath: &str) -> Result<()> {
551    let path = Path::new(subpath);
552    if path.is_absolute() {
553        bail!(
554            "Invalid subpath (must be relative and not contain '..'): {}",
555            subpath
556        );
557    }
558    for component in path.components() {
559        match component {
560            Component::ParentDir => {
561                bail!(
562                    "Invalid subpath (must be relative and not contain '..'): {}",
563                    subpath
564                );
565            }
566            Component::Prefix(_) => {
567                bail!(
568                    "Invalid subpath (must be relative and not contain '..'): {}",
569                    subpath
570                );
571            }
572            _ => {}
573        }
574    }
575    Ok(())
576}
577
578pub fn extract_repo_name_from_url(url: &str) -> Result<String> {
579    let url = url.trim_end_matches(".git");
580
581    // Handle different URL formats
582    if let Some(pos) = url.rfind('/') {
583        Ok(url[pos + 1..].to_string())
584    } else if let Some(pos) = url.rfind(':') {
585        // SSH format like git@github.com:user/repo
586        if let Some(slash_pos) = url[pos + 1..].rfind('/') {
587            Ok(url[pos + 1 + slash_pos + 1..].to_string())
588        } else {
589            Ok(url[pos + 1..].to_string())
590        }
591    } else {
592        bail!("Cannot extract repository name from URL: {}", url)
593    }
594}
595
596/// Extract org_path and repo from a URL.
597///
598/// Delegates to RepoIdentity for robust parsing that handles:
599/// - SSH with ports: `ssh://git@host:2222/org/repo.git`
600/// - GitLab subgroups: `https://gitlab.com/a/b/c/repo.git`
601/// - Azure DevOps: `https://dev.azure.com/org/proj/_git/repo`
602pub fn extract_org_repo_from_url(url: &str) -> anyhow::Result<(String, String)> {
603    let (base, _) = parse_url_and_subpath(url);
604    let id = RepoIdentity::parse(&base)?;
605    Ok((id.org_path, id.repo))
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use tempfile::TempDir;
612
613    #[test]
614    fn test_parse_url_and_subpath() {
615        let (url, sub) = parse_url_and_subpath("git@github.com:user/repo.git");
616        assert_eq!(url, "git@github.com:user/repo.git");
617        assert_eq!(sub, None);
618
619        let (url, sub) = parse_url_and_subpath("git@github.com:user/repo.git:docs/api");
620        assert_eq!(url, "git@github.com:user/repo.git");
621        assert_eq!(sub, Some("docs/api".to_string()));
622
623        let (url, sub) = parse_url_and_subpath("https://github.com/user/repo");
624        assert_eq!(url, "https://github.com/user/repo");
625        assert_eq!(sub, None);
626    }
627
628    #[test]
629    fn test_extract_repo_name() {
630        assert_eq!(
631            extract_repo_name_from_url("git@github.com:user/repo.git").unwrap(),
632            "repo"
633        );
634        assert_eq!(
635            extract_repo_name_from_url("https://github.com/user/repo").unwrap(),
636            "repo"
637        );
638        assert_eq!(
639            extract_repo_name_from_url("git@github.com:user/repo").unwrap(),
640            "repo"
641        );
642    }
643
644    #[test]
645    fn test_extract_org_repo() {
646        assert_eq!(
647            extract_org_repo_from_url("git@github.com:user/repo.git").unwrap(),
648            ("user".to_string(), "repo".to_string())
649        );
650        assert_eq!(
651            extract_org_repo_from_url("https://github.com/user/repo").unwrap(),
652            ("user".to_string(), "repo".to_string())
653        );
654        assert_eq!(
655            extract_org_repo_from_url("git@github.com:user/repo").unwrap(),
656            ("user".to_string(), "repo".to_string())
657        );
658        assert_eq!(
659            extract_org_repo_from_url("https://github.com/modelcontextprotocol/rust-sdk.git")
660                .unwrap(),
661            ("modelcontextprotocol".to_string(), "rust-sdk".to_string())
662        );
663    }
664
665    #[test]
666    fn test_default_clone_path_hierarchical() {
667        // Test hierarchical path: ~/.thoughts/clones/{host}/{org}/{repo}
668        let p =
669            RepoMappingManager::get_default_clone_path("git@github.com:org/repo.git:docs").unwrap();
670        assert!(p.ends_with(std::path::Path::new(".thoughts/clones/github.com/org/repo")));
671    }
672
673    #[test]
674    fn test_default_clone_path_gitlab_subgroups() {
675        let p = RepoMappingManager::get_default_clone_path(
676            "https://gitlab.com/group/subgroup/team/repo.git",
677        )
678        .unwrap();
679        assert!(p.ends_with(std::path::Path::new(
680            ".thoughts/clones/gitlab.com/group/subgroup/team/repo"
681        )));
682    }
683
684    #[test]
685    fn test_default_clone_path_ssh_port() {
686        let p = RepoMappingManager::get_default_clone_path(
687            "ssh://git@myhost.example.com:2222/org/repo.git",
688        )
689        .unwrap();
690        assert!(p.ends_with(std::path::Path::new(
691            ".thoughts/clones/myhost.example.com/org/repo"
692        )));
693    }
694
695    #[test]
696    fn test_default_reference_clone_path_appends_ref_key() {
697        let p = RepoMappingManager::get_default_reference_clone_path(
698            "https://github.com/org/repo.git",
699            Some("refs/tags/v1.2.3"),
700        )
701        .unwrap();
702        assert!(p.ends_with(std::path::Path::new(
703            ".thoughts/clones/github.com/org/repo@r-refs~2ftags~2fv1.2.3"
704        )));
705    }
706
707    #[test]
708    fn test_canonical_key_consistency() {
709        let ssh_key = RepoMappingManager::get_canonical_key("git@github.com:Org/Repo.git").unwrap();
710        let https_key =
711            RepoMappingManager::get_canonical_key("https://github.com/org/repo").unwrap();
712        assert_eq!(
713            ssh_key, https_key,
714            "SSH and HTTPS should have same canonical key"
715        );
716    }
717
718    #[test]
719    fn test_add_reference_mapping_keeps_different_refs_separate() {
720        let temp_dir = TempDir::new().unwrap();
721        let mapping_path = temp_dir.path().join("repos.json");
722        let mut manager = RepoMappingManager { mapping_path };
723
724        let main_path = temp_dir.path().join("repo-main");
725        let tag_path = temp_dir.path().join("repo-tag");
726        std::fs::create_dir_all(&main_path).unwrap();
727        std::fs::create_dir_all(&tag_path).unwrap();
728
729        manager
730            .add_reference_mapping(
731                "https://github.com/org/repo.git".to_string(),
732                Some("refs/heads/main"),
733                main_path.clone(),
734                true,
735            )
736            .unwrap();
737        manager
738            .add_reference_mapping(
739                "git@github.com:Org/Repo.git".to_string(),
740                Some("refs/tags/v1.0.0"),
741                tag_path.clone(),
742                true,
743            )
744            .unwrap();
745
746        assert_eq!(
747            manager
748                .resolve_reference_url("https://github.com/org/repo", Some("refs/heads/main"))
749                .unwrap(),
750            Some(main_path)
751        );
752        assert_eq!(
753            manager
754                .resolve_reference_url("https://github.com/org/repo", Some("refs/tags/v1.0.0"))
755                .unwrap(),
756            Some(tag_path)
757        );
758
759        let mapping = manager.load().unwrap();
760        assert_eq!(mapping.mappings.len(), 2);
761    }
762
763    #[test]
764    fn test_resolve_reference_url_matches_legacy_refs_remotes_and_heads_equivalently() {
765        let temp_dir = TempDir::new().unwrap();
766        let mapping_path = temp_dir.path().join("repos.json");
767        let mut manager = RepoMappingManager {
768            mapping_path: mapping_path.clone(),
769        };
770
771        let repo_path = temp_dir.path().join("repo-legacy");
772        std::fs::create_dir_all(&repo_path).unwrap();
773
774        manager
775            .add_reference_mapping(
776                "https://github.com/org/repo.git".to_string(),
777                Some("refs/remotes/origin/main"),
778                repo_path.clone(),
779                true,
780            )
781            .unwrap();
782
783        let mgr_ro = RepoMappingManager { mapping_path };
784        assert_eq!(
785            mgr_ro
786                .resolve_reference_url("https://github.com/org/repo", Some("refs/heads/main"))
787                .unwrap(),
788            Some(repo_path)
789        );
790    }
791
792    #[test]
793    fn test_update_reference_sync_time_updates_ref_specific_entry() {
794        let temp_dir = TempDir::new().unwrap();
795        let mapping_path = temp_dir.path().join("repos.json");
796        let mut manager = RepoMappingManager { mapping_path };
797
798        let repo_path = temp_dir.path().join("repo-main");
799        std::fs::create_dir_all(&repo_path).unwrap();
800
801        manager
802            .add_reference_mapping(
803                "https://github.com/org/repo.git".to_string(),
804                Some("refs/heads/main"),
805                repo_path,
806                true,
807            )
808            .unwrap();
809
810        manager
811            .update_reference_sync_time("git@github.com:Org/Repo.git", Some("refs/heads/main"))
812            .unwrap();
813
814        let mapping = manager.load().unwrap();
815        let found = mapping
816            .mappings
817            .iter()
818            .find(|(key, _)| key.contains("#thoughts-ref="))
819            .unwrap();
820        assert!(found.1.last_sync.is_some());
821    }
822
823    #[test]
824    fn test_is_reference_auto_managed_matches_ref_specific_entry() {
825        let temp_dir = TempDir::new().unwrap();
826        let mapping_path = temp_dir.path().join("repos.json");
827        let mut manager = RepoMappingManager {
828            mapping_path: mapping_path.clone(),
829        };
830
831        let repo_path = temp_dir.path().join("repo-main");
832        std::fs::create_dir_all(&repo_path).unwrap();
833
834        manager
835            .add_reference_mapping(
836                "https://github.com/org/repo.git".to_string(),
837                Some("refs/heads/main"),
838                repo_path,
839                true,
840            )
841            .unwrap();
842
843        let mgr_ro = RepoMappingManager { mapping_path };
844        assert!(
845            mgr_ro
846                .is_reference_auto_managed("git@github.com:Org/Repo.git", Some("refs/heads/main"))
847                .unwrap()
848        );
849    }
850
851    // TODO(2): Add integration test for resolve_url_with_details canonical fallback path.
852    // This test verifies keys match, but doesn't test that resolve_url_with_details
853    // actually uses canonical matching to find mappings stored under different URL schemes.
854    // Test should: 1) add mapping with SSH URL, 2) resolve with HTTPS URL,
855    // 3) verify CanonicalFallback resolution kind is returned.
856
857    #[test]
858    fn test_validate_subpath_accepts_valid_paths() {
859        // Simple relative paths should be accepted
860        assert!(validate_subpath("docs").is_ok());
861        assert!(validate_subpath("docs/api").is_ok());
862        assert!(validate_subpath("src/lib/utils").is_ok());
863        assert!(validate_subpath("a/b/c/d/e").is_ok());
864    }
865
866    #[test]
867    fn test_validate_subpath_rejects_parent_dir_traversal() {
868        // Parent directory traversal should be rejected
869        assert!(validate_subpath("..").is_err());
870        assert!(validate_subpath("../etc").is_err());
871        assert!(validate_subpath("docs/../..").is_err());
872        assert!(validate_subpath("docs/../../etc").is_err());
873        assert!(validate_subpath("a/b/c/../../../etc").is_err());
874    }
875
876    #[test]
877    fn test_validate_subpath_rejects_absolute_paths() {
878        // Absolute paths should be rejected
879        assert!(validate_subpath("/etc").is_err());
880        assert!(validate_subpath("/etc/passwd").is_err());
881        assert!(validate_subpath("/home/user/.ssh").is_err());
882    }
883
884    // -------------------------------------------------------------------------
885    // Migration and load() TOCTOU-fix tests
886    // -------------------------------------------------------------------------
887
888    #[test]
889    fn test_migrate_legacy_when_destination_missing() {
890        let dir = TempDir::new().unwrap();
891        let mapping_path = dir.path().join("repos.json");
892        let legacy_path = dir.path().join("legacy_repos.json");
893
894        let legacy_bytes = br#"{ "mappings": { "git@github.com:a/b.git": { "path": "/tmp/x", "auto_managed": false, "last_sync": null } } }"#;
895        std::fs::write(&legacy_path, legacy_bytes).unwrap();
896
897        let migrated = migrate_legacy_repos_json_if_needed(&mapping_path, &legacy_path).unwrap();
898        assert!(migrated);
899        assert!(mapping_path.exists());
900
901        let got = std::fs::read(&mapping_path).unwrap();
902        assert_eq!(got, legacy_bytes);
903    }
904
905    #[test]
906    fn test_migrate_does_not_overwrite_existing_destination() {
907        let dir = TempDir::new().unwrap();
908        let mapping_path = dir.path().join("repos.json");
909        let legacy_path = dir.path().join("legacy_repos.json");
910
911        std::fs::write(&legacy_path, b"legacy").unwrap();
912        std::fs::write(&mapping_path, b"already-there").unwrap();
913
914        let migrated = migrate_legacy_repos_json_if_needed(&mapping_path, &legacy_path).unwrap();
915        assert!(!migrated);
916
917        let got = std::fs::read(&mapping_path).unwrap();
918        assert_eq!(got, b"already-there");
919    }
920
921    #[test]
922    fn test_migrate_noop_when_legacy_missing() {
923        let dir = TempDir::new().unwrap();
924        let mapping_path = dir.path().join("repos.json");
925        let legacy_path = dir.path().join("legacy_repos.json"); // intentionally absent
926
927        let migrated = migrate_legacy_repos_json_if_needed(&mapping_path, &legacy_path).unwrap();
928        assert!(!migrated);
929        assert!(!mapping_path.exists());
930    }
931
932    #[test]
933    fn test_load_missing_file_returns_default_without_creating_file() {
934        let dir = TempDir::new().unwrap();
935        let mapping_path = dir.path().join("repos.json");
936        let mgr = RepoMappingManager {
937            mapping_path: mapping_path.clone(),
938        };
939
940        let mapping = mgr.load().unwrap();
941        assert_eq!(mapping, RepoMapping::default());
942        assert!(!mapping_path.exists(), "load() must not create repos.json");
943    }
944
945    #[test]
946    fn test_load_reads_existing_file() {
947        let dir = TempDir::new().unwrap();
948        let mapping_path = dir.path().join("repos.json");
949        let mgr = RepoMappingManager {
950            mapping_path: mapping_path.clone(),
951        };
952
953        let mut m = RepoMapping::default();
954        m.mappings.insert(
955            "git@github.com:org/repo.git".to_string(),
956            RepoLocation {
957                path: PathBuf::from("/tmp/repo"),
958                auto_managed: false,
959                last_sync: None,
960            },
961        );
962        mgr.save(&m).unwrap();
963
964        let loaded = mgr.load().unwrap();
965        assert!(loaded.mappings.contains_key("git@github.com:org/repo.git"));
966    }
967}