cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! `cartu backlog` — show what's there to do across the workspace.

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

use crate::domain::usecases::decision_record::DecisionRecordRepository;
use crate::domain::usecases::issue::backlog::{build_backlog, BacklogReport};
use crate::domain::usecases::issue::IssueRepository;
use crate::infra::driving::cli::theme;
use crate::infra::driving::cli::{render_structured, Context};

pub(in super::super) fn subcommand() -> Command {
    Command::new("backlog")
        .about("List decision records pending a decision and open issues grouped by priority")
}

/// Read the `priority:<value>` tag from an issue, if any (case-preserved).
fn priority_value(issue: &crate::domain::model::issue::Issue) -> Option<String> {
    for tag in issue.tags.iter() {
        if let Some(rest) = tag.as_str().strip_prefix("priority:") {
            if !rest.is_empty() {
                return Some(rest.to_string());
            }
        }
    }
    None
}

/// Read the `flow:<value>` tag from an issue, if any.
fn flow_value(issue: &crate::domain::model::issue::Issue) -> Option<String> {
    for tag in issue.tags.iter() {
        if let Some(rest) = tag.as_str().strip_prefix("flow:") {
            if !rest.is_empty() {
                return Some(rest.to_string());
            }
        }
    }
    None
}

/// Read the `size:<value>` tag from an issue, if any.
fn size_value(issue: &crate::domain::model::issue::Issue) -> Option<String> {
    for tag in issue.tags.iter() {
        if let Some(rest) = tag.as_str().strip_prefix("size:") {
            if !rest.is_empty() {
                return Some(rest.to_string());
            }
        }
    }
    None
}

pub(in super::super) fn execute(_sub: &ArgMatches, ctx: &Context<'_>) {
    let issues = ctx.issue_repository().list().unwrap_or_default().into_vec();
    let mut drs_by_kind = Vec::new();
    for kind_cfg in &ctx.config().decision_kinds {
        let repo = ctx.decision_record_repository(kind_cfg);
        let records = repo.list().unwrap_or_default().into_vec();
        drs_by_kind.push((kind_cfg.kind.clone(), records));
    }
    // Priority levels come from the `[tags.priority]` descriptor scoped to issues.
    let levels: Vec<String> = ctx
        .config()
        .tag_descriptors_for("issues")
        .get("priority")
        .map(|d| d.levels.clone())
        .unwrap_or_default();

    let report = build_backlog(issues, drs_by_kind, &levels);

    if ctx.output_fmt.is_structured() {
        let view = ReportView::from(&report);
        render_structured(&view, ctx.output_fmt);
        return;
    }

    print_human(&report);
}

fn print_human(report: &BacklogReport) {
    if report.total_drs == 0 && report.total_issues == 0 {
        println!("Nothing to do — backlog is empty.");
        return;
    }

    println!("Backlog");
    println!();

    for section in &report.dr_sections {
        if section.records.is_empty() {
            continue;
        }
        println!(
            "{} ({})",
            theme::section(&format!("{}s to resolve", section.kind.to_uppercase())),
            section.records.len(),
        );
        for r in &section.records {
            println!(
                "  {}  {}  {}",
                theme::id(&r.id.to_string()),
                r.title,
                theme::dr_status(r.status),
            );
        }
        println!();
    }

    if !report.priority_buckets.is_empty() {
        println!(
            "{} ({} open)",
            theme::section("Issues to do"),
            report.total_issues,
        );
        println!();
        for bucket in &report.priority_buckets {
            let label = bucket.priority_label.as_deref().unwrap_or("No priority");
            println!("  {} ({})", theme::label(label), bucket.issues.len());
            for issue in &bucket.issues {
                let size = size_value(issue).unwrap_or_else(|| "-".to_string());
                let flow = flow_value(issue).unwrap_or_else(|| "-".to_string());
                println!(
                    "    {}  {}  {}  {}",
                    theme::id(&issue.id.to_string()),
                    issue.title,
                    flow,
                    size,
                );
            }
            println!();
        }
    }
}

// ── structured output (json / yaml) ──────────────────────────────────────────

#[derive(Serialize)]
struct ReportView<'a> {
    total_drs: usize,
    total_issues: usize,
    dr_sections: Vec<DrSectionView<'a>>,
    priority_buckets: Vec<PriorityBucketView<'a>>,
}

#[derive(Serialize)]
struct DrSectionView<'a> {
    kind: &'a str,
    records: Vec<DrRecordView<'a>>,
}

#[derive(Serialize)]
struct DrRecordView<'a> {
    id: String,
    title: &'a str,
    status: &'a str,
}

#[derive(Serialize)]
struct PriorityBucketView<'a> {
    #[serde(skip_serializing_if = "Option::is_none")]
    priority: Option<&'a str>,
    issues: Vec<IssueLineView>,
}

#[derive(Serialize)]
struct IssueLineView {
    id: String,
    title: String,
    #[serde(rename = "flow", skip_serializing_if = "Option::is_none")]
    flow: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    priority: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    size: Option<String>,
}

impl<'a> From<&'a BacklogReport> for ReportView<'a> {
    fn from(r: &'a BacklogReport) -> Self {
        ReportView {
            total_drs: r.total_drs,
            total_issues: r.total_issues,
            dr_sections: r
                .dr_sections
                .iter()
                .map(|s| DrSectionView {
                    kind: &s.kind,
                    records: s
                        .records
                        .iter()
                        .map(|rec| DrRecordView {
                            id: rec.id.to_string(),
                            title: rec.title.as_str(),
                            status: rec.status.as_str(),
                        })
                        .collect(),
                })
                .collect(),
            priority_buckets: r
                .priority_buckets
                .iter()
                .map(|b| PriorityBucketView {
                    priority: b.priority_label.as_deref(),
                    issues: b
                        .issues
                        .iter()
                        .map(|i| IssueLineView {
                            id: i.id.to_string(),
                            title: i.title.as_str().to_string(),
                            flow: flow_value(i),
                            priority: priority_value(i),
                            size: size_value(i),
                        })
                        .collect(),
                })
                .collect(),
        }
    }
}