xbp 10.38.2

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! Resolved automation settings for TODO scan/sync (project overrides global).

use crate::config::{TodosConfig, SshConfig};
use crate::utils::find_xbp_config_upwards;
use serde::Deserialize;
use std::collections::BTreeMap;
use std::env;
use std::fs;
use std::path::Path;

#[derive(Debug, Clone)]
pub struct ResolvedTodosSettings {
    pub default_to: SyncTarget,
    pub auto_yes: bool,
    pub prompt_sync_after_scan: bool,
    pub linear_labels: Vec<String>,
    pub github_labels: Vec<String>,
    /// Only consumed when building with `--features linear`.
    #[allow(dead_code)]
    pub linear_assignee: Option<String>,
    /// Linear project name/id/slug for created issues.
    #[allow(dead_code)]
    pub linear_project: Option<String>,
    /// Repo-relative path prefixes; empty = scan entire tree.
    pub watch_paths: Vec<String>,
    /// After GitHub creates, wait for Linear auto-link (seconds).
    pub linear_link_wait_secs: u64,
    /// Poll interval while waiting (ms).
    pub linear_link_poll_ms: u64,
    /// Archive xbp-created Linear dups when auto-link already exists.
    pub purge_duplicate_linear: bool,
    pub kinds: Option<Vec<String>>,
    pub priority_by_kind: BTreeMap<String, i32>,
    pub annotate_source: bool,
    /// Opt-in OpenRouter enrichment (default **false**).
    pub openrouter_enrich: bool,
    /// Model override for enrichment; empty → global OpenRouter commit model.
    pub openrouter_enrich_model: Option<String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SyncTarget {
    #[cfg(feature = "linear")]
    Linear,
    Github,
    #[cfg(feature = "linear")]
    Both,
}

impl SyncTarget {
    pub fn wants_linear(self) -> bool {
        #[cfg(feature = "linear")]
        {
            matches!(self, Self::Linear | Self::Both)
        }
        #[cfg(not(feature = "linear"))]
        {
            let _ = self;
            false
        }
    }

    pub fn wants_github(self) -> bool {
        #[cfg(feature = "linear")]
        {
            matches!(self, Self::Github | Self::Both)
        }
        #[cfg(not(feature = "linear"))]
        {
            matches!(self, Self::Github)
        }
    }
}

impl ResolvedTodosSettings {
    pub fn load(project_root: Option<&Path>) -> Self {
        let global = SshConfig::load().ok().and_then(|c| c.todos);
        let project = project_root
            .and_then(load_project_todos_config)
            .or_else(load_project_todos_from_cwd);
        merge_settings(global, project)
    }
}

fn load_project_todos_from_cwd() -> Option<TodosConfig> {
    let cwd = env::current_dir().ok()?;
    let found = find_xbp_config_upwards(&cwd)?;
    load_project_todos_config(&found.project_root)
}

fn load_project_todos_config(project_root: &Path) -> Option<TodosConfig> {
    let found = find_xbp_config_upwards(project_root)?;
    let content = fs::read_to_string(&found.config_path).ok()?;
    #[derive(Deserialize)]
    struct Partial {
        #[serde(default)]
        todos: Option<TodosConfig>,
    }
    if found.kind == "yaml" {
        serde_yaml::from_str::<Partial>(&content)
            .ok()
            .and_then(|p| p.todos)
    } else {
        serde_json::from_str::<Partial>(&content)
            .ok()
            .and_then(|p| p.todos)
    }
}

fn merge_settings(global: Option<TodosConfig>, project: Option<TodosConfig>) -> ResolvedTodosSettings {
    let g = global.unwrap_or_default();
    let p = project.unwrap_or_default();

    #[cfg(feature = "linear")]
    let default_raw = "both";
    #[cfg(not(feature = "linear"))]
    let default_raw = "github";

    let default_to = parse_to(
        p.default_to
            .as_deref()
            .or(g.default_to.as_deref())
            .unwrap_or(default_raw),
    );

    let auto_yes = p.auto_yes.or(g.auto_yes).unwrap_or(false);
    let prompt_sync_after_scan = p
        .prompt_sync_after_scan
        .or(g.prompt_sync_after_scan)
        .unwrap_or(true);

    let linear_labels = p
        .linear_labels
        .or(g.linear_labels)
        .unwrap_or_else(|| vec!["xbp-todo".to_string()]);
    let github_labels = p
        .github_labels
        .or(g.github_labels)
        .unwrap_or_else(|| vec!["xbp-todo".to_string()]);

    let linear_assignee = p
        .linear_assignee
        .filter(|s| !s.trim().is_empty())
        .or_else(|| g.linear_assignee.filter(|s| !s.trim().is_empty()));

    let linear_project = p
        .linear_project
        .filter(|s| !s.trim().is_empty())
        .or_else(|| g.linear_project.filter(|s| !s.trim().is_empty()));

    let watch_paths = p
        .watch_paths
        .or(g.watch_paths)
        .unwrap_or_default()
        .into_iter()
        .map(|s| s.replace('\\', "/").trim().trim_matches('/').to_string())
        .filter(|s| !s.is_empty())
        .collect();

    let linear_link_wait_secs = p
        .linear_link_wait_secs
        .or(g.linear_link_wait_secs)
        .unwrap_or(20);
    let linear_link_poll_ms = p
        .linear_link_poll_ms
        .or(g.linear_link_poll_ms)
        .unwrap_or(2000)
        .max(100);
    let purge_duplicate_linear = p
        .purge_duplicate_linear
        .or(g.purge_duplicate_linear)
        .unwrap_or(true);

    let kinds = p.kinds.or(g.kinds);

    let mut priority_by_kind = default_priority_map();
    if let Some(map) = g.priority_by_kind {
        for (k, v) in map {
            priority_by_kind.insert(k.to_ascii_uppercase(), v);
        }
    }
    if let Some(map) = p.priority_by_kind {
        for (k, v) in map {
            priority_by_kind.insert(k.to_ascii_uppercase(), v);
        }
    }

    let annotate_source = p.annotate_source.or(g.annotate_source).unwrap_or(false);
    // Deliberately default false — OpenRouter enrichment is opt-in only.
    let openrouter_enrich = p
        .openrouter_enrich
        .or(g.openrouter_enrich)
        .unwrap_or(false);
    let openrouter_enrich_model = p
        .openrouter_enrich_model
        .filter(|s| !s.trim().is_empty())
        .or_else(|| g.openrouter_enrich_model.filter(|s| !s.trim().is_empty()));

    ResolvedTodosSettings {
        default_to,
        auto_yes,
        prompt_sync_after_scan,
        linear_labels,
        github_labels,
        linear_assignee,
        linear_project,
        watch_paths,
        linear_link_wait_secs,
        linear_link_poll_ms,
        purge_duplicate_linear,
        kinds,
        priority_by_kind,
        annotate_source,
        openrouter_enrich,
        openrouter_enrich_model,
    }
}

fn default_priority_map() -> BTreeMap<String, i32> {
    let mut map = BTreeMap::new();
    map.insert("FIXME".into(), 1); // Urgent
    map.insert("HACK".into(), 2); // High
    map.insert("TODO".into(), 3); // Medium
    map.insert("XXX".into(), 4); // Low
    map
}

pub fn parse_to(raw: &str) -> SyncTarget {
    match raw.trim().to_ascii_lowercase().as_str() {
        #[cfg(feature = "linear")]
        "linear" | "lin" => SyncTarget::Linear,
        "github" | "gh" => SyncTarget::Github,
        #[cfg(feature = "linear")]
        _ => SyncTarget::Both,
        #[cfg(not(feature = "linear"))]
        // Without the linear feature, treat "both"/"linear" as GitHub-only.
        _ => SyncTarget::Github,
    }
}

impl ResolvedTodosSettings {
    pub fn priority_for_kind(&self, kind: &str) -> Option<i32> {
        self.priority_by_kind
            .get(&kind.to_ascii_uppercase())
            .copied()
            .filter(|p| (0..=4).contains(p))
    }

    pub fn allows_kind(&self, kind: &str) -> bool {
        let Some(kinds) = &self.kinds else {
            return true;
        };
        if kinds.is_empty() {
            return true;
        }
        let upper = kind.to_ascii_uppercase();
        kinds
            .iter()
            .any(|k| k.trim().eq_ignore_ascii_case(&upper))
    }

    /// When `watch_paths` is non-empty, only paths under those prefixes pass.
    pub fn allows_path(&self, path: &str) -> bool {
        if self.watch_paths.is_empty() {
            return true;
        }
        path_matches_watch_prefixes(path, &self.watch_paths)
    }
}

/// Normalize and test whether `path` is under any watch prefix.
pub fn path_matches_watch_prefixes(path: &str, prefixes: &[String]) -> bool {
    if prefixes.is_empty() {
        return true;
    }
    let path = path.replace('\\', "/").trim().trim_start_matches('/').to_string();
    prefixes.iter().any(|prefix| {
        let p = prefix.replace('\\', "/");
        let p = p.trim().trim_matches('/');
        if p.is_empty() {
            return true;
        }
        path == p || path.starts_with(&format!("{p}/"))
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn merge_prefers_project_over_global() {
        let global = TodosConfig {
            default_to: Some("linear".into()),
            auto_yes: Some(false),
            github_labels: Some(vec!["global".into()]),
            ..Default::default()
        };
        let project = TodosConfig {
            default_to: Some("github".into()),
            auto_yes: Some(true),
            ..Default::default()
        };
        let resolved = merge_settings(Some(global), Some(project));
        assert_eq!(resolved.default_to, SyncTarget::Github);
        assert!(resolved.auto_yes);
        assert_eq!(resolved.github_labels, vec!["global".to_string()]);
        assert_eq!(resolved.priority_for_kind("FIXME"), Some(1));
        assert!(
            !resolved.openrouter_enrich,
            "OpenRouter enrichment must stay opt-in by default"
        );
        assert_eq!(resolved.linear_link_wait_secs, 20);
        assert!(resolved.purge_duplicate_linear);
    }

    #[test]
    fn openrouter_enrich_defaults_off_and_project_can_enable() {
        let off = merge_settings(None, None);
        assert!(!off.openrouter_enrich);

        let on = merge_settings(
            None,
            Some(TodosConfig {
                openrouter_enrich: Some(true),
                openrouter_enrich_model: Some("openai/gpt-4o-mini".into()),
                ..Default::default()
            }),
        );
        assert!(on.openrouter_enrich);
        assert_eq!(
            on.openrouter_enrich_model.as_deref(),
            Some("openai/gpt-4o-mini")
        );
    }

    #[test]
    fn watch_paths_filter() {
        let settings = ResolvedTodosSettings {
            default_to: SyncTarget::Github,
            auto_yes: false,
            prompt_sync_after_scan: true,
            linear_labels: vec![],
            github_labels: vec![],
            linear_assignee: None,
            linear_project: None,
            watch_paths: vec!["crates/cli".into(), "crates/core".into()],
            linear_link_wait_secs: 20,
            linear_link_poll_ms: 2000,
            purge_duplicate_linear: true,
            kinds: None,
            priority_by_kind: default_priority_map(),
            annotate_source: false,
            openrouter_enrich: false,
            openrouter_enrich_model: None,
        };
        assert!(settings.allows_path("crates/cli/src/lib.rs"));
        assert!(settings.allows_path("crates/core/foo.rs"));
        assert!(!settings.allows_path("apps/web/page.tsx"));
        assert!(settings.allows_path("crates/cli"));
    }

    #[test]
    fn kinds_filter() {
        let settings = ResolvedTodosSettings {
            default_to: SyncTarget::Both,
            auto_yes: false,
            prompt_sync_after_scan: true,
            linear_labels: vec![],
            github_labels: vec![],
            linear_assignee: None,
            linear_project: None,
            watch_paths: vec![],
            linear_link_wait_secs: 20,
            linear_link_poll_ms: 2000,
            purge_duplicate_linear: true,
            kinds: Some(vec!["FIXME".into()]),
            priority_by_kind: default_priority_map(),
            annotate_source: false,
            openrouter_enrich: false,
            openrouter_enrich_model: None,
        };
        assert!(settings.allows_kind("FIXME"));
        assert!(!settings.allows_kind("TODO"));
    }
}