tix-tracker 0.12.0

A terminal issue tracker: markdown + YAML frontmatter issues with a kanban TUI and CLI
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
//! The template management service (WP03; mission `issue-form-templates-01M3C76V`).
//!
//! This is the **operation layer** over WP01's registry
//! ([`crate::templates::registry`]). It adds no storage of its own and no second
//! issue writer:
//!
//! - every read (`list`, `show`) borrows the registry and returns derived copies;
//! - every write (`create`, `update`, `duplicate`, `delete`) builds a **candidate**
//!   registry, validates the complete definition against the caller's [`Config`],
//!   and only then persists through the existing atomic
//!   [`Config::save`] — a refused or failed operation writes nothing
//!   (`data-model.md` §5, `contracts/template-registry.md`);
//! - identity is the stable ID. A rename replaces the display label and nothing
//!   else, so selection by ID keeps scope, defaults, prompts, references, and
//!   preserved unknown metadata intact (C-002/C-006);
//! - delete removes a **definition** and nothing else. Issues created from a
//!   template live in the issue tree and are never named here, so delete cannot
//!   reach them (C-001).
//!
//! # Read-only versus write operations
//!
//! The split is visible in the signatures:
//!
//! | operation | returns | writes |
//! |---|---|---|
//! | `list`, `show` | `Result<..>` | nothing |
//! | `create`, `update`, `duplicate`, `delete` | `TemplateMutation<..>` | one atomic `tix.toml` save |
//!
//! A read can only be refused (a plain [`TixError`]). A write can also *fail*:
//! the registry already models a refusal differently from an infrastructure
//! failure, and [`TemplateWriteError`] keeps that distinction rather than
//! flattening it.
//!
//! # Auditable, idempotent mutations
//!
//! An accepted mutation returns a [`TemplateOutcome`] carrying exactly one
//! [`TemplateAuditEvent`] — what changed, for which stable ID, under which
//! display name at the time of the operation. A refused or failed operation
//! carries **no** event, because nothing happened. A same-content `update` is a
//! no-op: no save is issued and the outcome reports [`TemplateOutcome::is_noop`].
//! Committing the same registry twice writes the same bytes, so re-applying an
//! operation converges.
//!
//! # Definition-only delete
//!
//! [`TemplateService::delete`] cannot be called without a [`DeleteConfirmed`], a
//! token whose field is private so "I forgot to confirm" is a compile error
//! rather than a defaulted `true`. It removes the definition and nothing else:
//! no issue, comment, relationship, or board data is touched.

use std::fmt;
use std::path::Path;

use crate::config::Config;
use crate::error::{Result, TixError};
use crate::templates::registry::{
    Template, TemplateDefaults, TemplateMetadata, TemplatePrompt, TemplateReference,
    TemplateRegistry, TemplateScope,
};

/// The outcome of a service **write**: an accepted value plus its audit event, or
/// a refusal/failure that wrote nothing.
pub type TemplateMutation<T> = std::result::Result<TemplateOutcome<T>, TemplateWriteError>;

/// Why a template write did not happen.
///
/// The two cases are deliberately distinct. [`Refused`](Self::Refused) is the
/// registry/config declining the change — a bad ID, an unknown scope, an
/// unsupported field: nothing was written, and the caller should show the
/// message. [`Failed`](Self::Failed) is infrastructure — the atomic save could
/// not complete. In both cases `tix.toml` is unchanged; only the remedy differs.
#[derive(Debug)]
pub enum TemplateWriteError {
    /// The candidate was invalid; the registry and `tix.toml` are unchanged.
    Refused(TixError),
    /// The candidate was valid but the atomic save failed; `tix.toml` is unchanged.
    Failed(TixError),
}

impl TemplateWriteError {
    /// The underlying error, whichever kind this is.
    #[must_use]
    pub fn inner(&self) -> &TixError {
        match self {
            Self::Refused(error) | Self::Failed(error) => error,
        }
    }

    /// Consume the error for its underlying [`TixError`].
    #[must_use]
    pub fn into_inner(self) -> TixError {
        match self {
            Self::Refused(error) | Self::Failed(error) => error,
        }
    }

    /// Whether this is a validation refusal rather than an infrastructure failure.
    #[must_use]
    pub const fn is_refusal(&self) -> bool {
        matches!(self, Self::Refused(_))
    }
}

impl From<TixError> for TemplateWriteError {
    /// A bare error from the registry is a **refusal** by default: the registry's
    /// failures are all validation, and the save path maps its own error to
    /// [`TemplateWriteError::Failed`] explicitly.
    fn from(error: TixError) -> Self {
        Self::Refused(error)
    }
}

impl fmt::Display for TemplateWriteError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Refused(error) => write!(f, "template refused: {error}"),
            Self::Failed(error) => write!(f, "template write failed: {error}"),
        }
    }
}

impl std::error::Error for TemplateWriteError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        Some(self.inner())
    }
}

/// What kind of registry mutation an accepted operation performed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TemplateMutationKind {
    /// A new definition was added under a caller-supplied stable ID.
    Create,
    /// An existing definition's name, scope, and data were replaced (ID unchanged).
    Update,
    /// A new definition was copied from an existing one under a new stable ID.
    Duplicate,
    /// A definition was removed. Only the definition.
    Delete,
}

impl TemplateMutationKind {
    /// The stable name of this mutation, for output and matching.
    #[must_use]
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Create => "create",
            Self::Update => "update",
            Self::Duplicate => "duplicate",
            Self::Delete => "delete",
        }
    }
}

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

/// The auditable record of one **accepted** registry mutation.
///
/// Emitted once per accepted change, and never for a refusal or a no-op. The
/// template ID is identity; the name is the display label *at the time of the
/// operation*, kept for a readable history even after a later rename.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TemplateAuditEvent {
    kind: TemplateMutationKind,
    template_id: String,
    template_name: String,
}

impl TemplateAuditEvent {
    fn new(kind: TemplateMutationKind, template_id: String, template_name: String) -> Self {
        Self {
            kind,
            template_id,
            template_name,
        }
    }

    /// What kind of mutation this was.
    #[must_use]
    pub const fn kind(&self) -> TemplateMutationKind {
        self.kind
    }

    /// The stable ID of the affected definition.
    #[must_use]
    pub fn template_id(&self) -> &str {
        &self.template_id
    }

    /// The display name the definition carried when the mutation was accepted.
    #[must_use]
    pub fn template_name(&self) -> &str {
        &self.template_name
    }
}

/// An accepted operation's value and its audit event.
///
/// `audit` is `None` exactly when the candidate registry equalled the current
/// one — an idempotent no-op that issued no save.
#[derive(Debug, Clone, PartialEq)]
pub struct TemplateOutcome<T> {
    value: T,
    audit: Option<TemplateAuditEvent>,
}

impl<T> TemplateOutcome<T> {
    fn accepted(value: T, audit: TemplateAuditEvent) -> Self {
        Self {
            value,
            audit: Some(audit),
        }
    }

    fn unchanged(value: T) -> Self {
        Self { value, audit: None }
    }

    /// The operation's result value.
    #[must_use]
    pub fn value(&self) -> &T {
        &self.value
    }

    /// Consume the outcome for its value.
    #[must_use]
    pub fn into_value(self) -> T {
        self.value
    }

    /// The one audit event, or `None` for an idempotent no-op.
    #[must_use]
    pub fn audit(&self) -> Option<&TemplateAuditEvent> {
        self.audit.as_ref()
    }

    /// Whether the operation changed nothing (no save, no event).
    #[must_use]
    pub fn is_noop(&self) -> bool {
        self.audit.is_none()
    }
}

/// Explicit confirmation for a definition-only delete.
///
/// The private field is the whole point: the token cannot be built outside this
/// module, so [`TemplateService::delete`] is not callable without a deliberate
/// confirmation. There is no `Default` and no `From<bool>`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DeleteConfirmed(());

impl DeleteConfirmed {
    /// Acknowledge that only the template **definition** is being removed — never
    /// issues created from it and never their provenance.
    #[must_use]
    pub const fn confirmed() -> Self {
        Self(())
    }
}

/// A definition handed to `create`/`update`: the mutable fields of a record.
///
/// Every field is optional and means **"supply this"**. `create` fills an absent
/// field with its default (no name, repository scope, no defaults/prompts/
/// references); `update` leaves an absent field exactly as it was. That is what
/// makes a rename a rename: [`TemplateDefinition::rename`] names a new label and
/// touches nothing else, so scope, defaults, prompts, and references survive it
/// (C-002).
///
/// The ID and the preserved extension fields are **not** here: identity is the
/// registry key, and extensions survive an update untouched, so a caller cannot
/// accidentally drop either (C-006).
#[derive(Debug, Clone, PartialEq, Default)]
pub struct TemplateDefinition {
    name: Option<String>,
    scope: Option<TemplateScope>,
    defaults: Option<TemplateDefaults>,
    prompts: Option<Vec<TemplatePrompt>>,
    references: Option<Vec<TemplateReference>>,
}

impl TemplateDefinition {
    /// A named, scoped definition with no defaults, prompts, or references.
    #[must_use]
    pub fn new(name: impl Into<String>, scope: TemplateScope) -> Self {
        Self {
            name: Some(name.into()),
            scope: Some(scope),
            ..Self::default()
        }
    }

    /// A definition with no display name; the stable ID is shown in its place.
    #[must_use]
    pub fn unnamed(scope: TemplateScope) -> Self {
        Self {
            scope: Some(scope),
            ..Self::default()
        }
    }

    /// A rename: a new display label and nothing else.
    ///
    /// The only field carried is the name, so an update through it cannot change
    /// the scope, defaults, prompts, or references of the record it renames.
    #[must_use]
    pub fn rename(name: impl Into<String>) -> Self {
        Self {
            name: Some(name.into()),
            ..Self::default()
        }
    }

    /// Set the normal create-request defaults.
    #[must_use]
    pub fn with_defaults(mut self, defaults: TemplateDefaults) -> Self {
        self.defaults = Some(defaults);
        self
    }

    /// Set the ordered structured prompts.
    #[must_use]
    pub fn with_prompts(mut self, prompts: Vec<TemplatePrompt>) -> Self {
        self.prompts = Some(prompts);
        self
    }

    /// Set the typed references.
    #[must_use]
    pub fn with_references(mut self, references: Vec<TemplateReference>) -> Self {
        self.references = Some(references);
        self
    }

    /// A fresh record for `create`, with no preserved metadata.
    fn into_template(self) -> Template {
        Template {
            name: self.name,
            scope: self.scope.unwrap_or_default(),
            defaults: self.defaults.unwrap_or_default(),
            prompts: self.prompts.unwrap_or_default(),
            references: self.references.unwrap_or_default(),
            ..Template::default()
        }
    }

    /// Apply the supplied fields to an existing record, leaving the rest as-is.
    fn apply_to(self, template: &mut Template) {
        if let Some(name) = self.name {
            template.name = Some(name);
        }
        if let Some(scope) = self.scope {
            template.scope = scope;
        }
        if let Some(defaults) = self.defaults {
            template.defaults = defaults;
        }
        if let Some(prompts) = self.prompts {
            template.prompts = prompts;
        }
        if let Some(references) = self.references {
            template.references = references;
        }
    }
}

/// A definition shown to a caller: the stable ID and the full stored record.
///
/// The record carries the display name, scope, defaults, prompts, references, and
/// any preserved unknown metadata, so a surface renders everything without
/// reaching back into the registry (T013).
#[derive(Debug, Clone, PartialEq)]
pub struct TemplateDetail {
    id: String,
    template: Template,
}

impl TemplateDetail {
    fn new(id: String, template: Template) -> Self {
        Self { id, template }
    }

    /// The immutable stable ID.
    #[must_use]
    pub fn id(&self) -> &str {
        &self.id
    }

    /// The stored record.
    #[must_use]
    pub fn template(&self) -> &Template {
        &self.template
    }

    /// The display label, falling back to the stable ID.
    #[must_use]
    pub fn display_name(&self) -> &str {
        self.template.display_name(&self.id)
    }

    /// The explicit scope.
    #[must_use]
    pub fn scope(&self) -> &TemplateScope {
        &self.template.scope
    }

    /// The normal create-request defaults.
    #[must_use]
    pub fn defaults(&self) -> &TemplateDefaults {
        &self.template.defaults
    }

    /// The ordered structured prompts.
    #[must_use]
    pub fn prompts(&self) -> &[TemplatePrompt] {
        &self.template.prompts
    }

    /// The typed references.
    #[must_use]
    pub fn references(&self) -> &[TemplateReference] {
        &self.template.references
    }

    /// Preserved unknown metadata, never interpreted.
    #[must_use]
    pub fn extensions(&self) -> &TemplateMetadata {
        &self.template.extensions
    }
}

/// The template service: the caller's registry-bearing [`Config`] and the
/// `tix.toml` path to save it through.
///
/// The service borrows the caller's config mutably because an accepted mutation
/// *is* `config.issue_templates` changing; it never reaches around the registry
/// to write anything else. There is no separate file and no hosted state — the
/// existing atomic [`Config::save`] is the persistence (C-001).
pub struct TemplateService<'a> {
    config: &'a mut Config,
    path: &'a Path,
}

impl<'a> TemplateService<'a> {
    /// A service over a caller's config and `tix.toml` path.
    #[must_use]
    pub fn new(config: &'a mut Config, path: &'a Path) -> Self {
        Self { config, path }
    }

    /// The registry, for a surface that must enumerate or render it directly.
    #[must_use]
    pub fn registry(&self) -> &TemplateRegistry {
        &self.config.issue_templates
    }

    // -- read-only --------------------------------------------------------

    /// Every stored definition, with its stable ID, in declaration order (T013).
    ///
    /// Declaration order is the registry's order, which the TOML loader restores
    /// from the document's section headers, so the list is deterministic across
    /// load/save round trips.
    #[must_use]
    pub fn list(&self) -> Vec<TemplateDetail> {
        self.config
            .issue_templates
            .iter()
            .map(|(id, template)| TemplateDetail::new(id.to_string(), template.clone()))
            .collect()
    }

    /// Resolve one definition by stable ID, or by a display name that names
    /// exactly one record, and return the full detail (T013).
    ///
    /// The ID is authoritative and is tried first; a name is a convenience, so an
    /// ambiguous name is refused rather than guessed. Unknown references are a
    /// `not found` error that names the request.
    pub fn show(&self, reference: &str) -> Result<TemplateDetail> {
        let (id, template) = self.select(reference)?;
        Ok(TemplateDetail::new(id, template))
    }

    // -- mutations --------------------------------------------------------

    /// Create a definition under the caller's stable ID (T014).
    ///
    /// The ID must be a well-formed handle no stored template already uses; the
    /// complete definition is validated before the atomic save, so a rejected
    /// create writes nothing.
    pub fn create(&mut self, id: &str, definition: TemplateDefinition) -> TemplateMutation<String> {
        let mut candidate = self.config.issue_templates.clone();
        candidate
            .insert(id, definition.into_template())
            .map_err(TemplateWriteError::Refused)?;

        let id = id.to_string();
        let name = display_name_of(&candidate, &id);
        self.commit(candidate)?;
        Ok(TemplateOutcome::accepted(
            id.clone(),
            TemplateAuditEvent::new(TemplateMutationKind::Create, id, name),
        ))
    }

    /// Replace an existing definition's supplied fields in place (T014).
    ///
    /// Only the fields the caller supplies change; an absent field is left as it
    /// was. The stable ID never changes — identity survives a rename (C-002) —
    /// and the record's preserved extension fields are left untouched (C-006). A
    /// same-content update is a no-op: no save, no audit event.
    pub fn update(
        &mut self,
        reference: &str,
        definition: TemplateDefinition,
    ) -> TemplateMutation<String> {
        let (id, _source) = self.select(reference)?;

        let mut candidate = self.config.issue_templates.clone();
        let template = candidate
            .get_mut(&id)
            .expect("the selected template is in the registry");
        definition.apply_to(template);
        // `extensions` is deliberately not assigned: unknown metadata survives
        // an update unchanged.

        if candidate == self.config.issue_templates {
            return Ok(TemplateOutcome::unchanged(id));
        }
        let name = display_name_of(&candidate, &id);
        self.commit(candidate)?;
        Ok(TemplateOutcome::accepted(
            id.clone(),
            TemplateAuditEvent::new(TemplateMutationKind::Update, id, name),
        ))
    }

    /// Duplicate a definition under a new stable ID (T015).
    ///
    /// The copy carries the source's scope, defaults, prompts, references, and
    /// preserved extensions verbatim; only the ID is new, and the source is left
    /// exactly as it was. `new_id` overrides the deterministic `<source>-copy`
    /// allocation; `name` overrides the label when given. The new definition is
    /// reported explicitly in the outcome value.
    pub fn duplicate(
        &mut self,
        reference: &str,
        new_id: Option<&str>,
        name: Option<&str>,
    ) -> TemplateMutation<TemplateDetail> {
        let (source_id, source) = self.select(reference)?;
        let target_id = match new_id {
            Some(id) => id.to_string(),
            None => self.allocate_duplicate_id(&source_id),
        };

        let mut copy = source;
        if let Some(name) = name {
            copy.name = Some(name.trim().to_string());
        }

        let mut candidate = self.config.issue_templates.clone();
        candidate
            .insert(target_id.clone(), copy)
            .map_err(TemplateWriteError::Refused)?;

        let detail = TemplateDetail::new(
            target_id.clone(),
            candidate_template(&candidate, &target_id),
        );
        let name = detail.display_name().to_string();
        self.commit(candidate)?;
        Ok(TemplateOutcome::accepted(
            detail,
            TemplateAuditEvent::new(TemplateMutationKind::Duplicate, target_id, name),
        ))
    }

    /// Delete a template **definition** after explicit confirmation (T016).
    ///
    /// Only the definition is removed. Issues created from the template are files
    /// in the issue tree that this service never names, so their bytes and their
    /// provenance are untouched. Deleting an unknown reference is refused —
    /// nothing was stored to remove. The removed definition is reported.
    pub fn delete(
        &mut self,
        reference: &str,
        _confirmation: DeleteConfirmed,
    ) -> TemplateMutation<TemplateDetail> {
        let (id, _source) = self.select(reference)?;

        let mut candidate = self.config.issue_templates.clone();
        let removed = candidate
            .remove(&id)
            .expect("the selected template is in the registry");
        let detail = TemplateDetail::new(id.clone(), removed);

        let name = detail.display_name().to_string();
        self.commit(candidate)?;
        Ok(TemplateOutcome::accepted(
            detail,
            TemplateAuditEvent::new(TemplateMutationKind::Delete, id, name),
        ))
    }

    // -- internals --------------------------------------------------------

    /// Resolve a reference to `(stable ID, record)`.
    ///
    /// The ID wins; a display name is accepted only when exactly one record
    /// carries it. Anything else is a `not found` or an ambiguity error, never a
    /// guess.
    fn select(&self, reference: &str) -> Result<(String, Template)> {
        if let Some(template) = self.config.issue_templates.get(reference) {
            return Ok((reference.to_string(), template.clone()));
        }
        let matches: Vec<(&str, &Template)> = self
            .config
            .issue_templates
            .iter()
            .filter(|(_, template)| template.name.as_deref() == Some(reference))
            .collect();
        match matches.as_slice() {
            [] => Err(TixError::NotFound(format!(
                "no template with id or name `{reference}`"
            ))),
            [(id, template)] => Ok(((*id).to_string(), (*template).clone())),
            many => {
                let ids: Vec<&str> = many.iter().map(|(id, _)| *id).collect();
                Err(TixError::Config(format!(
                    "template name `{reference}` is ambiguous; it matches ids {}",
                    ids.join(", ")
                )))
            }
        }
    }

    /// A deterministic new ID for a duplicate: `<source>-copy`, then
    /// `<source>-copy-2`, `-3`, … for the first handle no stored template uses.
    ///
    /// Deterministic rather than random because a template ID is a human handle:
    /// the same duplicate run twice on the same source names the same target, and
    /// a collision is resolved by counting rather than by redrawing.
    #[must_use]
    fn allocate_duplicate_id(&self, source: &str) -> String {
        let registry = &self.config.issue_templates;
        let first = format!("{source}-copy");
        if !registry.contains(&first) {
            return first;
        }
        let mut suffix = 2u32;
        loop {
            let candidate = format!("{source}-copy-{suffix}");
            if !registry.contains(&candidate) {
                return candidate;
            }
            suffix += 1;
        }
    }

    /// Validate the whole candidate registry against this config and persist it
    /// through the existing atomic save.
    ///
    /// The save is a clone-validate-save-swap: the in-memory config is only
    /// replaced once the bytes are on disk, so a refusal or an I/O failure leaves
    /// both the registry and `tix.toml` exactly as they were.
    fn commit(&mut self, next: TemplateRegistry) -> std::result::Result<(), TemplateWriteError> {
        let mut candidate = self.config.clone();
        candidate.issue_templates = next;
        candidate.validate().map_err(TemplateWriteError::Refused)?;
        candidate
            .save(self.path)
            .map_err(TemplateWriteError::Failed)?;
        *self.config = candidate;
        Ok(())
    }
}

/// The display name of a stored template.
fn display_name_of(registry: &TemplateRegistry, id: &str) -> String {
    registry.get(id).map_or_else(
        || id.to_string(),
        |template| template.display_name(id).to_string(),
    )
}

/// The stored template for `id`.
fn candidate_template(registry: &TemplateRegistry, id: &str) -> Template {
    registry
        .get(id)
        .expect("the just-inserted template is in the registry")
        .clone()
}

#[cfg(test)]
mod tests {
    #[allow(clippy::wildcard_imports)]
    use super::*;
    use crate::config::Config;
    use std::fs;

    fn fixture(raw: &str) -> (tempfile::TempDir, Config) {
        let dir = tempfile::TempDir::new().unwrap();
        let path = dir.path().join("tix.toml");
        fs::write(&path, raw).unwrap();
        let config = Config::load(dir.path()).unwrap();
        (dir, config)
    }

    fn definition(name: &str) -> TemplateDefinition {
        TemplateDefinition::new(name, TemplateScope::repository())
    }

    #[test]
    fn create_then_show_round_trips_and_reloads() {
        let (dir, mut config) = fixture("issues_dir = \"docs/issues\"\n");
        let path = dir.path().join("tix.toml");

        let mut service = TemplateService::new(&mut config, &path);
        let outcome = service.create("bug", definition("Bug report")).unwrap();
        assert_eq!(
            outcome.audit().unwrap().kind(),
            TemplateMutationKind::Create
        );
        assert_eq!(outcome.value(), "bug");

        let detail = service.show("bug").unwrap();
        assert_eq!(detail.id(), "bug");
        assert_eq!(detail.display_name(), "Bug report");
        assert!(detail.scope().is_repository());

        // The write reached disk and survives a reload.
        let reloaded = Config::load(dir.path()).unwrap();
        assert_eq!(
            reloaded.issue_templates.get("bug").unwrap().name.as_deref(),
            Some("Bug report")
        );
    }

    #[test]
    fn a_same_content_update_is_a_noop() {
        let (dir, mut config) = fixture("issues_dir = \"docs/issues\"\n");
        let path = dir.path().join("tix.toml");
        let mut service = TemplateService::new(&mut config, &path);
        service.create("bug", definition("Bug report")).unwrap();

        let outcome = service.update("bug", definition("Bug report")).unwrap();
        assert!(outcome.is_noop());
    }
}