Skip to main content

bones_core/
config.rs

1use anyhow::{Context, Result};
2use serde::{Deserialize, Serialize};
3use std::env;
4use std::io::IsTerminal;
5use std::path::{Path, PathBuf};
6
7#[derive(Debug, Clone, Serialize, Deserialize, Default)]
8pub struct ProjectConfig {
9    #[serde(default)]
10    pub goals: GoalConfig,
11    #[serde(default)]
12    pub search: SearchConfig,
13    #[serde(default)]
14    pub triage: TriageConfig,
15    #[serde(default)]
16    pub done: DoneConfig,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct GoalConfig {
21    #[serde(default = "default_true")]
22    pub auto_complete: bool,
23}
24
25impl Default for GoalConfig {
26    fn default() -> Self {
27        Self {
28            auto_complete: default_true(),
29        }
30    }
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct SearchConfig {
35    #[serde(default = "default_true")]
36    pub semantic: bool,
37    #[serde(default = "default_search_model")]
38    pub model: String,
39    #[serde(default = "default_duplicate_threshold")]
40    pub duplicate_threshold: f64,
41    #[serde(default = "default_related_threshold")]
42    pub related_threshold: f64,
43    #[serde(default = "default_true")]
44    pub warn_on_create: bool,
45}
46
47impl Default for SearchConfig {
48    fn default() -> Self {
49        Self {
50            semantic: default_true(),
51            model: default_search_model(),
52            duplicate_threshold: default_duplicate_threshold(),
53            related_threshold: default_related_threshold(),
54            warn_on_create: default_true(),
55        }
56    }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct TriageConfig {
61    #[serde(default = "default_true")]
62    pub feedback_learning: bool,
63}
64
65impl Default for TriageConfig {
66    fn default() -> Self {
67        Self {
68            feedback_learning: default_true(),
69        }
70    }
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, Default)]
74pub struct DoneConfig {
75    #[serde(default)]
76    pub require_reason: bool,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct RepoConfig {
81    pub name: String,
82    pub path: PathBuf,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize, Default)]
86pub struct UserConfig {
87    #[serde(default)]
88    pub output: Option<String>,
89    #[serde(default)]
90    pub repos: Vec<RepoConfig>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct EffectiveConfig {
95    pub project: ProjectConfig,
96    pub user: UserConfig,
97    pub resolved_output: String,
98}
99
100/// Load project-level configuration from `.bones/config.toml`.
101///
102/// # Errors
103///
104/// Returns an error if the file exists but cannot be read or parsed.
105pub fn load_project_config(project_root: &Path) -> Result<ProjectConfig> {
106    let path = project_root.join(".bones/config.toml");
107    if !path.exists() {
108        return Ok(ProjectConfig::default());
109    }
110
111    let content = std::fs::read_to_string(&path)
112        .with_context(|| format!("Failed to read {}", path.display()))?;
113
114    toml::from_str::<ProjectConfig>(&content)
115        .with_context(|| format!("Failed to parse {}", path.display()))
116}
117
118/// Load user-level configuration from `<config-dir>/bones/config.toml`.
119///
120/// # Errors
121///
122/// Returns an error if the file exists but cannot be read or parsed.
123pub fn load_user_config() -> Result<UserConfig> {
124    let Some(config_dir) = dirs::config_dir() else {
125        return Ok(UserConfig::default());
126    };
127
128    let path = config_dir.join("bones/config.toml");
129    if !path.exists() {
130        return Ok(UserConfig::default());
131    }
132
133    let content = std::fs::read_to_string(&path)
134        .with_context(|| format!("Failed to read {}", path.display()))?;
135
136    toml::from_str::<UserConfig>(&content)
137        .with_context(|| format!("Failed to parse {}", path.display()))
138}
139
140#[must_use]
141pub fn discover_repos(config: &UserConfig) -> Vec<(String, PathBuf, bool)> {
142    config
143        .repos
144        .iter()
145        .map(|repo_config| {
146            let path = &repo_config.path;
147            let bones_dir = path.join(".bones");
148
149            let available = path.exists() && bones_dir.exists();
150
151            if !available {
152                if path.exists() {
153                    eprintln!(
154                        "Warning: Repository '{}' at {} does not contain .bones/ directory",
155                        repo_config.name,
156                        path.display()
157                    );
158                } else {
159                    eprintln!(
160                        "Warning: Repository '{}' configured at {} does not exist",
161                        repo_config.name,
162                        path.display()
163                    );
164                }
165            }
166
167            (repo_config.name.clone(), path.clone(), available)
168        })
169        .collect()
170}
171
172/// Resolve the effective configuration by merging project, user, CLI, and
173/// environment settings.
174///
175/// # Errors
176///
177/// Returns an error if loading project or user configuration fails.
178pub fn resolve_config(project_root: &Path, cli_json: bool) -> Result<EffectiveConfig> {
179    let project = load_project_config(project_root)?;
180    let user = load_user_config()?;
181
182    let env_format = env::var("FORMAT").ok();
183    let resolved_output = resolve_output(cli_json, user.output.as_deref(), env_format.as_deref())?;
184
185    Ok(EffectiveConfig {
186        project,
187        user,
188        resolved_output,
189    })
190}
191
192fn resolve_output(
193    cli_json: bool,
194    user_output: Option<&str>,
195    env_format: Option<&str>,
196) -> Result<String> {
197    fn normalize_output_mode(raw: &str) -> Option<&'static str> {
198        match raw.trim().to_ascii_lowercase().as_str() {
199            // canonical values + legacy aliases
200            "pretty" | "human" => Some("pretty"),
201            "text" | "table" => Some("text"),
202            "json" => Some("json"),
203            _ => None,
204        }
205    }
206
207    if cli_json {
208        return Ok("json".to_string());
209    }
210
211    if let Some(mode) = env_format.and_then(normalize_output_mode) {
212        return Ok(mode.to_string());
213    }
214
215    if let Some(mode) = user_output.and_then(normalize_output_mode) {
216        return Ok(mode.to_string());
217    }
218
219    if std::io::stdout().is_terminal() {
220        Ok("pretty".to_string())
221    } else {
222        Ok("text".to_string())
223    }
224}
225
226const fn default_true() -> bool {
227    true
228}
229
230fn default_search_model() -> String {
231    "minilm-l6-v2-int8".to_string()
232}
233
234const fn default_duplicate_threshold() -> f64 {
235    0.85
236}
237
238const fn default_related_threshold() -> f64 {
239    0.65
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use std::sync::atomic::{AtomicU64, Ordering};
246
247    fn make_temp_dir(label: &str) -> std::path::PathBuf {
248        static COUNTER: AtomicU64 = AtomicU64::new(0);
249        let id = COUNTER.fetch_add(1, Ordering::SeqCst);
250        let dir = std::env::temp_dir().join(format!("bones-config-test-{label}-{id}"));
251        let _ = std::fs::remove_dir_all(&dir);
252        std::fs::create_dir_all(&dir).expect("temp dir must be created");
253        dir
254    }
255
256    #[test]
257    fn missing_project_config_uses_defaults() {
258        let root = make_temp_dir("project-default");
259        let cfg = load_project_config(&root).expect("load should succeed");
260        assert!(cfg.goals.auto_complete);
261        assert!(cfg.search.semantic);
262        assert_eq!(cfg.search.model, "minilm-l6-v2-int8");
263        assert!(cfg.triage.feedback_learning);
264        assert!(!cfg.done.require_reason);
265        let _ = std::fs::remove_dir_all(&root);
266    }
267
268    #[test]
269    fn cli_json_overrides_env_and_config() {
270        let output =
271            resolve_output(true, Some("pretty"), Some("text")).expect("resolve should succeed");
272        assert_eq!(output, "json");
273    }
274
275    #[test]
276    fn legacy_aliases_are_normalized() {
277        let pretty =
278            resolve_output(false, Some("table"), Some("human")).expect("resolve should succeed");
279        assert_eq!(pretty, "pretty");
280
281        let text =
282            resolve_output(false, Some("human"), Some("table")).expect("resolve should succeed");
283        assert_eq!(text, "text");
284    }
285
286    #[test]
287    fn user_config_parses_repos_list() {
288        let temp_dir = make_temp_dir("user-config-repos");
289        let config_dir = temp_dir.join("config/bones");
290        std::fs::create_dir_all(&config_dir).expect("create config dir");
291
292        let config_content = r#"
293output = "json"
294
295[[repos]]
296name = "backend"
297path = "/home/alice/src/backend"
298
299[[repos]]
300name = "frontend"
301path = "/home/alice/src/frontend"
302"#;
303
304        let config_file = config_dir.join("config.toml");
305        std::fs::write(&config_file, config_content).expect("write config");
306
307        let content = std::fs::read_to_string(&config_file).expect("read back");
308        let cfg: UserConfig = toml::from_str(&content).expect("parse");
309
310        assert_eq!(cfg.output, Some("json".to_string()));
311        assert_eq!(cfg.repos.len(), 2);
312        assert_eq!(cfg.repos[0].name, "backend");
313        assert_eq!(cfg.repos[0].path, PathBuf::from("/home/alice/src/backend"));
314        assert_eq!(cfg.repos[1].name, "frontend");
315        assert_eq!(cfg.repos[1].path, PathBuf::from("/home/alice/src/frontend"));
316
317        let _ = std::fs::remove_dir_all(&temp_dir);
318    }
319
320    #[test]
321    fn discover_repos_validates_bones_directory() {
322        let temp_dir = make_temp_dir("discover-valid");
323
324        // Create first repo with .bones/
325        let repo1_path = temp_dir.join("repo1");
326        std::fs::create_dir_all(repo1_path.join(".bones")).expect("create repo1/.bones");
327
328        // Create second repo with .bones/
329        let repo2_path = temp_dir.join("repo2");
330        std::fs::create_dir_all(repo2_path.join(".bones")).expect("create repo2/.bones");
331
332        let config = UserConfig {
333            output: None,
334            repos: vec![
335                RepoConfig {
336                    name: "repo1".to_string(),
337                    path: repo1_path.clone(),
338                },
339                RepoConfig {
340                    name: "repo2".to_string(),
341                    path: repo2_path.clone(),
342                },
343            ],
344        };
345
346        let discovered = discover_repos(&config);
347
348        assert_eq!(discovered.len(), 2);
349        assert_eq!(discovered[0], ("repo1".to_string(), repo1_path, true));
350        assert_eq!(discovered[1], ("repo2".to_string(), repo2_path, true));
351
352        let _ = std::fs::remove_dir_all(&temp_dir);
353    }
354
355    #[test]
356    fn discover_repos_handles_missing_directories() {
357        let temp_dir = make_temp_dir("discover-missing");
358        let nonexistent = temp_dir.join("nonexistent");
359
360        let config = UserConfig {
361            output: None,
362            repos: vec![RepoConfig {
363                name: "missing".to_string(),
364                path: nonexistent.clone(),
365            }],
366        };
367
368        let discovered = discover_repos(&config);
369
370        assert_eq!(discovered.len(), 1);
371        assert_eq!(discovered[0].0, "missing");
372        assert_eq!(discovered[0].1, nonexistent);
373        assert!(!discovered[0].2); // available = false
374
375        let _ = std::fs::remove_dir_all(&temp_dir);
376    }
377
378    #[test]
379    fn discover_repos_handles_missing_bones_directory() {
380        let temp_dir = make_temp_dir("discover-no-bones");
381        let repo_path = temp_dir.join("repo");
382        std::fs::create_dir(&repo_path).expect("create repo dir");
383        // Note: not creating .bones/ subdirectory
384
385        let config = UserConfig {
386            output: None,
387            repos: vec![RepoConfig {
388                name: "incomplete".to_string(),
389                path: repo_path.clone(),
390            }],
391        };
392
393        let discovered = discover_repos(&config);
394
395        assert_eq!(discovered.len(), 1);
396        assert_eq!(discovered[0].0, "incomplete");
397        assert_eq!(discovered[0].1, repo_path);
398        assert!(!discovered[0].2); // available = false
399
400        let _ = std::fs::remove_dir_all(&temp_dir);
401    }
402
403    #[test]
404    fn discover_repos_empty_config() {
405        let config = UserConfig {
406            output: None,
407            repos: vec![],
408        };
409
410        let discovered = discover_repos(&config);
411        assert_eq!(discovered.len(), 0);
412    }
413}