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 {
41 org_path: _,
42 repo: _,
43 ref_key: _,
44 } => {
45 None
48 }
49 }
50 }
51
52 pub fn get_mount_target(&self, space: &MountSpace, repo_root: &Path) -> PathBuf {
54 repo_root
55 .join(".thoughts-data")
56 .join(space.relative_path(&self.mount_dirs))
57 }
58}
59
60pub struct RepoConfigManager {
61 repo_root: PathBuf,
62}
63
64impl RepoConfigManager {
65 pub fn new(repo_root: PathBuf) -> Self {
66 let abs = if repo_root.is_absolute() {
68 repo_root
69 } else {
70 std::fs::canonicalize(&repo_root).unwrap_or_else(|_| {
71 std::env::current_dir()
72 .expect("Failed to determine current directory for path normalization")
73 .join(&repo_root)
74 })
75 };
76 Self { repo_root: abs }
77 }
78
79 pub fn load(&self) -> Result<Option<RepoConfig>> {
81 let config_path = paths::get_repo_config_path(&self.repo_root);
82 if !config_path.exists() {
83 return Ok(None);
84 }
85
86 let content = fs::read_to_string(&config_path)
87 .with_context(|| format!("Failed to read config from {config_path:?}"))?;
88 let config: RepoConfig = serde_json::from_str(&content)
89 .with_context(|| "Failed to parse repository configuration")?;
90
91 self.validate(&config)?;
92 Ok(Some(config))
93 }
94
95 pub fn save(&self, config: &RepoConfig) -> Result<()> {
97 self.validate(config)?;
98
99 let config_path = paths::get_repo_config_path(&self.repo_root);
100
101 if let Some(parent) = config_path.parent() {
103 fs::create_dir_all(parent)
104 .with_context(|| format!("Failed to create directory {parent:?}"))?;
105 }
106
107 let json =
108 serde_json::to_string_pretty(config).context("Failed to serialize configuration")?;
109
110 AtomicFile::new(&config_path, OverwriteBehavior::AllowOverwrite)
111 .write(|f| f.write_all(json.as_bytes()))
112 .with_context(|| format!("Failed to write config to {config_path:?}"))?;
113
114 Ok(())
115 }
116
117 pub fn ensure_default(&self) -> Result<RepoConfig> {
119 if let Some(config) = self.load()? {
120 return Ok(config);
121 }
122
123 let default_config = RepoConfig {
124 version: "1.0".to_string(),
125 mount_dirs: MountDirs::default(),
126 requires: vec![],
127 rules: vec![],
128 };
129
130 self.save(&default_config)?;
131 Ok(default_config)
132 }
133
134 pub fn load_desired_state(&self) -> Result<Option<DesiredState>> {
135 let config_path = paths::get_repo_config_path(&self.repo_root);
136 if !config_path.exists() {
137 return Ok(None);
138 }
139
140 let raw = std::fs::read_to_string(&config_path)?;
141 let v: serde_json::Value = serde_json::from_str(&raw)?;
143 let version = v.get("version").and_then(|x| x.as_str()).unwrap_or("1.0");
144
145 if version == "2.0" {
146 let v2: RepoConfigV2 = serde_json::from_str(&raw)?;
147 let refs = v2
149 .references
150 .into_iter()
151 .map(|e| match e {
152 ReferenceEntry::Simple(url) => ReferenceMount {
153 remote: url,
154 description: None,
155 ref_name: None,
156 },
157 ReferenceEntry::WithMetadata(rm) => rm,
158 })
159 .collect();
160 return Ok(Some(DesiredState {
161 mount_dirs: v2.mount_dirs,
162 thoughts_mount: v2.thoughts_mount,
163 context_mounts: v2.context_mounts,
164 references: refs,
165 was_v1: false,
166 }));
167 }
168
169 let v1: RepoConfig = serde_json::from_str(&raw)?;
171 let ds = self.map_v1_to_desired_state(&v1);
172 Ok(Some(ds))
173 }
174
175 fn validate(&self, config: &RepoConfig) -> Result<()> {
176 if config.version != "1.0" {
178 anyhow::bail!("Unsupported configuration version: {}", config.version);
179 }
180
181 if config.mount_dirs.repository == "personal" {
183 anyhow::bail!("Repository mount directory cannot be named 'personal'");
184 }
185
186 if config.mount_dirs.repository == config.mount_dirs.personal {
187 anyhow::bail!("Repository and personal mount directories must be different");
188 }
189
190 let mut seen_paths = std::collections::HashSet::new();
192 for mount in &config.requires {
193 if !seen_paths.insert(&mount.mount_path) {
194 anyhow::bail!("Duplicate mount path: {}", mount.mount_path);
195 }
196 }
197
198 for mount in &config.requires {
200 self.validate_remote(&mount.remote)?;
201 }
202
203 for rule in &config.rules {
205 glob::Pattern::new(&rule.pattern)
206 .with_context(|| format!("Invalid pattern: {}", rule.pattern))?;
207 }
208
209 Ok(())
210 }
211
212 fn validate_remote(&self, remote: &str) -> Result<()> {
213 if remote.starts_with("./") {
214 return Ok(());
216 }
217
218 if !remote.starts_with("git@")
219 && !remote.starts_with("https://")
220 && !remote.starts_with("ssh://")
221 {
222 anyhow::bail!(
223 "Invalid remote URL: {}. Must be a git URL or relative path starting with ./",
224 remote
225 );
226 }
227
228 Ok(())
229 }
230
231 #[allow(dead_code)]
232 pub fn add_mount(&mut self, mount: RequiredMount) -> Result<()> {
234 let mut config = self.load()?.unwrap_or_else(|| RepoConfig {
235 version: "1.0".to_string(),
236 mount_dirs: MountDirs::default(),
237 requires: vec![],
238 rules: vec![],
239 });
240
241 if config
243 .requires
244 .iter()
245 .any(|m| m.mount_path == mount.mount_path)
246 {
247 anyhow::bail!("Mount path '{}' already exists", mount.mount_path);
248 }
249
250 config.requires.push(mount);
251 self.save(&config)?;
252 Ok(())
253 }
254
255 #[allow(dead_code)]
256 pub fn remove_mount(&mut self, mount_path: &str) -> Result<bool> {
258 let mut config = self
259 .load()?
260 .ok_or_else(|| anyhow::anyhow!("No repository configuration found"))?;
261
262 let initial_len = config.requires.len();
263 config.requires.retain(|m| m.mount_path != mount_path);
264
265 if config.requires.len() == initial_len {
266 return Ok(false);
267 }
268
269 self.save(&config)?;
270 Ok(true)
271 }
272
273 fn map_v1_to_desired_state(&self, v1: &RepoConfig) -> DesiredState {
275 let defaults = MountDirsV2::default();
277 let mut context_mounts = vec![];
278 let mut references = vec![];
279
280 for req in &v1.requires {
281 let is_ref =
282 req.mount_path.starts_with("references/") || req.sync == SyncStrategy::None;
283 if is_ref {
284 references.push(ReferenceMount {
285 remote: req.remote.clone(),
286 description: Some(req.description.clone()),
287 ref_name: None,
288 });
289 } else {
290 context_mounts.push(ContextMount {
291 remote: req.remote.clone(),
292 subpath: req.subpath.clone(),
293 mount_path: req.mount_path.clone(),
294 sync: if req.sync == SyncStrategy::None {
296 SyncStrategy::Auto
297 } else {
298 req.sync
299 },
300 });
301 }
302 }
303
304 DesiredState {
305 mount_dirs: MountDirsV2 {
306 thoughts: defaults.thoughts,
307 context: v1.mount_dirs.repository.clone(), references: defaults.references,
309 },
310 thoughts_mount: None, context_mounts,
312 references,
313 was_v1: true,
314 }
315 }
316
317 pub fn load_v2_or_bail(&self) -> Result<RepoConfigV2> {
319 let config_path = paths::get_repo_config_path(&self.repo_root);
320 if !config_path.exists() {
321 anyhow::bail!("No repository configuration found. Run 'thoughts init' first.");
322 }
323
324 let raw = std::fs::read_to_string(&config_path)?;
325 let v: serde_json::Value = serde_json::from_str(&raw)?;
326 let version = v.get("version").and_then(|x| x.as_str()).unwrap_or("1.0");
327
328 if version == "2.0" {
329 let v2: RepoConfigV2 = serde_json::from_str(&raw)?;
330 Ok(v2)
331 } else {
332 anyhow::bail!(
333 "Repository is using v1 configuration. Please migrate to v2 configuration format."
334 );
335 }
336 }
337
338 pub fn save_v2(&self, config: &RepoConfigV2) -> Result<()> {
340 let config_path = paths::get_repo_config_path(&self.repo_root);
341
342 if let Some(parent) = config_path.parent() {
344 fs::create_dir_all(parent)
345 .with_context(|| format!("Failed to create directory {parent:?}"))?;
346 }
347
348 let json =
349 serde_json::to_string_pretty(config).context("Failed to serialize configuration")?;
350
351 AtomicFile::new(&config_path, OverwriteBehavior::AllowOverwrite)
352 .write(|f| f.write_all(json.as_bytes()))
353 .with_context(|| format!("Failed to write config to {config_path:?}"))?;
354
355 Ok(())
356 }
357
358 pub fn ensure_v2_default(&self) -> Result<RepoConfigV2> {
360 let config_path = paths::get_repo_config_path(&self.repo_root);
361 if config_path.exists() {
362 let raw = std::fs::read_to_string(&config_path)?;
364 let v: serde_json::Value = serde_json::from_str(&raw)?;
365 let version = v.get("version").and_then(|x| x.as_str()).unwrap_or("1.0");
366
367 if version == "2.0" {
368 return serde_json::from_str(&raw).context("Failed to parse v2 configuration");
369 }
370
371 let v1: RepoConfig = serde_json::from_str(&raw)?;
373 let ds = self.map_v1_to_desired_state(&v1);
374
375 let needs_backup =
377 !ds.context_mounts.is_empty() || !ds.references.is_empty() || !v1.rules.is_empty();
378 if needs_backup {
379 use chrono::Local;
380 let ts = Local::now().format("%Y%m%d-%H%M%S");
381 let backup_path = config_path
382 .parent()
383 .unwrap()
384 .join(format!("config.v1.bak-{}.json", ts));
385 AtomicFile::new(&backup_path, OverwriteBehavior::AllowOverwrite)
386 .write(|f| f.write_all(raw.as_bytes()))
387 .with_context(|| format!("Failed to write backup to {:?}", backup_path))?;
388 }
389
390 let v2_config = RepoConfigV2 {
392 version: "2.0".to_string(),
393 mount_dirs: ds.mount_dirs,
394 thoughts_mount: ds.thoughts_mount,
395 context_mounts: ds.context_mounts,
396 references: ds
397 .references
398 .into_iter()
399 .map(|rm| {
400 if rm.description.is_some() || rm.ref_name.is_some() {
401 ReferenceEntry::WithMetadata(rm)
402 } else {
403 ReferenceEntry::Simple(rm.remote)
404 }
405 })
406 .collect(),
407 };
408
409 self.save_v2_validated(&v2_config)?;
411 return Ok(v2_config);
412 }
413
414 let default_config = RepoConfigV2 {
416 version: "2.0".to_string(),
417 mount_dirs: MountDirsV2::default(),
418 thoughts_mount: None,
419 context_mounts: vec![],
420 references: vec![],
421 };
422
423 self.save_v2(&default_config)?;
424 Ok(default_config)
425 }
426
427 pub fn validate_v2_soft(&self, cfg: &RepoConfigV2) -> Vec<String> {
429 let mut warnings = Vec::new();
430 for r in &cfg.references {
431 let url = match r {
432 ReferenceEntry::Simple(s) => s.as_str(),
433 ReferenceEntry::WithMetadata(rm) => rm.remote.as_str(),
434 };
435 if let Err(e) = crate::config::validation::validate_reference_url(url) {
436 warnings.push(format!("Invalid reference '{}': {}", url, e));
437 }
438 }
439 warnings
440 }
441
442 pub fn peek_config_version(&self) -> Result<Option<String>> {
444 let config_path = paths::get_repo_config_path(&self.repo_root);
445 if !config_path.exists() {
446 return Ok(None);
447 }
448 let raw = std::fs::read_to_string(&config_path)?;
449 let v: serde_json::Value = serde_json::from_str(&raw)?;
450 Ok(v.get("version")
451 .and_then(|x| x.as_str())
452 .map(|s| s.to_string()))
453 }
454
455 pub fn validate_v2_hard(&self, cfg: &RepoConfigV2) -> Result<Vec<String>> {
457 if cfg.version != "2.0" {
458 anyhow::bail!("Unsupported configuration version: {}", cfg.version);
459 }
460
461 let m = &cfg.mount_dirs;
463 for (name, val) in [
464 ("thoughts", &m.thoughts),
465 ("context", &m.context),
466 ("references", &m.references),
467 ] {
468 if val.trim().is_empty() {
469 anyhow::bail!("Mount directory '{}' cannot be empty", name);
470 }
471 if val == ".thoughts-data" {
472 anyhow::bail!(
473 "Mount directory '{}' cannot be named '.thoughts-data'",
474 name
475 );
476 }
477 if val == "." || val == ".." {
478 anyhow::bail!("Mount directory '{}' cannot be '.' or '..'", name);
479 }
480 if val.contains('/') || val.contains('\\') {
481 anyhow::bail!(
482 "Mount directory '{}' must be a single path segment (got {})",
483 name,
484 val
485 );
486 }
487 }
488 if m.thoughts == m.context || m.thoughts == m.references || m.context == m.references {
489 anyhow::bail!("Mount directories must be distinct (thoughts/context/references)");
490 }
491
492 if let Some(tm) = &cfg.thoughts_mount {
494 self.validate_remote(&tm.remote)?;
495 }
496
497 let mut warnings = Vec::new();
499 let mut seen_mount_paths = std::collections::HashSet::new();
500 for cm in &cfg.context_mounts {
501 if !seen_mount_paths.insert(&cm.mount_path) {
503 anyhow::bail!("Duplicate context mount path: {}", cm.mount_path);
504 }
505
506 let mp = cm.mount_path.trim();
508 if mp.is_empty() {
509 anyhow::bail!("Context mount path cannot be empty");
510 }
511 if mp == "." || mp == ".." {
512 anyhow::bail!("Context mount path cannot be '.' or '..'");
513 }
514 if mp.contains('/') || mp.contains('\\') {
515 anyhow::bail!(
516 "Context mount path must be a single path segment (got {})",
517 cm.mount_path
518 );
519 }
520 let m = &cfg.mount_dirs;
521 if mp == m.thoughts || mp == m.context || mp == m.references {
522 anyhow::bail!(
523 "Context mount path '{}' cannot conflict with configured mount_dirs names ('{}', '{}', '{}')",
524 cm.mount_path,
525 m.thoughts,
526 m.context,
527 m.references
528 );
529 }
530
531 self.validate_remote(&cm.remote)?;
533 if matches!(cm.sync, SyncStrategy::None) {
534 warnings.push(format!(
535 "Context mount '{}' has sync:None; allowed but discouraged. Consider SyncStrategy::Auto.",
536 cm.mount_path
537 ));
538 }
539 }
540
541 use crate::config::validation::{
543 canonical_reference_instance_key, validate_pinned_ref_full_name, validate_reference_url,
544 };
545 let mut seen_refs = std::collections::HashSet::new();
546 for r in &cfg.references {
547 let (url, ref_name) = match r {
548 ReferenceEntry::Simple(s) => (s.as_str(), None),
549 ReferenceEntry::WithMetadata(rm) => (rm.remote.as_str(), rm.ref_name.as_deref()),
550 };
551 validate_reference_url(url).with_context(|| format!("Invalid reference '{}'", url))?;
552 if let Some(ref_name) = ref_name {
553 if ref_name.starts_with("refs/remotes/") {
554 warnings.push(format!(
555 "Reference '{}' uses legacy pinned ref '{}'. New references should use refs/heads/* or refs/tags/*; refs/remotes/* is a local remote-tracking namespace.",
556 url, ref_name
557 ));
558 }
559 validate_pinned_ref_full_name(ref_name).with_context(|| {
560 format!("Invalid pinned ref '{}' for reference '{}'", ref_name, url)
561 })?;
562 }
563 let key = canonical_reference_instance_key(url, ref_name)?;
564 if !seen_refs.insert(key) {
565 anyhow::bail!("Duplicate reference detected: {}", url);
566 }
567 }
568
569 Ok(warnings)
570 }
571
572 pub fn save_v2_validated(&self, config: &RepoConfigV2) -> Result<Vec<String>> {
574 let warnings = self.validate_v2_hard(config)?;
575 self.save_v2(config)?;
576 Ok(warnings)
577 }
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583 use tempfile::TempDir;
584
585 #[test]
586 fn test_save_and_load_config() {
587 let temp_dir = TempDir::new().unwrap();
588 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
589
590 let config = RepoConfig {
591 version: "1.0".to_string(),
592 mount_dirs: MountDirs::default(),
593 requires: vec![RequiredMount {
594 remote: "git@github.com:test/repo.git".to_string(),
595 mount_path: "test".to_string(),
596 subpath: None,
597 description: "Test repository".to_string(),
598 optional: false,
599 override_rules: None,
600 sync: crate::config::SyncStrategy::Auto,
601 }],
602 rules: vec![],
603 };
604
605 manager.save(&config).unwrap();
607
608 let loaded = manager.load().unwrap().unwrap();
610
611 assert_eq!(loaded.version, config.version);
612 assert_eq!(loaded.requires.len(), config.requires.len());
613 assert_eq!(loaded.requires[0].remote, config.requires[0].remote);
614 }
615
616 #[test]
617 fn test_validation_invalid_version() {
618 let temp_dir = TempDir::new().unwrap();
619 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
620
621 let config = RepoConfig {
622 version: "2.0".to_string(), mount_dirs: MountDirs::default(),
624 requires: vec![],
625 rules: vec![],
626 };
627
628 assert!(manager.save(&config).is_err());
629 }
630
631 #[test]
632 fn test_validation_conflicting_mount_dirs() {
633 let temp_dir = TempDir::new().unwrap();
634 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
635
636 let config = RepoConfig {
637 version: "1.0".to_string(),
638 mount_dirs: MountDirs {
639 repository: "personal".to_string(), personal: "personal".to_string(),
641 },
642 requires: vec![],
643 rules: vec![],
644 };
645
646 assert!(manager.save(&config).is_err());
647 }
648
649 #[test]
650 fn test_validation_duplicate_mount_paths() {
651 let temp_dir = TempDir::new().unwrap();
652 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
653
654 let config = RepoConfig {
655 version: "1.0".to_string(),
656 mount_dirs: MountDirs::default(),
657 requires: vec![
658 RequiredMount {
659 remote: "git@github.com:test/repo1.git".to_string(),
660 mount_path: "test".to_string(),
661 subpath: None,
662 description: "Test 1".to_string(),
663 optional: false,
664 override_rules: None,
665 sync: crate::config::SyncStrategy::None,
666 },
667 RequiredMount {
668 remote: "git@github.com:test/repo2.git".to_string(),
669 mount_path: "test".to_string(), subpath: None,
671 description: "Test 2".to_string(),
672 optional: false,
673 override_rules: None,
674 sync: crate::config::SyncStrategy::None,
675 },
676 ],
677 rules: vec![],
678 };
679
680 assert!(manager.save(&config).is_err());
681 }
682
683 #[test]
684 fn test_validation_invalid_remote() {
685 let temp_dir = TempDir::new().unwrap();
686 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
687
688 let config = RepoConfig {
689 version: "1.0".to_string(),
690 mount_dirs: MountDirs::default(),
691 requires: vec![RequiredMount {
692 remote: "invalid-url".to_string(), mount_path: "test".to_string(),
694 subpath: None,
695 description: "Test".to_string(),
696 optional: false,
697 override_rules: None,
698 sync: crate::config::SyncStrategy::None,
699 }],
700 rules: vec![],
701 };
702
703 assert!(manager.save(&config).is_err());
704 }
705
706 #[test]
707 fn test_validation_local_mount() {
708 let temp_dir = TempDir::new().unwrap();
709 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
710
711 let config = RepoConfig {
712 version: "1.0".to_string(),
713 mount_dirs: MountDirs::default(),
714 requires: vec![RequiredMount {
715 remote: "./local/path".to_string(), mount_path: "local".to_string(),
717 subpath: None,
718 description: "Local mount".to_string(),
719 optional: false,
720 override_rules: None,
721 sync: crate::config::SyncStrategy::None,
722 }],
723 rules: vec![],
724 };
725
726 assert!(manager.save(&config).is_ok());
727 }
728
729 #[test]
730 fn test_add_and_remove_mount() {
731 let temp_dir = TempDir::new().unwrap();
732 let mut manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
733
734 let mount = RequiredMount {
736 remote: "git@github.com:test/repo.git".to_string(),
737 mount_path: "test".to_string(),
738 subpath: None,
739 description: "Test repository".to_string(),
740 optional: false,
741 override_rules: None,
742 sync: crate::config::SyncStrategy::Auto,
743 };
744
745 manager.add_mount(mount.clone()).unwrap();
746
747 let config = manager.load().unwrap().unwrap();
749 assert_eq!(config.requires.len(), 1);
750 assert_eq!(config.requires[0].mount_path, "test");
751
752 assert!(manager.remove_mount("test").unwrap());
754
755 let config = manager.load().unwrap().unwrap();
757 assert_eq!(config.requires.len(), 0);
758
759 assert!(!manager.remove_mount("test").unwrap());
761 }
762
763 #[test]
764 fn test_v1_to_desired_state_mapping() {
765 let temp_dir = TempDir::new().unwrap();
766 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
767
768 let v1_config = RepoConfig {
770 version: "1.0".to_string(),
771 mount_dirs: MountDirs {
772 repository: "context".to_string(),
773 personal: "personal".to_string(),
774 },
775 requires: vec![
776 RequiredMount {
777 remote: "git@github.com:user/context-repo.git".to_string(),
778 mount_path: "context-mount".to_string(),
779 subpath: Some("subdir".to_string()),
780 description: "Context mount".to_string(),
781 optional: false,
782 override_rules: None,
783 sync: crate::config::SyncStrategy::Auto,
784 },
785 RequiredMount {
786 remote: "git@github.com:org/ref-repo.git".to_string(),
787 mount_path: "references/ref-mount".to_string(),
788 subpath: None,
789 description: "Reference mount".to_string(),
790 optional: true,
791 override_rules: None,
792 sync: crate::config::SyncStrategy::None,
793 },
794 ],
795 rules: vec![],
796 };
797
798 manager.save(&v1_config).unwrap();
800
801 let desired_state = manager.load_desired_state().unwrap().unwrap();
803
804 assert!(desired_state.was_v1);
806 assert_eq!(desired_state.mount_dirs.context, "context");
807 assert_eq!(desired_state.mount_dirs.thoughts, "thoughts");
808 assert_eq!(desired_state.mount_dirs.references, "references");
809
810 assert_eq!(desired_state.context_mounts.len(), 1);
812 assert_eq!(
813 desired_state.context_mounts[0].remote,
814 "git@github.com:user/context-repo.git"
815 );
816 assert_eq!(desired_state.context_mounts[0].mount_path, "context-mount");
817 assert_eq!(
818 desired_state.context_mounts[0].subpath,
819 Some("subdir".to_string())
820 );
821
822 assert_eq!(desired_state.references.len(), 1);
824 assert_eq!(
825 desired_state.references[0].remote,
826 "git@github.com:org/ref-repo.git"
827 );
828 assert_eq!(
829 desired_state.references[0].description.as_deref(),
830 Some("Reference mount")
831 );
832
833 assert!(desired_state.thoughts_mount.is_none());
835 }
836
837 #[test]
838 fn test_v2_config_loading() {
839 let temp_dir = TempDir::new().unwrap();
840 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
841
842 let v2_config = crate::config::RepoConfigV2 {
844 version: "2.0".to_string(),
845 mount_dirs: crate::config::MountDirsV2::default(),
846 thoughts_mount: Some(crate::config::ThoughtsMount {
847 remote: "git@github.com:user/thoughts.git".to_string(),
848 subpath: None,
849 sync: crate::config::SyncStrategy::Auto,
850 }),
851 context_mounts: vec![crate::config::ContextMount {
852 remote: "git@github.com:user/context.git".to_string(),
853 subpath: Some("docs".to_string()),
854 mount_path: "docs".to_string(),
855 sync: crate::config::SyncStrategy::Auto,
856 }],
857 references: vec![
858 ReferenceEntry::Simple("git@github.com:org/ref1.git".to_string()),
859 ReferenceEntry::Simple("https://github.com/org/ref2.git".to_string()),
860 ],
861 };
862
863 let config_path = paths::get_repo_config_path(temp_dir.path());
865 std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
866 let json = serde_json::to_string_pretty(&v2_config).unwrap();
867 std::fs::write(&config_path, json).unwrap();
868
869 let desired_state = manager.load_desired_state().unwrap().unwrap();
871
872 assert!(!desired_state.was_v1);
874 assert!(desired_state.thoughts_mount.is_some());
875 assert_eq!(
876 desired_state.thoughts_mount.as_ref().unwrap().remote,
877 "git@github.com:user/thoughts.git"
878 );
879 assert_eq!(desired_state.context_mounts.len(), 1);
880 assert_eq!(desired_state.references.len(), 2);
881 }
882
883 #[test]
884 fn test_v2_references_normalize_to_reference_mount() {
885 let temp_dir = TempDir::new().unwrap();
886 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
887
888 let json = r#"{
889 "version": "2.0",
890 "mount_dirs": {},
891 "context_mounts": [],
892 "references": [
893 "git@github.com:org/ref1.git",
894 {"remote": "https://github.com/org/ref2.git", "description": "Ref 2"}
895 ]
896 }"#;
897
898 let config_path = paths::get_repo_config_path(temp_dir.path());
899 std::fs::create_dir_all(config_path.parent().unwrap()).unwrap();
900 std::fs::write(&config_path, json).unwrap();
901
902 let ds = manager.load_desired_state().unwrap().unwrap();
903 assert_eq!(ds.references.len(), 2);
904 assert_eq!(ds.references[0].remote, "git@github.com:org/ref1.git");
905 assert_eq!(ds.references[0].description, None);
906 assert_eq!(ds.references[1].remote, "https://github.com/org/ref2.git");
907 assert_eq!(ds.references[1].description.as_deref(), Some("Ref 2"));
908 }
909
910 #[test]
911 fn test_v1_migration_preserves_reference_descriptions() {
912 let temp_dir = TempDir::new().unwrap();
913 let manager = RepoConfigManager::new(temp_dir.path().to_path_buf());
914
915 let v1_config = RepoConfig {
917 version: "1.0".to_string(),
918 mount_dirs: MountDirs::default(),
919 requires: vec![RequiredMount {
920 remote: "git@github.com:org/ref-repo.git".to_string(),
921 mount_path: "references/ref-mount".to_string(),
922 subpath: None,
923 description: "Important reference repository".to_string(),
924 optional: true,
925 override_rules: None,
926 sync: crate::config::SyncStrategy::None,
927 }],
928 rules: vec![],
929 };
930
931 manager.save(&v1_config).unwrap();
933
934 let ds = manager.load_desired_state().unwrap().unwrap();
936
937 assert!(ds.was_v1);
939 assert_eq!(ds.references.len(), 1);
940 assert_eq!(ds.references[0].remote, "git@github.com:org/ref-repo.git");
941 assert_eq!(
942 ds.references[0].description.as_deref(),
943 Some("Important reference repository")
944 );
945 }
946
947 #[test]
948 fn test_validate_v2_soft_handles_both_variants() {
949 let temp_dir = tempfile::TempDir::new().unwrap();
950 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
951
952 let cfg = RepoConfigV2 {
953 version: "2.0".into(),
954 mount_dirs: MountDirsV2::default(),
955 thoughts_mount: None,
956 context_mounts: vec![],
957 references: vec![
958 ReferenceEntry::Simple("https://github.com/org/repo".into()),
959 ReferenceEntry::WithMetadata(ReferenceMount {
960 remote: "git@github.com:org/repo.git:docs".into(), description: None,
962 ref_name: None,
963 }),
964 ],
965 };
966
967 let warnings = mgr.validate_v2_soft(&cfg);
968 assert_eq!(warnings.len(), 1, "Expected one invalid reference warning");
969 assert!(warnings[0].contains("git@github.com:org/repo.git:docs"));
970 }
971
972 #[test]
973 fn test_peek_config_version_returns_none_when_no_config() {
974 let temp_dir = tempfile::TempDir::new().unwrap();
975 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
976 assert_eq!(mgr.peek_config_version().unwrap(), None);
977 }
978
979 #[test]
980 fn test_peek_config_version_returns_v1() {
981 let temp_dir = tempfile::TempDir::new().unwrap();
982 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
983 let v1_config = RepoConfig {
984 version: "1.0".to_string(),
985 mount_dirs: MountDirs::default(),
986 requires: vec![],
987 rules: vec![],
988 };
989 mgr.save(&v1_config).unwrap();
990 assert_eq!(mgr.peek_config_version().unwrap(), Some("1.0".to_string()));
991 }
992
993 #[test]
994 fn test_peek_config_version_returns_v2() {
995 let temp_dir = tempfile::TempDir::new().unwrap();
996 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
997 let v2_config = RepoConfigV2 {
998 version: "2.0".to_string(),
999 mount_dirs: MountDirsV2::default(),
1000 thoughts_mount: None,
1001 context_mounts: vec![],
1002 references: vec![],
1003 };
1004 mgr.save_v2(&v2_config).unwrap();
1005 assert_eq!(mgr.peek_config_version().unwrap(), Some("2.0".to_string()));
1006 }
1007
1008 #[test]
1009 fn test_validate_v2_hard_rejects_invalid_version() {
1010 let temp_dir = tempfile::TempDir::new().unwrap();
1011 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1012 let cfg = RepoConfigV2 {
1013 version: "3.0".to_string(),
1014 mount_dirs: MountDirsV2::default(),
1015 thoughts_mount: None,
1016 context_mounts: vec![],
1017 references: vec![],
1018 };
1019 let result = mgr.validate_v2_hard(&cfg);
1020 assert!(result.is_err());
1021 assert!(
1022 result
1023 .unwrap_err()
1024 .to_string()
1025 .contains("Unsupported configuration version: 3.0")
1026 );
1027 }
1028
1029 #[test]
1030 fn test_validate_v2_hard_rejects_pinned_ref_with_whitespace_or_trailing_slash() {
1031 let temp_dir = tempfile::TempDir::new().unwrap();
1032 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1033
1034 for bad_ref in [" refs/heads/main", "refs/heads/main ", "refs/heads/main/"] {
1035 let cfg = RepoConfigV2 {
1036 version: "2.0".to_string(),
1037 mount_dirs: MountDirsV2::default(),
1038 thoughts_mount: None,
1039 context_mounts: vec![],
1040 references: vec![ReferenceEntry::WithMetadata(ReferenceMount {
1041 remote: "https://github.com/org/repo".to_string(),
1042 description: None,
1043 ref_name: Some(bad_ref.to_string()),
1044 })],
1045 };
1046
1047 assert!(
1048 mgr.validate_v2_hard(&cfg).is_err(),
1049 "expected {bad_ref:?} to fail hard validation"
1050 );
1051 }
1052 }
1053
1054 #[test]
1055 fn test_validate_v2_hard_rejects_empty_mount_dirs() {
1056 let temp_dir = tempfile::TempDir::new().unwrap();
1057 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1058 let cfg = RepoConfigV2 {
1059 version: "2.0".to_string(),
1060 mount_dirs: MountDirsV2 {
1061 thoughts: "".to_string(),
1062 context: "context".to_string(),
1063 references: "references".to_string(),
1064 },
1065 thoughts_mount: None,
1066 context_mounts: vec![],
1067 references: vec![],
1068 };
1069 let result = mgr.validate_v2_hard(&cfg);
1070 assert!(result.is_err());
1071 assert!(result.unwrap_err().to_string().contains("cannot be empty"));
1072 }
1073
1074 #[test]
1075 fn test_validate_v2_hard_rejects_reserved_mount_dir_name() {
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: ".thoughts-data".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!(result.unwrap_err().to_string().contains(".thoughts-data"));
1092 }
1093
1094 #[test]
1095 fn test_validate_v2_hard_rejects_dot_mount_dirs() {
1096 let temp_dir = tempfile::TempDir::new().unwrap();
1097 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1098 let cfg = RepoConfigV2 {
1099 version: "2.0".to_string(),
1100 mount_dirs: MountDirsV2 {
1101 thoughts: ".".to_string(),
1102 context: "context".to_string(),
1103 references: "references".to_string(),
1104 },
1105 thoughts_mount: None,
1106 context_mounts: vec![],
1107 references: vec![],
1108 };
1109 let result = mgr.validate_v2_hard(&cfg);
1110 assert!(result.is_err());
1111 assert!(
1112 result
1113 .unwrap_err()
1114 .to_string()
1115 .contains("cannot be '.' or '..'")
1116 );
1117 }
1118
1119 #[test]
1120 fn test_validate_v2_hard_rejects_multi_segment_mount_dirs() {
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 {
1126 thoughts: "sub/path".to_string(),
1127 context: "context".to_string(),
1128 references: "references".to_string(),
1129 },
1130 thoughts_mount: None,
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("must be a single path segment")
1141 );
1142 }
1143
1144 #[test]
1145 fn test_validate_v2_hard_rejects_duplicate_mount_dirs() {
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 {
1151 thoughts: "same".to_string(),
1152 context: "same".to_string(),
1153 references: "references".to_string(),
1154 },
1155 thoughts_mount: None,
1156 context_mounts: vec![],
1157 references: vec![],
1158 };
1159 let result = mgr.validate_v2_hard(&cfg);
1160 assert!(result.is_err());
1161 assert!(result.unwrap_err().to_string().contains("must be distinct"));
1162 }
1163
1164 #[test]
1165 fn test_validate_v2_hard_rejects_invalid_thoughts_mount_remote() {
1166 let temp_dir = tempfile::TempDir::new().unwrap();
1167 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1168 let cfg = RepoConfigV2 {
1169 version: "2.0".to_string(),
1170 mount_dirs: MountDirsV2::default(),
1171 thoughts_mount: Some(ThoughtsMount {
1172 remote: "invalid-url".to_string(),
1173 subpath: None,
1174 sync: SyncStrategy::Auto,
1175 }),
1176 context_mounts: vec![],
1177 references: vec![],
1178 };
1179 let result = mgr.validate_v2_hard(&cfg);
1180 assert!(result.is_err());
1181 assert!(
1182 result
1183 .unwrap_err()
1184 .to_string()
1185 .contains("Invalid remote URL")
1186 );
1187 }
1188
1189 #[test]
1190 fn test_validate_v2_hard_rejects_duplicate_context_mount_path() {
1191 let temp_dir = tempfile::TempDir::new().unwrap();
1192 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1193 let cfg = RepoConfigV2 {
1194 version: "2.0".to_string(),
1195 mount_dirs: MountDirsV2::default(),
1196 thoughts_mount: None,
1197 context_mounts: vec![
1198 ContextMount {
1199 remote: "git@github.com:org/repo1.git".to_string(),
1200 subpath: None,
1201 mount_path: "same".to_string(),
1202 sync: SyncStrategy::Auto,
1203 },
1204 ContextMount {
1205 remote: "git@github.com:org/repo2.git".to_string(),
1206 subpath: None,
1207 mount_path: "same".to_string(),
1208 sync: SyncStrategy::Auto,
1209 },
1210 ],
1211 references: vec![],
1212 };
1213 let result = mgr.validate_v2_hard(&cfg);
1214 assert!(result.is_err());
1215 assert!(
1216 result
1217 .unwrap_err()
1218 .to_string()
1219 .contains("Duplicate context mount path")
1220 );
1221 }
1222
1223 #[test]
1224 fn test_validate_v2_hard_rejects_invalid_context_remote() {
1225 let temp_dir = tempfile::TempDir::new().unwrap();
1226 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1227 let cfg = RepoConfigV2 {
1228 version: "2.0".to_string(),
1229 mount_dirs: MountDirsV2::default(),
1230 thoughts_mount: None,
1231 context_mounts: vec![ContextMount {
1232 remote: "invalid-url".to_string(),
1233 subpath: None,
1234 mount_path: "mount1".to_string(),
1235 sync: SyncStrategy::Auto,
1236 }],
1237 references: vec![],
1238 };
1239 let result = mgr.validate_v2_hard(&cfg);
1240 assert!(result.is_err());
1241 assert!(
1242 result
1243 .unwrap_err()
1244 .to_string()
1245 .contains("Invalid remote URL")
1246 );
1247 }
1248
1249 #[test]
1250 fn test_validate_v2_hard_warns_on_sync_none() {
1251 let temp_dir = tempfile::TempDir::new().unwrap();
1252 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1253 let cfg = RepoConfigV2 {
1254 version: "2.0".to_string(),
1255 mount_dirs: MountDirsV2::default(),
1256 thoughts_mount: None,
1257 context_mounts: vec![ContextMount {
1258 remote: "git@github.com:org/repo.git".to_string(),
1259 subpath: None,
1260 mount_path: "mount1".to_string(),
1261 sync: SyncStrategy::None,
1262 }],
1263 references: vec![],
1264 };
1265 let result = mgr.validate_v2_hard(&cfg);
1266 assert!(result.is_ok());
1267 let warnings = result.unwrap();
1268 assert_eq!(warnings.len(), 1);
1269 assert!(warnings[0].contains("sync:None"));
1270 assert!(warnings[0].contains("discouraged"));
1271 }
1272
1273 #[test]
1274 fn test_validate_v2_hard_rejects_invalid_reference_url() {
1275 let temp_dir = tempfile::TempDir::new().unwrap();
1276 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1277 let cfg = RepoConfigV2 {
1278 version: "2.0".to_string(),
1279 mount_dirs: MountDirsV2::default(),
1280 thoughts_mount: None,
1281 context_mounts: vec![],
1282 references: vec![ReferenceEntry::Simple(
1283 "git@github.com:org/repo.git:subpath".to_string(),
1284 )],
1285 };
1286 let result = mgr.validate_v2_hard(&cfg);
1287 assert!(result.is_err());
1288 assert!(result.unwrap_err().to_string().contains("subpath"));
1289 }
1290
1291 #[test]
1292 fn test_validate_v2_hard_rejects_duplicate_references() {
1293 let temp_dir = tempfile::TempDir::new().unwrap();
1294 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1295 let cfg = RepoConfigV2 {
1296 version: "2.0".to_string(),
1297 mount_dirs: MountDirsV2::default(),
1298 thoughts_mount: None,
1299 context_mounts: vec![],
1300 references: vec![
1301 ReferenceEntry::Simple("git@github.com:Org/Repo.git".to_string()),
1302 ReferenceEntry::Simple("https://github.com/org/repo".to_string()),
1303 ],
1304 };
1305 let result = mgr.validate_v2_hard(&cfg);
1306 assert!(result.is_err());
1307 assert!(
1308 result
1309 .unwrap_err()
1310 .to_string()
1311 .contains("Duplicate reference")
1312 );
1313 }
1314
1315 #[test]
1316 fn test_validate_v2_hard_allows_same_repo_with_different_refs() {
1317 let temp_dir = tempfile::TempDir::new().unwrap();
1318 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1319 let cfg = RepoConfigV2 {
1320 version: "2.0".to_string(),
1321 mount_dirs: MountDirsV2::default(),
1322 thoughts_mount: None,
1323 context_mounts: vec![],
1324 references: vec![
1325 ReferenceEntry::WithMetadata(ReferenceMount {
1326 remote: "https://github.com/org/repo".to_string(),
1327 description: None,
1328 ref_name: Some("refs/heads/main".to_string()),
1329 }),
1330 ReferenceEntry::WithMetadata(ReferenceMount {
1331 remote: "git@github.com:Org/Repo.git".to_string(),
1332 description: None,
1333 ref_name: Some("refs/tags/v1.0.0".to_string()),
1334 }),
1335 ],
1336 };
1337
1338 let result = mgr.validate_v2_hard(&cfg);
1339 assert!(result.is_ok());
1340 }
1341
1342 #[test]
1343 fn test_validate_v2_hard_rejects_duplicate_references_with_same_ref() {
1344 let temp_dir = tempfile::TempDir::new().unwrap();
1345 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1346 let cfg = RepoConfigV2 {
1347 version: "2.0".to_string(),
1348 mount_dirs: MountDirsV2::default(),
1349 thoughts_mount: None,
1350 context_mounts: vec![],
1351 references: vec![
1352 ReferenceEntry::WithMetadata(ReferenceMount {
1353 remote: "https://github.com/org/repo".to_string(),
1354 description: None,
1355 ref_name: Some("refs/heads/main".to_string()),
1356 }),
1357 ReferenceEntry::WithMetadata(ReferenceMount {
1358 remote: "git@github.com:Org/Repo.git".to_string(),
1359 description: Some("duplicate".to_string()),
1360 ref_name: Some("refs/heads/main".to_string()),
1361 }),
1362 ],
1363 };
1364
1365 let result = mgr.validate_v2_hard(&cfg);
1366 assert!(result.is_err());
1367 assert!(
1368 result
1369 .unwrap_err()
1370 .to_string()
1371 .contains("Duplicate reference")
1372 );
1373 }
1374
1375 #[test]
1376 fn test_validate_v2_hard_rejects_duplicate_references_legacy_remotes_vs_heads() {
1377 let temp_dir = tempfile::TempDir::new().unwrap();
1378 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1379
1380 let cfg = RepoConfigV2 {
1381 version: "2.0".to_string(),
1382 mount_dirs: MountDirsV2::default(),
1383 thoughts_mount: None,
1384 context_mounts: vec![],
1385 references: vec![
1386 ReferenceEntry::WithMetadata(ReferenceMount {
1387 remote: "https://github.com/org/repo".to_string(),
1388 description: None,
1389 ref_name: Some("refs/remotes/origin/main".to_string()),
1390 }),
1391 ReferenceEntry::WithMetadata(ReferenceMount {
1392 remote: "git@github.com:Org/Repo.git".to_string(),
1393 description: None,
1394 ref_name: Some("refs/heads/main".to_string()),
1395 }),
1396 ],
1397 };
1398
1399 let err = mgr.validate_v2_hard(&cfg).unwrap_err();
1400 assert!(err.to_string().contains("Duplicate reference"));
1401 }
1402
1403 #[test]
1404 fn test_validate_v2_hard_rejects_shorthand_pinned_ref() {
1405 let temp_dir = tempfile::TempDir::new().unwrap();
1406 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1407
1408 let cfg = RepoConfigV2 {
1409 version: "2.0".to_string(),
1410 mount_dirs: MountDirsV2::default(),
1411 thoughts_mount: None,
1412 context_mounts: vec![],
1413 references: vec![ReferenceEntry::WithMetadata(ReferenceMount {
1414 remote: "https://github.com/org/repo".to_string(),
1415 description: None,
1416 ref_name: Some("main".to_string()),
1417 })],
1418 };
1419
1420 let err = mgr.validate_v2_hard(&cfg).unwrap_err();
1421 assert!(
1422 format!("{err:#}").contains("Pinned refs must be full ref names"),
1423 "unexpected error chain: {err:#}"
1424 );
1425 }
1426
1427 #[test]
1428 fn test_validate_v2_hard_rejects_incomplete_pinned_ref_prefix() {
1429 let temp_dir = tempfile::TempDir::new().unwrap();
1430 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1431
1432 let cfg = RepoConfigV2 {
1433 version: "2.0".to_string(),
1434 mount_dirs: MountDirsV2::default(),
1435 thoughts_mount: None,
1436 context_mounts: vec![],
1437 references: vec![ReferenceEntry::WithMetadata(ReferenceMount {
1438 remote: "https://github.com/org/repo".to_string(),
1439 description: None,
1440 ref_name: Some("refs/heads/".to_string()),
1441 })],
1442 };
1443
1444 assert!(mgr.validate_v2_hard(&cfg).is_err());
1445 }
1446
1447 #[test]
1448 fn test_validate_v2_hard_warns_on_legacy_refs_remotes() {
1449 let temp_dir = tempfile::TempDir::new().unwrap();
1450 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1451
1452 let cfg = RepoConfigV2 {
1453 version: "2.0".to_string(),
1454 mount_dirs: MountDirsV2::default(),
1455 thoughts_mount: None,
1456 context_mounts: vec![],
1457 references: vec![ReferenceEntry::WithMetadata(ReferenceMount {
1458 remote: "https://github.com/org/repo".to_string(),
1459 description: None,
1460 ref_name: Some("refs/remotes/origin/main".to_string()),
1461 })],
1462 };
1463
1464 let warnings = mgr
1465 .validate_v2_hard(&cfg)
1466 .expect("legacy refs/remotes should warn");
1467 assert_eq!(warnings.len(), 1);
1468 assert!(warnings[0].contains("refs/remotes/origin/main"));
1469 assert!(warnings[0].contains("legacy pinned ref"));
1470 }
1471
1472 #[test]
1473 fn test_validate_v2_hard_accepts_valid_config() {
1474 let temp_dir = tempfile::TempDir::new().unwrap();
1475 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1476 let cfg = RepoConfigV2 {
1477 version: "2.0".to_string(),
1478 mount_dirs: MountDirsV2::default(),
1479 thoughts_mount: Some(ThoughtsMount {
1480 remote: "git@github.com:user/thoughts.git".to_string(),
1481 subpath: None,
1482 sync: SyncStrategy::Auto,
1483 }),
1484 context_mounts: vec![ContextMount {
1485 remote: "git@github.com:org/context.git".to_string(),
1486 subpath: Some("docs".to_string()),
1487 mount_path: "docs".to_string(),
1488 sync: SyncStrategy::Auto,
1489 }],
1490 references: vec![
1491 ReferenceEntry::Simple("git@github.com:org/repo1.git".to_string()),
1492 ReferenceEntry::WithMetadata(ReferenceMount {
1493 remote: "https://github.com/org/repo2".to_string(),
1494 description: Some("Reference 2".to_string()),
1495 ref_name: None,
1496 }),
1497 ],
1498 };
1499 let result = mgr.validate_v2_hard(&cfg);
1500 assert!(result.is_ok());
1501 let warnings = result.unwrap();
1502 assert_eq!(warnings.len(), 0);
1503 }
1504
1505 #[test]
1506 fn test_save_v2_validated_fails_before_write_on_invalid() {
1507 let temp_dir = tempfile::TempDir::new().unwrap();
1508 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1509 let cfg = RepoConfigV2 {
1510 version: "2.0".to_string(),
1511 mount_dirs: MountDirsV2 {
1512 thoughts: "same".to_string(),
1513 context: "same".to_string(),
1514 references: "references".to_string(),
1515 },
1516 thoughts_mount: None,
1517 context_mounts: vec![],
1518 references: vec![],
1519 };
1520
1521 let result = mgr.save_v2_validated(&cfg);
1522 assert!(result.is_err());
1523
1524 let config_path = paths::get_repo_config_path(temp_dir.path());
1526 assert!(!config_path.exists());
1527 }
1528
1529 #[test]
1530 fn test_save_v2_validated_returns_warnings_on_valid() {
1531 let temp_dir = tempfile::TempDir::new().unwrap();
1532 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1533 let cfg = RepoConfigV2 {
1534 version: "2.0".to_string(),
1535 mount_dirs: MountDirsV2::default(),
1536 thoughts_mount: None,
1537 context_mounts: vec![ContextMount {
1538 remote: "git@github.com:org/repo.git".to_string(),
1539 subpath: None,
1540 mount_path: "mount1".to_string(),
1541 sync: SyncStrategy::None,
1542 }],
1543 references: vec![],
1544 };
1545
1546 let result = mgr.save_v2_validated(&cfg);
1547 assert!(result.is_ok());
1548 let warnings = result.unwrap();
1549 assert_eq!(warnings.len(), 1);
1550 assert!(warnings[0].contains("sync:None"));
1551
1552 let config_path = paths::get_repo_config_path(temp_dir.path());
1554 assert!(config_path.exists());
1555 }
1556
1557 #[test]
1558 fn test_ensure_v2_default_migrates_v1_without_panic() {
1559 let temp_dir = tempfile::TempDir::new().unwrap();
1560 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1561
1562 let v1_config = RepoConfig {
1564 version: "1.0".to_string(),
1565 mount_dirs: MountDirs {
1566 repository: "mycontext".to_string(),
1567 personal: "personal".to_string(),
1568 },
1569 requires: vec![
1570 RequiredMount {
1571 remote: "git@github.com:org/context-repo.git".to_string(),
1572 mount_path: "docs".to_string(),
1573 subpath: Some("content".to_string()),
1574 description: "Documentation".to_string(),
1575 optional: false,
1576 override_rules: None,
1577 sync: SyncStrategy::Auto,
1578 },
1579 RequiredMount {
1580 remote: "git@github.com:org/ref-repo.git".to_string(),
1581 mount_path: "references/ref".to_string(),
1582 subpath: None,
1583 description: "Reference repo".to_string(),
1584 optional: true,
1585 override_rules: None,
1586 sync: SyncStrategy::None,
1587 },
1588 ],
1589 rules: vec![],
1590 };
1591
1592 mgr.save(&v1_config).unwrap();
1594
1595 let v2_config = mgr.ensure_v2_default().unwrap();
1597
1598 assert_eq!(v2_config.version, "2.0");
1600 assert_eq!(v2_config.mount_dirs.context, "mycontext");
1601 assert_eq!(v2_config.context_mounts.len(), 1);
1602 assert_eq!(v2_config.context_mounts[0].mount_path, "docs");
1603 assert_eq!(
1604 v2_config.context_mounts[0].subpath,
1605 Some("content".to_string())
1606 );
1607 assert_eq!(v2_config.references.len(), 1);
1608
1609 let ds = mgr.load_desired_state().unwrap().unwrap();
1611 assert!(!ds.was_v1); assert_eq!(ds.context_mounts.len(), 1);
1613 assert_eq!(ds.references.len(), 1);
1614 }
1615
1616 #[test]
1617 fn test_validate_v2_hard_rejects_empty_context_mount_path() {
1618 let temp_dir = tempfile::TempDir::new().unwrap();
1619 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1620 let cfg = RepoConfigV2 {
1621 version: "2.0".to_string(),
1622 mount_dirs: MountDirsV2::default(),
1623 thoughts_mount: None,
1624 context_mounts: vec![ContextMount {
1625 remote: "git@github.com:org/repo.git".to_string(),
1626 subpath: None,
1627 mount_path: " ".to_string(), sync: SyncStrategy::Auto,
1629 }],
1630 references: vec![],
1631 };
1632 let result = mgr.validate_v2_hard(&cfg);
1633 assert!(result.is_err());
1634 assert!(result.unwrap_err().to_string().contains("cannot be empty"));
1635 }
1636
1637 #[test]
1638 fn test_validate_v2_hard_rejects_dot_context_mount_path() {
1639 let temp_dir = tempfile::TempDir::new().unwrap();
1640 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1641 let cfg = RepoConfigV2 {
1642 version: "2.0".to_string(),
1643 mount_dirs: MountDirsV2::default(),
1644 thoughts_mount: None,
1645 context_mounts: vec![ContextMount {
1646 remote: "git@github.com:org/repo.git".to_string(),
1647 subpath: None,
1648 mount_path: ".".to_string(),
1649 sync: SyncStrategy::Auto,
1650 }],
1651 references: vec![],
1652 };
1653 let result = mgr.validate_v2_hard(&cfg);
1654 assert!(result.is_err());
1655 assert!(
1656 result
1657 .unwrap_err()
1658 .to_string()
1659 .contains("cannot be '.' or '..'")
1660 );
1661 }
1662
1663 #[test]
1664 fn test_validate_v2_hard_rejects_dotdot_context_mount_path() {
1665 let temp_dir = tempfile::TempDir::new().unwrap();
1666 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1667 let cfg = RepoConfigV2 {
1668 version: "2.0".to_string(),
1669 mount_dirs: MountDirsV2::default(),
1670 thoughts_mount: None,
1671 context_mounts: vec![ContextMount {
1672 remote: "git@github.com:org/repo.git".to_string(),
1673 subpath: None,
1674 mount_path: "..".to_string(),
1675 sync: SyncStrategy::Auto,
1676 }],
1677 references: vec![],
1678 };
1679 let result = mgr.validate_v2_hard(&cfg);
1680 assert!(result.is_err());
1681 assert!(
1682 result
1683 .unwrap_err()
1684 .to_string()
1685 .contains("cannot be '.' or '..'")
1686 );
1687 }
1688
1689 #[test]
1690 fn test_validate_v2_hard_rejects_slash_in_context_mount_path() {
1691 let temp_dir = tempfile::TempDir::new().unwrap();
1692 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1693 let cfg = RepoConfigV2 {
1694 version: "2.0".to_string(),
1695 mount_dirs: MountDirsV2::default(),
1696 thoughts_mount: None,
1697 context_mounts: vec![ContextMount {
1698 remote: "git@github.com:org/repo.git".to_string(),
1699 subpath: None,
1700 mount_path: "sub/path".to_string(),
1701 sync: SyncStrategy::Auto,
1702 }],
1703 references: vec![],
1704 };
1705 let result = mgr.validate_v2_hard(&cfg);
1706 assert!(result.is_err());
1707 assert!(
1708 result
1709 .unwrap_err()
1710 .to_string()
1711 .contains("single path segment")
1712 );
1713 }
1714
1715 #[test]
1716 fn test_validate_v2_hard_rejects_backslash_in_context_mount_path() {
1717 let temp_dir = tempfile::TempDir::new().unwrap();
1718 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1719 let cfg = RepoConfigV2 {
1720 version: "2.0".to_string(),
1721 mount_dirs: MountDirsV2::default(),
1722 thoughts_mount: None,
1723 context_mounts: vec![ContextMount {
1724 remote: "git@github.com:org/repo.git".to_string(),
1725 subpath: None,
1726 mount_path: "sub\\path".to_string(),
1727 sync: SyncStrategy::Auto,
1728 }],
1729 references: vec![],
1730 };
1731 let result = mgr.validate_v2_hard(&cfg);
1732 assert!(result.is_err());
1733 assert!(
1734 result
1735 .unwrap_err()
1736 .to_string()
1737 .contains("single path segment")
1738 );
1739 }
1740
1741 #[test]
1742 fn test_validate_v2_hard_accepts_valid_context_mount_path() {
1743 let temp_dir = tempfile::TempDir::new().unwrap();
1744 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1745 let cfg = RepoConfigV2 {
1746 version: "2.0".to_string(),
1747 mount_dirs: MountDirsV2::default(),
1748 thoughts_mount: None,
1749 context_mounts: vec![ContextMount {
1750 remote: "git@github.com:org/repo.git".to_string(),
1751 subpath: None,
1752 mount_path: "docs".to_string(),
1753 sync: SyncStrategy::Auto,
1754 }],
1755 references: vec![],
1756 };
1757 let result = mgr.validate_v2_hard(&cfg);
1758 assert!(result.is_ok());
1759 let warnings = result.unwrap();
1760 assert_eq!(warnings.len(), 0);
1761 }
1762
1763 #[test]
1764 fn test_new_makes_absolute_when_given_relative_repo_root() {
1765 let temp_dir = TempDir::new().unwrap();
1766 let cwd_before = std::env::current_dir().unwrap();
1767
1768 std::env::set_current_dir(temp_dir.path()).unwrap();
1770
1771 std::fs::create_dir_all("repo").unwrap();
1773
1774 let mgr = RepoConfigManager::new(PathBuf::from("repo"));
1775
1776 assert!(mgr.peek_config_version().is_ok());
1780
1781 std::env::set_current_dir(cwd_before).unwrap();
1783 }
1784
1785 #[test]
1792 fn test_validate_v2_hard_rejects_trailing_slash_in_mount_dirs() {
1793 let temp_dir = tempfile::TempDir::new().unwrap();
1794 let mgr = RepoConfigManager::new(temp_dir.path().to_path_buf());
1795
1796 let cfg = RepoConfigV2 {
1798 version: "2.0".to_string(),
1799 mount_dirs: MountDirsV2 {
1800 thoughts: "thoughts/".to_string(),
1801 context: "context".to_string(),
1802 references: "references".to_string(),
1803 },
1804 thoughts_mount: None,
1805 context_mounts: vec![],
1806 references: vec![],
1807 };
1808 let result = mgr.validate_v2_hard(&cfg);
1809 assert!(
1810 result.is_err(),
1811 "trailing slash on thoughts should be rejected"
1812 );
1813 assert!(
1814 result
1815 .unwrap_err()
1816 .to_string()
1817 .contains("single path segment"),
1818 "error should mention single path segment requirement"
1819 );
1820
1821 let cfg = RepoConfigV2 {
1823 version: "2.0".to_string(),
1824 mount_dirs: MountDirsV2 {
1825 thoughts: "thoughts".to_string(),
1826 context: "context/".to_string(),
1827 references: "references".to_string(),
1828 },
1829 thoughts_mount: None,
1830 context_mounts: vec![],
1831 references: vec![],
1832 };
1833 let result = mgr.validate_v2_hard(&cfg);
1834 assert!(
1835 result.is_err(),
1836 "trailing slash on context should be rejected"
1837 );
1838
1839 let cfg = RepoConfigV2 {
1841 version: "2.0".to_string(),
1842 mount_dirs: MountDirsV2 {
1843 thoughts: "thoughts".to_string(),
1844 context: "context".to_string(),
1845 references: "references/".to_string(),
1846 },
1847 thoughts_mount: None,
1848 context_mounts: vec![],
1849 references: vec![],
1850 };
1851 let result = mgr.validate_v2_hard(&cfg);
1852 assert!(
1853 result.is_err(),
1854 "trailing slash on references should be rejected"
1855 );
1856 }
1857}