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}
17
18impl Target {
19    pub fn parse_target(s: &str) -> Option<Self> {
20        match s.to_lowercase().as_str() {
21            "claude.md" | "claudemd" => Some(Target::ClaudeMd),
22            "cursorrules" | ".cursorrules" => Some(Target::CursorRules),
23            "windsurfrules" | ".windsurfrules" => Some(Target::WindsurfRules),
24            "agents.md" | "agentsmd" => Some(Target::AgentsMd),
25            "copilot" | "copilot-instructions" => Some(Target::Copilot),
26            _ => None,
27        }
28    }
29
30    pub fn filename(&self) -> &'static str {
31        match self {
32            Target::ClaudeMd => "CLAUDE.md",
33            Target::CursorRules => ".cursorrules",
34            Target::WindsurfRules => ".windsurfrules",
35            Target::AgentsMd => "AGENTS.md",
36            Target::Copilot => ".github/copilot-instructions.md",
37        }
38    }
39}
40
41/// Load agent rules from the project config.
42pub fn load_agent_rules() -> Option<AgentRules> {
43    let config = crate::team::project::load_project_config()?;
44    config.agent_rules
45}
46
47/// Generate agent config content for a specific target.
48pub fn generate_config(rules: &AgentRules) -> String {
49    let mut output = String::new();
50
51    // Header
52    output.push_str("# Project Rules\n\n");
53
54    // Global rules
55    if !rules.global.is_empty() {
56        for rule in &rules.global {
57            output.push_str(&format!("- {}\n", rule));
58        }
59        output.push('\n');
60    }
61
62    // Pinned docs
63    if rules.include_pins {
64        let pins = list_pins();
65        if !pins.is_empty() {
66            output.push_str("## Pinned Documentation\n");
67            output.push_str(
68                "Use `chub get <id>` to fetch these docs when working with these libraries:\n",
69            );
70            for pin in &pins {
71                let mut desc = format!("- {}", pin.id);
72                if let Some(ref lang) = pin.lang {
73                    desc.push_str(&format!(" ({})", lang));
74                }
75                if let Some(ref version) = pin.version {
76                    desc.push_str(&format!(" v{}", version));
77                }
78                if let Some(ref reason) = pin.reason {
79                    desc.push_str(&format!(" — {}", reason));
80                }
81                output.push_str(&desc);
82                output.push('\n');
83            }
84            output.push('\n');
85        }
86    }
87
88    // Project context
89    if rules.include_context {
90        let context_docs = list_context_docs();
91        if !context_docs.is_empty() {
92            output.push_str("## Project Context\n");
93            output.push_str("Use `chub get project/<name>` or ask Chub for these:\n");
94            for doc in &context_docs {
95                let stem = doc.file.strip_suffix(".md").unwrap_or(&doc.file);
96                let mut desc = format!("- project/{}", stem);
97                if !doc.description.is_empty() {
98                    desc.push_str(&format!(" — {}", doc.description));
99                }
100                output.push_str(&desc);
101                output.push('\n');
102            }
103            output.push('\n');
104        }
105    }
106
107    // Module rules
108    for (module_name, module_rules) in &rules.modules {
109        output.push_str(&format!(
110            "## Module: {} ({})\n",
111            module_name, module_rules.path
112        ));
113        for rule in &module_rules.rules {
114            output.push_str(&format!("- {}\n", rule));
115        }
116        output.push('\n');
117    }
118
119    output
120}
121
122/// Result of a sync operation for one target.
123#[derive(Debug, Clone)]
124pub struct SyncResult {
125    pub target: String,
126    pub filename: String,
127    pub action: SyncAction,
128}
129
130#[derive(Debug, Clone)]
131pub enum SyncAction {
132    Created,
133    Updated,
134    Unchanged,
135}
136
137/// Generate and write all configured target files.
138pub fn sync_configs() -> Result<Vec<SyncResult>> {
139    let rules = load_agent_rules().ok_or_else(|| {
140        Error::Config(
141            "No agent_rules found in .chub/config.yaml. Add agent_rules section first.".to_string(),
142        )
143    })?;
144
145    let project_root = crate::team::project::find_project_root(None).ok_or_else(|| {
146        Error::Config("No .chub/ directory found. Run `chub init` first.".to_string())
147    })?;
148
149    let content = generate_config(&rules);
150    let mut results = Vec::new();
151
152    for target_name in &rules.targets {
153        let target = match Target::parse_target(target_name) {
154            Some(t) => t,
155            None => continue,
156        };
157
158        let path = project_root.join(target.filename());
159
160        // Create parent directory if needed
161        if let Some(parent) = path.parent() {
162            let _ = fs::create_dir_all(parent);
163        }
164
165        let action = if path.exists() {
166            let existing = fs::read_to_string(&path).unwrap_or_default();
167            if existing == content {
168                SyncAction::Unchanged
169            } else {
170                fs::write(&path, &content)?;
171                SyncAction::Updated
172            }
173        } else {
174            fs::write(&path, &content)?;
175            SyncAction::Created
176        };
177
178        results.push(SyncResult {
179            target: target_name.clone(),
180            filename: target.filename().to_string(),
181            action,
182        });
183    }
184
185    Ok(results)
186}
187
188/// Show what would change without writing.
189pub fn diff_configs() -> Result<Vec<(String, String, Option<String>)>> {
190    let rules = load_agent_rules()
191        .ok_or_else(|| Error::Config("No agent_rules found in .chub/config.yaml.".to_string()))?;
192
193    let project_root = crate::team::project::find_project_root(None)
194        .ok_or_else(|| Error::Config("No .chub/ directory found.".to_string()))?;
195
196    let content = generate_config(&rules);
197    let mut diffs = Vec::new();
198
199    for target_name in &rules.targets {
200        let target = match Target::parse_target(target_name) {
201            Some(t) => t,
202            None => continue,
203        };
204
205        let path = project_root.join(target.filename());
206        let existing = if path.exists() {
207            Some(fs::read_to_string(&path).unwrap_or_default())
208        } else {
209            None
210        };
211
212        diffs.push((target.filename().to_string(), content.clone(), existing));
213    }
214
215    Ok(diffs)
216}