tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! `tix beads …`: work with a beads workspace through the `bd` CLI.
//!
//! Every surface here goes through `bd` and **never** opens the Dolt database or
//! reads `.beads/issues.jsonl`. The reads — `detect`, `status`, `list`, `show`,
//! `watch` — write nothing anywhere; the writes — `push`, `pull`, `reconcile` —
//! are refused while the configured mode is `mirror`, which is read-only by
//! contract ([`crate::sync::beads::ensure_writes_allowed`]).
//!
//! See `docs/for-users/beads-integration.md`.

use std::collections::BTreeMap;
use std::path::PathBuf;

use crate::cli::BeadsAction;
use crate::config::Config;
use crate::error::{Result, TixError};
use crate::storage::Store;
use crate::sync::ProcessRunner;
use crate::sync::beads::{self, BeadsRow, EventRecord};

/// Route one `tix beads` verb.
pub fn run(config: &Config, store: &Store, action: &BeadsAction) -> Result<()> {
    let runner = ProcessRunner;
    match action {
        BeadsAction::Detect => {
            let detection = beads::detect(&runner, config);
            println!("{}", detection.detail);
            println!(
                "  binary:    {} ({})",
                detection.binary,
                if detection.available {
                    "found"
                } else {
                    "missing"
                }
            );
            println!(
                "  workspace: {}",
                detection.path.as_deref().unwrap_or("none")
            );
            match detection.journal {
                Some(true) => println!("  journal:   on"),
                Some(false) => println!("  journal:   OFF — a live mirror would see nothing"),
                None => {}
            }
            Ok(())
        }
        BeadsAction::Status => {
            let rows = beads::fetch(&runner, config)?;
            let status_by_id = status_map(&rows);
            let (mut open, mut closed, mut blocked, mut ready) = (0usize, 0usize, 0usize, 0usize);
            for row in &rows {
                if row.status.as_deref() == Some("closed") {
                    closed += 1;
                } else {
                    open += 1;
                    if beads::is_blocked(row, &status_by_id) {
                        blocked += 1;
                    } else {
                        ready += 1;
                    }
                }
            }
            println!("beads: {} issue(s)", rows.len());
            println!("  open     {open}");
            println!("  closed   {closed}");
            println!("  blocked  {blocked}");
            println!("  ready    {ready}");
            Ok(())
        }
        BeadsAction::List { all } => {
            let rows = beads::fetch(&runner, config)?;
            let status_by_id = status_map(&rows);
            let mut shown = 0usize;
            for row in &rows {
                if row.status.as_deref() == Some("closed") && !*all {
                    continue;
                }
                println!("{}", render_line(row, &status_by_id));
                shown += 1;
            }
            if shown == 0 {
                println!("no issues");
            }
            Ok(())
        }
        BeadsAction::Show { issue } => {
            let rows = beads::fetch(&runner, config)?;
            let row = find_row(&rows, issue)?;
            print!("{}", render_detail(row));
            Ok(())
        }
        BeadsAction::Watch { since, interval } => {
            watch_with(&runner, config, since.unwrap_or(0), *interval, None)
        }
        BeadsAction::Push {
            dry_run,
            all,
            verbose,
        } => {
            beads::ensure_writes_allowed(config)?;
            push(config, store, *dry_run, *all, *verbose)
        }
        BeadsAction::Pull { quiet } => {
            beads::ensure_writes_allowed(config)?;
            pull(config, store, *quiet)
        }
        BeadsAction::Reconcile { quiet } => {
            beads::ensure_writes_allowed(config)?;
            pull(config, store, *quiet)?;
            push(config, store, false, false, false)
        }
    }
}

/// The project root the beads state file lives in.
fn root(store: &Store) -> PathBuf {
    Config::project_root(&store.issues_dir).unwrap_or_else(|| PathBuf::from("."))
}

/// beads id → status, the input [`beads::is_blocked`] needs.
fn status_map(rows: &[BeadsRow]) -> BTreeMap<String, String> {
    rows.iter()
        .map(|row| (row.id.clone(), row.status.clone().unwrap_or_default()))
        .collect()
}

/// One list line: id, status, priority, title, and a blocked marker.
fn render_line(row: &BeadsRow, status_by_id: &BTreeMap<String, String>) -> String {
    let marker = if beads::is_blocked(row, status_by_id) {
        " [blocked]"
    } else {
        ""
    };
    format!(
        "{:<28} {:<12} p{} {}{}",
        row.id,
        row.status.as_deref().unwrap_or("?"),
        row.priority.map_or("-".to_string(), |p| p.to_string()),
        row.title,
        marker
    )
}

/// A one-row detail view, read-only.
fn render_detail(row: &BeadsRow) -> String {
    let mut out = String::new();
    out.push_str(&format!("{}\n", row.title));
    out.push_str(&format!("  id:       {}\n", row.id));
    out.push_str(&format!(
        "  status:   {}\n",
        row.status.as_deref().unwrap_or("?")
    ));
    if let Some(priority) = row.priority {
        out.push_str(&format!("  priority: p{priority}\n"));
    }
    if let Some(kind) = &row.issue_type {
        out.push_str(&format!("  type:     {kind}\n"));
    }
    if let Some(assignee) = row.effective_assignee() {
        out.push_str(&format!("  assignee: {assignee}\n"));
    }
    if !row.labels.is_empty() {
        out.push_str(&format!("  labels:   {}\n", row.labels.join(", ")));
    }
    if let Some(updated) = &row.updated_at {
        out.push_str(&format!("  updated:  {updated}\n"));
    }
    if !row.dependencies.is_empty() {
        out.push_str("  dependencies:\n");
        for dep in &row.dependencies {
            out.push_str(&format!("    {} {}\n", dep.kind, dep.depends_on_id));
        }
    }
    if let Some(description) = &row.description {
        if !description.trim().is_empty() {
            out.push_str(&format!("\n{description}\n"));
        }
    }
    out
}

/// Resolve one beads id: exact, else a unique substring of an id or a title.
fn find_row<'a>(rows: &'a [BeadsRow], selector: &str) -> Result<&'a BeadsRow> {
    if let Some(row) = rows.iter().find(|row| row.id == selector) {
        return Ok(row);
    }
    let matches: Vec<&BeadsRow> = rows
        .iter()
        .filter(|row| row.id.contains(selector) || row.title.contains(selector))
        .collect();
    match matches.len() {
        0 => Err(TixError::NotFound(format!(
            "no beads issue matches `{selector}`"
        ))),
        1 => Ok(matches[0]),
        _ => Err(TixError::InvalidInput(format!(
            "`{selector}` matches {} beads issues: {}",
            matches.len(),
            matches
                .iter()
                .map(|row| row.id.as_str())
                .collect::<Vec<_>>()
                .join(", ")
        ))),
    }
}

/// Follow the events journal, rendering each change until interrupted.
fn watch_with(
    runner: &ProcessRunner,
    config: &Config,
    since: u64,
    interval: u64,
    max_polls: Option<usize>,
) -> Result<()> {
    let mut checkpoint = since;
    let mut printed = since;
    let mut polls = 0usize;
    loop {
        let records = beads::events(runner, config, checkpoint, 200)?;
        for record in &records {
            if record.seq <= printed {
                continue;
            }
            println!("{}", render_event(record));
            printed = record.seq;
            checkpoint = record.seq;
        }
        polls += 1;
        if max_polls.is_some_and(|max| polls >= max) {
            return Ok(());
        }
        std::thread::sleep(std::time::Duration::from_secs(interval.max(1)));
    }
}

/// One journal line: when, what, and the resulting state.
fn render_event(record: &EventRecord) -> String {
    let status = record
        .issue
        .as_ref()
        .and_then(|issue| issue.status.clone())
        .unwrap_or_default();
    let title = record
        .issue
        .as_ref()
        .map(|issue| issue.title.clone())
        .unwrap_or_default();
    format!(
        "{:>6} {:<18} {:<8} {:<10} {}",
        record.seq,
        record.ts.as_deref().unwrap_or("-"),
        record.op,
        status,
        if title.is_empty() {
            record.issue_id.clone()
        } else {
            title
        }
    )
}

/// Write the local board into beads through `bd import`.
fn push(config: &Config, store: &Store, dry_run: bool, all: bool, verbose: bool) -> Result<()> {
    // One writer: the export target's core owns the plan, the mapping, the
    // incremental selection and the `bd import` call, so `tix beads push` and
    // `tix export --target beads` cannot drift.
    let report = crate::commands::export::run_beads_core(
        config,
        store,
        crate::commands::export::ExportOptions {
            dry_run,
            all,
            verbose,
        },
    )?;
    if dry_run && report.trim().is_empty() {
        eprintln!("nothing changed since the last export (pass --all to send everything)");
    }
    print!("{report}");
    Ok(())
}

/// Pull the beads workspace into the local board.
fn pull(config: &Config, store: &Store, quiet: bool) -> Result<()> {
    let root = root(store);
    let mut state = beads::load_state(&root);
    let rows = beads::fetch(&ProcessRunner, config)?;
    let resolution = crate::workflow::resolution(config, config.default_config(), "default");
    let report = beads::import_into(store, config, &resolution, &rows, &mut state)?;
    beads::save_state(&root, &state)?;
    if !quiet {
        for diagnostic in &report.diagnostics {
            eprintln!("{diagnostic}");
        }
        println!(
            "pulled {} bead(s): {} created{}, {} updated, {} skipped",
            rows.len(),
            report.created,
            if report.foreign > 0 {
                format!(" ({} beads-originated)", report.foreign)
            } else {
                String::new()
            },
            report.updated,
            report.skipped
        );
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;

    fn row(id: &str, status: &str, title: &str) -> BeadsRow {
        BeadsRow {
            record_type: "issue".to_string(),
            id: id.to_string(),
            title: title.to_string(),
            description: None,
            status: Some(status.to_string()),
            priority: Some(2),
            issue_type: Some("task".to_string()),
            assignee: None,
            owner: None,
            labels: Vec::new(),
            dependencies: Vec::new(),
            close_reason: None,
            created_at: None,
            updated_at: None,
        }
    }

    #[test]
    fn a_blocking_dependency_marks_the_dependent_blocked() {
        let mut dependent = row("tix-2", "open", "two");
        dependent.dependencies.push(beads::BeadsDependency {
            issue_id: "tix-2".to_string(),
            depends_on_id: "tix-1".to_string(),
            kind: "blocks".to_string(),
        });
        let rows = vec![row("tix-1", "open", "one"), dependent.clone()];
        let map = status_map(&rows);
        assert!(beads::is_blocked(&dependent, &map));
        assert!(render_line(&dependent, &map).contains("[blocked]"));

        // A closed blocker releases it.
        let rows = vec![row("tix-1", "closed", "one"), dependent.clone()];
        let map = status_map(&rows);
        assert!(!beads::is_blocked(&dependent, &map));
    }

    #[test]
    fn show_resolves_an_exact_id_a_unique_fragment_and_refuses_ambiguity() {
        let rows = vec![row("tix-1", "open", "alpha"), row("tix-22", "open", "beta")];
        assert_eq!(find_row(&rows, "tix-1").unwrap().title, "alpha");
        assert_eq!(find_row(&rows, "beta").unwrap().id, "tix-22");
        // A fragment that names exactly one row resolves to it...
        assert_eq!(find_row(&rows, "tix-2").unwrap().id, "tix-22");
        // ...and one that names several is refused rather than guessed at.
        assert!(matches!(
            find_row(&rows, "tix"),
            Err(TixError::InvalidInput(_))
        ));
        assert!(matches!(
            find_row(&rows, "nope"),
            Err(TixError::NotFound(_))
        ));
    }

    #[test]
    fn a_journal_record_renders_its_operation_and_state() {
        let record = EventRecord {
            seq: 42,
            ts: Some("2026-01-02T03:04:05Z".to_string()),
            op: "create".to_string(),
            issue_id: "tix-1".to_string(),
            actor: None,
            issue: Some(row("tix-1", "open", "wire the seam")),
        };
        let line = render_event(&record);
        assert!(line.contains("42"), "{line}");
        assert!(line.contains("create"), "{line}");
        assert!(line.contains("wire the seam"), "{line}");
    }
}