Skip to main content

repograph_core/
config.rs

1//! Config model and TOML persistence.
2
3use std::collections::BTreeMap;
4use std::path::{Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::agents::AgentId;
9use crate::error::RepographError;
10
11/// On-disk file name within the config directory.
12pub const CONFIG_FILE_NAME: &str = "config.toml";
13
14/// Maximum length of a workspace name (RFC 1123 label rule).
15pub const MAX_WORKSPACE_NAME_LEN: usize = 63;
16
17/// Reserved workspace names. These collide with future filter ergonomics
18/// (e.g. `--workspace all`) and are rejected at write time.
19pub const RESERVED_WORKSPACE_NAMES: &[&str] = &["default", "all", "none"];
20
21/// A registered local git repository. The `name` is the map key in
22/// [`Config::repos`] — it does not appear as a field on this struct.
23#[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/// A set of in-place changes to apply to a registered repo via
33/// [`Config::edit_repo`]. Every field is opt-in: `None` (or the outer `None`
34/// for `description`) leaves the current value untouched.
35///
36/// - `new_name`: rename the entry; workspace memberships are rewritten so
37///   groupings survive the rename.
38/// - `description`: `Some(Some(text))` sets it, `Some(None)` clears it, `None`
39///   leaves it unchanged.
40/// - `stack`: `Some(vec)` replaces the stack wholesale; `None` leaves it.
41/// - `path`: a pre-validated, canonicalized path; `Some(p)` replaces it.
42#[derive(Debug, Default, Clone)]
43pub struct RepoEdit {
44    pub new_name: Option<String>,
45    pub description: Option<Option<String>>,
46    pub stack: Option<Vec<String>>,
47    pub path: Option<PathBuf>,
48}
49
50/// A named grouping of registered repositories.
51///
52/// The `name` is the map key in [`Config::workspaces`] — it does not appear
53/// as a field on this struct. `members` holds bare repo names (keys into
54/// [`Config::repos`]) and is kept sorted on write for round-trip stability.
55#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
56pub struct Workspace {
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub description: Option<String>,
59    #[serde(default)]
60    pub members: Vec<String>,
61}
62
63/// Result of resolving a workspace's `members` against the repo registry:
64/// `(live, dangling)`. Live entries borrow the repo's name and its
65/// [`Repo`] entry; dangling entries borrow only the orphaned name.
66pub type WorkspaceResolution<'a> = (Vec<(&'a String, &'a Repo)>, Vec<&'a String>);
67
68/// The `[agents]` section of the on-disk config. Presence of this section
69/// signals that `repograph init` has been run; absence triggers the first-run
70/// prompt the next time an agent-consuming command runs.
71///
72/// `selected` preserves the order the user chose at init time so the rendered
73/// config and any downstream agent prompt have stable, predictable output.
74#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
75pub struct Agents {
76    #[serde(default)]
77    pub selected: Vec<AgentId>,
78}
79
80/// The `[settings]` section of the on-disk config. User-tunable knobs that
81/// don't fit naturally under `[agents]`, `[repo.*]`, or `[workspace.*]`.
82///
83/// All fields are optional; absent values fall back to either an env var
84/// (where one exists, e.g. `REPOGRAPH_PROJECT_ROOT` for `projects_root`) or
85/// to "ask the user next time they need it" semantics.
86#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct Settings {
88    /// User-declared root folder for git projects (e.g. `~/IdeaProjects`,
89    /// `~/code`). When set, `repograph init`'s repo-registration step scans
90    /// this directly instead of probing the filesystem for common
91    /// conventions. `None` means "ask the user next time they need it" or
92    /// "fall back to free-form input with autocomplete."
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub projects_root: Option<PathBuf>,
95}
96
97/// Top-level config aggregating all registered repos, workspaces, and the
98/// user's agent selection.
99#[derive(Debug, Default, Clone, Serialize, Deserialize)]
100pub struct Config {
101    #[serde(default, rename = "repo", skip_serializing_if = "BTreeMap::is_empty")]
102    repos: BTreeMap<String, Repo>,
103    #[serde(
104        default,
105        rename = "workspace",
106        skip_serializing_if = "BTreeMap::is_empty"
107    )]
108    workspaces: BTreeMap<String, Workspace>,
109    /// User's agent toolchain selection. `None` means init has not been run;
110    /// `Some(Agents { selected: vec![] })` means init was run and the user
111    /// explicitly opted out of agent docs.
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    agents: Option<Agents>,
114    /// Persistent user preferences (project root, …). Omitted from
115    /// serialization when empty.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    settings: Option<Settings>,
118}
119
120impl Config {
121    /// Read-only view of the registered repos.
122    #[must_use]
123    pub const fn repos(&self) -> &BTreeMap<String, Repo> {
124        &self.repos
125    }
126
127    /// Read-only view of the registered workspaces.
128    #[must_use]
129    pub const fn workspaces(&self) -> &BTreeMap<String, Workspace> {
130        &self.workspaces
131    }
132
133    /// Read-only view of the user's agent selection. Returns `None` when no
134    /// `[agents]` section is present (init has not been run); `Some(_)` when
135    /// init has run, even if `selected` is empty.
136    #[must_use]
137    pub const fn agents(&self) -> Option<&Agents> {
138        self.agents.as_ref()
139    }
140
141    /// Replace the `[agents]` section with the given selection. Passing
142    /// `Some(Agents { selected: vec![] })` writes a configured-but-empty
143    /// section; passing `None` removes the section (and signals "not
144    /// initialized" to consumers).
145    pub fn set_agents(&mut self, agents: Option<Agents>) {
146        self.agents = agents;
147    }
148
149    /// Read-only view of the user's persistent settings (project root,
150    /// future preferences). Returns `None` when no `[settings]` section is
151    /// present.
152    #[must_use]
153    pub const fn settings(&self) -> Option<&Settings> {
154        self.settings.as_ref()
155    }
156
157    /// Replace the `[settings]` section. Pass `None` to remove the section.
158    pub fn set_settings(&mut self, settings: Option<Settings>) {
159        self.settings = settings;
160    }
161
162    /// Platform-default config directory: `dirs::config_dir() / "repograph"`.
163    /// Returns `None` when no platform default exists (e.g. minimal envs); the
164    /// binary surfaces this as a usage error guiding the user to `--config-dir`.
165    #[must_use]
166    pub fn default_dir() -> Option<PathBuf> {
167        dirs::config_dir().map(|d| d.join("repograph"))
168    }
169
170    /// Load config from `dir/config.toml`. Missing file → empty `Config`.
171    /// Malformed TOML → `RepographError::ConfigParse`.
172    ///
173    /// # Errors
174    ///
175    /// Returns [`RepographError::Io`] for filesystem failures, or
176    /// [`RepographError::ConfigParse`] when the file exists but is not valid TOML.
177    pub fn load(dir: &Path) -> Result<Self, RepographError> {
178        let path = dir.join(CONFIG_FILE_NAME);
179        match fs_err::read_to_string(&path) {
180            Ok(body) => Ok(toml::from_str(&body)?),
181            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
182            Err(e) => Err(e.into()),
183        }
184    }
185
186    /// Save config atomically to `dir/config.toml`, creating `dir` if missing.
187    ///
188    /// Atomicity: we serialize to a sibling temp file, then `rename` to the
189    /// target. A crash mid-write cannot leave the target half-written.
190    ///
191    /// # Errors
192    ///
193    /// Returns [`RepographError::ConfigWrite`] when serialization fails,
194    /// [`RepographError::PermissionDenied`] when the target dir or file is not
195    /// writable, or [`RepographError::Io`] for other filesystem failures.
196    pub fn save(&self, dir: &Path) -> Result<(), RepographError> {
197        let body = toml::to_string_pretty(self)?;
198        let target = dir.join(CONFIG_FILE_NAME);
199
200        if let Err(e) = fs_err::create_dir_all(dir) {
201            return Err(map_io_to_perm(e, dir));
202        }
203
204        let tmp = dir.join(format!(".{CONFIG_FILE_NAME}.tmp"));
205        if let Err(e) = fs_err::write(&tmp, body.as_bytes()) {
206            return Err(map_io_to_perm(e, &tmp));
207        }
208        if let Err(e) = fs_err::rename(&tmp, &target) {
209            return Err(map_io_to_perm(e, &target));
210        }
211        Ok(())
212    }
213
214    /// Register a repo, enforcing both name and path uniqueness.
215    ///
216    /// # Errors
217    ///
218    /// Returns [`RepographError::Conflict`] with `kind = "name"` when `name`
219    /// is already registered, or `kind = "path"` when `repo.path` is already
220    /// registered under a different name.
221    pub fn add_repo(&mut self, name: String, repo: Repo) -> Result<(), RepographError> {
222        if self.repos.contains_key(&name) {
223            return Err(RepographError::Conflict { kind: "name", name });
224        }
225        if let Some((existing_name, _)) = self.repos.iter().find(|(_, r)| r.path == repo.path) {
226            return Err(RepographError::Conflict {
227                kind: "path",
228                name: existing_name.clone(),
229            });
230        }
231        self.repos.insert(name, repo);
232        Ok(())
233    }
234
235    /// Deregister a repo by name.
236    ///
237    /// # Errors
238    ///
239    /// Returns [`RepographError::NotFound`] when no repo by that name is registered.
240    pub fn remove_repo(&mut self, name: &str) -> Result<Repo, RepographError> {
241        self.repos
242            .remove(name)
243            .ok_or_else(|| RepographError::NotFound {
244                kind: "repo",
245                name: name.to_string(),
246            })
247    }
248
249    /// Update a registered repo in place, returning the resulting `(name, repo)`.
250    ///
251    /// Unlike a remove-then-add, this preserves workspace memberships: a rename
252    /// (`edit.new_name`) rewrites every `workspace.members` entry that pointed at
253    /// the old name, so groupings survive with no dangling references. All
254    /// validation runs before any mutation, so an error leaves the config
255    /// untouched.
256    ///
257    /// # Errors
258    ///
259    /// Returns [`RepographError::NotFound`] with `kind = "repo"` when `name` is
260    /// not registered; [`RepographError::Conflict`] with `kind = "name"` when
261    /// `new_name` collides with a different existing repo, or `kind = "path"`
262    /// when `edit.path` is already registered under a different name.
263    pub fn edit_repo(
264        &mut self,
265        name: &str,
266        edit: RepoEdit,
267    ) -> Result<(String, Repo), RepographError> {
268        if !self.repos.contains_key(name) {
269            return Err(RepographError::NotFound {
270                kind: "repo",
271                name: name.to_string(),
272            });
273        }
274
275        // A rename to the same name is a no-op rename, not a conflict.
276        let rename_to = edit
277            .new_name
278            .as_deref()
279            .filter(|n| *n != name)
280            .map(ToString::to_string);
281
282        // Validate before mutating: target name must be free.
283        if let Some(new_name) = &rename_to {
284            if self.repos.contains_key(new_name) {
285                return Err(RepographError::Conflict {
286                    kind: "name",
287                    name: new_name.clone(),
288                });
289            }
290        }
291        // Validate before mutating: a new path must not collide with another repo.
292        if let Some(new_path) = &edit.path {
293            if let Some((existing, _)) = self
294                .repos
295                .iter()
296                .find(|(k, r)| k.as_str() != name && &r.path == new_path)
297            {
298                return Err(RepographError::Conflict {
299                    kind: "path",
300                    name: existing.clone(),
301                });
302            }
303        }
304
305        // All checks passed — apply field updates to the (possibly soon-renamed) entry.
306        // Safe to unwrap-free: presence was verified above.
307        let mut repo = self
308            .repos
309            .remove(name)
310            .ok_or_else(|| RepographError::NotFound {
311                kind: "repo",
312                name: name.to_string(),
313            })?;
314        if let Some(description) = edit.description {
315            repo.description = description.filter(|s| !s.is_empty());
316        }
317        if let Some(stack) = edit.stack {
318            repo.stack = stack;
319        }
320        if let Some(path) = edit.path {
321            repo.path = path;
322        }
323
324        let final_name = rename_to.clone().unwrap_or_else(|| name.to_string());
325        self.repos.insert(final_name.clone(), repo.clone());
326
327        // Rewrite workspace memberships on rename so groupings don't dangle.
328        if let Some(new_name) = &rename_to {
329            for ws in self.workspaces.values_mut() {
330                let mut touched = false;
331                for member in &mut ws.members {
332                    if member == name {
333                        member.clone_from(new_name);
334                        touched = true;
335                    }
336                }
337                if touched {
338                    ws.members.sort();
339                    ws.members.dedup();
340                }
341            }
342        }
343
344        Ok((final_name, repo))
345    }
346
347    /// Create an empty workspace under `name` with an optional description.
348    ///
349    /// The name must satisfy [`validate_workspace_name`]. The workspace must
350    /// not already exist.
351    ///
352    /// # Errors
353    ///
354    /// Returns [`RepographError::InvalidName`] when `name` violates the
355    /// naming policy, or [`RepographError::Conflict`] with `kind = "workspace"`
356    /// when a workspace by that name already exists.
357    pub fn create_workspace(
358        &mut self,
359        name: String,
360        description: Option<String>,
361    ) -> Result<(), RepographError> {
362        validate_workspace_name(&name)?;
363        if self.workspaces.contains_key(&name) {
364            return Err(RepographError::Conflict {
365                kind: "workspace",
366                name,
367            });
368        }
369        self.workspaces.insert(
370            name,
371            Workspace {
372                description: description.filter(|s| !s.is_empty()),
373                members: Vec::new(),
374            },
375        );
376        Ok(())
377    }
378
379    /// Delete a workspace by name. Registered repos are untouched.
380    ///
381    /// # Errors
382    ///
383    /// Returns [`RepographError::NotFound`] with `kind = "workspace"` when
384    /// no workspace by that name is registered.
385    pub fn remove_workspace(&mut self, name: &str) -> Result<Workspace, RepographError> {
386        self.workspaces
387            .remove(name)
388            .ok_or_else(|| RepographError::NotFound {
389                kind: "workspace",
390                name: name.to_string(),
391            })
392    }
393
394    /// Atomically attach one or more registered repos to a workspace.
395    ///
396    /// All `repos` must be registered before any mutation occurs; if even one
397    /// is missing, the workspace is left unchanged. Already-member repos are
398    /// silently ignored. On success the `members` list is sorted and
399    /// deduplicated.
400    ///
401    /// # Errors
402    ///
403    /// Returns [`RepographError::NotFound`] with `kind = "workspace"` when
404    /// the workspace does not exist, or `kind = "repo"` (naming the first
405    /// missing repo) when any input repo is not registered.
406    pub fn add_members(&mut self, workspace: &str, repos: &[String]) -> Result<(), RepographError> {
407        // Workspace presence first, so the error message names the right thing
408        // when neither workspace nor any of the repos exists.
409        if !self.workspaces.contains_key(workspace) {
410            return Err(RepographError::NotFound {
411                kind: "workspace",
412                name: workspace.to_string(),
413            });
414        }
415        for name in repos {
416            if !self.repos.contains_key(name) {
417                return Err(RepographError::NotFound {
418                    kind: "repo",
419                    name: name.clone(),
420                });
421            }
422        }
423        // Re-fetch as mutable; we re-emit NotFound rather than expect() so a
424        // future refactor that drops the contains_key guard can't introduce a panic.
425        let ws = self
426            .workspaces
427            .get_mut(workspace)
428            .ok_or_else(|| RepographError::NotFound {
429                kind: "workspace",
430                name: workspace.to_string(),
431            })?;
432        for name in repos {
433            ws.members.push(name.clone());
434        }
435        ws.members.sort();
436        ws.members.dedup();
437        Ok(())
438    }
439
440    /// Detach one or more repos from a workspace. Non-members are silently
441    /// ignored. The repo registry is not modified.
442    ///
443    /// # Errors
444    ///
445    /// Returns [`RepographError::NotFound`] with `kind = "workspace"` when
446    /// the workspace does not exist.
447    pub fn remove_members(
448        &mut self,
449        workspace: &str,
450        repos: &[String],
451    ) -> Result<(), RepographError> {
452        let ws = self
453            .workspaces
454            .get_mut(workspace)
455            .ok_or_else(|| RepographError::NotFound {
456                kind: "workspace",
457                name: workspace.to_string(),
458            })?;
459        ws.members.retain(|m| !repos.iter().any(|r| r == m));
460        Ok(())
461    }
462
463    /// Walk a workspace's members and partition them into live entries
464    /// (resolved against the repo registry) and dangling names (tombstoned
465    /// references to repos that are no longer registered). The order in each
466    /// returned vector matches the workspace's stored `members` order
467    /// (alphabetical after sort-on-write).
468    ///
469    /// # Errors
470    ///
471    /// Returns [`RepographError::NotFound`] with `kind = "workspace"` when
472    /// the workspace does not exist.
473    pub fn resolve_workspace<'a>(
474        &'a self,
475        workspace: &str,
476    ) -> Result<WorkspaceResolution<'a>, RepographError> {
477        let ws = self
478            .workspaces
479            .get(workspace)
480            .ok_or_else(|| RepographError::NotFound {
481                kind: "workspace",
482                name: workspace.to_string(),
483            })?;
484        let mut live = Vec::with_capacity(ws.members.len());
485        let mut dangling = Vec::new();
486        for name in &ws.members {
487            if let Some((key, repo)) = self.repos.get_key_value(name) {
488                live.push((key, repo));
489            } else {
490                dangling.push(name);
491            }
492        }
493        Ok((live, dangling))
494    }
495}
496
497/// Enforce the workspace naming policy: lowercase ASCII alphanumerics and
498/// hyphens, must start alphanumeric, length 1..=63, and not one of the
499/// reserved words.
500///
501/// # Errors
502///
503/// Returns [`RepographError::InvalidName`] with `kind = "workspace"` when the
504/// name violates the policy. The `reason` text is a short, user-facing phrase.
505pub fn validate_workspace_name(name: &str) -> Result<(), RepographError> {
506    if name.is_empty() {
507        return Err(invalid_workspace_name(name, "must not be empty"));
508    }
509    if name.len() > MAX_WORKSPACE_NAME_LEN {
510        return Err(invalid_workspace_name(
511            name,
512            "must be at most 63 characters",
513        ));
514    }
515    if RESERVED_WORKSPACE_NAMES.contains(&name) {
516        return Err(invalid_workspace_name(name, "is a reserved name"));
517    }
518    for (i, c) in name.chars().enumerate() {
519        let alnum_lower = c.is_ascii_lowercase() || c.is_ascii_digit();
520        if i == 0 {
521            if !alnum_lower {
522                return Err(invalid_workspace_name(
523                    name,
524                    "must start with a lowercase letter or digit",
525                ));
526            }
527        } else if !alnum_lower && c != '-' {
528            return Err(invalid_workspace_name(
529                name,
530                "must contain only lowercase letters, digits, and hyphens",
531            ));
532        }
533    }
534    Ok(())
535}
536
537fn invalid_workspace_name(name: &str, reason: &'static str) -> RepographError {
538    RepographError::InvalidName {
539        kind: "workspace",
540        name: name.to_string(),
541        reason,
542    }
543}
544
545/// Map an [`std::io::Error`] to a typed permission-denied error when the kind
546/// matches; otherwise pass it through as `Io`.
547fn map_io_to_perm(e: std::io::Error, path: &Path) -> RepographError {
548    if e.kind() == std::io::ErrorKind::PermissionDenied {
549        RepographError::PermissionDenied {
550            path: path.to_path_buf(),
551        }
552    } else {
553        RepographError::Io(e)
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    #![allow(clippy::unwrap_used, clippy::expect_used)]
560    use super::*;
561    use tempfile::TempDir;
562
563    fn make(path: &str) -> Repo {
564        Repo {
565            path: PathBuf::from(path),
566            description: None,
567            stack: vec![],
568        }
569    }
570
571    #[test]
572    fn load_missing_returns_empty() {
573        let tmp = TempDir::new().unwrap();
574        let cfg = Config::load(tmp.path()).unwrap();
575        assert!(cfg.repos.is_empty());
576    }
577
578    #[test]
579    fn save_then_load_round_trip() {
580        let tmp = TempDir::new().unwrap();
581        let mut cfg = Config::default();
582        cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
583        cfg.add_repo(
584            "bar".into(),
585            Repo {
586                path: PathBuf::from("/tmp/bar"),
587                description: Some("hi".into()),
588                stack: vec!["rust".into()],
589            },
590        )
591        .unwrap();
592
593        cfg.save(tmp.path()).unwrap();
594        let loaded = Config::load(tmp.path()).unwrap();
595        assert_eq!(loaded.repos.len(), 2);
596        assert_eq!(
597            loaded.repos.get("bar").unwrap().description.as_deref(),
598            Some("hi")
599        );
600    }
601
602    #[test]
603    fn name_conflict_blocks_insert() {
604        let mut cfg = Config::default();
605        cfg.add_repo("foo".into(), make("/a")).unwrap();
606        let err = cfg.add_repo("foo".into(), make("/b")).unwrap_err();
607        assert!(matches!(err, RepographError::Conflict { kind: "name", .. }));
608        assert_eq!(cfg.repos.get("foo").unwrap().path, PathBuf::from("/a"));
609    }
610
611    #[test]
612    fn path_conflict_blocks_insert() {
613        let mut cfg = Config::default();
614        cfg.add_repo("foo".into(), make("/shared")).unwrap();
615        let err = cfg.add_repo("bar".into(), make("/shared")).unwrap_err();
616        assert!(matches!(err, RepographError::Conflict { kind: "path", .. }));
617        assert!(!cfg.repos.contains_key("bar"));
618    }
619
620    #[test]
621    fn remove_missing_returns_not_found() {
622        let mut cfg = Config::default();
623        let err = cfg.remove_repo("ghost").unwrap_err();
624        assert!(matches!(err, RepographError::NotFound { .. }));
625    }
626
627    #[test]
628    fn unknown_field_in_toml_is_tolerated() {
629        let tmp = TempDir::new().unwrap();
630        std::fs::create_dir_all(tmp.path()).unwrap();
631        std::fs::write(
632            tmp.path().join(CONFIG_FILE_NAME),
633            "[repo.foo]\npath = \"/tmp/foo\"\nfuture = \"yes\"\n",
634        )
635        .unwrap();
636        let cfg = Config::load(tmp.path()).unwrap();
637        assert!(cfg.repos.contains_key("foo"));
638    }
639
640    // --- Workspace tests ---
641
642    #[test]
643    fn validate_workspace_name_accepts_simple_lowercase() {
644        assert!(validate_workspace_name("acme").is_ok());
645        assert!(validate_workspace_name("acme-rebuild-2026").is_ok());
646        assert!(validate_workspace_name("a").is_ok());
647        assert!(validate_workspace_name("0").is_ok());
648        assert!(validate_workspace_name("0acme").is_ok());
649    }
650
651    #[test]
652    fn validate_workspace_name_rejects_empty() {
653        let err = validate_workspace_name("").unwrap_err();
654        assert!(matches!(err, RepographError::InvalidName { .. }));
655        assert_eq!(err.exit_code(), 2);
656    }
657
658    #[test]
659    fn validate_workspace_name_rejects_uppercase() {
660        let err = validate_workspace_name("AcmeRebuild").unwrap_err();
661        assert!(matches!(err, RepographError::InvalidName { .. }));
662    }
663
664    #[test]
665    fn validate_workspace_name_rejects_leading_hyphen() {
666        let err = validate_workspace_name("-acme").unwrap_err();
667        assert!(matches!(err, RepographError::InvalidName { .. }));
668    }
669
670    #[test]
671    fn validate_workspace_name_rejects_underscore() {
672        let err = validate_workspace_name("ac_me").unwrap_err();
673        assert!(matches!(err, RepographError::InvalidName { .. }));
674    }
675
676    #[test]
677    fn validate_workspace_name_rejects_spaces() {
678        let err = validate_workspace_name("ac me").unwrap_err();
679        assert!(matches!(err, RepographError::InvalidName { .. }));
680    }
681
682    #[test]
683    fn validate_workspace_name_rejects_overlength() {
684        let name = "a".repeat(MAX_WORKSPACE_NAME_LEN + 1);
685        let err = validate_workspace_name(&name).unwrap_err();
686        assert!(matches!(err, RepographError::InvalidName { .. }));
687    }
688
689    #[test]
690    fn validate_workspace_name_accepts_exact_max_length() {
691        let name = "a".repeat(MAX_WORKSPACE_NAME_LEN);
692        assert!(validate_workspace_name(&name).is_ok());
693    }
694
695    #[test]
696    fn validate_workspace_name_rejects_reserved_words() {
697        for reserved in RESERVED_WORKSPACE_NAMES {
698            let err = validate_workspace_name(reserved).unwrap_err();
699            assert!(
700                matches!(err, RepographError::InvalidName { .. }),
701                "reserved `{reserved}` must be rejected"
702            );
703        }
704    }
705
706    #[test]
707    fn create_workspace_inserts_empty_entry() {
708        let mut cfg = Config::default();
709        cfg.create_workspace("acme".into(), None).unwrap();
710        let ws = cfg.workspaces.get("acme").unwrap();
711        assert!(ws.description.is_none());
712        assert!(ws.members.is_empty());
713    }
714
715    #[test]
716    fn create_workspace_persists_description() {
717        let mut cfg = Config::default();
718        cfg.create_workspace("acme".into(), Some("rebuild".into()))
719            .unwrap();
720        assert_eq!(
721            cfg.workspaces.get("acme").unwrap().description.as_deref(),
722            Some("rebuild")
723        );
724    }
725
726    #[test]
727    fn create_workspace_conflict_returns_conflict() {
728        let mut cfg = Config::default();
729        cfg.create_workspace("acme".into(), None).unwrap();
730        let err = cfg.create_workspace("acme".into(), None).unwrap_err();
731        assert!(matches!(
732            err,
733            RepographError::Conflict {
734                kind: "workspace",
735                ..
736            }
737        ));
738        assert_eq!(err.exit_code(), 5);
739    }
740
741    #[test]
742    fn create_workspace_invalid_name_returns_invalid_name() {
743        let mut cfg = Config::default();
744        let err = cfg.create_workspace("Bad Name".into(), None).unwrap_err();
745        assert!(matches!(err, RepographError::InvalidName { .. }));
746        assert_eq!(err.exit_code(), 2);
747        assert!(cfg.workspaces.is_empty());
748    }
749
750    #[test]
751    fn remove_workspace_missing_returns_not_found() {
752        let mut cfg = Config::default();
753        let err = cfg.remove_workspace("ghost").unwrap_err();
754        assert!(matches!(
755            err,
756            RepographError::NotFound {
757                kind: "workspace",
758                ..
759            }
760        ));
761        assert_eq!(err.exit_code(), 3);
762    }
763
764    #[test]
765    fn remove_workspace_does_not_touch_repos() {
766        let mut cfg = Config::default();
767        cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
768        cfg.create_workspace("acme".into(), None).unwrap();
769        cfg.add_members("acme", &["api".into()]).unwrap();
770        cfg.remove_workspace("acme").unwrap();
771        assert!(cfg.repos.contains_key("api"));
772        assert!(!cfg.workspaces.contains_key("acme"));
773    }
774
775    #[test]
776    fn add_members_atomic_when_one_repo_missing() {
777        let mut cfg = Config::default();
778        cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
779        cfg.add_repo("ui".into(), make("/tmp/ui")).unwrap();
780        cfg.create_workspace("acme".into(), None).unwrap();
781        let err = cfg
782            .add_members("acme", &["api".into(), "ghost".into(), "ui".into()])
783            .unwrap_err();
784        assert!(matches!(
785            err,
786            RepographError::NotFound { kind: "repo", ref name } if name == "ghost"
787        ));
788        // No partial application.
789        assert!(cfg.workspaces.get("acme").unwrap().members.is_empty());
790    }
791
792    #[test]
793    fn add_members_sorts_and_deduplicates() {
794        let mut cfg = Config::default();
795        cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
796        cfg.add_repo("ui".into(), make("/tmp/ui")).unwrap();
797        cfg.add_repo("libs".into(), make("/tmp/libs")).unwrap();
798        cfg.create_workspace("acme".into(), None).unwrap();
799        cfg.add_members("acme", &["ui".into(), "api".into(), "libs".into()])
800            .unwrap();
801        assert_eq!(
802            cfg.workspaces.get("acme").unwrap().members,
803            vec!["api", "libs", "ui"]
804        );
805        // Idempotent.
806        cfg.add_members("acme", &["api".into()]).unwrap();
807        assert_eq!(
808            cfg.workspaces.get("acme").unwrap().members,
809            vec!["api", "libs", "ui"]
810        );
811    }
812
813    #[test]
814    fn add_members_missing_workspace_returns_not_found() {
815        let mut cfg = Config::default();
816        cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
817        let err = cfg.add_members("ghost", &["api".into()]).unwrap_err();
818        assert!(matches!(
819            err,
820            RepographError::NotFound {
821                kind: "workspace",
822                ..
823            }
824        ));
825    }
826
827    #[test]
828    fn remove_members_is_idempotent_for_non_members() {
829        let mut cfg = Config::default();
830        cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
831        cfg.create_workspace("acme".into(), None).unwrap();
832        cfg.add_members("acme", &["api".into()]).unwrap();
833        cfg.remove_members("acme", &["ghost".into()]).unwrap();
834        assert_eq!(cfg.workspaces.get("acme").unwrap().members, vec!["api"]);
835    }
836
837    #[test]
838    fn remove_members_does_not_deregister_repo() {
839        let mut cfg = Config::default();
840        cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
841        cfg.create_workspace("acme".into(), None).unwrap();
842        cfg.add_members("acme", &["api".into()]).unwrap();
843        cfg.remove_members("acme", &["api".into()]).unwrap();
844        assert!(cfg.repos.contains_key("api"));
845        assert!(cfg.workspaces.get("acme").unwrap().members.is_empty());
846    }
847
848    #[test]
849    fn resolve_workspace_partitions_live_and_dangling() {
850        let mut cfg = Config::default();
851        cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
852        cfg.add_repo("ui".into(), make("/tmp/ui")).unwrap();
853        cfg.create_workspace("acme".into(), None).unwrap();
854        cfg.add_members("acme", &["api".into(), "ui".into()])
855            .unwrap();
856        // Tombstone: forcibly drop `ui` from the registry without touching the workspace.
857        cfg.remove_repo("ui").unwrap();
858        let (live, dangling) = cfg.resolve_workspace("acme").unwrap();
859        assert_eq!(live.len(), 1);
860        assert_eq!(live[0].0, "api");
861        assert_eq!(dangling.len(), 1);
862        assert_eq!(dangling[0], "ui");
863    }
864
865    #[test]
866    fn resolve_workspace_recovers_after_reregistration() {
867        let mut cfg = Config::default();
868        cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
869        cfg.create_workspace("acme".into(), None).unwrap();
870        cfg.add_members("acme", &["api".into()]).unwrap();
871        cfg.remove_repo("api").unwrap();
872        let (_, dangling) = cfg.resolve_workspace("acme").unwrap();
873        assert_eq!(dangling, vec!["api"]);
874        cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
875        let (live, dangling) = cfg.resolve_workspace("acme").unwrap();
876        assert_eq!(live.len(), 1);
877        assert!(dangling.is_empty());
878    }
879
880    #[test]
881    fn round_trip_with_mixed_repos_and_workspaces() {
882        let tmp = TempDir::new().unwrap();
883        let mut cfg = Config::default();
884        cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
885        cfg.add_repo("ui".into(), make("/tmp/ui")).unwrap();
886        cfg.create_workspace("acme".into(), Some("Rebuild".into()))
887            .unwrap();
888        cfg.add_members("acme", &["ui".into(), "api".into()])
889            .unwrap();
890        cfg.create_workspace("billing".into(), None).unwrap();
891        cfg.save(tmp.path()).unwrap();
892
893        let body_first = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
894        let loaded = Config::load(tmp.path()).unwrap();
895        loaded.save(tmp.path()).unwrap();
896        let body_second = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
897        assert_eq!(body_first, body_second, "byte-identical round trip");
898
899        assert_eq!(loaded.workspaces.len(), 2);
900        let acme = loaded.workspaces.get("acme").unwrap();
901        assert_eq!(acme.description.as_deref(), Some("Rebuild"));
902        assert_eq!(acme.members, vec!["api", "ui"]);
903        let billing = loaded.workspaces.get("billing").unwrap();
904        assert!(billing.description.is_none());
905        assert!(billing.members.is_empty());
906    }
907
908    // --- Agents schema tests ---
909
910    #[test]
911    fn config_without_agents_section_loads_as_none() {
912        let tmp = TempDir::new().unwrap();
913        std::fs::create_dir_all(tmp.path()).unwrap();
914        std::fs::write(
915            tmp.path().join(CONFIG_FILE_NAME),
916            "[repo.foo]\npath = \"/tmp/foo\"\n",
917        )
918        .unwrap();
919        let cfg = Config::load(tmp.path()).unwrap();
920        assert!(cfg.agents().is_none());
921        assert!(cfg.repos.contains_key("foo"));
922    }
923
924    #[test]
925    fn config_with_empty_agents_is_some_with_empty_selection() {
926        let tmp = TempDir::new().unwrap();
927        std::fs::create_dir_all(tmp.path()).unwrap();
928        std::fs::write(
929            tmp.path().join(CONFIG_FILE_NAME),
930            "[agents]\nselected = []\n",
931        )
932        .unwrap();
933        let cfg = Config::load(tmp.path()).unwrap();
934        let agents = cfg.agents().expect("agents present");
935        assert!(agents.selected.is_empty());
936    }
937
938    #[test]
939    fn save_with_agents_none_omits_section() {
940        let tmp = TempDir::new().unwrap();
941        let mut cfg = Config::default();
942        cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
943        // agents remains None.
944        cfg.save(tmp.path()).unwrap();
945        let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
946        assert!(
947            !body.contains("[agents]"),
948            "no [agents] section when agents is None, got:\n{body}"
949        );
950    }
951
952    #[test]
953    fn save_with_empty_agents_writes_section_header() {
954        let tmp = TempDir::new().unwrap();
955        let mut cfg = Config::default();
956        cfg.set_agents(Some(Agents { selected: vec![] }));
957        cfg.save(tmp.path()).unwrap();
958        let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
959        assert!(
960            body.contains("[agents]"),
961            "configured-but-empty still writes section header, got:\n{body}"
962        );
963    }
964
965    #[test]
966    fn agents_selection_order_round_trips() {
967        let tmp = TempDir::new().unwrap();
968        let mut cfg = Config::default();
969        cfg.set_agents(Some(Agents {
970            selected: vec![AgentId::Cursor, AgentId::ClaudeCode, AgentId::AgentsMd],
971        }));
972        cfg.save(tmp.path()).unwrap();
973        let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
974        let cursor = body.find("\"cursor\"").expect("cursor present");
975        let claude = body.find("\"claude-code\"").expect("claude-code present");
976        let agents_md = body.find("\"agents-md\"").expect("agents-md present");
977        assert!(cursor < claude && claude < agents_md, "order preserved");
978
979        let reloaded = Config::load(tmp.path()).unwrap();
980        assert_eq!(
981            reloaded.agents().unwrap().selected,
982            vec![AgentId::Cursor, AgentId::ClaudeCode, AgentId::AgentsMd]
983        );
984    }
985
986    #[test]
987    fn agents_round_trip_with_repos_and_workspaces_is_byte_stable() {
988        let tmp = TempDir::new().unwrap();
989        let mut cfg = Config::default();
990        cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
991        cfg.create_workspace("acme".into(), None).unwrap();
992        cfg.add_members("acme", &["api".into()]).unwrap();
993        cfg.set_agents(Some(Agents {
994            selected: vec![AgentId::ClaudeCode],
995        }));
996        cfg.save(tmp.path()).unwrap();
997        let body_first = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
998
999        let loaded = Config::load(tmp.path()).unwrap();
1000        loaded.save(tmp.path()).unwrap();
1001        let body_second = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
1002        assert_eq!(
1003            body_first, body_second,
1004            "round-trip byte-identical with [agents]"
1005        );
1006    }
1007
1008    #[test]
1009    fn unknown_agent_id_in_config_produces_parse_error() {
1010        let tmp = TempDir::new().unwrap();
1011        std::fs::create_dir_all(tmp.path()).unwrap();
1012        std::fs::write(
1013            tmp.path().join(CONFIG_FILE_NAME),
1014            "[agents]\nselected = [\"claude-code\", \"bogus\"]\n",
1015        )
1016        .unwrap();
1017        let err = Config::load(tmp.path()).unwrap_err();
1018        assert!(
1019            matches!(err, RepographError::ConfigParse(_)),
1020            "expected ConfigParse, got {err:?}"
1021        );
1022        assert_eq!(err.exit_code(), 1);
1023    }
1024
1025    // --- Settings schema tests ---
1026
1027    #[test]
1028    fn config_without_settings_section_loads_as_none() {
1029        let tmp = TempDir::new().unwrap();
1030        std::fs::create_dir_all(tmp.path()).unwrap();
1031        std::fs::write(
1032            tmp.path().join(CONFIG_FILE_NAME),
1033            "[agents]\nselected = []\n",
1034        )
1035        .unwrap();
1036        let cfg = Config::load(tmp.path()).unwrap();
1037        assert!(cfg.settings().is_none());
1038    }
1039
1040    #[test]
1041    fn save_with_settings_none_omits_section() {
1042        let tmp = TempDir::new().unwrap();
1043        let mut cfg = Config::default();
1044        cfg.set_agents(Some(Agents {
1045            selected: vec![AgentId::ClaudeCode],
1046        }));
1047        // settings remains None.
1048        cfg.save(tmp.path()).unwrap();
1049        let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
1050        assert!(
1051            !body.contains("[settings]"),
1052            "no [settings] section when settings is None, got:\n{body}"
1053        );
1054    }
1055
1056    #[test]
1057    fn settings_projects_root_round_trip() {
1058        let tmp = TempDir::new().unwrap();
1059        let mut cfg = Config::default();
1060        cfg.set_settings(Some(Settings {
1061            projects_root: Some(PathBuf::from("/home/dev/IdeaProjects")),
1062        }));
1063        cfg.save(tmp.path()).unwrap();
1064        let reloaded = Config::load(tmp.path()).unwrap();
1065        assert_eq!(
1066            reloaded.settings().unwrap().projects_root.as_deref(),
1067            Some(Path::new("/home/dev/IdeaProjects"))
1068        );
1069    }
1070
1071    #[test]
1072    fn settings_with_none_projects_root_still_writes_section_header() {
1073        let tmp = TempDir::new().unwrap();
1074        let mut cfg = Config::default();
1075        cfg.set_settings(Some(Settings::default()));
1076        cfg.save(tmp.path()).unwrap();
1077        let body = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
1078        assert!(
1079            body.contains("[settings]"),
1080            "configured-but-empty settings still writes header, got:\n{body}"
1081        );
1082        assert!(
1083            !body.contains("projects_root"),
1084            "absent field is omitted, got:\n{body}"
1085        );
1086    }
1087
1088    #[test]
1089    fn settings_round_trip_with_agents_and_repos_is_byte_stable() {
1090        let tmp = TempDir::new().unwrap();
1091        let mut cfg = Config::default();
1092        cfg.add_repo("api".into(), make("/tmp/api")).unwrap();
1093        cfg.set_agents(Some(Agents {
1094            selected: vec![AgentId::ClaudeCode],
1095        }));
1096        cfg.set_settings(Some(Settings {
1097            projects_root: Some(PathBuf::from("/home/dev/IdeaProjects")),
1098        }));
1099        cfg.save(tmp.path()).unwrap();
1100        let body_first = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
1101
1102        let loaded = Config::load(tmp.path()).unwrap();
1103        loaded.save(tmp.path()).unwrap();
1104        let body_second = fs_err::read_to_string(tmp.path().join(CONFIG_FILE_NAME)).unwrap();
1105        assert_eq!(
1106            body_first, body_second,
1107            "round-trip byte-identical with [settings]"
1108        );
1109    }
1110
1111    #[test]
1112    fn unknown_field_on_workspace_is_tolerated() {
1113        let tmp = TempDir::new().unwrap();
1114        std::fs::create_dir_all(tmp.path()).unwrap();
1115        std::fs::write(
1116            tmp.path().join(CONFIG_FILE_NAME),
1117            "[workspace.acme]\nmembers = []\nfuture = \"yes\"\n",
1118        )
1119        .unwrap();
1120        let cfg = Config::load(tmp.path()).unwrap();
1121        assert!(cfg.workspaces.contains_key("acme"));
1122    }
1123
1124    #[test]
1125    fn edit_repo_updates_description_and_stack_in_place() {
1126        let mut cfg = Config::default();
1127        cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
1128        let (name, repo) = cfg
1129            .edit_repo(
1130                "foo",
1131                RepoEdit {
1132                    description: Some(Some("new".into())),
1133                    stack: Some(vec!["rust".into(), "cli".into()]),
1134                    ..RepoEdit::default()
1135                },
1136            )
1137            .unwrap();
1138        assert_eq!(name, "foo");
1139        assert_eq!(repo.description.as_deref(), Some("new"));
1140        assert_eq!(repo.stack, vec!["rust", "cli"]);
1141        assert_eq!(
1142            cfg.repos.get("foo").unwrap().path,
1143            PathBuf::from("/tmp/foo")
1144        );
1145    }
1146
1147    #[test]
1148    fn edit_repo_empty_description_clears_it() {
1149        let mut cfg = Config::default();
1150        cfg.add_repo(
1151            "foo".into(),
1152            Repo {
1153                path: PathBuf::from("/tmp/foo"),
1154                description: Some("old".into()),
1155                stack: vec![],
1156            },
1157        )
1158        .unwrap();
1159        cfg.edit_repo(
1160            "foo",
1161            RepoEdit {
1162                description: Some(None),
1163                ..RepoEdit::default()
1164            },
1165        )
1166        .unwrap();
1167        assert!(cfg.repos.get("foo").unwrap().description.is_none());
1168    }
1169
1170    #[test]
1171    fn edit_repo_rename_preserves_workspace_membership() {
1172        let mut cfg = Config::default();
1173        cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
1174        cfg.create_workspace("acme".into(), None).unwrap();
1175        cfg.add_members("acme", &["foo".into()]).unwrap();
1176
1177        let (name, _) = cfg
1178            .edit_repo(
1179                "foo",
1180                RepoEdit {
1181                    new_name: Some("bar".into()),
1182                    ..RepoEdit::default()
1183                },
1184            )
1185            .unwrap();
1186        assert_eq!(name, "bar");
1187        assert!(cfg.repos.contains_key("bar"));
1188        assert!(!cfg.repos.contains_key("foo"));
1189        // The workspace now references `bar` as a live member, no dangling.
1190        let (live, dangling) = cfg.resolve_workspace("acme").unwrap();
1191        assert!(dangling.is_empty(), "rename left a dangling member");
1192        assert_eq!(live.len(), 1);
1193        assert_eq!(live[0].0, "bar");
1194    }
1195
1196    #[test]
1197    fn edit_repo_rename_to_existing_name_conflicts() {
1198        let mut cfg = Config::default();
1199        cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
1200        cfg.add_repo("bar".into(), make("/tmp/bar")).unwrap();
1201        let err = cfg
1202            .edit_repo(
1203                "foo",
1204                RepoEdit {
1205                    new_name: Some("bar".into()),
1206                    ..RepoEdit::default()
1207                },
1208            )
1209            .unwrap_err();
1210        assert!(matches!(err, RepographError::Conflict { kind: "name", .. }));
1211        // No mutation: foo still present, bar untouched.
1212        assert!(cfg.repos.contains_key("foo"));
1213        assert_eq!(
1214            cfg.repos.get("bar").unwrap().path,
1215            PathBuf::from("/tmp/bar")
1216        );
1217    }
1218
1219    #[test]
1220    fn edit_repo_nonexistent_returns_not_found() {
1221        let mut cfg = Config::default();
1222        let err = cfg
1223            .edit_repo(
1224                "ghost",
1225                RepoEdit {
1226                    description: Some(Some("x".into())),
1227                    ..RepoEdit::default()
1228                },
1229            )
1230            .unwrap_err();
1231        assert!(matches!(err, RepographError::NotFound { kind: "repo", .. }));
1232    }
1233
1234    #[test]
1235    fn edit_repo_path_conflict_returns_conflict() {
1236        let mut cfg = Config::default();
1237        cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
1238        cfg.add_repo("bar".into(), make("/tmp/bar")).unwrap();
1239        let err = cfg
1240            .edit_repo(
1241                "foo",
1242                RepoEdit {
1243                    path: Some(PathBuf::from("/tmp/bar")),
1244                    ..RepoEdit::default()
1245                },
1246            )
1247            .unwrap_err();
1248        assert!(matches!(err, RepographError::Conflict { kind: "path", .. }));
1249        assert_eq!(
1250            cfg.repos.get("foo").unwrap().path,
1251            PathBuf::from("/tmp/foo")
1252        );
1253    }
1254
1255    #[test]
1256    fn edit_repo_rename_to_same_name_is_noop_not_conflict() {
1257        let mut cfg = Config::default();
1258        cfg.add_repo("foo".into(), make("/tmp/foo")).unwrap();
1259        let (name, _) = cfg
1260            .edit_repo(
1261                "foo",
1262                RepoEdit {
1263                    new_name: Some("foo".into()),
1264                    description: Some(Some("d".into())),
1265                    ..RepoEdit::default()
1266                },
1267            )
1268            .unwrap();
1269        assert_eq!(name, "foo");
1270        assert_eq!(
1271            cfg.repos.get("foo").unwrap().description.as_deref(),
1272            Some("d")
1273        );
1274    }
1275}