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 toml::from_str::<ProjectConfig>(&content)
115 .with_context(|| format!("Failed to parse {}", path.display()))
116}
117
118pub 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
172pub 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 "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 let repo1_path = temp_dir.join("repo1");
326 std::fs::create_dir_all(repo1_path.join(".bones")).expect("create repo1/.bones");
327
328 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); 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 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); 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}