Skip to main content

loom_core/agent/
mod.rs

1pub mod claude_code;
2
3use std::path::Path;
4
5use anyhow::Result;
6
7use crate::config::Config;
8use crate::manifest::WorkspaceManifest;
9
10/// A file to be written into the workspace by an agent generator.
11#[derive(Debug)]
12pub struct GeneratedFile {
13    /// Path relative to the workspace root.
14    pub relative_path: String,
15    /// File content.
16    pub content: String,
17}
18
19/// A per-repo config entry that matched a manifest repo during agent file generation.
20#[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
36/// Trait for generating agent configuration files in a workspace.
37pub trait AgentGenerator {
38    /// Human-readable name for this generator (e.g., "claude-code").
39    fn name(&self) -> &str;
40
41    /// Generate files for the given workspace.
42    fn generate(&self, manifest: &WorkspaceManifest, config: &Config)
43    -> Result<Vec<GeneratedFile>>;
44}
45
46/// Run all enabled agent generators and write their files into the workspace.
47///
48/// Called after workspace composition changes (new, add, remove, partial down).
49pub fn generate_agent_files(
50    config: &Config,
51    ws_path: &Path,
52    manifest: &WorkspaceManifest,
53) -> Result<Vec<MatchedRepoConfig>> {
54    // Validate preset exists (stale preset check)
55    if let Some(ref preset_name) = manifest.preset {
56        crate::config::validate_preset_exists(&config.agents.claude_code.presets, preset_name)?;
57    }
58
59    // Collect applied repo configs and debug-log non-matches
60    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        // Clean up legacy and conditional files before regeneration
88        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            // Remove stale .mcp.json if MCP servers were removed from config
94            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            // Guard against path traversal and absolute paths in relative_path
103            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        // Legacy file should be removed
221        assert!(!legacy_path.exists());
222        // New file should exist
223        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        // Should not error, just skip
264        generate_agent_files(&config, &ws_path, &manifest).unwrap();
265
266        // No files written
267        assert!(!ws_path.join("CLAUDE.md").exists());
268    }
269
270    /// Helper: test the path traversal guard in isolation.
271    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        // Safe paths
280        assert!(is_safe_relative_path("CLAUDE.md"));
281        assert!(is_safe_relative_path(".claude/settings.json"));
282
283        // Path traversal with ..
284        assert!(!is_safe_relative_path("../etc/passwd"));
285        assert!(!is_safe_relative_path("foo/../../bar"));
286
287        // Absolute paths
288        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        // Only "pkm" matches — "unrelated" is in config but not in manifest,
529        // and "dsp-api" is in manifest but not in config.
530        assert_eq!(matched.len(), 1);
531        assert_eq!(matched[0].repo, "pkm");
532        assert_eq!(matched[0].workflow, Workflow::Push);
533    }
534}