cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! `cartu issue tree` — render the parent/child hierarchy as a forest.
//!
//! Reuses the same `--status` / `--tag` / `--active` filters as
//! `cartu issue list`. Filtering is structural: an ancestor kept only
//! because a descendant matches is rendered dimmed, so the tree never
//! has dangling children.

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

use std::collections::BTreeMap;

use crate::domain::model::status::RollupHistogram;
use crate::domain::usecases::issue::tree_view::{build_issue_tree_view, TreeRollup, TreeViewNode};
use crate::domain::usecases::issue::{IssueRepository, TagRollupValue};
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("tree")
        .about("List issues as a parent/child tree")
        .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("with-rollup")
                .long("with-rollup")
                .help(
                    "Surface the derived status rollup of composite issues \
                     next to each parent node.",
                )
                .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 repo = ctx.issue_repository();
    let issues = repo
        .list()
        .unwrap_or_else(|e| die1(CliError::new(e.to_string()), output_fmt))
        .into_vec();
    let filter = crate::domain::model::issue::IssueFilter {
        status: status_filter.as_ref(),
        active: active_only,
        tags: &tag_filters,
    };
    let descriptors = ctx.config().tag_descriptors_for("issues");
    let forest = build_issue_tree_view(&issues, &filter, &descriptors);

    if output_fmt.is_structured() {
        let view: Vec<NodeView<'_>> = forest.iter().map(NodeView::from).collect();
        render_structured(&view, output_fmt);
        return;
    }

    if forest.is_empty() {
        println!("No issues found");
        return;
    }

    for (i, root) in forest.iter().enumerate() {
        print_node(root, "", i == forest.len() - 1, true, with_rollup);
    }
}

fn print_node(node: &TreeViewNode, prefix: &str, last: bool, is_root: bool, with_rollup: bool) {
    let connector = if is_root {
        ""
    } else if last {
        "└── "
    } else {
        "├── "
    };
    let id = theme::id(&node.issue.id.to_string());
    let status = theme::status(&node.issue.status.label, node.issue.status.category);
    let title = node.issue.title.to_string();
    let rollup_suffix = if with_rollup {
        node.rollup.as_ref().map(rollup_suffix).unwrap_or_default()
    } else {
        String::new()
    };
    let line = format!("{prefix}{connector}{id}  {status}  {title}{rollup_suffix}");
    if node.matched {
        println!("{line}");
    } else {
        println!("{}", theme::noop(&line));
    }
    let child_prefix = if is_root {
        String::new()
    } else if last {
        format!("{prefix}    ")
    } else {
        format!("{prefix}")
    };
    for (i, child) in node.children.iter().enumerate() {
        print_node(
            child,
            &child_prefix,
            i == node.children.len() - 1,
            false,
            with_rollup,
        );
    }
}

fn rollup_suffix(r: &TreeRollup) -> String {
    let mut parts: Vec<String> = Vec::new();
    if let Some(h) = &r.status {
        parts.push(theme::status(h.category().as_str(), h.category()));
    }
    for (key, rollup) in &r.tags {
        parts.push(format!("{key}:{}", rollup.value));
    }
    if parts.is_empty() {
        String::new()
    } else {
        format!("  [{}]", parts.join(", "))
    }
}

// ── Structured output (json / yaml) ──────────────────────────────────────────

#[derive(Serialize)]
struct NodeView<'a> {
    id: String,
    title: &'a str,
    status: &'a str,
    matched: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    rollup: Option<NodeRollupView>,
    children: Vec<NodeView<'a>>,
}

#[derive(Serialize)]
struct NodeRollupView {
    #[serde(skip_serializing_if = "Option::is_none")]
    status: Option<StatusRollupView>,
    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
    tags: BTreeMap<String, TagRollupValue>,
}

#[derive(Serialize)]
struct StatusRollupView {
    category: String,
    histogram: RollupHistogram,
}

impl<'a> From<&'a TreeViewNode> for NodeView<'a> {
    fn from(n: &'a TreeViewNode) -> Self {
        let rollup = n.rollup.as_ref().map(|r| NodeRollupView {
            status: r.status.map(|h| StatusRollupView {
                category: h.category().as_str().to_string(),
                histogram: h,
            }),
            tags: r.tags.clone(),
        });
        NodeView {
            id: n.issue.id.to_string(),
            title: n.issue.title.as_str(),
            status: n.issue.status.label.as_str(),
            matched: n.matched,
            rollup,
            children: n.children.iter().map(NodeView::from).collect(),
        }
    }
}