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
100pub 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
121pub 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
155pub 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
209pub 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 "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 let repo1_path = temp_dir.join("repo1");
406 std::fs::create_dir_all(repo1_path.join(".bones")).expect("create repo1/.bones");
407
408 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); 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 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); 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}