Skip to main content

tix/templates/
management.rs

1//! The template management service (WP03; mission `issue-form-templates-01M3C76V`).
2//!
3//! This is the **operation layer** over WP01's registry
4//! ([`crate::templates::registry`]). It adds no storage of its own and no second
5//! issue writer:
6//!
7//! - every read (`list`, `show`) borrows the registry and returns derived copies;
8//! - every write (`create`, `update`, `duplicate`, `delete`) builds a **candidate**
9//!   registry, validates the complete definition against the caller's [`Config`],
10//!   and only then persists through the existing atomic
11//!   [`Config::save`] — a refused or failed operation writes nothing
12//!   (`data-model.md` §5, `contracts/template-registry.md`);
13//! - identity is the stable ID. A rename replaces the display label and nothing
14//!   else, so selection by ID keeps scope, defaults, prompts, references, and
15//!   preserved unknown metadata intact (C-002/C-006);
16//! - delete removes a **definition** and nothing else. Issues created from a
17//!   template live in the issue tree and are never named here, so delete cannot
18//!   reach them (C-001).
19//!
20//! # Read-only versus write operations
21//!
22//! The split is visible in the signatures:
23//!
24//! | operation | returns | writes |
25//! |---|---|---|
26//! | `list`, `show` | `Result<..>` | nothing |
27//! | `create`, `update`, `duplicate`, `delete` | `TemplateMutation<..>` | one atomic `tix.toml` save |
28//!
29//! A read can only be refused (a plain [`TixError`]). A write can also *fail*:
30//! the registry already models a refusal differently from an infrastructure
31//! failure, and [`TemplateWriteError`] keeps that distinction rather than
32//! flattening it.
33//!
34//! # Auditable, idempotent mutations
35//!
36//! An accepted mutation returns a [`TemplateOutcome`] carrying exactly one
37//! [`TemplateAuditEvent`] — what changed, for which stable ID, under which
38//! display name at the time of the operation. A refused or failed operation
39//! carries **no** event, because nothing happened. A same-content `update` is a
40//! no-op: no save is issued and the outcome reports [`TemplateOutcome::is_noop`].
41//! Committing the same registry twice writes the same bytes, so re-applying an
42//! operation converges.
43//!
44//! # Definition-only delete
45//!
46//! [`TemplateService::delete`] cannot be called without a [`DeleteConfirmed`], a
47//! token whose field is private so "I forgot to confirm" is a compile error
48//! rather than a defaulted `true`. It removes the definition and nothing else:
49//! no issue, comment, relationship, or board data is touched.
50
51use std::fmt;
52use std::path::Path;
53
54use crate::config::Config;
55use crate::error::{Result, TixError};
56use crate::templates::registry::{
57    Template, TemplateDefaults, TemplateMetadata, TemplatePrompt, TemplateReference,
58    TemplateRegistry, TemplateScope,
59};
60
61/// The outcome of a service **write**: an accepted value plus its audit event, or
62/// a refusal/failure that wrote nothing.
63pub type TemplateMutation<T> = std::result::Result<TemplateOutcome<T>, TemplateWriteError>;
64
65/// Why a template write did not happen.
66///
67/// The two cases are deliberately distinct. [`Refused`](Self::Refused) is the
68/// registry/config declining the change — a bad ID, an unknown scope, an
69/// unsupported field: nothing was written, and the caller should show the
70/// message. [`Failed`](Self::Failed) is infrastructure — the atomic save could
71/// not complete. In both cases `tix.toml` is unchanged; only the remedy differs.
72#[derive(Debug)]
73pub enum TemplateWriteError {
74    /// The candidate was invalid; the registry and `tix.toml` are unchanged.
75    Refused(TixError),
76    /// The candidate was valid but the atomic save failed; `tix.toml` is unchanged.
77    Failed(TixError),
78}
79
80impl TemplateWriteError {
81    /// The underlying error, whichever kind this is.
82    #[must_use]
83    pub fn inner(&self) -> &TixError {
84        match self {
85            Self::Refused(error) | Self::Failed(error) => error,
86        }
87    }
88
89    /// Consume the error for its underlying [`TixError`].
90    #[must_use]
91    pub fn into_inner(self) -> TixError {
92        match self {
93            Self::Refused(error) | Self::Failed(error) => error,
94        }
95    }
96
97    /// Whether this is a validation refusal rather than an infrastructure failure.
98    #[must_use]
99    pub const fn is_refusal(&self) -> bool {
100        matches!(self, Self::Refused(_))
101    }
102}
103
104impl From<TixError> for TemplateWriteError {
105    /// A bare error from the registry is a **refusal** by default: the registry's
106    /// failures are all validation, and the save path maps its own error to
107    /// [`TemplateWriteError::Failed`] explicitly.
108    fn from(error: TixError) -> Self {
109        Self::Refused(error)
110    }
111}
112
113impl fmt::Display for TemplateWriteError {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        match self {
116            Self::Refused(error) => write!(f, "template refused: {error}"),
117            Self::Failed(error) => write!(f, "template write failed: {error}"),
118        }
119    }
120}
121
122impl std::error::Error for TemplateWriteError {
123    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
124        Some(self.inner())
125    }
126}
127
128/// What kind of registry mutation an accepted operation performed.
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130pub enum TemplateMutationKind {
131    /// A new definition was added under a caller-supplied stable ID.
132    Create,
133    /// An existing definition's name, scope, and data were replaced (ID unchanged).
134    Update,
135    /// A new definition was copied from an existing one under a new stable ID.
136    Duplicate,
137    /// A definition was removed. Only the definition.
138    Delete,
139}
140
141impl TemplateMutationKind {
142    /// The stable name of this mutation, for output and matching.
143    #[must_use]
144    pub const fn as_str(self) -> &'static str {
145        match self {
146            Self::Create => "create",
147            Self::Update => "update",
148            Self::Duplicate => "duplicate",
149            Self::Delete => "delete",
150        }
151    }
152}
153
154impl fmt::Display for TemplateMutationKind {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        f.write_str(self.as_str())
157    }
158}
159
160/// The auditable record of one **accepted** registry mutation.
161///
162/// Emitted once per accepted change, and never for a refusal or a no-op. The
163/// template ID is identity; the name is the display label *at the time of the
164/// operation*, kept for a readable history even after a later rename.
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct TemplateAuditEvent {
167    kind: TemplateMutationKind,
168    template_id: String,
169    template_name: String,
170}
171
172impl TemplateAuditEvent {
173    fn new(kind: TemplateMutationKind, template_id: String, template_name: String) -> Self {
174        Self {
175            kind,
176            template_id,
177            template_name,
178        }
179    }
180
181    /// What kind of mutation this was.
182    #[must_use]
183    pub const fn kind(&self) -> TemplateMutationKind {
184        self.kind
185    }
186
187    /// The stable ID of the affected definition.
188    #[must_use]
189    pub fn template_id(&self) -> &str {
190        &self.template_id
191    }
192
193    /// The display name the definition carried when the mutation was accepted.
194    #[must_use]
195    pub fn template_name(&self) -> &str {
196        &self.template_name
197    }
198}
199
200/// An accepted operation's value and its audit event.
201///
202/// `audit` is `None` exactly when the candidate registry equalled the current
203/// one — an idempotent no-op that issued no save.
204#[derive(Debug, Clone, PartialEq)]
205pub struct TemplateOutcome<T> {
206    value: T,
207    audit: Option<TemplateAuditEvent>,
208}
209
210impl<T> TemplateOutcome<T> {
211    fn accepted(value: T, audit: TemplateAuditEvent) -> Self {
212        Self {
213            value,
214            audit: Some(audit),
215        }
216    }
217
218    fn unchanged(value: T) -> Self {
219        Self { value, audit: None }
220    }
221
222    /// The operation's result value.
223    #[must_use]
224    pub fn value(&self) -> &T {
225        &self.value
226    }
227
228    /// Consume the outcome for its value.
229    #[must_use]
230    pub fn into_value(self) -> T {
231        self.value
232    }
233
234    /// The one audit event, or `None` for an idempotent no-op.
235    #[must_use]
236    pub fn audit(&self) -> Option<&TemplateAuditEvent> {
237        self.audit.as_ref()
238    }
239
240    /// Whether the operation changed nothing (no save, no event).
241    #[must_use]
242    pub fn is_noop(&self) -> bool {
243        self.audit.is_none()
244    }
245}
246
247/// Explicit confirmation for a definition-only delete.
248///
249/// The private field is the whole point: the token cannot be built outside this
250/// module, so [`TemplateService::delete`] is not callable without a deliberate
251/// confirmation. There is no `Default` and no `From<bool>`.
252#[derive(Debug, Clone, Copy, PartialEq, Eq)]
253pub struct DeleteConfirmed(());
254
255impl DeleteConfirmed {
256    /// Acknowledge that only the template **definition** is being removed — never
257    /// issues created from it and never their provenance.
258    #[must_use]
259    pub const fn confirmed() -> Self {
260        Self(())
261    }
262}
263
264/// A definition handed to `create`/`update`: the mutable fields of a record.
265///
266/// Every field is optional and means **"supply this"**. `create` fills an absent
267/// field with its default (no name, repository scope, no defaults/prompts/
268/// references); `update` leaves an absent field exactly as it was. That is what
269/// makes a rename a rename: [`TemplateDefinition::rename`] names a new label and
270/// touches nothing else, so scope, defaults, prompts, and references survive it
271/// (C-002).
272///
273/// The ID and the preserved extension fields are **not** here: identity is the
274/// registry key, and extensions survive an update untouched, so a caller cannot
275/// accidentally drop either (C-006).
276#[derive(Debug, Clone, PartialEq, Default)]
277pub struct TemplateDefinition {
278    name: Option<String>,
279    scope: Option<TemplateScope>,
280    defaults: Option<TemplateDefaults>,
281    prompts: Option<Vec<TemplatePrompt>>,
282    references: Option<Vec<TemplateReference>>,
283}
284
285impl TemplateDefinition {
286    /// A named, scoped definition with no defaults, prompts, or references.
287    #[must_use]
288    pub fn new(name: impl Into<String>, scope: TemplateScope) -> Self {
289        Self {
290            name: Some(name.into()),
291            scope: Some(scope),
292            ..Self::default()
293        }
294    }
295
296    /// A definition with no display name; the stable ID is shown in its place.
297    #[must_use]
298    pub fn unnamed(scope: TemplateScope) -> Self {
299        Self {
300            scope: Some(scope),
301            ..Self::default()
302        }
303    }
304
305    /// A rename: a new display label and nothing else.
306    ///
307    /// The only field carried is the name, so an update through it cannot change
308    /// the scope, defaults, prompts, or references of the record it renames.
309    #[must_use]
310    pub fn rename(name: impl Into<String>) -> Self {
311        Self {
312            name: Some(name.into()),
313            ..Self::default()
314        }
315    }
316
317    /// Set the normal create-request defaults.
318    #[must_use]
319    pub fn with_defaults(mut self, defaults: TemplateDefaults) -> Self {
320        self.defaults = Some(defaults);
321        self
322    }
323
324    /// Set the ordered structured prompts.
325    #[must_use]
326    pub fn with_prompts(mut self, prompts: Vec<TemplatePrompt>) -> Self {
327        self.prompts = Some(prompts);
328        self
329    }
330
331    /// Set the typed references.
332    #[must_use]
333    pub fn with_references(mut self, references: Vec<TemplateReference>) -> Self {
334        self.references = Some(references);
335        self
336    }
337
338    /// A fresh record for `create`, with no preserved metadata.
339    fn into_template(self) -> Template {
340        Template {
341            name: self.name,
342            scope: self.scope.unwrap_or_default(),
343            defaults: self.defaults.unwrap_or_default(),
344            prompts: self.prompts.unwrap_or_default(),
345            references: self.references.unwrap_or_default(),
346            ..Template::default()
347        }
348    }
349
350    /// Apply the supplied fields to an existing record, leaving the rest as-is.
351    fn apply_to(self, template: &mut Template) {
352        if let Some(name) = self.name {
353            template.name = Some(name);
354        }
355        if let Some(scope) = self.scope {
356            template.scope = scope;
357        }
358        if let Some(defaults) = self.defaults {
359            template.defaults = defaults;
360        }
361        if let Some(prompts) = self.prompts {
362            template.prompts = prompts;
363        }
364        if let Some(references) = self.references {
365            template.references = references;
366        }
367    }
368}
369
370/// A definition shown to a caller: the stable ID and the full stored record.
371///
372/// The record carries the display name, scope, defaults, prompts, references, and
373/// any preserved unknown metadata, so a surface renders everything without
374/// reaching back into the registry (T013).
375#[derive(Debug, Clone, PartialEq)]
376pub struct TemplateDetail {
377    id: String,
378    template: Template,
379}
380
381impl TemplateDetail {
382    fn new(id: String, template: Template) -> Self {
383        Self { id, template }
384    }
385
386    /// The immutable stable ID.
387    #[must_use]
388    pub fn id(&self) -> &str {
389        &self.id
390    }
391
392    /// The stored record.
393    #[must_use]
394    pub fn template(&self) -> &Template {
395        &self.template
396    }
397
398    /// The display label, falling back to the stable ID.
399    #[must_use]
400    pub fn display_name(&self) -> &str {
401        self.template.display_name(&self.id)
402    }
403
404    /// The explicit scope.
405    #[must_use]
406    pub fn scope(&self) -> &TemplateScope {
407        &self.template.scope
408    }
409
410    /// The normal create-request defaults.
411    #[must_use]
412    pub fn defaults(&self) -> &TemplateDefaults {
413        &self.template.defaults
414    }
415
416    /// The ordered structured prompts.
417    #[must_use]
418    pub fn prompts(&self) -> &[TemplatePrompt] {
419        &self.template.prompts
420    }
421
422    /// The typed references.
423    #[must_use]
424    pub fn references(&self) -> &[TemplateReference] {
425        &self.template.references
426    }
427
428    /// Preserved unknown metadata, never interpreted.
429    #[must_use]
430    pub fn extensions(&self) -> &TemplateMetadata {
431        &self.template.extensions
432    }
433}
434
435/// The template service: the caller's registry-bearing [`Config`] and the
436/// `tix.toml` path to save it through.
437///
438/// The service borrows the caller's config mutably because an accepted mutation
439/// *is* `config.issue_templates` changing; it never reaches around the registry
440/// to write anything else. There is no separate file and no hosted state — the
441/// existing atomic [`Config::save`] is the persistence (C-001).
442pub struct TemplateService<'a> {
443    config: &'a mut Config,
444    path: &'a Path,
445}
446
447impl<'a> TemplateService<'a> {
448    /// A service over a caller's config and `tix.toml` path.
449    #[must_use]
450    pub fn new(config: &'a mut Config, path: &'a Path) -> Self {
451        Self { config, path }
452    }
453
454    /// The registry, for a surface that must enumerate or render it directly.
455    #[must_use]
456    pub fn registry(&self) -> &TemplateRegistry {
457        &self.config.issue_templates
458    }
459
460    // -- read-only --------------------------------------------------------
461
462    /// Every stored definition, with its stable ID, in declaration order (T013).
463    ///
464    /// Declaration order is the registry's order, which the TOML loader restores
465    /// from the document's section headers, so the list is deterministic across
466    /// load/save round trips.
467    #[must_use]
468    pub fn list(&self) -> Vec<TemplateDetail> {
469        self.config
470            .issue_templates
471            .iter()
472            .map(|(id, template)| TemplateDetail::new(id.to_string(), template.clone()))
473            .collect()
474    }
475
476    /// Resolve one definition by stable ID, or by a display name that names
477    /// exactly one record, and return the full detail (T013).
478    ///
479    /// The ID is authoritative and is tried first; a name is a convenience, so an
480    /// ambiguous name is refused rather than guessed. Unknown references are a
481    /// `not found` error that names the request.
482    pub fn show(&self, reference: &str) -> Result<TemplateDetail> {
483        let (id, template) = self.select(reference)?;
484        Ok(TemplateDetail::new(id, template))
485    }
486
487    // -- mutations --------------------------------------------------------
488
489    /// Create a definition under the caller's stable ID (T014).
490    ///
491    /// The ID must be a well-formed handle no stored template already uses; the
492    /// complete definition is validated before the atomic save, so a rejected
493    /// create writes nothing.
494    pub fn create(&mut self, id: &str, definition: TemplateDefinition) -> TemplateMutation<String> {
495        let mut candidate = self.config.issue_templates.clone();
496        candidate
497            .insert(id, definition.into_template())
498            .map_err(TemplateWriteError::Refused)?;
499
500        let id = id.to_string();
501        let name = display_name_of(&candidate, &id);
502        self.commit(candidate)?;
503        Ok(TemplateOutcome::accepted(
504            id.clone(),
505            TemplateAuditEvent::new(TemplateMutationKind::Create, id, name),
506        ))
507    }
508
509    /// Replace an existing definition's supplied fields in place (T014).
510    ///
511    /// Only the fields the caller supplies change; an absent field is left as it
512    /// was. The stable ID never changes — identity survives a rename (C-002) —
513    /// and the record's preserved extension fields are left untouched (C-006). A
514    /// same-content update is a no-op: no save, no audit event.
515    pub fn update(
516        &mut self,
517        reference: &str,
518        definition: TemplateDefinition,
519    ) -> TemplateMutation<String> {
520        let (id, _source) = self.select(reference)?;
521
522        let mut candidate = self.config.issue_templates.clone();
523        let template = candidate
524            .get_mut(&id)
525            .expect("the selected template is in the registry");
526        definition.apply_to(template);
527        // `extensions` is deliberately not assigned: unknown metadata survives
528        // an update unchanged.
529
530        if candidate == self.config.issue_templates {
531            return Ok(TemplateOutcome::unchanged(id));
532        }
533        let name = display_name_of(&candidate, &id);
534        self.commit(candidate)?;
535        Ok(TemplateOutcome::accepted(
536            id.clone(),
537            TemplateAuditEvent::new(TemplateMutationKind::Update, id, name),
538        ))
539    }
540
541    /// Duplicate a definition under a new stable ID (T015).
542    ///
543    /// The copy carries the source's scope, defaults, prompts, references, and
544    /// preserved extensions verbatim; only the ID is new, and the source is left
545    /// exactly as it was. `new_id` overrides the deterministic `<source>-copy`
546    /// allocation; `name` overrides the label when given. The new definition is
547    /// reported explicitly in the outcome value.
548    pub fn duplicate(
549        &mut self,
550        reference: &str,
551        new_id: Option<&str>,
552        name: Option<&str>,
553    ) -> TemplateMutation<TemplateDetail> {
554        let (source_id, source) = self.select(reference)?;
555        let target_id = match new_id {
556            Some(id) => id.to_string(),
557            None => self.allocate_duplicate_id(&source_id),
558        };
559
560        let mut copy = source;
561        if let Some(name) = name {
562            copy.name = Some(name.trim().to_string());
563        }
564
565        let mut candidate = self.config.issue_templates.clone();
566        candidate
567            .insert(target_id.clone(), copy)
568            .map_err(TemplateWriteError::Refused)?;
569
570        let detail = TemplateDetail::new(
571            target_id.clone(),
572            candidate_template(&candidate, &target_id),
573        );
574        let name = detail.display_name().to_string();
575        self.commit(candidate)?;
576        Ok(TemplateOutcome::accepted(
577            detail,
578            TemplateAuditEvent::new(TemplateMutationKind::Duplicate, target_id, name),
579        ))
580    }
581
582    /// Delete a template **definition** after explicit confirmation (T016).
583    ///
584    /// Only the definition is removed. Issues created from the template are files
585    /// in the issue tree that this service never names, so their bytes and their
586    /// provenance are untouched. Deleting an unknown reference is refused —
587    /// nothing was stored to remove. The removed definition is reported.
588    pub fn delete(
589        &mut self,
590        reference: &str,
591        _confirmation: DeleteConfirmed,
592    ) -> TemplateMutation<TemplateDetail> {
593        let (id, _source) = self.select(reference)?;
594
595        let mut candidate = self.config.issue_templates.clone();
596        let removed = candidate
597            .remove(&id)
598            .expect("the selected template is in the registry");
599        let detail = TemplateDetail::new(id.clone(), removed);
600
601        let name = detail.display_name().to_string();
602        self.commit(candidate)?;
603        Ok(TemplateOutcome::accepted(
604            detail,
605            TemplateAuditEvent::new(TemplateMutationKind::Delete, id, name),
606        ))
607    }
608
609    // -- internals --------------------------------------------------------
610
611    /// Resolve a reference to `(stable ID, record)`.
612    ///
613    /// The ID wins; a display name is accepted only when exactly one record
614    /// carries it. Anything else is a `not found` or an ambiguity error, never a
615    /// guess.
616    fn select(&self, reference: &str) -> Result<(String, Template)> {
617        if let Some(template) = self.config.issue_templates.get(reference) {
618            return Ok((reference.to_string(), template.clone()));
619        }
620        let matches: Vec<(&str, &Template)> = self
621            .config
622            .issue_templates
623            .iter()
624            .filter(|(_, template)| template.name.as_deref() == Some(reference))
625            .collect();
626        match matches.as_slice() {
627            [] => Err(TixError::NotFound(format!(
628                "no template with id or name `{reference}`"
629            ))),
630            [(id, template)] => Ok(((*id).to_string(), (*template).clone())),
631            many => {
632                let ids: Vec<&str> = many.iter().map(|(id, _)| *id).collect();
633                Err(TixError::Config(format!(
634                    "template name `{reference}` is ambiguous; it matches ids {}",
635                    ids.join(", ")
636                )))
637            }
638        }
639    }
640
641    /// A deterministic new ID for a duplicate: `<source>-copy`, then
642    /// `<source>-copy-2`, `-3`, … for the first handle no stored template uses.
643    ///
644    /// Deterministic rather than random because a template ID is a human handle:
645    /// the same duplicate run twice on the same source names the same target, and
646    /// a collision is resolved by counting rather than by redrawing.
647    #[must_use]
648    fn allocate_duplicate_id(&self, source: &str) -> String {
649        let registry = &self.config.issue_templates;
650        let first = format!("{source}-copy");
651        if !registry.contains(&first) {
652            return first;
653        }
654        let mut suffix = 2u32;
655        loop {
656            let candidate = format!("{source}-copy-{suffix}");
657            if !registry.contains(&candidate) {
658                return candidate;
659            }
660            suffix += 1;
661        }
662    }
663
664    /// Validate the whole candidate registry against this config and persist it
665    /// through the existing atomic save.
666    ///
667    /// The save is a clone-validate-save-swap: the in-memory config is only
668    /// replaced once the bytes are on disk, so a refusal or an I/O failure leaves
669    /// both the registry and `tix.toml` exactly as they were.
670    fn commit(&mut self, next: TemplateRegistry) -> std::result::Result<(), TemplateWriteError> {
671        let mut candidate = self.config.clone();
672        candidate.issue_templates = next;
673        candidate.validate().map_err(TemplateWriteError::Refused)?;
674        candidate
675            .save(self.path)
676            .map_err(TemplateWriteError::Failed)?;
677        *self.config = candidate;
678        Ok(())
679    }
680}
681
682/// The display name of a stored template.
683fn display_name_of(registry: &TemplateRegistry, id: &str) -> String {
684    registry.get(id).map_or_else(
685        || id.to_string(),
686        |template| template.display_name(id).to_string(),
687    )
688}
689
690/// The stored template for `id`.
691fn candidate_template(registry: &TemplateRegistry, id: &str) -> Template {
692    registry
693        .get(id)
694        .expect("the just-inserted template is in the registry")
695        .clone()
696}
697
698#[cfg(test)]
699mod tests {
700    #[allow(clippy::wildcard_imports)]
701    use super::*;
702    use crate::config::Config;
703    use std::fs;
704
705    fn fixture(raw: &str) -> (tempfile::TempDir, Config) {
706        let dir = tempfile::TempDir::new().unwrap();
707        let path = dir.path().join("tix.toml");
708        fs::write(&path, raw).unwrap();
709        let config = Config::load(dir.path()).unwrap();
710        (dir, config)
711    }
712
713    fn definition(name: &str) -> TemplateDefinition {
714        TemplateDefinition::new(name, TemplateScope::repository())
715    }
716
717    #[test]
718    fn create_then_show_round_trips_and_reloads() {
719        let (dir, mut config) = fixture("issues_dir = \"docs/issues\"\n");
720        let path = dir.path().join("tix.toml");
721
722        let mut service = TemplateService::new(&mut config, &path);
723        let outcome = service.create("bug", definition("Bug report")).unwrap();
724        assert_eq!(
725            outcome.audit().unwrap().kind(),
726            TemplateMutationKind::Create
727        );
728        assert_eq!(outcome.value(), "bug");
729
730        let detail = service.show("bug").unwrap();
731        assert_eq!(detail.id(), "bug");
732        assert_eq!(detail.display_name(), "Bug report");
733        assert!(detail.scope().is_repository());
734
735        // The write reached disk and survives a reload.
736        let reloaded = Config::load(dir.path()).unwrap();
737        assert_eq!(
738            reloaded.issue_templates.get("bug").unwrap().name.as_deref(),
739            Some("Bug report")
740        );
741    }
742
743    #[test]
744    fn a_same_content_update_is_a_noop() {
745        let (dir, mut config) = fixture("issues_dir = \"docs/issues\"\n");
746        let path = dir.path().join("tix.toml");
747        let mut service = TemplateService::new(&mut config, &path);
748        service.create("bug", definition("Bug report")).unwrap();
749
750        let outcome = service.update("bug", definition("Bug report")).unwrap();
751        assert!(outcome.is_noop());
752    }
753}