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}