1use crate::config::{SshConfig, TodosConfig};
4use crate::utils::find_xbp_config_upwards;
5use serde::Deserialize;
6use std::collections::BTreeMap;
7use std::env;
8use std::fs;
9use std::path::Path;
10
11#[derive(Debug, Clone)]
12pub struct ResolvedTodosSettings {
13 pub default_to: SyncTarget,
14 pub auto_yes: bool,
15 pub prompt_sync_after_scan: bool,
16 pub linear_labels: Vec<String>,
17 pub github_labels: Vec<String>,
18 #[allow(dead_code)]
20 pub linear_assignee: Option<String>,
21 #[allow(dead_code)]
23 pub linear_project: Option<String>,
24 pub watch_paths: Vec<String>,
26 pub linear_link_wait_secs: u64,
28 pub linear_link_poll_ms: u64,
30 pub purge_duplicate_linear: bool,
32 pub kinds: Option<Vec<String>>,
33 pub priority_by_kind: BTreeMap<String, i32>,
34 pub annotate_source: bool,
35 pub openrouter_enrich: bool,
37 pub openrouter_enrich_model: Option<String>,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum SyncTarget {
43 #[cfg(feature = "linear")]
44 Linear,
45 Github,
46 #[cfg(feature = "linear")]
47 Both,
48}
49
50impl SyncTarget {
51 pub fn wants_linear(self) -> bool {
52 #[cfg(feature = "linear")]
53 {
54 matches!(self, Self::Linear | Self::Both)
55 }
56 #[cfg(not(feature = "linear"))]
57 {
58 let _ = self;
59 false
60 }
61 }
62
63 pub fn wants_github(self) -> bool {
64 #[cfg(feature = "linear")]
65 {
66 matches!(self, Self::Github | Self::Both)
67 }
68 #[cfg(not(feature = "linear"))]
69 {
70 matches!(self, Self::Github)
71 }
72 }
73}
74
75impl ResolvedTodosSettings {
76 pub fn load(project_root: Option<&Path>) -> Self {
77 let global = SshConfig::load().ok().and_then(|c| c.issues.or(c.todos));
78 let project = project_root
79 .and_then(load_project_issues_config)
80 .or_else(load_project_issues_from_cwd);
81 merge_settings(global, project)
82 }
83}
84
85fn load_project_issues_from_cwd() -> Option<TodosConfig> {
86 let cwd = env::current_dir().ok()?;
87 let found = find_xbp_config_upwards(&cwd)?;
88 load_project_issues_config(&found.project_root)
89}
90
91fn load_project_issues_config(project_root: &Path) -> Option<TodosConfig> {
92 let found = find_xbp_config_upwards(project_root)?;
93 let content = fs::read_to_string(&found.config_path).ok()?;
94 #[derive(Deserialize)]
95 struct Partial {
96 #[serde(default)]
97 issues: Option<TodosConfig>,
98 #[serde(default)]
99 todos: Option<TodosConfig>,
100 }
101 if found.kind == "yaml" {
102 serde_yaml::from_str::<Partial>(&content)
103 .ok()
104 .and_then(|p| p.issues.or(p.todos))
105 } else {
106 serde_json::from_str::<Partial>(&content)
107 .ok()
108 .and_then(|p| p.issues.or(p.todos))
109 }
110}
111
112fn merge_settings(
113 global: Option<TodosConfig>,
114 project: Option<TodosConfig>,
115) -> ResolvedTodosSettings {
116 let g = global.unwrap_or_default();
117 let p = project.unwrap_or_default();
118
119 #[cfg(feature = "linear")]
120 let default_raw = "both";
121 #[cfg(not(feature = "linear"))]
122 let default_raw = "github";
123
124 let default_to = parse_to(
125 p.default_to
126 .as_deref()
127 .or(g.default_to.as_deref())
128 .unwrap_or(default_raw),
129 );
130
131 let auto_yes = p.auto_yes.or(g.auto_yes).unwrap_or(false);
132 let prompt_sync_after_scan = p
133 .prompt_sync_after_scan
134 .or(g.prompt_sync_after_scan)
135 .unwrap_or(true);
136
137 let linear_labels = p
138 .linear_labels
139 .or(g.linear_labels)
140 .unwrap_or_else(|| vec!["xbp-todo".to_string()]);
141 let github_labels = p
142 .github_labels
143 .or(g.github_labels)
144 .unwrap_or_else(|| vec!["xbp-todo".to_string()]);
145
146 let linear_assignee = p
147 .linear_assignee
148 .filter(|s| !s.trim().is_empty())
149 .or_else(|| g.linear_assignee.filter(|s| !s.trim().is_empty()));
150
151 let linear_project = p
152 .linear_project
153 .filter(|s| !s.trim().is_empty())
154 .or_else(|| g.linear_project.filter(|s| !s.trim().is_empty()));
155
156 let watch_paths = p
157 .watch_paths
158 .or(g.watch_paths)
159 .unwrap_or_default()
160 .into_iter()
161 .map(|s| s.replace('\\', "/").trim().trim_matches('/').to_string())
162 .filter(|s| !s.is_empty())
163 .collect();
164
165 let linear_link_wait_secs = p
166 .linear_link_wait_secs
167 .or(g.linear_link_wait_secs)
168 .unwrap_or(20);
169 let linear_link_poll_ms = p
170 .linear_link_poll_ms
171 .or(g.linear_link_poll_ms)
172 .unwrap_or(2000)
173 .max(100);
174 let purge_duplicate_linear = p
175 .purge_duplicate_linear
176 .or(g.purge_duplicate_linear)
177 .unwrap_or(true);
178
179 let kinds = p.kinds.or(g.kinds);
180
181 let mut priority_by_kind = default_priority_map();
182 if let Some(map) = g.priority_by_kind {
183 for (k, v) in map {
184 priority_by_kind.insert(k.to_ascii_uppercase(), v);
185 }
186 }
187 if let Some(map) = p.priority_by_kind {
188 for (k, v) in map {
189 priority_by_kind.insert(k.to_ascii_uppercase(), v);
190 }
191 }
192
193 let annotate_source = p.annotate_source.or(g.annotate_source).unwrap_or(false);
194 let openrouter_enrich = p.openrouter_enrich.or(g.openrouter_enrich).unwrap_or(false);
196 let openrouter_enrich_model = p
197 .openrouter_enrich_model
198 .filter(|s| !s.trim().is_empty())
199 .or_else(|| g.openrouter_enrich_model.filter(|s| !s.trim().is_empty()));
200
201 ResolvedTodosSettings {
202 default_to,
203 auto_yes,
204 prompt_sync_after_scan,
205 linear_labels,
206 github_labels,
207 linear_assignee,
208 linear_project,
209 watch_paths,
210 linear_link_wait_secs,
211 linear_link_poll_ms,
212 purge_duplicate_linear,
213 kinds,
214 priority_by_kind,
215 annotate_source,
216 openrouter_enrich,
217 openrouter_enrich_model,
218 }
219}
220
221fn default_priority_map() -> BTreeMap<String, i32> {
222 let mut map = BTreeMap::new();
223 map.insert("FIXME".into(), 1); map.insert("HACK".into(), 2); map.insert("TODO".into(), 3); map.insert("XXX".into(), 4); map
228}
229
230pub fn parse_to(raw: &str) -> SyncTarget {
231 match raw.trim().to_ascii_lowercase().as_str() {
232 #[cfg(feature = "linear")]
233 "linear" | "lin" => SyncTarget::Linear,
234 "github" | "gh" => SyncTarget::Github,
235 #[cfg(feature = "linear")]
236 _ => SyncTarget::Both,
237 #[cfg(not(feature = "linear"))]
238 _ => SyncTarget::Github,
240 }
241}
242
243impl ResolvedTodosSettings {
244 pub fn priority_for_kind(&self, kind: &str) -> Option<i32> {
245 self.priority_by_kind
246 .get(&kind.to_ascii_uppercase())
247 .copied()
248 .filter(|p| (0..=4).contains(p))
249 }
250
251 pub fn allows_kind(&self, kind: &str) -> bool {
252 let Some(kinds) = &self.kinds else {
253 return true;
254 };
255 if kinds.is_empty() {
256 return true;
257 }
258 let upper = kind.to_ascii_uppercase();
259 kinds.iter().any(|k| k.trim().eq_ignore_ascii_case(&upper))
260 }
261
262 pub fn allows_path(&self, path: &str) -> bool {
264 if self.watch_paths.is_empty() {
265 return true;
266 }
267 path_matches_watch_prefixes(path, &self.watch_paths)
268 }
269}
270
271pub fn path_matches_watch_prefixes(path: &str, prefixes: &[String]) -> bool {
273 if prefixes.is_empty() {
274 return true;
275 }
276 let path = path
277 .replace('\\', "/")
278 .trim()
279 .trim_start_matches('/')
280 .to_string();
281 prefixes.iter().any(|prefix| {
282 let p = prefix.replace('\\', "/");
283 let p = p.trim().trim_matches('/');
284 if p.is_empty() {
285 return true;
286 }
287 path == p || path.starts_with(&format!("{p}/"))
288 })
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294
295 #[test]
296 fn merge_prefers_project_over_global() {
297 let global = TodosConfig {
298 default_to: Some("linear".into()),
299 auto_yes: Some(false),
300 github_labels: Some(vec!["global".into()]),
301 ..Default::default()
302 };
303 let project = TodosConfig {
304 default_to: Some("github".into()),
305 auto_yes: Some(true),
306 ..Default::default()
307 };
308 let resolved = merge_settings(Some(global), Some(project));
309 assert_eq!(resolved.default_to, SyncTarget::Github);
310 assert!(resolved.auto_yes);
311 assert_eq!(resolved.github_labels, vec!["global".to_string()]);
312 assert_eq!(resolved.priority_for_kind("FIXME"), Some(1));
313 assert!(
314 !resolved.openrouter_enrich,
315 "OpenRouter enrichment must stay opt-in by default"
316 );
317 assert_eq!(resolved.linear_link_wait_secs, 20);
318 assert!(resolved.purge_duplicate_linear);
319 }
320
321 #[test]
322 fn project_issues_config_takes_precedence_over_todos_config() {
323 let stamp = std::time::SystemTime::now()
324 .duration_since(std::time::UNIX_EPOCH)
325 .unwrap()
326 .as_nanos();
327 let dir = std::env::temp_dir().join(format!("xbp-issues-settings-{stamp}"));
328 let xbp = dir.join(".xbp");
329 fs::create_dir_all(&xbp).unwrap();
330 fs::write(
331 xbp.join("xbp.yaml"),
332 r#"
333todos:
334 default_to: github
335 auto_yes: false
336issues:
337 default_to: linear
338 auto_yes: true
339"#,
340 )
341 .unwrap();
342
343 let loaded = load_project_issues_config(&dir).expect("project issues config");
344 assert_eq!(loaded.default_to.as_deref(), Some("linear"));
345 assert_eq!(loaded.auto_yes, Some(true));
346
347 let _ = fs::remove_dir_all(dir);
348 }
349
350 #[test]
351 fn openrouter_enrich_defaults_off_and_project_can_enable() {
352 let off = merge_settings(None, None);
353 assert!(!off.openrouter_enrich);
354
355 let on = merge_settings(
356 None,
357 Some(TodosConfig {
358 openrouter_enrich: Some(true),
359 openrouter_enrich_model: Some("openai/gpt-4o-mini".into()),
360 ..Default::default()
361 }),
362 );
363 assert!(on.openrouter_enrich);
364 assert_eq!(
365 on.openrouter_enrich_model.as_deref(),
366 Some("openai/gpt-4o-mini")
367 );
368 }
369
370 #[test]
371 fn watch_paths_filter() {
372 let settings = ResolvedTodosSettings {
373 default_to: SyncTarget::Github,
374 auto_yes: false,
375 prompt_sync_after_scan: true,
376 linear_labels: vec![],
377 github_labels: vec![],
378 linear_assignee: None,
379 linear_project: None,
380 watch_paths: vec!["crates/cli".into(), "crates/core".into()],
381 linear_link_wait_secs: 20,
382 linear_link_poll_ms: 2000,
383 purge_duplicate_linear: true,
384 kinds: None,
385 priority_by_kind: default_priority_map(),
386 annotate_source: false,
387 openrouter_enrich: false,
388 openrouter_enrich_model: None,
389 };
390 assert!(settings.allows_path("crates/cli/src/lib.rs"));
391 assert!(settings.allows_path("crates/core/foo.rs"));
392 assert!(!settings.allows_path("apps/web/page.tsx"));
393 assert!(settings.allows_path("crates/cli"));
394 }
395
396 #[test]
397 fn kinds_filter() {
398 let settings = ResolvedTodosSettings {
399 default_to: SyncTarget::Both,
400 auto_yes: false,
401 prompt_sync_after_scan: true,
402 linear_labels: vec![],
403 github_labels: vec![],
404 linear_assignee: None,
405 linear_project: None,
406 watch_paths: vec![],
407 linear_link_wait_secs: 20,
408 linear_link_poll_ms: 2000,
409 purge_duplicate_linear: true,
410 kinds: Some(vec!["FIXME".into()]),
411 priority_by_kind: default_priority_map(),
412 annotate_source: false,
413 openrouter_enrich: false,
414 openrouter_enrich_model: None,
415 };
416 assert!(settings.allows_kind("FIXME"));
417 assert!(!settings.allows_kind("TODO"));
418 }
419}