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::usecases::issue::{list_issues, ListedIssue};
use crate::infra::driving::cli::errors::{die1, CliError};
use crate::infra::driving::cli::theme;
use crate::infra::driving::cli::{render_structured, Context};

pub(super) fn subcommand() -> Command {
    Command::new("list")
        .about("List issues")
        .arg(
            Arg::new("status")
                .long("status")
                .help("Filter by status (open, in-progress, closed)")
                .value_name("STATUS"),
        )
        .arg(
            Arg::new("active")
                .long("active")
                .help("Show only active issues (open or in-progress)")
                .action(clap::ArgAction::SetTrue),
        )
        .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("sort")
                .long("sort")
                .help(
                    "Sort by an ordered tag descriptor. The key must be declared as \
                     `[tags.<key>]` with `ordered = true` and \
                     `applies_to = [\"issues\"]`.",
                )
                .value_name("KEY"),
        )
        .arg(
            Arg::new("with-rollup")
                .long("with-rollup")
                .help(
                    "Surface the derived status rollup of composite issues \
                     as an extra column. Off by default to keep the listing \
                     narrow.",
                )
                .action(clap::ArgAction::SetTrue),
        )
}

pub(super) fn execute(sub: &ArgMatches, ctx: &Context<'_>) {
    let statuses = ctx.issues_statuses;
    let output_fmt = ctx.output_fmt;
    let active_only = sub.get_flag("active");
    let with_rollup = sub.get_flag("with-rollup");
    let status_filter = if active_only {
        None
    } else {
        sub.get_one::<String>("status").map(|s| {
            statuses.resolve(s).unwrap_or_else(|_| {
                let known: Vec<&str> = statuses.status_names().collect();
                die1(
                    CliError::new(format!("unknown status '{s}'"))
                        .kind("validation")
                        .hint(format!("Known statuses: {}", known.join(", "))),
                    output_fmt,
                );
            })
        })
    };
    let tag_filters: Vec<crate::domain::model::tag_filter::TagFilter> = sub
        .get_many::<String>("tag")
        .unwrap_or_default()
        .map(|s| {
            crate::domain::model::tag_filter::TagFilter::parse(s).unwrap_or_else(|e| {
                die1(
                    CliError::new(format!("invalid tag filter '{s}': {e}")).kind("validation"),
                    output_fmt,
                );
            })
        })
        .collect();
    let descriptors = ctx.config().tag_descriptors_for("issues");
    let sort_descriptor = sub.get_one::<String>("sort").map(|key| {
        let descriptor = descriptors.get(key).unwrap_or_else(|| {
            die1(
                CliError::new(format!("--sort {key:?}: no descriptor for that key"))
                    .kind("validation")
                    .hint(format!(
                        "Declare `[tags.{key}]` with `applies_to = [\"issues\"]` in cartulary.toml."
                    )),
                output_fmt,
            );
        });
        if !descriptor.ordered {
            die1(
                CliError::new(format!("--sort {key:?}: not an ordered tag descriptor"))
                    .kind("validation")
                    .hint(format!("Add `ordered = true` to `[tags.{key}]`.")),
                output_fmt,
            );
        }
        descriptor.clone()
    });
    let filter = crate::domain::model::issue::IssueFilter {
        status: status_filter.as_ref(),
        active: active_only,
        tags: &tag_filters,
    };
    let repo = ctx.issue_repository();
    let listed = list_issues(
        &repo,
        &filter,
        sort_descriptor.as_ref(),
        &descriptors,
        with_rollup,
    )
    .unwrap_or_else(|e| die1(CliError::new(e.to_string()), output_fmt));

    if output_fmt.is_structured() {
        use crate::infra::driving::cli::issue_view::IssueView;
        let views: Vec<IssueView> = listed
            .iter()
            .map(|l| {
                let mut v = IssueView::from_issue(&l.issue);
                if let Some(rollup) = &l.rollup {
                    v = v.with_rollups(rollup.status, rollup.tags.clone());
                }
                v
            })
            .collect();
        render_structured(&views, output_fmt);
        return;
    }

    render_table(&listed, with_rollup);
}

fn render_table(listed: &[ListedIssue], with_rollup: bool) {
    if listed.is_empty() {
        println!("No issues found");
        return;
    }
    use crate::infra::driving::cli::table::{terminal_width, Cell, Table};
    let mut table = Table::new(terminal_width());
    for l in listed {
        let mut cells = vec![
            Cell::new(theme::id(&l.issue.id.to_string())),
            Cell::new(theme::status(
                &l.issue.status.label,
                l.issue.status.category,
            )),
        ];
        if with_rollup {
            cells.push(Cell::new(format_rollup_cell(l.rollup.as_ref())));
        }
        let title = if matches!(
            l.issue.origin,
            crate::domain::model::entry_origin::EntryOrigin::Union { .. }
        ) {
            format!("{} [union]", l.issue.title)
        } else {
            l.issue.title.to_string()
        };
        cells.push(Cell::new(title));
        table.push(cells);
    }
    table.print();
}

fn format_rollup_cell(
    rollup: Option<&crate::domain::usecases::issue::tree_view::TreeRollup>,
) -> String {
    let Some(rollup) = rollup else {
        return String::new();
    };
    let mut parts: Vec<String> = Vec::new();
    if let Some(h) = &rollup.status {
        parts.push(theme::status(h.category().as_str(), h.category()));
    }
    for (key, tag) in &rollup.tags {
        parts.push(format!("{key}:{}", tag.value));
    }
    parts.join("  ")
}