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::clock::Clock;
use crate::domain::usecases::issue::stats::{compute_distribution_by_tag, TagDistribution};
use crate::domain::usecases::issue::IssueRepository;
use crate::infra::driven::clock::SystemClock;
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("distribution")
        .about("Throughput distribution grouped by a structured-tag key")
        .arg(
            Arg::new("by")
                .long("by")
                .help("Tag key to group by (any key declared under `[tags.<key>]` with `applies_to = [\"issues\"]`)")
                .value_name("KEY")
                .required(true),
        )
        .arg(
            Arg::new("weeks")
                .long("weeks")
                .help("Window size in weeks (default: 8)")
                .value_name("N")
                .value_parser(clap::value_parser!(u32)),
        )
        .arg(
            Arg::new("include-unset")
                .long("include-unset")
                .help("Bucket issues without that key under an empty value")
                .action(clap::ArgAction::SetTrue),
        )
}

pub(super) fn execute(sub: &ArgMatches, ctx: &Context<'_>) {
    let key = crate::infra::driving::cli::helpers::required_str(sub, "by");
    let weeks = sub.get_one::<u32>("weeks").copied().unwrap_or(8);
    let include_unset = sub.get_flag("include-unset");
    let output_fmt = ctx.output_fmt;

    let config = ctx.config();
    let issue_descriptors = config.tag_descriptors_for("issues");
    let descriptor = issue_descriptors.get(key).unwrap_or_else(|| {
        die1(
            CliError::new(format!("--by {key:?}: no descriptor for that key"))
                .kind("validation")
                .hint(format!(
                    "Declare `[tags.{key}]` with `applies_to = [\"issues\"]` in cartulary.toml."
                )),
            output_fmt,
        );
    });

    let repo = ctx.issue_repository();
    let issues = repo.list().unwrap_or_else(|e| {
        die1(CliError::new(e.to_string()), output_fmt);
    });
    let today = SystemClock.today_local();
    let view = issues.view();
    let dist = compute_distribution_by_tag(
        &view,
        ctx.issues_statuses,
        &today,
        weeks,
        key,
        Some(descriptor),
        include_unset,
    );

    if output_fmt.is_structured() {
        render_structured(&SerializableDistribution::from(&dist), output_fmt);
    } else {
        print_human(&dist);
    }
}

fn print_human(dist: &TagDistribution) {
    println!(
        "{}  [last {} weeks, {} items closed, --by {}]:",
        theme::section("Distribution"),
        dist.weeks,
        dist.total_closed,
        dist.key,
    );
    if dist.by_value.is_empty() {
        println!("  (no data)");
        return;
    }
    let max = dist.by_value.iter().map(|(_, c)| *c).max().unwrap_or(1) as f64;
    for (value, count) in &dist.by_value {
        let bar_len = (*count as f64 / max * 20.0).round() as usize;
        let bar = "".repeat(bar_len);
        let pct = if dist.total_closed > 0 {
            *count as f64 / dist.total_closed as f64 * 100.0
        } else {
            0.0
        };
        let label = if value.is_empty() { "(unset)" } else { value };
        println!(
            "  {:<14}  {:<20}  {:>3}  ({:.0}%)",
            theme::label(label),
            bar,
            count,
            pct
        );
    }
}

#[derive(serde::Serialize)]
struct SerializableDistribution<'a> {
    key: &'a str,
    weeks: u32,
    total_closed: u32,
    #[serde(serialize_with = "serialize_by_value")]
    by_value: &'a [(String, u32)],
}

impl<'a> From<&'a TagDistribution> for SerializableDistribution<'a> {
    fn from(d: &'a TagDistribution) -> Self {
        SerializableDistribution {
            key: &d.key,
            weeks: d.weeks,
            total_closed: d.total_closed,
            by_value: &d.by_value,
        }
    }
}

fn serialize_by_value<S>(pairs: &&[(String, u32)], s: S) -> Result<S::Ok, S::Error>
where
    S: serde::Serializer,
{
    use serde::ser::SerializeMap;
    let mut map = s.serialize_map(Some(pairs.len()))?;
    for (value, count) in pairs.iter() {
        let key = if value.is_empty() { "(unset)" } else { value };
        map.serialize_entry(key, count)?;
    }
    map.end()
}