Skip to main content

loom_core/config/
init.rs

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
14/// Well-known directories to check for scan_roots auto-detection.
15const CANDIDATE_SCAN_ROOTS: &[&str] = &[
16    "~/_github.com",
17    "~/src",
18    "~/code",
19    "~/repos",
20    "~/Projects",
21    "~/dev",
22];
23
24/// Auto-detect scan roots by checking which well-known directories exist.
25pub 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
36/// Detect terminal from $TERM_PROGRAM environment variable.
37pub fn detect_terminal() -> Option<String> {
38    std::env::var("TERM_PROGRAM").ok().map(|t| {
39        // Map common terminal program names to their CLI commands
40        match t.as_str() {
41            "ghostty" => {
42                // On macOS, the bare "ghostty" command may not open a terminal shell
43                // (it opens a config interface instead). Prefer "open -a Ghostty"
44                // when the .app bundle exists.
45                if cfg!(target_os = "macos")
46                    && std::path::Path::new("/Applications/Ghostty.app").exists()
47                {
48                    "open -a Ghostty".to_string()
49                } else {
50                    // Linux or Nix/Homebrew install — bare command works
51                    "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/// Security flavor for Claude Code permissions during `loom init`.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum SecurityFlavor {
66    /// OS-level sandbox isolation with auto-allow (recommended).
67    Sandbox,
68    /// Explicit tool allowlists for fine-grained control.
69    Permissions,
70    /// Sandbox for Bash commands + permissions for non-Bash tools.
71    Both,
72    /// Don't configure now — can be added later in config.toml.
73    Skip,
74}
75
76/// Build a `ClaudeCodeConfig` from the selected security flavor.
77pub 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            // Illustrative defaults — users should replace with their own tool patterns.
95            allowed_tools: vec!["Bash(gh issue *)".to_string(), "Bash(gh run *)".to_string()],
96            ..Default::default()
97        },
98        SecurityFlavor::Both => ClaudeCodeConfig {
99            // Illustrative default — users should replace with their own tool patterns.
100            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
118/// Return commented-out preset examples for the given flavor.
119pub 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
247/// Run the init workflow and return the resulting Config.
248///
249/// This is the core logic; the CLI layer handles prompting via `dialoguer`.
250/// For non-interactive use (testing), pass the values directly.
251pub 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    // Verify git is installed and meets minimum version
260    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, // Add [editor] section to config.toml to enable `loom editor`
275        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
289/// Save config to the default path with commented preset examples appended.
290///
291/// Used for fresh `loom init` where no config exists yet.
292pub fn save_init_config(config: &Config, flavor: SecurityFlavor) -> Result<()> {
293    let path = Config::path()?;
294    save_init_config_to(config, flavor, &path)
295}
296
297/// Comment block for repos and specs — appended to all configs regardless of security flavor.
298pub(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
310/// Save config to a specific path with commented preset examples appended.
311pub fn save_init_config_to(config: &Config, flavor: SecurityFlavor, path: &Path) -> Result<()> {
312    // Validate agent config before writing (defense in depth)
313    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    // Ensure parent directory exists
322    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    // Atomic write
328    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
339/// Update an existing config file preserving the `[agents.*]` sections and comments.
340///
341/// Uses `toml_edit` to parse the existing file, update only the non-agent sections
342/// (registry, workspace, terminal, defaults), and write back. The entire `[agents]`
343/// section — including `agents.enabled`, all `[agents.claude-code.*]` tables, and
344/// commented-out preset examples — is preserved (formatting and comments intact,
345/// though minor whitespace normalisation may occur). If new `agents.enabled`
346/// entries need to be written during re-init, use `save_init_config` instead.
347pub fn update_non_agent_config(config: &Config) -> Result<()> {
348    let path = Config::path()?;
349    update_non_agent_config_at(config, &path, None)
350}
351
352/// Update config at a specific path, preserving agents section and comments.
353///
354/// If `existing_content` is provided, it is used directly instead of re-reading from disk.
355/// This avoids redundant I/O when the caller has already read the file (e.g., for agent
356/// config detection during re-init).
357pub 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    // Update registry.scan_roots
377    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    // Update workspace.root
384    doc["workspace"]["root"] = toml_edit::value(config.workspace.root.display().to_string());
385
386    // Update terminal
387    if let Some(ref terminal) = config.terminal {
388        // Ensure [terminal] table exists
389        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    // Update defaults.branch_prefix
398    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    // Write back atomically
404    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
416/// Create required directories after init.
417///
418/// Separate from config saving so the CLI can choose the appropriate save strategy
419/// (fresh init vs re-init with preservation).
420pub fn finalize_init(config: &Config) -> Result<()> {
421    let config_path = Config::path()?;
422    eprintln!("  config: {}", config_path.display());
423
424    // Create workspace root
425    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    // Create state directory
434    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        // Should not panic; results depend on system
458        let roots = detect_scan_roots();
459        // All returned paths should exist and be directories
460        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        // Result depends on environment, just ensure no panic
468        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        // Write an initial config with agent settings + comments
650        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        // Build a new config with updated non-agent values
673        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(), // Doesn't matter — agents section is preserved
693            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        // Non-agent sections are updated
701        assert!(content.contains("/new/code"));
702        assert!(content.contains("/new/workspaces"));
703        assert!(content.contains("wezterm"));
704        assert!(content.contains("\"dev\""));
705
706        // Old values are gone
707        assert!(!content.contains("/old/code"));
708        assert!(!content.contains("/old/workspaces"));
709
710        // Agent section is preserved (including comments)
711        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        // Test directory creation
742        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        // All flavors produce non-empty comment blocks
785        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        // Non-agent sections are updated
882        assert!(content.contains("/new/code"));
883        assert!(content.contains("/new/workspaces"));
884
885        // repos and specs sections are preserved
886        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}