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    let config = toml::from_str::<ProjectConfig>(&content)
115        .with_context(|| format!("Failed to parse {}", path.display()))?;
116    validate_project_config(&config)
117        .with_context(|| format!("Invalid project config {}", path.display()))?;
118    Ok(config)
119}
120
121/// Validate semantic invariants that serde cannot express.
122///
123/// # Errors
124///
125/// Returns an error if numeric thresholds are non-finite, outside the
126/// normalized score range, or ordered inconsistently.
127pub fn validate_project_config(config: &ProjectConfig) -> Result<()> {
128    validate_threshold(
129        "search.duplicate_threshold",
130        config.search.duplicate_threshold,
131    )?;
132    validate_threshold("search.related_threshold", config.search.related_threshold)?;
133
134    if config.search.related_threshold > config.search.duplicate_threshold {
135        anyhow::bail!(
136            "search.related_threshold ({}) must be less than or equal to search.duplicate_threshold ({})",
137            config.search.related_threshold,
138            config.search.duplicate_threshold
139        );
140    }
141
142    Ok(())
143}
144
145fn validate_threshold(name: &str, value: f64) -> Result<()> {
146    if !value.is_finite() {
147        anyhow::bail!("{name} must be finite");
148    }
149    if !(0.0..=1.0).contains(&value) {
150        anyhow::bail!("{name} must be between 0.0 and 1.0");
151    }
152    Ok(())
153}
154
155/// Load user-level configuration from `<config-dir>/bones/config.toml`.
156///
157/// # Errors
158///
159/// Returns an error if the file exists but cannot be read or parsed.
160pub fn load_user_config() -> Result<UserConfig> {
161    let Some(config_dir) = dirs::config_dir() else {
162        return Ok(UserConfig::default());
163    };
164
165    let path = config_dir.join("bones/config.toml");
166    if !path.exists() {
167        return Ok(UserConfig::default());
168    }
169
170    let content = std::fs::read_to_string(&path)
171        .with_context(|| format!("Failed to read {}", path.display()))?;
172
173    toml::from_str::<UserConfig>(&content)
174        .with_context(|| format!("Failed to parse {}", path.display()))
175}
176
177#[must_use]
178pub fn discover_repos(config: &UserConfig) -> Vec<(String, PathBuf, bool)> {
179    config
180        .repos
181        .iter()
182        .map(|repo_config| {
183            let path = &repo_config.path;
184            let bones_dir = path.join(".bones");
185
186            let available = path.exists() && bones_dir.exists();
187
188            if !available {
189                if path.exists() {
190                    eprintln!(
191                        "Warning: Repository '{}' at {} does not contain .bones/ directory",
192                        repo_config.name,
193                        path.display()
194                    );
195                } else {
196                    eprintln!(
197                        "Warning: Repository '{}' configured at {} does not exist",
198                        repo_config.name,
199                        path.display()
200                    );
201                }
202            }
203
204            (repo_config.name.clone(), path.clone(), available)
205        })
206        .collect()
207}
208
209/// Resolve the effective configuration by merging project, user, CLI, and
210/// environment settings.
211///
212/// # Errors
213///
214/// Returns an error if loading project or user configuration fails.
215pub fn resolve_config(project_root: &Path, cli_json: bool) -> Result<EffectiveConfig> {
216    let project = load_project_config(project_root)?;
217    let user = load_user_config()?;
218
219    let env_format = env::var("FORMAT").ok();
220    let resolved_output = resolve_output(cli_json, user.output.as_deref(), env_format.as_deref())?;
221
222    Ok(EffectiveConfig {
223        project,
224        user,
225        resolved_output,
226    })
227}
228
229fn resolve_output(
230    cli_json: bool,
231    user_output: Option<&str>,
232    env_format: Option<&str>,
233) -> Result<String> {
234    fn normalize_output_mode(raw: &str) -> Option<&'static str> {
235        match raw.trim().to_ascii_lowercase().as_str() {
236            // canonical values + legacy aliases
237            "pretty" | "human" => Some("pretty"),
238            "text" | "table" => Some("text"),
239            "json" => Some("json"),
240            _ => None,
241        }
242    }
243
244    if cli_json {
245        return Ok("json".to_string());
246    }
247
248    if let Some(mode) = env_format.and_then(normalize_output_mode) {
249        return Ok(mode.to_string());
250    }
251
252    if let Some(mode) = user_output.and_then(normalize_output_mode) {
253        return Ok(mode.to_string());
254    }
255
256    if std::io::stdout().is_terminal() {
257        Ok("pretty".to_string())
258    } else {
259        Ok("text".to_string())
260    }
261}
262
263const fn default_true() -> bool {
264    true
265}
266
267fn default_search_model() -> String {
268    "minilm-l6-v2-int8".to_string()
269}
270
271const fn default_duplicate_threshold() -> f64 {
272    0.85
273}
274
275const fn default_related_threshold() -> f64 {
276    0.65
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use std::sync::atomic::{AtomicU64, Ordering};
283
284    fn make_temp_dir(label: &str) -> std::path::PathBuf {
285        static COUNTER: AtomicU64 = AtomicU64::new(0);
286        let id = COUNTER.fetch_add(1, Ordering::SeqCst);
287        let dir = std::env::temp_dir().join(format!("bones-config-test-{label}-{id}"));
288        let _ = std::fs::remove_dir_all(&dir);
289        std::fs::create_dir_all(&dir).expect("temp dir must be created");
290        dir
291    }
292
293    #[test]
294    fn missing_project_config_uses_defaults() {
295        let root = make_temp_dir("project-default");
296        let cfg = load_project_config(&root).expect("load should succeed");
297        assert!(cfg.goals.auto_complete);
298        assert!(cfg.search.semantic);
299        assert_eq!(cfg.search.model, "minilm-l6-v2-int8");
300        assert!(cfg.triage.feedback_learning);
301        assert!(!cfg.done.require_reason);
302        let _ = std::fs::remove_dir_all(&root);
303    }
304
305    #[test]
306    fn project_config_rejects_out_of_range_thresholds() {
307        let root = make_temp_dir("project-bad-threshold");
308        std::fs::create_dir_all(root.join(".bones")).expect("create .bones");
309        std::fs::write(
310            root.join(".bones/config.toml"),
311            r#"
312[search]
313duplicate_threshold = 1.2
314"#,
315        )
316        .expect("write config");
317
318        let err = load_project_config(&root).expect_err("invalid threshold should fail");
319        assert!(err.to_string().contains("Invalid project config"));
320
321        let _ = std::fs::remove_dir_all(&root);
322    }
323
324    #[test]
325    fn project_config_rejects_inverted_thresholds() {
326        let root = make_temp_dir("project-inverted-thresholds");
327        std::fs::create_dir_all(root.join(".bones")).expect("create .bones");
328        std::fs::write(
329            root.join(".bones/config.toml"),
330            r#"
331[search]
332duplicate_threshold = 0.6
333related_threshold = 0.7
334"#,
335        )
336        .expect("write config");
337
338        let err = load_project_config(&root).expect_err("inverted thresholds should fail");
339        assert!(err.to_string().contains("Invalid project config"));
340        assert!(
341            err.chain()
342                .any(|cause| cause.to_string().contains("search.related_threshold"))
343        );
344
345        let _ = std::fs::remove_dir_all(&root);
346    }
347
348    #[test]
349    fn cli_json_overrides_env_and_config() {
350        let output =
351            resolve_output(true, Some("pretty"), Some("text")).expect("resolve should succeed");
352        assert_eq!(output, "json");
353    }
354
355    #[test]
356    fn legacy_aliases_are_normalized() {
357        let pretty =
358            resolve_output(false, Some("table"), Some("human")).expect("resolve should succeed");
359        assert_eq!(pretty, "pretty");
360
361        let text =
362            resolve_output(false, Some("human"), Some("table")).expect("resolve should succeed");
363        assert_eq!(text, "text");
364    }
365
366    #[test]
367    fn user_config_parses_repos_list() {
368        let temp_dir = make_temp_dir("user-config-repos");
369        let config_dir = temp_dir.join("config/bones");
370        std::fs::create_dir_all(&config_dir).expect("create config dir");
371
372        let config_content = r#"
373output = "json"
374
375[[repos]]
376name = "backend"
377path = "/home/alice/src/backend"
378
379[[repos]]
380name = "frontend"
381path = "/home/alice/src/frontend"
382"#;
383
384        let config_file = config_dir.join("config.toml");
385        std::fs::write(&config_file, config_content).expect("write config");
386
387        let content = std::fs::read_to_string(&config_file).expect("read back");
388        let cfg: UserConfig = toml::from_str(&content).expect("parse");
389
390        assert_eq!(cfg.output, Some("json".to_string()));
391        assert_eq!(cfg.repos.len(), 2);
392        assert_eq!(cfg.repos[0].name, "backend");
393        assert_eq!(cfg.repos[0].path, PathBuf::from("/home/alice/src/backend"));
394        assert_eq!(cfg.repos[1].name, "frontend");
395        assert_eq!(cfg.repos[1].path, PathBuf::from("/home/alice/src/frontend"));
396
397        let _ = std::fs::remove_dir_all(&temp_dir);
398    }
399
400    #[test]
401    fn discover_repos_validates_bones_directory() {
402        let temp_dir = make_temp_dir("discover-valid");
403
404        // Create first repo with .bones/
405        let repo1_path = temp_dir.join("repo1");
406        std::fs::create_dir_all(repo1_path.join(".bones")).expect("create repo1/.bones");
407
408        // Create second repo with .bones/
409        let repo2_path = temp_dir.join("repo2");
410        std::fs::create_dir_all(repo2_path.join(".bones")).expect("create repo2/.bones");
411
412        let config = UserConfig {
413            output: None,
414            repos: vec![
415                RepoConfig {
416                    name: "repo1".to_string(),
417                    path: repo1_path.clone(),
418                },
419                RepoConfig {
420                    name: "repo2".to_string(),
421                    path: repo2_path.clone(),
422                },
423            ],
424        };
425
426        let discovered = discover_repos(&config);
427
428        assert_eq!(discovered.len(), 2);
429        assert_eq!(discovered[0], ("repo1".to_string(), repo1_path, true));
430        assert_eq!(discovered[1], ("repo2".to_string(), repo2_path, true));
431
432        let _ = std::fs::remove_dir_all(&temp_dir);
433    }
434
435    #[test]
436    fn discover_repos_handles_missing_directories() {
437        let temp_dir = make_temp_dir("discover-missing");
438        let nonexistent = temp_dir.join("nonexistent");
439
440        let config = UserConfig {
441            output: None,
442            repos: vec![RepoConfig {
443                name: "missing".to_string(),
444                path: nonexistent.clone(),
445            }],
446        };
447
448        let discovered = discover_repos(&config);
449
450        assert_eq!(discovered.len(), 1);
451        assert_eq!(discovered[0].0, "missing");
452        assert_eq!(discovered[0].1, nonexistent);
453        assert!(!discovered[0].2); // available = false
454
455        let _ = std::fs::remove_dir_all(&temp_dir);
456    }
457
458    #[test]
459    fn discover_repos_handles_missing_bones_directory() {
460        let temp_dir = make_temp_dir("discover-no-bones");
461        let repo_path = temp_dir.join("repo");
462        std::fs::create_dir(&repo_path).expect("create repo dir");
463        // Note: not creating .bones/ subdirectory
464
465        let config = UserConfig {
466            output: None,
467            repos: vec![RepoConfig {
468                name: "incomplete".to_string(),
469                path: repo_path.clone(),
470            }],
471        };
472
473        let discovered = discover_repos(&config);
474
475        assert_eq!(discovered.len(), 1);
476        assert_eq!(discovered[0].0, "incomplete");
477        assert_eq!(discovered[0].1, repo_path);
478        assert!(!discovered[0].2); // available = false
479
480        let _ = std::fs::remove_dir_all(&temp_dir);
481    }
482
483    #[test]
484    fn discover_repos_empty_config() {
485        let config = UserConfig {
486            output: None,
487            repos: vec![],
488        };
489
490        let discovered = discover_repos(&config);
491        assert_eq!(discovered.len(), 0);
492    }
493}