cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! `cartu decisions` — brief the decisions of every record matching a tag.
//!
//! Spans every configured decision kind (ADR, DDR, …). Output is
//! Markdown to stdout, designed to be pasted into a prompt or scanned
//! quickly by a human starting work on a part of the codebase.

use clap::{Arg, ArgMatches, Command};

use crate::domain::model::tag_filter::TagFilter;
use crate::domain::usecases::decision_record::brief::{
    brief_decisions, BriefSource, DecisionBrief,
};
use crate::infra::driving::cli::errors::{die1, CliError};
use crate::infra::driving::cli::Context;

pub(in super::super) fn subcommand() -> Command {
    Command::new("decisions")
        .about("Brief the decisions of every record matching a tag")
        .long_about(
            "Walk every configured decision kind (ADR, DDR, …) and emit a \
             Markdown brief of the matching records. When a record carries \
             a `> [!DECISION]` alert, only that block is shown; otherwise \
             the full body is included with a footer note.",
        )
        .arg(
            Arg::new("tag")
                .long("tag")
                .help(
                    "Filter by tag (repeatable; AND across filters). \
                     Forms: `name`, `key:value`, or `key:` to match any value of that key.",
                )
                .value_name("PATTERN")
                .action(clap::ArgAction::Append),
        )
        .arg(
            Arg::new("all-statuses")
                .long("all-statuses")
                .help(
                    "Include every record regardless of status. By default only \
                     applicable decisions are listed (active and settled — \
                     `accepted` on the default preset); `proposed`, `rejected`, \
                     `deprecated`, `superseded` are filtered out.",
                )
                .action(clap::ArgAction::SetTrue),
        )
}

pub(in super::super) fn execute(sub: &ArgMatches, ctx: &Context<'_>) {
    let output_fmt = ctx.output_fmt;
    let raw_tags: Vec<String> = sub
        .get_many::<String>("tag")
        .unwrap_or_default()
        .cloned()
        .collect();
    let tag_filters: Vec<TagFilter> = raw_tags
        .iter()
        .map(|s| {
            TagFilter::parse(s).unwrap_or_else(|e| {
                die1(
                    CliError::new(format!("invalid tag filter '{s}': {e}")).kind("validation"),
                    output_fmt,
                );
            })
        })
        .collect();
    let applicable_only = !sub.get_flag("all-statuses");

    let dr_repos: Vec<_> = ctx
        .config()
        .decision_kinds
        .iter()
        .map(|kind_cfg| ctx.decision_record_repository(kind_cfg))
        .collect();

    let mut all_briefs: Vec<DecisionBrief> = Vec::new();
    for repo in &dr_repos {
        let mut briefs =
            brief_decisions(repo, None, &tag_filters, applicable_only).unwrap_or_else(|e| {
                die1(CliError::new(e.to_string()), output_fmt);
            });
        all_briefs.append(&mut briefs);
    }
    all_briefs.sort_by(|a, b| {
        a.record
            .kind
            .as_str()
            .cmp(b.record.kind.as_str())
            .then_with(|| a.record.id.cmp(&b.record.id))
    });

    print_brief(&raw_tags, &all_briefs);
}

fn print_brief(raw_tags: &[String], briefs: &[DecisionBrief]) {
    println!("{}", header(raw_tags, briefs.len()));
    if briefs.is_empty() {
        return;
    }
    for brief in briefs {
        println!();
        println!(
            "## {}{} ({})",
            brief.record.id,
            brief.record.title,
            brief.record.status.as_str(),
        );
        let banner_lines = banner_lines(brief);
        for line in &banner_lines {
            println!();
            println!("{line}");
        }
        println!();
        match brief.source {
            BriefSource::Marker if brief.content.is_empty() => println!("_(empty marker)_"),
            BriefSource::Marker => println!("{}", brief.content),
            BriefSource::Absent => println!(
                "> _No `[!DECISION]` marker yet — read {} for the rationale._",
                brief.record.id,
            ),
        }
    }
}

/// One banner per non-empty backref family:
/// 1. `Amended by …` — the decision is narrowed by a later ADR.
/// 2. `Amends …` — what this decision refines.
fn banner_lines(brief: &DecisionBrief) -> Vec<String> {
    let mut lines = Vec::new();
    if !brief.amended_by.is_empty() {
        lines.push(format!("_Amended by {}._", join_refs(&brief.amended_by)));
    }
    if !brief.amends.is_empty() {
        lines.push(format!("_Amends {}._", join_refs(&brief.amends)));
    }
    lines
}

fn join_refs(refs: &[crate::domain::model::entity_ref::EntityRef]) -> String {
    refs.iter()
        .map(|r| r.as_str().to_string())
        .collect::<Vec<_>>()
        .join(", ")
}

fn header(raw_tags: &[String], count: usize) -> String {
    if raw_tags.is_empty() {
        return format!("# Decisions ({count})");
    }
    let patterns: Vec<String> = raw_tags.iter().map(|s| format!("`{s}`")).collect();
    format!("# Decisions matching {} ({count})", patterns.join(" AND "))
}