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