cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Generic `status` command — argument schema and rendering shared by
//! issues and decision records.
//!
//! `status` is a parent subcommand that dispatches to `show` (read the
//! current status) and `update` (transition to a new one). Per-family
//! shims build the typed id and repository, invoke the typed use case,
//! and feed the normalised [`View`] back into [`render`].

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

use crate::domain::model::status::{Status, StatusesConfig};
use crate::infra::driving::cli::errors::{die1, CliError};
use crate::infra::driving::cli::output::OutputFormat;
use crate::infra::driving::cli::render_structured;
use crate::infra::driving::cli::theme;

pub(crate) fn subcommand(noun: &'static str, id_help: &'static str) -> Command {
    let article = super::indefinite(noun);
    Command::new("status")
        .about(format!("Read or change the status of {article}"))
        .subcommand_required(true)
        .arg_required_else_help(true)
        .subcommand(
            Command::new("show")
                .about(format!("Print the current status of {article}"))
                .arg(id_arg(id_help)),
        )
        .subcommand(
            Command::new("update")
                .about(format!(
                    "Transition {article} to a new status (records a status_changed event)",
                ))
                .arg(id_arg(id_help))
                .arg(
                    Arg::new("status")
                        .required(true)
                        .value_name("STATUS")
                        .help("New status (must be one of the configured values)"),
                ),
        )
}

fn id_arg(help: &'static str) -> Arg {
    Arg::new("id").required(true).value_name("ID").help(help)
}

pub(crate) fn required_status_arg(sub: &ArgMatches) -> &str {
    crate::infra::driving::cli::helpers::required_str(sub, "status")
}

pub(crate) fn resolve_status_or_die(
    statuses: &StatusesConfig,
    raw: &str,
    output_fmt: OutputFormat,
) -> Status {
    statuses.resolve(raw).unwrap_or_else(|e| {
        let known = statuses.status_names().collect::<Vec<_>>().join(", ");
        die1(
            CliError::new(format!("invalid status '{raw}': {e}"))
                .kind("validation")
                .hint(format!("Known statuses: {known}")),
            output_fmt,
        )
    })
}

pub(crate) enum View {
    NotFound,
    Shown { status: String },
    Changed { from: Status, to: Status },
    NoOp,
}

pub(crate) fn render(
    view: View,
    display_id: &str,
    canonical_id: &str,
    noun: &str,
    output_fmt: OutputFormat,
) {
    match view {
        View::NotFound => die1(
            CliError::not_found(format!("{noun} '{display_id}'"), &format!("{noun} list")),
            output_fmt,
        ),
        view if output_fmt.is_structured() => {
            render_structured_view(view, canonical_id, output_fmt)
        }
        View::Shown { status } => println!("{status}"),
        View::Changed { from, to } => println!(
            "{}",
            theme::success(&format!("Updated {display_id}: {from}{to}")),
        ),
        View::NoOp => println!(
            "{}",
            theme::noop(&format!(
                "No change: {noun} {display_id} already has that status",
            )),
        ),
    }
}

fn render_structured_view(view: View, canonical_id: &str, output_fmt: OutputFormat) {
    #[derive(serde::Serialize)]
    #[serde(untagged)]
    enum StatusResult<'a> {
        Show {
            id: &'a str,
            status: &'a str,
        },
        Change {
            id: &'a str,
            outcome: &'a str,
            #[serde(skip_serializing_if = "Option::is_none")]
            from: Option<String>,
            #[serde(skip_serializing_if = "Option::is_none")]
            to: Option<String>,
        },
    }
    let r = match &view {
        View::Shown { status } => StatusResult::Show {
            id: canonical_id,
            status,
        },
        View::Changed { from, to } => StatusResult::Change {
            id: canonical_id,
            outcome: "status_changed",
            from: Some(from.as_str().to_string()),
            to: Some(to.as_str().to_string()),
        },
        View::NoOp => StatusResult::Change {
            id: canonical_id,
            outcome: "noop",
            from: None,
            to: None,
        },
        View::NotFound => unreachable!(),
    };
    render_structured(&r, output_fmt);
}