cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
use clap::{Arg, ArgMatches, Command};

use crate::domain::model::issue::Issue;
use crate::domain::usecases::issue::read_companion::{read_companion, ReadCompanionOutcome};
use crate::domain::usecases::issue::{show_issue_with_family_and_tags, IssueFamily};
use crate::infra::driving::cli::errors::{die1, CliError};
use crate::infra::driving::cli::helpers::required_str;
use crate::infra::driving::cli::id_parsing::resolve_issue_id;
use crate::infra::driving::cli::theme;
use crate::infra::driving::cli::{render_structured, Context};

pub(super) fn subcommand() -> Command {
    Command::new("show")
        .about("Show issue details")
        .arg(
            Arg::new("id")
                .required(true)
                .value_name("ID")
                .help("Issue ID (e.g. ISSUE-01H8MSQGXYZ12)"),
        )
        .arg(
            Arg::new("history")
                .long("history")
                .help("Show event history")
                .action(clap::ArgAction::SetTrue),
        )
        .arg(
            Arg::new("companion")
                .long("companion")
                .value_name("NAME")
                .help(
                    "Render a companion by canonical filename, stem, or unique prefix \
                     (e.g. `plan`, `p`, `mockup`)",
                ),
        )
}

pub(super) fn execute(sub: &ArgMatches, ctx: &Context<'_>) {
    let _statuses = ctx.issues_statuses;
    let output_fmt = ctx.output_fmt;
    let id_str = required_str(sub, "id");
    let show_history = sub.get_flag("history");
    let companion_arg = sub.get_one::<String>("companion").cloned();

    let repo = ctx.issue_repository();

    // Resolve the user input: canonical ID, then unique TSID prefix
    // (ADR-0022 phase 4), then alias forwarding.
    use crate::domain::usecases::issue::IssueRepository;
    let id = resolve_issue_id(
        &repo as &dyn IssueRepository,
        id_str,
        ctx.issues_id_prefix(),
        output_fmt,
    );

    if let Some(name) = companion_arg.as_deref() {
        render_companion(&repo, &id, name, output_fmt);
        return;
    }

    let descriptors = &ctx.config().tag_descriptors_for("issues");
    match show_issue_with_family_and_tags(&repo, &id, descriptors) {
        Ok(Some((issue, family))) => {
            if output_fmt.is_structured() {
                render_structured(
                    &crate::infra::driving::cli::issue_view::IssueView::from_issue(&issue)
                        .with_family(&family),
                    output_fmt,
                );
                return;
            }
            if show_history {
                print_issue_history(&issue);
            } else {
                print_issue_detail(&issue, &family);
            }
            print_companions_block(&repo, &id);
        }
        Ok(None) => {
            die1(
                CliError::not_found(format!("issue {id}"), "issue list"),
                output_fmt,
            );
        }
        Err(e) => {
            die1(CliError::new(e.to_string()), output_fmt);
        }
    }
}

fn render_companion(
    repo: &dyn crate::domain::usecases::issue::IssueRepository,
    id: &crate::domain::model::record_ref::IssueRef,
    name: &str,
    output_fmt: crate::infra::driving::cli::OutputFormat,
) {
    match read_companion(repo, id, name) {
        Ok(ReadCompanionOutcome::Found { body, .. }) => println!("{}", body.as_str()),
        Ok(ReadCompanionOutcome::Absent { filename }) => println!("no {filename} in {id}"),
        Ok(ReadCompanionOutcome::Unmanaged { identifier }) => die1(
            CliError::new(format!("companion '{identifier}' is not addressable"))
                .kind("validation"),
            output_fmt,
        ),
        Ok(ReadCompanionOutcome::AmbiguousId { candidates }) => {
            let list: Vec<&str> = candidates.iter().map(|c| c.as_str()).collect();
            die1(
                CliError::new(format!("companion '{name}' is ambiguous"))
                    .kind("validation")
                    .hint(format!("Candidates: {}", list.join(", "))),
                output_fmt,
            );
        }
        Ok(ReadCompanionOutcome::Inexistant) => die1(
            CliError::new(format!("no companion matching '{name}' in {id}")).kind("validation"),
            output_fmt,
        ),
        Err(e) => die1(CliError::new(format!("{e}")).kind("io"), output_fmt),
    }
}

fn print_issue_history(issue: &Issue) {
    let id_display = issue.id.to_string();
    println!(
        "{}  {}",
        theme::id(&id_display),
        theme::status(&issue.status.label, issue.status.category),
    );
    println!("{}", theme::separator(50));
    println!("{}", issue.title);
    println!();
    println!("{}", theme::section("Event History:"));
    if issue.events.is_empty() {
        println!("  No events recorded");
        return;
    }
    for event in &issue.events {
        use crate::domain::model::event::EventAction;
        println!("{} - {}", event.timestamp.format_local(), event.action);
        match &event.action {
            EventAction::Created { state } => println!("  Status: {state}"),
            EventAction::StatusChanged { from, to } => {
                println!("  From: {from}");
                println!("  To: {to}");
            }
        }
        println!();
    }
}

fn print_companions_block(
    repo: &dyn crate::domain::usecases::issue::IssueRepository,
    id: &crate::domain::model::record_ref::IssueRef,
) {
    let companions = repo.issue_companions(id).unwrap_or_default();
    if companions.is_empty() {
        return;
    }
    println!();
    let names: Vec<&str> = companions.iter().map(|c| c.identifier.as_str()).collect();
    println!("{} {}", theme::section("Companions:"), names.join(", "));
}

fn print_issue_detail(issue: &Issue, family: &IssueFamily) {
    let id_display = issue.id.to_string();
    println!(
        "{}  {}",
        theme::id(&id_display),
        theme::status(&issue.status.label, issue.status.category),
    );
    println!("{}", theme::separator(50));
    println!("{}", issue.title);
    println!("{} {}", theme::label("Date:"), issue.date);
    if let crate::domain::model::entry_origin::EntryOrigin::Union { name } = &issue.origin {
        println!(
            "{} read-only, from union source '{name}'",
            theme::label("Origin:"),
        );
    }
    if !issue.tags.is_empty() {
        let tags_str: Vec<&str> = issue.tags.iter().map(|t| t.as_str()).collect();
        println!("{} {}", theme::label("Tags:"), tags_str.join(", "));
    }
    if let Some(parent) = &family.parent {
        println!("{} {}", theme::label("Parent:"), parent);
    }
    if !family.children.is_empty() {
        println!();
        println!("{}", theme::label("Children:"));
        for child in &family.children {
            println!("  - {child}");
        }
    }
    print_rollup_block(family);
    // Hierarchical links are surfaced via the dedicated Parent/Children
    // sections above; skip them here to avoid showing the same info twice.
    let peer_links: Vec<_> = issue
        .links
        .iter()
        .filter(|l| !l.relationship.is_hierarchical())
        .collect();
    if !peer_links.is_empty() {
        println!();
        println!("{}", theme::label("Links:"));
        for link in peer_links {
            let rel = link.relationship.as_str();
            let label = {
                let mut c = rel.chars();
                match c.next() {
                    None => String::new(),
                    Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
                }
            };
            println!("  {}: {}", theme::label(&label), link.target);
        }
    }
    println!();
    println!("{}", issue.content);
}

/// Consolidated `Rollup` block — surfaces the status rollup and
/// every per-tag rollup that fired, when the issue has children.
fn print_rollup_block(family: &IssueFamily) {
    let has_status = family.rollup.is_some();
    let has_tags = !family.tag_rollups.is_empty();
    if !has_status && !has_tags {
        return;
    }
    println!();
    let header = match family.rollup.map(|h| h.total()) {
        Some(n) => format!("Rollup (from {n} direct children)"),
        None => "Rollup".to_string(),
    };
    println!("{}", theme::section(&header));
    if let Some(h) = &family.rollup {
        println!(
            "  {:<12} {}  [Q:{} A:{} S:{} R:{} C:{}]",
            theme::label("status:"),
            theme::status(h.category().as_str(), h.category()),
            h.queued,
            h.active,
            h.stalled,
            h.resolved,
            h.cancelled,
        );
    }
    for (key, rollup) in &family.tag_rollups {
        let provenance = if rollup.count == 1 {
            "1 child".to_string()
        } else {
            format!("{} children", rollup.count)
        };
        println!(
            "  {:<12} {}  ({provenance})",
            theme::label(&format!("{key}:")),
            rollup.value,
        );
    }
}