1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use std::io::Write;
5
6use anyhow::{Context, Result};
7
8use super::{
9 AgentsConfig, ClaudeCodeConfig, Config, DefaultsConfig, RegistryConfig, SandboxConfig,
10 SandboxFilesystemConfig, SandboxNetworkConfig, TerminalConfig, UpdateConfig, WorkspaceConfig,
11};
12use crate::git;
13
14const CANDIDATE_SCAN_ROOTS: &[&str] = &[
16 "~/_github.com",
17 "~/src",
18 "~/code",
19 "~/repos",
20 "~/Projects",
21 "~/dev",
22];
23
24pub fn detect_scan_roots() -> Vec<PathBuf> {
26 CANDIDATE_SCAN_ROOTS
27 .iter()
28 .filter_map(|p| {
29 let expanded = shellexpand::tilde(p);
30 let path = PathBuf::from(expanded.as_ref());
31 if path.is_dir() { Some(path) } else { None }
32 })
33 .collect()
34}
35
36pub fn detect_terminal() -> Option<String> {
38 std::env::var("TERM_PROGRAM").ok().map(|t| {
39 match t.as_str() {
41 "ghostty" => {
42 if cfg!(target_os = "macos")
46 && std::path::Path::new("/Applications/Ghostty.app").exists()
47 {
48 "open -a Ghostty".to_string()
49 } else {
50 "ghostty".to_string()
52 }
53 }
54 "WezTerm" => "wezterm".to_string(),
55 "iTerm.app" => "open -a iTerm".to_string(),
56 "Apple_Terminal" => "open -a Terminal".to_string(),
57 "vscode" => "code".to_string(),
58 _ => t,
59 }
60 })
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum SecurityFlavor {
66 Sandbox,
68 Permissions,
70 Both,
72 Skip,
74}
75
76pub fn build_claude_code_config(flavor: SecurityFlavor) -> ClaudeCodeConfig {
78 match flavor {
79 SecurityFlavor::Sandbox => ClaudeCodeConfig {
80 sandbox: SandboxConfig {
81 enabled: Some(true),
82 auto_allow: Some(true),
83 excluded_commands: vec!["docker".to_string()],
84 filesystem: SandboxFilesystemConfig::default(),
85 network: SandboxNetworkConfig {
86 allowed_domains: vec!["github.com".to_string()],
87 ..Default::default()
88 },
89 ..Default::default()
90 },
91 ..Default::default()
92 },
93 SecurityFlavor::Permissions => ClaudeCodeConfig {
94 allowed_tools: vec!["Bash(gh issue *)".to_string(), "Bash(gh run *)".to_string()],
96 ..Default::default()
97 },
98 SecurityFlavor::Both => ClaudeCodeConfig {
99 allowed_tools: vec!["WebFetch(domain:docs.rs)".to_string()],
101 sandbox: SandboxConfig {
102 enabled: Some(true),
103 auto_allow: Some(true),
104 excluded_commands: vec!["docker".to_string()],
105 filesystem: SandboxFilesystemConfig::default(),
106 network: SandboxNetworkConfig {
107 allowed_domains: vec!["github.com".to_string()],
108 ..Default::default()
109 },
110 ..Default::default()
111 },
112 ..Default::default()
113 },
114 SecurityFlavor::Skip => ClaudeCodeConfig::default(),
115 }
116}
117
118pub fn preset_comment_block(flavor: SecurityFlavor) -> &'static str {
120 match flavor {
121 SecurityFlavor::Sandbox => {
122 "\
123\n# enabled_mcp_servers = [\"linear\", \"notion\"]
124#
125# [agents.claude-code.env]
126# GIT_SSH_COMMAND = \"ssh\" # Fix SSH push on macOS with sandbox proxy
127#
128# [agents.claude-code.mcp_servers.linear]
129# command = \"npx\"
130# args = [\"@anthropic/linear-mcp\"]
131#
132# Named presets — select per workspace with: loom new my-ws --preset rust
133# [agents.claude-code.presets.rust.sandbox.filesystem]
134# allow_write = [\"~/.cargo\"]
135#
136# [agents.claude-code.presets.rust.sandbox.network]
137# allowed_domains = [\"docs.rs\", \"crates.io\"]
138# allow_unix_sockets = [\"/path/to/ssh-agent.sock\"]
139# allow_local_binding = true # Allow binding localhost ports (e.g., for e2e tests)
140"
141 }
142 SecurityFlavor::Permissions => {
143 "\
144\n# enabled_mcp_servers = [\"linear\", \"notion\"]
145#
146# [agents.claude-code.env]
147# GIT_SSH_COMMAND = \"ssh\" # Fix SSH push on macOS with sandbox proxy
148#
149# [agents.claude-code.mcp_servers.linear]
150# command = \"npx\"
151# args = [\"@anthropic/linear-mcp\"]
152#
153# Named presets — select per workspace with: loom new my-ws --preset rust
154# [agents.claude-code.presets.rust]
155# allowed_tools = [
156# \"Bash(cargo test *)\",
157# \"Bash(cargo fmt *)\",
158# \"Bash(cargo clippy *)\",
159# ]
160"
161 }
162 SecurityFlavor::Both => {
163 "\
164\n# enabled_mcp_servers = [\"linear\", \"notion\"]
165#
166# [agents.claude-code.env]
167# GIT_SSH_COMMAND = \"ssh\" # Fix SSH push on macOS with sandbox proxy
168#
169# [agents.claude-code.mcp_servers.linear]
170# command = \"npx\"
171# args = [\"@anthropic/linear-mcp\"]
172#
173# Named presets — select per workspace with: loom new my-ws --preset rust
174# [agents.claude-code.presets.rust]
175# allowed_tools = [
176# \"Bash(cargo test *)\",
177# \"Bash(cargo clippy *)\",
178# ]
179#
180# [agents.claude-code.presets.rust.sandbox.filesystem]
181# allow_write = [\"~/.cargo\"]
182#
183# [agents.claude-code.presets.rust.sandbox.network]
184# allowed_domains = [\"docs.rs\", \"crates.io\"]
185# allow_unix_sockets = [\"/path/to/ssh-agent.sock\"]
186# allow_local_binding = true # Allow binding localhost ports (e.g., for e2e tests)
187"
188 }
189 SecurityFlavor::Skip => {
190 "\
191\n# --- Claude Code agent settings ---
192# Choose a security flavor and uncomment the relevant sections.
193#
194# Sandbox — OS-level isolation:
195# [agents.claude-code.sandbox]
196# enabled = true
197# auto_allow = true
198# excluded_commands = [\"docker\"]
199#
200# [agents.claude-code.sandbox.filesystem]
201# allow_write = []
202#
203# [agents.claude-code.sandbox.network]
204# allowed_domains = [\"github.com\"]
205# allow_unix_sockets = [\"/path/to/ssh-agent.sock\"]
206# allow_local_binding = true # Allow binding localhost ports (e.g., for e2e tests)
207#
208# Permissions — explicit tool allowlists:
209# [agents.claude-code]
210# model = \"opus\" # Pin the Claude model (sonnet, opus, haiku, etc.)
211# effort_level = \"high\" # Adaptive reasoning effort (low/medium/high)
212# allowed_tools = [
213# \"Bash(gh issue *)\",
214# \"Bash(gh run *)\",
215# ]
216# enabled_mcp_servers = [\"linear\", \"notion\"]
217#
218# Environment variables for Claude Code sessions:
219# [agents.claude-code.env]
220# GIT_SSH_COMMAND = \"ssh\" # Fix SSH push on macOS with sandbox proxy
221#
222# MCP server definitions (generates .mcp.json in workspace root):
223# [agents.claude-code.mcp_servers.linear]
224# command = \"npx\"
225# args = [\"@anthropic/linear-mcp\"]
226#
227# Named presets — select per workspace with: loom new my-ws --preset rust
228# [agents.claude-code.presets.rust]
229# allowed_tools = [
230# \"Bash(cargo test *)\",
231# \"Bash(cargo fmt *)\",
232# \"Bash(cargo clippy *)\",
233# ]
234#
235# [agents.claude-code.presets.rust.sandbox.filesystem]
236# allow_write = [\"~/.cargo\"]
237#
238# [agents.claude-code.presets.rust.sandbox.network]
239# allowed_domains = [\"docs.rs\", \"crates.io\"]
240# allow_unix_sockets = [\"/path/to/ssh-agent.sock\"]
241# allow_local_binding = true # Allow binding localhost ports (e.g., for e2e tests)
242"
243 }
244 }
245}
246
247pub fn create_config(
252 scan_roots: Vec<PathBuf>,
253 workspace_root: PathBuf,
254 terminal: Option<String>,
255 branch_prefix: String,
256 agents: Vec<String>,
257 claude_code: ClaudeCodeConfig,
258) -> Result<Config> {
259 let git_version = git::check_git_version().context("Git check failed during loom init")?;
261
262 eprintln!(" git version: {git_version}");
263
264 let config = Config {
265 registry: RegistryConfig {
266 scan_roots,
267 scan_depth: 2,
268 },
269 workspace: WorkspaceConfig {
270 root: workspace_root,
271 },
272 sync: None,
273 terminal: terminal.map(|command| TerminalConfig { command }),
274 editor: None, defaults: DefaultsConfig { branch_prefix },
276 groups: BTreeMap::new(),
277 repos: BTreeMap::new(),
278 specs: None,
279 agents: AgentsConfig {
280 enabled: agents,
281 claude_code,
282 },
283 update: UpdateConfig::default(),
284 };
285
286 Ok(config)
287}
288
289pub fn save_init_config(config: &Config, flavor: SecurityFlavor) -> Result<()> {
293 let path = Config::path()?;
294 save_init_config_to(config, flavor, &path)
295}
296
297pub(crate) fn repos_specs_comment_block() -> &'static str {
299 "\
300\n# Per-repo settings — configure push workflow per repo
301# [repos.my-repo]
302# workflow = \"pr\" # \"pr\" (default) or \"push\"
303#
304# Specs directory for PRDs and implementation plans
305# [specs]
306# path = \"path/to/specs\"
307"
308}
309
310pub fn save_init_config_to(config: &Config, flavor: SecurityFlavor, path: &Path) -> Result<()> {
312 config.validate_agent_config()?;
314
315 let mut content =
316 toml::to_string_pretty(config).context("Failed to serialize config to TOML")?;
317
318 content.push_str(preset_comment_block(flavor));
319 content.push_str(repos_specs_comment_block());
320
321 if let Some(parent) = path.parent() {
323 std::fs::create_dir_all(parent)
324 .with_context(|| format!("Failed to create config directory {}", parent.display()))?;
325 }
326
327 let parent = path.parent().unwrap_or(Path::new("."));
329 let mut tmp = tempfile::NamedTempFile::new_in(parent)
330 .with_context(|| format!("Failed to create temp file in {}", parent.display()))?;
331 tmp.write_all(content.as_bytes())
332 .with_context(|| "Failed to write config to temp file")?;
333 tmp.persist(path)
334 .with_context(|| format!("Failed to persist config to {}", path.display()))?;
335
336 Ok(())
337}
338
339pub fn update_non_agent_config(config: &Config) -> Result<()> {
348 let path = Config::path()?;
349 update_non_agent_config_at(config, &path, None)
350}
351
352pub fn update_non_agent_config_at(
358 config: &Config,
359 path: &Path,
360 existing_content: Option<&str>,
361) -> Result<()> {
362 let owned;
363 let existing = match existing_content {
364 Some(content) => content,
365 None => {
366 owned = std::fs::read_to_string(path)
367 .with_context(|| format!("Failed to read existing config at {}", path.display()))?;
368 &owned
369 }
370 };
371
372 let mut doc: toml_edit::DocumentMut = existing
373 .parse()
374 .with_context(|| "Failed to parse existing config with toml_edit")?;
375
376 let mut scan_roots_array = toml_edit::Array::new();
378 for root in &config.registry.scan_roots {
379 scan_roots_array.push(root.display().to_string());
380 }
381 doc["registry"]["scan_roots"] = toml_edit::value(scan_roots_array);
382
383 doc["workspace"]["root"] = toml_edit::value(config.workspace.root.display().to_string());
385
386 if let Some(ref terminal) = config.terminal {
388 if !doc.contains_key("terminal") {
390 doc["terminal"] = toml_edit::Item::Table(toml_edit::Table::new());
391 }
392 doc["terminal"]["command"] = toml_edit::value(&terminal.command);
393 } else if doc.contains_key("terminal") {
394 doc.remove("terminal");
395 }
396
397 if !doc.contains_key("defaults") {
399 doc["defaults"] = toml_edit::Item::Table(toml_edit::Table::new());
400 }
401 doc["defaults"]["branch_prefix"] = toml_edit::value(&config.defaults.branch_prefix);
402
403 let content = doc.to_string();
405 let parent = path.parent().unwrap_or(Path::new("."));
406 let mut tmp = tempfile::NamedTempFile::new_in(parent)
407 .with_context(|| format!("Failed to create temp file in {}", parent.display()))?;
408 tmp.write_all(content.as_bytes())
409 .with_context(|| "Failed to write config to temp file")?;
410 tmp.persist(path)
411 .with_context(|| format!("Failed to persist config to {}", path.display()))?;
412
413 Ok(())
414}
415
416pub fn finalize_init(config: &Config) -> Result<()> {
421 let config_path = Config::path()?;
422 eprintln!(" config: {}", config_path.display());
423
424 std::fs::create_dir_all(&config.workspace.root).with_context(|| {
426 format!(
427 "Failed to create workspace root at {}",
428 config.workspace.root.display()
429 )
430 })?;
431 eprintln!(" workspace root: {}", config.workspace.root.display());
432
433 let state_dir = config.workspace.root.join(".loom");
435 std::fs::create_dir_all(&state_dir).with_context(|| {
436 format!(
437 "Failed to create state directory at {}",
438 state_dir.display()
439 )
440 })?;
441
442 if config.editor.is_none() {
443 eprintln!(
444 " hint: add [editor] to config.toml to enable `loom editor` (e.g., command = \"zed\")"
445 );
446 }
447
448 Ok(())
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454
455 #[test]
456 fn test_detect_scan_roots() {
457 let roots = detect_scan_roots();
459 for root in &roots {
461 assert!(root.is_dir(), "{} should be a directory", root.display());
462 }
463 }
464
465 #[test]
466 fn test_detect_terminal() {
467 let _ = detect_terminal();
469 }
470
471 #[test]
472 fn test_create_config() {
473 let dir = tempfile::tempdir().unwrap();
474 let scan_root = dir.path().join("code");
475 std::fs::create_dir_all(&scan_root).unwrap();
476
477 let config = create_config(
478 vec![scan_root.clone()],
479 dir.path().join("loom"),
480 Some("ghostty".to_string()),
481 "loom".to_string(),
482 vec!["claude-code".to_string()],
483 ClaudeCodeConfig::default(),
484 )
485 .unwrap();
486
487 assert_eq!(config.registry.scan_roots, vec![scan_root]);
488 assert_eq!(config.defaults.branch_prefix, "loom");
489 assert!(config.terminal.is_some());
490 }
491
492 #[test]
493 fn test_create_config_with_sandbox_flavor() {
494 let dir = tempfile::tempdir().unwrap();
495 let scan_root = dir.path().join("code");
496 std::fs::create_dir_all(&scan_root).unwrap();
497
498 let cc = build_claude_code_config(SecurityFlavor::Sandbox);
499 let config = create_config(
500 vec![scan_root],
501 dir.path().join("loom"),
502 Some("ghostty".to_string()),
503 "loom".to_string(),
504 vec!["claude-code".to_string()],
505 cc,
506 )
507 .unwrap();
508
509 assert_eq!(config.agents.claude_code.sandbox.enabled, Some(true));
510 assert_eq!(config.agents.claude_code.sandbox.auto_allow, Some(true));
511 assert!(config.agents.claude_code.allowed_tools.is_empty());
512 }
513
514 #[test]
515 fn test_create_config_with_permissions_flavor() {
516 let dir = tempfile::tempdir().unwrap();
517 let scan_root = dir.path().join("code");
518 std::fs::create_dir_all(&scan_root).unwrap();
519
520 let cc = build_claude_code_config(SecurityFlavor::Permissions);
521 let config = create_config(
522 vec![scan_root],
523 dir.path().join("loom"),
524 None,
525 "loom".to_string(),
526 vec!["claude-code".to_string()],
527 cc,
528 )
529 .unwrap();
530
531 assert!(config.agents.claude_code.sandbox.is_empty());
532 assert_eq!(config.agents.claude_code.allowed_tools.len(), 2);
533 }
534
535 #[test]
536 fn test_create_config_with_both_flavor() {
537 let dir = tempfile::tempdir().unwrap();
538 let scan_root = dir.path().join("code");
539 std::fs::create_dir_all(&scan_root).unwrap();
540
541 let cc = build_claude_code_config(SecurityFlavor::Both);
542 let config = create_config(
543 vec![scan_root],
544 dir.path().join("loom"),
545 None,
546 "loom".to_string(),
547 vec!["claude-code".to_string()],
548 cc,
549 )
550 .unwrap();
551
552 assert_eq!(config.agents.claude_code.sandbox.enabled, Some(true));
553 assert_eq!(config.agents.claude_code.allowed_tools.len(), 1);
554 }
555
556 #[test]
557 fn test_create_config_with_skip_flavor() {
558 let dir = tempfile::tempdir().unwrap();
559 let scan_root = dir.path().join("code");
560 std::fs::create_dir_all(&scan_root).unwrap();
561
562 let cc = build_claude_code_config(SecurityFlavor::Skip);
563 let config = create_config(
564 vec![scan_root],
565 dir.path().join("loom"),
566 None,
567 "loom".to_string(),
568 vec!["claude-code".to_string()],
569 cc,
570 )
571 .unwrap();
572
573 assert!(config.agents.claude_code.is_empty());
574 }
575
576 #[test]
577 fn test_save_init_config_includes_preset_comments() {
578 let dir = tempfile::tempdir().unwrap();
579 let config_path = dir.path().join("config.toml");
580
581 let config = Config {
582 registry: RegistryConfig {
583 scan_roots: vec![PathBuf::from("/code")],
584 scan_depth: 2,
585 },
586 workspace: WorkspaceConfig {
587 root: PathBuf::from("/workspaces"),
588 },
589 sync: None,
590 terminal: None,
591 editor: None,
592 defaults: DefaultsConfig::default(),
593 groups: BTreeMap::new(),
594 repos: BTreeMap::new(),
595 specs: None,
596 agents: AgentsConfig {
597 enabled: vec!["claude-code".to_string()],
598 claude_code: build_claude_code_config(SecurityFlavor::Sandbox),
599 },
600 update: UpdateConfig::default(),
601 };
602
603 save_init_config_to(&config, SecurityFlavor::Sandbox, &config_path).unwrap();
604
605 let content = std::fs::read_to_string(&config_path).unwrap();
606 assert!(content.contains("# Named presets"));
607 assert!(content.contains("# allow_write"));
608 assert!(content.contains("[agents.claude-code.sandbox]"));
609 assert!(content.contains("enabled = true"));
610 }
611
612 #[test]
613 fn test_save_init_config_skip_has_all_flavors() {
614 let dir = tempfile::tempdir().unwrap();
615 let config_path = dir.path().join("config.toml");
616
617 let config = Config {
618 registry: RegistryConfig {
619 scan_roots: vec![PathBuf::from("/code")],
620 scan_depth: 2,
621 },
622 workspace: WorkspaceConfig {
623 root: PathBuf::from("/workspaces"),
624 },
625 sync: None,
626 terminal: None,
627 editor: None,
628 defaults: DefaultsConfig::default(),
629 groups: BTreeMap::new(),
630 repos: BTreeMap::new(),
631 specs: None,
632 agents: AgentsConfig::default(),
633 update: UpdateConfig::default(),
634 };
635
636 save_init_config_to(&config, SecurityFlavor::Skip, &config_path).unwrap();
637
638 let content = std::fs::read_to_string(&config_path).unwrap();
639 assert!(content.contains("# Sandbox"));
640 assert!(content.contains("# Permissions"));
641 assert!(content.contains("# Named presets"));
642 }
643
644 #[test]
645 fn test_update_non_agent_config_preserves_agents() {
646 let dir = tempfile::tempdir().unwrap();
647 let config_path = dir.path().join("config.toml");
648
649 let initial = r#"[registry]
651scan_roots = ["/old/code"]
652
653[workspace]
654root = "/old/workspaces"
655
656[defaults]
657branch_prefix = "loom"
658
659[agents]
660enabled = ["claude-code"]
661
662[agents.claude-code.sandbox]
663enabled = true
664auto_allow = true
665
666# Named presets — select per workspace with: loom new my-ws --preset rust
667# [agents.claude-code.presets.rust.sandbox.filesystem]
668# allow_write = ["~/.cargo"]
669"#;
670 std::fs::write(&config_path, initial).unwrap();
671
672 let config = Config {
674 registry: RegistryConfig {
675 scan_roots: vec![PathBuf::from("/new/code")],
676 scan_depth: 2,
677 },
678 workspace: WorkspaceConfig {
679 root: PathBuf::from("/new/workspaces"),
680 },
681 sync: None,
682 terminal: Some(TerminalConfig {
683 command: "wezterm".to_string(),
684 }),
685 editor: None,
686 defaults: DefaultsConfig {
687 branch_prefix: "dev".to_string(),
688 },
689 groups: BTreeMap::new(),
690 repos: BTreeMap::new(),
691 specs: None,
692 agents: AgentsConfig::default(), update: UpdateConfig::default(),
694 };
695
696 update_non_agent_config_at(&config, &config_path, None).unwrap();
697
698 let content = std::fs::read_to_string(&config_path).unwrap();
699
700 assert!(content.contains("/new/code"));
702 assert!(content.contains("/new/workspaces"));
703 assert!(content.contains("wezterm"));
704 assert!(content.contains("\"dev\""));
705
706 assert!(!content.contains("/old/code"));
708 assert!(!content.contains("/old/workspaces"));
709
710 assert!(content.contains("[agents.claude-code.sandbox]"));
712 assert!(content.contains("enabled = true"));
713 assert!(content.contains("# Named presets"));
714 assert!(content.contains("# allow_write"));
715 }
716
717 #[test]
718 fn test_finalize_init() {
719 let dir = tempfile::tempdir().unwrap();
720 let ws_root = dir.path().join("loom");
721
722 let _config = Config {
723 registry: RegistryConfig {
724 scan_roots: vec![],
725 scan_depth: 2,
726 },
727 workspace: WorkspaceConfig {
728 root: ws_root.clone(),
729 },
730 sync: None,
731 terminal: None,
732 editor: None,
733 defaults: DefaultsConfig::default(),
734 groups: BTreeMap::new(),
735 repos: BTreeMap::new(),
736 specs: None,
737 agents: AgentsConfig::default(),
738 update: UpdateConfig::default(),
739 };
740
741 std::fs::create_dir_all(&ws_root).unwrap();
743 let state_dir = ws_root.join(".loom");
744 std::fs::create_dir_all(&state_dir).unwrap();
745
746 assert!(ws_root.exists());
747 assert!(state_dir.exists());
748 }
749
750 #[test]
751 fn test_build_claude_code_config_sandbox() {
752 let cc = build_claude_code_config(SecurityFlavor::Sandbox);
753 assert_eq!(cc.sandbox.enabled, Some(true));
754 assert_eq!(cc.sandbox.auto_allow, Some(true));
755 assert_eq!(cc.sandbox.excluded_commands, vec!["docker"]);
756 assert_eq!(cc.sandbox.network.allowed_domains, vec!["github.com"]);
757 assert!(cc.allowed_tools.is_empty());
758 assert!(cc.presets.is_empty());
759 }
760
761 #[test]
762 fn test_build_claude_code_config_permissions() {
763 let cc = build_claude_code_config(SecurityFlavor::Permissions);
764 assert!(cc.sandbox.is_empty());
765 assert_eq!(cc.allowed_tools.len(), 2);
766 assert!(cc.allowed_tools.contains(&"Bash(gh issue *)".to_string()));
767 }
768
769 #[test]
770 fn test_build_claude_code_config_both() {
771 let cc = build_claude_code_config(SecurityFlavor::Both);
772 assert_eq!(cc.sandbox.enabled, Some(true));
773 assert!(!cc.allowed_tools.is_empty());
774 }
775
776 #[test]
777 fn test_build_claude_code_config_skip() {
778 let cc = build_claude_code_config(SecurityFlavor::Skip);
779 assert!(cc.is_empty());
780 }
781
782 #[test]
783 fn test_preset_comment_block_not_empty() {
784 for flavor in [
786 SecurityFlavor::Sandbox,
787 SecurityFlavor::Permissions,
788 SecurityFlavor::Both,
789 SecurityFlavor::Skip,
790 ] {
791 let block = preset_comment_block(flavor);
792 assert!(!block.is_empty(), "{flavor:?} should produce comments");
793 assert!(
794 block.contains('#'),
795 "{flavor:?} should contain comment markers"
796 );
797 }
798 }
799
800 #[test]
801 fn test_save_init_config_includes_repos_specs_comments() {
802 let dir = tempfile::tempdir().unwrap();
803 let config_path = dir.path().join("config.toml");
804
805 let config = Config {
806 registry: RegistryConfig {
807 scan_roots: vec![PathBuf::from("/code")],
808 scan_depth: 2,
809 },
810 workspace: WorkspaceConfig {
811 root: PathBuf::from("/workspaces"),
812 },
813 sync: None,
814 terminal: None,
815 editor: None,
816 defaults: DefaultsConfig::default(),
817 groups: BTreeMap::new(),
818 repos: BTreeMap::new(),
819 specs: None,
820 agents: AgentsConfig::default(),
821 update: UpdateConfig::default(),
822 };
823
824 save_init_config_to(&config, SecurityFlavor::Sandbox, &config_path).unwrap();
825
826 let content = std::fs::read_to_string(&config_path).unwrap();
827 assert!(content.contains("# [repos.my-repo]"));
828 assert!(content.contains("# workflow = \"pr\""));
829 assert!(content.contains("# [specs]"));
830 assert!(content.contains("# path = \"path/to/specs\""));
831 }
832
833 #[test]
834 fn test_update_non_agent_config_preserves_repos_and_specs() {
835 let dir = tempfile::tempdir().unwrap();
836 let config_path = dir.path().join("config.toml");
837
838 let initial = r#"[registry]
839scan_roots = ["/old/code"]
840
841[workspace]
842root = "/old/workspaces"
843
844[defaults]
845branch_prefix = "loom"
846
847[repos.my-repo]
848workflow = "push"
849
850[specs]
851path = "docs/specs"
852
853[agents]
854enabled = ["claude-code"]
855"#;
856 std::fs::write(&config_path, initial).unwrap();
857
858 let config = Config {
859 registry: RegistryConfig {
860 scan_roots: vec![PathBuf::from("/new/code")],
861 scan_depth: 2,
862 },
863 workspace: WorkspaceConfig {
864 root: PathBuf::from("/new/workspaces"),
865 },
866 sync: None,
867 terminal: None,
868 editor: None,
869 defaults: DefaultsConfig::default(),
870 groups: BTreeMap::new(),
871 repos: BTreeMap::new(),
872 specs: None,
873 agents: AgentsConfig::default(),
874 update: UpdateConfig::default(),
875 };
876
877 update_non_agent_config_at(&config, &config_path, None).unwrap();
878
879 let content = std::fs::read_to_string(&config_path).unwrap();
880
881 assert!(content.contains("/new/code"));
883 assert!(content.contains("/new/workspaces"));
884
885 assert!(
887 content.contains("[repos.my-repo]"),
888 "repos section should be preserved during re-init"
889 );
890 assert!(
891 content.contains("workflow = \"push\""),
892 "workflow value should be preserved"
893 );
894 assert!(
895 content.contains("[specs]"),
896 "specs section should be preserved during re-init"
897 );
898 assert!(
899 content.contains("docs/specs"),
900 "specs path should be preserved"
901 );
902 }
903}