Skip to main content

thoughts_tool/config/
repo_mapping_manager.rs

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