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