tix-tracker 0.10.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
//! Pre-write validation for the pure template resolver
//! (`contracts/resolver.md`, data-model §6).
//!
//! Nothing here writes anything: every function takes immutable references and
//! returns the complete, ordered list of [`TemplateDiagnostic`] values. That is
//! what lets [`super::resolver::resolve`] collect **every** missing prompt and
//! unknown reference in one pass instead of failing on the first problem
//! (C-005).
//!
//! References are validated through the *existing* registries and model
//! vocabulary — [`crate::commands::create::planner_for`] for cycles and
//! milestones, [`RelationKind`] for relation kinds — never a second schema
//! (research R5). Labels, assignees and files are free-form under the current
//! model, so for them only an explicit `required` membership check runs.

use std::fmt;
use std::str::FromStr;

use crate::commands::create::planner_for;
use crate::config::Config;
use crate::relationships::RelationKind;

use super::registry::{CREATE_FIELDS, ReferenceKind, Template};
use super::resolver::MergedFields;

/// The stable category of a resolution diagnostic.
///
/// A caller may branch on the category without parsing a message, which keeps
/// CLI/TUI/MCP error handling equivalent (contract §Equivalence) while the
/// prose stays human-readable.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum DiagnosticKind {
    /// No template with the selected stable ID exists.
    UnknownTemplate,
    /// The selected scope is malformed or names an unknown board/team.
    InvalidScope,
    /// The selected scope is well-formed but is not the template's scope.
    ScopeMismatch,
    /// A required prompt (or the required title) has no value.
    MissingRequired,
    /// A declared reference names nothing in the existing registries.
    UnknownReference,
    /// A provided value is not valid for its create field.
    InvalidValue,
}

impl DiagnosticKind {
    /// The stable machine-readable spelling.
    #[must_use]
    pub fn as_str(self) -> &'static str {
        match self {
            DiagnosticKind::UnknownTemplate => "unknown-template",
            DiagnosticKind::InvalidScope => "invalid-scope",
            DiagnosticKind::ScopeMismatch => "scope-mismatch",
            DiagnosticKind::MissingRequired => "missing-required",
            DiagnosticKind::UnknownReference => "unknown-reference",
            DiagnosticKind::InvalidValue => "invalid-value",
        }
    }
}

impl fmt::Display for DiagnosticKind {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

/// One actionable, field-aware resolution diagnostic.
///
/// `field` names the create field (`title`, `cycles`, …) or the declaration
/// that failed (`references[0].value`, `scope`, `template`), so a surface can
/// point at the exact input a user must fix.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TemplateDiagnostic {
    kind: DiagnosticKind,
    field: String,
    message: String,
}

impl TemplateDiagnostic {
    /// Build a diagnostic. Crate-internal: the resolver owns the vocabulary.
    pub(crate) fn new(
        kind: DiagnosticKind,
        field: impl Into<String>,
        message: impl Into<String>,
    ) -> Self {
        Self {
            kind,
            field: field.into(),
            message: message.into(),
        }
    }

    /// The stable diagnostic category.
    #[must_use]
    pub fn kind(&self) -> DiagnosticKind {
        self.kind
    }

    /// The field or declaration the diagnostic is about.
    #[must_use]
    pub fn field(&self) -> &str {
        &self.field
    }

    /// The human-readable, actionable message.
    #[must_use]
    pub fn message(&self) -> &str {
        &self.message
    }
}

impl fmt::Display for TemplateDiagnostic {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}: {}", self.field, self.message)
    }
}

/// Every required prompt (and the required title) that has no resolved value,
/// in [`CREATE_FIELDS`] order.
///
/// The title is a required field of the normal create model, so a template
/// that neither defaults nor prompts for it still needs the caller to supply
/// one; reporting that here keeps the failure pre-write and identical in
/// wording across surfaces.
pub(crate) fn missing_required(
    template: &Template,
    merged: &MergedFields,
) -> Vec<TemplateDiagnostic> {
    let mut out = Vec::new();
    for field in CREATE_FIELDS {
        let required = *field == "title"
            || template
                .prompts
                .iter()
                .any(|prompt| prompt.key == *field && prompt.required);
        if required && merged.is_blank(field) {
            let message = if *field == "title" {
                "a title is required; set it directly or with a required prompt/default".to_string()
            } else {
                format!("required prompt `{field}` has no value")
            };
            out.push(TemplateDiagnostic::new(
                DiagnosticKind::MissingRequired,
                *field,
                message,
            ));
        }
    }
    out
}

/// A value that cannot be represented in the normal create request.
///
/// Currently only `priority`: the create core refuses anything outside
/// `''`/`low`/`medium`/`high`, and the diagnostic reuses that exact message so
/// the template path and `tix create --priority` cannot drift.
pub(crate) fn invalid_values(merged: &MergedFields) -> Vec<TemplateDiagnostic> {
    let mut out = Vec::new();
    if let Some(raw) = merged.get("priority") {
        let trimmed = raw.trim();
        if !trimmed.is_empty()
            && let Err(error) = crate::commands::parse_priority(trimmed)
        {
            out.push(TemplateDiagnostic::new(
                DiagnosticKind::InvalidValue,
                "priority",
                error.to_string(),
            ));
        }
    }
    out
}

/// Validate the template's typed references against the existing registries.
///
/// * `cycle`/`milestone` values must exist in the [`Planner`](crate::planning::Planner)
///   registry, with the same wording `validate_planning_inputs` uses.
/// * `relationship` values must name a [`RelationKind`].
/// * `label`/`assignee`/`file` values are free-form under the current model, so
///   no registry lookup runs for them.
///
/// A `required` reference additionally asserts that its value is actually
/// present in the merged request's corresponding field (`labels`, `assignee`,
/// `files`, `cycles`, `milestones`), which is what "absence rejects
/// resolution" means for a declaration that always carries a value.
pub(crate) fn validate_references(
    config: &Config,
    template: &Template,
    merged: &MergedFields,
) -> Vec<TemplateDiagnostic> {
    let planner = planner_for(config);
    let mut out = Vec::new();
    for (index, reference) in template.references.iter().enumerate() {
        let field = format!("references[{index}].value");
        let resolved = match reference.kind {
            ReferenceKind::Label | ReferenceKind::Assignee | ReferenceKind::File => true,
            ReferenceKind::Cycle => {
                if planner.cycle(&reference.value).is_some() {
                    true
                } else {
                    let known = planner.cycle_names();
                    out.push(TemplateDiagnostic::new(
                        DiagnosticKind::UnknownReference,
                        &field,
                        unknown_message("cycle", &reference.value, &known),
                    ));
                    false
                }
            }
            ReferenceKind::Milestone => {
                if planner.milestone(&reference.value).is_ok() {
                    true
                } else {
                    let known = planner.milestone_names().unwrap_or_default();
                    out.push(TemplateDiagnostic::new(
                        DiagnosticKind::UnknownReference,
                        &field,
                        unknown_message("milestone", &reference.value, &known),
                    ));
                    false
                }
            }
            ReferenceKind::Relationship => {
                if let Err(error) = RelationKind::from_str(&reference.value) {
                    out.push(TemplateDiagnostic::new(
                        DiagnosticKind::UnknownReference,
                        &field,
                        error.to_string(),
                    ));
                    false
                } else {
                    true
                }
            }
        };
        if !resolved || !reference.required {
            continue;
        }
        if let Some(target) = reference_field(reference.kind)
            && !contains_value(merged, target, &reference.value)
        {
            out.push(TemplateDiagnostic::new(
                DiagnosticKind::MissingRequired,
                &field,
                format!(
                    "required {} `{}` is missing from `{target}`",
                    kind_label(reference.kind),
                    reference.value
                ),
            ));
        }
    }
    out
}

/// The shared "unknown X — valid X: …" message shape used by the create core.
fn unknown_message(kind: &str, value: &str, known: &[String]) -> String {
    let list = if known.is_empty() {
        "none configured".to_string()
    } else {
        known.join(", ")
    };
    format!("unknown {kind} `{value}` — valid {kind}s: {list}")
}

/// The create field a reference kind asserts membership in, when it has one.
fn reference_field(kind: ReferenceKind) -> Option<&'static str> {
    match kind {
        ReferenceKind::Label => Some("labels"),
        ReferenceKind::Assignee => Some("assignee"),
        ReferenceKind::File => Some("files"),
        ReferenceKind::Cycle => Some("cycles"),
        ReferenceKind::Milestone => Some("milestones"),
        ReferenceKind::Relationship => None,
    }
}

/// The lowercase label used in a reference diagnostic.
fn kind_label(kind: ReferenceKind) -> &'static str {
    match kind {
        ReferenceKind::Label => "label",
        ReferenceKind::Assignee => "assignee",
        ReferenceKind::File => "file",
        ReferenceKind::Cycle => "cycle",
        ReferenceKind::Milestone => "milestone",
        ReferenceKind::Relationship => "relationship",
    }
}

/// Whether `needle` is present in `field`'s resolved value, using the same CSV
/// rule the create core applies (a scalar field compares exactly).
fn contains_value(merged: &MergedFields, field: &str, needle: &str) -> bool {
    let needle = needle.trim();
    match merged.get(field) {
        None => false,
        Some(raw) if field == "assignee" => raw.trim() == needle,
        Some(raw) => crate::commands::parse_csv_list(raw)
            .iter()
            .any(|value| value == needle),
    }
}