Skip to main content

chub_core/team/
agent_config.rs

1use std::fs;
2
3use crate::error::{Error, Result};
4use crate::team::context::list_context_docs;
5use crate::team::pins::list_pins;
6use crate::team::project::AgentRules;
7
8/// Supported agent config targets.
9#[derive(Debug, Clone)]
10pub enum Target {
11    ClaudeMd,
12    CursorRules,
13    WindsurfRules,
14    AgentsMd,
15    Copilot,
16    GeminiMd,
17    ClineRules,
18    RooRules,
19    AugmentRules,
20    KiroSteering,
21}
22
23impl Target {
24    pub fn parse_target(s: &str) -> Option<Self> {
25        match s.to_lowercase().as_str() {
26            "claude.md" | "claudemd" => Some(Target::ClaudeMd),
27            "cursorrules" | ".cursorrules" => Some(Target::CursorRules),
28            "windsurfrules" | ".windsurfrules" => Some(Target::WindsurfRules),
29            "agents.md" | "agentsmd" => Some(Target::AgentsMd),
30            "copilot" | "copilot-instructions" => Some(Target::Copilot),
31            "gemini.md" | "geminimd" => Some(Target::GeminiMd),
32            "clinerules" | ".clinerules" => Some(Target::ClineRules),
33            "roorules" | "roo-rules" => Some(Target::RooRules),
34            "augmentrules" | "augment-rules" => Some(Target::AugmentRules),
35            "kiro" | "kiro-steering" => Some(Target::KiroSteering),
36            _ => None,
37        }
38    }
39
40    pub fn filename(&self) -> &'static str {
41        match self {
42            Target::ClaudeMd => "CLAUDE.md",
43            Target::CursorRules => ".cursorrules",
44            Target::WindsurfRules => ".windsurfrules",
45            Target::AgentsMd => "AGENTS.md",
46            Target::Copilot => ".github/copilot-instructions.md",
47            Target::GeminiMd => "GEMINI.md",
48            Target::ClineRules => ".clinerules",
49            Target::RooRules => ".roo/rules/chub-rules.md",
50            Target::AugmentRules => ".augment/rules/chub-rules.md",
51            Target::KiroSteering => ".kiro/steering/chub-rules.md",
52        }
53    }
54
55    /// Returns all known target names for documentation and help text.
56    pub fn all_target_names() -> &'static [&'static str] {
57        &[
58            "claude.md",
59            "cursorrules",
60            "windsurfrules",
61            "agents.md",
62            "copilot",
63            "gemini.md",
64            "clinerules",
65            "roorules",
66            "augmentrules",
67            "kiro",
68        ]
69    }
70}
71
72/// Load agent rules from the project config.
73pub fn load_agent_rules() -> Option<AgentRules> {
74    let config = crate::team::project::load_project_config()?;
75    config.agent_rules
76}
77
78/// Generate agent config content for a specific target.
79pub fn generate_config(rules: &AgentRules) -> String {
80    let mut output = String::new();
81
82    // Header
83    output.push_str("# Project Rules\n\n");
84
85    // Global rules
86    if !rules.global.is_empty() {
87        for rule in &rules.global {
88            output.push_str(&format!("- {}\n", rule));
89        }
90        output.push('\n');
91    }
92
93    // Pinned docs
94    if rules.include_pins {
95        let pins = list_pins();
96        if !pins.is_empty() {
97            output.push_str("## Pinned Documentation\n");
98            output.push_str(
99                "Use `chub get <id>` to fetch these docs when working with these libraries:\n",
100            );
101            for pin in &pins {
102                let mut desc = format!("- {}", pin.id);
103                if let Some(ref lang) = pin.lang {
104                    desc.push_str(&format!(" ({})", lang));
105                }
106                if let Some(ref version) = pin.version {
107                    desc.push_str(&format!(" v{}", version));
108                }
109                if let Some(ref reason) = pin.reason {
110                    desc.push_str(&format!(" — {}", reason));
111                }
112                output.push_str(&desc);
113                output.push('\n');
114            }
115            output.push('\n');
116        }
117    }
118
119    // Project context
120    if rules.include_context {
121        let context_docs = list_context_docs();
122        if !context_docs.is_empty() {
123            output.push_str("## Project Context\n");
124            output.push_str("Use `chub get project/<name>` or ask Chub for these:\n");
125            for doc in &context_docs {
126                let stem = doc.file.strip_suffix(".md").unwrap_or(&doc.file);
127                let mut desc = format!("- project/{}", stem);
128                if !doc.description.is_empty() {
129                    desc.push_str(&format!(" — {}", doc.description));
130                }
131                output.push_str(&desc);
132                output.push('\n');
133            }
134            output.push('\n');
135        }
136    }
137
138    // Annotation policy
139    if rules.include_annotation_policy {
140        output.push_str("## Annotation Policy\n\n");
141        output.push_str(
142            "**IMPORTANT**: Before implementing anything with a pinned library, ALWAYS check \
143             for existing annotations first via `chub_annotate` (id only) or `chub get <id>`. \
144             Annotations contain team-verified issues, workarounds, and conventions that save \
145             you from repeating known mistakes.\n\n",
146        );
147        output.push_str(
148            "When you encounter something non-obvious while using a library, record it \
149             via the `chub_annotate` MCP tool (or `chub annotate` CLI):\n\n",
150        );
151        output.push_str(
152            "- **Issue** (confirmed bug): `chub_annotate` with `id`, `kind=\"issue\"`, `severity=\"high|medium|low\"`, `note`\n",
153        );
154        output.push_str(
155            "- **Fix** (workaround): `chub_annotate` with `id`, `kind=\"fix\"`, `note`\n",
156        );
157        output.push_str(
158            "- **Practice** (team convention): `chub_annotate` with `id`, `kind=\"practice\"`, `note`\n\n",
159        );
160        output.push_str("Rules:\n");
161        output.push_str("- **Read before implementing** — always check existing annotations before using a library to avoid known pitfalls\n");
162        output.push_str("- **Write after discovering** — if you hit a non-obvious issue, found a workaround, or established a working pattern, annotate it for the team\n");
163        output.push_str("- Annotate after confirming, not speculatively — only write what you have verified works or fails\n");
164        output
165            .push_str("- One fact per annotation — do not bundle multiple issues into one note\n");
166        output.push_str(
167            "- Be reproducible — include the exact call, param, or value, not vague descriptions\n",
168        );
169        output.push_str("- Check first — call `chub_annotate` with only `id` to read existing annotations before writing to avoid duplicates\n");
170        output.push_str("- Do not annotate what is already in the official docs — only capture what the docs missed or got wrong\n\n");
171    }
172
173    // AI usage tracking
174    if rules.include_annotation_policy {
175        output.push_str("## AI Usage Tracking\n\n");
176        output.push_str(
177            "This project tracks AI coding sessions via `chub track`. Use `chub_track` MCP tool to:\n",
178        );
179        output.push_str("- `{\"action\": \"status\"}` — check current session state\n");
180        output
181            .push_str("- `{\"action\": \"report\"}` — view usage summary (tokens, costs, tools)\n");
182        output.push_str("- `{\"action\": \"log\"}` — list recent sessions\n\n");
183    }
184
185    // Module rules — sort by name for deterministic output (HashMap iteration is unordered).
186    let mut sorted_modules: Vec<_> = rules.modules.iter().collect();
187    sorted_modules.sort_by_key(|(name, _)| name.as_str());
188    for (module_name, module_rules) in sorted_modules {
189        output.push_str(&format!(
190            "## Module: {} ({})\n",
191            module_name, module_rules.path
192        ));
193        for rule in &module_rules.rules {
194            output.push_str(&format!("- {}\n", rule));
195        }
196        output.push('\n');
197    }
198
199    output
200}
201
202/// Result of a sync operation for one target.
203#[derive(Debug, Clone)]
204pub struct SyncResult {
205    pub target: String,
206    pub filename: String,
207    pub action: SyncAction,
208}
209
210#[derive(Debug, Clone)]
211pub enum SyncAction {
212    Created,
213    Updated,
214    Unchanged,
215    Unknown,
216}
217
218/// Generate and write all configured target files.
219pub fn sync_configs() -> Result<Vec<SyncResult>> {
220    let rules = load_agent_rules().ok_or_else(|| {
221        Error::Config(
222            "No agent_rules found in .chub/config.yaml. Add agent_rules section first.".to_string(),
223        )
224    })?;
225
226    let project_root = crate::team::project::find_project_root(None).ok_or_else(|| {
227        Error::Config("No .chub/ directory found. Run `chub init` first.".to_string())
228    })?;
229
230    let content = generate_config(&rules);
231    let mut results = Vec::new();
232
233    for target_name in &rules.targets {
234        let target = match Target::parse_target(target_name) {
235            Some(t) => t,
236            None => {
237                results.push(SyncResult {
238                    target: target_name.clone(),
239                    filename: target_name.clone(),
240                    action: SyncAction::Unknown,
241                });
242                continue;
243            }
244        };
245
246        let path = project_root.join(target.filename());
247
248        // Create parent directory if needed
249        if let Some(parent) = path.parent() {
250            let _ = fs::create_dir_all(parent);
251        }
252
253        let action = if path.exists() {
254            let existing = fs::read_to_string(&path).unwrap_or_default();
255            if existing == content {
256                SyncAction::Unchanged
257            } else {
258                fs::write(&path, &content)?;
259                SyncAction::Updated
260            }
261        } else {
262            fs::write(&path, &content)?;
263            SyncAction::Created
264        };
265
266        results.push(SyncResult {
267            target: target_name.clone(),
268            filename: target.filename().to_string(),
269            action,
270        });
271    }
272
273    Ok(results)
274}
275
276/// Show what would change without writing.
277pub fn diff_configs() -> Result<Vec<(String, String, Option<String>)>> {
278    let rules = load_agent_rules()
279        .ok_or_else(|| Error::Config("No agent_rules found in .chub/config.yaml.".to_string()))?;
280
281    let project_root = crate::team::project::find_project_root(None)
282        .ok_or_else(|| Error::Config("No .chub/ directory found.".to_string()))?;
283
284    let content = generate_config(&rules);
285    let mut diffs = Vec::new();
286
287    for target_name in &rules.targets {
288        let target = match Target::parse_target(target_name) {
289            Some(t) => t,
290            None => continue,
291        };
292
293        let path = project_root.join(target.filename());
294        let existing = if path.exists() {
295            Some(fs::read_to_string(&path).unwrap_or_default())
296        } else {
297            None
298        };
299
300        diffs.push((target.filename().to_string(), content.clone(), existing));
301    }
302
303    Ok(diffs)
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    #[test]
311    fn parse_all_known_targets() {
312        for name in Target::all_target_names() {
313            assert!(
314                Target::parse_target(name).is_some(),
315                "all_target_names entry '{}' should parse",
316                name
317            );
318        }
319    }
320
321    #[test]
322    fn parse_target_aliases() {
323        let cases = [
324            ("claude.md", "CLAUDE.md"),
325            ("claudemd", "CLAUDE.md"),
326            (".cursorrules", ".cursorrules"),
327            ("cursorrules", ".cursorrules"),
328            (".windsurfrules", ".windsurfrules"),
329            ("agents.md", "AGENTS.md"),
330            ("agentsmd", "AGENTS.md"),
331            ("copilot", ".github/copilot-instructions.md"),
332            ("copilot-instructions", ".github/copilot-instructions.md"),
333            ("gemini.md", "GEMINI.md"),
334            ("geminimd", "GEMINI.md"),
335            (".clinerules", ".clinerules"),
336            ("clinerules", ".clinerules"),
337            ("roorules", ".roo/rules/chub-rules.md"),
338            ("roo-rules", ".roo/rules/chub-rules.md"),
339            ("augmentrules", ".augment/rules/chub-rules.md"),
340            ("augment-rules", ".augment/rules/chub-rules.md"),
341            ("kiro", ".kiro/steering/chub-rules.md"),
342            ("kiro-steering", ".kiro/steering/chub-rules.md"),
343        ];
344        for (input, expected_file) in cases {
345            let target =
346                Target::parse_target(input).unwrap_or_else(|| panic!("'{}' should parse", input));
347            assert_eq!(target.filename(), expected_file, "input: '{}'", input);
348        }
349    }
350
351    #[test]
352    fn parse_unknown_target_returns_none() {
353        assert!(Target::parse_target("vim").is_none());
354        assert!(Target::parse_target("").is_none());
355        assert!(Target::parse_target("zed").is_none());
356    }
357
358    #[test]
359    fn parse_is_case_insensitive() {
360        assert!(Target::parse_target("CLAUDE.MD").is_some());
361        assert!(Target::parse_target("CursorRules").is_some());
362        assert!(Target::parse_target("GEMINI.MD").is_some());
363        assert!(Target::parse_target("KIRO").is_some());
364    }
365
366    #[test]
367    fn generate_config_includes_global_rules() {
368        let rules = AgentRules {
369            global: vec!["Run tests".to_string(), "Format code".to_string()],
370            modules: Default::default(),
371            targets: vec![],
372            include_pins: false,
373            include_context: false,
374            include_annotation_policy: false,
375        };
376        let output = generate_config(&rules);
377        assert!(output.contains("- Run tests"));
378        assert!(output.contains("- Format code"));
379        assert!(output.starts_with("# Project Rules"));
380    }
381}