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),
}
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,
)
}
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,
)
})
}
fn entity_ref_prefix(raw: &str) -> &str {
raw.split_once('-').map(|(p, _)| p).unwrap_or(raw)
}