use crate::config::{SshConfig, TodosConfig};
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>,
#[allow(dead_code)]
pub linear_assignee: Option<String>,
#[allow(dead_code)]
pub linear_project: Option<String>,
pub watch_paths: Vec<String>,
pub linear_link_wait_secs: u64,
pub linear_link_poll_ms: u64,
pub purge_duplicate_linear: bool,
pub kinds: Option<Vec<String>>,
pub priority_by_kind: BTreeMap<String, i32>,
pub annotate_source: bool,
pub openrouter_enrich: bool,
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.issues.or(c.todos));
let project = project_root
.and_then(load_project_issues_config)
.or_else(load_project_issues_from_cwd);
merge_settings(global, project)
}
}
fn load_project_issues_from_cwd() -> Option<TodosConfig> {
let cwd = env::current_dir().ok()?;
let found = find_xbp_config_upwards(&cwd)?;
load_project_issues_config(&found.project_root)
}
fn load_project_issues_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)]
issues: Option<TodosConfig>,
#[serde(default)]
todos: Option<TodosConfig>,
}
if found.kind == "yaml" {
serde_yaml::from_str::<Partial>(&content)
.ok()
.and_then(|p| p.issues.or(p.todos))
} else {
serde_json::from_str::<Partial>(&content)
.ok()
.and_then(|p| p.issues.or(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);
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); map.insert("HACK".into(), 2); map.insert("TODO".into(), 3); map.insert("XXX".into(), 4); 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"))]
_ => 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))
}
pub fn allows_path(&self, path: &str) -> bool {
if self.watch_paths.is_empty() {
return true;
}
path_matches_watch_prefixes(path, &self.watch_paths)
}
}
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 project_issues_config_takes_precedence_over_todos_config() {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("xbp-issues-settings-{stamp}"));
let xbp = dir.join(".xbp");
fs::create_dir_all(&xbp).unwrap();
fs::write(
xbp.join("xbp.yaml"),
r#"
todos:
default_to: github
auto_yes: false
issues:
default_to: linear
auto_yes: true
"#,
)
.unwrap();
let loaded = load_project_issues_config(&dir).expect("project issues config");
assert_eq!(loaded.default_to.as_deref(), Some("linear"));
assert_eq!(loaded.auto_yes, Some(true));
let _ = fs::remove_dir_all(dir);
}
#[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"));
}
}