1use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::agents::AgentId;
9use crate::error::RepographError;
10
11pub const CONFIG_FILE_NAME: &str = "config.toml";
13
14pub const MAX_WORKSPACE_NAME_LEN: usize = 63;
16
17pub const RESERVED_WORKSPACE_NAMES: &[&str] = &["default", "all", "none"];
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24pub struct Repo {
25 pub path: PathBuf,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
27 pub description: Option<String>,
28 #[serde(default, skip_serializing_if = "Vec::is_empty")]
29 pub stack: Vec<String>,
30}
31
32#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct Workspace {
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub description: Option<String>,
41 #[serde(default)]
42 pub members: Vec<String>,
43}
44
45pub type WorkspaceResolution<'a> = (Vec<(&'a String, &'a Repo)>, Vec<&'a String>);
49
50#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct Agents {
58 #[serde(default)]
59 pub selected: Vec<AgentId>,
60}
61
62#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
69pub struct Settings {
70 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub projects_root: Option<PathBuf>,
77}
78
79#[derive(Debug, Default, Clone, Serialize, Deserialize)]
82pub struct Config {
83 #[serde(default, rename = "repo", skip_serializing_if = "BTreeMap::is_empty")]
84 repos: BTreeMap<String, Repo>,
85 #[serde(
86 default,
87 rename = "workspace",
88 skip_serializing_if = "BTreeMap::is_empty"
89 )]
90 workspaces: BTreeMap<String, Workspace>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
95 agents: Option<Agents>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
99 settings: Option<Settings>,
100}
101
102impl Config {
103 #[must_use]
105 pub const fn repos(&self) -> &BTreeMap<String, Repo> {
106 &self.repos
107 }
108
109 #[must_use]
111 pub const fn workspaces(&self) -> &BTreeMap<String, Workspace> {
112 &self.workspaces
113 }
114
115 #[must_use]
119 pub const fn agents(&self) -> Option<&Agents> {
120 self.agents.as_ref()
121 }
122
123 pub fn set_agents(&mut self, agents: Option<Agents>) {
128 self.agents = agents;
129 }
130
131 #[must_use]
135 pub const fn settings(&self) -> Option<&Settings> {
136 self.settings.as_ref()
137 }
138
139 pub fn set_settings(&mut self, settings: Option<Settings>) {
141 self.settings = settings;
142 }
143
144 #[must_use]
148 pub fn default_dir() -> Option<PathBuf> {
149 dirs::config_dir().map(|d| d.join("repograph"))
150 }
151
152 pub fn load(dir: &Path) -> Result<Self, RepographError> {
160 let path = dir.join(CONFIG_FILE_NAME);
161 match fs_err::read_to_string(&path) {
162 Ok(body) => Ok(toml::from_str(&body)?),
163 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
164 Err(e) => Err(e.into()),
165 }
166 }
167
168 pub fn save(&self, dir: &Path) -> Result<(), RepographError> {
179 let body = toml::to_string_pretty(self)?;
180 let target = dir.join(CONFIG_FILE_NAME);
181
182 if let Err(e) = fs_err::create_dir_all(dir) {
183 return Err(map_io_to_perm(e, dir));
184 }
185
186 let tmp = dir.join(format!(".{CONFIG_FILE_NAME}.tmp"));
187 if let Err(e) = fs_err::write(&tmp, body.as_bytes()) {
188 return Err(map_io_to_perm(e, &tmp));
189 }
190 if let Err(e) = fs_err::rename(&tmp, &target) {
191 return Err(map_io_to_perm(e, &target));
192 }
193 Ok(())
194 }
195
196 pub fn add_repo(&mut self, name: String, repo: Repo) -> Result<(), RepographError> {
204 if self.repos.contains_key(&name) {
205 return Err(RepographError::Conflict { kind: "name", name });
206 }
207 if let Some((existing_name, _)) = self.repos.iter().find(|(_, r)| r.path == repo.path) {
208 return Err(RepographError::Conflict {
209 kind: "path",
210 name: existing_name.clone(),
211 });
212 }
213 self.repos.insert(name, repo);
214 Ok(())
215 }
216
217 pub fn remove_repo(&mut self, name: &str) -> Result<Repo, RepographError> {
223 self.repos
224 .remove(name)
225 .ok_or_else(|| RepographError::NotFound {
226 kind: "repo",
227 name: name.to_string(),
228 })
229 }
230
231 pub fn create_workspace(
242 &mut self,
243 name: String,
244 description: Option<String>,
245 ) -> Result<(), RepographError> {
246 validate_workspace_name(&name)?;
247 if self.workspaces.contains_key(&name) {
248 return Err(RepographError::Conflict {
249 kind: "workspace",
250 name,
251 });
252 }
253 self.workspaces.insert(
254 name,
255 Workspace {
256 description: description.filter(|s| !s.is_empty()),
257 members: Vec::new(),
258 },
259 );
260 Ok(())
261 }
262
263 pub fn remove_workspace(&mut self, name: &str) -> Result<Workspace, RepographError> {
270 self.workspaces
271 .remove(name)
272 .ok_or_else(|| RepographError::NotFound {
273 kind: "workspace",
274 name: name.to_string(),
275 })
276 }
277
278 pub fn add_members(&mut self, workspace: &str, repos: &[String]) -> Result<(), RepographError> {
291 if !self.workspaces.contains_key(workspace) {
294 return Err(RepographError::NotFound {
295 kind: "workspace",
296 name: workspace.to_string(),
297 });
298 }
299 for name in repos {
300 if !self.repos.contains_key(name) {
301 return Err(RepographError::NotFound {
302 kind: "repo",
303 name: name.clone(),
304 });
305 }
306 }
307 let ws = self
310 .workspaces
311 .get_mut(workspace)
312 .ok_or_else(|| RepographError::NotFound {
313 kind: "workspace",
314 name: workspace.to_string(),
315 })?;
316 for name in repos {
317 ws.members.push(name.clone());
318 }
319 ws.members.sort();
320 ws.members.dedup();
321 Ok(())
322 }
323
324 pub fn remove_members(
332 &mut self,
333 workspace: &str,
334 repos: &[String],
335 ) -> Result<(), RepographError> {
336 let ws = self
337 .workspaces
338 .get_mut(workspace)
339 .ok_or_else(|| RepographError::NotFound {
340 kind: "workspace",
341 name: workspace.to_string(),
342 })?;
343 ws.members.retain(|m| !repos.iter().any(|r| r == m));
344 Ok(())
345 }
346
347 pub fn resolve_workspace<'a>(
358 &'a self,
359 workspace: &str,
360 ) -> Result<WorkspaceResolution<'a>, RepographError> {
361 let ws = self
362 .workspaces
363 .get(workspace)
364 .ok_or_else(|| RepographError::NotFound {
365 kind: "workspace",
366 name: workspace.to_string(),
367 })?;
368 let mut live = Vec::with_capacity(ws.members.len());
369 let mut dangling = Vec::new();
370 for name in &ws.members {
371 if let Some((key, repo)) = self.repos.get_key_value(name) {
372 live.push((key, repo));
373 } else {
374 dangling.push(name);
375 }
376 }
377 Ok((live, dangling))
378 }
379}
380
381pub fn validate_workspace_name(name: &str) -> Result<(), RepographError> {
390 if name.is_empty() {
391 return Err(invalid_workspace_name(name, "must not be empty"));
392 }
393 if name.len() > MAX_WORKSPACE_NAME_LEN {
394 return Err(invalid_workspace_name(
395 name,
396 "must be at most 63 characters",
397 ));
398 }
399 if RESERVED_WORKSPACE_NAMES.contains(&name) {
400 return Err(invalid_workspace_name(name, "is a reserved name"));
401 }
402 for (i, c) in name.chars().enumerate() {
403 let alnum_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
404 if i == 0 {
405 if !alnum_lower {
406 return Err(invalid_workspace_name(
407 name,
408 "must start with a lowercase letter or digit",
409 ));
410 }
411 } else if !alnum_lower && c != '-' {
412 return Err(invalid_workspace_name(
413 name,
414 "must contain only lowercase letters, digits, and hyphens",
415 ));
416 }
417 }
418 Ok(())
419}
420
421fn invalid_workspace_name(name: &str, reason: &'static str) -> RepographError {
422 RepographError::InvalidName {
423 kind: "workspace",
424 name: name.to_string(),
425 reason,
426 }
427}
428
429fn map_io_to_perm(e: std::io::Error, path: &Path) -> RepographError {
432 if e.kind() == std::io::ErrorKind::PermissionDenied {
433 RepographError::PermissionDenied {
434 path: path.to_path_buf(),
435 }
436 } else {
437 RepographError::Io(e)
438 }
439}
440
441#[cfg(test)]
442mod tests {
443 #![allow(clippy::unwrap_used, clippy::expect_used)]
444 use super::*;
445 use tempfile::TempDir;
446
447 fn make(path: &str) -> Repo {
448 Repo {
449 path: PathBuf::from(path),
450 description: None,
451 stack: vec![],
452 }
453 }
454
455 #[test]
456 fn load_missing_returns_empty() {
457 let tmp = TempDir::new().unwrap();
458 let cfg = Config::load(tmp.path()).unwrap();
459 assert!(cfg.repos.is_empty());
460 }
461
462 #[test]
463 fn save_then_load_round_trip() {
464 let tmp = TempDir::new().unwrap();
465 let mut cfg = Config::default();
466 cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
467 cfg.add_repo(
468 "bar".into(),
469 Repo {
470 path: PathBuf::from("/tmp/bar"),
471 description: Some("hi".into()),
472 stack: vec!["rust".into()],
473 },
474 )
475 .unwrap();
476
477 cfg.save(tmp.path()).unwrap();
478 let loaded = Config::load(tmp.path()).unwrap();
479 assert_eq!(loaded.repos.len(), 2);
480 assert_eq!(
481 loaded.repos.get("bar").unwrap().description.as_deref(),
482 Some("hi")
483 );
484 }
485
486 #[test]
487 fn name_conflict_blocks_insert() {
488 let mut cfg = Config::default();
489 cfg.add_repo("foo".into(), make("/a")).unwrap();
490 let err = cfg.add_repo("foo".into(), make("/b")).unwrap_err();
491 assert!(matches!(err, RepographError::Conflict { kind: "name", .. }));
492 assert_eq!(cfg.repos.get("foo").unwrap().path, PathBuf::from("/a"));
493 }
494
495 #[test]
496 fn path_conflict_blocks_insert() {
497 let mut cfg = Config::default();
498 cfg.add_repo("foo".into(), make("/shared")).unwrap();
499 let err = cfg.add_repo("bar".into(), make("/shared")).unwrap_err();
500 assert!(matches!(err, RepographError::Conflict { kind: "path", .. }));
501 assert!(!cfg.repos.contains_key("bar"));
502 }
503
504 #[test]
505 fn remove_missing_returns_not_found() {
506 let mut cfg = Config::default();
507 let err = cfg.remove_repo("ghost").unwrap_err();
508 assert!(matches!(err, RepographError::NotFound { .. }));
509 }
510
511 #[test]
512 fn unknown_field_in_toml_is_tolerated() {
513 let tmp = TempDir::new().unwrap();
514 std::fs::create_dir_all(tmp.path()).unwrap();
515 std::fs::write(
516 tmp.path().join(CONFIG_FILE_NAME),
517 "[repo.foo]\npath = \"/tmp/foo\"\nfuture = \"yes\"\n",
518 )
519 .unwrap();
520 let cfg = Config::load(tmp.path()).unwrap();
521 assert!(cfg.repos.contains_key("foo"));
522 }
523
524 #[test]
527 fn validate_workspace_name_accepts_simple_lowercase() {
528 assert!(validate_workspace_name("acme").is_ok());
529 assert!(validate_workspace_name("acme-rebuild-2026").is_ok());
530 assert!(validate_workspace_name("a").is_ok());
531 assert!(validate_workspace_name("0").is_ok());
532 assert!(validate_workspace_name("0acme").is_ok());
533 }
534
535 #[test]
536 fn validate_workspace_name_rejects_empty() {
537 let err = validate_workspace_name("").unwrap_err();
538 assert!(matches!(err, RepographError::InvalidName { .. }));
539 assert_eq!(err.exit_code(), 2);
540 }
541
542 #[test]
543 fn validate_workspace_name_rejects_uppercase() {
544 let err = validate_workspace_name("AcmeRebuild").unwrap_err();
545 assert!(matches!(err, RepographError::InvalidName { .. }));
546 }
547
548 #[test]
549 fn validate_workspace_name_rejects_leading_hyphen() {
550 let err = validate_workspace_name("-acme").unwrap_err();
551 assert!(matches!(err, RepographError::InvalidName { .. }));
552 }
553
554 #[test]
555 fn validate_workspace_name_rejects_underscore() {
556 let err = validate_workspace_name("ac_me").unwrap_err();
557 assert!(matches!(err, RepographError::InvalidName { .. }));
558 }
559
560 #[test]
561 fn validate_workspace_name_rejects_spaces() {
562 let err = validate_workspace_name("ac me").unwrap_err();
563 assert!(matches!(err, RepographError::InvalidName { .. }));
564 }
565
566 #[test]
567 fn validate_workspace_name_rejects_overlength() {
568 let name = "a".repeat(MAX_WORKSPACE_NAME_LEN + 1);
569 let err = validate_workspace_name(&name).unwrap_err();
570 assert!(matches!(err, RepographError::InvalidName { .. }));
571 }
572
573 #[test]
574 fn validate_workspace_name_accepts_exact_max_length() {
575 let name = "a".repeat(MAX_WORKSPACE_NAME_LEN);
576 assert!(validate_workspace_name(&name).is_ok());
577 }
578
579 #[test]
580 fn validate_workspace_name_rejects_reserved_words() {
581 for reserved in RESERVED_WORKSPACE_NAMES {
582 let err = validate_workspace_name(reserved).unwrap_err();
583 assert!(
584 matches!(err, RepographError::InvalidName { .. }),
585 "reserved `{reserved}` must be rejected"
586 );
587 }
588 }
589
590 #[test]
591 fn create_workspace_inserts_empty_entry() {
592 let mut cfg = Config::default();
593 cfg.create_workspace("acme".into(), None).unwrap();
594 let ws = cfg.workspaces.get("acme").unwrap();
595 assert!(ws.description.is_none());
596 assert!(ws.members.is_empty());
597 }
598
599 #[test]
600 fn create_workspace_persists_description() {
601 let mut cfg = Config::default();
602 cfg.create_workspace("acme".into(), Some("rebuild".into()))
603 .unwrap();
604 assert_eq!(
605 cfg.workspaces.get("acme").unwrap().description.as_deref(),
606 Some("rebuild")
607 );
608 }
609
610 #[test]
611 fn create_workspace_conflict_returns_conflict() {
612 let mut cfg = Config::default();
613 cfg.create_workspace("acme".into(), None).unwrap();
614 let err = cfg.create_workspace("acme".into(), None).unwrap_err();
615 assert!(matches!(
616 err,
617 RepographError::Conflict {
618 kind: "workspace",
619 ..
620 }
621 ));
622 assert_eq!(err.exit_code(), 5);
623 }
624
625 #[test]
626 fn create_workspace_invalid_name_returns_invalid_name() {
627 let mut cfg = Config::default();
628 let err = cfg.create_workspace("Bad Name".into(), None).unwrap_err();
629 assert!(matches!(err, RepographError::InvalidName { .. }));
630 assert_eq!(err.exit_code(), 2);
631 assert!(cfg.workspaces.is_empty());
632 }
633
634 #[test]
635 fn remove_workspace_missing_returns_not_found() {
636 let mut cfg = Config::default();
637 let err = cfg.remove_workspace("ghost").unwrap_err();
638 assert!(matches!(
639 err,
640 RepographError::NotFound {
641 kind: "workspace",
642 ..
643 }
644 ));
645 assert_eq!(err.exit_code(), 3);
646 }
647
648 #[test]
649 fn remove_workspace_does_not_touch_repos() {
650 let mut cfg = Config::default();
651 cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
652 cfg.create_workspace("acme".into(), None).unwrap();
653 cfg.add_members("acme", &["api".into()]).unwrap();
654 cfg.remove_workspace("acme").unwrap();
655 assert!(cfg.repos.contains_key("api"));
656 assert!(!cfg.workspaces.contains_key("acme"));
657 }
658
659 #[test]
660 fn add_members_atomic_when_one_repo_missing() {
661 let mut cfg = Config::default();
662 cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
663 cfg.add_repo("ui".into(), make("/tmp/ui")).unwrap();
664 cfg.create_workspace("acme".into(), None).unwrap();
665 let err = cfg
666 .add_members("acme", &["api".into(), "ghost".into(), "ui".into()])
667 .unwrap_err();
668 assert!(matches!(
669 err,
670 RepographError::NotFound { kind: "repo", ref name } if name == "ghost"
671 ));
672 assert!(cfg.workspaces.get("acme").unwrap().members.is_empty());
674 }
675
676 #[test]
677 fn add_members_sorts_and_deduplicates() {
678 let mut cfg = Config::default();
679 cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
680 cfg.add_repo("ui".into(), make("/tmp/ui")).unwrap();
681 cfg.add_repo("libs".into(), make("/tmp/libs")).unwrap();
682 cfg.create_workspace("acme".into(), None).unwrap();
683 cfg.add_members("acme", &["ui".into(), "api".into(), "libs".into()])
684 .unwrap();
685 assert_eq!(
686 cfg.workspaces.get("acme").unwrap().members,
687 vec!["api", "libs", "ui"]
688 );
689 cfg.add_members("acme", &["api".into()]).unwrap();
691 assert_eq!(
692 cfg.workspaces.get("acme").unwrap().members,
693 vec!["api", "libs", "ui"]
694 );
695 }
696
697 #[test]
698 fn add_members_missing_workspace_returns_not_found() {
699 let mut cfg = Config::default();
700 cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
701 let err = cfg.add_members("ghost", &["api".into()]).unwrap_err();
702 assert!(matches!(
703 err,
704 RepographError::NotFound {
705 kind: "workspace",
706 ..
707 }
708 ));
709 }
710
711 #[test]
712 fn remove_members_is_idempotent_for_non_members() {
713 let mut cfg = Config::default();
714 cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
715 cfg.create_workspace("acme".into(), None).unwrap();
716 cfg.add_members("acme", &["api".into()]).unwrap();
717 cfg.remove_members("acme", &["ghost".into()]).unwrap();
718 assert_eq!(cfg.workspaces.get("acme").unwrap().members, vec!["api"]);
719 }
720
721 #[test]
722 fn remove_members_does_not_deregister_repo() {
723 let mut cfg = Config::default();
724 cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
725 cfg.create_workspace("acme".into(), None).unwrap();
726 cfg.add_members("acme", &["api".into()]).unwrap();
727 cfg.remove_members("acme", &["api".into()]).unwrap();
728 assert!(cfg.repos.contains_key("api"));
729 assert!(cfg.workspaces.get("acme").unwrap().members.is_empty());
730 }
731
732 #[test]
733 fn resolve_workspace_partitions_live_and_dangling() {
734 let mut cfg = Config::default();
735 cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
736 cfg.add_repo("ui".into(), make("/tmp/ui")).unwrap();
737 cfg.create_workspace("acme".into(), None).unwrap();
738 cfg.add_members("acme", &["api".into(), "ui".into()])
739 .unwrap();
740 cfg.remove_repo("ui").unwrap();
742 let (live, dangling) = cfg.resolve_workspace("acme").unwrap();
743 assert_eq!(live.len(), 1);
744 assert_eq!(live[0].0, "api");
745 assert_eq!(dangling.len(), 1);
746 assert_eq!(dangling[0], "ui");
747 }
748
749 #[test]
750 fn resolve_workspace_recovers_after_reregistration() {
751 let mut cfg = Config::default();
752 cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
753 cfg.create_workspace("acme".into(), None).unwrap();
754 cfg.add_members("acme", &["api".into()]).unwrap();
755 cfg.remove_repo("api").unwrap();
756 let (_, dangling) = cfg.resolve_workspace("acme").unwrap();
757 assert_eq!(dangling, vec!["api"]);
758 cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
759 let (live, dangling) = cfg.resolve_workspace("acme").unwrap();
760 assert_eq!(live.len(), 1);
761 assert!(dangling.is_empty());
762 }
763
764 #[test]
765 fn round_trip_with_mixed_repos_and_workspaces() {
766 let tmp = TempDir::new().unwrap();
767 let mut cfg = Config::default();
768 cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
769 cfg.add_repo("ui".into(), make("/tmp/ui")).unwrap();
770 cfg.create_workspace("acme".into(), Some("Rebuild".into()))
771 .unwrap();
772 cfg.add_members("acme", &["ui".into(), "api".into()])
773 .unwrap();
774 cfg.create_workspace("billing".into(), None).unwrap();
775 cfg.save(tmp.path()).unwrap();
776
777 let body_first = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
778 let loaded = Config::load(tmp.path()).unwrap();
779 loaded.save(tmp.path()).unwrap();
780 let body_second = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
781 assert_eq!(body_first, body_second, "byte-identical round trip");
782
783 assert_eq!(loaded.workspaces.len(), 2);
784 let acme = loaded.workspaces.get("acme").unwrap();
785 assert_eq!(acme.description.as_deref(), Some("Rebuild"));
786 assert_eq!(acme.members, vec!["api", "ui"]);
787 let billing = loaded.workspaces.get("billing").unwrap();
788 assert!(billing.description.is_none());
789 assert!(billing.members.is_empty());
790 }
791
792 #[test]
795 fn config_without_agents_section_loads_as_none() {
796 let tmp = TempDir::new().unwrap();
797 std::fs::create_dir_all(tmp.path()).unwrap();
798 std::fs::write(
799 tmp.path().join(CONFIG_FILE_NAME),
800 "[repo.foo]\npath = \"/tmp/foo\"\n",
801 )
802 .unwrap();
803 let cfg = Config::load(tmp.path()).unwrap();
804 assert!(cfg.agents().is_none());
805 assert!(cfg.repos.contains_key("foo"));
806 }
807
808 #[test]
809 fn config_with_empty_agents_is_some_with_empty_selection() {
810 let tmp = TempDir::new().unwrap();
811 std::fs::create_dir_all(tmp.path()).unwrap();
812 std::fs::write(
813 tmp.path().join(CONFIG_FILE_NAME),
814 "[agents]\nselected = []\n",
815 )
816 .unwrap();
817 let cfg = Config::load(tmp.path()).unwrap();
818 let agents = cfg.agents().expect("agents present");
819 assert!(agents.selected.is_empty());
820 }
821
822 #[test]
823 fn save_with_agents_none_omits_section() {
824 let tmp = TempDir::new().unwrap();
825 let mut cfg = Config::default();
826 cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
827 cfg.save(tmp.path()).unwrap();
829 let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
830 assert!(
831 !body.contains("[agents]"),
832 "no [agents] section when agents is None, got:\n{body}"
833 );
834 }
835
836 #[test]
837 fn save_with_empty_agents_writes_section_header() {
838 let tmp = TempDir::new().unwrap();
839 let mut cfg = Config::default();
840 cfg.set_agents(Some(Agents { selected: vec![] }));
841 cfg.save(tmp.path()).unwrap();
842 let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
843 assert!(
844 body.contains("[agents]"),
845 "configured-but-empty still writes section header, got:\n{body}"
846 );
847 }
848
849 #[test]
850 fn agents_selection_order_round_trips() {
851 let tmp = TempDir::new().unwrap();
852 let mut cfg = Config::default();
853 cfg.set_agents(Some(Agents {
854 selected: vec![AgentId::Cursor, AgentId::ClaudeCode, AgentId::AgentsMd],
855 }));
856 cfg.save(tmp.path()).unwrap();
857 let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
858 let cursor = body.find("\"cursor\"").expect("cursor present");
859 let claude = body.find("\"claude-code\"").expect("claude-code present");
860 let agents_md = body.find("\"agents-md\"").expect("agents-md present");
861 assert!(cursor < claude && claude < agents_md, "order preserved");
862
863 let reloaded = Config::load(tmp.path()).unwrap();
864 assert_eq!(
865 reloaded.agents().unwrap().selected,
866 vec![AgentId::Cursor, AgentId::ClaudeCode, AgentId::AgentsMd]
867 );
868 }
869
870 #[test]
871 fn agents_round_trip_with_repos_and_workspaces_is_byte_stable() {
872 let tmp = TempDir::new().unwrap();
873 let mut cfg = Config::default();
874 cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
875 cfg.create_workspace("acme".into(), None).unwrap();
876 cfg.add_members("acme", &["api".into()]).unwrap();
877 cfg.set_agents(Some(Agents {
878 selected: vec![AgentId::ClaudeCode],
879 }));
880 cfg.save(tmp.path()).unwrap();
881 let body_first = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
882
883 let loaded = Config::load(tmp.path()).unwrap();
884 loaded.save(tmp.path()).unwrap();
885 let body_second = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
886 assert_eq!(
887 body_first, body_second,
888 "round-trip byte-identical with [agents]"
889 );
890 }
891
892 #[test]
893 fn unknown_agent_id_in_config_produces_parse_error() {
894 let tmp = TempDir::new().unwrap();
895 std::fs::create_dir_all(tmp.path()).unwrap();
896 std::fs::write(
897 tmp.path().join(CONFIG_FILE_NAME),
898 "[agents]\nselected = [\"claude-code\", \"bogus\"]\n",
899 )
900 .unwrap();
901 let err = Config::load(tmp.path()).unwrap_err();
902 assert!(
903 matches!(err, RepographError::ConfigParse(_)),
904 "expected ConfigParse, got {err:?}"
905 );
906 assert_eq!(err.exit_code(), 1);
907 }
908
909 #[test]
912 fn config_without_settings_section_loads_as_none() {
913 let tmp = TempDir::new().unwrap();
914 std::fs::create_dir_all(tmp.path()).unwrap();
915 std::fs::write(
916 tmp.path().join(CONFIG_FILE_NAME),
917 "[agents]\nselected = []\n",
918 )
919 .unwrap();
920 let cfg = Config::load(tmp.path()).unwrap();
921 assert!(cfg.settings().is_none());
922 }
923
924 #[test]
925 fn save_with_settings_none_omits_section() {
926 let tmp = TempDir::new().unwrap();
927 let mut cfg = Config::default();
928 cfg.set_agents(Some(Agents {
929 selected: vec![AgentId::ClaudeCode],
930 }));
931 cfg.save(tmp.path()).unwrap();
933 let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
934 assert!(
935 !body.contains("[settings]"),
936 "no [settings] section when settings is None, got:\n{body}"
937 );
938 }
939
940 #[test]
941 fn settings_projects_root_round_trip() {
942 let tmp = TempDir::new().unwrap();
943 let mut cfg = Config::default();
944 cfg.set_settings(Some(Settings {
945 projects_root: Some(PathBuf::from("/home/dev/IdeaProjects")),
946 }));
947 cfg.save(tmp.path()).unwrap();
948 let reloaded = Config::load(tmp.path()).unwrap();
949 assert_eq!(
950 reloaded.settings().unwrap().projects_root.as_deref(),
951 Some(Path::new("/home/dev/IdeaProjects"))
952 );
953 }
954
955 #[test]
956 fn settings_with_none_projects_root_still_writes_section_header() {
957 let tmp = TempDir::new().unwrap();
958 let mut cfg = Config::default();
959 cfg.set_settings(Some(Settings::default()));
960 cfg.save(tmp.path()).unwrap();
961 let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
962 assert!(
963 body.contains("[settings]"),
964 "configured-but-empty settings still writes header, got:\n{body}"
965 );
966 assert!(
967 !body.contains("projects_root"),
968 "absent field is omitted, got:\n{body}"
969 );
970 }
971
972 #[test]
973 fn settings_round_trip_with_agents_and_repos_is_byte_stable() {
974 let tmp = TempDir::new().unwrap();
975 let mut cfg = Config::default();
976 cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
977 cfg.set_agents(Some(Agents {
978 selected: vec![AgentId::ClaudeCode],
979 }));
980 cfg.set_settings(Some(Settings {
981 projects_root: Some(PathBuf::from("/home/dev/IdeaProjects")),
982 }));
983 cfg.save(tmp.path()).unwrap();
984 let body_first = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
985
986 let loaded = Config::load(tmp.path()).unwrap();
987 loaded.save(tmp.path()).unwrap();
988 let body_second = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
989 assert_eq!(
990 body_first, body_second,
991 "round-trip byte-identical with [settings]"
992 );
993 }
994
995 #[test]
996 fn unknown_field_on_workspace_is_tolerated() {
997 let tmp = TempDir::new().unwrap();
998 std::fs::create_dir_all(tmp.path()).unwrap();
999 std::fs::write(
1000 tmp.path().join(CONFIG_FILE_NAME),
1001 "[workspace.acme]\nmembers = []\nfuture = \"yes\"\n",
1002 )
1003 .unwrap();
1004 let cfg = Config::load(tmp.path()).unwrap();
1005 assert!(cfg.workspaces.contains_key("acme"));
1006 }
1007}