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
9pub 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 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
54fn 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 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 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 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
181fn 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
190fn 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
203fn to_sandbox_path(path: &str) -> String {
213 if path.starts_with("//") || path.starts_with("~/") {
214 path.to_string()
216 } else if path.starts_with('/') {
217 format!("/{path}")
219 } else {
220 path.to_string()
222 }
223}
224
225fn 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 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 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 let allowed_domains = merge_sorted(
307 &sandbox.network.allowed_domains,
308 preset.map_or(&[], |p| &p.sandbox.network.allowed_domains),
309 );
310 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 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
351fn generate_mcp_json(cc_config: &ClaudeCodeConfig, preset_name: Option<&str>) -> Option<String> {
356 let mut servers = BTreeMap::new();
357
358 for (name, config) in &cc_config.mcp_servers {
360 servers.insert(name.clone(), config.clone());
361 }
362
363 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 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
396fn 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 let preset = preset_name.and_then(|name| cc_config.presets.get(name));
460
461 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 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 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 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 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 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 ..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 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 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 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 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 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 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}