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#[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 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
72pub fn load_agent_rules() -> Option<AgentRules> {
74 let config = crate::team::project::load_project_config()?;
75 config.agent_rules
76}
77
78pub fn generate_config(rules: &AgentRules) -> String {
80 let mut output = String::new();
81
82 output.push_str("# Project Rules\n\n");
84
85 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 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 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 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 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 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#[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
218pub 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 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
276pub 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}