cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
use crate::domain::model::issue::Issue;
use crate::domain::model::record_ref::IssueRef;

use super::repository::IssueRepository;

/// Outcome of [`resolve_issue`] /
/// [`crate::domain::usecases::decision_record::resolve_decision_record`].
///
/// Phase 4 (ADR-0022) added prefix matching as a third resolution path between
/// "exact canonical id" and "alias scan". When the prefix matches more than
/// one record, we return the candidate list instead of guessing.
#[derive(Debug)]
pub enum Resolved<T> {
    /// One unambiguous match.
    Found(T),
    /// Nothing matched.
    NotFound,
    /// The user input was a TSID prefix (3+ chars, suffix of `<KIND>-<text>`)
    /// shared by several records. The CLI should print the candidates and
    /// ask the user to disambiguate.
    AmbiguousPrefix { matches: Vec<String> },
}

/// Resolve a free-form reference to an issue: the canonical `id:` first,
/// then any string in `aliases:` (ADR-0022). Returns `Ok(None)` only if
/// nothing matches. See [`resolve_issue`] for the full outcome, including
/// ambiguous-prefix detection (phase 4).
pub fn find_issue_by_id_or_alias(
    repo: &dyn IssueRepository,
    raw: &str,
) -> anyhow::Result<Option<Issue>> {
    match resolve_issue(repo, raw)? {
        Resolved::Found(issue) => Ok(Some(issue)),
        Resolved::NotFound | Resolved::AmbiguousPrefix { .. } => Ok(None),
    }
}

/// Resolve a free-form reference into a typed outcome (ADR-0022 phase 4):
///
/// 1. Exact canonical id match (via [`IssueRepository::find_by_id`]).
/// 2. Unique TSID prefix match (suffix is 3+ chars, all canonical ids
///    starting with `<KIND>-<input-suffix>` are considered).
/// 3. Free-form alias scan over every record's `aliases:` block.
///
/// Returns `AmbiguousPrefix` if step 2 yields several candidates so the
/// caller can render an actionable error. Iterates via
/// [`IssueRepository::list`] for steps 2 and 3 — parse failures are out
/// of scope of resolution (you can't address a record that doesn't load).
pub fn resolve_issue(repo: &dyn IssueRepository, raw: &str) -> anyhow::Result<Resolved<Issue>> {
    // Step 1: exact canonical id.
    if let Ok(id) = raw.parse::<IssueRef>() {
        if let Some(found) = repo.find_by_id(&id)? {
            return Ok(Resolved::Found(found));
        }
    }

    // Step 2: TSID prefix matching. Active when the input is shaped
    // `<PREFIX>-<SUFFIX>` with `SUFFIX` of 3+ chars. The legacy-id
    // alias path runs after this on a miss, so historical refs like
    // `ISSUE-0042` still resolve when no TSID prefix matches.
    let issues = repo.list()?;
    if let Some((prefix, suffix)) = split_for_prefix_match(raw) {
        if suffix.len() >= 3 {
            let needle = format!("{prefix}-{}", suffix.to_ascii_uppercase());
            let mut hits = Vec::new();
            let mut hit_issues = Vec::new();
            for issue in &issues {
                let id_str = issue.id.as_str();
                if id_str.len() != needle.len() && id_str.to_ascii_uppercase().starts_with(&needle)
                {
                    hits.push(id_str.to_string());
                    hit_issues.push(issue.clone());
                }
            }
            match hits.len() {
                0 => {} // fall through to alias scan
                1 => return Ok(Resolved::Found(hit_issues.into_iter().next().unwrap())),
                _ => return Ok(Resolved::AmbiguousPrefix { matches: hits }),
            }
        }
    }

    // Step 3: alias scan.
    for issue in issues {
        if issue.aliases.iter().any(|a| a == raw) {
            return Ok(Resolved::Found(issue));
        }
    }
    Ok(Resolved::NotFound)
}

/// Split a raw `<PREFIX>-<SUFFIX>` ref for prefix matching. Returns `None`
/// when the input doesn't carry a `-` (a bare integer like `42` falls
/// through to the alias scan). Shared with
/// [`crate::domain::usecases::decision_record::resolve_decision_record`].
pub(crate) fn split_for_prefix_match(raw: &str) -> Option<(&str, &str)> {
    let pos = raw.rfind('-')?;
    Some((&raw[..pos], &raw[pos + 1..]))
}