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#[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 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
26 pub groups: BTreeMap<String, Vec<String>>,
27 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
29 pub repos: BTreeMap<String, RepoConfig>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub specs: Option<SpecsConfig>,
33 #[serde(default)]
34 pub agents: AgentsConfig,
35 #[serde(default, skip_serializing_if = "UpdateConfig::is_empty")]
37 pub update: UpdateConfig,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct RegistryConfig {
42 pub scan_roots: Vec<PathBuf>,
44 #[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 pub root: PathBuf,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct SyncConfig {
70 pub repo: PathBuf,
72 pub path: String,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct TerminalConfig {
78 pub command: String,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
83pub struct EditorConfig {
84 pub command: String,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct DefaultsConfig {
90 #[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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
106pub struct MarketplaceEntry {
107 pub name: String,
109 pub repo: String,
111}
112
113#[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#[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#[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#[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#[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#[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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub model: Option<String>,
240
241 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub effort_level: Option<EffortLevel>,
244
245 #[serde(default, skip_serializing_if = "Vec::is_empty")]
247 pub extra_known_marketplaces: Vec<MarketplaceEntry>,
248
249 #[serde(default, skip_serializing_if = "Vec::is_empty")]
251 pub enabled_plugins: Vec<String>,
252
253 #[serde(default, skip_serializing_if = "Vec::is_empty")]
255 pub enabled_mcp_servers: Vec<String>,
256
257 #[serde(default, skip_serializing_if = "Vec::is_empty")]
259 pub allowed_tools: Vec<String>,
260
261 #[serde(default, skip_serializing_if = "SandboxConfig::is_empty")]
263 pub sandbox: SandboxConfig,
264
265 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
267 pub env: BTreeMap<String, String>,
268
269 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
271 pub mcp_servers: BTreeMap<String, McpServerConfig>,
272
273 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
275 pub presets: BTreeMap<String, PermissionPreset>,
276}
277
278impl ClaudeCodeConfig {
279 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 #[serde(default)]
298 pub enabled: Vec<String>,
299
300 #[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
320#[serde(rename_all = "lowercase")]
321pub enum Workflow {
322 #[default]
324 Pr,
325 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 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
350pub struct RepoConfig {
351 #[serde(default)]
353 pub workflow: Workflow,
354}
355
356#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
358pub struct SpecsConfig {
359 pub path: String,
361}
362
363#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
365pub struct UpdateConfig {
366 #[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 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
394pub 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
419fn 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 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
452fn 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
462fn 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
473pub(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
486fn 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
534fn 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
550fn 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
575fn 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 pub fn load() -> Result<Self> {
585 let path = Self::path()?;
586 Self::load_from(&path)
587 }
588
589 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 pub fn save(&self) -> Result<()> {
607 let path = Self::path()?;
608 self.save_to(&path)
609 }
610
611 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 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 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 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 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 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 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 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 pub fn validate(&self) -> Result<()> {
737 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 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 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 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 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 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 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 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 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 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 self.validate_agent_config()?;
860
861 Ok(())
862 }
863}
864
865pub 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 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); }
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 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 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 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 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 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 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 assert!(validate_permission_entry("invalid_bare_name", "test").is_err());
1643 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 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 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 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 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 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, },
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}