mod annotate;
mod ledger;
mod scan;
mod settings;
mod setup;
mod sync;
use crate::cli::commands::{TodosCmd, TodosSubCommand, TodosSyncTarget};
use crate::commands::terminal_table::{render_table, TableStyle};
use crate::utils::find_xbp_config_upwards;
use colored::Colorize;
use crate::commands::text_util::truncate_chars;
use settings::ResolvedTodosSettings;
use std::env;
use std::path::{Path, PathBuf};
pub use ledger::{load_ledger, TodoLedger};
pub use scan::{scan_todos, TodoHit};
pub use settings::SyncTarget;
pub use sync::{sync_todos, SyncOptions};
pub async fn run_todos(cmd: TodosCmd) -> Result<(), String> {
match cmd.command {
None | Some(TodosSubCommand::Scan(_)) => {
let path = match &cmd.command {
Some(TodosSubCommand::Scan(args)) => args.path.clone(),
_ => None,
};
let json = matches!(
&cmd.command,
Some(TodosSubCommand::Scan(args)) if args.json
);
let no_prompt = matches!(
&cmd.command,
Some(TodosSubCommand::Scan(args)) if args.no_prompt
);
run_scan(path.as_deref(), json, no_prompt).await
}
Some(TodosSubCommand::Sync(args)) => {
let target = args.to.map(|t| match t {
#[cfg(feature = "linear")]
TodosSyncTarget::Linear => SyncTarget::Linear,
TodosSyncTarget::Github => SyncTarget::Github,
#[cfg(feature = "linear")]
TodosSyncTarget::Both => SyncTarget::Both,
});
sync_todos(SyncOptions {
path: args.path.clone(),
target,
dry_run: args.dry_run,
yes: args.yes,
#[cfg(feature = "linear")]
team: args.team.clone(),
#[cfg(not(feature = "linear"))]
team: None,
owner: args.owner.clone(),
repo: args.repo.clone(),
annotate: if args.annotate {
Some(true)
} else if args.no_annotate {
Some(false)
} else {
None
},
})
.await
}
Some(TodosSubCommand::Status(args)) => run_status(args.path.as_deref()).await,
Some(TodosSubCommand::Prune(args)) => {
let root = resolve_scan_root(args.path.as_deref())?;
sync::prune_ledger(&root, args.dry_run)?;
Ok(())
}
Some(TodosSubCommand::Setup) => setup::run_setup().await,
}
}
pub async fn run_todos_scan_default() -> Result<(), String> {
run_scan(None, false, false).await
}
async fn run_scan(path: Option<&Path>, json: bool, no_prompt: bool) -> Result<(), String> {
let root = resolve_scan_root(path)?;
let settings = ResolvedTodosSettings::load(Some(&root));
let hits = scan_todos(&root)?;
let hits: Vec<TodoHit> = hits
.into_iter()
.filter(|h| settings.allows_kind(&h.kind))
.collect();
if json {
println!(
"{}",
serde_json::to_string_pretty(&hits).map_err(|e| e.to_string())?
);
return Ok(());
}
print_hits_table(&hits);
let ledger = load_ledger(&root)?;
let unlinked = hits
.iter()
.filter(|h| {
ledger
.entries
.get(&h.fingerprint)
.map(|e| e.linear.is_none() && e.github.is_none())
.unwrap_or(true)
})
.count();
println!(
"{} scanned under {} ({} unlinked)",
format!("{} TODO(s)", hits.len()).bright_white().bold(),
root.display().to_string().dimmed(),
unlinked
);
if unlinked > 0 {
println!(
"{}",
format!(
"Tip: `xbp todos sync --dry-run` (default target from config: {:?})",
settings.default_to
)
.dimmed()
);
}
if !no_prompt && !json && unlinked > 0 {
sync::prompt_sync_after_scan(&root).await?;
}
Ok(())
}
async fn run_status(path: Option<&Path>) -> Result<(), String> {
let root = resolve_scan_root(path)?;
let settings = ResolvedTodosSettings::load(Some(&root));
let hits = scan_todos(&root)?;
let hits: Vec<TodoHit> = hits
.into_iter()
.filter(|h| settings.allows_kind(&h.kind))
.collect();
let ledger = load_ledger(&root)?;
let live_fps: std::collections::HashSet<_> =
hits.iter().map(|h| h.fingerprint.clone()).collect();
let stale = ledger
.entries
.keys()
.filter(|fp| !live_fps.contains(*fp))
.count();
let mut linked_linear = 0usize;
let mut linked_github = 0usize;
let mut unlinked = 0usize;
let rows: Vec<Vec<String>> = hits
.iter()
.map(|hit| {
let entry = ledger.entries.get(&hit.fingerprint);
let (lin, gh) = match entry {
Some(e) => {
if e.linear.is_some() {
linked_linear += 1;
}
if e.github.is_some() {
linked_github += 1;
}
if e.linear.is_none() && e.github.is_none() {
unlinked += 1;
}
(
e.linear
.as_ref()
.map(|l| l.identifier.clone())
.unwrap_or_else(|| "—".into()),
e.github
.as_ref()
.map(|g| format!("#{}", g.number))
.unwrap_or_else(|| "—".into()),
)
}
None => {
unlinked += 1;
("—".into(), "—".into())
}
};
vec![
hit.kind.clone().bright_yellow().to_string(),
format!("{}:{}", hit.path, hit.line),
truncate_chars(&hit.text, 40),
if lin == "—" {
lin.dimmed().to_string()
} else {
lin.bright_cyan().to_string()
},
if gh == "—" {
gh.dimmed().to_string()
} else {
gh.bright_green().to_string()
},
]
})
.collect();
println!();
println!("{}", "TODO sync status".bright_cyan().bold());
println!(" root: {}", root.display());
println!(" scanned: {}", hits.len());
println!(" ledger entries: {}", ledger.entries.len());
println!(" linked Linear: {}", linked_linear);
println!(" linked GitHub: {}", linked_github);
println!(" unlinked: {}", unlinked);
println!(
" stale ledger: {}{}",
stale,
if stale > 0 {
" (run `xbp todos prune`)"
} else {
""
}
);
println!(" default_to: {:?}", settings.default_to);
println!(" auto_yes: {}", settings.auto_yes);
println!(" annotate_source: {}", settings.annotate_source);
println!(
" linear labels: {}",
settings.linear_labels.join(", ")
);
println!(
" github labels: {}",
settings.github_labels.join(", ")
);
println!();
if !rows.is_empty() {
print!(
"{}",
render_table(
&["Kind", "Location", "Text", "Linear", "GitHub"],
&rows,
TableStyle::Pipe,
"",
)
);
}
println!();
Ok(())
}
pub(crate) fn resolve_scan_root(path: Option<&Path>) -> Result<PathBuf, String> {
if let Some(path) = path {
return path
.canonicalize()
.or_else(|_| Ok(path.to_path_buf()))
.map_err(|e: std::io::Error| e.to_string());
}
let cwd = env::current_dir().map_err(|e| format!("Failed to get cwd: {e}"))?;
if let Some(found) = find_xbp_config_upwards(&cwd) {
return Ok(found.project_root);
}
for dir in cwd.ancestors() {
if dir.join(".git").exists() {
return Ok(dir.to_path_buf());
}
}
Ok(cwd)
}
pub(crate) fn print_hits_table(hits: &[TodoHit]) {
if hits.is_empty() {
println!("{}", "No TODO/FIXME markers found.".dimmed());
return;
}
let rows: Vec<Vec<String>> = hits
.iter()
.map(|h| {
vec![
h.kind.clone().bright_yellow().to_string(),
format!("{}:{}", h.path, h.line),
truncate_chars(&h.text, 70),
]
})
.collect();
print!(
"{}",
render_table(&["Kind", "Location", "Text"], &rows, TableStyle::Pipe, "")
);
}