1pub mod claude_code;
2
3use std::path::Path;
4
5use anyhow::Result;
6
7use crate::config::Config;
8use crate::manifest::WorkspaceManifest;
9
10#[derive(Debug)]
12pub struct GeneratedFile {
13 pub relative_path: String,
15 pub content: String,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct MatchedRepoConfig {
22 pub repo: String,
23 pub workflow: crate::config::Workflow,
24}
25
26impl std::fmt::Display for MatchedRepoConfig {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 write!(
29 f,
30 "Matched config for '{}' (workflow: {})",
31 self.repo, self.workflow
32 )
33 }
34}
35
36pub trait AgentGenerator {
38 fn name(&self) -> &str;
40
41 fn generate(&self, manifest: &WorkspaceManifest, config: &Config)
43 -> Result<Vec<GeneratedFile>>;
44}
45
46pub fn generate_agent_files(
50 config: &Config,
51 ws_path: &Path,
52 manifest: &WorkspaceManifest,
53) -> Result<Vec<MatchedRepoConfig>> {
54 if let Some(ref preset_name) = manifest.preset {
56 crate::config::validate_preset_exists(&config.agents.claude_code.presets, preset_name)?;
57 }
58
59 let mut applied = Vec::new();
61 for repo in &manifest.repos {
62 if let Some(repo_config) = config.repos.get(&repo.name) {
63 applied.push(MatchedRepoConfig {
64 repo: repo.name.clone(),
65 workflow: repo_config.workflow,
66 });
67 }
68 }
69 for repo_key in config.repos.keys() {
70 if !manifest.repos.iter().any(|r| r.name == *repo_key) {
71 tracing::debug!(
72 repo = %repo_key,
73 "config entry does not match any workspace repo, skipping"
74 );
75 }
76 }
77
78 for agent_name in &config.agents.enabled {
79 let generator: Box<dyn AgentGenerator> = match agent_name.as_str() {
80 "claude-code" => Box::new(claude_code::ClaudeCodeGenerator),
81 other => {
82 tracing::warn!(agent = other, "unknown agent, skipping");
83 continue;
84 }
85 };
86
87 if agent_name == "claude-code" {
89 let legacy = ws_path.join(".claude/settings.local.json");
90 if legacy.exists() {
91 std::fs::remove_file(&legacy)?;
92 }
93 let mcp_json = ws_path.join(".mcp.json");
95 if mcp_json.exists() {
96 std::fs::remove_file(&mcp_json)?;
97 }
98 }
99
100 let files = generator.generate(manifest, config)?;
101 for file in &files {
102 crate::config::validate_no_path_traversal(
104 &file.relative_path,
105 &format!("agent '{agent_name}' relative path"),
106 )?;
107 let full_path = ws_path.join(&file.relative_path);
108 if let Some(parent) = full_path.parent() {
109 std::fs::create_dir_all(parent)?;
110 }
111 std::fs::write(&full_path, &file.content)?;
112 }
113 }
114
115 Ok(applied)
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121 use crate::config::{
122 AgentsConfig, DefaultsConfig, RegistryConfig, RepoConfig, UpdateConfig, Workflow,
123 WorkspaceConfig,
124 };
125 use crate::manifest::RepoManifestEntry;
126 use std::collections::BTreeMap;
127 use std::path::PathBuf;
128
129 #[test]
130 fn test_generate_agent_files_writes_files() {
131 let dir = tempfile::tempdir().unwrap();
132 let ws_path = dir.path().join("my-ws");
133 std::fs::create_dir_all(&ws_path).unwrap();
134
135 let config = Config {
136 registry: RegistryConfig {
137 scan_roots: vec![],
138 scan_depth: 2,
139 },
140 workspace: WorkspaceConfig {
141 root: dir.path().to_path_buf(),
142 },
143 sync: None,
144 terminal: None,
145 editor: None,
146 defaults: DefaultsConfig::default(),
147 groups: BTreeMap::new(),
148 repos: BTreeMap::new(),
149 specs: None,
150 agents: AgentsConfig {
151 enabled: vec!["claude-code".to_string()],
152 ..Default::default()
153 },
154 update: UpdateConfig::default(),
155 };
156
157 let manifest = WorkspaceManifest {
158 name: "my-ws".to_string(),
159 branch: None,
160 created: chrono::Utc::now(),
161 base_branch: None,
162 preset: None,
163 repos: vec![RepoManifestEntry {
164 name: "dsp-api".to_string(),
165 original_path: PathBuf::from("/code/dasch-swiss/dsp-api"),
166 worktree_path: ws_path.join("dsp-api"),
167 branch: "loom/my-ws".to_string(),
168 remote_url: "git@github.com:dasch-swiss/dsp-api.git".to_string(),
169 }],
170 };
171
172 generate_agent_files(&config, &ws_path, &manifest).unwrap();
173
174 assert!(ws_path.join("CLAUDE.md").exists());
175 assert!(ws_path.join(".claude/settings.json").exists());
176 }
177
178 #[test]
179 fn test_generate_agent_files_removes_legacy_settings() {
180 let dir = tempfile::tempdir().unwrap();
181 let ws_path = dir.path().join("my-ws");
182 let legacy_path = ws_path.join(".claude/settings.local.json");
183 std::fs::create_dir_all(legacy_path.parent().unwrap()).unwrap();
184 std::fs::write(&legacy_path, "{}").unwrap();
185 assert!(legacy_path.exists());
186
187 let config = Config {
188 registry: RegistryConfig {
189 scan_roots: vec![],
190 scan_depth: 2,
191 },
192 workspace: WorkspaceConfig {
193 root: dir.path().to_path_buf(),
194 },
195 sync: None,
196 terminal: None,
197 editor: None,
198 defaults: DefaultsConfig::default(),
199 groups: BTreeMap::new(),
200 repos: BTreeMap::new(),
201 specs: None,
202 agents: AgentsConfig {
203 enabled: vec!["claude-code".to_string()],
204 ..Default::default()
205 },
206 update: UpdateConfig::default(),
207 };
208
209 let manifest = WorkspaceManifest {
210 name: "my-ws".to_string(),
211 branch: None,
212 created: chrono::Utc::now(),
213 base_branch: None,
214 preset: None,
215 repos: vec![],
216 };
217
218 generate_agent_files(&config, &ws_path, &manifest).unwrap();
219
220 assert!(!legacy_path.exists());
222 assert!(ws_path.join(".claude/settings.json").exists());
224 }
225
226 #[test]
227 fn test_generate_agent_files_unknown_agent_skipped() {
228 let dir = tempfile::tempdir().unwrap();
229 let ws_path = dir.path().join("my-ws");
230 std::fs::create_dir_all(&ws_path).unwrap();
231
232 let config = Config {
233 registry: RegistryConfig {
234 scan_roots: vec![],
235 scan_depth: 2,
236 },
237 workspace: WorkspaceConfig {
238 root: dir.path().to_path_buf(),
239 },
240 sync: None,
241 terminal: None,
242 editor: None,
243 defaults: DefaultsConfig::default(),
244 groups: BTreeMap::new(),
245 repos: BTreeMap::new(),
246 specs: None,
247 agents: AgentsConfig {
248 enabled: vec!["unknown-agent".to_string()],
249 ..Default::default()
250 },
251 update: UpdateConfig::default(),
252 };
253
254 let manifest = WorkspaceManifest {
255 name: "my-ws".to_string(),
256 branch: None,
257 created: chrono::Utc::now(),
258 base_branch: None,
259 preset: None,
260 repos: vec![],
261 };
262
263 generate_agent_files(&config, &ws_path, &manifest).unwrap();
265
266 assert!(!ws_path.join("CLAUDE.md").exists());
268 }
269
270 fn is_safe_relative_path(path: &str) -> bool {
272 use std::path::{Component, Path};
273 let p = Path::new(path);
274 !p.is_absolute() && !p.components().any(|c| c == Component::ParentDir)
275 }
276
277 #[test]
278 fn test_path_traversal_guard() {
279 assert!(is_safe_relative_path("CLAUDE.md"));
281 assert!(is_safe_relative_path(".claude/settings.json"));
282
283 assert!(!is_safe_relative_path("../etc/passwd"));
285 assert!(!is_safe_relative_path("foo/../../bar"));
286
287 assert!(!is_safe_relative_path("/etc/passwd"));
289 assert!(!is_safe_relative_path("/tmp/evil"));
290 }
291
292 #[test]
293 fn test_generate_agent_files_no_agents() {
294 let dir = tempfile::tempdir().unwrap();
295 let ws_path = dir.path().join("my-ws");
296 std::fs::create_dir_all(&ws_path).unwrap();
297
298 let config = Config {
299 registry: RegistryConfig {
300 scan_roots: vec![],
301 scan_depth: 2,
302 },
303 workspace: WorkspaceConfig {
304 root: dir.path().to_path_buf(),
305 },
306 sync: None,
307 terminal: None,
308 editor: None,
309 defaults: DefaultsConfig::default(),
310 groups: BTreeMap::new(),
311 repos: BTreeMap::new(),
312 specs: None,
313 agents: AgentsConfig {
314 enabled: vec![],
315 ..Default::default()
316 },
317 update: UpdateConfig::default(),
318 };
319
320 let manifest = WorkspaceManifest {
321 name: "my-ws".to_string(),
322 branch: None,
323 created: chrono::Utc::now(),
324 base_branch: None,
325 preset: None,
326 repos: vec![],
327 };
328
329 generate_agent_files(&config, &ws_path, &manifest).unwrap();
330 }
331
332 #[test]
333 fn test_applied_repo_configs_when_matching() {
334 let dir = tempfile::tempdir().unwrap();
335 let ws_path = dir.path().join("my-ws");
336 std::fs::create_dir_all(&ws_path).unwrap();
337
338 let mut repos = BTreeMap::new();
339 repos.insert(
340 "pkm".to_string(),
341 RepoConfig {
342 workflow: Workflow::Push,
343 },
344 );
345 repos.insert(
346 "loom".to_string(),
347 RepoConfig {
348 workflow: Workflow::Pr,
349 },
350 );
351
352 let config = Config {
353 registry: RegistryConfig {
354 scan_roots: vec![],
355 scan_depth: 2,
356 },
357 workspace: WorkspaceConfig {
358 root: dir.path().to_path_buf(),
359 },
360 sync: None,
361 terminal: None,
362 editor: None,
363 defaults: DefaultsConfig::default(),
364 groups: BTreeMap::new(),
365 repos,
366 specs: None,
367 agents: AgentsConfig {
368 enabled: vec!["claude-code".to_string()],
369 ..Default::default()
370 },
371 update: UpdateConfig::default(),
372 };
373
374 let manifest = WorkspaceManifest {
375 name: "my-ws".to_string(),
376 branch: None,
377 created: chrono::Utc::now(),
378 base_branch: None,
379 preset: None,
380 repos: vec![
381 RepoManifestEntry {
382 name: "pkm".to_string(),
383 original_path: PathBuf::from("/code/subotic/pkm"),
384 worktree_path: ws_path.join("pkm"),
385 branch: "loom/my-ws".to_string(),
386 remote_url: "git@github.com:subotic/pkm.git".to_string(),
387 },
388 RepoManifestEntry {
389 name: "loom".to_string(),
390 original_path: PathBuf::from("/code/subotic/loom"),
391 worktree_path: ws_path.join("loom"),
392 branch: "loom/my-ws".to_string(),
393 remote_url: "git@github.com:subotic/loom.git".to_string(),
394 },
395 ],
396 };
397
398 let matched = generate_agent_files(&config, &ws_path, &manifest).unwrap();
399 assert_eq!(matched.len(), 2);
400 let by_name: std::collections::HashMap<_, _> =
401 matched.iter().map(|m| (m.repo.as_str(), m)).collect();
402 assert_eq!(by_name["pkm"].workflow, Workflow::Push);
403 assert_eq!(by_name["loom"].workflow, Workflow::Pr);
404 }
405
406 #[test]
407 fn test_applied_repo_configs_empty_when_no_match() {
408 let dir = tempfile::tempdir().unwrap();
409 let ws_path = dir.path().join("my-ws");
410 std::fs::create_dir_all(&ws_path).unwrap();
411
412 let mut repos = BTreeMap::new();
413 repos.insert(
414 "other-repo".to_string(),
415 RepoConfig {
416 workflow: Workflow::Pr,
417 },
418 );
419
420 let config = Config {
421 registry: RegistryConfig {
422 scan_roots: vec![],
423 scan_depth: 2,
424 },
425 workspace: WorkspaceConfig {
426 root: dir.path().to_path_buf(),
427 },
428 sync: None,
429 terminal: None,
430 editor: None,
431 defaults: DefaultsConfig::default(),
432 groups: BTreeMap::new(),
433 repos,
434 specs: None,
435 agents: AgentsConfig {
436 enabled: vec!["claude-code".to_string()],
437 ..Default::default()
438 },
439 update: UpdateConfig::default(),
440 };
441
442 let manifest = WorkspaceManifest {
443 name: "my-ws".to_string(),
444 branch: None,
445 created: chrono::Utc::now(),
446 base_branch: None,
447 preset: None,
448 repos: vec![RepoManifestEntry {
449 name: "dsp-api".to_string(),
450 original_path: PathBuf::from("/code/dasch-swiss/dsp-api"),
451 worktree_path: ws_path.join("dsp-api"),
452 branch: "loom/my-ws".to_string(),
453 remote_url: "git@github.com:dasch-swiss/dsp-api.git".to_string(),
454 }],
455 };
456
457 let matched = generate_agent_files(&config, &ws_path, &manifest).unwrap();
458 assert!(matched.is_empty());
459 }
460
461 #[test]
462 fn test_applied_repo_configs_mixed() {
463 let dir = tempfile::tempdir().unwrap();
464 let ws_path = dir.path().join("my-ws");
465 std::fs::create_dir_all(&ws_path).unwrap();
466
467 let mut repos = BTreeMap::new();
468 repos.insert(
469 "pkm".to_string(),
470 RepoConfig {
471 workflow: Workflow::Push,
472 },
473 );
474 repos.insert(
475 "unrelated".to_string(),
476 RepoConfig {
477 workflow: Workflow::Pr,
478 },
479 );
480
481 let config = Config {
482 registry: RegistryConfig {
483 scan_roots: vec![],
484 scan_depth: 2,
485 },
486 workspace: WorkspaceConfig {
487 root: dir.path().to_path_buf(),
488 },
489 sync: None,
490 terminal: None,
491 editor: None,
492 defaults: DefaultsConfig::default(),
493 groups: BTreeMap::new(),
494 repos,
495 specs: None,
496 agents: AgentsConfig {
497 enabled: vec!["claude-code".to_string()],
498 ..Default::default()
499 },
500 update: UpdateConfig::default(),
501 };
502
503 let manifest = WorkspaceManifest {
504 name: "my-ws".to_string(),
505 branch: None,
506 created: chrono::Utc::now(),
507 base_branch: None,
508 preset: None,
509 repos: vec![
510 RepoManifestEntry {
511 name: "pkm".to_string(),
512 original_path: PathBuf::from("/code/subotic/pkm"),
513 worktree_path: ws_path.join("pkm"),
514 branch: "loom/my-ws".to_string(),
515 remote_url: "git@github.com:subotic/pkm.git".to_string(),
516 },
517 RepoManifestEntry {
518 name: "dsp-api".to_string(),
519 original_path: PathBuf::from("/code/dasch-swiss/dsp-api"),
520 worktree_path: ws_path.join("dsp-api"),
521 branch: "loom/my-ws".to_string(),
522 remote_url: "git@github.com:dasch-swiss/dsp-api.git".to_string(),
523 },
524 ],
525 };
526
527 let matched = generate_agent_files(&config, &ws_path, &manifest).unwrap();
528 assert_eq!(matched.len(), 1);
531 assert_eq!(matched[0].repo, "pkm");
532 assert_eq!(matched[0].workflow, Workflow::Push);
533 }
534}