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