Skip to main content

loom_core/config/
mod.rs

1pub mod init;
2
3use std::collections::BTreeMap;
4use std::io::Write;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9
10/// Top-level LOOM configuration, stored at ~/.config/loom/config.toml
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct Config {
13    pub registry: RegistryConfig,
14    pub workspace: WorkspaceConfig,
15    #[serde(default)]
16    pub sync: Option<SyncConfig>,
17    #[serde(default)]
18    pub terminal: Option<TerminalConfig>,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub editor: Option<EditorConfig>,
21    #[serde(default)]
22    pub defaults: DefaultsConfig,
23    /// Named repo groups for quick workspace creation.
24    /// Each group maps a name to a list of repo names (bare or org/name).
25    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
26    pub groups: BTreeMap<String, Vec<String>>,
27    /// Per-repo settings (e.g., workflow). Keyed by repo name.
28    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
29    pub repos: BTreeMap<String, RepoConfig>,
30    /// Specs conventions for generated CLAUDE.md.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub specs: Option<SpecsConfig>,
33    #[serde(default)]
34    pub agents: AgentsConfig,
35    /// Auto-update settings.
36    #[serde(default, skip_serializing_if = "UpdateConfig::is_empty")]
37    pub update: UpdateConfig,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct RegistryConfig {
42    /// Directories to scan recursively for git repos
43    pub scan_roots: Vec<PathBuf>,
44    /// Scan depth for repo discovery: how many directory levels to traverse.
45    /// 1 = flat (root/repo), 2 = org-grouped (root/org/repo, default),
46    /// 3 = host/org/repo, 4 = max.
47    #[serde(
48        default = "default_scan_depth",
49        skip_serializing_if = "is_default_scan_depth"
50    )]
51    pub scan_depth: u8,
52}
53
54fn default_scan_depth() -> u8 {
55    2
56}
57
58fn is_default_scan_depth(v: &u8) -> bool {
59    *v == 2
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct WorkspaceConfig {
64    /// Root directory for all workspaces (default: ~/workspaces)
65    pub root: PathBuf,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct SyncConfig {
70    /// Path to sync repo (e.g., PKM repo)
71    pub repo: PathBuf,
72    /// Subdirectory within sync repo for workspace manifests
73    pub path: String,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct TerminalConfig {
78    /// Terminal command to open (e.g., "ghostty", "wezterm")
79    pub command: String,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct EditorConfig {
84    /// Editor command to open workspaces (e.g., "code", "cursor", "zed")
85    pub command: String,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct DefaultsConfig {
90    /// Branch prefix for worktrees (default: "loom")
91    #[serde(default = "default_branch_prefix")]
92    pub branch_prefix: String,
93}
94
95impl Default for DefaultsConfig {
96    fn default() -> Self {
97        Self {
98            branch_prefix: default_branch_prefix(),
99        }
100    }
101}
102
103/// A marketplace source for Claude Code plugins.
104/// MVP supports GitHub sources only; other source types can be added later.
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
106pub struct MarketplaceEntry {
107    /// Marketplace name (used as key in generated JSON)
108    pub name: String,
109    /// GitHub repo in "owner/repo" format
110    pub repo: String,
111}
112
113/// Sandbox filesystem isolation settings.
114#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
115#[serde(default)]
116pub struct SandboxFilesystemConfig {
117    #[serde(default, skip_serializing_if = "Vec::is_empty")]
118    pub allow_write: Vec<String>,
119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
120    pub deny_write: Vec<String>,
121    #[serde(default, skip_serializing_if = "Vec::is_empty")]
122    pub deny_read: Vec<String>,
123}
124
125impl SandboxFilesystemConfig {
126    pub(crate) fn is_empty(&self) -> bool {
127        self.allow_write.is_empty() && self.deny_write.is_empty() && self.deny_read.is_empty()
128    }
129}
130
131/// Sandbox network isolation settings.
132#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
133#[serde(default)]
134pub struct SandboxNetworkConfig {
135    #[serde(default, skip_serializing_if = "Vec::is_empty")]
136    pub allowed_domains: Vec<String>,
137    #[serde(default, skip_serializing_if = "Vec::is_empty")]
138    pub allow_unix_sockets: Vec<String>,
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub allow_local_binding: Option<bool>,
141}
142
143impl SandboxNetworkConfig {
144    pub(crate) fn is_empty(&self) -> bool {
145        self.allowed_domains.is_empty()
146            && self.allow_unix_sockets.is_empty()
147            && self.allow_local_binding.is_none()
148    }
149}
150
151/// OS-level sandbox configuration for Claude Code.
152#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
153#[serde(default)]
154pub struct SandboxConfig {
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub enabled: Option<bool>,
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub auto_allow: Option<bool>,
159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160    pub excluded_commands: Vec<String>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub allow_unsandboxed_commands: Option<bool>,
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub enable_weaker_network_isolation: Option<bool>,
165    #[serde(default, skip_serializing_if = "SandboxFilesystemConfig::is_empty")]
166    pub filesystem: SandboxFilesystemConfig,
167    #[serde(default, skip_serializing_if = "SandboxNetworkConfig::is_empty")]
168    pub network: SandboxNetworkConfig,
169}
170
171impl SandboxConfig {
172    pub(crate) fn is_empty(&self) -> bool {
173        self.enabled.is_none()
174            && self.auto_allow.is_none()
175            && self.excluded_commands.is_empty()
176            && self.allow_unsandboxed_commands.is_none()
177            && self.enable_weaker_network_isolation.is_none()
178            && self.filesystem.is_empty()
179            && self.network.is_empty()
180    }
181}
182
183/// Sandbox settings within a preset (arrays + `allow_local_binding` fallback; no top-level booleans).
184#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
185#[serde(default)]
186pub struct PresetSandboxConfig {
187    #[serde(default, skip_serializing_if = "SandboxFilesystemConfig::is_empty")]
188    pub filesystem: SandboxFilesystemConfig,
189    #[serde(default, skip_serializing_if = "SandboxNetworkConfig::is_empty")]
190    pub network: SandboxNetworkConfig,
191}
192
193impl PresetSandboxConfig {
194    pub(crate) fn is_empty(&self) -> bool {
195        self.filesystem.is_empty() && self.network.is_empty()
196    }
197}
198
199/// An MCP server definition (stdio or SSE).
200#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
201#[serde(default)]
202pub struct McpServerConfig {
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub command: Option<String>,
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    pub args: Option<Vec<String>>,
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub url: Option<String>,
209    #[serde(default, skip_serializing_if = "Option::is_none")]
210    pub env: Option<BTreeMap<String, String>>,
211}
212
213/// A named permission preset (e.g., "rust", "node").
214#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
215#[serde(default)]
216pub struct PermissionPreset {
217    #[serde(default, skip_serializing_if = "Vec::is_empty")]
218    pub allowed_tools: Vec<String>,
219    #[serde(default, skip_serializing_if = "PresetSandboxConfig::is_empty")]
220    pub sandbox: PresetSandboxConfig,
221    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
222    pub mcp_servers: BTreeMap<String, McpServerConfig>,
223}
224
225/// Effort level for adaptive reasoning (Opus 4.6 / Sonnet 4.6 only).
226#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
227#[serde(rename_all = "lowercase")]
228pub enum EffortLevel {
229    Low,
230    Medium,
231    High,
232}
233
234#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
235#[serde(default)]
236pub struct ClaudeCodeConfig {
237    /// Claude model alias or full model ID (e.g., "opus", "claude-opus-4-6")
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub model: Option<String>,
240
241    /// Effort level for adaptive reasoning (e.g., "low", "medium", "high")
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub effort_level: Option<EffortLevel>,
244
245    /// Extra marketplace repos
246    #[serde(default, skip_serializing_if = "Vec::is_empty")]
247    pub extra_known_marketplaces: Vec<MarketplaceEntry>,
248
249    /// Enabled plugins (e.g., ["pluginName@marketplaceName"])
250    #[serde(default, skip_serializing_if = "Vec::is_empty")]
251    pub enabled_plugins: Vec<String>,
252
253    /// MCP JSON servers to enable (e.g., ["linear", "notion"])
254    #[serde(default, skip_serializing_if = "Vec::is_empty")]
255    pub enabled_mcp_servers: Vec<String>,
256
257    /// Global permission allowlist entries (e.g., ["Bash(cargo test *)"])
258    #[serde(default, skip_serializing_if = "Vec::is_empty")]
259    pub allowed_tools: Vec<String>,
260
261    /// Global sandbox configuration
262    #[serde(default, skip_serializing_if = "SandboxConfig::is_empty")]
263    pub sandbox: SandboxConfig,
264
265    /// Environment variables to set in Claude Code sessions
266    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
267    pub env: BTreeMap<String, String>,
268
269    /// MCP server definitions (stdio or SSE)
270    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
271    pub mcp_servers: BTreeMap<String, McpServerConfig>,
272
273    /// Named permission presets (selected per workspace via --preset)
274    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
275    pub presets: BTreeMap<String, PermissionPreset>,
276}
277
278impl ClaudeCodeConfig {
279    /// Returns true when all fields are empty (used by serde skip_serializing_if and init re-check).
280    pub fn is_empty(&self) -> bool {
281        self.model.is_none()
282            && self.effort_level.is_none()
283            && self.extra_known_marketplaces.is_empty()
284            && self.enabled_plugins.is_empty()
285            && self.enabled_mcp_servers.is_empty()
286            && self.allowed_tools.is_empty()
287            && self.sandbox.is_empty()
288            && self.env.is_empty()
289            && self.mcp_servers.is_empty()
290            && self.presets.is_empty()
291    }
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct AgentsConfig {
296    /// Which agents to configure (e.g., ["claude-code"])
297    #[serde(default)]
298    pub enabled: Vec<String>,
299
300    /// Claude Code-specific settings
301    #[serde(
302        default,
303        rename = "claude-code",
304        skip_serializing_if = "ClaudeCodeConfig::is_empty"
305    )]
306    pub claude_code: ClaudeCodeConfig,
307}
308
309impl Default for AgentsConfig {
310    fn default() -> Self {
311        Self {
312            enabled: vec!["claude-code".to_string()],
313            claude_code: ClaudeCodeConfig::default(),
314        }
315    }
316}
317
318/// Push workflow for a repository.
319#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
320#[serde(rename_all = "lowercase")]
321pub enum Workflow {
322    /// Create a branch off origin/main, commit, push, open a PR.
323    #[default]
324    Pr,
325    /// Commit on the workspace branch, push directly to main.
326    Push,
327}
328
329impl std::fmt::Display for Workflow {
330    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
331        match self {
332            Workflow::Pr => write!(f, "pr"),
333            Workflow::Push => write!(f, "push"),
334        }
335    }
336}
337
338impl Workflow {
339    /// Human-readable label for the repos table (e.g., "PR to `main`").
340    pub fn label(self, default_branch: &str) -> String {
341        match self {
342            Workflow::Pr => format!("PR to `{default_branch}`"),
343            Workflow::Push => format!("Push to `{default_branch}`"),
344        }
345    }
346}
347
348/// Per-repo configuration, keyed by repo name.
349#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
350pub struct RepoConfig {
351    /// Push workflow: Pr (default) or Push.
352    #[serde(default)]
353    pub workflow: Workflow,
354}
355
356/// Specs conventions for generated CLAUDE.md.
357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358pub struct SpecsConfig {
359    /// Path to specs directory, relative to workspace root.
360    pub path: String,
361}
362
363/// Auto-update settings.
364#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
365pub struct UpdateConfig {
366    /// Whether auto-update is enabled (default: true).
367    #[serde(default = "default_true")]
368    pub enabled: bool,
369}
370
371impl Default for UpdateConfig {
372    fn default() -> Self {
373        Self { enabled: true }
374    }
375}
376
377impl UpdateConfig {
378    /// Returns true when the config is at its default value (enabled = true).
379    /// Used by `skip_serializing_if` to omit the `[update]` section when unnecessary.
380    /// Named `is_empty` to match the project's serde convention.
381    pub fn is_empty(&self) -> bool {
382        *self == Self::default()
383    }
384}
385
386fn default_true() -> bool {
387    true
388}
389
390fn default_branch_prefix() -> String {
391    "loom".to_string()
392}
393
394/// Validate that a preset name exists in the config's preset map.
395///
396/// Returns `Ok(())` if the preset exists, or a descriptive error listing available presets.
397pub fn validate_preset_exists(
398    presets: &BTreeMap<String, PermissionPreset>,
399    preset_name: &str,
400) -> Result<()> {
401    if presets.contains_key(preset_name) {
402        return Ok(());
403    }
404    let available: Vec<&str> = presets.keys().map(|s| s.as_str()).collect();
405    if available.is_empty() {
406        anyhow::bail!(
407            "Preset '{}' not found. No presets defined in config.toml.",
408            preset_name
409        );
410    } else {
411        anyhow::bail!(
412            "Preset '{}' not found. Available presets: {}",
413            preset_name,
414            available.join(", ")
415        );
416    }
417}
418
419/// Validate a Claude Code permission entry has the form `ToolName(specifier)`
420/// or is a bare MCP tool name (e.g., `mcp__sentry__find_organizations`).
421fn validate_permission_entry(entry: &str, context: &str) -> Result<()> {
422    let trimmed = entry.trim();
423    if trimmed.is_empty() {
424        anyhow::bail!("{context}: permission entry cannot be empty");
425    }
426    // Bare MCP tool names are valid (e.g., "mcp__sentry__find_organizations")
427    if trimmed.starts_with("mcp__") && !trimmed.contains('(') {
428        return Ok(());
429    }
430    if !trimmed.ends_with(')') || !trimmed.contains('(') {
431        anyhow::bail!("{context}: invalid format '{trimmed}' — expected ToolName(specifier)");
432    }
433    let paren_idx = trimmed.find('(').expect("already checked for '('");
434    let specifier = &trimmed[paren_idx + 1..trimmed.len() - 1];
435    if specifier.trim().is_empty() {
436        anyhow::bail!("{context}: specifier in '{trimmed}' cannot be empty");
437    }
438    let tool_name = &trimmed[..paren_idx];
439    if !tool_name.starts_with("mcp__")
440        && !tool_name
441            .chars()
442            .next()
443            .is_some_and(|c| c.is_ascii_uppercase())
444    {
445        anyhow::bail!(
446            "{context}: tool name must start with uppercase letter or 'mcp__', got '{tool_name}'"
447        );
448    }
449    Ok(())
450}
451
452/// Validate that no entry in a string list is empty or whitespace-only.
453fn validate_no_empty_entries(entries: &[String], context: &str) -> Result<()> {
454    for entry in entries {
455        if entry.trim().is_empty() {
456            anyhow::bail!("{context}: entries cannot be empty or whitespace-only");
457        }
458    }
459    Ok(())
460}
461
462/// Validate that a string list has no duplicates.
463fn validate_no_duplicates(entries: &[String], context: &str) -> Result<()> {
464    let mut seen = std::collections::HashSet::new();
465    for entry in entries {
466        if !seen.insert(entry) {
467            anyhow::bail!("{context}: duplicate entry '{entry}'");
468        }
469    }
470    Ok(())
471}
472
473/// Validate that a path has no parent-directory (`..`) components and is not absolute.
474pub(crate) fn validate_no_path_traversal(path: &str, context: &str) -> Result<()> {
475    use std::path::{Component, Path as StdPath};
476    let p = StdPath::new(path);
477    if p.is_absolute() {
478        anyhow::bail!("{context}: path must not be absolute: '{path}'");
479    }
480    if p.components().any(|c| c == Component::ParentDir) {
481        anyhow::bail!("{context}: path must not contain '..' components: '{path}'");
482    }
483    Ok(())
484}
485
486/// Validate sandbox filesystem and network entries for a given context prefix.
487fn validate_sandbox_entries(
488    fs: &SandboxFilesystemConfig,
489    net: &SandboxNetworkConfig,
490    context: &str,
491) -> Result<()> {
492    validate_no_empty_entries(
493        &fs.allow_write,
494        &format!("{context}.sandbox.filesystem.allow_write"),
495    )?;
496    validate_no_empty_entries(
497        &fs.deny_write,
498        &format!("{context}.sandbox.filesystem.deny_write"),
499    )?;
500    validate_no_empty_entries(
501        &fs.deny_read,
502        &format!("{context}.sandbox.filesystem.deny_read"),
503    )?;
504    validate_no_empty_entries(
505        &net.allowed_domains,
506        &format!("{context}.sandbox.network.allowed_domains"),
507    )?;
508    validate_no_empty_entries(
509        &net.allow_unix_sockets,
510        &format!("{context}.sandbox.network.allow_unix_sockets"),
511    )?;
512    validate_no_duplicates(
513        &net.allow_unix_sockets,
514        &format!("{context}.sandbox.network.allow_unix_sockets"),
515    )?;
516    for entry in &net.allow_unix_sockets {
517        if !entry.starts_with('/') {
518            anyhow::bail!(
519                "{context}.sandbox.network.allow_unix_sockets: '{}' must be an absolute path",
520                entry
521            );
522        }
523        let p = Path::new(entry);
524        if p.components().any(|c| c == std::path::Component::ParentDir) {
525            anyhow::bail!(
526                "{context}.sandbox.network.allow_unix_sockets: '{}' must not contain '..' components",
527                entry
528            );
529        }
530    }
531    Ok(())
532}
533
534/// Validate that environment variable keys are non-empty and contain no `=` or NUL characters.
535fn validate_env_keys(env: &BTreeMap<String, String>, context: &str) -> Result<()> {
536    for key in env.keys() {
537        if key.trim().is_empty() {
538            anyhow::bail!("{context}: key cannot be empty or whitespace-only");
539        }
540        if key.contains('=') || key.contains('\0') {
541            anyhow::bail!(
542                "{context}: key '{}' contains invalid character ('=' or NUL)",
543                key
544            );
545        }
546    }
547    Ok(())
548}
549
550/// Validate a single MCP server config (command XOR url, no args on SSE servers).
551fn validate_mcp_server(server: &McpServerConfig, context: &str) -> Result<()> {
552    match (&server.command, &server.url) {
553        (Some(_), Some(_)) => anyhow::bail!("{context}: cannot have both 'command' and 'url'"),
554        (None, None) => anyhow::bail!("{context}: must have either 'command' or 'url'"),
555        (Some(cmd), None) => {
556            if cmd.trim().is_empty() {
557                anyhow::bail!("{context}: 'command' cannot be empty or whitespace-only");
558            }
559        }
560        (None, Some(url)) => {
561            if url.trim().is_empty() {
562                anyhow::bail!("{context}: 'url' cannot be empty or whitespace-only");
563            }
564            if server.args.is_some() {
565                anyhow::bail!("{context}: 'args' cannot be used with 'url' (SSE transport)");
566            }
567        }
568    }
569    if let Some(env) = &server.env {
570        validate_env_keys(env, &format!("{context}.env"))?;
571    }
572    Ok(())
573}
574
575/// Expand ~ and environment variables in a path using shellexpand
576fn expand_path(path: &Path) -> PathBuf {
577    let s = path.to_string_lossy();
578    let expanded = shellexpand::tilde(&s);
579    PathBuf::from(expanded.as_ref())
580}
581
582impl Config {
583    /// Load config from ~/.config/loom/config.toml
584    pub fn load() -> Result<Self> {
585        let path = Self::path()?;
586        Self::load_from(&path)
587    }
588
589    /// Load config from a specific path (useful for testing)
590    pub fn load_from(path: &Path) -> Result<Self> {
591        if !path.exists() {
592            anyhow::bail!(
593                "Configuration not found at {}. Run `loom init` to create a config file.",
594                path.display()
595            );
596        }
597        let content = std::fs::read_to_string(path)
598            .with_context(|| format!("Failed to read config at {}", path.display()))?;
599        let mut config: Config = toml::from_str(&content)
600            .with_context(|| format!("Failed to parse config at {}", path.display()))?;
601        config.expand_paths();
602        Ok(config)
603    }
604
605    /// Save config to ~/.config/loom/config.toml atomically
606    pub fn save(&self) -> Result<()> {
607        let path = Self::path()?;
608        self.save_to(&path)
609    }
610
611    /// Save config to a specific path atomically (useful for testing)
612    pub fn save_to(&self, path: &Path) -> Result<()> {
613        let content = toml::to_string_pretty(self).context("Failed to serialize config to TOML")?;
614
615        // Ensure parent directory exists
616        if let Some(parent) = path.parent() {
617            std::fs::create_dir_all(parent).with_context(|| {
618                format!("Failed to create config directory {}", parent.display())
619            })?;
620        }
621
622        // Atomic write: create temp file in same dir, write, then persist (rename)
623        let parent = path.parent().unwrap_or(Path::new("."));
624        let mut tmp = tempfile::NamedTempFile::new_in(parent)
625            .with_context(|| format!("Failed to create temp file in {}", parent.display()))?;
626        tmp.write_all(content.as_bytes())
627            .with_context(|| "Failed to write config to temp file")?;
628        tmp.persist(path)
629            .with_context(|| format!("Failed to persist config to {}", path.display()))?;
630
631        Ok(())
632    }
633
634    /// Sensible defaults for `loom init`
635    pub fn default_config() -> Self {
636        Self {
637            registry: RegistryConfig {
638                scan_roots: Vec::new(),
639                scan_depth: 2,
640            },
641            workspace: WorkspaceConfig {
642                root: PathBuf::from("~/workspaces"),
643            },
644            sync: None,
645            terminal: None,
646            editor: None,
647            defaults: DefaultsConfig::default(),
648            groups: BTreeMap::new(),
649            repos: BTreeMap::new(),
650            specs: None,
651            agents: AgentsConfig::default(),
652            update: UpdateConfig::default(),
653        }
654    }
655
656    /// Path to the config file: ~/.config/loom/config.toml
657    ///
658    /// Hardcoded to ~/.config/loom/ for cross-platform consistency.
659    /// This matches developer tool conventions (ripgrep, bat, starship)
660    /// and avoids the `directories` crate's macOS path (~Library/Application Support/).
661    pub fn path() -> Result<PathBuf> {
662        let home = dirs::home_dir()
663            .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?;
664        Ok(home.join(".config").join("loom").join("config.toml"))
665    }
666
667    /// Expand ~ in all PathBuf fields (post-deserialization step)
668    fn expand_paths(&mut self) {
669        for root in &mut self.registry.scan_roots {
670            *root = expand_path(root);
671        }
672        self.workspace.root = expand_path(&self.workspace.root);
673        if let Some(sync) = &mut self.sync {
674            sync.repo = expand_path(&sync.repo);
675        }
676    }
677
678    /// Validate only the agent config section (no path-existence checks).
679    ///
680    /// Use this during `loom init` where paths may not exist yet.
681    /// Validate agent permission and sandbox syntax only.
682    ///
683    /// This checks allowed_tools format, sandbox paths, and preset entries without
684    /// requiring that scan_roots or workspace.root exist on disk. Safe to call at
685    /// init time before directories are created. Does **not** check marketplace/plugin
686    /// entries — use [`validate()`](Self::validate) for the full post-load check.
687    pub fn validate_agent_config(&self) -> Result<()> {
688        let cc = &self.agents.claude_code;
689        if let Some(ref model) = cc.model
690            && model.trim().is_empty()
691        {
692            anyhow::bail!("agents.claude-code.model cannot be empty or whitespace-only");
693        }
694        for entry in &cc.allowed_tools {
695            validate_permission_entry(entry, "agents.claude-code.allowed_tools")?;
696        }
697        validate_no_duplicates(&cc.allowed_tools, "agents.claude-code.allowed_tools")?;
698        validate_sandbox_entries(
699            &cc.sandbox.filesystem,
700            &cc.sandbox.network,
701            "agents.claude-code",
702        )?;
703        validate_no_empty_entries(
704            &cc.sandbox.excluded_commands,
705            "agents.claude-code.sandbox.excluded_commands",
706        )?;
707        validate_no_empty_entries(
708            &cc.enabled_mcp_servers,
709            "agents.claude-code.enabled_mcp_servers",
710        )?;
711        validate_no_duplicates(
712            &cc.enabled_mcp_servers,
713            "agents.claude-code.enabled_mcp_servers",
714        )?;
715        validate_env_keys(&cc.env, "agents.claude-code.env")?;
716        // Validate MCP server configs
717        for (name, server) in &cc.mcp_servers {
718            validate_mcp_server(server, &format!("agents.claude-code.mcp_servers.{name}"))?;
719        }
720        for (name, preset) in &cc.presets {
721            let ctx = format!("agents.claude-code.presets.{name}");
722            for entry in &preset.allowed_tools {
723                validate_permission_entry(entry, &format!("{ctx}.allowed_tools"))?;
724            }
725            validate_no_duplicates(&preset.allowed_tools, &format!("{ctx}.allowed_tools"))?;
726            validate_sandbox_entries(&preset.sandbox.filesystem, &preset.sandbox.network, &ctx)?;
727            for (srv_name, server) in &preset.mcp_servers {
728                validate_mcp_server(server, &format!("{ctx}.mcp_servers.{srv_name}"))?;
729            }
730        }
731        Ok(())
732    }
733
734    /// Full post-load validation: path existence, branch prefix, marketplace/plugin
735    /// entries, and all agent config (delegates to [`validate_agent_config()`](Self::validate_agent_config)).
736    pub fn validate(&self) -> Result<()> {
737        // Validate scan_depth range
738        if self.registry.scan_depth == 0 || self.registry.scan_depth > 4 {
739            anyhow::bail!(
740                "registry.scan_depth must be between 1 and 4 (got {}). \
741                 1=flat, 2=org-grouped (default), 3=host/org/repo, 4=max.",
742                self.registry.scan_depth
743            );
744        }
745
746        // Validate editor command is not empty
747        if let Some(ref editor) = self.editor
748            && editor.command.trim().is_empty()
749        {
750            anyhow::bail!("editor.command cannot be empty.");
751        }
752
753        // Validate scan_roots paths exist
754        for root in &self.registry.scan_roots {
755            if !root.exists() {
756                anyhow::bail!(
757                    "scan_roots path `{}` does not exist. Create it or update config.",
758                    root.display()
759                );
760            }
761            if !root.is_dir() {
762                anyhow::bail!("scan_roots path `{}` is not a directory.", root.display());
763            }
764        }
765
766        // Validate workspace root parent exists (we can create the root itself)
767        let ws_root = &self.workspace.root;
768        if let Some(parent) = ws_root.parent()
769            && !parent.as_os_str().is_empty()
770            && !parent.exists()
771        {
772            anyhow::bail!(
773                "workspace.root parent `{}` does not exist. Create it first.",
774                parent.display()
775            );
776        }
777
778        // Validate branch_prefix is a valid git ref component
779        let prefix = &self.defaults.branch_prefix;
780        if prefix.is_empty() {
781            anyhow::bail!("defaults.branch_prefix cannot be empty.");
782        }
783        if prefix.contains(' ') || prefix.contains("..") || prefix.starts_with('.') {
784            anyhow::bail!(
785                "defaults.branch_prefix `{}` is not a valid git ref component.",
786                prefix
787            );
788        }
789
790        // Validate marketplace entries
791        let mut seen_names = std::collections::HashSet::new();
792        for entry in &self.agents.claude_code.extra_known_marketplaces {
793            if entry.name.is_empty() {
794                anyhow::bail!(
795                    "agents.claude-code.extra_known_marketplaces: marketplace name cannot be empty."
796                );
797            }
798            match entry.repo.split_once('/') {
799                Some((owner, repo))
800                    if !owner.is_empty() && !repo.is_empty() && !repo.contains('/') => {}
801                _ => {
802                    anyhow::bail!(
803                        "agents.claude-code.extra_known_marketplaces: repo '{}' must be in 'owner/repo' format.",
804                        entry.repo
805                    );
806                }
807            }
808            if !seen_names.insert(&entry.name) {
809                anyhow::bail!(
810                    "agents.claude-code.extra_known_marketplaces: duplicate marketplace name '{}'.",
811                    entry.name
812                );
813            }
814        }
815
816        // Validate enabled_plugins format
817        for plugin in &self.agents.claude_code.enabled_plugins {
818            match plugin.split_once('@') {
819                Some((name, marketplace)) if !name.is_empty() && !marketplace.is_empty() => {}
820                _ => {
821                    anyhow::bail!(
822                        "agents.claude-code.enabled_plugins: '{}' must be in 'pluginName@marketplaceName' format.",
823                        plugin
824                    );
825                }
826            }
827        }
828
829        // Validate repos config keys
830        for key in self.repos.keys() {
831            if key.trim().is_empty() {
832                anyhow::bail!("repos: key must not be empty or whitespace-only");
833            }
834        }
835
836        // Validate groups.
837        // Note: group repo names are NOT cross-validated against the registry here
838        // because the registry is discovered at runtime from scan_roots (not available
839        // at config load time). Validation of repo names happens at workspace creation.
840        for (name, repos) in &self.groups {
841            crate::manifest::validate_name(name)
842                .with_context(|| format!("groups: invalid group name '{name}'"))?;
843            if repos.is_empty() {
844                anyhow::bail!("groups.{name}: group must contain at least one repo");
845            }
846            validate_no_empty_entries(repos, &format!("groups.{name}"))?;
847            validate_no_duplicates(repos, &format!("groups.{name}"))?;
848        }
849
850        // Validate specs config
851        if let Some(specs) = &self.specs {
852            if specs.path.trim().is_empty() {
853                anyhow::bail!("specs.path must not be empty");
854            }
855            validate_no_path_traversal(&specs.path, "specs.path")?;
856        }
857
858        // Validate agent config (permissions, sandbox, presets)
859        self.validate_agent_config()?;
860
861        Ok(())
862    }
863}
864
865/// Load config from standard path, returning actionable error if missing.
866/// Use this in every command that needs config (all except `init`).
867pub fn ensure_config_loaded() -> Result<Config> {
868    let config = Config::load()?;
869    config.validate()?;
870    Ok(config)
871}
872
873#[cfg(test)]
874mod tests {
875    use super::*;
876
877    #[test]
878    fn test_toml_round_trip() {
879        let config = Config {
880            registry: RegistryConfig {
881                scan_roots: vec![PathBuf::from("/home/user/code")],
882                scan_depth: 2,
883            },
884            workspace: WorkspaceConfig {
885                root: PathBuf::from("/home/user/loom"),
886            },
887            sync: Some(SyncConfig {
888                repo: PathBuf::from("/home/user/pkm"),
889                path: "loom".to_string(),
890            }),
891            terminal: Some(TerminalConfig {
892                command: "ghostty".to_string(),
893            }),
894            editor: None,
895            defaults: DefaultsConfig {
896                branch_prefix: "loom".to_string(),
897            },
898            groups: BTreeMap::new(),
899            repos: BTreeMap::new(),
900            specs: None,
901            agents: AgentsConfig {
902                enabled: vec!["claude-code".to_string()],
903                ..Default::default()
904            },
905            update: UpdateConfig::default(),
906        };
907
908        let toml_str = toml::to_string_pretty(&config).unwrap();
909        let parsed: Config = toml::from_str(&toml_str).unwrap();
910
911        assert_eq!(parsed.registry.scan_roots, config.registry.scan_roots);
912        assert_eq!(parsed.workspace.root, config.workspace.root);
913        assert_eq!(parsed.defaults.branch_prefix, "loom");
914        assert!(parsed.sync.is_some());
915        assert!(parsed.terminal.is_some());
916        assert!(parsed.update.enabled);
917    }
918
919    #[test]
920    fn test_update_config_disabled() {
921        let toml_str = r#"
922[registry]
923scan_roots = ["/code"]
924
925[workspace]
926root = "/loom"
927
928[update]
929enabled = false
930"#;
931        let config: Config = toml::from_str(toml_str).unwrap();
932        assert!(!config.update.enabled);
933    }
934
935    #[test]
936    fn test_update_config_defaults_to_enabled() {
937        let toml_str = r#"
938[registry]
939scan_roots = ["/code"]
940
941[workspace]
942root = "/loom"
943"#;
944        let config: Config = toml::from_str(toml_str).unwrap();
945        assert!(config.update.enabled);
946    }
947
948    #[test]
949    fn test_missing_optional_fields() {
950        let toml_str = r#"
951[registry]
952scan_roots = ["/code"]
953
954[workspace]
955root = "/loom"
956"#;
957        let config: Config = toml::from_str(toml_str).unwrap();
958        assert!(config.sync.is_none());
959        assert!(config.terminal.is_none());
960        assert_eq!(config.defaults.branch_prefix, "loom");
961        // Default agents config includes claude-code
962        assert_eq!(config.agents.enabled, vec!["claude-code"]);
963    }
964
965    #[test]
966    fn test_tilde_expansion() {
967        let path = PathBuf::from("~/code");
968        let expanded = expand_path(&path);
969        assert!(!expanded.to_string_lossy().contains('~'));
970        assert!(expanded.to_string_lossy().len() > 6); // longer than ~/code
971    }
972
973    #[test]
974    fn test_config_path() {
975        let path = Config::path().unwrap();
976        let path_str = path.to_string_lossy();
977        assert!(path_str.contains(".config/loom/config.toml"));
978    }
979
980    #[test]
981    fn test_save_and_load() {
982        let dir = tempfile::tempdir().unwrap();
983        let config_path = dir.path().join("config.toml");
984
985        let config = Config {
986            registry: RegistryConfig {
987                scan_roots: vec![dir.path().to_path_buf()],
988                scan_depth: 2,
989            },
990            workspace: WorkspaceConfig {
991                root: dir.path().join("workspaces"),
992            },
993            sync: None,
994            terminal: None,
995            editor: None,
996            defaults: DefaultsConfig::default(),
997            groups: BTreeMap::new(),
998            repos: BTreeMap::new(),
999            specs: None,
1000            agents: AgentsConfig::default(),
1001            update: UpdateConfig::default(),
1002        };
1003
1004        config.save_to(&config_path).unwrap();
1005        let loaded = Config::load_from(&config_path).unwrap();
1006
1007        assert_eq!(loaded.registry.scan_roots, config.registry.scan_roots);
1008        assert_eq!(loaded.workspace.root, config.workspace.root);
1009    }
1010
1011    #[test]
1012    fn test_validate_invalid_branch_prefix() {
1013        let dir = tempfile::tempdir().unwrap();
1014        let config = Config {
1015            registry: RegistryConfig {
1016                scan_roots: vec![dir.path().to_path_buf()],
1017                scan_depth: 2,
1018            },
1019            workspace: WorkspaceConfig {
1020                root: dir.path().to_path_buf(),
1021            },
1022            sync: None,
1023            terminal: None,
1024            editor: None,
1025            defaults: DefaultsConfig {
1026                branch_prefix: "..invalid".to_string(),
1027            },
1028            groups: BTreeMap::new(),
1029            repos: BTreeMap::new(),
1030            specs: None,
1031            agents: AgentsConfig::default(),
1032            update: UpdateConfig::default(),
1033        };
1034
1035        assert!(config.validate().is_err());
1036    }
1037
1038    #[test]
1039    fn test_validate_missing_scan_root() {
1040        let config = Config {
1041            registry: RegistryConfig {
1042                scan_roots: vec![PathBuf::from("/nonexistent/path/abc123")],
1043                scan_depth: 2,
1044            },
1045            workspace: WorkspaceConfig {
1046                root: PathBuf::from("/tmp"),
1047            },
1048            sync: None,
1049            terminal: None,
1050            editor: None,
1051            defaults: DefaultsConfig::default(),
1052            groups: BTreeMap::new(),
1053            repos: BTreeMap::new(),
1054            specs: None,
1055            agents: AgentsConfig::default(),
1056            update: UpdateConfig::default(),
1057        };
1058
1059        let err = config.validate().unwrap_err();
1060        assert!(err.to_string().contains("does not exist"));
1061    }
1062
1063    #[test]
1064    fn test_load_nonexistent() {
1065        let result = Config::load_from(Path::new("/nonexistent/config.toml"));
1066        assert!(result.is_err());
1067        let err = result.unwrap_err();
1068        assert!(err.to_string().contains("loom init"));
1069    }
1070
1071    #[test]
1072    fn test_default_config() {
1073        let config = Config::default_config();
1074        assert!(config.registry.scan_roots.is_empty());
1075        assert_eq!(config.workspace.root, PathBuf::from("~/workspaces"));
1076        assert_eq!(config.defaults.branch_prefix, "loom");
1077        assert_eq!(config.agents.enabled, vec!["claude-code"]);
1078    }
1079
1080    #[test]
1081    fn test_claude_code_config_round_trip() {
1082        let config = Config {
1083            registry: RegistryConfig {
1084                scan_roots: vec![PathBuf::from("/code")],
1085                scan_depth: 2,
1086            },
1087            workspace: WorkspaceConfig {
1088                root: PathBuf::from("/loom"),
1089            },
1090            sync: None,
1091            terminal: None,
1092            editor: None,
1093            defaults: DefaultsConfig::default(),
1094            groups: BTreeMap::new(),
1095            repos: BTreeMap::new(),
1096            specs: None,
1097            agents: AgentsConfig {
1098                enabled: vec!["claude-code".to_string()],
1099                claude_code: ClaudeCodeConfig {
1100                    extra_known_marketplaces: vec![
1101                        MarketplaceEntry {
1102                            name: "my-plugins".to_string(),
1103                            repo: "owner/my-plugins".to_string(),
1104                        },
1105                        MarketplaceEntry {
1106                            name: "team-plugins".to_string(),
1107                            repo: "org/team-plugins".to_string(),
1108                        },
1109                    ],
1110                    enabled_plugins: vec![
1111                        "pkm@my-plugins".to_string(),
1112                        "eng@team-plugins".to_string(),
1113                    ],
1114                    enabled_mcp_servers: vec!["linear".to_string(), "notion".to_string()],
1115                    ..Default::default()
1116                },
1117            },
1118            update: UpdateConfig::default(),
1119        };
1120
1121        let toml_str = toml::to_string_pretty(&config).unwrap();
1122        let parsed: Config = toml::from_str(&toml_str).unwrap();
1123
1124        assert_eq!(
1125            parsed.agents.claude_code.extra_known_marketplaces,
1126            config.agents.claude_code.extra_known_marketplaces
1127        );
1128        assert_eq!(
1129            parsed.agents.claude_code.enabled_plugins,
1130            config.agents.claude_code.enabled_plugins
1131        );
1132        assert_eq!(
1133            parsed.agents.claude_code.enabled_mcp_servers,
1134            config.agents.claude_code.enabled_mcp_servers
1135        );
1136    }
1137
1138    #[test]
1139    fn test_claude_code_config_empty_suppressed_in_toml() {
1140        let config = Config {
1141            registry: RegistryConfig {
1142                scan_roots: vec![PathBuf::from("/code")],
1143                scan_depth: 2,
1144            },
1145            workspace: WorkspaceConfig {
1146                root: PathBuf::from("/loom"),
1147            },
1148            sync: None,
1149            terminal: None,
1150            editor: None,
1151            defaults: DefaultsConfig::default(),
1152            groups: BTreeMap::new(),
1153            repos: BTreeMap::new(),
1154            specs: None,
1155            agents: AgentsConfig::default(),
1156            update: UpdateConfig::default(),
1157        };
1158
1159        let toml_str = toml::to_string_pretty(&config).unwrap();
1160        // Empty ClaudeCodeConfig should not produce [agents.claude-code] section header
1161        assert!(
1162            !toml_str.contains("[agents.claude-code]"),
1163            "Empty claude-code config section should be suppressed in TOML:\n{toml_str}"
1164        );
1165    }
1166
1167    #[test]
1168    fn test_missing_claude_code_section_deserializes() {
1169        let toml_str = r#"
1170[registry]
1171scan_roots = ["/code"]
1172
1173[workspace]
1174root = "/loom"
1175
1176[agents]
1177enabled = ["claude-code"]
1178"#;
1179        let config: Config = toml::from_str(toml_str).unwrap();
1180        assert!(
1181            config
1182                .agents
1183                .claude_code
1184                .extra_known_marketplaces
1185                .is_empty()
1186        );
1187        assert!(config.agents.claude_code.enabled_plugins.is_empty());
1188    }
1189
1190    #[test]
1191    fn test_validate_duplicate_marketplace_name() {
1192        let dir = tempfile::tempdir().unwrap();
1193        let config = Config {
1194            registry: RegistryConfig {
1195                scan_roots: vec![dir.path().to_path_buf()],
1196                scan_depth: 2,
1197            },
1198            workspace: WorkspaceConfig {
1199                root: dir.path().to_path_buf(),
1200            },
1201            sync: None,
1202            terminal: None,
1203            editor: None,
1204            defaults: DefaultsConfig::default(),
1205            groups: BTreeMap::new(),
1206            repos: BTreeMap::new(),
1207            specs: None,
1208            agents: AgentsConfig {
1209                enabled: vec!["claude-code".to_string()],
1210                claude_code: ClaudeCodeConfig {
1211                    extra_known_marketplaces: vec![
1212                        MarketplaceEntry {
1213                            name: "dupe".to_string(),
1214                            repo: "owner/repo1".to_string(),
1215                        },
1216                        MarketplaceEntry {
1217                            name: "dupe".to_string(),
1218                            repo: "owner/repo2".to_string(),
1219                        },
1220                    ],
1221                    ..Default::default()
1222                },
1223            },
1224            update: UpdateConfig::default(),
1225        };
1226
1227        let err = config.validate().unwrap_err();
1228        assert!(err.to_string().contains("duplicate marketplace name"));
1229    }
1230
1231    #[test]
1232    fn test_validate_empty_marketplace_name() {
1233        let dir = tempfile::tempdir().unwrap();
1234        let config = Config {
1235            registry: RegistryConfig {
1236                scan_roots: vec![dir.path().to_path_buf()],
1237                scan_depth: 2,
1238            },
1239            workspace: WorkspaceConfig {
1240                root: dir.path().to_path_buf(),
1241            },
1242            sync: None,
1243            terminal: None,
1244            editor: None,
1245            defaults: DefaultsConfig::default(),
1246            groups: BTreeMap::new(),
1247            repos: BTreeMap::new(),
1248            specs: None,
1249            agents: AgentsConfig {
1250                enabled: vec!["claude-code".to_string()],
1251                claude_code: ClaudeCodeConfig {
1252                    extra_known_marketplaces: vec![MarketplaceEntry {
1253                        name: String::new(),
1254                        repo: "owner/repo".to_string(),
1255                    }],
1256                    ..Default::default()
1257                },
1258            },
1259            update: UpdateConfig::default(),
1260        };
1261
1262        let err = config.validate().unwrap_err();
1263        assert!(err.to_string().contains("name cannot be empty"));
1264    }
1265
1266    #[test]
1267    fn test_validate_invalid_marketplace_repo() {
1268        let dir = tempfile::tempdir().unwrap();
1269        let config = Config {
1270            registry: RegistryConfig {
1271                scan_roots: vec![dir.path().to_path_buf()],
1272                scan_depth: 2,
1273            },
1274            workspace: WorkspaceConfig {
1275                root: dir.path().to_path_buf(),
1276            },
1277            sync: None,
1278            terminal: None,
1279            editor: None,
1280            defaults: DefaultsConfig::default(),
1281            groups: BTreeMap::new(),
1282            repos: BTreeMap::new(),
1283            specs: None,
1284            agents: AgentsConfig {
1285                enabled: vec!["claude-code".to_string()],
1286                claude_code: ClaudeCodeConfig {
1287                    extra_known_marketplaces: vec![MarketplaceEntry {
1288                        name: "test".to_string(),
1289                        repo: "no-slash".to_string(),
1290                    }],
1291                    ..Default::default()
1292                },
1293            },
1294            update: UpdateConfig::default(),
1295        };
1296
1297        let err = config.validate().unwrap_err();
1298        assert!(err.to_string().contains("owner/repo"));
1299    }
1300
1301    #[test]
1302    fn test_validate_repo_with_multiple_slashes() {
1303        let dir = tempfile::tempdir().unwrap();
1304        let config = Config {
1305            registry: RegistryConfig {
1306                scan_roots: vec![dir.path().to_path_buf()],
1307                scan_depth: 2,
1308            },
1309            workspace: WorkspaceConfig {
1310                root: dir.path().to_path_buf(),
1311            },
1312            sync: None,
1313            terminal: None,
1314            editor: None,
1315            defaults: DefaultsConfig::default(),
1316            groups: BTreeMap::new(),
1317            repos: BTreeMap::new(),
1318            specs: None,
1319            agents: AgentsConfig {
1320                enabled: vec!["claude-code".to_string()],
1321                claude_code: ClaudeCodeConfig {
1322                    extra_known_marketplaces: vec![MarketplaceEntry {
1323                        name: "test".to_string(),
1324                        repo: "a/b/c".to_string(),
1325                    }],
1326                    ..Default::default()
1327                },
1328            },
1329            update: UpdateConfig::default(),
1330        };
1331
1332        let err = config.validate().unwrap_err();
1333        assert!(err.to_string().contains("owner/repo"));
1334    }
1335
1336    #[test]
1337    fn test_validate_plugin_empty_parts() {
1338        let dir = tempfile::tempdir().unwrap();
1339        // "@marketplace" — empty plugin name
1340        let config = Config {
1341            registry: RegistryConfig {
1342                scan_roots: vec![dir.path().to_path_buf()],
1343                scan_depth: 2,
1344            },
1345            workspace: WorkspaceConfig {
1346                root: dir.path().to_path_buf(),
1347            },
1348            sync: None,
1349            terminal: None,
1350            editor: None,
1351            defaults: DefaultsConfig::default(),
1352            groups: BTreeMap::new(),
1353            repos: BTreeMap::new(),
1354            specs: None,
1355            agents: AgentsConfig {
1356                enabled: vec!["claude-code".to_string()],
1357                claude_code: ClaudeCodeConfig {
1358                    enabled_plugins: vec!["@marketplace".to_string()],
1359                    ..Default::default()
1360                },
1361            },
1362            update: UpdateConfig::default(),
1363        };
1364        assert!(config.validate().is_err());
1365
1366        // "plugin@" — empty marketplace name
1367        let config2 = Config {
1368            groups: BTreeMap::new(),
1369            repos: BTreeMap::new(),
1370            specs: None,
1371            agents: AgentsConfig {
1372                enabled: vec!["claude-code".to_string()],
1373                claude_code: ClaudeCodeConfig {
1374                    enabled_plugins: vec!["plugin@".to_string()],
1375                    ..Default::default()
1376                },
1377            },
1378            ..config.clone()
1379        };
1380        assert!(config2.validate().is_err());
1381
1382        // "@" — both parts empty
1383        let config3 = Config {
1384            groups: BTreeMap::new(),
1385            repos: BTreeMap::new(),
1386            specs: None,
1387            agents: AgentsConfig {
1388                enabled: vec!["claude-code".to_string()],
1389                claude_code: ClaudeCodeConfig {
1390                    enabled_plugins: vec!["@".to_string()],
1391                    ..Default::default()
1392                },
1393            },
1394            ..config
1395        };
1396        assert!(config3.validate().is_err());
1397    }
1398
1399    #[test]
1400    fn test_validate_invalid_plugin_format() {
1401        let dir = tempfile::tempdir().unwrap();
1402        let config = Config {
1403            registry: RegistryConfig {
1404                scan_roots: vec![dir.path().to_path_buf()],
1405                scan_depth: 2,
1406            },
1407            workspace: WorkspaceConfig {
1408                root: dir.path().to_path_buf(),
1409            },
1410            sync: None,
1411            terminal: None,
1412            editor: None,
1413            defaults: DefaultsConfig::default(),
1414            groups: BTreeMap::new(),
1415            repos: BTreeMap::new(),
1416            specs: None,
1417            agents: AgentsConfig {
1418                enabled: vec!["claude-code".to_string()],
1419                claude_code: ClaudeCodeConfig {
1420                    enabled_plugins: vec!["no-at-sign".to_string()],
1421                    ..Default::default()
1422                },
1423            },
1424            update: UpdateConfig::default(),
1425        };
1426
1427        let err = config.validate().unwrap_err();
1428        assert!(err.to_string().contains("pluginName@marketplaceName"));
1429    }
1430
1431    #[test]
1432    fn test_allowed_tools_round_trip() {
1433        let config = Config {
1434            registry: RegistryConfig {
1435                scan_roots: vec![PathBuf::from("/code")],
1436                scan_depth: 2,
1437            },
1438            workspace: WorkspaceConfig {
1439                root: PathBuf::from("/loom"),
1440            },
1441            sync: None,
1442            terminal: None,
1443            editor: None,
1444            defaults: DefaultsConfig::default(),
1445            groups: BTreeMap::new(),
1446            repos: BTreeMap::new(),
1447            specs: None,
1448            agents: AgentsConfig {
1449                enabled: vec!["claude-code".to_string()],
1450                claude_code: ClaudeCodeConfig {
1451                    allowed_tools: vec![
1452                        "Bash(cargo test *)".to_string(),
1453                        "WebFetch(domain:docs.rs)".to_string(),
1454                    ],
1455                    ..Default::default()
1456                },
1457            },
1458            update: UpdateConfig::default(),
1459        };
1460
1461        let toml_str = toml::to_string_pretty(&config).unwrap();
1462        let parsed: Config = toml::from_str(&toml_str).unwrap();
1463        assert_eq!(
1464            parsed.agents.claude_code.allowed_tools,
1465            config.agents.claude_code.allowed_tools
1466        );
1467    }
1468
1469    #[test]
1470    fn test_sandbox_config_round_trip() {
1471        let config = Config {
1472            registry: RegistryConfig {
1473                scan_roots: vec![PathBuf::from("/code")],
1474                scan_depth: 2,
1475            },
1476            workspace: WorkspaceConfig {
1477                root: PathBuf::from("/loom"),
1478            },
1479            sync: None,
1480            terminal: None,
1481            editor: None,
1482            defaults: DefaultsConfig::default(),
1483            groups: BTreeMap::new(),
1484            repos: BTreeMap::new(),
1485            specs: None,
1486            agents: AgentsConfig {
1487                enabled: vec!["claude-code".to_string()],
1488                claude_code: ClaudeCodeConfig {
1489                    sandbox: SandboxConfig {
1490                        enabled: Some(true),
1491                        auto_allow: Some(true),
1492                        excluded_commands: vec!["docker".to_string()],
1493                        allow_unsandboxed_commands: Some(false),
1494                        enable_weaker_network_isolation: None,
1495                        filesystem: SandboxFilesystemConfig {
1496                            allow_write: vec!["~/.cargo".to_string()],
1497                            deny_write: vec![],
1498                            deny_read: vec![],
1499                        },
1500                        network: SandboxNetworkConfig {
1501                            allowed_domains: vec!["github.com".to_string()],
1502                            allow_unix_sockets: vec!["/tmp/ssh-agent.sock".to_string()],
1503                            allow_local_binding: Some(true),
1504                        },
1505                    },
1506                    ..Default::default()
1507                },
1508            },
1509            update: UpdateConfig::default(),
1510        };
1511
1512        let toml_str = toml::to_string_pretty(&config).unwrap();
1513        let parsed: Config = toml::from_str(&toml_str).unwrap();
1514        assert_eq!(
1515            parsed.agents.claude_code.sandbox,
1516            config.agents.claude_code.sandbox
1517        );
1518    }
1519
1520    #[test]
1521    fn test_presets_round_trip() {
1522        let mut presets = BTreeMap::new();
1523        presets.insert(
1524            "rust".to_string(),
1525            PermissionPreset {
1526                allowed_tools: vec![
1527                    "Bash(cargo test *)".to_string(),
1528                    "Bash(cargo clippy *)".to_string(),
1529                ],
1530                sandbox: PresetSandboxConfig {
1531                    filesystem: SandboxFilesystemConfig {
1532                        allow_write: vec!["~/.cargo".to_string()],
1533                        ..Default::default()
1534                    },
1535                    network: SandboxNetworkConfig {
1536                        allowed_domains: vec!["docs.rs".to_string(), "crates.io".to_string()],
1537                        ..Default::default()
1538                    },
1539                },
1540                ..Default::default()
1541            },
1542        );
1543
1544        let config = Config {
1545            registry: RegistryConfig {
1546                scan_roots: vec![PathBuf::from("/code")],
1547                scan_depth: 2,
1548            },
1549            workspace: WorkspaceConfig {
1550                root: PathBuf::from("/loom"),
1551            },
1552            sync: None,
1553            terminal: None,
1554            editor: None,
1555            defaults: DefaultsConfig::default(),
1556            groups: BTreeMap::new(),
1557            repos: BTreeMap::new(),
1558            specs: None,
1559            agents: AgentsConfig {
1560                enabled: vec!["claude-code".to_string()],
1561                claude_code: ClaudeCodeConfig {
1562                    presets,
1563                    ..Default::default()
1564                },
1565            },
1566            update: UpdateConfig::default(),
1567        };
1568
1569        let toml_str = toml::to_string_pretty(&config).unwrap();
1570        let parsed: Config = toml::from_str(&toml_str).unwrap();
1571        assert_eq!(
1572            parsed.agents.claude_code.presets,
1573            config.agents.claude_code.presets
1574        );
1575    }
1576
1577    #[test]
1578    fn test_sandbox_empty_suppressed() {
1579        let config = Config {
1580            registry: RegistryConfig {
1581                scan_roots: vec![PathBuf::from("/code")],
1582                scan_depth: 2,
1583            },
1584            workspace: WorkspaceConfig {
1585                root: PathBuf::from("/loom"),
1586            },
1587            sync: None,
1588            terminal: None,
1589            editor: None,
1590            defaults: DefaultsConfig::default(),
1591            groups: BTreeMap::new(),
1592            repos: BTreeMap::new(),
1593            specs: None,
1594            agents: AgentsConfig::default(),
1595            update: UpdateConfig::default(),
1596        };
1597
1598        let toml_str = toml::to_string_pretty(&config).unwrap();
1599        assert!(
1600            !toml_str.contains("sandbox"),
1601            "Empty sandbox should be suppressed:\n{toml_str}"
1602        );
1603        assert!(
1604            !toml_str.contains("allowed_tools"),
1605            "Empty allowed_tools should be suppressed:\n{toml_str}"
1606        );
1607        assert!(
1608            !toml_str.contains("presets"),
1609            "Empty presets should be suppressed:\n{toml_str}"
1610        );
1611    }
1612
1613    #[test]
1614    fn test_sandbox_enabled_only_is_not_empty() {
1615        let sandbox = SandboxConfig {
1616            enabled: Some(true),
1617            ..Default::default()
1618        };
1619        assert!(
1620            !sandbox.is_empty(),
1621            "sandbox with enabled=true should not be empty"
1622        );
1623    }
1624
1625    #[test]
1626    fn test_validate_permission_entries() {
1627        assert!(validate_permission_entry("Bash(cargo test *)", "test").is_ok());
1628        assert!(validate_permission_entry("mcp__slack__send(channel *)", "test").is_ok());
1629        assert!(validate_permission_entry("WebFetch(domain:docs.rs)", "test").is_ok());
1630        assert!(validate_permission_entry("Skill(eng:workflows:plan)", "test").is_ok());
1631
1632        // Bare MCP tool names are valid
1633        assert!(validate_permission_entry("mcp__sentry__find_organizations", "test").is_ok());
1634        assert!(validate_permission_entry("mcp__linear__get_issue", "test").is_ok());
1635
1636        // Invalid cases
1637        assert!(validate_permission_entry("", "test").is_err());
1638        assert!(validate_permission_entry("   ", "test").is_err());
1639        assert!(validate_permission_entry("Bash", "test").is_err());
1640        assert!(validate_permission_entry("bash(cargo test *)", "test").is_err());
1641        // Non-mcp bare names are still invalid
1642        assert!(validate_permission_entry("invalid_bare_name", "test").is_err());
1643        // Empty specifier
1644        assert!(validate_permission_entry("Bash()", "test").is_err());
1645        assert!(validate_permission_entry("Bash(  )", "test").is_err());
1646    }
1647
1648    #[test]
1649    fn test_validate_allowed_tools_duplicates() {
1650        let dir = tempfile::tempdir().unwrap();
1651        let config = Config {
1652            registry: RegistryConfig {
1653                scan_roots: vec![dir.path().to_path_buf()],
1654                scan_depth: 2,
1655            },
1656            workspace: WorkspaceConfig {
1657                root: dir.path().to_path_buf(),
1658            },
1659            sync: None,
1660            terminal: None,
1661            editor: None,
1662            defaults: DefaultsConfig::default(),
1663            groups: BTreeMap::new(),
1664            repos: BTreeMap::new(),
1665            specs: None,
1666            agents: AgentsConfig {
1667                enabled: vec!["claude-code".to_string()],
1668                claude_code: ClaudeCodeConfig {
1669                    allowed_tools: vec![
1670                        "Bash(cargo test *)".to_string(),
1671                        "Bash(cargo test *)".to_string(),
1672                    ],
1673                    ..Default::default()
1674                },
1675            },
1676            update: UpdateConfig::default(),
1677        };
1678
1679        let err = config.validate().unwrap_err();
1680        assert!(err.to_string().contains("duplicate"));
1681    }
1682
1683    #[test]
1684    fn test_validate_empty_model_rejected() {
1685        let dir = tempfile::tempdir().unwrap();
1686        let config = Config {
1687            registry: RegistryConfig {
1688                scan_roots: vec![dir.path().to_path_buf()],
1689                scan_depth: 2,
1690            },
1691            workspace: WorkspaceConfig {
1692                root: dir.path().to_path_buf(),
1693            },
1694            sync: None,
1695            terminal: None,
1696            editor: None,
1697            defaults: DefaultsConfig::default(),
1698            groups: BTreeMap::new(),
1699            repos: BTreeMap::new(),
1700            specs: None,
1701            agents: AgentsConfig {
1702                enabled: vec!["claude-code".to_string()],
1703                claude_code: ClaudeCodeConfig {
1704                    model: Some("  ".to_string()),
1705                    ..Default::default()
1706                },
1707            },
1708            update: UpdateConfig::default(),
1709        };
1710
1711        let err = config.validate_agent_config().unwrap_err();
1712        assert!(
1713            err.to_string()
1714                .contains("cannot be empty or whitespace-only")
1715        );
1716    }
1717
1718    #[test]
1719    fn test_validate_sandbox_empty_path() {
1720        let dir = tempfile::tempdir().unwrap();
1721        let config = Config {
1722            registry: RegistryConfig {
1723                scan_roots: vec![dir.path().to_path_buf()],
1724                scan_depth: 2,
1725            },
1726            workspace: WorkspaceConfig {
1727                root: dir.path().to_path_buf(),
1728            },
1729            sync: None,
1730            terminal: None,
1731            editor: None,
1732            defaults: DefaultsConfig::default(),
1733            groups: BTreeMap::new(),
1734            repos: BTreeMap::new(),
1735            specs: None,
1736            agents: AgentsConfig {
1737                enabled: vec!["claude-code".to_string()],
1738                claude_code: ClaudeCodeConfig {
1739                    sandbox: SandboxConfig {
1740                        filesystem: SandboxFilesystemConfig {
1741                            allow_write: vec!["  ".to_string()],
1742                            ..Default::default()
1743                        },
1744                        ..Default::default()
1745                    },
1746                    ..Default::default()
1747                },
1748            },
1749            update: UpdateConfig::default(),
1750        };
1751
1752        let err = config.validate().unwrap_err();
1753        assert!(err.to_string().contains("empty or whitespace"));
1754    }
1755
1756    #[test]
1757    fn test_validate_allow_unix_sockets_empty_entry() {
1758        let config = Config {
1759            registry: RegistryConfig {
1760                scan_roots: vec![],
1761                scan_depth: 2,
1762            },
1763            workspace: WorkspaceConfig {
1764                root: PathBuf::from("/loom"),
1765            },
1766            sync: None,
1767            terminal: None,
1768            editor: None,
1769            defaults: DefaultsConfig::default(),
1770            groups: BTreeMap::new(),
1771            repos: BTreeMap::new(),
1772            specs: None,
1773            agents: AgentsConfig {
1774                enabled: vec!["claude-code".to_string()],
1775                claude_code: ClaudeCodeConfig {
1776                    sandbox: SandboxConfig {
1777                        network: SandboxNetworkConfig {
1778                            allow_unix_sockets: vec!["  ".to_string()],
1779                            ..Default::default()
1780                        },
1781                        ..Default::default()
1782                    },
1783                    ..Default::default()
1784                },
1785            },
1786            update: UpdateConfig::default(),
1787        };
1788
1789        let err = config.validate_agent_config().unwrap_err();
1790        assert!(err.to_string().contains("empty or whitespace"));
1791        assert!(err.to_string().contains("allow_unix_sockets"));
1792    }
1793
1794    #[test]
1795    fn test_validate_allow_unix_sockets_duplicate() {
1796        let config = Config {
1797            registry: RegistryConfig {
1798                scan_roots: vec![],
1799                scan_depth: 2,
1800            },
1801            workspace: WorkspaceConfig {
1802                root: PathBuf::from("/loom"),
1803            },
1804            sync: None,
1805            terminal: None,
1806            editor: None,
1807            defaults: DefaultsConfig::default(),
1808            groups: BTreeMap::new(),
1809            repos: BTreeMap::new(),
1810            specs: None,
1811            agents: AgentsConfig {
1812                enabled: vec!["claude-code".to_string()],
1813                claude_code: ClaudeCodeConfig {
1814                    sandbox: SandboxConfig {
1815                        network: SandboxNetworkConfig {
1816                            allow_unix_sockets: vec![
1817                                "/tmp/sock".to_string(),
1818                                "/tmp/sock".to_string(),
1819                            ],
1820                            ..Default::default()
1821                        },
1822                        ..Default::default()
1823                    },
1824                    ..Default::default()
1825                },
1826            },
1827            update: UpdateConfig::default(),
1828        };
1829
1830        let err = config.validate_agent_config().unwrap_err();
1831        assert!(err.to_string().contains("duplicate"));
1832        assert!(err.to_string().contains("allow_unix_sockets"));
1833    }
1834
1835    #[test]
1836    fn test_validate_allow_unix_sockets_relative_path() {
1837        let config = Config {
1838            registry: RegistryConfig {
1839                scan_roots: vec![],
1840                scan_depth: 2,
1841            },
1842            workspace: WorkspaceConfig {
1843                root: PathBuf::from("/loom"),
1844            },
1845            sync: None,
1846            terminal: None,
1847            editor: None,
1848            defaults: DefaultsConfig::default(),
1849            groups: BTreeMap::new(),
1850            repos: BTreeMap::new(),
1851            specs: None,
1852            agents: AgentsConfig {
1853                enabled: vec!["claude-code".to_string()],
1854                claude_code: ClaudeCodeConfig {
1855                    sandbox: SandboxConfig {
1856                        network: SandboxNetworkConfig {
1857                            allow_unix_sockets: vec!["relative/path.sock".to_string()],
1858                            ..Default::default()
1859                        },
1860                        ..Default::default()
1861                    },
1862                    ..Default::default()
1863                },
1864            },
1865            update: UpdateConfig::default(),
1866        };
1867
1868        let err = config.validate_agent_config().unwrap_err();
1869        assert!(err.to_string().contains("must be an absolute path"));
1870    }
1871
1872    #[test]
1873    fn test_validate_allow_unix_sockets_parent_dir() {
1874        let config = Config {
1875            registry: RegistryConfig {
1876                scan_roots: vec![],
1877                scan_depth: 2,
1878            },
1879            workspace: WorkspaceConfig {
1880                root: PathBuf::from("/loom"),
1881            },
1882            sync: None,
1883            terminal: None,
1884            editor: None,
1885            defaults: DefaultsConfig::default(),
1886            groups: BTreeMap::new(),
1887            repos: BTreeMap::new(),
1888            specs: None,
1889            agents: AgentsConfig {
1890                enabled: vec!["claude-code".to_string()],
1891                claude_code: ClaudeCodeConfig {
1892                    sandbox: SandboxConfig {
1893                        network: SandboxNetworkConfig {
1894                            allow_unix_sockets: vec!["/tmp/../etc/sock".to_string()],
1895                            ..Default::default()
1896                        },
1897                        ..Default::default()
1898                    },
1899                    ..Default::default()
1900                },
1901            },
1902            update: UpdateConfig::default(),
1903        };
1904
1905        let err = config.validate_agent_config().unwrap_err();
1906        assert!(err.to_string().contains("must not contain '..'"));
1907    }
1908
1909    #[test]
1910    fn test_validate_enabled_mcp_servers_empty_entry() {
1911        let config = Config {
1912            registry: RegistryConfig {
1913                scan_roots: vec![],
1914                scan_depth: 2,
1915            },
1916            workspace: WorkspaceConfig {
1917                root: PathBuf::from("/loom"),
1918            },
1919            sync: None,
1920            terminal: None,
1921            editor: None,
1922            defaults: DefaultsConfig::default(),
1923            groups: BTreeMap::new(),
1924            repos: BTreeMap::new(),
1925            specs: None,
1926            agents: AgentsConfig {
1927                enabled: vec!["claude-code".to_string()],
1928                claude_code: ClaudeCodeConfig {
1929                    enabled_mcp_servers: vec!["".to_string()],
1930                    ..Default::default()
1931                },
1932            },
1933            update: UpdateConfig::default(),
1934        };
1935
1936        let err = config.validate_agent_config().unwrap_err();
1937        assert!(err.to_string().contains("empty or whitespace"));
1938        assert!(err.to_string().contains("enabled_mcp_servers"));
1939    }
1940
1941    #[test]
1942    fn test_validate_enabled_mcp_servers_duplicates() {
1943        let config = Config {
1944            registry: RegistryConfig {
1945                scan_roots: vec![],
1946                scan_depth: 2,
1947            },
1948            workspace: WorkspaceConfig {
1949                root: PathBuf::from("/loom"),
1950            },
1951            sync: None,
1952            terminal: None,
1953            editor: None,
1954            defaults: DefaultsConfig::default(),
1955            groups: BTreeMap::new(),
1956            repos: BTreeMap::new(),
1957            specs: None,
1958            agents: AgentsConfig {
1959                enabled: vec!["claude-code".to_string()],
1960                claude_code: ClaudeCodeConfig {
1961                    enabled_mcp_servers: vec!["linear".to_string(), "linear".to_string()],
1962                    ..Default::default()
1963                },
1964            },
1965            update: UpdateConfig::default(),
1966        };
1967
1968        let err = config.validate_agent_config().unwrap_err();
1969        assert!(err.to_string().contains("duplicate"));
1970        assert!(err.to_string().contains("enabled_mcp_servers"));
1971    }
1972
1973    #[test]
1974    fn test_validate_preset_invalid_permission() {
1975        let dir = tempfile::tempdir().unwrap();
1976        let mut presets = BTreeMap::new();
1977        presets.insert(
1978            "bad".to_string(),
1979            PermissionPreset {
1980                allowed_tools: vec!["bash(lowercase)".to_string()],
1981                ..Default::default()
1982            },
1983        );
1984
1985        let config = Config {
1986            registry: RegistryConfig {
1987                scan_roots: vec![dir.path().to_path_buf()],
1988                scan_depth: 2,
1989            },
1990            workspace: WorkspaceConfig {
1991                root: dir.path().to_path_buf(),
1992            },
1993            sync: None,
1994            terminal: None,
1995            editor: None,
1996            defaults: DefaultsConfig::default(),
1997            groups: BTreeMap::new(),
1998            repos: BTreeMap::new(),
1999            specs: None,
2000            agents: AgentsConfig {
2001                enabled: vec!["claude-code".to_string()],
2002                claude_code: ClaudeCodeConfig {
2003                    presets,
2004                    ..Default::default()
2005                },
2006            },
2007            update: UpdateConfig::default(),
2008        };
2009
2010        let err = config.validate().unwrap_err();
2011        assert!(err.to_string().contains("presets.bad"));
2012    }
2013
2014    #[test]
2015    fn test_full_config_round_trip() {
2016        let mut presets = BTreeMap::new();
2017        presets.insert(
2018            "rust".to_string(),
2019            PermissionPreset {
2020                allowed_tools: vec!["Bash(cargo test *)".to_string()],
2021                sandbox: PresetSandboxConfig {
2022                    filesystem: SandboxFilesystemConfig {
2023                        allow_write: vec!["~/.cargo".to_string()],
2024                        ..Default::default()
2025                    },
2026                    network: SandboxNetworkConfig {
2027                        allowed_domains: vec!["docs.rs".to_string()],
2028                        allow_unix_sockets: vec!["/tmp/preset.sock".to_string()],
2029                        ..Default::default()
2030                    },
2031                },
2032                ..Default::default()
2033            },
2034        );
2035
2036        let config = Config {
2037            registry: RegistryConfig {
2038                scan_roots: vec![PathBuf::from("/code")],
2039                scan_depth: 2,
2040            },
2041            workspace: WorkspaceConfig {
2042                root: PathBuf::from("/loom"),
2043            },
2044            sync: None,
2045            terminal: None,
2046            editor: None,
2047            defaults: DefaultsConfig::default(),
2048            groups: BTreeMap::new(),
2049            repos: BTreeMap::new(),
2050            specs: None,
2051            agents: AgentsConfig {
2052                enabled: vec!["claude-code".to_string()],
2053                claude_code: ClaudeCodeConfig {
2054                    extra_known_marketplaces: vec![MarketplaceEntry {
2055                        name: "test".to_string(),
2056                        repo: "org/test".to_string(),
2057                    }],
2058                    enabled_plugins: vec!["eng@test".to_string()],
2059                    enabled_mcp_servers: vec!["linear".to_string()],
2060                    allowed_tools: vec!["Bash(gh issue *)".to_string()],
2061                    sandbox: SandboxConfig {
2062                        enabled: Some(true),
2063                        auto_allow: Some(true),
2064                        excluded_commands: vec!["docker".to_string()],
2065                        allow_unsandboxed_commands: None,
2066                        enable_weaker_network_isolation: None,
2067                        filesystem: SandboxFilesystemConfig {
2068                            allow_write: vec!["~/.config/loom".to_string()],
2069                            ..Default::default()
2070                        },
2071                        network: SandboxNetworkConfig {
2072                            allowed_domains: vec!["github.com".to_string()],
2073                            allow_unix_sockets: vec!["/tmp/global.sock".to_string()],
2074                            ..Default::default()
2075                        },
2076                    },
2077                    presets,
2078                    ..Default::default()
2079                },
2080            },
2081            update: UpdateConfig::default(),
2082        };
2083
2084        let toml_str = toml::to_string_pretty(&config).unwrap();
2085        let parsed: Config = toml::from_str(&toml_str).unwrap();
2086
2087        assert_eq!(
2088            parsed.agents.claude_code.allowed_tools,
2089            config.agents.claude_code.allowed_tools
2090        );
2091        assert_eq!(
2092            parsed.agents.claude_code.sandbox,
2093            config.agents.claude_code.sandbox
2094        );
2095        assert_eq!(
2096            parsed.agents.claude_code.presets,
2097            config.agents.claude_code.presets
2098        );
2099    }
2100
2101    #[test]
2102    fn test_workflow_serde_valid_values() {
2103        let toml_pr = r#"workflow = "pr""#;
2104        let parsed: RepoConfig = toml::from_str(toml_pr).unwrap();
2105        assert_eq!(parsed.workflow, Workflow::Pr);
2106
2107        let toml_push = r#"workflow = "push""#;
2108        let parsed: RepoConfig = toml::from_str(toml_push).unwrap();
2109        assert_eq!(parsed.workflow, Workflow::Push);
2110    }
2111
2112    #[test]
2113    fn test_workflow_default_is_pr() {
2114        // Missing workflow field should default to Pr
2115        let toml_str = "";
2116        let parsed: RepoConfig = toml::from_str(toml_str).unwrap();
2117        assert_eq!(parsed.workflow, Workflow::Pr);
2118    }
2119
2120    #[test]
2121    fn test_workflow_invalid_value_rejected() {
2122        let toml_str = r#"workflow = "merge""#;
2123        let result: Result<RepoConfig, _> = toml::from_str(toml_str);
2124        assert!(result.is_err());
2125        let err = result.unwrap_err().to_string();
2126        assert!(
2127            err.contains("merge") || err.contains("unknown variant"),
2128            "Error should mention the invalid value: {err}"
2129        );
2130    }
2131
2132    #[test]
2133    fn test_workflow_uppercase_rejected() {
2134        let toml_str = r#"workflow = "PR""#;
2135        let result: Result<RepoConfig, _> = toml::from_str(toml_str);
2136        assert!(result.is_err());
2137    }
2138
2139    #[test]
2140    fn test_workflow_label() {
2141        assert_eq!(Workflow::Pr.label("main"), "PR to `main`");
2142        assert_eq!(Workflow::Push.label("main"), "Push to `main`");
2143        assert_eq!(Workflow::Pr.label("develop"), "PR to `develop`");
2144    }
2145
2146    #[test]
2147    fn test_validate_specs_empty_path() {
2148        let dir = tempfile::tempdir().unwrap();
2149        let config = Config {
2150            registry: RegistryConfig {
2151                scan_roots: vec![dir.path().to_path_buf()],
2152                scan_depth: 2,
2153            },
2154            workspace: WorkspaceConfig {
2155                root: dir.path().to_path_buf(),
2156            },
2157            sync: None,
2158            terminal: None,
2159            editor: None,
2160            defaults: DefaultsConfig::default(),
2161            groups: BTreeMap::new(),
2162            repos: BTreeMap::new(),
2163            specs: Some(SpecsConfig {
2164                path: "  ".to_string(),
2165            }),
2166            agents: AgentsConfig::default(),
2167            update: UpdateConfig::default(),
2168        };
2169        let err = config.validate().unwrap_err();
2170        assert!(err.to_string().contains("must not be empty"));
2171    }
2172
2173    #[test]
2174    fn test_validate_specs_path_traversal() {
2175        let dir = tempfile::tempdir().unwrap();
2176        let config = Config {
2177            registry: RegistryConfig {
2178                scan_roots: vec![dir.path().to_path_buf()],
2179                scan_depth: 2,
2180            },
2181            workspace: WorkspaceConfig {
2182                root: dir.path().to_path_buf(),
2183            },
2184            sync: None,
2185            terminal: None,
2186            editor: None,
2187            defaults: DefaultsConfig::default(),
2188            groups: BTreeMap::new(),
2189            repos: BTreeMap::new(),
2190            specs: Some(SpecsConfig {
2191                path: "../etc/passwd".to_string(),
2192            }),
2193            agents: AgentsConfig::default(),
2194            update: UpdateConfig::default(),
2195        };
2196        let err = config.validate().unwrap_err();
2197        assert!(err.to_string().contains(".."));
2198    }
2199
2200    #[test]
2201    fn test_validate_specs_absolute_path() {
2202        let dir = tempfile::tempdir().unwrap();
2203        let config = Config {
2204            registry: RegistryConfig {
2205                scan_roots: vec![dir.path().to_path_buf()],
2206                scan_depth: 2,
2207            },
2208            workspace: WorkspaceConfig {
2209                root: dir.path().to_path_buf(),
2210            },
2211            sync: None,
2212            terminal: None,
2213            editor: None,
2214            defaults: DefaultsConfig::default(),
2215            groups: BTreeMap::new(),
2216            repos: BTreeMap::new(),
2217            specs: Some(SpecsConfig {
2218                path: "/etc/passwd".to_string(),
2219            }),
2220            agents: AgentsConfig::default(),
2221            update: UpdateConfig::default(),
2222        };
2223        let err = config.validate().unwrap_err();
2224        assert!(err.to_string().contains("absolute"));
2225    }
2226
2227    #[test]
2228    fn test_validate_no_path_traversal_accepts_legitimate_paths() {
2229        // Dots in filenames are fine
2230        assert!(validate_no_path_traversal("v2..3/file", "test").is_ok());
2231        assert!(validate_no_path_traversal(".hidden/config", "test").is_ok());
2232        assert!(validate_no_path_traversal("pkm/01 - PROJECTS/specs", "test").is_ok());
2233    }
2234
2235    #[test]
2236    fn test_validate_no_path_traversal_rejects_bad_paths() {
2237        assert!(validate_no_path_traversal("../etc/passwd", "test").is_err());
2238        assert!(validate_no_path_traversal("foo/../../bar", "test").is_err());
2239        assert!(validate_no_path_traversal("/etc/passwd", "test").is_err());
2240    }
2241
2242    #[test]
2243    fn test_toml_round_trip_with_repos_and_specs() {
2244        let mut repos = BTreeMap::new();
2245        repos.insert(
2246            "loom".to_string(),
2247            RepoConfig {
2248                workflow: Workflow::Pr,
2249            },
2250        );
2251        repos.insert(
2252            "pkm".to_string(),
2253            RepoConfig {
2254                workflow: Workflow::Push,
2255            },
2256        );
2257
2258        let config = Config {
2259            registry: RegistryConfig {
2260                scan_roots: vec![PathBuf::from("/code")],
2261                scan_depth: 2,
2262            },
2263            workspace: WorkspaceConfig {
2264                root: PathBuf::from("/loom"),
2265            },
2266            sync: None,
2267            terminal: None,
2268            editor: None,
2269            defaults: DefaultsConfig::default(),
2270            groups: BTreeMap::new(),
2271            repos,
2272            specs: Some(SpecsConfig {
2273                path: "pkm/01 - PROJECTS/Personal/LOOM/specs".to_string(),
2274            }),
2275            agents: AgentsConfig::default(),
2276            update: UpdateConfig::default(),
2277        };
2278
2279        let toml_str = toml::to_string_pretty(&config).unwrap();
2280        let parsed: Config = toml::from_str(&toml_str).unwrap();
2281
2282        assert_eq!(parsed.repos.len(), 2);
2283        assert_eq!(parsed.repos["loom"].workflow, Workflow::Pr);
2284        assert_eq!(parsed.repos["pkm"].workflow, Workflow::Push);
2285        assert_eq!(
2286            parsed.specs.as_ref().unwrap().path,
2287            "pkm/01 - PROJECTS/Personal/LOOM/specs"
2288        );
2289    }
2290
2291    #[test]
2292    fn test_toml_round_trip_hyphenated_repo_names() {
2293        let mut repos = BTreeMap::new();
2294        repos.insert(
2295            "dsp-api".to_string(),
2296            RepoConfig {
2297                workflow: Workflow::Push,
2298            },
2299        );
2300
2301        let config = Config {
2302            registry: RegistryConfig {
2303                scan_roots: vec![PathBuf::from("/code")],
2304                scan_depth: 2,
2305            },
2306            workspace: WorkspaceConfig {
2307                root: PathBuf::from("/loom"),
2308            },
2309            sync: None,
2310            terminal: None,
2311            editor: None,
2312            defaults: DefaultsConfig::default(),
2313            groups: BTreeMap::new(),
2314            repos,
2315            specs: None,
2316            agents: AgentsConfig::default(),
2317            update: UpdateConfig::default(),
2318        };
2319
2320        let toml_str = toml::to_string_pretty(&config).unwrap();
2321        assert!(toml_str.contains("[repos.dsp-api]"));
2322
2323        let parsed: Config = toml::from_str(&toml_str).unwrap();
2324        assert_eq!(parsed.repos["dsp-api"].workflow, Workflow::Push);
2325    }
2326
2327    #[test]
2328    fn test_repos_and_specs_suppressed_when_empty() {
2329        let config = Config {
2330            registry: RegistryConfig {
2331                scan_roots: vec![PathBuf::from("/code")],
2332                scan_depth: 2,
2333            },
2334            workspace: WorkspaceConfig {
2335                root: PathBuf::from("/loom"),
2336            },
2337            sync: None,
2338            terminal: None,
2339            editor: None,
2340            defaults: DefaultsConfig::default(),
2341            groups: BTreeMap::new(),
2342            repos: BTreeMap::new(),
2343            specs: None,
2344            agents: AgentsConfig::default(),
2345            update: UpdateConfig::default(),
2346        };
2347
2348        let toml_str = toml::to_string_pretty(&config).unwrap();
2349        assert!(
2350            !toml_str.contains("[repos"),
2351            "Empty repos should be suppressed:\n{toml_str}"
2352        );
2353        assert!(
2354            !toml_str.contains("[specs"),
2355            "None specs should be suppressed:\n{toml_str}"
2356        );
2357    }
2358
2359    #[test]
2360    fn test_groups_toml_round_trip() {
2361        let mut groups = BTreeMap::new();
2362        groups.insert(
2363            "dsp-stack".to_string(),
2364            vec![
2365                "dsp-api".to_string(),
2366                "dsp-das".to_string(),
2367                "sipi".to_string(),
2368            ],
2369        );
2370        groups.insert(
2371            "infra".to_string(),
2372            vec!["dsp-api".to_string(), "dsp-tools".to_string()],
2373        );
2374
2375        let config = Config {
2376            registry: RegistryConfig {
2377                scan_roots: vec![PathBuf::from("/code")],
2378                scan_depth: 2,
2379            },
2380            workspace: WorkspaceConfig {
2381                root: PathBuf::from("/loom"),
2382            },
2383            sync: None,
2384            terminal: None,
2385            editor: None,
2386            defaults: DefaultsConfig::default(),
2387            groups: groups.clone(),
2388            repos: BTreeMap::new(),
2389            specs: None,
2390            agents: AgentsConfig::default(),
2391            update: UpdateConfig::default(),
2392        };
2393
2394        let toml_str = toml::to_string_pretty(&config).unwrap();
2395        assert!(toml_str.contains("[groups]"));
2396        assert!(toml_str.contains("dsp-stack"));
2397        assert!(toml_str.contains("infra"));
2398
2399        let parsed: Config = toml::from_str(&toml_str).unwrap();
2400        assert_eq!(parsed.groups, groups);
2401    }
2402
2403    #[test]
2404    fn test_groups_empty_suppressed_in_toml() {
2405        let config = Config {
2406            registry: RegistryConfig {
2407                scan_roots: vec![PathBuf::from("/code")],
2408                scan_depth: 2,
2409            },
2410            workspace: WorkspaceConfig {
2411                root: PathBuf::from("/loom"),
2412            },
2413            sync: None,
2414            terminal: None,
2415            editor: None,
2416            defaults: DefaultsConfig::default(),
2417            groups: BTreeMap::new(),
2418            repos: BTreeMap::new(),
2419            specs: None,
2420            agents: AgentsConfig::default(),
2421            update: UpdateConfig::default(),
2422        };
2423
2424        let toml_str = toml::to_string_pretty(&config).unwrap();
2425        assert!(
2426            !toml_str.contains("[groups"),
2427            "Empty groups should be suppressed:\n{toml_str}"
2428        );
2429    }
2430
2431    #[test]
2432    fn test_groups_missing_from_toml_defaults_to_empty() {
2433        let toml_str = r#"
2434[registry]
2435scan_roots = ["/code"]
2436
2437[workspace]
2438root = "/loom"
2439"#;
2440        let config: Config = toml::from_str(toml_str).unwrap();
2441        assert!(config.groups.is_empty());
2442    }
2443
2444    #[test]
2445    fn test_validate_groups_empty_group() {
2446        let dir = tempfile::tempdir().unwrap();
2447        let mut groups = BTreeMap::new();
2448        groups.insert("empty-group".to_string(), vec![]);
2449
2450        let config = Config {
2451            registry: RegistryConfig {
2452                scan_roots: vec![dir.path().to_path_buf()],
2453                scan_depth: 2,
2454            },
2455            workspace: WorkspaceConfig {
2456                root: dir.path().to_path_buf(),
2457            },
2458            sync: None,
2459            terminal: None,
2460            editor: None,
2461            defaults: DefaultsConfig::default(),
2462            groups,
2463            repos: BTreeMap::new(),
2464            specs: None,
2465            agents: AgentsConfig::default(),
2466            update: UpdateConfig::default(),
2467        };
2468
2469        let err = config.validate().unwrap_err();
2470        assert!(err.to_string().contains("must contain at least one repo"));
2471    }
2472
2473    #[test]
2474    fn test_validate_groups_invalid_name() {
2475        let dir = tempfile::tempdir().unwrap();
2476        let mut groups = BTreeMap::new();
2477        groups.insert("UPPERCASE".to_string(), vec!["some-repo".to_string()]);
2478
2479        let config = Config {
2480            registry: RegistryConfig {
2481                scan_roots: vec![dir.path().to_path_buf()],
2482                scan_depth: 2,
2483            },
2484            workspace: WorkspaceConfig {
2485                root: dir.path().to_path_buf(),
2486            },
2487            sync: None,
2488            terminal: None,
2489            editor: None,
2490            defaults: DefaultsConfig::default(),
2491            groups,
2492            repos: BTreeMap::new(),
2493            specs: None,
2494            agents: AgentsConfig::default(),
2495            update: UpdateConfig::default(),
2496        };
2497
2498        let err = config.validate().unwrap_err();
2499        assert!(err.to_string().contains("invalid group name"));
2500    }
2501
2502    #[test]
2503    fn test_validate_groups_duplicate_entries() {
2504        let dir = tempfile::tempdir().unwrap();
2505        let mut groups = BTreeMap::new();
2506        groups.insert(
2507            "dupes".to_string(),
2508            vec!["repo-a".to_string(), "repo-a".to_string()],
2509        );
2510
2511        let config = Config {
2512            registry: RegistryConfig {
2513                scan_roots: vec![dir.path().to_path_buf()],
2514                scan_depth: 2,
2515            },
2516            workspace: WorkspaceConfig {
2517                root: dir.path().to_path_buf(),
2518            },
2519            sync: None,
2520            terminal: None,
2521            editor: None,
2522            defaults: DefaultsConfig::default(),
2523            groups,
2524            repos: BTreeMap::new(),
2525            specs: None,
2526            agents: AgentsConfig::default(),
2527            update: UpdateConfig::default(),
2528        };
2529
2530        let err = config.validate().unwrap_err();
2531        assert!(err.to_string().contains("duplicate entry"));
2532    }
2533
2534    #[test]
2535    fn test_validate_groups_empty_entry() {
2536        let dir = tempfile::tempdir().unwrap();
2537        let mut groups = BTreeMap::new();
2538        groups.insert(
2539            "has-empty".to_string(),
2540            vec!["repo-a".to_string(), "  ".to_string()],
2541        );
2542
2543        let config = Config {
2544            registry: RegistryConfig {
2545                scan_roots: vec![dir.path().to_path_buf()],
2546                scan_depth: 2,
2547            },
2548            workspace: WorkspaceConfig {
2549                root: dir.path().to_path_buf(),
2550            },
2551            sync: None,
2552            terminal: None,
2553            editor: None,
2554            defaults: DefaultsConfig::default(),
2555            groups,
2556            repos: BTreeMap::new(),
2557            specs: None,
2558            agents: AgentsConfig::default(),
2559            update: UpdateConfig::default(),
2560        };
2561
2562        let err = config.validate().unwrap_err();
2563        assert!(err.to_string().contains("empty or whitespace-only"));
2564    }
2565
2566    #[test]
2567    fn test_validate_groups_valid() {
2568        let dir = tempfile::tempdir().unwrap();
2569        let mut groups = BTreeMap::new();
2570        groups.insert(
2571            "dsp-stack".to_string(),
2572            vec!["dsp-api".to_string(), "sipi".to_string()],
2573        );
2574
2575        let config = Config {
2576            registry: RegistryConfig {
2577                scan_roots: vec![dir.path().to_path_buf()],
2578                scan_depth: 2,
2579            },
2580            workspace: WorkspaceConfig {
2581                root: dir.path().to_path_buf(),
2582            },
2583            sync: None,
2584            terminal: None,
2585            editor: None,
2586            defaults: DefaultsConfig::default(),
2587            groups,
2588            repos: BTreeMap::new(),
2589            specs: None,
2590            agents: AgentsConfig::default(),
2591            update: UpdateConfig::default(),
2592        };
2593
2594        assert!(config.validate().is_ok());
2595    }
2596
2597    #[test]
2598    fn test_effort_level_round_trip() {
2599        let toml_str = r#"
2600[registry]
2601scan_roots = ["/code"]
2602
2603[workspace]
2604root = "/loom"
2605
2606[agents.claude-code]
2607effort_level = "high"
2608"#;
2609        let config: Config = toml::from_str(toml_str).unwrap();
2610        assert_eq!(
2611            config.agents.claude_code.effort_level,
2612            Some(EffortLevel::High)
2613        );
2614
2615        let serialized = toml::to_string_pretty(&config).unwrap();
2616        let reparsed: Config = toml::from_str(&serialized).unwrap();
2617        assert_eq!(
2618            reparsed.agents.claude_code.effort_level,
2619            Some(EffortLevel::High)
2620        );
2621    }
2622
2623    #[test]
2624    fn test_effort_level_invalid_value_rejected() {
2625        let toml_str = r#"
2626[registry]
2627scan_roots = ["/code"]
2628
2629[workspace]
2630root = "/loom"
2631
2632[agents.claude-code]
2633effort_level = "max"
2634"#;
2635        let result = toml::from_str::<Config>(toml_str);
2636        assert!(result.is_err());
2637    }
2638
2639    #[test]
2640    fn test_effort_level_none_keeps_config_empty() {
2641        let config = ClaudeCodeConfig::default();
2642        assert!(config.is_empty());
2643        assert!(config.effort_level.is_none());
2644    }
2645
2646    #[test]
2647    fn test_effort_level_some_makes_config_non_empty() {
2648        let config = ClaudeCodeConfig {
2649            effort_level: Some(EffortLevel::Medium),
2650            ..Default::default()
2651        };
2652        assert!(!config.is_empty());
2653    }
2654
2655    #[test]
2656    fn test_mcp_server_validation_command_only_ok() {
2657        let dir = tempfile::tempdir().unwrap();
2658        let mut mcp_servers = BTreeMap::new();
2659        mcp_servers.insert(
2660            "test".to_string(),
2661            McpServerConfig {
2662                command: Some("npx".to_string()),
2663                ..Default::default()
2664            },
2665        );
2666        let config = Config {
2667            registry: RegistryConfig {
2668                scan_roots: vec![dir.path().to_path_buf()],
2669                scan_depth: 2,
2670            },
2671            workspace: WorkspaceConfig {
2672                root: dir.path().to_path_buf(),
2673            },
2674            sync: None,
2675            terminal: None,
2676            editor: None,
2677            defaults: DefaultsConfig::default(),
2678            groups: BTreeMap::new(),
2679            repos: BTreeMap::new(),
2680            specs: None,
2681            agents: AgentsConfig {
2682                enabled: vec!["claude-code".to_string()],
2683                claude_code: ClaudeCodeConfig {
2684                    mcp_servers,
2685                    ..Default::default()
2686                },
2687            },
2688            update: UpdateConfig::default(),
2689        };
2690        assert!(config.validate_agent_config().is_ok());
2691    }
2692
2693    #[test]
2694    fn test_mcp_server_validation_url_only_ok() {
2695        let dir = tempfile::tempdir().unwrap();
2696        let mut mcp_servers = BTreeMap::new();
2697        mcp_servers.insert(
2698            "test".to_string(),
2699            McpServerConfig {
2700                url: Some("https://example.com/mcp".to_string()),
2701                ..Default::default()
2702            },
2703        );
2704        let config = Config {
2705            registry: RegistryConfig {
2706                scan_roots: vec![dir.path().to_path_buf()],
2707                scan_depth: 2,
2708            },
2709            workspace: WorkspaceConfig {
2710                root: dir.path().to_path_buf(),
2711            },
2712            sync: None,
2713            terminal: None,
2714            editor: None,
2715            defaults: DefaultsConfig::default(),
2716            groups: BTreeMap::new(),
2717            repos: BTreeMap::new(),
2718            specs: None,
2719            agents: AgentsConfig {
2720                enabled: vec!["claude-code".to_string()],
2721                claude_code: ClaudeCodeConfig {
2722                    mcp_servers,
2723                    ..Default::default()
2724                },
2725            },
2726            update: UpdateConfig::default(),
2727        };
2728        assert!(config.validate_agent_config().is_ok());
2729    }
2730
2731    #[test]
2732    fn test_mcp_server_validation_both_command_and_url_fails() {
2733        let dir = tempfile::tempdir().unwrap();
2734        let mut mcp_servers = BTreeMap::new();
2735        mcp_servers.insert(
2736            "bad".to_string(),
2737            McpServerConfig {
2738                command: Some("npx".to_string()),
2739                url: Some("https://example.com".to_string()),
2740                ..Default::default()
2741            },
2742        );
2743        let config = Config {
2744            registry: RegistryConfig {
2745                scan_roots: vec![dir.path().to_path_buf()],
2746                scan_depth: 2,
2747            },
2748            workspace: WorkspaceConfig {
2749                root: dir.path().to_path_buf(),
2750            },
2751            sync: None,
2752            terminal: None,
2753            editor: None,
2754            defaults: DefaultsConfig::default(),
2755            groups: BTreeMap::new(),
2756            repos: BTreeMap::new(),
2757            specs: None,
2758            agents: AgentsConfig {
2759                enabled: vec!["claude-code".to_string()],
2760                claude_code: ClaudeCodeConfig {
2761                    mcp_servers,
2762                    ..Default::default()
2763                },
2764            },
2765            update: UpdateConfig::default(),
2766        };
2767        let err = config.validate_agent_config().unwrap_err();
2768        assert!(err.to_string().contains("cannot have both"));
2769    }
2770
2771    #[test]
2772    fn test_mcp_server_validation_neither_command_nor_url_fails() {
2773        let dir = tempfile::tempdir().unwrap();
2774        let mut mcp_servers = BTreeMap::new();
2775        mcp_servers.insert("empty".to_string(), McpServerConfig::default());
2776        let config = Config {
2777            registry: RegistryConfig {
2778                scan_roots: vec![dir.path().to_path_buf()],
2779                scan_depth: 2,
2780            },
2781            workspace: WorkspaceConfig {
2782                root: dir.path().to_path_buf(),
2783            },
2784            sync: None,
2785            terminal: None,
2786            editor: None,
2787            defaults: DefaultsConfig::default(),
2788            groups: BTreeMap::new(),
2789            repos: BTreeMap::new(),
2790            specs: None,
2791            agents: AgentsConfig {
2792                enabled: vec!["claude-code".to_string()],
2793                claude_code: ClaudeCodeConfig {
2794                    mcp_servers,
2795                    ..Default::default()
2796                },
2797            },
2798            update: UpdateConfig::default(),
2799        };
2800        let err = config.validate_agent_config().unwrap_err();
2801        assert!(err.to_string().contains("must have either"));
2802    }
2803
2804    #[test]
2805    fn test_mcp_servers_makes_config_non_empty() {
2806        let mut mcp_servers = BTreeMap::new();
2807        mcp_servers.insert(
2808            "test".to_string(),
2809            McpServerConfig {
2810                command: Some("cmd".to_string()),
2811                ..Default::default()
2812            },
2813        );
2814        let config = ClaudeCodeConfig {
2815            mcp_servers,
2816            ..Default::default()
2817        };
2818        assert!(!config.is_empty());
2819    }
2820
2821    #[test]
2822    fn test_env_makes_config_non_empty() {
2823        let mut env = BTreeMap::new();
2824        env.insert("GIT_SSH_COMMAND".to_string(), "ssh".to_string());
2825        let config = ClaudeCodeConfig {
2826            env,
2827            ..Default::default()
2828        };
2829        assert!(!config.is_empty());
2830    }
2831
2832    #[test]
2833    fn test_validate_env_key_empty_rejected() {
2834        let config = Config {
2835            registry: RegistryConfig {
2836                scan_roots: vec![],
2837                scan_depth: 2,
2838            },
2839            workspace: WorkspaceConfig {
2840                root: PathBuf::from("/loom"),
2841            },
2842            sync: None,
2843            terminal: None,
2844            editor: None,
2845            defaults: DefaultsConfig::default(),
2846            groups: BTreeMap::new(),
2847            repos: BTreeMap::new(),
2848            specs: None,
2849            agents: AgentsConfig {
2850                enabled: vec!["claude-code".to_string()],
2851                claude_code: ClaudeCodeConfig {
2852                    env: {
2853                        let mut m = BTreeMap::new();
2854                        m.insert("  ".to_string(), "val".to_string());
2855                        m
2856                    },
2857                    ..Default::default()
2858                },
2859            },
2860            update: UpdateConfig::default(),
2861        };
2862        let err = config.validate_agent_config().unwrap_err();
2863        assert!(err.to_string().contains("empty or whitespace"));
2864    }
2865
2866    #[test]
2867    fn test_validate_env_key_with_equals_rejected() {
2868        let config = Config {
2869            registry: RegistryConfig {
2870                scan_roots: vec![],
2871                scan_depth: 2,
2872            },
2873            workspace: WorkspaceConfig {
2874                root: PathBuf::from("/loom"),
2875            },
2876            sync: None,
2877            terminal: None,
2878            editor: None,
2879            defaults: DefaultsConfig::default(),
2880            groups: BTreeMap::new(),
2881            repos: BTreeMap::new(),
2882            specs: None,
2883            agents: AgentsConfig {
2884                enabled: vec!["claude-code".to_string()],
2885                claude_code: ClaudeCodeConfig {
2886                    env: {
2887                        let mut m = BTreeMap::new();
2888                        m.insert("KEY=VAL".to_string(), "val".to_string());
2889                        m
2890                    },
2891                    ..Default::default()
2892                },
2893            },
2894            update: UpdateConfig::default(),
2895        };
2896        let err = config.validate_agent_config().unwrap_err();
2897        assert!(err.to_string().contains("invalid character"));
2898    }
2899
2900    #[test]
2901    fn test_validate_env_key_valid_ok() {
2902        let config = Config {
2903            registry: RegistryConfig {
2904                scan_roots: vec![],
2905                scan_depth: 2,
2906            },
2907            workspace: WorkspaceConfig {
2908                root: PathBuf::from("/loom"),
2909            },
2910            sync: None,
2911            terminal: None,
2912            editor: None,
2913            defaults: DefaultsConfig::default(),
2914            groups: BTreeMap::new(),
2915            repos: BTreeMap::new(),
2916            specs: None,
2917            agents: AgentsConfig {
2918                enabled: vec!["claude-code".to_string()],
2919                claude_code: ClaudeCodeConfig {
2920                    env: {
2921                        let mut m = BTreeMap::new();
2922                        m.insert("GIT_SSH_COMMAND".to_string(), "ssh".to_string());
2923                        m
2924                    },
2925                    ..Default::default()
2926                },
2927            },
2928            update: UpdateConfig::default(),
2929        };
2930        assert!(config.validate_agent_config().is_ok());
2931    }
2932
2933    #[test]
2934    fn test_env_toml_round_trip() {
2935        let toml_str = r#"
2936[registry]
2937scan_roots = ["/code"]
2938
2939[workspace]
2940root = "/loom"
2941
2942[agents.claude-code.env]
2943GIT_SSH_COMMAND = "ssh"
2944EDITOR = "vim"
2945"#;
2946        let config: Config = toml::from_str(toml_str).unwrap();
2947        assert_eq!(config.agents.claude_code.env.len(), 2);
2948        assert_eq!(config.agents.claude_code.env["GIT_SSH_COMMAND"], "ssh");
2949
2950        let serialized = toml::to_string_pretty(&config).unwrap();
2951        let reparsed: Config = toml::from_str(&serialized).unwrap();
2952        assert_eq!(
2953            reparsed.agents.claude_code.env,
2954            config.agents.claude_code.env
2955        );
2956    }
2957
2958    #[test]
2959    fn test_mcp_servers_toml_round_trip() {
2960        let toml_str = r#"
2961[registry]
2962scan_roots = ["/code"]
2963
2964[workspace]
2965root = "/loom"
2966
2967[agents.claude-code.mcp_servers.linear]
2968command = "npx"
2969args = ["@anthropic/linear-mcp"]
2970
2971[agents.claude-code.mcp_servers.linear.env]
2972TOKEN = "secret"
2973
2974[agents.claude-code.mcp_servers.grafana]
2975url = "https://grafana.example.com/mcp"
2976"#;
2977        let config: Config = toml::from_str(toml_str).unwrap();
2978        assert_eq!(config.agents.claude_code.mcp_servers.len(), 2);
2979        let linear = &config.agents.claude_code.mcp_servers["linear"];
2980        assert_eq!(linear.command, Some("npx".to_string()));
2981        assert_eq!(linear.env.as_ref().unwrap()["TOKEN"], "secret");
2982        let grafana = &config.agents.claude_code.mcp_servers["grafana"];
2983        assert_eq!(
2984            grafana.url,
2985            Some("https://grafana.example.com/mcp".to_string())
2986        );
2987
2988        let serialized = toml::to_string_pretty(&config).unwrap();
2989        let reparsed: Config = toml::from_str(&serialized).unwrap();
2990        assert_eq!(
2991            reparsed.agents.claude_code.mcp_servers,
2992            config.agents.claude_code.mcp_servers
2993        );
2994    }
2995
2996    #[test]
2997    fn test_mcp_server_validation_args_with_url_fails() {
2998        assert!(
2999            validate_mcp_server(
3000                &McpServerConfig {
3001                    url: Some("https://example.com".to_string()),
3002                    args: Some(vec!["--flag".to_string()]),
3003                    ..Default::default()
3004                },
3005                "test"
3006            )
3007            .unwrap_err()
3008            .to_string()
3009            .contains("args")
3010        );
3011    }
3012
3013    #[test]
3014    fn test_mcp_server_validation_empty_command_fails() {
3015        assert!(
3016            validate_mcp_server(
3017                &McpServerConfig {
3018                    command: Some("".to_string()),
3019                    ..Default::default()
3020                },
3021                "test"
3022            )
3023            .unwrap_err()
3024            .to_string()
3025            .contains("empty")
3026        );
3027    }
3028
3029    #[test]
3030    fn test_mcp_server_validation_empty_url_fails() {
3031        assert!(
3032            validate_mcp_server(
3033                &McpServerConfig {
3034                    url: Some("  ".to_string()),
3035                    ..Default::default()
3036                },
3037                "test"
3038            )
3039            .unwrap_err()
3040            .to_string()
3041            .contains("empty")
3042        );
3043    }
3044
3045    #[test]
3046    fn test_mcp_server_env_key_validation() {
3047        // Valid env key
3048        assert!(
3049            validate_mcp_server(
3050                &McpServerConfig {
3051                    command: Some("cmd".to_string()),
3052                    env: Some({
3053                        let mut m = BTreeMap::new();
3054                        m.insert("TOKEN".to_string(), "secret".to_string());
3055                        m
3056                    }),
3057                    ..Default::default()
3058                },
3059                "test"
3060            )
3061            .is_ok()
3062        );
3063
3064        // Empty env key
3065        assert!(
3066            validate_mcp_server(
3067                &McpServerConfig {
3068                    command: Some("cmd".to_string()),
3069                    env: Some({
3070                        let mut m = BTreeMap::new();
3071                        m.insert("".to_string(), "val".to_string());
3072                        m
3073                    }),
3074                    ..Default::default()
3075                },
3076                "test"
3077            )
3078            .is_err()
3079        );
3080    }
3081
3082    #[test]
3083    fn test_editor_config_round_trip() {
3084        let toml_str = r#"
3085[registry]
3086scan_roots = ["/code"]
3087
3088[workspace]
3089root = "/loom"
3090
3091[editor]
3092command = "zed"
3093"#;
3094        let parsed: Config = toml::from_str(toml_str).unwrap();
3095        assert_eq!(parsed.editor.as_ref().unwrap().command, "zed");
3096
3097        // Round-trip: serialize and deserialize again
3098        let serialized = toml::to_string_pretty(&parsed).unwrap();
3099        let reparsed: Config = toml::from_str(&serialized).unwrap();
3100        assert_eq!(reparsed.editor.as_ref().unwrap().command, "zed");
3101    }
3102
3103    #[test]
3104    fn test_editor_none_suppressed_in_toml() {
3105        let config = Config {
3106            registry: RegistryConfig {
3107                scan_roots: vec![PathBuf::from("/code")],
3108                scan_depth: 2,
3109            },
3110            workspace: WorkspaceConfig {
3111                root: PathBuf::from("/loom"),
3112            },
3113            sync: None,
3114            terminal: None,
3115            editor: None,
3116            defaults: DefaultsConfig::default(),
3117            groups: BTreeMap::new(),
3118            repos: BTreeMap::new(),
3119            specs: None,
3120            agents: AgentsConfig::default(),
3121            update: UpdateConfig::default(),
3122        };
3123        let toml_str = toml::to_string_pretty(&config).unwrap();
3124        assert!(
3125            !toml_str.contains("[editor]"),
3126            "editor = None should be suppressed in TOML output"
3127        );
3128    }
3129
3130    #[test]
3131    fn test_scan_depth_round_trip() {
3132        let toml_str = r#"
3133[registry]
3134scan_roots = ["/code"]
3135scan_depth = 3
3136
3137[workspace]
3138root = "/loom"
3139"#;
3140        let parsed: Config = toml::from_str(toml_str).unwrap();
3141        assert_eq!(parsed.registry.scan_depth, 3);
3142
3143        let serialized = toml::to_string_pretty(&parsed).unwrap();
3144        let reparsed: Config = toml::from_str(&serialized).unwrap();
3145        assert_eq!(reparsed.registry.scan_depth, 3);
3146    }
3147
3148    #[test]
3149    fn test_scan_depth_default_suppressed_in_toml() {
3150        let config = Config {
3151            registry: RegistryConfig {
3152                scan_roots: vec![PathBuf::from("/code")],
3153                scan_depth: 2, // default
3154            },
3155            workspace: WorkspaceConfig {
3156                root: PathBuf::from("/loom"),
3157            },
3158            sync: None,
3159            terminal: None,
3160            editor: None,
3161            defaults: DefaultsConfig::default(),
3162            groups: BTreeMap::new(),
3163            repos: BTreeMap::new(),
3164            specs: None,
3165            agents: AgentsConfig::default(),
3166            update: UpdateConfig::default(),
3167        };
3168        let toml_str = toml::to_string_pretty(&config).unwrap();
3169        assert!(
3170            !toml_str.contains("scan_depth"),
3171            "default scan_depth should be suppressed in TOML output"
3172        );
3173    }
3174}