Skip to main content

tix/templates/
resolver.rs

1//! The pure template resolver: registry + explicit scope + user input →
2//! **one normal create request** (`contracts/resolver.md`, data-model §6).
3//!
4//! Resolution is a pure function. It borrows the [`Config`] (which owns the
5//! [`TemplateRegistry`]), reads nothing else, and returns a
6//! [`TemplateResolution`] — the created-by-nobody request plus provenance, or
7//! the complete set of [`TemplateDiagnostic`] values that prevented one. It
8//! never reads `stdin`, launches an editor, mutates the registry, or writes an
9//! issue (C-005).
10//!
11//! # Merge policy (deterministic)
12//!
13//! For every field in [`CREATE_FIELDS`], in declaration order, the resolved
14//! value is the first of:
15//!
16//! 1. the user's input value, even when blank (an explicit override wins);
17//! 2. the field's prompt `default`, when a prompt declares one;
18//! 3. the template's `defaults` value.
19//!
20//! A list-valued field (`labels`, `files`, `cycles`, `milestones`,
21//! `related_issues`) is replaced wholesale rather than appended to, using the
22//! same comma-separated rule the create core applies. Defaults are applied
23//! before user input; user input reaches only documented create fields because
24//! [`TemplateInput`] *is* the create-field shape.
25//!
26//! # Request shape
27//!
28//! [`CreateRequest`] has exactly the parameters of
29//! [`crate::commands::create::create`], so applying a resolution is a direct
30//! call into the shared create core — there is no second issue schema and no
31//! second writer (C-001/C-007/NFR-002). Status-column and date validation stay
32//! in that core, which runs them **before** `Store::create`; this module does
33//! not restate them, so the two paths cannot drift.
34
35use std::fmt;
36
37use crate::config::Config;
38use crate::model::{Priority, Status};
39
40use super::registry::{
41    CREATE_FIELDS, Template, TemplateDefaults, TemplateScope, TemplateScopeKind,
42};
43use super::validation::{self, DiagnosticKind, TemplateDiagnostic};
44
45/// Which template to resolve and the scope the caller explicitly selected.
46///
47/// Both are required: the ID is immutable identity (C-002) and the scope is an
48/// explicit, visible selection (C-003/FR-005). A display name is never identity
49/// — see [`resolve`].
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct TemplateSelection {
52    /// The stable template ID.
53    pub id: String,
54    /// The scope the caller selected; must equal the template's own scope.
55    pub scope: TemplateScope,
56}
57
58impl TemplateSelection {
59    /// Build a selection from a stable ID and an explicit scope.
60    pub fn new(id: impl Into<String>, scope: TemplateScope) -> Self {
61        Self {
62            id: id.into(),
63            scope,
64        }
65    }
66}
67
68/// The caller's normal create-field values.
69///
70/// This mirrors the shared create arguments one-for-one — the same
71/// comma-separated string shape the CLI passes — so a surface cannot smuggle a
72/// field the create model does not understand. Every field is optional: an
73/// absent field means "not provided" (the template default, if any, applies),
74/// while `Some("")` is an explicit blank override.
75#[derive(Debug, Clone, Default, PartialEq, Eq)]
76pub struct TemplateInput {
77    /// Issue title.
78    pub title: Option<String>,
79    /// Issue body (Markdown).
80    pub body: Option<String>,
81    /// Status column.
82    pub status: Option<String>,
83    /// Priority (`''`/`low`/`medium`/`high`).
84    pub priority: Option<String>,
85    /// Assignee identity.
86    pub assignee: Option<String>,
87    /// Comma-separated labels.
88    pub labels: Option<String>,
89    /// Comma-separated file paths.
90    pub files: Option<String>,
91    /// Estimate.
92    pub estimate: Option<String>,
93    /// Comma-separated cycle names.
94    pub cycles: Option<String>,
95    /// Comma-separated milestone names.
96    pub milestones: Option<String>,
97    /// Reviewer.
98    pub reviewer: Option<String>,
99    /// Start date (`YYYY-MM-DD`).
100    pub start_date: Option<String>,
101    /// Due date (`YYYY-MM-DD`).
102    pub due_date: Option<String>,
103    /// Parent selector.
104    pub parent: Option<String>,
105    /// Comma-separated cross-repository references.
106    pub related_issues: Option<String>,
107    /// Issue type (the create core's `type`).
108    pub type_: Option<String>,
109}
110
111impl TemplateInput {
112    /// The raw value for a create-field key, mapping the TOML key `type` onto
113    /// the Rust field `type_`.
114    #[must_use]
115    pub fn get(&self, field: &str) -> Option<&str> {
116        match field {
117            "title" => self.title.as_deref(),
118            "body" => self.body.as_deref(),
119            "status" => self.status.as_deref(),
120            "priority" => self.priority.as_deref(),
121            "assignee" => self.assignee.as_deref(),
122            "labels" => self.labels.as_deref(),
123            "files" => self.files.as_deref(),
124            "estimate" => self.estimate.as_deref(),
125            "cycles" => self.cycles.as_deref(),
126            "milestones" => self.milestones.as_deref(),
127            "reviewer" => self.reviewer.as_deref(),
128            "start_date" => self.start_date.as_deref(),
129            "due_date" => self.due_date.as_deref(),
130            "parent" => self.parent.as_deref(),
131            "related_issues" => self.related_issues.as_deref(),
132            "type" => self.type_.as_deref(),
133            _ => None,
134        }
135    }
136}
137
138/// The normal create request a resolution produced.
139///
140/// The fields are exactly the parameters of
141/// [`crate::commands::create::create`], in the same order and with the same
142/// types; a caller destructures this straight into the shared core. Nothing
143/// here is derived from a template-specific schema — a template can only ever
144/// pre-fill these documented fields.
145#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct CreateRequest {
147    /// `create(..., title, ...)`: always non-blank on a successful resolution.
148    pub title: String,
149    /// `create(..., body, ...)`.
150    pub body: Option<String>,
151    /// `create(..., status, ...)`.
152    pub status: Option<Status>,
153    /// `create(..., priority, ...)`.
154    pub priority: Option<Priority>,
155    /// `create(..., assignee, ...)`.
156    pub assignee: Option<String>,
157    /// `create(..., labels, ...)`, comma-separated.
158    pub labels: Option<String>,
159    /// `create(..., files, ...)`, comma-separated.
160    pub files: Option<String>,
161    /// `create(..., estimate, ...)`.
162    pub estimate: Option<String>,
163    /// `create(..., cycles, ...)`, comma-separated.
164    pub cycles: Option<String>,
165    /// `create(..., milestones, ...)`, comma-separated.
166    pub milestones: Option<String>,
167    /// `create(..., reviewer, ...)`.
168    pub reviewer: Option<String>,
169    /// `create(..., start_date, ...)`.
170    pub start_date: Option<String>,
171    /// `create(..., due_date, ...)`.
172    pub due_date: Option<String>,
173    /// `create(..., parent, ...)`.
174    pub parent: Option<String>,
175    /// `create(..., related_issues, ...)`, comma-separated.
176    pub related_issues: Option<String>,
177    /// `create(..., type_, ...)`.
178    pub type_: Option<String>,
179}
180
181/// The pure result of applying a template to user input (data-model §6).
182///
183/// `request` is present **iff** `diagnostics` is empty: a failed resolution
184/// never yields a partial request, and the template/config it read is
185/// untouched.
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct TemplateResolution {
188    template_id: String,
189    scope: TemplateScope,
190    provided_fields: Vec<String>,
191    diagnostics: Vec<TemplateDiagnostic>,
192    request: Option<CreateRequest>,
193}
194
195impl TemplateResolution {
196    /// The selected template's stable ID (provenance, even on failure).
197    #[must_use]
198    pub fn template_id(&self) -> &str {
199        &self.template_id
200    }
201
202    /// The resolved (explicitly selected) scope.
203    #[must_use]
204    pub fn scope(&self) -> &TemplateScope {
205        &self.scope
206    }
207
208    /// The create fields that received a value, in [`CREATE_FIELDS`] order.
209    #[must_use]
210    pub fn provided_fields(&self) -> &[String] {
211        &self.provided_fields
212    }
213
214    /// Every validation diagnostic, in deterministic order.
215    ///
216    /// Non-empty exactly when [`Self::request`] is `None`.
217    #[must_use]
218    pub fn diagnostics(&self) -> &[TemplateDiagnostic] {
219        &self.diagnostics
220    }
221
222    /// The normal create request, present only on success.
223    #[must_use]
224    pub fn request(&self) -> Option<&CreateRequest> {
225        self.request.as_ref()
226    }
227
228    /// Whether resolution succeeded and a request is available.
229    #[must_use]
230    pub fn is_ok(&self) -> bool {
231        self.request.is_some()
232    }
233
234    /// Consume the resolution, yielding the request when it succeeded.
235    #[must_use]
236    pub fn into_request(self) -> Option<CreateRequest> {
237        self.request
238    }
239
240    /// A failed (or early-rejected) resolution.
241    fn rejected(
242        template_id: &str,
243        scope: &TemplateScope,
244        diagnostics: Vec<TemplateDiagnostic>,
245    ) -> Self {
246        Self {
247            template_id: template_id.to_string(),
248            scope: scope.clone(),
249            provided_fields: Vec::new(),
250            diagnostics,
251            request: None,
252        }
253    }
254}
255
256impl fmt::Display for TemplateResolution {
257    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258        if self.is_ok() {
259            write!(
260                f,
261                "template `{}` at scope `{}` resolved",
262                self.template_id, self.scope
263            )
264        } else {
265            write!(
266                f,
267                "template `{}` at scope `{}` did not resolve ({} diagnostic(s))",
268                self.template_id,
269                self.scope,
270                self.diagnostics.len()
271            )
272        }
273    }
274}
275
276/// Resolve `selection` against the registry in `config`, merging `input`.
277///
278/// Pure: no I/O, no registry mutation, no prompting. A successful result
279/// carries a [`CreateRequest`] and gives the caller one normal create request;
280/// a failed result carries **all** actionable diagnostics — every missing
281/// required prompt, every unknown reference — and no request.
282///
283/// Rejections, in order:
284///
285/// * a blank selection ID, or an ID the registry does not contain (a display
286///   name is diagnosed with the stable ID to use instead — names are labels,
287///   not identity);
288/// * an explicit scope that is malformed or names an unknown board/team;
289/// * an explicit scope that differs from the template's own scope;
290/// * a structurally invalid template (a safety net; the registry validates on
291///   load/save);
292/// * missing required prompts/title, invalid values, unknown references.
293#[must_use]
294pub fn resolve(
295    config: &Config,
296    selection: &TemplateSelection,
297    input: &TemplateInput,
298) -> TemplateResolution {
299    let id = selection.id.trim().to_string();
300    if id.is_empty() {
301        return TemplateResolution::rejected(
302            &id,
303            &selection.scope,
304            vec![TemplateDiagnostic::new(
305                DiagnosticKind::UnknownTemplate,
306                "template_id",
307                "a template id is required (a display name is not identity)",
308            )],
309        );
310    }
311    let Some(template) = config.issue_templates.get(&id) else {
312        return TemplateResolution::rejected(
313            &id,
314            &selection.scope,
315            vec![TemplateDiagnostic::new(
316                DiagnosticKind::UnknownTemplate,
317                "template_id",
318                unknown_template_message(config, &id),
319            )],
320        );
321    };
322    // Safety net: the registry validates on load and save, but resolution is
323    // the last gate before a write, so a structurally broken template is
324    // diagnosed here rather than merged.
325    if let Err(error) = template.validate(&id, config) {
326        return TemplateResolution::rejected(
327            &id,
328            &selection.scope,
329            vec![TemplateDiagnostic::new(
330                DiagnosticKind::InvalidValue,
331                "template",
332                error.to_string(),
333            )],
334        );
335    }
336    let scope_diagnostics = scope_diagnostics(config, &id, selection, template);
337    if !scope_diagnostics.is_empty() {
338        return TemplateResolution::rejected(&id, &selection.scope, scope_diagnostics);
339    }
340    let merged = merge(template, input);
341    let mut diagnostics = validation::missing_required(template, &merged);
342    diagnostics.extend(validation::invalid_values(&merged));
343    diagnostics.extend(validation::validate_references(config, template, &merged));
344    let provided_fields = merged
345        .iter()
346        .map(|(field, _)| field.to_string())
347        .collect::<Vec<_>>();
348    let request = diagnostics.is_empty().then(|| build_request(&merged));
349    TemplateResolution {
350        template_id: id,
351        scope: selection.scope.clone(),
352        provided_fields,
353        diagnostics,
354        request,
355    }
356}
357
358/// The diagnostic for an ID the registry does not contain, naming the stable
359/// ID when the caller passed a display name.
360fn unknown_template_message(config: &Config, id: &str) -> String {
361    if let Some((stable, _)) = config
362        .issue_templates
363        .iter()
364        .find(|(_, template)| template.display_name("") == id)
365    {
366        return format!("`{id}` is a display name, not a stable id; use `{stable}`");
367    }
368    let known = config.issue_templates.ids().collect::<Vec<_>>();
369    let list = if known.is_empty() {
370        "none configured".to_string()
371    } else {
372        known.join(", ")
373    };
374    format!("unknown template id `{id}` — known ids: {list}")
375}
376
377/// Validate the explicit selection scope, then require it to be the template's.
378fn scope_diagnostics(
379    config: &Config,
380    id: &str,
381    selection: &TemplateSelection,
382    template: &Template,
383) -> Vec<TemplateDiagnostic> {
384    let requested = &selection.scope;
385    let mut out = Vec::new();
386    let qualifier = requested.qualifier().unwrap_or_default().trim();
387    match requested.kind() {
388        TemplateScopeKind::Repository => {}
389        TemplateScopeKind::Board if qualifier.is_empty() => out.push(TemplateDiagnostic::new(
390            DiagnosticKind::InvalidScope,
391            "scope",
392            "a board scope must name its board as `board:<name>`",
393        )),
394        TemplateScopeKind::Board => {
395            if !config.kanban.configurations.contains_key(qualifier) {
396                out.push(TemplateDiagnostic::new(
397                    DiagnosticKind::InvalidScope,
398                    "scope",
399                    format!("references unknown board `{qualifier}`"),
400                ));
401            }
402        }
403        TemplateScopeKind::Team if qualifier.is_empty() => out.push(TemplateDiagnostic::new(
404            DiagnosticKind::InvalidScope,
405            "scope",
406            "a team scope must name its team as `team:<label>`",
407        )),
408        TemplateScopeKind::Team => {
409            if !config.teams.contains_key(qualifier) {
410                out.push(TemplateDiagnostic::new(
411                    DiagnosticKind::InvalidScope,
412                    "scope",
413                    format!("references unknown team `{qualifier}`"),
414                ));
415            }
416        }
417    }
418    if out.is_empty() && requested != &template.scope {
419        out.push(TemplateDiagnostic::new(
420            DiagnosticKind::ScopeMismatch,
421            "scope",
422            format!(
423                "template `{id}` is scoped to `{}`, not `{}`",
424                template.scope, requested
425            ),
426        ));
427    }
428    out
429}
430
431/// The merged view of every create field: the value each field resolved to,
432/// in [`CREATE_FIELDS`] order. Fields with no value are absent.
433#[derive(Debug, Clone, Default, PartialEq, Eq)]
434pub(crate) struct MergedFields {
435    entries: Vec<(&'static str, String)>,
436}
437
438impl MergedFields {
439    /// Record `field`'s resolved value. Callers insert in [`CREATE_FIELDS`]
440    /// order, so the entries stay ordered.
441    fn set(&mut self, field: &'static str, value: String) {
442        self.entries.push((field, value));
443    }
444
445    /// The resolved value for `field`, if any. A blank string is a value: an
446    /// explicit blank override.
447    pub(crate) fn get(&self, field: &str) -> Option<&str> {
448        self.entries
449            .iter()
450            .find(|(key, _)| *key == field)
451            .map(|(_, value)| value.as_str())
452    }
453
454    /// Whether `field` resolved to nothing, or to a blank string.
455    pub(crate) fn is_blank(&self, field: &str) -> bool {
456        self.get(field).is_none_or(|value| value.trim().is_empty())
457    }
458
459    /// The resolved fields and their values, in order.
460    pub(crate) fn iter(&self) -> impl Iterator<Item = (&'static str, &str)> {
461        self.entries
462            .iter()
463            .map(|(field, value)| (*field, value.as_str()))
464    }
465}
466
467/// Merge defaults, prompt defaults and user input per the module's documented
468/// precedence, in [`CREATE_FIELDS`] order.
469fn merge(template: &Template, input: &TemplateInput) -> MergedFields {
470    let mut merged = MergedFields::default();
471    for field in CREATE_FIELDS {
472        let value = input
473            .get(field)
474            .map(str::to_string)
475            .or_else(|| prompt_default(template, field))
476            .or_else(|| defaults_value(&template.defaults, field));
477        if let Some(value) = value {
478            merged.set(field, value);
479        }
480    }
481    merged
482}
483
484/// A prompt's own `default` for `field`, when a prompt declares one.
485fn prompt_default(template: &Template, field: &str) -> Option<String> {
486    template
487        .prompts
488        .iter()
489        .find(|prompt| prompt.key == field)
490        .and_then(|prompt| non_blank(&prompt.default))
491}
492
493/// The template defaults value for `field`, as the create core's argument
494/// shape (list fields join with the shared comma rule).
495fn defaults_value(defaults: &TemplateDefaults, field: &str) -> Option<String> {
496    let scalar = match field {
497        "title" => &defaults.title,
498        "body" => &defaults.body,
499        "status" => &defaults.status,
500        "priority" => &defaults.priority,
501        "assignee" => &defaults.assignee,
502        "estimate" => &defaults.estimate,
503        "reviewer" => &defaults.reviewer,
504        "start_date" => &defaults.start_date,
505        "due_date" => &defaults.due_date,
506        "parent" => &defaults.parent,
507        "type" => &defaults.type_,
508        _ => return list_default(defaults, field),
509    };
510    scalar.clone()
511}
512
513/// The comma-joined value of a list default, or `None` when empty.
514fn list_default(defaults: &TemplateDefaults, field: &str) -> Option<String> {
515    let values = match field {
516        "labels" => &defaults.labels,
517        "files" => &defaults.files,
518        "cycles" => &defaults.cycles,
519        "milestones" => &defaults.milestones,
520        "related_issues" => &defaults.related_issues,
521        _ => return None,
522    };
523    let joined = values
524        .iter()
525        .filter(|value| !value.trim().is_empty())
526        .cloned()
527        .collect::<Vec<_>>()
528        .join(",");
529    if joined.is_empty() {
530        None
531    } else {
532        Some(joined)
533    }
534}
535
536/// A prompt default, filtering a blank one out (the registry refuses these).
537fn non_blank(value: &Option<String>) -> Option<String> {
538    value
539        .as_deref()
540        .map(str::trim)
541        .filter(|value| !value.is_empty())
542        .map(str::to_string)
543}
544
545/// The resolved field's value, as `Option<String>`.
546impl MergedFields {
547    fn owned(&self, field: &str) -> Option<String> {
548        self.get(field).map(str::to_string)
549    }
550}
551
552/// Build the normal create request from the merged fields.
553///
554/// Priority is parsed with the shared strict parser; a failure is already a
555/// diagnostic, so `.ok()` here cannot silently drop a bad value on a successful
556/// resolution.
557fn build_request(merged: &MergedFields) -> CreateRequest {
558    CreateRequest {
559        title: merged.get("title").unwrap_or_default().trim().to_string(),
560        body: merged.owned("body"),
561        status: merged.owned("status").map(Status::new),
562        priority: merged
563            .get("priority")
564            .and_then(|raw| crate::commands::parse_priority(raw.trim()).ok()),
565        assignee: merged.owned("assignee"),
566        labels: merged.owned("labels"),
567        files: merged.owned("files"),
568        estimate: merged.owned("estimate"),
569        cycles: merged.owned("cycles"),
570        milestones: merged.owned("milestones"),
571        reviewer: merged.owned("reviewer"),
572        start_date: merged.owned("start_date"),
573        due_date: merged.owned("due_date"),
574        parent: merged.owned("parent"),
575        related_issues: merged.owned("related_issues"),
576        type_: merged.owned("type"),
577    }
578}