Skip to main content

loom_core/agent/
claude_code.rs

1use std::collections::BTreeMap;
2
3use anyhow::Result;
4
5use crate::agent::{AgentGenerator, GeneratedFile};
6use crate::config::{ClaudeCodeConfig, Config, Workflow};
7use crate::manifest::WorkspaceManifest;
8
9/// Generates CLAUDE.md and .claude/settings.json for Claude Code.
10pub struct ClaudeCodeGenerator;
11
12impl AgentGenerator for ClaudeCodeGenerator {
13    fn name(&self) -> &str {
14        "claude-code"
15    }
16
17    fn generate(
18        &self,
19        manifest: &WorkspaceManifest,
20        config: &Config,
21    ) -> Result<Vec<GeneratedFile>> {
22        let claude_md = generate_claude_md(manifest, config);
23        let settings = generate_settings(
24            manifest,
25            &config.agents.claude_code,
26            manifest.preset.as_deref(),
27        );
28
29        let mut files = vec![
30            GeneratedFile {
31                relative_path: "CLAUDE.md".to_string(),
32                content: claude_md,
33            },
34            GeneratedFile {
35                relative_path: ".claude/settings.json".to_string(),
36                content: settings,
37            },
38        ];
39
40        // Generate .mcp.json if MCP servers are configured
41        if let Some(mcp_json) =
42            generate_mcp_json(&config.agents.claude_code, manifest.preset.as_deref())
43        {
44            files.push(GeneratedFile {
45                relative_path: ".mcp.json".to_string(),
46                content: mcp_json,
47            });
48        }
49
50        Ok(files)
51    }
52}
53
54/// Generate workspace CLAUDE.md content.
55fn generate_claude_md(manifest: &WorkspaceManifest, config: &Config) -> String {
56    let mut md = String::new();
57
58    let has_workflow_config = !config.repos.is_empty();
59    let default_branch = manifest.base_branch.as_deref().unwrap_or("main");
60
61    md.push_str(&format!("# LOOM Workspace: {}\n\n", manifest.name));
62    md.push_str(
63        "This workspace was created by [LOOM](https://github.com/subotic/loom) \
64         and contains linked worktrees for multi-repo development.\n\n",
65    );
66
67    if !manifest.repos.is_empty() {
68        md.push_str("## Repositories\n\n");
69
70        if has_workflow_config {
71            md.push_str("| Directory | Branch | Source | Workflow |\n");
72            md.push_str("|-----------|--------|--------|----------|\n");
73        } else {
74            md.push_str("| Directory | Branch | Source |\n");
75            md.push_str("|-----------|--------|--------|\n");
76        }
77
78        for repo in &manifest.repos {
79            let dir = repo.name.as_str();
80            let branch = repo.branch.as_str();
81            let source = if repo.remote_url.is_empty() {
82                repo.original_path.display().to_string()
83            } else {
84                repo.remote_url.clone()
85            };
86
87            if has_workflow_config {
88                let workflow = config
89                    .repos
90                    .get(dir)
91                    .map(|r| r.workflow)
92                    .unwrap_or_default();
93                let workflow_label = workflow.label(default_branch);
94                md.push_str(&format!(
95                    "| `{dir}` | `{branch}` | {source} | {workflow_label} |\n"
96                ));
97            } else {
98                md.push_str(&format!("| `{dir}` | `{branch}` | {source} |\n"));
99            }
100        }
101
102        md.push('\n');
103    }
104
105    md.push_str("## Working in this workspace\n\n");
106    md.push_str("- Each subdirectory is a git worktree checked out on the workspace branch.\n");
107    md.push_str("- Changes in one repo's worktree do not affect the original repo until pushed.\n");
108    md.push_str("- Each repo may have its own `CLAUDE.md` with repo-specific context.\n");
109    md.push_str("- Use `loom exec <cmd>` to run a command across all repos.\n");
110    md.push_str("- Use `loom save` to push all branches.\n");
111    md.push_str("- Use `loom status` to see per-repo branch and dirty state.\n");
112
113    // Workflows subsection (only when config has repo entries AND manifest has repos)
114    if has_workflow_config && !manifest.repos.is_empty() {
115        let has_pr = config.repos.values().any(|r| r.workflow == Workflow::Pr);
116        let has_push = config.repos.values().any(|r| r.workflow == Workflow::Push);
117        let ws_branch = manifest.branch_name(&config.defaults.branch_prefix);
118
119        md.push_str("\n### Workflows\n\n");
120        if has_pr {
121            md.push_str(&format!(
122                "- **PR to `{default_branch}`**: Create a branch off `origin/{default_branch}`, \
123                 commit there, push, and open a PR. Do not push the workspace branch.\n"
124            ));
125        }
126        if has_push {
127            md.push_str(&format!(
128                "- **Push to `{default_branch}`**: Commit on the workspace branch, then push \
129                 directly to `{default_branch}` (`git push origin HEAD:{default_branch}`).\n"
130            ));
131        }
132        if has_pr {
133            md.push_str(&format!(
134                "\nAfter creating a PR, continue working on the workspace branch `{ws_branch}`.\n"
135            ));
136        }
137    }
138
139    // Specs section
140    if let Some(specs) = &config.specs {
141        md.push_str("\n## Specs (PRDs and Implementation Plans)\n\n");
142        md.push_str("Specs for this workspace are stored in:\n\n```\n");
143        md.push_str(&format!("{}/\n", specs.path));
144        md.push_str("└── YYYY-MM-DD-{title-slug}/\n");
145        md.push_str("    ├── {nn}-{topic}-PRD.md\n");
146        md.push_str("    └── {nn}-{type}-{topic}-plan.md\n");
147        md.push_str("```\n\n");
148        md.push_str(
149            "- **Folder naming:** `YYYY-MM-DD-{title-slug}/` (creation date of first artifact)\n",
150        );
151        md.push_str(
152            "- **File naming:** `{nn}` is per-folder sequential numbering \
153             (zero-padded, starting at `01`)\n",
154        );
155        md.push_str("  - PRDs: `{nn}-{topic}-PRD.md`\n");
156        md.push_str(
157            "  - Plans: `{nn}-{type}-{topic}-plan.md` where `{type}` is \
158             `feat`, `fix`, or `refactor`\n",
159        );
160        md.push_str("- PRDs and plans share the same numbering sequence within a folder\n");
161    }
162
163    // Sandbox guidance
164    if config.agents.claude_code.sandbox.enabled == Some(true) {
165        md.push_str("\n## Sandbox\n\n");
166        md.push_str(
167            "This workspace runs with sandbox enabled. Always try to work within the\n\
168             sandbox constraints first — use paths that are within the workspace, prefer\n\
169             tools and commands that don't require outside access, and structure your\n\
170             approach to stay within allowed boundaries.\n\n\
171             Only if a command genuinely cannot work within the sandbox (e.g., accessing\n\
172             system binaries, global config, or paths in other worktrees), use\n\
173             `dangerouslyDisableSandbox: true` on the Bash tool call. The user will\n\
174             still be prompted to approve each such command.\n",
175        );
176    }
177
178    md
179}
180
181/// Merge two string slices into a sorted, deduplicated Vec.
182fn merge_sorted(global: &[String], preset: &[String]) -> Vec<String> {
183    let mut merged = global.to_vec();
184    merged.extend(preset.iter().cloned());
185    merged.sort();
186    merged.dedup();
187    merged
188}
189
190/// Merge two filesystem path slices, convert to Claude Code sandbox convention,
191/// then sort and deduplicate.
192fn merge_sandbox_paths(global: &[String], preset: &[String]) -> Vec<String> {
193    let mut paths: Vec<String> = global
194        .iter()
195        .chain(preset.iter())
196        .map(|p| to_sandbox_path(p))
197        .collect();
198    paths.sort();
199    paths.dedup();
200    paths
201}
202
203/// Convert a sandbox filesystem path to Claude Code's settings.json convention.
204///
205/// Claude Code resolves sandbox paths as:
206/// - `//path` → absolute from filesystem root
207/// - `~/path` → relative to $HOME
208/// - `/path`  → relative to settings file directory (NOT absolute!)
209///
210/// TOML config stores standard absolute paths (e.g., `/Users/me/.cargo`),
211/// so we must convert them to `//` prefix for settings.json.
212fn to_sandbox_path(path: &str) -> String {
213    if path.starts_with("//") || path.starts_with("~/") {
214        // Already using Claude Code convention
215        path.to_string()
216    } else if path.starts_with('/') {
217        // Absolute path — prepend extra / for Claude Code
218        format!("/{path}")
219    } else {
220        // Relative path — pass through as-is
221        path.to_string()
222    }
223}
224
225/// Build the sandbox JSON object from global config, optional preset, and manifest repos.
226///
227/// When `sandbox.enabled == Some(true)`, auto-injects each repo's original `.git`
228/// directory into `allowWrite` so git worktree operations can access shared state.
229fn build_sandbox_json(
230    sandbox: &crate::config::SandboxConfig,
231    preset: Option<&crate::config::PermissionPreset>,
232    repos: &[crate::manifest::RepoManifestEntry],
233) -> serde_json::Map<String, serde_json::Value> {
234    let mut obj = serde_json::Map::new();
235
236    if let Some(enabled) = sandbox.enabled {
237        obj.insert("enabled".to_string(), serde_json::json!(enabled));
238    }
239    if let Some(auto_allow) = sandbox.auto_allow {
240        obj.insert(
241            "autoAllowBashIfSandboxed".to_string(),
242            serde_json::json!(auto_allow),
243        );
244    }
245    if !sandbox.excluded_commands.is_empty() {
246        obj.insert(
247            "excludedCommands".to_string(),
248            serde_json::json!(sandbox.excluded_commands),
249        );
250    }
251    if let Some(allow_unsandboxed) = sandbox.allow_unsandboxed_commands {
252        obj.insert(
253            "allowUnsandboxedCommands".to_string(),
254            serde_json::json!(allow_unsandboxed),
255        );
256    }
257    if let Some(val) = sandbox.enable_weaker_network_isolation {
258        obj.insert(
259            "enableWeakerNetworkIsolation".to_string(),
260            serde_json::json!(val),
261        );
262    }
263
264    // Merge filesystem arrays: global ∪ preset, convert to Claude Code path convention
265    let preset_fs = preset.map(|p| &p.sandbox.filesystem);
266
267    let mut allow_write = merge_sandbox_paths(
268        &sandbox.filesystem.allow_write,
269        preset_fs.map_or(&[], |fs| &fs.allow_write),
270    );
271
272    // Auto-inject original repo .git paths for worktree operations
273    if sandbox.enabled == Some(true) {
274        for repo in repos {
275            let git_dir = repo.original_path.join(".git");
276            allow_write.push(to_sandbox_path(&git_dir.display().to_string()));
277        }
278        allow_write.sort();
279        allow_write.dedup();
280    }
281
282    let deny_write = merge_sandbox_paths(
283        &sandbox.filesystem.deny_write,
284        preset_fs.map_or(&[], |fs| &fs.deny_write),
285    );
286    let deny_read = merge_sandbox_paths(
287        &sandbox.filesystem.deny_read,
288        preset_fs.map_or(&[], |fs| &fs.deny_read),
289    );
290
291    if !allow_write.is_empty() || !deny_write.is_empty() || !deny_read.is_empty() {
292        let mut fs_obj = serde_json::Map::new();
293        if !allow_write.is_empty() {
294            fs_obj.insert("allowWrite".to_string(), serde_json::json!(allow_write));
295        }
296        if !deny_write.is_empty() {
297            fs_obj.insert("denyWrite".to_string(), serde_json::json!(deny_write));
298        }
299        if !deny_read.is_empty() {
300            fs_obj.insert("denyRead".to_string(), serde_json::json!(deny_read));
301        }
302        obj.insert("filesystem".to_string(), serde_json::Value::Object(fs_obj));
303    }
304
305    // Merge network arrays: global ∪ preset
306    let allowed_domains = merge_sorted(
307        &sandbox.network.allowed_domains,
308        preset.map_or(&[], |p| &p.sandbox.network.allowed_domains),
309    );
310    // Unix socket paths go to the network proxy, not the filesystem sandbox,
311    // so use merge_sorted (no // prefix conversion), not merge_sandbox_paths.
312    let allow_unix_sockets = merge_sorted(
313        &sandbox.network.allow_unix_sockets,
314        preset.map_or(&[], |p| &p.sandbox.network.allow_unix_sockets),
315    );
316    // Merge allow_local_binding: global wins if set, else preset
317    let allow_local_binding = sandbox
318        .network
319        .allow_local_binding
320        .or(preset.and_then(|p| p.sandbox.network.allow_local_binding));
321
322    if !allowed_domains.is_empty()
323        || !allow_unix_sockets.is_empty()
324        || allow_local_binding.is_some()
325    {
326        let mut net_obj = serde_json::Map::new();
327        if !allowed_domains.is_empty() {
328            net_obj.insert(
329                "allowedDomains".to_string(),
330                serde_json::json!(allowed_domains),
331            );
332        }
333        if !allow_unix_sockets.is_empty() {
334            net_obj.insert(
335                "allowUnixSockets".to_string(),
336                serde_json::json!(allow_unix_sockets),
337            );
338        }
339        if let Some(allow_local_binding) = allow_local_binding {
340            net_obj.insert(
341                "allowLocalBinding".to_string(),
342                serde_json::Value::Bool(allow_local_binding),
343            );
344        }
345        obj.insert("network".to_string(), serde_json::Value::Object(net_obj));
346    }
347
348    obj
349}
350
351/// Generate .mcp.json content from global + preset MCP server definitions.
352///
353/// Returns `None` if no servers are configured. Preset servers override global servers
354/// with the same name.
355fn generate_mcp_json(cc_config: &ClaudeCodeConfig, preset_name: Option<&str>) -> Option<String> {
356    let mut servers = BTreeMap::new();
357
358    // Global MCP servers
359    for (name, config) in &cc_config.mcp_servers {
360        servers.insert(name.clone(), config.clone());
361    }
362
363    // Preset MCP servers (override global by name)
364    if let Some(preset_name) = preset_name
365        && let Some(preset) = cc_config.presets.get(preset_name)
366    {
367        for (name, config) in &preset.mcp_servers {
368            servers.insert(name.clone(), config.clone());
369        }
370    }
371
372    if servers.is_empty() {
373        return None;
374    }
375
376    // Convert to JSON — inject "type" field based on transport variant.
377    // Claude Code requires "type": "sse" for SSE servers; stdio is the default.
378    let mcp_servers: serde_json::Map<String, serde_json::Value> = servers
379        .into_iter()
380        .map(|(name, config)| {
381            let mut val =
382                serde_json::to_value(&config).expect("McpServerConfig is always serializable");
383            if let Some(obj) = val.as_object_mut()
384                && config.url.is_some()
385            {
386                obj.insert("type".to_string(), serde_json::json!("sse"));
387            }
388            (name, val)
389        })
390        .collect();
391
392    let root = serde_json::json!({ "mcpServers": mcp_servers });
393    Some(serde_json::to_string_pretty(&root).expect("JSON serialization cannot fail"))
394}
395
396/// Generate .claude/settings.json content.
397///
398/// If `preset_name` is provided, merges the named preset's settings with global config.
399fn generate_settings(
400    manifest: &WorkspaceManifest,
401    cc_config: &crate::config::ClaudeCodeConfig,
402    preset_name: Option<&str>,
403) -> String {
404    let paths: Vec<String> = manifest
405        .repos
406        .iter()
407        .map(|r| r.worktree_path.display().to_string())
408        .collect();
409
410    let mut obj = serde_json::json!({
411        "additionalDirectories": paths
412    });
413
414    if let Some(ref model) = cc_config.model {
415        obj["model"] = serde_json::json!(model);
416    }
417
418    if let Some(effort) = cc_config.effort_level {
419        obj["effortLevel"] = serde_json::json!(effort);
420    }
421
422    if !cc_config.extra_known_marketplaces.is_empty() {
423        let mut marketplaces = serde_json::Map::new();
424        for entry in &cc_config.extra_known_marketplaces {
425            marketplaces.insert(
426                entry.name.clone(),
427                serde_json::json!({
428                    "source": {
429                        "source": "github",
430                        "repo": entry.repo
431                    }
432                }),
433            );
434        }
435        obj["extraKnownMarketplaces"] = serde_json::Value::Object(marketplaces);
436    }
437
438    if !cc_config.enabled_plugins.is_empty() {
439        let plugins: serde_json::Map<String, serde_json::Value> = cc_config
440            .enabled_plugins
441            .iter()
442            .map(|p| (p.clone(), serde_json::Value::Bool(true)))
443            .collect();
444        obj["enabledPlugins"] = serde_json::Value::Object(plugins);
445    }
446
447    if !cc_config.enabled_mcp_servers.is_empty() {
448        obj["enabledMcpjsonServers"] = serde_json::json!(cc_config.enabled_mcp_servers);
449    }
450
451    if !cc_config.env.is_empty() {
452        obj["env"] = serde_json::json!(cc_config.env);
453    }
454
455    // Resolve the preset (if any). Upstream callers (generate_agent_files,
456    // create_workspace, run_refresh) validate the preset exists via
457    // validate_preset_exists() before reaching here. A miss falls back to
458    // global-only settings, which is safe but should not happen in practice.
459    let preset = preset_name.and_then(|name| cc_config.presets.get(name));
460
461    // Build permissions.allow from global + preset allowed_tools
462    let allow = merge_sorted(
463        &cc_config.allowed_tools,
464        preset.map_or(&[], |p| &p.allowed_tools),
465    );
466    if !allow.is_empty() {
467        obj["permissions"] = serde_json::json!({ "allow": allow });
468    }
469
470    // Build sandbox from global config + preset arrays
471    if !cc_config.sandbox.is_empty() || preset.is_some_and(|p| !p.sandbox.is_empty()) {
472        let sandbox_obj = build_sandbox_json(&cc_config.sandbox, preset, &manifest.repos);
473        if !sandbox_obj.is_empty() {
474            obj["sandbox"] = serde_json::Value::Object(sandbox_obj);
475        }
476    }
477
478    serde_json::to_string_pretty(&obj).expect("serde_json::Value is always serializable")
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use crate::config::{
485        AgentsConfig, ClaudeCodeConfig, DefaultsConfig, EffortLevel, MarketplaceEntry,
486        PermissionPreset, PresetSandboxConfig, RegistryConfig, RepoConfig, SandboxConfig,
487        SandboxFilesystemConfig, SandboxNetworkConfig, SpecsConfig, UpdateConfig, Workflow,
488        WorkspaceConfig,
489    };
490    use crate::manifest::RepoManifestEntry;
491    use std::collections::BTreeMap;
492    use std::path::PathBuf;
493
494    fn test_manifest() -> WorkspaceManifest {
495        WorkspaceManifest {
496            name: "my-feature".to_string(),
497            branch: None,
498            created: chrono::DateTime::parse_from_rfc3339("2026-02-27T10:00:00Z")
499                .unwrap()
500                .with_timezone(&chrono::Utc),
501            base_branch: Some("main".to_string()),
502            preset: None,
503            repos: vec![
504                RepoManifestEntry {
505                    name: "dsp-api".to_string(),
506                    original_path: PathBuf::from("/code/dasch-swiss/dsp-api"),
507                    worktree_path: PathBuf::from("/loom/my-feature/dsp-api"),
508                    branch: "loom/my-feature".to_string(),
509                    remote_url: "git@github.com:dasch-swiss/dsp-api.git".to_string(),
510                },
511                RepoManifestEntry {
512                    name: "dsp-das".to_string(),
513                    original_path: PathBuf::from("/code/dasch-swiss/dsp-das"),
514                    worktree_path: PathBuf::from("/loom/my-feature/dsp-das"),
515                    branch: "loom/my-feature".to_string(),
516                    remote_url: "git@github.com:dasch-swiss/dsp-das.git".to_string(),
517                },
518            ],
519        }
520    }
521
522    fn test_config() -> Config {
523        Config {
524            registry: RegistryConfig {
525                scan_roots: vec![],
526                scan_depth: 2,
527            },
528            workspace: WorkspaceConfig {
529                root: PathBuf::from("/loom"),
530            },
531            sync: None,
532            terminal: None,
533            editor: None,
534            defaults: DefaultsConfig::default(),
535            groups: BTreeMap::new(),
536            repos: BTreeMap::new(),
537            specs: None,
538            agents: AgentsConfig::default(),
539            update: UpdateConfig::default(),
540        }
541    }
542
543    #[test]
544    fn test_claude_md_snapshot() {
545        let manifest = test_manifest();
546        let config = test_config();
547        let content = generate_claude_md(&manifest, &config);
548        insta::assert_snapshot!(content);
549    }
550
551    #[test]
552    fn test_settings_snapshot() {
553        let manifest = test_manifest();
554        let cc_config = ClaudeCodeConfig::default();
555        let content = generate_settings(&manifest, &cc_config, None);
556        insta::assert_snapshot!(content);
557    }
558
559    #[test]
560    fn test_settings_with_model_snapshot() {
561        let manifest = test_manifest();
562        let cc_config = ClaudeCodeConfig {
563            model: Some("opus".to_string()),
564            ..Default::default()
565        };
566        let content = generate_settings(&manifest, &cc_config, None);
567        insta::assert_snapshot!(content);
568    }
569
570    #[test]
571    fn test_settings_with_effort_level_snapshot() {
572        let manifest = test_manifest();
573        let cc_config = ClaudeCodeConfig {
574            effort_level: Some(EffortLevel::High),
575            ..Default::default()
576        };
577        let content = generate_settings(&manifest, &cc_config, None);
578        insta::assert_snapshot!(content);
579    }
580
581    #[test]
582    fn test_settings_with_plugins_snapshot() {
583        let manifest = test_manifest();
584        let cc_config = ClaudeCodeConfig {
585            extra_known_marketplaces: vec![MarketplaceEntry {
586                name: "test-marketplace".to_string(),
587                repo: "org/test-plugins".to_string(),
588            }],
589            enabled_plugins: vec!["pkm@test-marketplace".to_string()],
590            ..Default::default()
591        };
592        let content = generate_settings(&manifest, &cc_config, None);
593        insta::assert_snapshot!(content);
594    }
595
596    #[test]
597    fn test_claude_md_empty_repos() {
598        let manifest = WorkspaceManifest {
599            name: "empty-ws".to_string(),
600            branch: None,
601            created: chrono::Utc::now(),
602            base_branch: None,
603            preset: None,
604            repos: vec![],
605        };
606        let config = test_config();
607
608        let content = generate_claude_md(&manifest, &config);
609        assert!(content.contains("# LOOM Workspace: empty-ws"));
610        assert!(!content.contains("## Repositories"));
611    }
612
613    #[test]
614    fn test_settings_empty_repos() {
615        let manifest = WorkspaceManifest {
616            name: "empty-ws".to_string(),
617            branch: None,
618            created: chrono::Utc::now(),
619            base_branch: None,
620            preset: None,
621            repos: vec![],
622        };
623
624        let cc_config = ClaudeCodeConfig::default();
625        let content = generate_settings(&manifest, &cc_config, None);
626        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
627        let dirs = parsed["additionalDirectories"].as_array().unwrap();
628        assert!(dirs.is_empty());
629        // No extra keys when config is empty
630        assert!(parsed.get("extraKnownMarketplaces").is_none());
631        assert!(parsed.get("enabledPlugins").is_none());
632        assert!(parsed.get("enabledMcpjsonServers").is_none());
633    }
634
635    #[test]
636    fn test_generator_trait() {
637        let generator = ClaudeCodeGenerator;
638        assert_eq!(generator.name(), "claude-code");
639
640        let manifest = test_manifest();
641        let config = test_config();
642        let files = generator.generate(&manifest, &config).unwrap();
643        assert_eq!(files.len(), 2);
644
645        let claude_md = files.iter().find(|f| f.relative_path == "CLAUDE.md");
646        assert!(claude_md.is_some());
647
648        let settings = files
649            .iter()
650            .find(|f| f.relative_path == ".claude/settings.json");
651        assert!(settings.is_some());
652    }
653
654    #[test]
655    fn test_claude_md_no_remote_url() {
656        let manifest = WorkspaceManifest {
657            name: "local-ws".to_string(),
658            branch: None,
659            created: chrono::Utc::now(),
660            base_branch: None,
661            preset: None,
662            repos: vec![RepoManifestEntry {
663                name: "my-repo".to_string(),
664                original_path: PathBuf::from("/code/my-repo"),
665                worktree_path: PathBuf::from("/loom/local-ws/my-repo"),
666                branch: "loom/local-ws".to_string(),
667                remote_url: String::new(),
668            }],
669        };
670
671        let config = test_config();
672        let content = generate_claude_md(&manifest, &config);
673        // Should fall back to original_path when remote_url is empty
674        assert!(content.contains("/code/my-repo"));
675    }
676
677    fn test_config_with_workflows() -> Config {
678        let mut config = test_config();
679        config.repos.insert(
680            "dsp-api".to_string(),
681            RepoConfig {
682                workflow: Workflow::Pr,
683            },
684        );
685        config.repos.insert(
686            "dsp-das".to_string(),
687            RepoConfig {
688                workflow: Workflow::Push,
689            },
690        );
691        config
692    }
693
694    fn test_config_with_specs() -> Config {
695        let mut config = test_config();
696        config.specs = Some(SpecsConfig {
697            path: "pkm/01 - PROJECTS/Personal/LOOM/specs".to_string(),
698        });
699        config
700    }
701
702    fn test_config_full() -> Config {
703        let mut config = test_config_with_workflows();
704        config.specs = Some(SpecsConfig {
705            path: "pkm/01 - PROJECTS/Personal/LOOM/specs".to_string(),
706        });
707        config
708    }
709
710    #[test]
711    fn test_claude_md_with_workflows_snapshot() {
712        let manifest = test_manifest();
713        let config = test_config_with_workflows();
714        let content = generate_claude_md(&manifest, &config);
715        insta::assert_snapshot!(content);
716    }
717
718    #[test]
719    fn test_claude_md_with_specs_snapshot() {
720        let manifest = test_manifest();
721        let config = test_config_with_specs();
722        let content = generate_claude_md(&manifest, &config);
723        insta::assert_snapshot!(content);
724    }
725
726    #[test]
727    fn test_claude_md_full_snapshot() {
728        let manifest = test_manifest();
729        let config = test_config_full();
730        let content = generate_claude_md(&manifest, &config);
731        insta::assert_snapshot!(content);
732    }
733
734    #[test]
735    fn test_claude_md_with_sandbox_guidance_snapshot() {
736        let manifest = test_manifest();
737        let mut config = test_config();
738        config.agents.claude_code.sandbox.enabled = Some(true);
739        let content = generate_claude_md(&manifest, &config);
740        insta::assert_snapshot!(content);
741    }
742
743    #[test]
744    fn test_claude_md_no_sandbox_guidance_when_disabled() {
745        let manifest = test_manifest();
746        let mut config = test_config();
747        config.agents.claude_code.sandbox.enabled = Some(false);
748        let content = generate_claude_md(&manifest, &config);
749        assert!(!content.contains("## Sandbox"));
750    }
751
752    #[test]
753    fn test_claude_md_no_sandbox_guidance_when_absent() {
754        let manifest = test_manifest();
755        let config = test_config();
756        let content = generate_claude_md(&manifest, &config);
757        assert!(!content.contains("## Sandbox"));
758    }
759
760    #[test]
761    fn test_claude_md_custom_default_branch() {
762        let mut manifest = test_manifest();
763        manifest.base_branch = Some("develop".to_string());
764        let config = test_config_with_workflows();
765        let content = generate_claude_md(&manifest, &config);
766        assert!(content.contains("PR to `develop`"));
767        assert!(content.contains("Push to `develop`"));
768        assert!(!content.contains("PR to `main`"));
769    }
770
771    #[test]
772    fn test_claude_md_empty_repos_with_workflow_config() {
773        let manifest = WorkspaceManifest {
774            name: "empty-ws".to_string(),
775            branch: None,
776            created: chrono::DateTime::parse_from_rfc3339("2026-02-27T10:00:00Z")
777                .unwrap()
778                .with_timezone(&chrono::Utc),
779            base_branch: None,
780            preset: None,
781            repos: vec![],
782        };
783        let config = test_config_with_workflows();
784        let content = generate_claude_md(&manifest, &config);
785        // Empty manifest repos should suppress the table entirely,
786        // even when config.repos has entries
787        assert!(!content.contains("## Repositories"));
788        assert!(!content.contains("Workflow"));
789    }
790
791    #[test]
792    fn test_settings_marketplaces_only() {
793        let manifest = test_manifest();
794        let cc_config = ClaudeCodeConfig {
795            extra_known_marketplaces: vec![MarketplaceEntry {
796                name: "my-plugins".to_string(),
797                repo: "owner/my-plugins".to_string(),
798            }],
799            ..Default::default()
800        };
801        let content = generate_settings(&manifest, &cc_config, None);
802        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
803        assert!(parsed.get("extraKnownMarketplaces").is_some());
804        assert!(parsed.get("enabledPlugins").is_none());
805    }
806
807    #[test]
808    fn test_settings_plugins_only() {
809        let manifest = test_manifest();
810        let cc_config = ClaudeCodeConfig {
811            enabled_plugins: vec!["pkm@global-marketplace".to_string()],
812            ..Default::default()
813        };
814        let content = generate_settings(&manifest, &cc_config, None);
815        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
816        assert!(parsed.get("extraKnownMarketplaces").is_none());
817        assert!(parsed.get("enabledPlugins").is_some());
818    }
819
820    #[test]
821    fn test_settings_with_permissions_snapshot() {
822        let manifest = test_manifest();
823        let cc_config = ClaudeCodeConfig {
824            allowed_tools: vec![
825                "Bash(gh issue *)".to_string(),
826                "Bash(gh run *)".to_string(),
827                "WebFetch(domain:docs.rs)".to_string(),
828            ],
829            ..Default::default()
830        };
831        let content = generate_settings(&manifest, &cc_config, None);
832        insta::assert_snapshot!(content);
833    }
834
835    #[test]
836    fn test_settings_with_bare_mcp_tool_snapshot() {
837        let manifest = test_manifest();
838        let cc_config = ClaudeCodeConfig {
839            allowed_tools: vec![
840                "Bash(gh issue *)".to_string(),
841                "mcp__sentry__find_organizations".to_string(),
842            ],
843            ..Default::default()
844        };
845        let content = generate_settings(&manifest, &cc_config, None);
846        insta::assert_snapshot!(content);
847    }
848
849    #[test]
850    fn test_settings_with_sandbox_snapshot() {
851        let manifest = test_manifest();
852        let cc_config = ClaudeCodeConfig {
853            sandbox: SandboxConfig {
854                enabled: Some(true),
855                auto_allow: Some(true),
856                excluded_commands: vec!["docker".to_string()],
857                allow_unsandboxed_commands: Some(false),
858                enable_weaker_network_isolation: None,
859                filesystem: SandboxFilesystemConfig {
860                    allow_write: vec!["~/.config/loom".to_string()],
861                    ..Default::default()
862                },
863                network: SandboxNetworkConfig {
864                    allowed_domains: vec!["github.com".to_string()],
865                    ..Default::default()
866                },
867            },
868            ..Default::default()
869        };
870        let content = generate_settings(&manifest, &cc_config, None);
871        insta::assert_snapshot!(content);
872    }
873
874    #[test]
875    fn test_settings_with_preset_snapshot() {
876        let manifest = test_manifest();
877
878        let mut presets = BTreeMap::new();
879        presets.insert(
880            "rust".to_string(),
881            PermissionPreset {
882                allowed_tools: vec![
883                    "Bash(cargo clippy *)".to_string(),
884                    "Bash(cargo fmt *)".to_string(),
885                    "Bash(cargo test *)".to_string(),
886                ],
887                sandbox: PresetSandboxConfig {
888                    filesystem: SandboxFilesystemConfig {
889                        allow_write: vec!["~/.cargo".to_string()],
890                        ..Default::default()
891                    },
892                    network: SandboxNetworkConfig {
893                        allowed_domains: vec!["crates.io".to_string(), "docs.rs".to_string()],
894                        ..Default::default()
895                    },
896                },
897                ..Default::default()
898            },
899        );
900
901        let cc_config = ClaudeCodeConfig {
902            allowed_tools: vec![
903                "Bash(gh issue *)".to_string(),
904                "Bash(gh run *)".to_string(),
905                "WebFetch(domain:docs.rs)".to_string(),
906            ],
907            sandbox: SandboxConfig {
908                enabled: Some(true),
909                auto_allow: Some(true),
910                excluded_commands: vec!["docker".to_string()],
911                allow_unsandboxed_commands: Some(false),
912                enable_weaker_network_isolation: None,
913                filesystem: SandboxFilesystemConfig {
914                    allow_write: vec!["~/.config/loom".to_string()],
915                    ..Default::default()
916                },
917                network: SandboxNetworkConfig {
918                    allowed_domains: vec!["github.com".to_string()],
919                    ..Default::default()
920                },
921            },
922            presets,
923            ..Default::default()
924        };
925
926        let content = generate_settings(&manifest, &cc_config, Some("rust"));
927        insta::assert_snapshot!(content);
928    }
929
930    #[test]
931    fn test_settings_with_all_features_snapshot() {
932        let manifest = test_manifest();
933
934        let mut presets = BTreeMap::new();
935        presets.insert(
936            "rust".to_string(),
937            PermissionPreset {
938                allowed_tools: vec!["Bash(cargo test *)".to_string()],
939                sandbox: PresetSandboxConfig {
940                    filesystem: SandboxFilesystemConfig {
941                        allow_write: vec!["~/.cargo".to_string()],
942                        ..Default::default()
943                    },
944                    network: SandboxNetworkConfig {
945                        allowed_domains: vec!["docs.rs".to_string()],
946                        allow_unix_sockets: vec!["/tmp/preset.sock".to_string()],
947                        ..Default::default()
948                    },
949                },
950                ..Default::default()
951            },
952        );
953
954        let cc_config = ClaudeCodeConfig {
955            model: Some("opus".to_string()),
956            effort_level: Some(EffortLevel::High),
957            extra_known_marketplaces: vec![MarketplaceEntry {
958                name: "test-marketplace".to_string(),
959                repo: "org/test-plugins".to_string(),
960            }],
961            enabled_plugins: vec!["pkm@test-marketplace".to_string()],
962            enabled_mcp_servers: vec!["linear".to_string(), "notion".to_string()],
963            allowed_tools: vec!["Bash(gh issue *)".to_string()],
964            sandbox: SandboxConfig {
965                enabled: Some(true),
966                auto_allow: Some(true),
967                excluded_commands: vec!["docker".to_string()],
968                // Intentionally None — verifies that the key is omitted from JSON when unset,
969                // complementing the preset snapshot test which sets it to Some(false).
970                allow_unsandboxed_commands: None,
971                enable_weaker_network_isolation: None,
972                filesystem: SandboxFilesystemConfig {
973                    allow_write: vec!["~/.config/loom".to_string()],
974                    ..Default::default()
975                },
976                network: SandboxNetworkConfig {
977                    allowed_domains: vec!["github.com".to_string()],
978                    allow_unix_sockets: vec!["/tmp/global.sock".to_string()],
979                    ..Default::default()
980                },
981            },
982            presets,
983            ..Default::default()
984        };
985
986        let content = generate_settings(&manifest, &cc_config, Some("rust"));
987        insta::assert_snapshot!(content);
988    }
989
990    #[test]
991    fn test_settings_with_unix_sockets_snapshot() {
992        let manifest = test_manifest();
993        let cc_config = ClaudeCodeConfig {
994            sandbox: SandboxConfig {
995                enabled: Some(true),
996                network: SandboxNetworkConfig {
997                    allowed_domains: vec!["github.com".to_string()],
998                    allow_unix_sockets: vec![
999                        "/Users/me/Library/Containers/com.maxgoedjen.Secretive.SecretAgent/Data/socket.ssh".to_string(),
1000                    ],
1001                    ..Default::default()
1002                },
1003                ..Default::default()
1004            },
1005            ..Default::default()
1006        };
1007        let content = generate_settings(&manifest, &cc_config, None);
1008        insta::assert_snapshot!(content);
1009    }
1010
1011    #[test]
1012    fn test_settings_with_mcp_servers_snapshot() {
1013        let manifest = test_manifest();
1014        let cc_config = ClaudeCodeConfig {
1015            enabled_mcp_servers: vec!["linear".to_string(), "notion".to_string()],
1016            ..Default::default()
1017        };
1018        let content = generate_settings(&manifest, &cc_config, None);
1019        insta::assert_snapshot!(content);
1020    }
1021
1022    #[test]
1023    fn test_settings_no_permissions_when_empty() {
1024        let manifest = test_manifest();
1025        let cc_config = ClaudeCodeConfig::default();
1026        let content = generate_settings(&manifest, &cc_config, None);
1027        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1028        assert!(parsed.get("permissions").is_none());
1029        assert!(parsed.get("sandbox").is_none());
1030    }
1031
1032    #[test]
1033    fn test_settings_allow_local_binding_preset_fallback() {
1034        let manifest = test_manifest();
1035        let mut presets = BTreeMap::new();
1036        presets.insert(
1037            "test".to_string(),
1038            PermissionPreset {
1039                sandbox: PresetSandboxConfig {
1040                    network: SandboxNetworkConfig {
1041                        allow_local_binding: Some(true),
1042                        ..Default::default()
1043                    },
1044                    ..Default::default()
1045                },
1046                ..Default::default()
1047            },
1048        );
1049        let cc_config = ClaudeCodeConfig {
1050            sandbox: SandboxConfig {
1051                enabled: Some(true),
1052                // global does NOT set allow_local_binding
1053                ..Default::default()
1054            },
1055            presets,
1056            ..Default::default()
1057        };
1058        let content = generate_settings(&manifest, &cc_config, Some("test"));
1059        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1060        // Preset value should be used as fallback
1061        assert_eq!(
1062            parsed["sandbox"]["network"]["allowLocalBinding"],
1063            serde_json::json!(true)
1064        );
1065    }
1066
1067    #[test]
1068    fn test_settings_enable_weaker_network_isolation() {
1069        let manifest = test_manifest();
1070        let cc_config = ClaudeCodeConfig {
1071            sandbox: SandboxConfig {
1072                enabled: Some(true),
1073                enable_weaker_network_isolation: Some(true),
1074                ..Default::default()
1075            },
1076            ..Default::default()
1077        };
1078        let content = generate_settings(&manifest, &cc_config, None);
1079        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1080        assert_eq!(
1081            parsed["sandbox"]["enableWeakerNetworkIsolation"],
1082            serde_json::json!(true)
1083        );
1084    }
1085
1086    #[test]
1087    fn test_settings_with_allow_local_binding_snapshot() {
1088        let manifest = test_manifest();
1089        let cc_config = ClaudeCodeConfig {
1090            sandbox: SandboxConfig {
1091                enabled: Some(true),
1092                network: SandboxNetworkConfig {
1093                    allow_local_binding: Some(true),
1094                    ..Default::default()
1095                },
1096                ..Default::default()
1097            },
1098            ..Default::default()
1099        };
1100        let content = generate_settings(&manifest, &cc_config, None);
1101        insta::assert_snapshot!(content);
1102    }
1103
1104    #[test]
1105    fn test_settings_allow_local_binding_absent_when_none() {
1106        let manifest = test_manifest();
1107        let cc_config = ClaudeCodeConfig {
1108            sandbox: SandboxConfig {
1109                enabled: Some(true),
1110                ..Default::default()
1111            },
1112            ..Default::default()
1113        };
1114        let content = generate_settings(&manifest, &cc_config, None);
1115        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1116        // No network key at all when no network fields are set
1117        assert!(parsed["sandbox"].get("network").is_none());
1118    }
1119
1120    #[test]
1121    fn test_settings_with_env_snapshot() {
1122        let manifest = test_manifest();
1123        let mut env = BTreeMap::new();
1124        env.insert("GIT_SSH_COMMAND".to_string(), "ssh".to_string());
1125        let cc_config = ClaudeCodeConfig {
1126            env,
1127            ..Default::default()
1128        };
1129        let content = generate_settings(&manifest, &cc_config, None);
1130        insta::assert_snapshot!(content);
1131    }
1132
1133    #[test]
1134    fn test_settings_env_absent_when_empty() {
1135        let manifest = test_manifest();
1136        let cc_config = ClaudeCodeConfig::default();
1137        let content = generate_settings(&manifest, &cc_config, None);
1138        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1139        assert!(parsed.get("env").is_none());
1140    }
1141
1142    #[test]
1143    fn test_settings_preset_not_found_uses_global_only() {
1144        let manifest = test_manifest();
1145        let cc_config = ClaudeCodeConfig {
1146            allowed_tools: vec!["Bash(gh issue *)".to_string()],
1147            ..Default::default()
1148        };
1149        // Non-existent preset — only global tools should appear
1150        let content = generate_settings(&manifest, &cc_config, Some("nonexistent"));
1151        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1152        let allow = parsed["permissions"]["allow"].as_array().unwrap();
1153        assert_eq!(allow.len(), 1);
1154        assert_eq!(allow[0], "Bash(gh issue *)");
1155    }
1156
1157    #[test]
1158    fn test_to_sandbox_path_absolute() {
1159        assert_eq!(to_sandbox_path("/Users/me/.cargo"), "//Users/me/.cargo");
1160    }
1161
1162    #[test]
1163    fn test_to_sandbox_path_already_double_slash() {
1164        assert_eq!(to_sandbox_path("//Users/me/.cargo"), "//Users/me/.cargo");
1165    }
1166
1167    #[test]
1168    fn test_to_sandbox_path_home_relative() {
1169        assert_eq!(to_sandbox_path("~/.config/loom"), "~/.config/loom");
1170    }
1171
1172    #[test]
1173    fn test_to_sandbox_path_relative() {
1174        assert_eq!(to_sandbox_path("target/debug"), "target/debug");
1175    }
1176
1177    #[test]
1178    fn test_settings_sandbox_absolute_paths_converted() {
1179        let manifest = test_manifest();
1180        let cc_config = ClaudeCodeConfig {
1181            sandbox: SandboxConfig {
1182                enabled: Some(true),
1183                filesystem: SandboxFilesystemConfig {
1184                    allow_write: vec!["/Users/me/.cargo".to_string()],
1185                    deny_write: vec!["/etc/passwd".to_string()],
1186                    deny_read: vec!["/private/secrets".to_string()],
1187                },
1188                ..Default::default()
1189            },
1190            ..Default::default()
1191        };
1192        let content = generate_settings(&manifest, &cc_config, None);
1193        insta::assert_snapshot!(content);
1194    }
1195
1196    #[test]
1197    fn test_settings_sandbox_no_git_injection_when_disabled() {
1198        let manifest = test_manifest();
1199        let cc_config = ClaudeCodeConfig {
1200            sandbox: SandboxConfig {
1201                // enabled is None — .git paths should NOT be injected
1202                filesystem: SandboxFilesystemConfig {
1203                    allow_write: vec!["~/.config/loom".to_string()],
1204                    ..Default::default()
1205                },
1206                ..Default::default()
1207            },
1208            ..Default::default()
1209        };
1210        let content = generate_settings(&manifest, &cc_config, None);
1211        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1212        let allow = parsed["sandbox"]["filesystem"]["allowWrite"]
1213            .as_array()
1214            .unwrap();
1215        // Only the user-configured path, no .git paths
1216        assert_eq!(allow.len(), 1);
1217        assert_eq!(allow[0], "~/.config/loom");
1218    }
1219
1220    #[test]
1221    fn test_settings_sandbox_no_git_injection_when_explicitly_false() {
1222        let manifest = test_manifest();
1223        let cc_config = ClaudeCodeConfig {
1224            sandbox: SandboxConfig {
1225                enabled: Some(false),
1226                filesystem: SandboxFilesystemConfig {
1227                    allow_write: vec!["~/.config/loom".to_string()],
1228                    ..Default::default()
1229                },
1230                ..Default::default()
1231            },
1232            ..Default::default()
1233        };
1234        let content = generate_settings(&manifest, &cc_config, None);
1235        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1236        let allow = parsed["sandbox"]["filesystem"]["allowWrite"]
1237            .as_array()
1238            .unwrap();
1239        // enabled = false — .git paths should NOT be injected
1240        assert_eq!(allow.len(), 1);
1241        assert_eq!(allow[0], "~/.config/loom");
1242    }
1243
1244    #[test]
1245    fn test_mcp_json_global_servers_snapshot() {
1246        use crate::config::McpServerConfig;
1247        let mut mcp_servers = BTreeMap::new();
1248        mcp_servers.insert(
1249            "linear".to_string(),
1250            McpServerConfig {
1251                command: Some("npx".to_string()),
1252                args: Some(vec!["@anthropic/linear-mcp".to_string()]),
1253                ..Default::default()
1254            },
1255        );
1256        let cc_config = ClaudeCodeConfig {
1257            mcp_servers,
1258            ..Default::default()
1259        };
1260        let content = generate_mcp_json(&cc_config, None).unwrap();
1261        insta::assert_snapshot!(content);
1262    }
1263
1264    #[test]
1265    fn test_mcp_json_sse_server_snapshot() {
1266        use crate::config::McpServerConfig;
1267        let mut mcp_servers = BTreeMap::new();
1268        mcp_servers.insert(
1269            "grafana".to_string(),
1270            McpServerConfig {
1271                url: Some("https://grafana.example.com/mcp".to_string()),
1272                ..Default::default()
1273            },
1274        );
1275        let cc_config = ClaudeCodeConfig {
1276            mcp_servers,
1277            ..Default::default()
1278        };
1279        let content = generate_mcp_json(&cc_config, None).unwrap();
1280        insta::assert_snapshot!(content);
1281    }
1282
1283    #[test]
1284    fn test_mcp_json_preset_overrides_global() {
1285        use crate::config::McpServerConfig;
1286        let mut global_servers = BTreeMap::new();
1287        global_servers.insert(
1288            "shared".to_string(),
1289            McpServerConfig {
1290                command: Some("global-cmd".to_string()),
1291                ..Default::default()
1292            },
1293        );
1294        let mut preset_servers = BTreeMap::new();
1295        preset_servers.insert(
1296            "shared".to_string(),
1297            McpServerConfig {
1298                command: Some("preset-cmd".to_string()),
1299                ..Default::default()
1300            },
1301        );
1302        let mut presets = BTreeMap::new();
1303        presets.insert(
1304            "rust".to_string(),
1305            PermissionPreset {
1306                mcp_servers: preset_servers,
1307                ..Default::default()
1308            },
1309        );
1310        let cc_config = ClaudeCodeConfig {
1311            mcp_servers: global_servers,
1312            presets,
1313            ..Default::default()
1314        };
1315        let content = generate_mcp_json(&cc_config, Some("rust")).unwrap();
1316        insta::assert_snapshot!(content);
1317    }
1318
1319    #[test]
1320    fn test_mcp_json_none_when_empty() {
1321        let cc_config = ClaudeCodeConfig::default();
1322        assert!(generate_mcp_json(&cc_config, None).is_none());
1323    }
1324
1325    #[test]
1326    fn test_generator_includes_mcp_json() {
1327        use crate::config::McpServerConfig;
1328        let generator = ClaudeCodeGenerator;
1329        let manifest = test_manifest();
1330        let mut config = test_config();
1331        config.agents.claude_code.mcp_servers.insert(
1332            "test".to_string(),
1333            McpServerConfig {
1334                command: Some("test-cmd".to_string()),
1335                ..Default::default()
1336            },
1337        );
1338        let files = generator.generate(&manifest, &config).unwrap();
1339        assert_eq!(files.len(), 3);
1340        let mcp_file = files.iter().find(|f| f.relative_path == ".mcp.json");
1341        assert!(mcp_file.is_some());
1342    }
1343}