Skip to main content

thoughts_tool/config/
repo_manager.rs

1use crate::config::ContextMount;
2use crate::config::Mount;
3use crate::config::MountDirsV2;
4use crate::config::ReferenceEntry;
5use crate::config::ReferenceMount;
6use crate::config::RepoConfigV2;
7use crate::config::SyncStrategy;
8use crate::config::ThoughtsMount;
9use crate::mount::MountSpace;
10use crate::utils::paths;
11use anyhow::Context;
12use anyhow::Result;
13use atomicwrites::AtomicFile;
14use atomicwrites::OverwriteBehavior;
15use std::fs;
16use std::io::Write;
17use std::path::Path;
18use std::path::PathBuf;
19
20#[derive(Debug, Clone)]
21pub struct DesiredState {
22    pub mount_dirs: MountDirsV2,
23    pub thoughts_mount: Option<ThoughtsMount>,
24    pub context_mounts: Vec<ContextMount>,
25    pub references: Vec<ReferenceMount>,
26    pub was_v1: bool, // for messaging
27}
28
29impl DesiredState {
30    /// Find a mount by its `MountSpace` identifier
31    pub fn find_mount(&self, space: &MountSpace) -> Option<Mount> {
32        match space {
33            MountSpace::Thoughts => self.thoughts_mount.as_ref().map(|tm| Mount::Git {
34                url: tm.remote.clone(),
35                sync: tm.sync,
36                subpath: tm.subpath.clone(),
37            }),
38            MountSpace::Context(mount_path) => self
39                .context_mounts
40                .iter()
41                .find(|cm| &cm.mount_path == mount_path)
42                .map(|cm| Mount::Git {
43                    url: cm.remote.clone(),
44                    sync: cm.sync,
45                    subpath: cm.subpath.clone(),
46                }),
47            MountSpace::Reference {
48                org_path: _,
49                repo: _,
50                ref_key: _,
51            } => {
52                // References need URL lookup - for now return None
53                // This will be addressed when references commands are implemented
54                None
55            }
56        }
57    }
58
59    /// Get target path for a mount space
60    pub fn get_mount_target(&self, space: &MountSpace, repo_root: &Path) -> PathBuf {
61        repo_root
62            .join(".thoughts-data")
63            .join(space.relative_path(&self.mount_dirs))
64    }
65}
66
67pub struct RepoConfigManager {
68    repo_root: PathBuf,
69}
70
71impl RepoConfigManager {
72    #[expect(
73        clippy::expect_used,
74        reason = "current_dir failure indicates a fatal system state; panicking is appropriate"
75    )]
76    pub fn new(repo_root: PathBuf) -> Self {
77        // Ensure absolute path at construction (defense-in-depth)
78        let abs = if repo_root.is_absolute() {
79            repo_root
80        } else {
81            std::fs::canonicalize(&repo_root).unwrap_or_else(|_| {
82                std::env::current_dir()
83                    .expect("Failed to determine current directory for path normalization")
84                    .join(&repo_root)
85            })
86        };
87        Self { repo_root: abs }
88    }
89
90    pub fn load_desired_state(&self) -> Result<Option<DesiredState>> {
91        let config_path = paths::get_repo_config_path(&self.repo_root);
92        if !config_path.exists() {
93            return Ok(None);
94        }
95
96        let raw = std::fs::read_to_string(&config_path)?;
97        // Peek version
98        let v: serde_json::Value = serde_json::from_str(&raw)?;
99        let version = v.get("version").and_then(|x| x.as_str()).unwrap_or("1.0");
100
101        if version == "2.0" {
102            let v2: RepoConfigV2 = serde_json::from_str(&raw)?;
103            // Normalize ReferenceEntry to ReferenceMount
104            let refs = v2
105                .references
106                .into_iter()
107                .map(|e| match e {
108                    ReferenceEntry::Simple(url) => ReferenceMount {
109                        remote: url,
110                        description: None,
111                        ref_name: None,
112                    },
113                    ReferenceEntry::WithMetadata(rm) => rm,
114                })
115                .collect();
116            return Ok(Some(DesiredState {
117                mount_dirs: v2.mount_dirs,
118                thoughts_mount: v2.thoughts_mount,
119                context_mounts: v2.context_mounts,
120                references: refs,
121                was_v1: false,
122            }));
123        }
124
125        // V1 configs are no longer supported
126        anyhow::bail!(
127            "Unsupported legacy config version (v1). V1 configurations are no longer supported. \
128             Please upgrade to a v2 configuration format."
129        );
130    }
131
132    fn validate_remote(remote: &str) -> Result<()> {
133        if remote.starts_with("./") {
134            // Local mount - relative path is OK
135            return Ok(());
136        }
137
138        if !remote.starts_with("git@")
139            && !remote.starts_with("https://")
140            && !remote.starts_with("ssh://")
141        {
142            anyhow::bail!(
143                "Invalid remote URL: {remote}. Must be a git URL or relative path starting with ./"
144            );
145        }
146
147        Ok(())
148    }
149
150    /// Load v2 config or error if it doesn't exist
151    pub fn load_v2_or_bail(&self) -> Result<RepoConfigV2> {
152        let config_path = paths::get_repo_config_path(&self.repo_root);
153        if !config_path.exists() {
154            anyhow::bail!("No repository configuration found. Run 'thoughts init' first.");
155        }
156
157        let raw = std::fs::read_to_string(&config_path)?;
158        let v: serde_json::Value = serde_json::from_str(&raw)?;
159        let version = v.get("version").and_then(|x| x.as_str()).unwrap_or("1.0");
160
161        if version == "2.0" {
162            let v2: RepoConfigV2 = serde_json::from_str(&raw)?;
163            Ok(v2)
164        } else {
165            anyhow::bail!(
166                "Repository is using v1 configuration. Please migrate to v2 configuration format."
167            );
168        }
169    }
170
171    /// Save v2 configuration
172    pub fn save_v2(&self, config: &RepoConfigV2) -> Result<()> {
173        let config_path = paths::get_repo_config_path(&self.repo_root);
174
175        // Ensure .thoughts directory exists
176        if let Some(parent) = config_path.parent() {
177            fs::create_dir_all(parent)
178                .with_context(|| format!("Failed to create directory {}", parent.display()))?;
179        }
180
181        let json =
182            serde_json::to_string_pretty(config).context("Failed to serialize configuration")?;
183
184        AtomicFile::new(&config_path, OverwriteBehavior::AllowOverwrite)
185            .write(|f| f.write_all(json.as_bytes()))
186            .with_context(|| format!("Failed to write config to {}", config_path.display()))?;
187
188        Ok(())
189    }
190
191    /// Ensure v2 config exists, create default if not.
192    /// Returns error if V1 config exists (V1 is no longer supported).
193    pub fn ensure_v2_default(&self) -> Result<RepoConfigV2> {
194        let config_path = paths::get_repo_config_path(&self.repo_root);
195        if config_path.exists() {
196            // Try to load existing config
197            let raw = std::fs::read_to_string(&config_path)?;
198            let v: serde_json::Value = serde_json::from_str(&raw)?;
199            let version = v.get("version").and_then(|x| x.as_str()).unwrap_or("1.0");
200
201            if version == "2.0" {
202                return serde_json::from_str(&raw).context("Failed to parse v2 configuration");
203            }
204
205            // V1 configs are no longer supported
206            anyhow::bail!(
207                "Unsupported legacy config version (v1). V1 configurations are no longer supported. \
208                 Please manually migrate to v2 format or delete the config and reinitialize."
209            );
210        }
211
212        // Create default v2 config
213        let default_config = RepoConfigV2 {
214            version: "2.0".to_string(),
215            mount_dirs: MountDirsV2::default(),
216            thoughts_mount: None,
217            context_mounts: vec![],
218            references: vec![],
219        };
220
221        self.save_v2(&default_config)?;
222        Ok(default_config)
223    }
224
225    /// Soft validation for v2 configuration returning warnings only
226    pub fn validate_v2_soft(&self, cfg: &RepoConfigV2) -> Vec<String> {
227        let mut warnings = Vec::new();
228        for r in &cfg.references {
229            let url = match r {
230                ReferenceEntry::Simple(s) => s.as_str(),
231                ReferenceEntry::WithMetadata(rm) => rm.remote.as_str(),
232            };
233            if let Err(e) = crate::config::validation::validate_reference_url(url) {
234                warnings.push(format!("Invalid reference '{url}': {e}"));
235            }
236        }
237        warnings
238    }
239
240    /// Peek the on-disk config version without fully parsing
241    pub fn peek_config_version(&self) -> Result<Option<String>> {
242        let config_path = paths::get_repo_config_path(&self.repo_root);
243        if !config_path.exists() {
244            return Ok(None);
245        }
246        let raw = std::fs::read_to_string(&config_path)?;
247        let v: serde_json::Value = serde_json::from_str(&raw)?;
248        Ok(v.get("version")
249            .and_then(|x| x.as_str())
250            .map(std::string::ToString::to_string))
251    }
252
253    /// Hard validator for v2 config. Returns warnings (non-fatal).
254    pub fn validate_v2_hard(&self, cfg: &RepoConfigV2) -> Result<Vec<String>> {
255        use crate::config::validation::canonical_reference_instance_key;
256        use crate::config::validation::validate_pinned_ref_full_name;
257        use crate::config::validation::validate_reference_url;
258
259        if cfg.version != "2.0" {
260            anyhow::bail!("Unsupported configuration version: {}", cfg.version);
261        }
262
263        // mount_dirs: non-empty and distinct
264        let m = &cfg.mount_dirs;
265        for (name, val) in [
266            ("thoughts", &m.thoughts),
267            ("context", &m.context),
268            ("references", &m.references),
269        ] {
270            if val.trim().is_empty() {
271                anyhow::bail!("Mount directory '{name}' cannot be empty");
272            }
273            if val == ".thoughts-data" {
274                anyhow::bail!("Mount directory '{name}' cannot be named '.thoughts-data'");
275            }
276            if val == "." || val == ".." {
277                anyhow::bail!("Mount directory '{name}' cannot be '.' or '..'");
278            }
279            if val.contains('/') || val.contains('\\') {
280                anyhow::bail!("Mount directory '{name}' must be a single path segment (got {val})");
281            }
282        }
283        if m.thoughts == m.context || m.thoughts == m.references || m.context == m.references {
284            anyhow::bail!("Mount directories must be distinct (thoughts/context/references)");
285        }
286
287        // thoughts_mount remote validation
288        if let Some(tm) = &cfg.thoughts_mount {
289            Self::validate_remote(&tm.remote)?;
290        }
291
292        // context_mounts: unique mount_path, valid remotes; warn on sync:None
293        let mut warnings = Vec::new();
294        let mut seen_mount_paths = std::collections::HashSet::new();
295        for cm in &cfg.context_mounts {
296            // uniqueness
297            if !seen_mount_paths.insert(&cm.mount_path) {
298                anyhow::bail!("Duplicate context mount path: {}", cm.mount_path);
299            }
300
301            // mount_path validation
302            let mp = cm.mount_path.trim();
303            if mp.is_empty() {
304                anyhow::bail!("Context mount path cannot be empty");
305            }
306            if mp == "." || mp == ".." {
307                anyhow::bail!("Context mount path cannot be '.' or '..'");
308            }
309            if mp.contains('/') || mp.contains('\\') {
310                anyhow::bail!(
311                    "Context mount path must be a single path segment (got {})",
312                    cm.mount_path
313                );
314            }
315            let m = &cfg.mount_dirs;
316            if mp == m.thoughts || mp == m.context || mp == m.references {
317                anyhow::bail!(
318                    "Context mount path '{}' cannot conflict with configured mount_dirs names ('{}', '{}', '{}')",
319                    cm.mount_path,
320                    m.thoughts,
321                    m.context,
322                    m.references
323                );
324            }
325
326            // remote validity
327            Self::validate_remote(&cm.remote)?;
328            if matches!(cm.sync, SyncStrategy::None) {
329                warnings.push(format!(
330                    "Context mount '{}' has sync:None; allowed but discouraged. Consider SyncStrategy::Auto.",
331                    cm.mount_path
332                ));
333            }
334        }
335
336        // references: validate and ensure uniqueness by canonical key
337        let mut seen_refs = std::collections::HashSet::new();
338        for r in &cfg.references {
339            let (url, ref_name) = match r {
340                ReferenceEntry::Simple(s) => (s.as_str(), None),
341                ReferenceEntry::WithMetadata(rm) => (rm.remote.as_str(), rm.ref_name.as_deref()),
342            };
343            validate_reference_url(url).with_context(|| format!("Invalid reference '{url}'"))?;
344            if let Some(ref_name) = ref_name {
345                if ref_name.starts_with("refs/remotes/") {
346                    warnings.push(format!(
347                        "Reference '{url}' uses legacy pinned ref '{ref_name}'. New references should use refs/heads/* or refs/tags/*; refs/remotes/* is a local remote-tracking namespace."
348                    ));
349                }
350                validate_pinned_ref_full_name(ref_name).with_context(|| {
351                    format!("Invalid pinned ref '{ref_name}' for reference '{url}'")
352                })?;
353            }
354            let key = canonical_reference_instance_key(url, ref_name)?;
355            if !seen_refs.insert(key) {
356                anyhow::bail!("Duplicate reference detected: {url}");
357            }
358        }
359
360        Ok(warnings)
361    }
362
363    /// Save v2 configuration with hard validation. Returns warnings (non-fatal).
364    pub fn save_v2_validated(&self, config: &RepoConfigV2) -> Result<Vec<String>> {
365        let warnings = self.validate_v2_hard(config)?;
366        self.save_v2(config)?;
367        Ok(warnings)
368    }
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use crate::utils::paths;
375    use tempfile::TempDir;
376
377    #[test]
378    fn test_load_desired_state_rejects_v1() {
379        let temp_dir = TempDir::new().unwrap();
380        let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
381
382        // Write a v1 config directly as JSON
383        let v1_json = r#"{
384            "version": "1.0",
385            "mount_dirs": {"repository": "context", "personal": "personal"},
386            "requires": [],
387            "rules": []
388        }"#;
389
390        let config_path = paths::get_repo_config_path(temp_dir.path());
391        std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
392        std::fs::write(&config_path, v1_json).unwrap();
393
394        // Attempting to load V1 config should error
395        let result = manager.load_desired_state();
396        assert!(result.is_err());
397        assert!(result.unwrap_err().to_string().contains("v1"));
398    }
399
400    #[test]
401    fn test_v2_config_loading() {
402        let temp_dir = TempDir::new().unwrap();
403        let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
404
405        // Create a v2 config
406        let v2_config = crate::config::RepoConfigV2 {
407            version: "2.0".to_string(),
408            mount_dirs: crate::config::MountDirsV2::default(),
409            thoughts_mount: Some(crate::config::ThoughtsMount {
410                remote: "git@github.com:user/thoughts.git".to_string(),
411                subpath: None,
412                sync: crate::config::SyncStrategy::Auto,
413            }),
414            context_mounts: vec![crate::config::ContextMount {
415                remote: "git@github.com:user/context.git".to_string(),
416                subpath: Some("docs".to_string()),
417                mount_path: "docs".to_string(),
418                sync: crate::config::SyncStrategy::Auto,
419            }],
420            references: vec![
421                ReferenceEntry::Simple("git@github.com:org/ref1.git".to_string()),
422                ReferenceEntry::Simple("https://github.com/org/ref2.git".to_string()),
423            ],
424        };
425
426        // Save the v2 config directly using JSON
427        let config_path = paths::get_repo_config_path(temp_dir.path());
428        std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
429        let json = serde_json::to_string_pretty(&v2_config).unwrap();
430        std::fs::write(&config_path, json).unwrap();
431
432        // Load as DesiredState
433        let desired_state = manager.load_desired_state().unwrap().unwrap();
434
435        // Verify the loading
436        assert!(!desired_state.was_v1);
437        assert!(desired_state.thoughts_mount.is_some());
438        assert_eq!(
439            desired_state.thoughts_mount.as_ref().unwrap().remote,
440            "git@github.com:user/thoughts.git"
441        );
442        assert_eq!(desired_state.context_mounts.len(), 1);
443        assert_eq!(desired_state.references.len(), 2);
444    }
445
446    #[test]
447    fn test_v2_references_normalize_to_reference_mount() {
448        let temp_dir = TempDir::new().unwrap();
449        let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
450
451        let json = r#"{
452            "version": "2.0",
453            "mount_dirs": {},
454            "context_mounts": [],
455            "references": [
456                "git@github.com:org/ref1.git",
457                {"remote": "https://github.com/org/ref2.git", "description": "Ref 2"}
458            ]
459        }"#;
460
461        let config_path = paths::get_repo_config_path(temp_dir.path());
462        std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
463        std::fs::write(&config_path, json).unwrap();
464
465        let ds = manager.load_desired_state().unwrap().unwrap();
466        assert_eq!(ds.references.len(), 2);
467        assert_eq!(ds.references[0].remote, "git@github.com:org/ref1.git");
468        assert_eq!(ds.references[0].description, None);
469        assert_eq!(ds.references[1].remote, "https://github.com/org/ref2.git");
470        assert_eq!(ds.references[1].description.as_deref(), Some("Ref 2"));
471    }
472
473    #[test]
474    fn test_validate_v2_soft_handles_both_variants() {
475        let temp_dir = tempfile::TempDir::new().unwrap();
476        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
477
478        let cfg = RepoConfigV2 {
479            version: "2.0".into(),
480            mount_dirs: MountDirsV2::default(),
481            thoughts_mount: None,
482            context_mounts: vec![],
483            references: vec![
484                ReferenceEntry::Simple("https://github.com/org/repo".into()),
485                ReferenceEntry::WithMetadata(ReferenceMount {
486                    remote: "git@github.com:org/repo.git:docs".into(), // invalid: subpath
487                    description: None,
488                    ref_name: None,
489                }),
490            ],
491        };
492
493        let warnings = mgr.validate_v2_soft(&cfg);
494        assert_eq!(warnings.len(), 1, "Expected one invalid reference warning");
495        assert!(warnings[0].contains("git@github.com:org/repo.git:docs"));
496    }
497
498    #[test]
499    fn test_peek_config_version_returns_none_when_no_config() {
500        let temp_dir = tempfile::TempDir::new().unwrap();
501        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
502        assert_eq!(mgr.peek_config_version().unwrap(), None);
503    }
504
505    #[test]
506    fn test_peek_config_version_returns_v1() {
507        let temp_dir = tempfile::TempDir::new().unwrap();
508        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
509
510        // Write V1 config as raw JSON
511        let v1_json = r#"{"version": "1.0", "mount_dirs": {}, "requires": [], "rules": []}"#;
512        let config_path = paths::get_repo_config_path(temp_dir.path());
513        std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
514        std::fs::write(&config_path, v1_json).unwrap();
515
516        assert_eq!(mgr.peek_config_version().unwrap(), Some("1.0".to_string()));
517    }
518
519    #[test]
520    fn test_peek_config_version_returns_v2() {
521        let temp_dir = tempfile::TempDir::new().unwrap();
522        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
523        let v2_config = RepoConfigV2 {
524            version: "2.0".to_string(),
525            mount_dirs: MountDirsV2::default(),
526            thoughts_mount: None,
527            context_mounts: vec![],
528            references: vec![],
529        };
530        mgr.save_v2(&v2_config).unwrap();
531        assert_eq!(mgr.peek_config_version().unwrap(), Some("2.0".to_string()));
532    }
533
534    #[test]
535    fn test_validate_v2_hard_rejects_invalid_version() {
536        let temp_dir = tempfile::TempDir::new().unwrap();
537        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
538        let cfg = RepoConfigV2 {
539            version: "3.0".to_string(),
540            mount_dirs: MountDirsV2::default(),
541            thoughts_mount: None,
542            context_mounts: vec![],
543            references: vec![],
544        };
545        let result = mgr.validate_v2_hard(&cfg);
546        assert!(result.is_err());
547        assert!(
548            result
549                .unwrap_err()
550                .to_string()
551                .contains("Unsupported configuration version: 3.0")
552        );
553    }
554
555    #[test]
556    fn test_validate_v2_hard_rejects_pinned_ref_with_whitespace_or_trailing_slash() {
557        let temp_dir = tempfile::TempDir::new().unwrap();
558        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
559
560        for bad_ref in [" refs/heads/main", "refs/heads/main ", "refs/heads/main/"] {
561            let cfg = RepoConfigV2 {
562                version: "2.0".to_string(),
563                mount_dirs: MountDirsV2::default(),
564                thoughts_mount: None,
565                context_mounts: vec![],
566                references: vec![ReferenceEntry::WithMetadata(ReferenceMount {
567                    remote: "https://github.com/org/repo".to_string(),
568                    description: None,
569                    ref_name: Some(bad_ref.to_string()),
570                })],
571            };
572
573            assert!(
574                mgr.validate_v2_hard(&cfg).is_err(),
575                "expected {bad_ref:?} to fail hard validation"
576            );
577        }
578    }
579
580    #[test]
581    fn test_validate_v2_hard_rejects_empty_mount_dirs() {
582        let temp_dir = tempfile::TempDir::new().unwrap();
583        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
584        let cfg = RepoConfigV2 {
585            version: "2.0".to_string(),
586            mount_dirs: MountDirsV2 {
587                thoughts: String::new(),
588                context: "context".to_string(),
589                references: "references".to_string(),
590            },
591            thoughts_mount: None,
592            context_mounts: vec![],
593            references: vec![],
594        };
595        let result = mgr.validate_v2_hard(&cfg);
596        assert!(result.is_err());
597        assert!(result.unwrap_err().to_string().contains("cannot be empty"));
598    }
599
600    #[test]
601    fn test_validate_v2_hard_rejects_reserved_mount_dir_name() {
602        let temp_dir = tempfile::TempDir::new().unwrap();
603        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
604        let cfg = RepoConfigV2 {
605            version: "2.0".to_string(),
606            mount_dirs: MountDirsV2 {
607                thoughts: ".thoughts-data".to_string(),
608                context: "context".to_string(),
609                references: "references".to_string(),
610            },
611            thoughts_mount: None,
612            context_mounts: vec![],
613            references: vec![],
614        };
615        let result = mgr.validate_v2_hard(&cfg);
616        assert!(result.is_err());
617        assert!(result.unwrap_err().to_string().contains(".thoughts-data"));
618    }
619
620    #[test]
621    fn test_validate_v2_hard_rejects_dot_mount_dirs() {
622        let temp_dir = tempfile::TempDir::new().unwrap();
623        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
624        let cfg = RepoConfigV2 {
625            version: "2.0".to_string(),
626            mount_dirs: MountDirsV2 {
627                thoughts: ".".to_string(),
628                context: "context".to_string(),
629                references: "references".to_string(),
630            },
631            thoughts_mount: None,
632            context_mounts: vec![],
633            references: vec![],
634        };
635        let result = mgr.validate_v2_hard(&cfg);
636        assert!(result.is_err());
637        assert!(
638            result
639                .unwrap_err()
640                .to_string()
641                .contains("cannot be '.' or '..'")
642        );
643    }
644
645    #[test]
646    fn test_validate_v2_hard_rejects_multi_segment_mount_dirs() {
647        let temp_dir = tempfile::TempDir::new().unwrap();
648        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
649        let cfg = RepoConfigV2 {
650            version: "2.0".to_string(),
651            mount_dirs: MountDirsV2 {
652                thoughts: "sub/path".to_string(),
653                context: "context".to_string(),
654                references: "references".to_string(),
655            },
656            thoughts_mount: None,
657            context_mounts: vec![],
658            references: vec![],
659        };
660        let result = mgr.validate_v2_hard(&cfg);
661        assert!(result.is_err());
662        assert!(
663            result
664                .unwrap_err()
665                .to_string()
666                .contains("must be a single path segment")
667        );
668    }
669
670    #[test]
671    fn test_validate_v2_hard_rejects_duplicate_mount_dirs() {
672        let temp_dir = tempfile::TempDir::new().unwrap();
673        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
674        let cfg = RepoConfigV2 {
675            version: "2.0".to_string(),
676            mount_dirs: MountDirsV2 {
677                thoughts: "same".to_string(),
678                context: "same".to_string(),
679                references: "references".to_string(),
680            },
681            thoughts_mount: None,
682            context_mounts: vec![],
683            references: vec![],
684        };
685        let result = mgr.validate_v2_hard(&cfg);
686        assert!(result.is_err());
687        assert!(result.unwrap_err().to_string().contains("must be distinct"));
688    }
689
690    #[test]
691    fn test_validate_v2_hard_rejects_invalid_thoughts_mount_remote() {
692        let temp_dir = tempfile::TempDir::new().unwrap();
693        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
694        let cfg = RepoConfigV2 {
695            version: "2.0".to_string(),
696            mount_dirs: MountDirsV2::default(),
697            thoughts_mount: Some(ThoughtsMount {
698                remote: "invalid-url".to_string(),
699                subpath: None,
700                sync: SyncStrategy::Auto,
701            }),
702            context_mounts: vec![],
703            references: vec![],
704        };
705        let result = mgr.validate_v2_hard(&cfg);
706        assert!(result.is_err());
707        assert!(
708            result
709                .unwrap_err()
710                .to_string()
711                .contains("Invalid remote URL")
712        );
713    }
714
715    #[test]
716    fn test_validate_v2_hard_rejects_duplicate_context_mount_path() {
717        let temp_dir = tempfile::TempDir::new().unwrap();
718        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
719        let cfg = RepoConfigV2 {
720            version: "2.0".to_string(),
721            mount_dirs: MountDirsV2::default(),
722            thoughts_mount: None,
723            context_mounts: vec![
724                ContextMount {
725                    remote: "git@github.com:org/repo1.git".to_string(),
726                    subpath: None,
727                    mount_path: "same".to_string(),
728                    sync: SyncStrategy::Auto,
729                },
730                ContextMount {
731                    remote: "git@github.com:org/repo2.git".to_string(),
732                    subpath: None,
733                    mount_path: "same".to_string(),
734                    sync: SyncStrategy::Auto,
735                },
736            ],
737            references: vec![],
738        };
739        let result = mgr.validate_v2_hard(&cfg);
740        assert!(result.is_err());
741        assert!(
742            result
743                .unwrap_err()
744                .to_string()
745                .contains("Duplicate context mount path")
746        );
747    }
748
749    #[test]
750    fn test_validate_v2_hard_rejects_invalid_context_remote() {
751        let temp_dir = tempfile::TempDir::new().unwrap();
752        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
753        let cfg = RepoConfigV2 {
754            version: "2.0".to_string(),
755            mount_dirs: MountDirsV2::default(),
756            thoughts_mount: None,
757            context_mounts: vec![ContextMount {
758                remote: "invalid-url".to_string(),
759                subpath: None,
760                mount_path: "mount1".to_string(),
761                sync: SyncStrategy::Auto,
762            }],
763            references: vec![],
764        };
765        let result = mgr.validate_v2_hard(&cfg);
766        assert!(result.is_err());
767        assert!(
768            result
769                .unwrap_err()
770                .to_string()
771                .contains("Invalid remote URL")
772        );
773    }
774
775    #[test]
776    fn test_validate_v2_hard_warns_on_sync_none() {
777        let temp_dir = tempfile::TempDir::new().unwrap();
778        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
779        let cfg = RepoConfigV2 {
780            version: "2.0".to_string(),
781            mount_dirs: MountDirsV2::default(),
782            thoughts_mount: None,
783            context_mounts: vec![ContextMount {
784                remote: "git@github.com:org/repo.git".to_string(),
785                subpath: None,
786                mount_path: "mount1".to_string(),
787                sync: SyncStrategy::None,
788            }],
789            references: vec![],
790        };
791        let result = mgr.validate_v2_hard(&cfg);
792        assert!(result.is_ok());
793        let warnings = result.unwrap();
794        assert_eq!(warnings.len(), 1);
795        assert!(warnings[0].contains("sync:None"));
796        assert!(warnings[0].contains("discouraged"));
797    }
798
799    #[test]
800    fn test_validate_v2_hard_rejects_invalid_reference_url() {
801        let temp_dir = tempfile::TempDir::new().unwrap();
802        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
803        let cfg = RepoConfigV2 {
804            version: "2.0".to_string(),
805            mount_dirs: MountDirsV2::default(),
806            thoughts_mount: None,
807            context_mounts: vec![],
808            references: vec![ReferenceEntry::Simple(
809                "git@github.com:org/repo.git:subpath".to_string(),
810            )],
811        };
812        let result = mgr.validate_v2_hard(&cfg);
813        assert!(result.is_err());
814        assert!(result.unwrap_err().to_string().contains("subpath"));
815    }
816
817    #[test]
818    fn test_validate_v2_hard_rejects_duplicate_references() {
819        let temp_dir = tempfile::TempDir::new().unwrap();
820        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
821        let cfg = RepoConfigV2 {
822            version: "2.0".to_string(),
823            mount_dirs: MountDirsV2::default(),
824            thoughts_mount: None,
825            context_mounts: vec![],
826            references: vec![
827                ReferenceEntry::Simple("git@github.com:Org/Repo.git".to_string()),
828                ReferenceEntry::Simple("https://github.com/org/repo".to_string()),
829            ],
830        };
831        let result = mgr.validate_v2_hard(&cfg);
832        assert!(result.is_err());
833        assert!(
834            result
835                .unwrap_err()
836                .to_string()
837                .contains("Duplicate reference")
838        );
839    }
840
841    #[test]
842    fn test_validate_v2_hard_allows_same_repo_with_different_refs() {
843        let temp_dir = tempfile::TempDir::new().unwrap();
844        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
845        let cfg = RepoConfigV2 {
846            version: "2.0".to_string(),
847            mount_dirs: MountDirsV2::default(),
848            thoughts_mount: None,
849            context_mounts: vec![],
850            references: vec![
851                ReferenceEntry::WithMetadata(ReferenceMount {
852                    remote: "https://github.com/org/repo".to_string(),
853                    description: None,
854                    ref_name: Some("refs/heads/main".to_string()),
855                }),
856                ReferenceEntry::WithMetadata(ReferenceMount {
857                    remote: "git@github.com:Org/Repo.git".to_string(),
858                    description: None,
859                    ref_name: Some("refs/tags/v1.0.0".to_string()),
860                }),
861            ],
862        };
863
864        let result = mgr.validate_v2_hard(&cfg);
865        assert!(result.is_ok());
866    }
867
868    #[test]
869    fn test_validate_v2_hard_rejects_duplicate_references_with_same_ref() {
870        let temp_dir = tempfile::TempDir::new().unwrap();
871        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
872        let cfg = RepoConfigV2 {
873            version: "2.0".to_string(),
874            mount_dirs: MountDirsV2::default(),
875            thoughts_mount: None,
876            context_mounts: vec![],
877            references: vec![
878                ReferenceEntry::WithMetadata(ReferenceMount {
879                    remote: "https://github.com/org/repo".to_string(),
880                    description: None,
881                    ref_name: Some("refs/heads/main".to_string()),
882                }),
883                ReferenceEntry::WithMetadata(ReferenceMount {
884                    remote: "git@github.com:Org/Repo.git".to_string(),
885                    description: Some("duplicate".to_string()),
886                    ref_name: Some("refs/heads/main".to_string()),
887                }),
888            ],
889        };
890
891        let result = mgr.validate_v2_hard(&cfg);
892        assert!(result.is_err());
893        assert!(
894            result
895                .unwrap_err()
896                .to_string()
897                .contains("Duplicate reference")
898        );
899    }
900
901    #[test]
902    fn test_validate_v2_hard_rejects_duplicate_references_legacy_remotes_vs_heads() {
903        let temp_dir = tempfile::TempDir::new().unwrap();
904        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
905
906        let cfg = RepoConfigV2 {
907            version: "2.0".to_string(),
908            mount_dirs: MountDirsV2::default(),
909            thoughts_mount: None,
910            context_mounts: vec![],
911            references: vec![
912                ReferenceEntry::WithMetadata(ReferenceMount {
913                    remote: "https://github.com/org/repo".to_string(),
914                    description: None,
915                    ref_name: Some("refs/remotes/origin/main".to_string()),
916                }),
917                ReferenceEntry::WithMetadata(ReferenceMount {
918                    remote: "git@github.com:Org/Repo.git".to_string(),
919                    description: None,
920                    ref_name: Some("refs/heads/main".to_string()),
921                }),
922            ],
923        };
924
925        let err = mgr.validate_v2_hard(&cfg).unwrap_err();
926        assert!(err.to_string().contains("Duplicate reference"));
927    }
928
929    #[test]
930    fn test_validate_v2_hard_rejects_shorthand_pinned_ref() {
931        let temp_dir = tempfile::TempDir::new().unwrap();
932        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
933
934        let cfg = RepoConfigV2 {
935            version: "2.0".to_string(),
936            mount_dirs: MountDirsV2::default(),
937            thoughts_mount: None,
938            context_mounts: vec![],
939            references: vec![ReferenceEntry::WithMetadata(ReferenceMount {
940                remote: "https://github.com/org/repo".to_string(),
941                description: None,
942                ref_name: Some("main".to_string()),
943            })],
944        };
945
946        let err = mgr.validate_v2_hard(&cfg).unwrap_err();
947        assert!(
948            format!("{err:#}").contains("Pinned refs must be full ref names"),
949            "unexpected error chain: {err:#}"
950        );
951    }
952
953    #[test]
954    fn test_validate_v2_hard_rejects_incomplete_pinned_ref_prefix() {
955        let temp_dir = tempfile::TempDir::new().unwrap();
956        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
957
958        let cfg = RepoConfigV2 {
959            version: "2.0".to_string(),
960            mount_dirs: MountDirsV2::default(),
961            thoughts_mount: None,
962            context_mounts: vec![],
963            references: vec![ReferenceEntry::WithMetadata(ReferenceMount {
964                remote: "https://github.com/org/repo".to_string(),
965                description: None,
966                ref_name: Some("refs/heads/".to_string()),
967            })],
968        };
969
970        assert!(mgr.validate_v2_hard(&cfg).is_err());
971    }
972
973    #[test]
974    fn test_validate_v2_hard_warns_on_legacy_refs_remotes() {
975        let temp_dir = tempfile::TempDir::new().unwrap();
976        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
977
978        let cfg = RepoConfigV2 {
979            version: "2.0".to_string(),
980            mount_dirs: MountDirsV2::default(),
981            thoughts_mount: None,
982            context_mounts: vec![],
983            references: vec![ReferenceEntry::WithMetadata(ReferenceMount {
984                remote: "https://github.com/org/repo".to_string(),
985                description: None,
986                ref_name: Some("refs/remotes/origin/main".to_string()),
987            })],
988        };
989
990        let warnings = mgr
991            .validate_v2_hard(&cfg)
992            .expect("legacy refs/remotes should warn");
993        assert_eq!(warnings.len(), 1);
994        assert!(warnings[0].contains("refs/remotes/origin/main"));
995        assert!(warnings[0].contains("legacy pinned ref"));
996    }
997
998    #[test]
999    fn test_validate_v2_hard_accepts_valid_config() {
1000        let temp_dir = tempfile::TempDir::new().unwrap();
1001        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1002        let cfg = RepoConfigV2 {
1003            version: "2.0".to_string(),
1004            mount_dirs: MountDirsV2::default(),
1005            thoughts_mount: Some(ThoughtsMount {
1006                remote: "git@github.com:user/thoughts.git".to_string(),
1007                subpath: None,
1008                sync: SyncStrategy::Auto,
1009            }),
1010            context_mounts: vec![ContextMount {
1011                remote: "git@github.com:org/context.git".to_string(),
1012                subpath: Some("docs".to_string()),
1013                mount_path: "docs".to_string(),
1014                sync: SyncStrategy::Auto,
1015            }],
1016            references: vec![
1017                ReferenceEntry::Simple("git@github.com:org/repo1.git".to_string()),
1018                ReferenceEntry::WithMetadata(ReferenceMount {
1019                    remote: "https://github.com/org/repo2".to_string(),
1020                    description: Some("Reference 2".to_string()),
1021                    ref_name: None,
1022                }),
1023            ],
1024        };
1025        let result = mgr.validate_v2_hard(&cfg);
1026        assert!(result.is_ok());
1027        let warnings = result.unwrap();
1028        assert_eq!(warnings.len(), 0);
1029    }
1030
1031    #[test]
1032    fn test_save_v2_validated_fails_before_write_on_invalid() {
1033        let temp_dir = tempfile::TempDir::new().unwrap();
1034        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1035        let cfg = RepoConfigV2 {
1036            version: "2.0".to_string(),
1037            mount_dirs: MountDirsV2 {
1038                thoughts: "same".to_string(),
1039                context: "same".to_string(),
1040                references: "references".to_string(),
1041            },
1042            thoughts_mount: None,
1043            context_mounts: vec![],
1044            references: vec![],
1045        };
1046
1047        let result = mgr.save_v2_validated(&cfg);
1048        assert!(result.is_err());
1049
1050        // Verify no file was written
1051        let config_path = paths::get_repo_config_path(temp_dir.path());
1052        assert!(!config_path.exists());
1053    }
1054
1055    #[test]
1056    fn test_save_v2_validated_returns_warnings_on_valid() {
1057        let temp_dir = tempfile::TempDir::new().unwrap();
1058        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1059        let cfg = RepoConfigV2 {
1060            version: "2.0".to_string(),
1061            mount_dirs: MountDirsV2::default(),
1062            thoughts_mount: None,
1063            context_mounts: vec![ContextMount {
1064                remote: "git@github.com:org/repo.git".to_string(),
1065                subpath: None,
1066                mount_path: "mount1".to_string(),
1067                sync: SyncStrategy::None,
1068            }],
1069            references: vec![],
1070        };
1071
1072        let result = mgr.save_v2_validated(&cfg);
1073        assert!(result.is_ok());
1074        let warnings = result.unwrap();
1075        assert_eq!(warnings.len(), 1);
1076        assert!(warnings[0].contains("sync:None"));
1077
1078        // Verify file was written
1079        let config_path = paths::get_repo_config_path(temp_dir.path());
1080        assert!(config_path.exists());
1081    }
1082
1083    #[test]
1084    fn test_ensure_v2_default_rejects_v1() {
1085        let temp_dir = tempfile::TempDir::new().unwrap();
1086        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1087
1088        // Create v1 config as raw JSON
1089        let v1_json = r#"{"version": "1.0", "mount_dirs": {}, "requires": [], "rules": []}"#;
1090        let config_path = paths::get_repo_config_path(temp_dir.path());
1091        std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
1092        std::fs::write(&config_path, v1_json).unwrap();
1093
1094        // Call ensure_v2_default() - should error on V1 config
1095        let result = mgr.ensure_v2_default();
1096        assert!(result.is_err());
1097        assert!(result.unwrap_err().to_string().contains("v1"));
1098    }
1099
1100    #[test]
1101    fn test_validate_v2_hard_rejects_empty_context_mount_path() {
1102        let temp_dir = tempfile::TempDir::new().unwrap();
1103        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1104        let cfg = RepoConfigV2 {
1105            version: "2.0".to_string(),
1106            mount_dirs: MountDirsV2::default(),
1107            thoughts_mount: None,
1108            context_mounts: vec![ContextMount {
1109                remote: "git@github.com:org/repo.git".to_string(),
1110                subpath: None,
1111                mount_path: "  ".to_string(), // whitespace-only
1112                sync: SyncStrategy::Auto,
1113            }],
1114            references: vec![],
1115        };
1116        let result = mgr.validate_v2_hard(&cfg);
1117        assert!(result.is_err());
1118        assert!(result.unwrap_err().to_string().contains("cannot be empty"));
1119    }
1120
1121    #[test]
1122    fn test_validate_v2_hard_rejects_dot_context_mount_path() {
1123        let temp_dir = tempfile::TempDir::new().unwrap();
1124        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1125        let cfg = RepoConfigV2 {
1126            version: "2.0".to_string(),
1127            mount_dirs: MountDirsV2::default(),
1128            thoughts_mount: None,
1129            context_mounts: vec![ContextMount {
1130                remote: "git@github.com:org/repo.git".to_string(),
1131                subpath: None,
1132                mount_path: ".".to_string(),
1133                sync: SyncStrategy::Auto,
1134            }],
1135            references: vec![],
1136        };
1137        let result = mgr.validate_v2_hard(&cfg);
1138        assert!(result.is_err());
1139        assert!(
1140            result
1141                .unwrap_err()
1142                .to_string()
1143                .contains("cannot be '.' or '..'")
1144        );
1145    }
1146
1147    #[test]
1148    fn test_validate_v2_hard_rejects_dotdot_context_mount_path() {
1149        let temp_dir = tempfile::TempDir::new().unwrap();
1150        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1151        let cfg = RepoConfigV2 {
1152            version: "2.0".to_string(),
1153            mount_dirs: MountDirsV2::default(),
1154            thoughts_mount: None,
1155            context_mounts: vec![ContextMount {
1156                remote: "git@github.com:org/repo.git".to_string(),
1157                subpath: None,
1158                mount_path: "..".to_string(),
1159                sync: SyncStrategy::Auto,
1160            }],
1161            references: vec![],
1162        };
1163        let result = mgr.validate_v2_hard(&cfg);
1164        assert!(result.is_err());
1165        assert!(
1166            result
1167                .unwrap_err()
1168                .to_string()
1169                .contains("cannot be '.' or '..'")
1170        );
1171    }
1172
1173    #[test]
1174    fn test_validate_v2_hard_rejects_slash_in_context_mount_path() {
1175        let temp_dir = tempfile::TempDir::new().unwrap();
1176        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1177        let cfg = RepoConfigV2 {
1178            version: "2.0".to_string(),
1179            mount_dirs: MountDirsV2::default(),
1180            thoughts_mount: None,
1181            context_mounts: vec![ContextMount {
1182                remote: "git@github.com:org/repo.git".to_string(),
1183                subpath: None,
1184                mount_path: "sub/path".to_string(),
1185                sync: SyncStrategy::Auto,
1186            }],
1187            references: vec![],
1188        };
1189        let result = mgr.validate_v2_hard(&cfg);
1190        assert!(result.is_err());
1191        assert!(
1192            result
1193                .unwrap_err()
1194                .to_string()
1195                .contains("single path segment")
1196        );
1197    }
1198
1199    #[test]
1200    fn test_validate_v2_hard_rejects_backslash_in_context_mount_path() {
1201        let temp_dir = tempfile::TempDir::new().unwrap();
1202        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1203        let cfg = RepoConfigV2 {
1204            version: "2.0".to_string(),
1205            mount_dirs: MountDirsV2::default(),
1206            thoughts_mount: None,
1207            context_mounts: vec![ContextMount {
1208                remote: "git@github.com:org/repo.git".to_string(),
1209                subpath: None,
1210                mount_path: "sub\\path".to_string(),
1211                sync: SyncStrategy::Auto,
1212            }],
1213            references: vec![],
1214        };
1215        let result = mgr.validate_v2_hard(&cfg);
1216        assert!(result.is_err());
1217        assert!(
1218            result
1219                .unwrap_err()
1220                .to_string()
1221                .contains("single path segment")
1222        );
1223    }
1224
1225    #[test]
1226    fn test_validate_v2_hard_accepts_valid_context_mount_path() {
1227        let temp_dir = tempfile::TempDir::new().unwrap();
1228        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1229        let cfg = RepoConfigV2 {
1230            version: "2.0".to_string(),
1231            mount_dirs: MountDirsV2::default(),
1232            thoughts_mount: None,
1233            context_mounts: vec![ContextMount {
1234                remote: "git@github.com:org/repo.git".to_string(),
1235                subpath: None,
1236                mount_path: "docs".to_string(),
1237                sync: SyncStrategy::Auto,
1238            }],
1239            references: vec![],
1240        };
1241        let result = mgr.validate_v2_hard(&cfg);
1242        assert!(result.is_ok());
1243        let warnings = result.unwrap();
1244        assert_eq!(warnings.len(), 0);
1245    }
1246
1247    #[test]
1248    fn test_new_makes_absolute_when_given_relative_repo_root() {
1249        let temp_dir = TempDir::new().unwrap();
1250        let cwd_before = std::env::current_dir().unwrap();
1251
1252        // Change cwd to temp_dir so a relative path exists
1253        std::env::set_current_dir(temp_dir.path()).unwrap();
1254
1255        // Create a subdir to use as repo root
1256        std::fs::create_dir_all("repo").unwrap();
1257
1258        let mgr = RepoConfigManager::new(PathBuf::from("repo"));
1259
1260        // repo_root field is private but we can verify via behavior
1261        // The test passes if construction succeeds (no panic) and
1262        // subsequent operations work correctly
1263        assert!(mgr.peek_config_version().is_ok());
1264
1265        // Restore cwd
1266        std::env::set_current_dir(cwd_before).unwrap();
1267    }
1268
1269    /// Regression test: `validate_v2_hard()` rejects mount directories with trailing slashes.
1270    ///
1271    /// This documents the invariant that the "single path segment" validation at lines 474-479
1272    /// implicitly blocks trailing slashes (which contain '/'). This invariant protects against
1273    /// a latent bug in fmt.rs path stripping where `format!("{}/", base)` would produce double
1274    /// slashes if `base` already ended with '/'.
1275    #[test]
1276    fn test_validate_v2_hard_rejects_trailing_slash_in_mount_dirs() {
1277        let temp_dir = tempfile::TempDir::new().unwrap();
1278        let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1279
1280        // Test trailing slash on thoughts mount dir
1281        let cfg = RepoConfigV2 {
1282            version: "2.0".to_string(),
1283            mount_dirs: MountDirsV2 {
1284                thoughts: "thoughts/".to_string(),
1285                context: "context".to_string(),
1286                references: "references".to_string(),
1287            },
1288            thoughts_mount: None,
1289            context_mounts: vec![],
1290            references: vec![],
1291        };
1292        let result = mgr.validate_v2_hard(&cfg);
1293        assert!(
1294            result.is_err(),
1295            "trailing slash on thoughts should be rejected"
1296        );
1297        assert!(
1298            result
1299                .unwrap_err()
1300                .to_string()
1301                .contains("single path segment"),
1302            "error should mention single path segment requirement"
1303        );
1304
1305        // Test trailing slash on context mount dir
1306        let cfg = RepoConfigV2 {
1307            version: "2.0".to_string(),
1308            mount_dirs: MountDirsV2 {
1309                thoughts: "thoughts".to_string(),
1310                context: "context/".to_string(),
1311                references: "references".to_string(),
1312            },
1313            thoughts_mount: None,
1314            context_mounts: vec![],
1315            references: vec![],
1316        };
1317        let result = mgr.validate_v2_hard(&cfg);
1318        assert!(
1319            result.is_err(),
1320            "trailing slash on context should be rejected"
1321        );
1322
1323        // Test trailing slash on references mount dir
1324        let cfg = RepoConfigV2 {
1325            version: "2.0".to_string(),
1326            mount_dirs: MountDirsV2 {
1327                thoughts: "thoughts".to_string(),
1328                context: "context".to_string(),
1329                references: "references/".to_string(),
1330            },
1331            thoughts_mount: None,
1332            context_mounts: vec![],
1333            references: vec![],
1334        };
1335        let result = mgr.validate_v2_hard(&cfg);
1336        assert!(
1337            result.is_err(),
1338            "trailing slash on references should be rejected"
1339        );
1340    }
1341}