1use crate::config::{
2 ContextMount, Mount, MountDirs, MountDirsV2, ReferenceEntry, ReferenceMount, RepoConfig,
3 RepoConfigV2, RequiredMount, SyncStrategy, ThoughtsMount,
4};
5use crate::mount::MountSpace;
6use crate::utils::paths;
7use anyhow::{Context, Result};
8use atomicwrites::{AtomicFile, OverwriteBehavior};
9use std::fs;
10use std::io::Write;
11use std::path::{Path, PathBuf};
12
13#[derive(Debug, Clone)]
14pub struct DesiredState {
15 pub mount_dirs: MountDirsV2,
16 pub thoughts_mount: Option<ThoughtsMount>,
17 pub context_mounts: Vec<ContextMount>,
18 pub references: Vec<ReferenceMount>,
19 pub was_v1: bool, }
21
22impl DesiredState {
23 pub fn find_mount(&self, space: &MountSpace) -> Option<Mount> {
25 match space {
26 MountSpace::Thoughts => self.thoughts_mount.as_ref().map(|tm| Mount::Git {
27 url: tm.remote.clone(),
28 sync: tm.sync,
29 subpath: tm.subpath.clone(),
30 }),
31 MountSpace::Context(mount_path) => self
32 .context_mounts
33 .iter()
34 .find(|cm| &cm.mount_path == mount_path)
35 .map(|cm| Mount::Git {
36 url: cm.remote.clone(),
37 sync: cm.sync,
38 subpath: cm.subpath.clone(),
39 }),
40 MountSpace::Reference { org: _, repo: _ } => {
41 None
44 }
45 }
46 }
47
48 pub fn get_mount_target(&self, space: &MountSpace, repo_root: &Path) -> PathBuf {
50 repo_root
51 .join(".thoughts-data")
52 .join(space.relative_path(&self.mount_dirs))
53 }
54}
55
56pub struct RepoConfigManager {
57 repo_root: PathBuf,
58}
59
60impl RepoConfigManager {
61 pub fn new(repo_root: PathBuf) -> Self {
62 let abs = if repo_root.is_absolute() {
64 repo_root
65 } else {
66 std::fs::canonicalize(&repo_root).unwrap_or_else(|_| {
67 std::env::current_dir()
68 .expect("Failed to determine current directory for path normalization")
69 .join(&repo_root)
70 })
71 };
72 Self { repo_root: abs }
73 }
74
75 pub fn load(&self) -> Result<Option<RepoConfig>> {
77 let config_path = paths::get_repo_config_path(&self.repo_root);
78 if !config_path.exists() {
79 return Ok(None);
80 }
81
82 let content = fs::read_to_string(&config_path)
83 .with_context(|| format!("Failed to read config from {config_path:?}"))?;
84 let config: RepoConfig = serde_json::from_str(&content)
85 .with_context(|| "Failed to parse repository configuration")?;
86
87 self.validate(&config)?;
88 Ok(Some(config))
89 }
90
91 pub fn save(&self, config: &RepoConfig) -> Result<()> {
93 self.validate(config)?;
94
95 let config_path = paths::get_repo_config_path(&self.repo_root);
96
97 if let Some(parent) = config_path.parent() {
99 fs::create_dir_all(parent)
100 .with_context(|| format!("Failed to create directory {parent:?}"))?;
101 }
102
103 let json =
104 serde_json::to_string_pretty(config).context("Failed to serialize configuration")?;
105
106 AtomicFile::new(&config_path, OverwriteBehavior::AllowOverwrite)
107 .write(|f| f.write_all(json.as_bytes()))
108 .with_context(|| format!("Failed to write config to {config_path:?}"))?;
109
110 Ok(())
111 }
112
113 pub fn ensure_default(&self) -> Result<RepoConfig> {
115 if let Some(config) = self.load()? {
116 return Ok(config);
117 }
118
119 let default_config = RepoConfig {
120 version: "1.0".to_string(),
121 mount_dirs: MountDirs::default(),
122 requires: vec![],
123 rules: vec![],
124 };
125
126 self.save(&default_config)?;
127 Ok(default_config)
128 }
129
130 pub fn load_desired_state(&self) -> Result<Option<DesiredState>> {
131 let config_path = paths::get_repo_config_path(&self.repo_root);
132 if !config_path.exists() {
133 return Ok(None);
134 }
135
136 let raw = std::fs::read_to_string(&config_path)?;
137 let v: serde_json::Value = serde_json::from_str(&raw)?;
139 let version = v.get("version").and_then(|x| x.as_str()).unwrap_or("1.0");
140
141 if version == "2.0" {
142 let v2: RepoConfigV2 = serde_json::from_str(&raw)?;
143 let refs = v2
145 .references
146 .into_iter()
147 .map(|e| match e {
148 ReferenceEntry::Simple(url) => ReferenceMount {
149 remote: url,
150 description: None,
151 },
152 ReferenceEntry::WithMetadata(rm) => rm,
153 })
154 .collect();
155 return Ok(Some(DesiredState {
156 mount_dirs: v2.mount_dirs,
157 thoughts_mount: v2.thoughts_mount,
158 context_mounts: v2.context_mounts,
159 references: refs,
160 was_v1: false,
161 }));
162 }
163
164 let v1: RepoConfig = serde_json::from_str(&raw)?;
166 let ds = self.map_v1_to_desired_state(&v1);
167 Ok(Some(ds))
168 }
169
170 fn validate(&self, config: &RepoConfig) -> Result<()> {
171 if config.version != "1.0" {
173 anyhow::bail!("Unsupported configuration version: {}", config.version);
174 }
175
176 if config.mount_dirs.repository == "personal" {
178 anyhow::bail!("Repository mount directory cannot be named 'personal'");
179 }
180
181 if config.mount_dirs.repository == config.mount_dirs.personal {
182 anyhow::bail!("Repository and personal mount directories must be different");
183 }
184
185 let mut seen_paths = std::collections::HashSet::new();
187 for mount in &config.requires {
188 if !seen_paths.insert(&mount.mount_path) {
189 anyhow::bail!("Duplicate mount path: {}", mount.mount_path);
190 }
191 }
192
193 for mount in &config.requires {
195 self.validate_remote(&mount.remote)?;
196 }
197
198 for rule in &config.rules {
200 glob::Pattern::new(&rule.pattern)
201 .with_context(|| format!("Invalid pattern: {}", rule.pattern))?;
202 }
203
204 Ok(())
205 }
206
207 fn validate_remote(&self, remote: &str) -> Result<()> {
208 if remote.starts_with("./") {
209 return Ok(());
211 }
212
213 if !remote.starts_with("git@")
214 && !remote.starts_with("https://")
215 && !remote.starts_with("ssh://")
216 {
217 anyhow::bail!(
218 "Invalid remote URL: {}. Must be a git URL or relative path starting with ./",
219 remote
220 );
221 }
222
223 Ok(())
224 }
225
226 #[allow(dead_code)]
227 pub fn add_mount(&mut self, mount: RequiredMount) -> Result<()> {
229 let mut config = self.load()?.unwrap_or_else(|| RepoConfig {
230 version: "1.0".to_string(),
231 mount_dirs: MountDirs::default(),
232 requires: vec![],
233 rules: vec![],
234 });
235
236 if config
238 .requires
239 .iter()
240 .any(|m| m.mount_path == mount.mount_path)
241 {
242 anyhow::bail!("Mount path '{}' already exists", mount.mount_path);
243 }
244
245 config.requires.push(mount);
246 self.save(&config)?;
247 Ok(())
248 }
249
250 #[allow(dead_code)]
251 pub fn remove_mount(&mut self, mount_path: &str) -> Result<bool> {
253 let mut config = self
254 .load()?
255 .ok_or_else(|| anyhow::anyhow!("No repository configuration found"))?;
256
257 let initial_len = config.requires.len();
258 config.requires.retain(|m| m.mount_path != mount_path);
259
260 if config.requires.len() == initial_len {
261 return Ok(false);
262 }
263
264 self.save(&config)?;
265 Ok(true)
266 }
267
268 fn map_v1_to_desired_state(&self, v1: &RepoConfig) -> DesiredState {
270 let defaults = MountDirsV2::default();
272 let mut context_mounts = vec![];
273 let mut references = vec![];
274
275 for req in &v1.requires {
276 let is_ref =
277 req.mount_path.starts_with("references/") || req.sync == SyncStrategy::None;
278 if is_ref {
279 references.push(ReferenceMount {
280 remote: req.remote.clone(),
281 description: Some(req.description.clone()),
282 });
283 } else {
284 context_mounts.push(ContextMount {
285 remote: req.remote.clone(),
286 subpath: req.subpath.clone(),
287 mount_path: req.mount_path.clone(),
288 sync: if req.sync == SyncStrategy::None {
290 SyncStrategy::Auto
291 } else {
292 req.sync
293 },
294 });
295 }
296 }
297
298 DesiredState {
299 mount_dirs: MountDirsV2 {
300 thoughts: defaults.thoughts,
301 context: v1.mount_dirs.repository.clone(), references: defaults.references,
303 },
304 thoughts_mount: None, context_mounts,
306 references,
307 was_v1: true,
308 }
309 }
310
311 pub fn load_v2_or_bail(&self) -> Result<RepoConfigV2> {
313 let config_path = paths::get_repo_config_path(&self.repo_root);
314 if !config_path.exists() {
315 anyhow::bail!("No repository configuration found. Run 'thoughts init' first.");
316 }
317
318 let raw = std::fs::read_to_string(&config_path)?;
319 let v: serde_json::Value = serde_json::from_str(&raw)?;
320 let version = v.get("version").and_then(|x| x.as_str()).unwrap_or("1.0");
321
322 if version == "2.0" {
323 let v2: RepoConfigV2 = serde_json::from_str(&raw)?;
324 Ok(v2)
325 } else {
326 anyhow::bail!(
327 "Repository is using v1 configuration. Please migrate to v2 configuration format."
328 );
329 }
330 }
331
332 pub fn save_v2(&self, config: &RepoConfigV2) -> Result<()> {
334 let config_path = paths::get_repo_config_path(&self.repo_root);
335
336 if let Some(parent) = config_path.parent() {
338 fs::create_dir_all(parent)
339 .with_context(|| format!("Failed to create directory {parent:?}"))?;
340 }
341
342 let json =
343 serde_json::to_string_pretty(config).context("Failed to serialize configuration")?;
344
345 AtomicFile::new(&config_path, OverwriteBehavior::AllowOverwrite)
346 .write(|f| f.write_all(json.as_bytes()))
347 .with_context(|| format!("Failed to write config to {config_path:?}"))?;
348
349 Ok(())
350 }
351
352 pub fn ensure_v2_default(&self) -> Result<RepoConfigV2> {
354 let config_path = paths::get_repo_config_path(&self.repo_root);
355 if config_path.exists() {
356 let raw = std::fs::read_to_string(&config_path)?;
358 let v: serde_json::Value = serde_json::from_str(&raw)?;
359 let version = v.get("version").and_then(|x| x.as_str()).unwrap_or("1.0");
360
361 if version == "2.0" {
362 return serde_json::from_str(&raw).context("Failed to parse v2 configuration");
363 }
364
365 let v1: RepoConfig = serde_json::from_str(&raw)?;
367 let ds = self.map_v1_to_desired_state(&v1);
368
369 let needs_backup =
371 !ds.context_mounts.is_empty() || !ds.references.is_empty() || !v1.rules.is_empty();
372 if needs_backup {
373 use chrono::Local;
374 let ts = Local::now().format("%Y%m%d-%H%M%S");
375 let backup_path = config_path
376 .parent()
377 .unwrap()
378 .join(format!("config.v1.bak-{}.json", ts));
379 AtomicFile::new(&backup_path, OverwriteBehavior::AllowOverwrite)
380 .write(|f| f.write_all(raw.as_bytes()))
381 .with_context(|| format!("Failed to write backup to {:?}", backup_path))?;
382 }
383
384 let v2_config = RepoConfigV2 {
386 version: "2.0".to_string(),
387 mount_dirs: ds.mount_dirs,
388 thoughts_mount: ds.thoughts_mount,
389 context_mounts: ds.context_mounts,
390 references: ds
391 .references
392 .into_iter()
393 .map(|rm| {
394 if rm.description.is_some() {
395 ReferenceEntry::WithMetadata(rm)
396 } else {
397 ReferenceEntry::Simple(rm.remote)
398 }
399 })
400 .collect(),
401 };
402
403 self.save_v2_validated(&v2_config)?;
405 return Ok(v2_config);
406 }
407
408 let default_config = RepoConfigV2 {
410 version: "2.0".to_string(),
411 mount_dirs: MountDirsV2::default(),
412 thoughts_mount: None,
413 context_mounts: vec![],
414 references: vec![],
415 };
416
417 self.save_v2(&default_config)?;
418 Ok(default_config)
419 }
420
421 pub fn validate_v2_soft(&self, cfg: &RepoConfigV2) -> Vec<String> {
423 let mut warnings = Vec::new();
424 for r in &cfg.references {
425 let url = match r {
426 ReferenceEntry::Simple(s) => s.as_str(),
427 ReferenceEntry::WithMetadata(rm) => rm.remote.as_str(),
428 };
429 if let Err(e) = crate::config::validation::validate_reference_url(url) {
430 warnings.push(format!("Invalid reference '{}': {}", url, e));
431 }
432 }
433 warnings
434 }
435
436 pub fn peek_config_version(&self) -> Result<Option<String>> {
438 let config_path = paths::get_repo_config_path(&self.repo_root);
439 if !config_path.exists() {
440 return Ok(None);
441 }
442 let raw = std::fs::read_to_string(&config_path)?;
443 let v: serde_json::Value = serde_json::from_str(&raw)?;
444 Ok(v.get("version")
445 .and_then(|x| x.as_str())
446 .map(|s| s.to_string()))
447 }
448
449 pub fn validate_v2_hard(&self, cfg: &RepoConfigV2) -> Result<Vec<String>> {
451 if cfg.version != "2.0" {
452 anyhow::bail!("Unsupported configuration version: {}", cfg.version);
453 }
454
455 let m = &cfg.mount_dirs;
457 for (name, val) in [
458 ("thoughts", &m.thoughts),
459 ("context", &m.context),
460 ("references", &m.references),
461 ] {
462 if val.trim().is_empty() {
463 anyhow::bail!("Mount directory '{}' cannot be empty", name);
464 }
465 if val == ".thoughts-data" {
466 anyhow::bail!(
467 "Mount directory '{}' cannot be named '.thoughts-data'",
468 name
469 );
470 }
471 if val == "." || val == ".." {
472 anyhow::bail!("Mount directory '{}' cannot be '.' or '..'", name);
473 }
474 if val.contains('/') || val.contains('\\') {
475 anyhow::bail!(
476 "Mount directory '{}' must be a single path segment (got {})",
477 name,
478 val
479 );
480 }
481 }
482 if m.thoughts == m.context || m.thoughts == m.references || m.context == m.references {
483 anyhow::bail!("Mount directories must be distinct (thoughts/context/references)");
484 }
485
486 if let Some(tm) = &cfg.thoughts_mount {
488 self.validate_remote(&tm.remote)?;
489 }
490
491 let mut warnings = Vec::new();
493 let mut seen_mount_paths = std::collections::HashSet::new();
494 for cm in &cfg.context_mounts {
495 if !seen_mount_paths.insert(&cm.mount_path) {
497 anyhow::bail!("Duplicate context mount path: {}", cm.mount_path);
498 }
499
500 let mp = cm.mount_path.trim();
502 if mp.is_empty() {
503 anyhow::bail!("Context mount path cannot be empty");
504 }
505 if mp == "." || mp == ".." {
506 anyhow::bail!("Context mount path cannot be '.' or '..'");
507 }
508 if mp.contains('/') || mp.contains('\\') {
509 anyhow::bail!(
510 "Context mount path must be a single path segment (got {})",
511 cm.mount_path
512 );
513 }
514 let m = &cfg.mount_dirs;
515 if mp == m.thoughts || mp == m.context || mp == m.references {
516 anyhow::bail!(
517 "Context mount path '{}' cannot conflict with configured mount_dirs names ('{}', '{}', '{}')",
518 cm.mount_path,
519 m.thoughts,
520 m.context,
521 m.references
522 );
523 }
524
525 self.validate_remote(&cm.remote)?;
527 if matches!(cm.sync, SyncStrategy::None) {
528 warnings.push(format!(
529 "Context mount '{}' has sync:None; allowed but discouraged. Consider SyncStrategy::Auto.",
530 cm.mount_path
531 ));
532 }
533 }
534
535 use crate::config::validation::{canonical_reference_key, validate_reference_url};
537 let mut seen_refs = std::collections::HashSet::new();
538 for r in &cfg.references {
539 let url = match r {
540 ReferenceEntry::Simple(s) => s.as_str(),
541 ReferenceEntry::WithMetadata(rm) => rm.remote.as_str(),
542 };
543 validate_reference_url(url).with_context(|| format!("Invalid reference '{}'", url))?;
544 let key = canonical_reference_key(url)?;
545 if !seen_refs.insert(key) {
546 anyhow::bail!("Duplicate reference detected: {}", url);
547 }
548 }
549
550 Ok(warnings)
551 }
552
553 pub fn save_v2_validated(&self, config: &RepoConfigV2) -> Result<Vec<String>> {
555 let warnings = self.validate_v2_hard(config)?;
556 self.save_v2(config)?;
557 Ok(warnings)
558 }
559}
560
561#[cfg(test)]
562mod tests {
563 use super::*;
564 use tempfile::TempDir;
565
566 #[test]
567 fn test_save_and_load_config() {
568 let temp_dir = TempDir::new().unwrap();
569 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
570
571 let config = RepoConfig {
572 version: "1.0".to_string(),
573 mount_dirs: MountDirs::default(),
574 requires: vec![RequiredMount {
575 remote: "git@github.com:test/repo.git".to_string(),
576 mount_path: "test".to_string(),
577 subpath: None,
578 description: "Test repository".to_string(),
579 optional: false,
580 override_rules: None,
581 sync: crate::config::SyncStrategy::Auto,
582 }],
583 rules: vec![],
584 };
585
586 manager.save(&config).unwrap();
588
589 let loaded = manager.load().unwrap().unwrap();
591
592 assert_eq!(loaded.version, config.version);
593 assert_eq!(loaded.requires.len(), config.requires.len());
594 assert_eq!(loaded.requires[0].remote, config.requires[0].remote);
595 }
596
597 #[test]
598 fn test_validation_invalid_version() {
599 let temp_dir = TempDir::new().unwrap();
600 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
601
602 let config = RepoConfig {
603 version: "2.0".to_string(), mount_dirs: MountDirs::default(),
605 requires: vec![],
606 rules: vec![],
607 };
608
609 assert!(manager.save(&config).is_err());
610 }
611
612 #[test]
613 fn test_validation_conflicting_mount_dirs() {
614 let temp_dir = TempDir::new().unwrap();
615 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
616
617 let config = RepoConfig {
618 version: "1.0".to_string(),
619 mount_dirs: MountDirs {
620 repository: "personal".to_string(), personal: "personal".to_string(),
622 },
623 requires: vec![],
624 rules: vec![],
625 };
626
627 assert!(manager.save(&config).is_err());
628 }
629
630 #[test]
631 fn test_validation_duplicate_mount_paths() {
632 let temp_dir = TempDir::new().unwrap();
633 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
634
635 let config = RepoConfig {
636 version: "1.0".to_string(),
637 mount_dirs: MountDirs::default(),
638 requires: vec![
639 RequiredMount {
640 remote: "git@github.com:test/repo1.git".to_string(),
641 mount_path: "test".to_string(),
642 subpath: None,
643 description: "Test 1".to_string(),
644 optional: false,
645 override_rules: None,
646 sync: crate::config::SyncStrategy::None,
647 },
648 RequiredMount {
649 remote: "git@github.com:test/repo2.git".to_string(),
650 mount_path: "test".to_string(), subpath: None,
652 description: "Test 2".to_string(),
653 optional: false,
654 override_rules: None,
655 sync: crate::config::SyncStrategy::None,
656 },
657 ],
658 rules: vec![],
659 };
660
661 assert!(manager.save(&config).is_err());
662 }
663
664 #[test]
665 fn test_validation_invalid_remote() {
666 let temp_dir = TempDir::new().unwrap();
667 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
668
669 let config = RepoConfig {
670 version: "1.0".to_string(),
671 mount_dirs: MountDirs::default(),
672 requires: vec![RequiredMount {
673 remote: "invalid-url".to_string(), mount_path: "test".to_string(),
675 subpath: None,
676 description: "Test".to_string(),
677 optional: false,
678 override_rules: None,
679 sync: crate::config::SyncStrategy::None,
680 }],
681 rules: vec![],
682 };
683
684 assert!(manager.save(&config).is_err());
685 }
686
687 #[test]
688 fn test_validation_local_mount() {
689 let temp_dir = TempDir::new().unwrap();
690 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
691
692 let config = RepoConfig {
693 version: "1.0".to_string(),
694 mount_dirs: MountDirs::default(),
695 requires: vec![RequiredMount {
696 remote: "./local/path".to_string(), mount_path: "local".to_string(),
698 subpath: None,
699 description: "Local mount".to_string(),
700 optional: false,
701 override_rules: None,
702 sync: crate::config::SyncStrategy::None,
703 }],
704 rules: vec![],
705 };
706
707 assert!(manager.save(&config).is_ok());
708 }
709
710 #[test]
711 fn test_add_and_remove_mount() {
712 let temp_dir = TempDir::new().unwrap();
713 let mut manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
714
715 let mount = RequiredMount {
717 remote: "git@github.com:test/repo.git".to_string(),
718 mount_path: "test".to_string(),
719 subpath: None,
720 description: "Test repository".to_string(),
721 optional: false,
722 override_rules: None,
723 sync: crate::config::SyncStrategy::Auto,
724 };
725
726 manager.add_mount(mount.clone()).unwrap();
727
728 let config = manager.load().unwrap().unwrap();
730 assert_eq!(config.requires.len(), 1);
731 assert_eq!(config.requires[0].mount_path, "test");
732
733 assert!(manager.remove_mount("test").unwrap());
735
736 let config = manager.load().unwrap().unwrap();
738 assert_eq!(config.requires.len(), 0);
739
740 assert!(!manager.remove_mount("test").unwrap());
742 }
743
744 #[test]
745 fn test_v1_to_desired_state_mapping() {
746 let temp_dir = TempDir::new().unwrap();
747 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
748
749 let v1_config = RepoConfig {
751 version: "1.0".to_string(),
752 mount_dirs: MountDirs {
753 repository: "context".to_string(),
754 personal: "personal".to_string(),
755 },
756 requires: vec![
757 RequiredMount {
758 remote: "git@github.com:user/context-repo.git".to_string(),
759 mount_path: "context-mount".to_string(),
760 subpath: Some("subdir".to_string()),
761 description: "Context mount".to_string(),
762 optional: false,
763 override_rules: None,
764 sync: crate::config::SyncStrategy::Auto,
765 },
766 RequiredMount {
767 remote: "git@github.com:org/ref-repo.git".to_string(),
768 mount_path: "references/ref-mount".to_string(),
769 subpath: None,
770 description: "Reference mount".to_string(),
771 optional: true,
772 override_rules: None,
773 sync: crate::config::SyncStrategy::None,
774 },
775 ],
776 rules: vec![],
777 };
778
779 manager.save(&v1_config).unwrap();
781
782 let desired_state = manager.load_desired_state().unwrap().unwrap();
784
785 assert!(desired_state.was_v1);
787 assert_eq!(desired_state.mount_dirs.context, "context");
788 assert_eq!(desired_state.mount_dirs.thoughts, "thoughts");
789 assert_eq!(desired_state.mount_dirs.references, "references");
790
791 assert_eq!(desired_state.context_mounts.len(), 1);
793 assert_eq!(
794 desired_state.context_mounts[0].remote,
795 "git@github.com:user/context-repo.git"
796 );
797 assert_eq!(desired_state.context_mounts[0].mount_path, "context-mount");
798 assert_eq!(
799 desired_state.context_mounts[0].subpath,
800 Some("subdir".to_string())
801 );
802
803 assert_eq!(desired_state.references.len(), 1);
805 assert_eq!(
806 desired_state.references[0].remote,
807 "git@github.com:org/ref-repo.git"
808 );
809 assert_eq!(
810 desired_state.references[0].description.as_deref(),
811 Some("Reference mount")
812 );
813
814 assert!(desired_state.thoughts_mount.is_none());
816 }
817
818 #[test]
819 fn test_v2_config_loading() {
820 let temp_dir = TempDir::new().unwrap();
821 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
822
823 let v2_config = crate::config::RepoConfigV2 {
825 version: "2.0".to_string(),
826 mount_dirs: crate::config::MountDirsV2::default(),
827 thoughts_mount: Some(crate::config::ThoughtsMount {
828 remote: "git@github.com:user/thoughts.git".to_string(),
829 subpath: None,
830 sync: crate::config::SyncStrategy::Auto,
831 }),
832 context_mounts: vec![crate::config::ContextMount {
833 remote: "git@github.com:user/context.git".to_string(),
834 subpath: Some("docs".to_string()),
835 mount_path: "docs".to_string(),
836 sync: crate::config::SyncStrategy::Auto,
837 }],
838 references: vec![
839 ReferenceEntry::Simple("git@github.com:org/ref1.git".to_string()),
840 ReferenceEntry::Simple("https://github.com/org/ref2.git".to_string()),
841 ],
842 };
843
844 let config_path = paths::get_repo_config_path(temp_dir.path());
846 std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
847 let json = serde_json::to_string_pretty(&v2_config).unwrap();
848 std::fs::write(&config_path, json).unwrap();
849
850 let desired_state = manager.load_desired_state().unwrap().unwrap();
852
853 assert!(!desired_state.was_v1);
855 assert!(desired_state.thoughts_mount.is_some());
856 assert_eq!(
857 desired_state.thoughts_mount.as_ref().unwrap().remote,
858 "git@github.com:user/thoughts.git"
859 );
860 assert_eq!(desired_state.context_mounts.len(), 1);
861 assert_eq!(desired_state.references.len(), 2);
862 }
863
864 #[test]
865 fn test_v2_references_normalize_to_reference_mount() {
866 let temp_dir = TempDir::new().unwrap();
867 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
868
869 let json = r#"{
870 "version": "2.0",
871 "mount_dirs": {},
872 "context_mounts": [],
873 "references": [
874 "git@github.com:org/ref1.git",
875 {"remote": "https://github.com/org/ref2.git", "description": "Ref 2"}
876 ]
877 }"#;
878
879 let config_path = paths::get_repo_config_path(temp_dir.path());
880 std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
881 std::fs::write(&config_path, json).unwrap();
882
883 let ds = manager.load_desired_state().unwrap().unwrap();
884 assert_eq!(ds.references.len(), 2);
885 assert_eq!(ds.references[0].remote, "git@github.com:org/ref1.git");
886 assert_eq!(ds.references[0].description, None);
887 assert_eq!(ds.references[1].remote, "https://github.com/org/ref2.git");
888 assert_eq!(ds.references[1].description.as_deref(), Some("Ref 2"));
889 }
890
891 #[test]
892 fn test_v1_migration_preserves_reference_descriptions() {
893 let temp_dir = TempDir::new().unwrap();
894 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
895
896 let v1_config = RepoConfig {
898 version: "1.0".to_string(),
899 mount_dirs: MountDirs::default(),
900 requires: vec![RequiredMount {
901 remote: "git@github.com:org/ref-repo.git".to_string(),
902 mount_path: "references/ref-mount".to_string(),
903 subpath: None,
904 description: "Important reference repository".to_string(),
905 optional: true,
906 override_rules: None,
907 sync: crate::config::SyncStrategy::None,
908 }],
909 rules: vec![],
910 };
911
912 manager.save(&v1_config).unwrap();
914
915 let ds = manager.load_desired_state().unwrap().unwrap();
917
918 assert!(ds.was_v1);
920 assert_eq!(ds.references.len(), 1);
921 assert_eq!(ds.references[0].remote, "git@github.com:org/ref-repo.git");
922 assert_eq!(
923 ds.references[0].description.as_deref(),
924 Some("Important reference repository")
925 );
926 }
927
928 #[test]
929 fn test_validate_v2_soft_handles_both_variants() {
930 let temp_dir = tempfile::TempDir::new().unwrap();
931 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
932
933 let cfg = RepoConfigV2 {
934 version: "2.0".into(),
935 mount_dirs: MountDirsV2::default(),
936 thoughts_mount: None,
937 context_mounts: vec![],
938 references: vec![
939 ReferenceEntry::Simple("https://github.com/org/repo".into()),
940 ReferenceEntry::WithMetadata(ReferenceMount {
941 remote: "git@github.com:org/repo.git:docs".into(), description: None,
943 }),
944 ],
945 };
946
947 let warnings = mgr.validate_v2_soft(&cfg);
948 assert_eq!(warnings.len(), 1, "Expected one invalid reference warning");
949 assert!(warnings[0].contains("git@github.com:org/repo.git:docs"));
950 }
951
952 #[test]
953 fn test_peek_config_version_returns_none_when_no_config() {
954 let temp_dir = tempfile::TempDir::new().unwrap();
955 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
956 assert_eq!(mgr.peek_config_version().unwrap(), None);
957 }
958
959 #[test]
960 fn test_peek_config_version_returns_v1() {
961 let temp_dir = tempfile::TempDir::new().unwrap();
962 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
963 let v1_config = RepoConfig {
964 version: "1.0".to_string(),
965 mount_dirs: MountDirs::default(),
966 requires: vec![],
967 rules: vec![],
968 };
969 mgr.save(&v1_config).unwrap();
970 assert_eq!(mgr.peek_config_version().unwrap(), Some("1.0".to_string()));
971 }
972
973 #[test]
974 fn test_peek_config_version_returns_v2() {
975 let temp_dir = tempfile::TempDir::new().unwrap();
976 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
977 let v2_config = RepoConfigV2 {
978 version: "2.0".to_string(),
979 mount_dirs: MountDirsV2::default(),
980 thoughts_mount: None,
981 context_mounts: vec![],
982 references: vec![],
983 };
984 mgr.save_v2(&v2_config).unwrap();
985 assert_eq!(mgr.peek_config_version().unwrap(), Some("2.0".to_string()));
986 }
987
988 #[test]
989 fn test_validate_v2_hard_rejects_invalid_version() {
990 let temp_dir = tempfile::TempDir::new().unwrap();
991 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
992 let cfg = RepoConfigV2 {
993 version: "3.0".to_string(),
994 mount_dirs: MountDirsV2::default(),
995 thoughts_mount: None,
996 context_mounts: vec![],
997 references: vec![],
998 };
999 let result = mgr.validate_v2_hard(&cfg);
1000 assert!(result.is_err());
1001 assert!(
1002 result
1003 .unwrap_err()
1004 .to_string()
1005 .contains("Unsupported configuration version: 3.0")
1006 );
1007 }
1008
1009 #[test]
1010 fn test_validate_v2_hard_rejects_empty_mount_dirs() {
1011 let temp_dir = tempfile::TempDir::new().unwrap();
1012 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1013 let cfg = RepoConfigV2 {
1014 version: "2.0".to_string(),
1015 mount_dirs: MountDirsV2 {
1016 thoughts: "".to_string(),
1017 context: "context".to_string(),
1018 references: "references".to_string(),
1019 },
1020 thoughts_mount: None,
1021 context_mounts: vec![],
1022 references: vec![],
1023 };
1024 let result = mgr.validate_v2_hard(&cfg);
1025 assert!(result.is_err());
1026 assert!(result.unwrap_err().to_string().contains("cannot be empty"));
1027 }
1028
1029 #[test]
1030 fn test_validate_v2_hard_rejects_reserved_mount_dir_name() {
1031 let temp_dir = tempfile::TempDir::new().unwrap();
1032 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1033 let cfg = RepoConfigV2 {
1034 version: "2.0".to_string(),
1035 mount_dirs: MountDirsV2 {
1036 thoughts: ".thoughts-data".to_string(),
1037 context: "context".to_string(),
1038 references: "references".to_string(),
1039 },
1040 thoughts_mount: None,
1041 context_mounts: vec![],
1042 references: vec![],
1043 };
1044 let result = mgr.validate_v2_hard(&cfg);
1045 assert!(result.is_err());
1046 assert!(result.unwrap_err().to_string().contains(".thoughts-data"));
1047 }
1048
1049 #[test]
1050 fn test_validate_v2_hard_rejects_dot_mount_dirs() {
1051 let temp_dir = tempfile::TempDir::new().unwrap();
1052 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1053 let cfg = RepoConfigV2 {
1054 version: "2.0".to_string(),
1055 mount_dirs: MountDirsV2 {
1056 thoughts: ".".to_string(),
1057 context: "context".to_string(),
1058 references: "references".to_string(),
1059 },
1060 thoughts_mount: None,
1061 context_mounts: vec![],
1062 references: vec![],
1063 };
1064 let result = mgr.validate_v2_hard(&cfg);
1065 assert!(result.is_err());
1066 assert!(
1067 result
1068 .unwrap_err()
1069 .to_string()
1070 .contains("cannot be '.' or '..'")
1071 );
1072 }
1073
1074 #[test]
1075 fn test_validate_v2_hard_rejects_multi_segment_mount_dirs() {
1076 let temp_dir = tempfile::TempDir::new().unwrap();
1077 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1078 let cfg = RepoConfigV2 {
1079 version: "2.0".to_string(),
1080 mount_dirs: MountDirsV2 {
1081 thoughts: "sub/path".to_string(),
1082 context: "context".to_string(),
1083 references: "references".to_string(),
1084 },
1085 thoughts_mount: None,
1086 context_mounts: vec![],
1087 references: vec![],
1088 };
1089 let result = mgr.validate_v2_hard(&cfg);
1090 assert!(result.is_err());
1091 assert!(
1092 result
1093 .unwrap_err()
1094 .to_string()
1095 .contains("must be a single path segment")
1096 );
1097 }
1098
1099 #[test]
1100 fn test_validate_v2_hard_rejects_duplicate_mount_dirs() {
1101 let temp_dir = tempfile::TempDir::new().unwrap();
1102 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1103 let cfg = RepoConfigV2 {
1104 version: "2.0".to_string(),
1105 mount_dirs: MountDirsV2 {
1106 thoughts: "same".to_string(),
1107 context: "same".to_string(),
1108 references: "references".to_string(),
1109 },
1110 thoughts_mount: None,
1111 context_mounts: vec![],
1112 references: vec![],
1113 };
1114 let result = mgr.validate_v2_hard(&cfg);
1115 assert!(result.is_err());
1116 assert!(result.unwrap_err().to_string().contains("must be distinct"));
1117 }
1118
1119 #[test]
1120 fn test_validate_v2_hard_rejects_invalid_thoughts_mount_remote() {
1121 let temp_dir = tempfile::TempDir::new().unwrap();
1122 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1123 let cfg = RepoConfigV2 {
1124 version: "2.0".to_string(),
1125 mount_dirs: MountDirsV2::default(),
1126 thoughts_mount: Some(ThoughtsMount {
1127 remote: "invalid-url".to_string(),
1128 subpath: None,
1129 sync: SyncStrategy::Auto,
1130 }),
1131 context_mounts: vec![],
1132 references: vec![],
1133 };
1134 let result = mgr.validate_v2_hard(&cfg);
1135 assert!(result.is_err());
1136 assert!(
1137 result
1138 .unwrap_err()
1139 .to_string()
1140 .contains("Invalid remote URL")
1141 );
1142 }
1143
1144 #[test]
1145 fn test_validate_v2_hard_rejects_duplicate_context_mount_path() {
1146 let temp_dir = tempfile::TempDir::new().unwrap();
1147 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1148 let cfg = RepoConfigV2 {
1149 version: "2.0".to_string(),
1150 mount_dirs: MountDirsV2::default(),
1151 thoughts_mount: None,
1152 context_mounts: vec![
1153 ContextMount {
1154 remote: "git@github.com:org/repo1.git".to_string(),
1155 subpath: None,
1156 mount_path: "same".to_string(),
1157 sync: SyncStrategy::Auto,
1158 },
1159 ContextMount {
1160 remote: "git@github.com:org/repo2.git".to_string(),
1161 subpath: None,
1162 mount_path: "same".to_string(),
1163 sync: SyncStrategy::Auto,
1164 },
1165 ],
1166 references: vec![],
1167 };
1168 let result = mgr.validate_v2_hard(&cfg);
1169 assert!(result.is_err());
1170 assert!(
1171 result
1172 .unwrap_err()
1173 .to_string()
1174 .contains("Duplicate context mount path")
1175 );
1176 }
1177
1178 #[test]
1179 fn test_validate_v2_hard_rejects_invalid_context_remote() {
1180 let temp_dir = tempfile::TempDir::new().unwrap();
1181 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1182 let cfg = RepoConfigV2 {
1183 version: "2.0".to_string(),
1184 mount_dirs: MountDirsV2::default(),
1185 thoughts_mount: None,
1186 context_mounts: vec![ContextMount {
1187 remote: "invalid-url".to_string(),
1188 subpath: None,
1189 mount_path: "mount1".to_string(),
1190 sync: SyncStrategy::Auto,
1191 }],
1192 references: vec![],
1193 };
1194 let result = mgr.validate_v2_hard(&cfg);
1195 assert!(result.is_err());
1196 assert!(
1197 result
1198 .unwrap_err()
1199 .to_string()
1200 .contains("Invalid remote URL")
1201 );
1202 }
1203
1204 #[test]
1205 fn test_validate_v2_hard_warns_on_sync_none() {
1206 let temp_dir = tempfile::TempDir::new().unwrap();
1207 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1208 let cfg = RepoConfigV2 {
1209 version: "2.0".to_string(),
1210 mount_dirs: MountDirsV2::default(),
1211 thoughts_mount: None,
1212 context_mounts: vec![ContextMount {
1213 remote: "git@github.com:org/repo.git".to_string(),
1214 subpath: None,
1215 mount_path: "mount1".to_string(),
1216 sync: SyncStrategy::None,
1217 }],
1218 references: vec![],
1219 };
1220 let result = mgr.validate_v2_hard(&cfg);
1221 assert!(result.is_ok());
1222 let warnings = result.unwrap();
1223 assert_eq!(warnings.len(), 1);
1224 assert!(warnings[0].contains("sync:None"));
1225 assert!(warnings[0].contains("discouraged"));
1226 }
1227
1228 #[test]
1229 fn test_validate_v2_hard_rejects_invalid_reference_url() {
1230 let temp_dir = tempfile::TempDir::new().unwrap();
1231 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1232 let cfg = RepoConfigV2 {
1233 version: "2.0".to_string(),
1234 mount_dirs: MountDirsV2::default(),
1235 thoughts_mount: None,
1236 context_mounts: vec![],
1237 references: vec![ReferenceEntry::Simple(
1238 "git@github.com:org/repo.git:subpath".to_string(),
1239 )],
1240 };
1241 let result = mgr.validate_v2_hard(&cfg);
1242 assert!(result.is_err());
1243 assert!(result.unwrap_err().to_string().contains("subpath"));
1244 }
1245
1246 #[test]
1247 fn test_validate_v2_hard_rejects_duplicate_references() {
1248 let temp_dir = tempfile::TempDir::new().unwrap();
1249 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1250 let cfg = RepoConfigV2 {
1251 version: "2.0".to_string(),
1252 mount_dirs: MountDirsV2::default(),
1253 thoughts_mount: None,
1254 context_mounts: vec![],
1255 references: vec![
1256 ReferenceEntry::Simple("git@github.com:Org/Repo.git".to_string()),
1257 ReferenceEntry::Simple("https://github.com/org/repo".to_string()),
1258 ],
1259 };
1260 let result = mgr.validate_v2_hard(&cfg);
1261 assert!(result.is_err());
1262 assert!(
1263 result
1264 .unwrap_err()
1265 .to_string()
1266 .contains("Duplicate reference")
1267 );
1268 }
1269
1270 #[test]
1271 fn test_validate_v2_hard_accepts_valid_config() {
1272 let temp_dir = tempfile::TempDir::new().unwrap();
1273 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1274 let cfg = RepoConfigV2 {
1275 version: "2.0".to_string(),
1276 mount_dirs: MountDirsV2::default(),
1277 thoughts_mount: Some(ThoughtsMount {
1278 remote: "git@github.com:user/thoughts.git".to_string(),
1279 subpath: None,
1280 sync: SyncStrategy::Auto,
1281 }),
1282 context_mounts: vec![ContextMount {
1283 remote: "git@github.com:org/context.git".to_string(),
1284 subpath: Some("docs".to_string()),
1285 mount_path: "docs".to_string(),
1286 sync: SyncStrategy::Auto,
1287 }],
1288 references: vec![
1289 ReferenceEntry::Simple("git@github.com:org/repo1.git".to_string()),
1290 ReferenceEntry::WithMetadata(ReferenceMount {
1291 remote: "https://github.com/org/repo2".to_string(),
1292 description: Some("Reference 2".to_string()),
1293 }),
1294 ],
1295 };
1296 let result = mgr.validate_v2_hard(&cfg);
1297 assert!(result.is_ok());
1298 let warnings = result.unwrap();
1299 assert_eq!(warnings.len(), 0);
1300 }
1301
1302 #[test]
1303 fn test_save_v2_validated_fails_before_write_on_invalid() {
1304 let temp_dir = tempfile::TempDir::new().unwrap();
1305 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1306 let cfg = RepoConfigV2 {
1307 version: "2.0".to_string(),
1308 mount_dirs: MountDirsV2 {
1309 thoughts: "same".to_string(),
1310 context: "same".to_string(),
1311 references: "references".to_string(),
1312 },
1313 thoughts_mount: None,
1314 context_mounts: vec![],
1315 references: vec![],
1316 };
1317
1318 let result = mgr.save_v2_validated(&cfg);
1319 assert!(result.is_err());
1320
1321 let config_path = paths::get_repo_config_path(temp_dir.path());
1323 assert!(!config_path.exists());
1324 }
1325
1326 #[test]
1327 fn test_save_v2_validated_returns_warnings_on_valid() {
1328 let temp_dir = tempfile::TempDir::new().unwrap();
1329 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1330 let cfg = RepoConfigV2 {
1331 version: "2.0".to_string(),
1332 mount_dirs: MountDirsV2::default(),
1333 thoughts_mount: None,
1334 context_mounts: vec![ContextMount {
1335 remote: "git@github.com:org/repo.git".to_string(),
1336 subpath: None,
1337 mount_path: "mount1".to_string(),
1338 sync: SyncStrategy::None,
1339 }],
1340 references: vec![],
1341 };
1342
1343 let result = mgr.save_v2_validated(&cfg);
1344 assert!(result.is_ok());
1345 let warnings = result.unwrap();
1346 assert_eq!(warnings.len(), 1);
1347 assert!(warnings[0].contains("sync:None"));
1348
1349 let config_path = paths::get_repo_config_path(temp_dir.path());
1351 assert!(config_path.exists());
1352 }
1353
1354 #[test]
1355 fn test_ensure_v2_default_migrates_v1_without_panic() {
1356 let temp_dir = tempfile::TempDir::new().unwrap();
1357 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1358
1359 let v1_config = RepoConfig {
1361 version: "1.0".to_string(),
1362 mount_dirs: MountDirs {
1363 repository: "mycontext".to_string(),
1364 personal: "personal".to_string(),
1365 },
1366 requires: vec![
1367 RequiredMount {
1368 remote: "git@github.com:org/context-repo.git".to_string(),
1369 mount_path: "docs".to_string(),
1370 subpath: Some("content".to_string()),
1371 description: "Documentation".to_string(),
1372 optional: false,
1373 override_rules: None,
1374 sync: SyncStrategy::Auto,
1375 },
1376 RequiredMount {
1377 remote: "git@github.com:org/ref-repo.git".to_string(),
1378 mount_path: "references/ref".to_string(),
1379 subpath: None,
1380 description: "Reference repo".to_string(),
1381 optional: true,
1382 override_rules: None,
1383 sync: SyncStrategy::None,
1384 },
1385 ],
1386 rules: vec![],
1387 };
1388
1389 mgr.save(&v1_config).unwrap();
1391
1392 let v2_config = mgr.ensure_v2_default().unwrap();
1394
1395 assert_eq!(v2_config.version, "2.0");
1397 assert_eq!(v2_config.mount_dirs.context, "mycontext");
1398 assert_eq!(v2_config.context_mounts.len(), 1);
1399 assert_eq!(v2_config.context_mounts[0].mount_path, "docs");
1400 assert_eq!(
1401 v2_config.context_mounts[0].subpath,
1402 Some("content".to_string())
1403 );
1404 assert_eq!(v2_config.references.len(), 1);
1405
1406 let ds = mgr.load_desired_state().unwrap().unwrap();
1408 assert!(!ds.was_v1); assert_eq!(ds.context_mounts.len(), 1);
1410 assert_eq!(ds.references.len(), 1);
1411 }
1412
1413 #[test]
1414 fn test_validate_v2_hard_rejects_empty_context_mount_path() {
1415 let temp_dir = tempfile::TempDir::new().unwrap();
1416 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1417 let cfg = RepoConfigV2 {
1418 version: "2.0".to_string(),
1419 mount_dirs: MountDirsV2::default(),
1420 thoughts_mount: None,
1421 context_mounts: vec![ContextMount {
1422 remote: "git@github.com:org/repo.git".to_string(),
1423 subpath: None,
1424 mount_path: " ".to_string(), sync: SyncStrategy::Auto,
1426 }],
1427 references: vec![],
1428 };
1429 let result = mgr.validate_v2_hard(&cfg);
1430 assert!(result.is_err());
1431 assert!(result.unwrap_err().to_string().contains("cannot be empty"));
1432 }
1433
1434 #[test]
1435 fn test_validate_v2_hard_rejects_dot_context_mount_path() {
1436 let temp_dir = tempfile::TempDir::new().unwrap();
1437 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1438 let cfg = RepoConfigV2 {
1439 version: "2.0".to_string(),
1440 mount_dirs: MountDirsV2::default(),
1441 thoughts_mount: None,
1442 context_mounts: vec![ContextMount {
1443 remote: "git@github.com:org/repo.git".to_string(),
1444 subpath: None,
1445 mount_path: ".".to_string(),
1446 sync: SyncStrategy::Auto,
1447 }],
1448 references: vec![],
1449 };
1450 let result = mgr.validate_v2_hard(&cfg);
1451 assert!(result.is_err());
1452 assert!(
1453 result
1454 .unwrap_err()
1455 .to_string()
1456 .contains("cannot be '.' or '..'")
1457 );
1458 }
1459
1460 #[test]
1461 fn test_validate_v2_hard_rejects_dotdot_context_mount_path() {
1462 let temp_dir = tempfile::TempDir::new().unwrap();
1463 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1464 let cfg = RepoConfigV2 {
1465 version: "2.0".to_string(),
1466 mount_dirs: MountDirsV2::default(),
1467 thoughts_mount: None,
1468 context_mounts: vec![ContextMount {
1469 remote: "git@github.com:org/repo.git".to_string(),
1470 subpath: None,
1471 mount_path: "..".to_string(),
1472 sync: SyncStrategy::Auto,
1473 }],
1474 references: vec![],
1475 };
1476 let result = mgr.validate_v2_hard(&cfg);
1477 assert!(result.is_err());
1478 assert!(
1479 result
1480 .unwrap_err()
1481 .to_string()
1482 .contains("cannot be '.' or '..'")
1483 );
1484 }
1485
1486 #[test]
1487 fn test_validate_v2_hard_rejects_slash_in_context_mount_path() {
1488 let temp_dir = tempfile::TempDir::new().unwrap();
1489 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1490 let cfg = RepoConfigV2 {
1491 version: "2.0".to_string(),
1492 mount_dirs: MountDirsV2::default(),
1493 thoughts_mount: None,
1494 context_mounts: vec![ContextMount {
1495 remote: "git@github.com:org/repo.git".to_string(),
1496 subpath: None,
1497 mount_path: "sub/path".to_string(),
1498 sync: SyncStrategy::Auto,
1499 }],
1500 references: vec![],
1501 };
1502 let result = mgr.validate_v2_hard(&cfg);
1503 assert!(result.is_err());
1504 assert!(
1505 result
1506 .unwrap_err()
1507 .to_string()
1508 .contains("single path segment")
1509 );
1510 }
1511
1512 #[test]
1513 fn test_validate_v2_hard_rejects_backslash_in_context_mount_path() {
1514 let temp_dir = tempfile::TempDir::new().unwrap();
1515 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1516 let cfg = RepoConfigV2 {
1517 version: "2.0".to_string(),
1518 mount_dirs: MountDirsV2::default(),
1519 thoughts_mount: None,
1520 context_mounts: vec![ContextMount {
1521 remote: "git@github.com:org/repo.git".to_string(),
1522 subpath: None,
1523 mount_path: "sub\\path".to_string(),
1524 sync: SyncStrategy::Auto,
1525 }],
1526 references: vec![],
1527 };
1528 let result = mgr.validate_v2_hard(&cfg);
1529 assert!(result.is_err());
1530 assert!(
1531 result
1532 .unwrap_err()
1533 .to_string()
1534 .contains("single path segment")
1535 );
1536 }
1537
1538 #[test]
1539 fn test_validate_v2_hard_accepts_valid_context_mount_path() {
1540 let temp_dir = tempfile::TempDir::new().unwrap();
1541 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1542 let cfg = RepoConfigV2 {
1543 version: "2.0".to_string(),
1544 mount_dirs: MountDirsV2::default(),
1545 thoughts_mount: None,
1546 context_mounts: vec![ContextMount {
1547 remote: "git@github.com:org/repo.git".to_string(),
1548 subpath: None,
1549 mount_path: "docs".to_string(),
1550 sync: SyncStrategy::Auto,
1551 }],
1552 references: vec![],
1553 };
1554 let result = mgr.validate_v2_hard(&cfg);
1555 assert!(result.is_ok());
1556 let warnings = result.unwrap();
1557 assert_eq!(warnings.len(), 0);
1558 }
1559
1560 #[test]
1561 fn test_new_makes_absolute_when_given_relative_repo_root() {
1562 let temp_dir = TempDir::new().unwrap();
1563 let cwd_before = std::env::current_dir().unwrap();
1564
1565 std::env::set_current_dir(temp_dir.path()).unwrap();
1567
1568 std::fs::create_dir_all("repo").unwrap();
1570
1571 let mgr = RepoConfigManager::new(PathBuf::from("repo"));
1572
1573 assert!(mgr.peek_config_version().is_ok());
1577
1578 std::env::set_current_dir(cwd_before).unwrap();
1580 }
1581}