Skip to main content

tix/commands/
templates.rs

1//! The CLI adapter for the stable-ID template registry (WP04; mission
2//! `issue-form-templates-01M3C76V`).
3//!
4//! This module parses and renders — it owns **no** validation, **no** issue
5//! writing, and **no** template grammar of its own:
6//!
7//! - `tix template …` (management) is a thin wrapper over
8//!   [`crate::templates::TemplateService`], the sibling of the saved-views
9//!   service (`src/commands/view.rs`). Every accepted mutation is the service's;
10//!   every refusal is a field-aware config error the service already produces;
11//! - `tix create --template …` calls the **shared create core**
12//!   ([`crate::commands::create::create`]) after the pure resolver turns the
13//!   template plus the user's normal create flags into one normal create
14//!   request. There is no second writer (C-001/C-007/NFR-002).
15//!
16//! # What stays out of here
17//!
18//! The resolver never reads `stdin` or launches an editor, and the create core
19//! never reads `stdin` (C-005), so the template path is non-interactive by
20//! construction: a missing required prompt is a diagnostic, never a prompt.
21//! `--title` is therefore required on the template path — a template that
22//! defaults a title must supply it, because there is no prompt to fall back to.
23//!
24//! # Management persistence
25//!
26//! Management reads and writes the registry in the project's `tix.toml`
27//! through the existing atomic [`crate::config::Config::save`]. The loaded
28//! config is **not** absolutized — it is saved back exactly as loaded except
29//! for the registry the operation changed, so an unrelated path in the file
30//! round-trips byte-for-byte.
31
32use std::fmt::Write as _;
33use std::io::IsTerminal;
34use std::path::{Path, PathBuf};
35
36use crate::cli::TemplateAction;
37use crate::commands::create;
38use crate::commands::parse_csv_list;
39use crate::config::Config;
40use crate::error::{Result, TixError};
41use crate::storage::Store;
42use crate::templates::registry::{
43    CREATE_FIELDS, PromptKind, ReferenceKind, TemplateDefaults, TemplatePrompt, TemplateReference,
44    TemplateScope,
45};
46use crate::templates::resolver::{
47    CreateRequest, TemplateInput, TemplateResolution, TemplateSelection, resolve,
48};
49use crate::templates::{
50    DeleteConfirmed, TemplateDefinition, TemplateDetail, TemplateService, TemplateWriteError,
51};
52
53// ---------------------------------------------------------------------------
54// `tix template …` — management
55// ---------------------------------------------------------------------------
56
57/// Run one `tix template` subcommand against the project's `tix.toml`.
58///
59/// The config is loaded fresh here rather than through `commands::dispatch`
60/// because a mutation needs a **mutable** [`Config`] and the file path it came
61/// from; `dispatch` sees neither. Routing happens in [`crate::cli::run`] before
62/// the store is opened, so the read-only and write subcommands share one
63/// resolution and a project with no `tix.toml` fails with the same guidance as
64/// the TUI.
65pub fn run(cwd: &Path, action: &TemplateAction) -> Result<()> {
66    let (mut config, path) = load_management_config(cwd)?;
67
68    match action {
69        TemplateAction::List => {
70            let service = TemplateService::new(&mut config, &path);
71            print!("{}", render_list(&service.list()));
72            Ok(())
73        }
74        TemplateAction::Show { reference } => {
75            let service = TemplateService::new(&mut config, &path);
76            print!("{}", render_detail(&service.show(reference)?));
77            Ok(())
78        }
79        TemplateAction::Create {
80            id,
81            name,
82            scope,
83            defaults,
84            prompts,
85            references,
86        } => {
87            let definition = definition_for_create(
88                name.as_deref(),
89                scope.as_deref(),
90                defaults,
91                prompts,
92                references,
93            )?;
94            let mut service = TemplateService::new(&mut config, &path);
95            let outcome = service
96                .create(id, definition)
97                .map_err(TemplateWriteError::into_inner)?;
98            println!("created template `{}`", outcome.value());
99            Ok(())
100        }
101        TemplateAction::Update {
102            reference,
103            name,
104            scope,
105            defaults,
106            prompts,
107            references,
108        } => {
109            let definition = definition_for_update(
110                name.as_deref(),
111                scope.as_deref(),
112                defaults,
113                prompts,
114                references,
115            )?;
116            let mut service = TemplateService::new(&mut config, &path);
117            let outcome = service
118                .update(reference, definition)
119                .map_err(TemplateWriteError::into_inner)?;
120            if outcome.is_noop() {
121                println!("unchanged template `{}`", outcome.value());
122            } else {
123                println!("updated template `{}`", outcome.value());
124            }
125            Ok(())
126        }
127        TemplateAction::Duplicate {
128            reference,
129            id,
130            name,
131        } => {
132            let mut service = TemplateService::new(&mut config, &path);
133            let outcome = service
134                .duplicate(reference, id.as_deref(), name.as_deref())
135                .map_err(TemplateWriteError::into_inner)?;
136            println!(
137                "duplicated template `{reference}` as `{}`",
138                outcome.value().id()
139            );
140            Ok(())
141        }
142        TemplateAction::Delete { reference, yes } => {
143            confirm_delete(reference, *yes)?;
144            let mut service = TemplateService::new(&mut config, &path);
145            let outcome = service
146                .delete(reference, DeleteConfirmed::confirmed())
147                .map_err(TemplateWriteError::into_inner)?;
148            println!("deleted template `{}`", outcome.value().id());
149            Ok(())
150        }
151    }
152}
153
154/// Load the project config and the `tix.toml` path it was read from.
155///
156/// Strict, like the TUI and `tix mcp`: a management command with no repository
157/// to manage fails with the contract's guidance rather than silently writing a
158/// new file.
159fn load_management_config(cwd: &Path) -> Result<(Config, PathBuf)> {
160    let root = Config::project_root(cwd)
161        .ok_or_else(|| TixError::Config("no tix.toml found — run \"tix init\" first".into()))?;
162    let config = Config::load(&root)?;
163    Ok((config, root.join("tix.toml")))
164}
165
166/// Mirror `tix cycle delete` / `tix milestone delete`: `--yes` or a piped
167/// stdin confirms, a bare TTY invocation asks.
168fn confirm_delete(reference: &str, yes: bool) -> Result<()> {
169    let mut confirmed = yes || !std::io::stdin().is_terminal();
170    if !confirmed {
171        eprint!("Delete template '{reference}'? [y/N] ");
172        let mut line = String::new();
173        std::io::stdin()
174            .read_line(&mut line)
175            .map_err(TixError::Io)?;
176        confirmed = line.trim().eq_ignore_ascii_case("y");
177    }
178    if !confirmed {
179        return Err(TixError::InvalidInput(format!(
180            "deletion of template `{reference}` was not confirmed"
181        )));
182    }
183    Ok(())
184}
185
186// ---------------------------------------------------------------------------
187// definition building (flags → service types)
188// ---------------------------------------------------------------------------
189
190/// The definition a `create` supplies: name/scope default, flags add data.
191fn definition_for_create(
192    name: Option<&str>,
193    scope: Option<&str>,
194    defaults: &[String],
195    prompts: &[String],
196    references: &[String],
197) -> Result<TemplateDefinition> {
198    // An omitted scope is the documented `repository` default for a *new*
199    // template; the registry persists no `scope` key for it.
200    let scope = match scope {
201        Some(raw) => parse_scope(raw)?,
202        None => TemplateScope::repository(),
203    };
204    let mut definition = match name {
205        Some(name) => TemplateDefinition::new(name, scope),
206        None => TemplateDefinition::unnamed(scope),
207    };
208    if !defaults.is_empty() {
209        definition = definition.with_defaults(build_defaults(defaults)?);
210    }
211    if !prompts.is_empty() {
212        definition = definition.with_prompts(build_prompts(prompts)?);
213    }
214    if !references.is_empty() {
215        definition = definition.with_references(build_references(references)?);
216    }
217    Ok(definition)
218}
219
220/// The definition an `update` supplies: **only** the flags given are set.
221///
222/// This is what makes `tix template update bug --name "…"` a rename: an absent
223/// scope/defaults/prompts/references argument leaves the stored value exactly
224/// as it was (C-002). A definition with no flags at all is a no-op the service
225/// reports as unchanged.
226fn definition_for_update(
227    name: Option<&str>,
228    scope: Option<&str>,
229    defaults: &[String],
230    prompts: &[String],
231    references: &[String],
232) -> Result<TemplateDefinition> {
233    let mut definition = match (name, scope) {
234        (Some(name), Some(raw)) => TemplateDefinition::new(name, parse_scope(raw)?),
235        (Some(name), None) => TemplateDefinition::rename(name),
236        (None, Some(raw)) => TemplateDefinition::unnamed(parse_scope(raw)?),
237        (None, None) => TemplateDefinition::default(),
238    };
239    // Supplying any `--default`/`--prompt`/`--reference` **replaces** that whole
240    // block; an absent flag leaves it untouched. That is the service's
241    // `Option`-per-block contract and the only behaviour that makes a rename a
242    // rename.
243    if !defaults.is_empty() {
244        definition = definition.with_defaults(build_defaults(defaults)?);
245    }
246    if !prompts.is_empty() {
247        definition = definition.with_prompts(build_prompts(prompts)?);
248    }
249    if !references.is_empty() {
250        definition = definition.with_references(build_references(references)?);
251    }
252    Ok(definition)
253}
254
255/// `--scope repository` | `board:<name>` | `team:<label>`.
256fn parse_scope(raw: &str) -> Result<TemplateScope> {
257    raw.parse::<TemplateScope>().map_err(TixError::InvalidInput)
258}
259
260/// Fold repeated `--default FIELD=VALUE` into one defaults block.
261///
262/// The value is taken verbatim after the first `=` (so a comma-containing list
263/// value survives), and list fields are split with the same CSV rule the create
264/// core applies.
265fn build_defaults(specs: &[String]) -> Result<TemplateDefaults> {
266    let mut defaults = TemplateDefaults::default();
267    for spec in specs {
268        let Some((field, value)) = spec.split_once('=') else {
269            return Err(TixError::InvalidInput(format!(
270                "--default `{spec}` must be `FIELD=VALUE`"
271            )));
272        };
273        let field = field.trim();
274        let value = value.trim().to_string();
275        match field {
276            "title" => defaults.title = Some(value),
277            "body" => defaults.body = Some(value),
278            "status" => defaults.status = Some(value),
279            "priority" => defaults.priority = Some(value),
280            "assignee" => defaults.assignee = Some(value),
281            "labels" => defaults.labels = parse_csv_list(&value),
282            "files" => defaults.files = parse_csv_list(&value),
283            "estimate" => defaults.estimate = Some(value),
284            "cycles" => defaults.cycles = parse_csv_list(&value),
285            "milestones" => defaults.milestones = parse_csv_list(&value),
286            "reviewer" => defaults.reviewer = Some(value),
287            "start_date" => defaults.start_date = Some(value),
288            "due_date" => defaults.due_date = Some(value),
289            "parent" => defaults.parent = Some(value),
290            "related_issues" => defaults.related_issues = parse_csv_list(&value),
291            "type" => defaults.type_ = Some(value),
292            other => {
293                return Err(TixError::InvalidInput(format!(
294                    "`{other}` is not a create field; expected one of {}",
295                    CREATE_FIELDS.join(", ")
296                )));
297            }
298        }
299    }
300    Ok(defaults)
301}
302
303/// `--prompt key=<field>[,kind=<kind>][,required=<bool>][,default=<v>][,label=<l>][,options_ref=<src>]`.
304fn build_prompts(specs: &[String]) -> Result<Vec<TemplatePrompt>> {
305    specs.iter().map(|spec| parse_prompt(spec)).collect()
306}
307
308fn parse_prompt(raw: &str) -> Result<TemplatePrompt> {
309    let mut key: Option<String> = None;
310    let mut label: Option<String> = None;
311    let mut kind = PromptKind::Text;
312    let mut required = false;
313    let mut default: Option<String> = None;
314    let mut options_ref: Option<String> = None;
315
316    for (name, value) in split_attributes(raw, "--prompt")? {
317        match name {
318            "key" => key = Some(value.to_string()),
319            "kind" => kind = parse_prompt_kind(value)?,
320            "required" => required = parse_bool(value, "--prompt required")?,
321            "default" => default = Some(value.to_string()),
322            "label" => label = Some(value.to_string()),
323            "options_ref" => options_ref = Some(value.to_string()),
324            other => {
325                return Err(TixError::InvalidInput(format!(
326                    "unknown --prompt attribute `{other}`; expected key, kind, required, \
327                     default, label or options_ref"
328                )));
329            }
330        }
331    }
332
333    let key = key.ok_or_else(|| {
334        TixError::InvalidInput("--prompt requires `key=<create field>`".to_string())
335    })?;
336    Ok(TemplatePrompt {
337        key,
338        label,
339        kind,
340        required,
341        default,
342        options_ref,
343    })
344}
345
346/// `--reference kind=<kind>,value=<v>[,required=<bool>]`.
347fn build_references(specs: &[String]) -> Result<Vec<TemplateReference>> {
348    specs.iter().map(|spec| parse_reference(spec)).collect()
349}
350
351fn parse_reference(raw: &str) -> Result<TemplateReference> {
352    let mut kind: Option<ReferenceKind> = None;
353    let mut value: Option<String> = None;
354    let mut required = false;
355
356    for (name, raw_value) in split_attributes(raw, "--reference")? {
357        match name {
358            "kind" => kind = Some(parse_reference_kind(raw_value)?),
359            "value" => value = Some(raw_value.to_string()),
360            "required" => required = parse_bool(raw_value, "--reference required")?,
361            other => {
362                return Err(TixError::InvalidInput(format!(
363                    "unknown --reference attribute `{other}`; expected kind, value or required"
364                )));
365            }
366        }
367    }
368
369    let kind = kind
370        .ok_or_else(|| TixError::InvalidInput("--reference requires `kind=<kind>`".to_string()))?;
371    let value = value.ok_or_else(|| {
372        TixError::InvalidInput("--reference requires `value=<value>`".to_string())
373    })?;
374    Ok(TemplateReference {
375        kind,
376        value,
377        required,
378    })
379}
380
381/// Split a `key=value,key=value` flag into trimmed pairs.
382///
383/// Strict on purpose: a token without `=` is a usage error naming the flag
384/// rather than a silently ignored typo, and a repeated attribute is the last
385/// one wins only because the grammar cannot express a list here.
386fn split_attributes<'a>(raw: &'a str, flag: &str) -> Result<Vec<(&'a str, &'a str)>> {
387    let mut out = Vec::new();
388    for token in raw.split(',') {
389        let token = token.trim();
390        if token.is_empty() {
391            continue;
392        }
393        let Some((name, value)) = token.split_once('=') else {
394            return Err(TixError::InvalidInput(format!(
395                "{flag} attribute `{token}` must be `key=value`"
396            )));
397        };
398        out.push((name.trim(), value.trim()));
399    }
400    Ok(out)
401}
402
403fn parse_bool(raw: &str, what: &str) -> Result<bool> {
404    match raw {
405        "true" => Ok(true),
406        "false" => Ok(false),
407        other => Err(TixError::InvalidInput(format!(
408            "{what} must be `true` or `false`, got `{other}`"
409        ))),
410    }
411}
412
413/// `PromptKind` has no `FromStr` (WP01 owns the type), so the CLI spells its
414/// own parse and the round-trip is pinned by a test.
415fn parse_prompt_kind(raw: &str) -> Result<PromptKind> {
416    match raw {
417        "text" => Ok(PromptKind::Text),
418        "select" => Ok(PromptKind::Select),
419        "multi-select" => Ok(PromptKind::MultiSelect),
420        "date" => Ok(PromptKind::Date),
421        other => Err(TixError::InvalidInput(format!(
422            "unknown prompt kind `{other}`; expected text, select, multi-select or date"
423        ))),
424    }
425}
426
427fn parse_reference_kind(raw: &str) -> Result<ReferenceKind> {
428    match raw {
429        "label" => Ok(ReferenceKind::Label),
430        "assignee" => Ok(ReferenceKind::Assignee),
431        "file" => Ok(ReferenceKind::File),
432        "cycle" => Ok(ReferenceKind::Cycle),
433        "milestone" => Ok(ReferenceKind::Milestone),
434        "relationship" => Ok(ReferenceKind::Relationship),
435        other => Err(TixError::InvalidInput(format!(
436            "unknown reference kind `{other}`; expected label, assignee, file, cycle, \
437             milestone or relationship"
438        ))),
439    }
440}
441
442// ---------------------------------------------------------------------------
443// rendering
444// ---------------------------------------------------------------------------
445
446/// `id\tname\tscope`, one row per stored definition, in declaration order.
447///
448/// Returns the text rather than printing it so the MCP surface renders the
449/// identical bytes (`surface-parity.md` §Equivalence) — the CLI's rendering is
450/// the shared one, not a second spelling kept in step by hand.
451pub fn render_list(details: &[TemplateDetail]) -> String {
452    let mut out = String::new();
453    for detail in details {
454        let _ = writeln!(
455            out,
456            "{}\t{}\t{}",
457            detail.id(),
458            detail.display_name(),
459            detail.scope()
460        );
461    }
462    out
463}
464
465/// A structured `key: value` block, so a script can read defaults, prompts,
466/// references and preserved metadata without reaching into `tix.toml`.
467///
468/// The keys are stable and dotted; a section is absent when it is empty. The
469/// template ID is `id` and the display name is `name`, never conflated.
470///
471/// Returns the text rather than printing it so the MCP surface renders the
472/// identical bytes (`surface-parity.md` §Equivalence).
473pub fn render_detail(detail: &TemplateDetail) -> String {
474    let mut out = String::new();
475    let _ = writeln!(out, "id: {}", detail.id());
476    let _ = writeln!(out, "name: {}", detail.display_name());
477    let _ = writeln!(out, "scope: {}", detail.scope());
478    out.push_str(&render_defaults(detail.defaults()));
479    for (index, prompt) in detail.prompts().iter().enumerate() {
480        let _ = writeln!(out, "prompt[{index}].key: {}", prompt.key);
481        if let Some(label) = &prompt.label {
482            let _ = writeln!(out, "prompt[{index}].label: {label}");
483        }
484        let _ = writeln!(
485            out,
486            "prompt[{index}].kind: {}",
487            prompt_kind_str(prompt.kind)
488        );
489        let _ = writeln!(out, "prompt[{index}].required: {}", prompt.required);
490        if let Some(default) = &prompt.default {
491            let _ = writeln!(out, "prompt[{index}].default: {default}");
492        }
493        if let Some(options_ref) = &prompt.options_ref {
494            let _ = writeln!(out, "prompt[{index}].options_ref: {options_ref}");
495        }
496    }
497    for (index, reference) in detail.references().iter().enumerate() {
498        let _ = writeln!(
499            out,
500            "reference[{index}].kind: {}",
501            reference_kind_str(reference.kind)
502        );
503        let _ = writeln!(out, "reference[{index}].value: {}", reference.value);
504        let _ = writeln!(out, "reference[{index}].required: {}", reference.required);
505    }
506    // Unknown metadata is **data**: shown, never interpreted. Rendering it is
507    // how "preserved" is visible without a second schema (C-006).
508    for (key, value) in detail.extensions().iter() {
509        let _ = writeln!(out, "unknown.{key}: {value}");
510    }
511    out
512}
513
514/// Defaults in [`CREATE_FIELDS`] order, so the block is deterministic.
515fn render_defaults(defaults: &TemplateDefaults) -> String {
516    let mut out = String::new();
517    for (field, value) in [
518        ("title", defaults.title.as_deref()),
519        ("body", defaults.body.as_deref()),
520        ("status", defaults.status.as_deref()),
521        ("priority", defaults.priority.as_deref()),
522        ("assignee", defaults.assignee.as_deref()),
523        ("estimate", defaults.estimate.as_deref()),
524        ("reviewer", defaults.reviewer.as_deref()),
525        ("start_date", defaults.start_date.as_deref()),
526        ("due_date", defaults.due_date.as_deref()),
527        ("parent", defaults.parent.as_deref()),
528        ("type", defaults.type_.as_deref()),
529    ] {
530        if let Some(value) = value {
531            let _ = writeln!(out, "defaults.{field}: {value}");
532        }
533    }
534    for (field, values) in [
535        ("labels", &defaults.labels),
536        ("files", &defaults.files),
537        ("cycles", &defaults.cycles),
538        ("milestones", &defaults.milestones),
539        ("related_issues", &defaults.related_issues),
540    ] {
541        if !values.is_empty() {
542            let _ = writeln!(out, "defaults.{field}: {}", values.join(", "));
543        }
544    }
545    out
546}
547
548fn prompt_kind_str(kind: PromptKind) -> &'static str {
549    match kind {
550        PromptKind::Text => "text",
551        PromptKind::Select => "select",
552        PromptKind::MultiSelect => "multi-select",
553        PromptKind::Date => "date",
554    }
555}
556
557fn reference_kind_str(kind: ReferenceKind) -> &'static str {
558    match kind {
559        ReferenceKind::Label => "label",
560        ReferenceKind::Assignee => "assignee",
561        ReferenceKind::File => "file",
562        ReferenceKind::Cycle => "cycle",
563        ReferenceKind::Milestone => "milestone",
564        ReferenceKind::Relationship => "relationship",
565    }
566}
567
568// ---------------------------------------------------------------------------
569// `tix create --template` — template-aware creation
570// ---------------------------------------------------------------------------
571
572/// The normal create flags a template selection is merged with.
573///
574/// It mirrors `tix create`'s arguments one-for-one — the same
575/// comma-separated string shape the create core takes — so a surface cannot
576/// name a field the create model does not understand.
577#[derive(Debug, Default, Clone, Copy)]
578pub struct CreateFields<'a> {
579    pub title: Option<&'a str>,
580    pub body: Option<&'a str>,
581    pub status: Option<&'a str>,
582    pub priority: Option<&'a str>,
583    pub assignee: Option<&'a str>,
584    pub labels: Option<&'a str>,
585    pub files: Option<&'a str>,
586    pub estimate: Option<&'a str>,
587    pub cycles: Option<&'a str>,
588    pub milestones: Option<&'a str>,
589    pub reviewer: Option<&'a str>,
590    pub start_date: Option<&'a str>,
591    pub due_date: Option<&'a str>,
592    pub parent: Option<&'a str>,
593    pub related_issues: Option<&'a str>,
594    pub type_: Option<&'a str>,
595}
596
597impl CreateFields<'_> {
598    /// The resolver's input shape: the same fields, owned, with the TOML-style
599    /// `type` key mapped onto the Rust field. Public so every surface feeds the
600    /// shared resolver the identical shape.
601    #[must_use]
602    pub fn to_input(self) -> TemplateInput {
603        TemplateInput {
604            title: self.title.map(str::to_string),
605            body: self.body.map(str::to_string),
606            status: self.status.map(str::to_string),
607            priority: self.priority.map(str::to_string),
608            assignee: self.assignee.map(str::to_string),
609            labels: self.labels.map(str::to_string),
610            files: self.files.map(str::to_string),
611            estimate: self.estimate.map(str::to_string),
612            cycles: self.cycles.map(str::to_string),
613            milestones: self.milestones.map(str::to_string),
614            reviewer: self.reviewer.map(str::to_string),
615            start_date: self.start_date.map(str::to_string),
616            due_date: self.due_date.map(str::to_string),
617            parent: self.parent.map(str::to_string),
618            related_issues: self.related_issues.map(str::to_string),
619            type_: self.type_.map(str::to_string),
620        }
621    }
622}
623
624/// Resolve `template_id`/`scope` plus `fields` into one create request and hand
625/// it to the shared create core.
626///
627/// Failure is total: the resolver returns **every** diagnostic and no request,
628/// and this function writes nothing — no issue file, no template/config save.
629/// The diagnostics are rendered onto one `InvalidInput`, so the process exits
630/// `1` with stdout untouched and every missing field named (FR-004/C-005).
631///
632/// The template ID is provenance: it is printed to **stderr** so `Created
633/// <file>` on stdout keeps the exact shape every script already parses, while
634/// the selected ID and scope stay visible (FR-005).
635pub fn create_from_template(
636    config: &Config,
637    store: &Store,
638    template_id: &str,
639    scope: Option<&str>,
640    fields: &CreateFields<'_>,
641) -> Result<()> {
642    let selection = selection_for(template_id, scope)?;
643    let resolution = resolve(config, &selection, &fields.to_input());
644
645    if !resolution.is_ok() {
646        return Err(resolution_error(&resolution));
647    }
648
649    let template_id = resolution.template_id().to_string();
650    let scope = resolution.scope().to_string();
651    let request = resolution
652        .into_request()
653        .expect("a successful resolution always carries a request");
654    eprintln!("using template `{template_id}` at scope `{scope}`");
655    let issue = apply_request(config, store, &request)?;
656    println!("Created {}", issue.filename);
657    Ok(())
658}
659
660/// The explicit selection a stable ID and an optional scope spelling make.
661///
662/// An omitted scope is the **explicit** `repository` default, never an implicit
663/// inheritance (C-003). Public so the MCP surface builds the identical
664/// selection — and gets the identical refusal for a malformed scope — rather
665/// than parsing the scope spelling a second time.
666pub fn selection_for(template_id: &str, scope: Option<&str>) -> Result<TemplateSelection> {
667    let scope = match scope {
668        Some(raw) => parse_scope(raw)?,
669        None => TemplateScope::repository(),
670    };
671    Ok(TemplateSelection::new(template_id, scope))
672}
673
674/// The one `InvalidInput` a failed resolution produces.
675///
676/// Public so every surface reports the **identical** failure text: MCP renders
677/// this error rather than composing a second copy of the message
678/// (`surface-parity.md` §Equivalence).
679pub fn resolution_error(resolution: &TemplateResolution) -> TixError {
680    let mut message = format!(
681        "template `{}` at scope `{}` did not resolve:",
682        resolution.template_id(),
683        resolution.scope()
684    );
685    for diagnostic in resolution.diagnostics() {
686        message.push_str(&format!(
687            "\n  - {}: {}: {}",
688            diagnostic.kind(),
689            diagnostic.field(),
690            diagnostic.message()
691        ));
692    }
693    TixError::InvalidInput(message)
694}
695
696/// Destructure a resolved request straight into the shared create core.
697///
698/// The request's fields are the core's parameters in the same order and shape,
699/// which is the whole point of the resolver returning `CreateRequest` rather
700/// than a template-shaped object (C-001/NFR-002).
701pub fn apply_request(
702    config: &Config,
703    store: &Store,
704    request: &CreateRequest,
705) -> Result<crate::model::Issue> {
706    create::create(
707        config,
708        store,
709        &request.title,
710        request.body.as_deref(),
711        request.status.clone(),
712        request.priority.clone(),
713        request.assignee.as_deref(),
714        request.labels.as_deref(),
715        request.files.as_deref(),
716        request.estimate.as_deref(),
717        request.cycles.as_deref(),
718        request.milestones.as_deref(),
719        request.reviewer.as_deref(),
720        request.start_date.as_deref(),
721        request.due_date.as_deref(),
722        request.parent.as_deref(),
723        request.related_issues.as_deref(),
724        request.type_.as_deref(),
725    )
726}