xbp 10.38.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! Manual TODO scan and idempotent Linear/GitHub issue generation.

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