balls 0.5.13

Git-native task tracker for parallel agent workflows
Documentation
//! `bl show <id>` — one ball in full, resolved by RECENCY (§9, § id generation):
//! the live `tasks/<id>.md` first, else the most recent incarnation
//! reconstructed from history. The human view is a labelled field block (only
//! present fields shown), its derived status badge, its `blockers` edges
//! annotated by what each gates (§10), the containment children that point at it
//! (§3 — `parent` is display-only), the markdown body, and the journal — the
//! ball's store-branch history rendered oldest-first ([`journal`], bl-0e16).
//! `--json` is the bedrock record. A dead ball renders the same block with its
//! retirement and deletion date in place of the live status; an id that
//! resolves to neither a live nor a dead ball is an error.

use std::fmt::Write;
use std::io;
use std::path::Path;

use super::history::{resolve_dead, Dead};
use super::{attribution, claim_age, journal, json_line, target, task_json, Catalog, Entry, Flags, Style};
use crate::civil::iso8601;
use crate::task::Task;

/// Resolve and render `bl show`. Live `tasks/<id>.md` wins; on a miss the recency
/// walk reconstructs the most recent dead incarnation from `balls/tasks` history
/// (§9); an id matching neither is an error. `flags.target` is parser-guaranteed.
/// `folded` is the §6 read-dispatch contribution — wired plugins' captured stdout
/// (the delivery worktree line, §11) — inserted verbatim into the human field
/// block; empty under `--json` (which never dispatches) or when nothing printed.
/// `now` is the render clock the derived claim-age is measured against (bl-46ef).
pub(crate) fn dispatch(store: &Path, cat: &Catalog, flags: &Flags, style: &Style, folded: &str, now: i64) -> io::Result<String> {
    let id = flags.target.as_deref().expect("parser guarantees show has a target");
    // A corrupt ball is PRESENT, not dead (bl-528c): surface its parse error
    // rather than fall through to history and resurrect a stale incarnation.
    if let Some(err) = cat.corruption(id) {
        return Err(io::Error::other(format!("tasks/{id}.md: {err}")));
    }
    match cat.get(id) {
        Some(e) => journaled(render_live(cat, e, flags, style, folded, store, now)?, store, id, flags),
        // A `--legacy` miss never falls through to the GREENFIELD store's
        // history — the legacy set is the whole world the flag names (§16).
        None if flags.legacy.is_some() => Err(io::Error::other(format!("no such legacy ball: {id}"))),
        None => match resolve_dead(store, id)? {
            Some(dead) => journaled(render_dead(cat, &dead, flags, style, folded, store)?, store, id, flags),
            None => Err(io::Error::other(format!("no such ball: {id}"))),
        },
    }
}

/// Append the §9 journal fold (bl-0e16) — the store history of this ball's
/// file, one paragraph after the body, live and dead alike. Human-only, the
/// worktree-line precedent: the journal is DERIVED (it is history), so bedrock
/// `--json` never carries it and never pays the walk; `--legacy` reads skip it
/// too (the projected set's history lives on the legacy ref, not this store).
fn journaled(mut out: String, store: &Path, id: &str, flags: &Flags) -> io::Result<String> {
    if flags.json || flags.legacy.is_some() {
        return Ok(out);
    }
    let section = journal::section(store, id)?;
    if !section.is_empty() {
        if !out.ends_with('\n') {
            out.push('\n');
        }
        out.push('\n');
        out.push_str(&section);
    }
    Ok(out)
}

/// Render a live ball: the bedrock record under `--json`, else the human field
/// block (badge, fields, blockers, children + the folded plugin lines, body).
fn render_live(cat: &Catalog, e: &Entry, flags: &Flags, style: &Style, folded: &str, store: &Path, now: i64) -> io::Result<String> {
    if flags.json {
        // `--json` is the bedrock record (§9) — the whole stored file (body
        // included, no derived `children`), identical to a `list` row and
        // re-ingestable by `bl import`. The rich view is the human projection.
        return Ok(json_line(&task_json(&e.id, &e.task)));
    }
    let badge = style.badge(cat.status(e));
    let mut out = header(&badge, &e.id, &e.task);
    field(&mut out, "status", cat.status(e).word());
    let claimed = claimed_line(e, flags, store, now)?;
    // A live ball's rendered body is its file as last sealed, so its bylines
    // derive from `HEAD` — the store checkout is only ever written inside a
    // sealed op, never edited underneath a read.
    let body = attributed(store, "HEAD", &e.id, &e.task.body, flags, style)?;
    body_block(&mut out, &e.task, &claimed, target::of(cat, &e.id, &e.task), &body, |out| {
        kids(out, cat, &child_ids(cat, &e.id), style);
        out.push_str(folded);
    });
    Ok(out)
}

/// The body with its §9 comment bylines folded in (bl-236c) — an ADDED render
/// line per `comment`-op region of the markdown, derived from `git blame` at
/// `rev` and stored nowhere ([`attribution`]). Human-only, like the journal and
/// the claim-age line: `--json` returns before this, and a `--legacy` read (its
/// history lives on the legacy ref, not this store, §16) renders the body bare.
fn attributed(store: &Path, rev: &str, id: &str, body: &str, flags: &Flags, style: &Style) -> io::Result<String> {
    if flags.legacy.is_some() {
        return Ok(body.to_string());
    }
    attribution::annotate(store, rev, id, body, style)
}

/// The derived `claimed <ISO> (<age> ago)` line a LIVE, currently-claimed ball
/// hangs under its `claimant` field (bl-46ef) — human-only and store-derived,
/// so a `--legacy` read (its history lives on the legacy ref, not this store,
/// §16) skips it, exactly as the journal fold does; `""` for an unclaimed ball
/// or a claimant with no `bl-op: claim` commit behind it.
fn claimed_line(e: &Entry, flags: &Flags, store: &Path, now: i64) -> io::Result<String> {
    if flags.legacy.is_some() || e.task.claimant.is_none() {
        return Ok(String::new());
    }
    let mut line = String::new();
    if let Some(t) = claim_age::claimed_at(store, &e.id)? {
        field(&mut line, "claimed", &format!("{} ({} ago)", iso8601(t), claim_age::humanize(now - t)));
    }
    Ok(line)
}

/// Render a dead (history-served) ball: the same bedrock `--json` record (its
/// reconstructed frontmatter round-trips), else the human block with the
/// retirement badge and an extra `retired` date line in place of the live status.
fn render_dead(cat: &Catalog, d: &Dead, flags: &Flags, style: &Style, folded: &str, store: &Path) -> io::Result<String> {
    if flags.json {
        return Ok(json_line(&task_json(&d.id, &d.task)));
    }
    let badge = style.retired_badge();
    let mut out = header(&badge, &d.id, &d.task);
    field(&mut out, "status", "closed");
    field(&mut out, "retired", &iso8601(d.retired_at));
    // The reconstructed body is the file as it stood the instant before
    // deletion, so its bylines derive at that same revision (bl-236c) — one
    // rule, live and dead alike, each blamed where its content came from.
    let body = attributed(store, &d.rev, &d.id, &d.task.body, flags, style)?;
    // Dead balls render no children rollup and no claim-age (retirement, not
    // occupancy, §9); a read-dispatch line still folds (in practice none — a
    // retired ball's worktree is torn down, §11).
    body_block(&mut out, &d.task, "", target::of(cat, &d.id, &d.task), &body, |out| out.push_str(folded));
    Ok(out)
}

/// The shared `<badge> <id>  <title>` heading both kinds open with.
fn header(badge: &str, id: &str, task: &Task) -> String {
    format!("{badge} {id}  {}\n", task.title)
}

/// The fields, blockers, kind-specific `extra` section, and body common to both
/// renders. `extra` injects the live children rollup (dead balls pass a no-op);
/// `claimed` is the derived claim-age line pushed under `claimant` (`""` for a
/// dead ball or an unaged live one, bl-46ef); `into` is the derived delivery
/// target (bl-6915), rendered under `parent` — the coordinate that turns bare
/// containment into nesting — and absent for the flat integration-branch case.
/// On a CLOSED ball it reads as "delivered here, not landed on the integration
/// branch"; its absence reads as landed. Human-only, like every derived line.
/// `body` is the ball's markdown as the render projects it — the stored bytes
/// verbatim, plus the derived comment bylines ([`attributed`], bl-236c).
fn body_block(out: &mut String, task: &Task, claimed: &str, into: Option<&str>, body: &str, extra: impl FnOnce(&mut String)) {
    field(out, "created", &iso8601(task.created));
    field(out, "updated", &iso8601(task.updated));
    if let Some(c) = &task.claimant {
        field(out, "claimant", c);
        out.push_str(claimed);
    }
    if let Some(p) = task.priority {
        field(out, "priority", &p.to_string());
    }
    if let Some(p) = &task.parent {
        field(out, "parent", p);
    }
    if let Some(t) = into {
        field(out, "delivers", t);
    }
    if !task.tags.is_empty() {
        field(out, "tags", &task.tags.join(", "));
    }
    blockers(out, task);
    extra(out);
    if !body.is_empty() {
        out.push('\n');
        out.push_str(body);
    }
}

/// The ids of balls whose `parent` points at `id`, in catalog (id) order —
/// the emergent containment rollup (§10), display-only (human render).
fn child_ids<'a>(cat: &'a Catalog, id: &str) -> Vec<&'a Entry> {
    cat.entries()
        .iter()
        .filter(|c| c.task.parent.as_deref() == Some(id))
        .collect()
}

/// A `  label  value` line — the field block's one row shape.
fn field(out: &mut String, label: &str, value: &str) {
    let _ = writeln!(out, "  {label:<9}{value}");
}

/// The `blockers` section: each edge as `<id> (on <op>)`, annotated as a
/// dependency (`on=claim`) or gate (`on=close`) by which transition it gates.
fn blockers(out: &mut String, task: &Task) {
    if task.blockers.is_empty() {
        return;
    }
    out.push_str("  blockers\n");
    for b in &task.blockers {
        let _ = writeln!(out, "    {} (on {})", b.id, b.on.token());
    }
}

/// The containment children, each as its own badge line under a `children`
/// header — empty containment prints nothing.
fn kids(out: &mut String, cat: &Catalog, children: &[&Entry], style: &Style) {
    if children.is_empty() {
        return;
    }
    out.push_str("  children\n");
    for c in children {
        let _ = writeln!(out, "    {} {}  {}", style.badge(cat.status(c)), c.id, c.task.title);
    }
}

#[cfg(test)]
#[path = "show_tests.rs"]
mod tests;