cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! `cartu relates` — top-level cross-kind "see also" pointer.
//!
//! `cartu relates add A B` writes `B` into `A`'s `relates:` frontmatter
//! list. Asymmetric by design: a bidirectional pointer is two commands.
//! Both sides may be of any kind (decision record or issue), unlike
//! typed `links:` verbs which are kind-bound. See DDR-018QWJVHRH35B and
//! ISSUE-018P03NSC7VNQ.

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

use crate::domain::model::entity_ref::EntityRef;
use crate::domain::usecases::decision_record::DecisionRecordRepository;
use crate::domain::usecases::issue::{IssueRepository, Resolved};
use crate::infra::driving::cli::errors::{die1, CliError};
use crate::infra::driving::cli::Context;

pub(in super::super) fn subcommand() -> Command {
    Command::new("relates")
        .about("Add, remove, or list cross-kind 'see also' pointers")
        .long_about(
            "Cross-kind 'see also' pointers stored on the source's \
             frontmatter (`relates:`). Asymmetric by design: declare both \
             directions if you want bidirectional. Both sides may be of \
             any kind (decision record or issue).",
        )
        .subcommand_required(true)
        .arg_required_else_help(true)
        .subcommand(
            Command::new("add")
                .about("Add a 'see also' pointer from <source> to <target>")
                .arg(source_arg())
                .arg(target_arg(true)),
        )
        .subcommand(
            Command::new("remove")
                .about("Remove a 'see also' pointer from <source> to <target>")
                .arg(source_arg())
                .arg(target_arg(true)),
        )
        .subcommand(
            Command::new("list")
                .about("List the outgoing 'see also' pointers of <source>")
                .arg(source_arg()),
        )
}

fn source_arg() -> Arg {
    Arg::new("source")
        .value_name("SOURCE")
        .required(true)
        .help("ID of the entry that holds the pointer")
}

fn target_arg(required: bool) -> Arg {
    Arg::new("target")
        .value_name("TARGET")
        .required(required)
        .help("ID of the entry pointed at")
}

pub(in super::super) fn execute(matches: &ArgMatches, ctx: &Context<'_>) {
    match matches.subcommand() {
        Some(("add", sub)) => {
            let (source, target) = source_target(sub, ctx);
            if source == target {
                die1(
                    CliError::new(format!(
                        "self-reference rejected: '{source}' cannot relate to itself"
                    ))
                    .kind("validation"),
                    ctx.output_fmt,
                );
            }
            execute_add(source, target, ctx);
        }
        Some(("remove", sub)) => {
            let (source, target) = source_target(sub, ctx);
            execute_remove(source, target, ctx);
        }
        Some(("list", sub)) => {
            let source = sub
                .get_one::<String>("source")
                .map(String::as_str)
                .unwrap_or("");
            execute_list(source, ctx);
        }
        _ => unreachable!(),
    }
}

fn source_target<'a>(sub: &'a ArgMatches, ctx: &Context<'_>) -> (&'a str, &'a str) {
    let source = sub
        .get_one::<String>("source")
        .map(String::as_str)
        .unwrap_or("");
    let target = match sub.get_one::<String>("target") {
        Some(t) => t.as_str(),
        None => die1(
            CliError::new("missing TARGET argument").kind("validation"),
            ctx.output_fmt,
        ),
    };
    (source, target)
}

fn execute_add(source: &str, target: &str, ctx: &Context<'_>) {
    let target_ref = parse_target(target, ctx);
    match find_source(source, ctx) {
        SourceEntry::DecisionRecord { kind_index, record } => {
            let new = match record.relates().with(target_ref) {
                crate::domain::model::relates::RelatesAddition::Unchanged => return,
                crate::domain::model::relates::RelatesAddition::Added { list, .. } => list,
            };
            let updated = record.with_relates(new);
            let kind_cfg = &ctx.config().decision_kinds[kind_index];
            let repo = ctx.decision_record_repository(kind_cfg);
            if let Err(e) = repo.save(&updated) {
                die1(
                    CliError::new(format!("failed to save record: {e}")).kind("io"),
                    ctx.output_fmt,
                );
            }
        }
        SourceEntry::Issue(issue) => {
            let new = match issue.relates().with(target_ref) {
                crate::domain::model::relates::RelatesAddition::Unchanged => return,
                crate::domain::model::relates::RelatesAddition::Added { list, .. } => list,
            };
            let updated = issue.with_relates(new);
            let repo = ctx.issue_repository();
            if let Err(e) = repo.save(&updated) {
                die1(
                    CliError::new(format!("failed to save issue: {e}")).kind("io"),
                    ctx.output_fmt,
                );
            }
        }
    }
}

fn execute_remove(source: &str, target: &str, ctx: &Context<'_>) {
    let target_ref = parse_target(target, ctx);
    match find_source(source, ctx) {
        SourceEntry::DecisionRecord { kind_index, record } => {
            let new = match record.relates().without(&target_ref) {
                crate::domain::model::relates::RelatesRemoval::Unchanged => return,
                crate::domain::model::relates::RelatesRemoval::Removed { list, .. } => list,
            };
            let updated = record.with_relates(new);
            let kind_cfg = &ctx.config().decision_kinds[kind_index];
            let repo = ctx.decision_record_repository(kind_cfg);
            if let Err(e) = repo.save(&updated) {
                die1(
                    CliError::new(format!("failed to save record: {e}")).kind("io"),
                    ctx.output_fmt,
                );
            }
        }
        SourceEntry::Issue(issue) => {
            let new = match issue.relates().without(&target_ref) {
                crate::domain::model::relates::RelatesRemoval::Unchanged => return,
                crate::domain::model::relates::RelatesRemoval::Removed { list, .. } => list,
            };
            let updated = issue.with_relates(new);
            let repo = ctx.issue_repository();
            if let Err(e) = repo.save(&updated) {
                die1(
                    CliError::new(format!("failed to save issue: {e}")).kind("io"),
                    ctx.output_fmt,
                );
            }
        }
    }
}

fn execute_list(source: &str, ctx: &Context<'_>) {
    match find_source(source, ctx) {
        SourceEntry::DecisionRecord { record, .. } => {
            for target in record.relates().iter() {
                println!("{target}");
            }
        }
        SourceEntry::Issue(issue) => {
            for target in issue.relates().iter() {
                println!("{target}");
            }
        }
    }
}

enum SourceEntry {
    DecisionRecord {
        kind_index: usize,
        record: crate::domain::model::decision_record::DecisionRecord,
    },
    Issue(crate::domain::model::issue::Issue),
}

/// Locate the source entry. Dispatches to the right repository based on the
/// id's prefix (e.g. `ADR-…` → DR kind whose `id_prefix` matches, `ISSUE-…`
/// → issue repo). Dies with a "not found" error if the relevant repository
/// has no record matching the input.
fn find_source(raw: &str, ctx: &Context<'_>) -> SourceEntry {
    let prefix = entity_ref_prefix(raw);
    let issue_prefix = ctx.issues_id_prefix().unwrap_or("ISSUE-");
    let issue_kind = issue_prefix.trim_end_matches('-');

    if prefix.eq_ignore_ascii_case(issue_kind) {
        let issue_repo = ctx.issue_repository();
        return match crate::domain::usecases::issue::resolve_issue(&issue_repo, raw) {
            Ok(Resolved::Found(issue)) => SourceEntry::Issue(issue),
            Ok(Resolved::AmbiguousPrefix { matches }) => die1(
                CliError::new(format!(
                    "ambiguous source prefix '{raw}' matches: {}. Disambiguate with a longer prefix.",
                    matches.join(", ")
                ))
                .kind("validation"),
                ctx.output_fmt,
            ),
            Ok(Resolved::NotFound) => die1(
                CliError::not_found(format!("source '{raw}'"), "issue list"),
                ctx.output_fmt,
            ),
            Err(e) => die1(CliError::new(e.to_string()).kind("io"), ctx.output_fmt),
        };
    }

    for (idx, kind_cfg) in ctx.config().decision_kinds.iter().enumerate() {
        let kind_prefix = kind_cfg
            .id_prefix
            .as_deref()
            .unwrap_or(&kind_cfg.kind)
            .trim_end_matches('-');
        if !prefix.eq_ignore_ascii_case(kind_prefix) {
            continue;
        }
        let repo = ctx.decision_record_repository(kind_cfg);
        return match crate::domain::usecases::decision_record::resolve_decision_record(
            &repo, raw,
        ) {
            Ok(Resolved::Found(record)) => SourceEntry::DecisionRecord {
                kind_index: idx,
                record,
            },
            Ok(Resolved::AmbiguousPrefix { matches }) => die1(
                CliError::new(format!(
                    "ambiguous source prefix '{raw}' matches: {}. Disambiguate with a longer prefix.",
                    matches.join(", ")
                ))
                .kind("validation"),
                ctx.output_fmt,
            ),
            Ok(Resolved::NotFound) => die1(
                CliError::not_found(format!("source '{raw}'"), &format!("{} list", kind_cfg.kind)),
                ctx.output_fmt,
            ),
            Err(e) => die1(CliError::new(e.to_string()).kind("io"), ctx.output_fmt),
        };
    }

    die1(
        CliError::new(format!("source '{raw}' has unknown kind prefix '{prefix}'"))
            .kind("validation"),
        ctx.output_fmt,
    )
}

/// Parse the target argument into an `EntityRef` without consulting any
/// repository. Existence is validated by `cartu check`, not at write time:
/// a `relates:` pointing at a missing entry is the same kind of dangling
/// reference that `check` already surfaces for typed `links:` targets.
fn parse_target(raw: &str, ctx: &Context<'_>) -> EntityRef {
    EntityRef::new(raw).unwrap_or_else(|e| {
        die1(
            CliError::new(format!("invalid target id '{raw}': {e}")).kind("validation"),
            ctx.output_fmt,
        )
    })
}

/// Return the leading kind prefix of `raw`, i.e. the substring before the
/// first `-`. For input without `-`, returns the whole string.
fn entity_ref_prefix(raw: &str) -> &str {
    raw.split_once('-').map(|(p, _)| p).unwrap_or(raw)
}