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::model::issue::{IssueLink, IssueRelationship};
use crate::domain::usecases::clock::Clock;
use crate::domain::usecases::issue::create_issue;
use crate::infra::driven::clock::SystemClock;
use crate::infra::driving::cli::errors::{die1, CliError};
use crate::infra::driving::cli::helpers::{read_body_from_stdin_or_editor, ISSUE_BODY_TEMPLATE};
use crate::infra::driving::cli::theme;
use crate::infra::driving::cli::{render_structured, Context};

pub(super) fn subcommand() -> Command {
    let mut new_cmd = Command::new("new")
        .about("Create a new issue")
        .arg(
            Arg::new("title")
                .required(true)
                .num_args(1..)
                .value_name("TITLE")
                .help("Issue title (one or more words)"),
        )
        .arg(
            Arg::new("tag")
                .long("tag")
                .help(
                    "Add a tag (repeatable). Form: `name` or `key:value`. \
                     Validation against [tags.<key>] descriptors is deferred to `cartu check`.",
                )
                .value_name("TAG")
                .action(clap::ArgAction::Append),
        );
    for rel in IssueRelationship::user_writable() {
        let flag: &'static str = rel.as_str();
        new_cmd = new_cmd.arg(
            Arg::new(flag)
                .long(flag)
                .value_name("ID")
                .help(help_for_new_flag(rel)),
        );
    }
    new_cmd
}

pub(super) fn execute(sub: &ArgMatches, ctx: &Context<'_>) {
    let output_fmt = ctx.output_fmt;
    let title = crate::infra::driving::cli::helpers::required_many(sub, "title")
        .collect::<Vec<_>>()
        .join(" ");
    let template = ISSUE_BODY_TEMPLATE;

    let extra_tags: Vec<crate::domain::model::tag::Tag> = sub
        .get_many::<String>("tag")
        .unwrap_or_default()
        .map(|s| {
            crate::domain::model::tag::Tag::new(s).unwrap_or_else(|e| {
                die1(
                    CliError::new(format!("invalid tag '{s}': {e}")).kind("validation"),
                    output_fmt,
                );
            })
        })
        .collect();

    let mut initial_links = crate::domain::model::issue::IssueLinks::new();
    for rel in IssueRelationship::user_writable() {
        if let Some(target_id) = sub.get_one::<String>(rel.as_str()) {
            let target =
                crate::domain::model::record_ref::IssueRef::new(target_id).unwrap_or_else(|e| {
                    die1(
                        CliError::new(format!("invalid target ID '{target_id}': {e}"))
                            .kind("validation"),
                        output_fmt,
                    );
                });
            initial_links.push(IssueLink {
                target,
                relationship: rel.clone(),
            });
        }
    }

    let typed_title = crate::domain::model::title::Title::new(&title).unwrap_or_else(|e| {
        die1(CliError::new(e.to_string()).kind("validation"), output_fmt);
    });
    let body = read_body_from_stdin_or_editor(template, output_fmt);
    let typed_body = crate::domain::model::body::Body::new(&body);
    let now = SystemClock.now();
    let repo = ctx.issue_repository();
    let id_gen = ctx.issue_id_generator();
    let initial_status = ctx
        .issues_statuses
        .resolve(ctx.issues_statuses.initial())
        .unwrap_or_else(|_| {
            crate::domain::model::status::Status::unresolved(ctx.issues_statuses.initial())
        });
    let issue = create_issue(
        &repo,
        &id_gen,
        typed_title,
        typed_body,
        now,
        initial_status,
        &extra_tags,
        initial_links,
    )
    .unwrap_or_else(|e| {
        die1(CliError::new(e.to_string()), output_fmt);
    });

    if output_fmt.is_structured() {
        render_structured(
            &crate::infra::driving::cli::issue_view::IssueView::from_issue(&issue),
            output_fmt,
        );
    } else {
        let slug = crate::infra::driven::fs::frontmatter::slugify(issue.title.as_str());
        println!(
            "{}",
            theme::success(&format!(
                "Created {}/{}-{}/index.md ({})",
                ctx.issues_dir().display(),
                issue.id.suffix(),
                slug,
                issue.id,
            ))
        );
    }
}

/// Per-variant help text for the `cartu issue new --<flag> <ID>` flag.
/// Hand-written to avoid the grammatical bug of suffixing every verb
/// with `s` (e.g. `blockss`, `parent-ofs`). Only called for
/// [`IssueRelationship::user_writable`] variants.
fn help_for_new_flag(rel: &IssueRelationship) -> &'static str {
    match rel {
        IssueRelationship::Blocks => "ID of the issue this new issue blocks",
        IssueRelationship::BlockedBy => "ID of the issue this new issue is blocked by",
        IssueRelationship::ParentOf => "ID of the issue this new issue is the parent of",
        IssueRelationship::ChildOf => "ID of the issue this new issue is the child of",
    }
}