Skip to main content

xbp_cli/commands/todos/
settings.rs

1//! Resolved automation settings for TODO scan/sync (project overrides global).
2
3use 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    /// Only consumed when building with `--features linear`.
19    #[allow(dead_code)]
20    pub linear_assignee: Option<String>,
21    /// Linear project name/id/slug for created issues.
22    #[allow(dead_code)]
23    pub linear_project: Option<String>,
24    /// Repo-relative path prefixes; empty = scan entire tree.
25    pub watch_paths: Vec<String>,
26    /// After GitHub creates, wait for Linear auto-link (seconds).
27    pub linear_link_wait_secs: u64,
28    /// Poll interval while waiting (ms).
29    pub linear_link_poll_ms: u64,
30    /// Archive xbp-created Linear dups when auto-link already exists.
31    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    /// Opt-in OpenRouter enrichment (default **false**).
36    pub openrouter_enrich: bool,
37    /// Model override for enrichment; empty → global OpenRouter commit model.
38    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    // Deliberately default false — OpenRouter enrichment is opt-in only.
195    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); // Urgent
224    map.insert("HACK".into(), 2); // High
225    map.insert("TODO".into(), 3); // Medium
226    map.insert("XXX".into(), 4); // Low
227    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        // Without the linear feature, treat "both"/"linear" as GitHub-only.
239        _ => 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    /// When `watch_paths` is non-empty, only paths under those prefixes pass.
263    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
271/// Normalize and test whether `path` is under any watch prefix.
272pub 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}