Skip to main content

cgx_engine/
config.rs

1use serde::{Deserialize, Serialize};
2use std::path::{Path, PathBuf};
3
4/// cgx configuration — loaded from `.cgx/config.toml` in the repo root
5/// or `~/.cgx/config.toml` for global defaults.
6#[derive(Debug, Clone, Serialize, Deserialize, Default)]
7pub struct CgxConfig {
8    #[serde(default)]
9    pub project: ProjectConfig,
10    #[serde(default)]
11    pub analyze: AnalyzeConfig,
12    #[serde(default)]
13    pub index: IndexConfig,
14    #[serde(default)]
15    pub watch: WatchConfig,
16    #[serde(default)]
17    pub chat: ChatConfig,
18    #[serde(default)]
19    pub serve: ServeConfig,
20    #[serde(default)]
21    pub mcp: McpConfig,
22    #[serde(default)]
23    pub skill: SkillConfig,
24    #[serde(default)]
25    pub export: ExportConfig,
26    #[serde(default)]
27    pub docs: DocsConfig,
28}
29
30impl CgxConfig {
31    /// Load config from `.cgx/config.toml` in the given directory,
32    /// falling back to `~/.cgx/config.toml` for missing fields.
33    pub fn load(repo_path: &Path) -> anyhow::Result<Self> {
34        let repo_config = repo_path.join(".cgx").join("config.toml");
35        let global_config = dirs::home_dir()
36            .unwrap_or_else(|| PathBuf::from("."))
37            .join(".cgx")
38            .join("config.toml");
39
40        // Start with defaults
41        let mut config = Self::default();
42
43        // Load global defaults first (if no repo config exists, this is the primary config)
44        if global_config.exists() {
45            let content = std::fs::read_to_string(&global_config)?;
46            config = toml::from_str(&content)?;
47        }
48
49        // Repo-local config fully overrides global (no partial merge to avoid
50        // default-value clobbering)
51        if repo_config.exists() {
52            let content = std::fs::read_to_string(&repo_config)?;
53            config = toml::from_str(&content)?;
54        }
55
56        Ok(config)
57    }
58
59    /// Save config to `.cgx/config.toml` in the given directory.
60    pub fn save(&self, repo_path: &Path) -> anyhow::Result<()> {
61        let config_dir = repo_path.join(".cgx");
62        std::fs::create_dir_all(&config_dir)?;
63        let config_path = config_dir.join("config.toml");
64        let content = toml::to_string_pretty(self)?;
65        std::fs::write(&config_path, content)?;
66        Ok(())
67    }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct ProjectConfig {
72    #[serde(default = "default_project_name")]
73    pub name: String,
74}
75
76impl Default for ProjectConfig {
77    fn default() -> Self {
78        Self {
79            name: default_project_name(),
80        }
81    }
82}
83
84fn default_project_name() -> String {
85    std::env::current_dir()
86        .ok()
87        .and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
88        .unwrap_or_else(|| "cgx-project".to_string())
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct AnalyzeConfig {
93    #[serde(default = "default_languages")]
94    pub languages: Vec<String>,
95    #[serde(default = "default_exclude")]
96    pub exclude: Vec<String>,
97    #[serde(default = "default_churn_window")]
98    pub churn_window_days: u32,
99    #[serde(default = "default_co_change_threshold")]
100    pub co_change_threshold: u32,
101    #[serde(default = "default_max_file_size")]
102    pub max_file_size: u64,
103}
104
105impl Default for AnalyzeConfig {
106    fn default() -> Self {
107        Self {
108            languages: default_languages(),
109            exclude: default_exclude(),
110            churn_window_days: default_churn_window(),
111            co_change_threshold: default_co_change_threshold(),
112            max_file_size: default_max_file_size(),
113        }
114    }
115}
116
117fn default_languages() -> Vec<String> {
118    vec![
119        "typescript".to_string(),
120        "javascript".to_string(),
121        "python".to_string(),
122        "rust".to_string(),
123        "go".to_string(),
124        "java".to_string(),
125        "php".to_string(),
126    ]
127}
128
129fn default_exclude() -> Vec<String> {
130    vec![
131        "vendor/".to_string(),
132        "generated/".to_string(),
133        "*.pb.go".to_string(),
134        "third_party/".to_string(),
135        "build/".to_string(),
136        "out/".to_string(),
137    ]
138}
139
140fn default_churn_window() -> u32 {
141    90
142}
143
144fn default_co_change_threshold() -> u32 {
145    2
146}
147
148fn default_max_file_size() -> u64 {
149    2 * 1024 * 1024
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct IndexConfig {
154    #[serde(default = "default_true")]
155    pub incremental: bool,
156    #[serde(default = "default_true")]
157    pub store_hashes: bool,
158}
159
160impl Default for IndexConfig {
161    fn default() -> Self {
162        Self {
163            incremental: true,
164            store_hashes: true,
165        }
166    }
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
170pub struct WatchConfig {
171    #[serde(default = "default_watch_include")]
172    pub include: Vec<String>,
173    #[serde(default = "default_watch_ignore")]
174    pub ignore: Vec<String>,
175    #[serde(default = "default_debounce")]
176    pub debounce_ms: u64,
177}
178
179impl Default for WatchConfig {
180    fn default() -> Self {
181        Self {
182            include: default_watch_include(),
183            ignore: default_watch_ignore(),
184            debounce_ms: default_debounce(),
185        }
186    }
187}
188
189fn default_watch_include() -> Vec<String> {
190    vec![
191        "*.ts".to_string(),
192        "*.js".to_string(),
193        "*.py".to_string(),
194        "*.rs".to_string(),
195        "*.go".to_string(),
196        "*.java".to_string(),
197        "*.php".to_string(),
198    ]
199}
200
201fn default_watch_ignore() -> Vec<String> {
202    vec![
203        ".git".to_string(),
204        "node_modules".to_string(),
205        "target".to_string(),
206        "dist".to_string(),
207        "__pycache__".to_string(),
208        ".cgx".to_string(),
209    ]
210}
211
212fn default_debounce() -> u64 {
213    500
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct ChatConfig {
218    #[serde(default = "default_chat_provider")]
219    pub provider: String,
220    #[serde(default = "default_chat_model")]
221    pub model: String,
222    #[serde(default = "default_ollama_host")]
223    pub ollama_host: String,
224    #[serde(default = "default_chat_timeout")]
225    pub timeout: u32,
226}
227
228impl Default for ChatConfig {
229    fn default() -> Self {
230        Self {
231            provider: default_chat_provider(),
232            model: default_chat_model(),
233            ollama_host: default_ollama_host(),
234            timeout: default_chat_timeout(),
235        }
236    }
237}
238
239fn default_chat_provider() -> String {
240    "ollama".to_string()
241}
242
243fn default_chat_model() -> String {
244    "codellama".to_string()
245}
246
247fn default_ollama_host() -> String {
248    "http://localhost:11434".to_string()
249}
250
251fn default_chat_timeout() -> u32 {
252    30
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct ServeConfig {
257    #[serde(default = "default_port")]
258    pub port: u16,
259    #[serde(default = "default_true")]
260    pub auto_open: bool,
261}
262
263impl Default for ServeConfig {
264    fn default() -> Self {
265        Self {
266            port: default_port(),
267            auto_open: true,
268        }
269    }
270}
271
272fn default_port() -> u16 {
273    7373
274}
275
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct McpConfig {
278    #[serde(default = "default_true")]
279    pub enabled: bool,
280    #[serde(default = "default_mcp_timeout")]
281    pub timeout: u32,
282}
283
284impl Default for McpConfig {
285    fn default() -> Self {
286        Self {
287            enabled: true,
288            timeout: default_mcp_timeout(),
289        }
290    }
291}
292
293fn default_mcp_timeout() -> u32 {
294    30
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct SkillConfig {
299    #[serde(default = "default_true")]
300    pub auto_generate: bool,
301    #[serde(default = "default_true")]
302    pub include_token_budget: bool,
303    #[serde(default = "default_true")]
304    pub include_architecture: bool,
305}
306
307impl Default for SkillConfig {
308    fn default() -> Self {
309        Self {
310            auto_generate: true,
311            include_token_budget: true,
312            include_architecture: true,
313        }
314    }
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
318pub struct ExportConfig {
319    #[serde(default = "default_export_format")]
320    pub default_format: String,
321    #[serde(default = "default_max_nodes")]
322    pub max_nodes: usize,
323}
324
325impl Default for ExportConfig {
326    fn default() -> Self {
327        Self {
328            default_format: default_export_format(),
329            max_nodes: default_max_nodes(),
330        }
331    }
332}
333
334fn default_export_format() -> String {
335    "json".to_string()
336}
337
338fn default_max_nodes() -> usize {
339    80
340}
341
342fn default_true() -> bool {
343    true
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct DocsConfig {
348    #[serde(default = "default_docs_output_dir")]
349    pub output_dir: String,
350    #[serde(default)]
351    pub vault_path: String,
352    #[serde(default = "default_docs_layout")]
353    pub layout: String,
354    #[serde(default = "default_wiki_links_style")]
355    pub wiki_links: String,
356    #[serde(default = "default_true")]
357    pub prompt_packets: bool,
358    #[serde(default = "default_true")]
359    pub frontmatter: bool,
360    #[serde(default = "default_true")]
361    pub include_dead_code: bool,
362    #[serde(default = "default_true")]
363    pub include_duplicates: bool,
364}
365
366impl Default for DocsConfig {
367    fn default() -> Self {
368        Self {
369            output_dir: default_docs_output_dir(),
370            vault_path: String::new(),
371            layout: default_docs_layout(),
372            wiki_links: default_wiki_links_style(),
373            prompt_packets: true,
374            frontmatter: true,
375            include_dead_code: true,
376            include_duplicates: true,
377        }
378    }
379}
380
381fn default_docs_output_dir() -> String {
382    "./cgx-docs".to_string()
383}
384
385fn default_docs_layout() -> String {
386    "layered".to_string()
387}
388
389fn default_wiki_links_style() -> String {
390    "obsidian".to_string()
391}