xbp 10.38.0

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>,
    pub kinds: Option<Vec<String>>,
    pub priority_by_kind: BTreeMap<String, i32>,
    pub annotate_source: bool,
}

#[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 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);

    ResolvedTodosSettings {
        default_to,
        auto_yes,
        prompt_sync_after_scan,
        linear_labels,
        github_labels,
        linear_assignee,
        kinds,
        priority_by_kind,
        annotate_source,
    }
}

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))
    }
}

#[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));
    }

    #[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,
            kinds: Some(vec!["FIXME".into()]),
            priority_by_kind: default_priority_map(),
            annotate_source: false,
        };
        assert!(settings.allows_kind("FIXME"));
        assert!(!settings.allows_kind("TODO"));
    }
}