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
#[allow(clippy::wildcard_imports)]
use super::*;

impl Store {
    /// Create an issue file atomically and assign its number by scanning the
    /// live issue set (issue #52): the highest existing `number` plus one,
    /// with a floor of `0` for an empty store.
    ///
    /// The number is resolved at creation time rather than read from a
    /// persisted `[state].next_number` counter, so `tix.toml` is never
    /// rewritten by `create`. A deleted high-numbered issue's number can be
    /// reused by a later create; the frontmatter `number` stays stable for
    /// every surviving issue (C-007).
    ///
    /// The file is written into the assignee segment derived from
    /// `issue.assignee` (`unassigned/` fallback, FR-029/FR-030), creating the
    /// segment directory as needed, and `issue.filename` is set to the
    /// relative path so `create` output and later edits agree. Atomic-write
    /// discipline (temp + rename, NFR-006) is preserved.
    pub fn create(
        &self,
        issue: &mut Issue,
        body: &str,
        filename_stem: Option<&str>,
    ) -> Result<PathBuf> {
        issue.body = body.to_string();
        issue.bump_updated();

        std::fs::create_dir_all(&self.issues_dir)?;
        issue.number = self.resolve_next_number()?;

        // Claim the number **before** anything is serialised, and hold it across the
        // whole write. `resolve_next_number` above reads the directory, takes `max`,
        // and adds 1, which is only a *hint*: two processes reaching this line
        // together both get the same answer and both proceed. See [`NumberClaim`]
        // for what that cost and why an `O_EXCL` marker is the fix.
        //
        // **Before** the serialisation, specifically. `to_file_contents` embeds
        // `number:` in the frontmatter, so serialising first and claiming after —
        // which is the order this was first written in — writes a *stale* number into
        // the file while the ledger records the correct one. The 24-create test still
        // saw 8 distinct numbers with 24 valid claims in place, because every claim
        // was correct and every file was wrong. A claim that does not reach the bytes
        // on disk is not a claim.
        let mut claim = NumberClaim::acquire(&self.issues_dir, issue.number)?;
        // Belt and braces (#1352): the number actually claimed must be the number
        // about to be written. `acquire` only climbs when the requested number is
        // already taken, so this is normally an identity — but it means a future
        // change to either side that drifts from the other fails loudly here
        // instead of writing an issue whose `number:` disagrees with the ledger.
        issue.number = claim.number().ok_or_else(|| {
            TixError::Io(std::io::Error::new(
                std::io::ErrorKind::InvalidData,
                format!("claimed number is unreadable from {}", claim.path.display()),
            ))
        })?;

        // Assign the globally unique identity before anything is serialised. The
        // `id:` frontmatter line and the filename prefix both come from it, so it
        // must be fixed before `to_file_contents` and before `layout::render`. A
        // caller that already set one (an import, or a test) keeps it; this is the
        // single place every create path — CLI, MCP, TUI, sync — flows through.
        if issue.id.is_none() {
            issue.id = Some(crate::id::generate_issue_id());
        }

        // WP02 layout, now configurable ([layout] in tix.toml): the directory is
        // rendered from the configured template and may be nested; `filename_stem`
        // still overrides the filename for callers that pin a stem.
        let rel = crate::layout::render(&self.config.layout, issue);
        let (dir, file) = rel.rsplit_once('/').unwrap_or(("", rel.as_str()));
        std::fs::create_dir_all(self.issues_dir.join(dir))?;

        let stem = match filename_stem {
            Some(s) => s.to_string(),
            None => file.strip_suffix(".md").unwrap_or(file).to_string(),
        };
        // Serialised **after** the number is fixed, so the `number:` in the file is
        // the claimed one.
        let contents = issue.to_file_contents();

        let written = self.write_new_issue_file(dir, &stem, contents.as_bytes());
        // The claim is now permanent: the issue file exists, so that file — not the
        // marker — is the authority for the number, and dropping the marker would
        // reopen the window this exists to close. A create that *failed* releases
        // its claim instead, so a failure does not leak a number for nothing.
        match written {
            Ok((filename, path)) => {
                claim.commit();
                issue.filename = filename;
                self.reconcile_mentions(issue, &issue.filename);
                Ok(path)
            }
            Err(e) => {
                claim.release();
                Err(e)
            }
        }
    }

    /// Write **new** issue file, never overwriting an existing one.
    ///
    /// The stem was `{created}-{slugified title}` at the time of this bug (a
    /// new-schema name now also carries the issue id:
    /// `{created}-{id}-{slugified title}`), so it was a function of the issue's
    /// creation date, its title, and its assignee segment — three things routinely
    /// identical across two issues. Two `tix create --title "Same title"` runs on
    /// the same day therefore resolved to the **same path**, and `atomic_write`
    /// renames over the target, so the second create silently destroyed the first.
    /// No concurrency was required, and both commands reported success:
    ///
    /// ```text
    /// $ tix create --title "Same title" --priority high
    /// Created herbert-maschke/2026-09-28-same-title.md
    /// $ tix create --title "Same title" --priority low
    /// Created herbert-maschke/2026-09-28-same-title.md
    /// $ ls docs/issues/*/
    /// 2026-09-28-same-title.md            # one file
    /// $ tix list
    /// NUM  TITLE       STATUS  PRIORITY
    /// 2    Same title  todo    low       # issue #1 is gone
    /// ```
    ///
    /// Two layers, because the first alone is still a race:
    ///
    /// 1. **Probe** for a free name, appending `-2`, `-3`, … to the stem. This
    ///    handles the deterministic case, which is the common one.
    /// 2. **Create the file with `O_EXCL`** via [`write_file_new`]. Probing cannot
    ///    see a file another process creates between the probe and the write, and
    ///    `rename` overwrites unconditionally, so the final step is the one that
    ///    actually guarantees no clobber. Losing the race retries from step 1.
    ///
    /// Probing alone is the version that looks correct and is not: a second process
    /// that writes the same stem in the same millisecond would still be destroyed,
    /// and the retry-on-`AlreadyExists` below exists precisely because a reviewer
    /// should not have to take that on trust.
    pub(in crate::storage) fn write_new_issue_file(
        &self,
        segment: &str,
        stem: &str,
        contents: &[u8],
    ) -> Result<(String, PathBuf)> {
        const MAX_ATTEMPTS: usize = 1_000;
        let mut suffix = 1usize;
        for _ in 0..MAX_ATTEMPTS {
            let filename = if suffix == 1 {
                format!("{segment}/{stem}.md")
            } else {
                format!("{segment}/{stem}-{suffix}.md")
            };
            let path = self.issues_dir.join(&filename);
            match write_file_new(&self.issues_dir, &path, contents) {
                Ok(()) => {
                    journal_issue_change(Path::new(&filename), true);
                    return Ok((filename, path));
                }
                // Someone took this name between the probe and the write. The
                // collision is benign — it is only a *name* — so take the next one.
                Err(TixError::Io(e)) if e.kind() == std::io::ErrorKind::AlreadyExists => {
                    suffix += 1;
                }
                Err(e) => return Err(e),
            }
        }
        Err(TixError::Io(std::io::Error::new(
            std::io::ErrorKind::AlreadyExists,
            format!(
                "could not find a free filename for {segment}/{stem}.md in {MAX_ATTEMPTS} attempts"
            ),
        )))
    }

    /// The number for the next created issue: `max(number)` over the live
    /// issue set plus one, or `1` when no issues exist (issue #52 — no
    /// persisted counter to advance). Scanning the same list that every
    /// other command uses keeps number resolution conservative: reusing a
    /// number requires first deleting every higher-numbered issue.
    ///
    /// **The empty case is `1`, not `0`.** It used to be `0`, because `max()` over
    /// an empty set has no natural answer and `0` was the fallback — which made the
    /// first issue in every fresh repository `#0`, and that number is then
    /// indistinguishable from a sentinel, an uninitialised value, or a field that
    /// failed to read. Found by running the binary rather than reading the code
    /// (tix #1376); two places in the codebase had already written the opposite
    /// assumption into a comment, and a third had used `0` as a
    /// `HierarchyNode::NO_ISSUE_YET` marker.
    ///
    /// Only the empty case changes. With any issue present the answer is `max + 1`,
    /// which is unaffected — so no existing repository renumbers, and no migration
    /// is implied. (One real issue in this repository is numbered `0`, written
    /// before this fix; it keeps its number, because renumbering is a migration and
    /// the mission's doctrine makes those an escalation trigger.)
    pub(in crate::storage) fn resolve_next_number(&self) -> Result<u32> {
        // `list_all`, not `list`: an archived issue keeps its number forever.
        // Reading only the active set would let a `create` reuse the number of
        // the highest-numbered archived issue (issue #1492).
        let issues = self.list_all()?;
        match issues.iter().map(|i| i.number).max() {
            None => Ok(1),
            Some(max) => max
                .checked_add(1)
                .ok_or_else(|| TixError::InvalidInput("issue-number overflow".to_string())),
        }
    }

    /// Write `issue` over the file at `filename`, then reconcile the file's
    /// **location** with the configured `[layout]` (`move_on_change`).
    ///
    /// Returns the store-relative path the issue lives at afterwards — the same
    /// one in the common case, a new one when a templated field (status, type,
    /// assignee, cycle, …) now renders a different directory. **Every write path
    /// goes through here**, so no surface (CLI, MCP, TUI, triage, sync) can
    /// leave an issue at a path that disagrees with the layout.
    pub fn update(&self, filename: &str, issue: &Issue) -> Result<String> {
        validate_filename(filename)?;
        let path = self.issues_dir.join(filename);
        if !path.is_file() {
            return Err(TixError::NotFound(format!("file {filename}")));
        }
        // The retired `links:` key is not in the model, so re-serializing drops
        // it — and with it the `retired_links_key` finding, silently. The
        // consequence is that `tix lint` reports a problem, the user runs an
        // unrelated `tix edit`, and both the finding and the data vanish with no
        // notice. That is the defect; C-001's no-migration intent is not.
        //
        // The write still proceeds, because refusing would make tix unusable
        // across the repository: 1216 of 1290 issue files here carry `links:`.
        // What changes is that the removal is *disclosed*, naming the field and
        // the file, so nothing is lost quietly. Removing the key by hand first
        // silences this.
        if issue.has_retired_links_key {
            // Recorded on the store, not printed here. `eprintln!` inside a
            // library function is invisible to the MCP server and the TUI, so
            // the same operation produced different observable output per
            // surface, which `contracts/surface-parity.md` forbids.
            //
            // Drained at the three surface boundaries, not at each write path:
            // `main` for every CLI command, the MCP `serve` loop per request,
            // and the TUI event loop. Draining per command was tried and reached
            // only `link` and `edit`, so every other write path — `mv`,
            // `rollover`, planning, sync, the TUI, MCP — warned about nothing.
            // That was worse than the `eprintln!` it replaced, which fired from
            // inside `update` and so covered all of them. A count of write
            // paths is deliberately not stated here: it drifts, and a figure a
            // later reader trusts is worse than no figure. `tests/mcp.rs` pins
            // the behaviour instead, for `main` and the MCP server.
            retired_key_disclosure(filename);
        }
        let contents = issue.to_file_contents();
        atomic_write(&self.issues_dir, &path, contents.as_bytes())?;
        journal_issue_change(Path::new(filename), true);
        // Unconditional auto-migration: after any write, the file is moved to the
        // directory `[layout]` renders (subject only to `move_on_change`). A
        // legacy or mismatched path is migrated, not left behind.
        let relocated = self.reconcile_location(filename, issue)?;
        // The mention records name the issue by id and record the path they saw it at, so a
        // reconciliation after the move stores the path the issue actually lives at.
        self.reconcile_mentions(issue, &relocated);
        Ok(relocated)
    }

    /// Move the file at `filename` so its directory matches what `[layout]`
    /// renders for `issue`, when `move_on_change` is set and the directory
    /// differs. The basename (including the archived-marker dot) is preserved.
    /// Returns the relative path afterwards (unchanged when no move happens).
    pub(in crate::storage) fn reconcile_location(
        &self,
        filename: &str,
        issue: &Issue,
    ) -> Result<String> {
        if !self.config.layout.move_on_change {
            return Ok(filename.to_string());
        }
        let target_dir = crate::layout::render_directory(&self.config.layout, issue);
        let Some((old_dir, base)) = split_relative(filename) else {
            return Ok(filename.to_string());
        };
        if old_dir == target_dir {
            return Ok(filename.to_string());
        }
        let stem = base.strip_suffix(".md").unwrap_or(&base);
        let moved = self.move_issue_file(filename, &target_dir, stem)?;
        Ok(moved.to_string_lossy().to_string())
    }

    pub fn delete(&self, filename: &str) -> Result<()> {
        validate_filename(filename)?;
        let path = self.issues_dir.join(filename);
        if !path.is_file() {
            return Err(TixError::NotFound(format!("file {filename}")));
        }
        // Read the number **before** the file goes, so the claim ledger can be told
        // the number is free again (#1352).
        //
        // Releasing the claim is what preserves issue #52's documented behaviour:
        // the next number is re-derived from the live set, so deleting the
        // highest-numbered issue lets its number be reused. Without this the ledger
        // holds that number forever, the next create climbs past it, and the feature
        // stops working — which is a regression the existing test caught.
        //
        // The residual window, stated rather than hidden: if a `create` is in flight
        // holding this claim while the `delete` runs, the claim is released and a
        // later create could take the number too. That needs a delete and a create to
        // interleave, and it was *unconditionally* broken before this ledger existed;
        // the ledger removes the plain concurrent-create case, which is the one that
        // actually happens. Repairing the delete/create interleave would need a lock,
        // and this is the reason the ledger was chosen over one.
        let freed = std::fs::read_to_string(&path)
            .ok()
            .and_then(|text| frontmatter_number(&text));
        // The issue's canonical id, best-effort, for clearing its mention records. Read before
        // the file goes, and read through the same parse the rest of the store uses rather than
        // a second frontmatter scraper.
        let identity = self
            .get(IssueSelector::Filename(filename.to_string()))
            .ok()
            .and_then(|issue| issue.id);
        journal_record_write(&path);
        std::fs::remove_file(&path)?;
        journal_issue_change(Path::new(filename), false);
        if let Some(number) = freed {
            NumberClaim::release_number(&self.issues_dir, number);
        }
        if let Some(id) = identity {
            self.clear_mentions(&id);
        }
        // Best-effort cleanup of the `<slug>-assets/` dir (FR-008): the `.md`
        // removal is the contract, so an asset-dir removal failure is only
        // reported as a warning, never propagated.
        if let Some((segment, basename)) = split_relative(filename) {
            let stem = basename.strip_suffix(".md").unwrap_or(&basename);
            let slug = slug_from_stem(stem);
            let asset_dir = self
                .issues_dir
                .join(&segment)
                .join(format!("{slug}-assets"));
            if asset_dir.exists()
                && let Err(e) = std::fs::remove_dir_all(&asset_dir)
            {
                eprintln!(
                    "warning: failed to remove asset dir {}: {e}",
                    asset_dir.display()
                );
            }
        }
        Ok(())
    }

    /// Archive an issue: rename its file to a dot-prefixed name **in place**.
    ///
    /// The marker is the filename (issue #1492), so this is a rename within the
    /// same assignee segment (`x.md` ↔ `.x.md`) plus exactly one `## History`
    /// event. `number`, `status`, `created` and every other frontmatter field
    /// are left untouched, no data is dropped, and the file is never deleted or
    /// moved between directories.
    ///
    /// An already-archived issue is refused with the `already_archived`
    /// diagnostic rather than re-archived: the operation is not idempotent by
    /// design, because a second call would append a second audit entry for a
    /// mutation that did not happen.
    pub fn archive(&self, sel: &str) -> Result<Issue> {
        self.set_archived(sel, true)
    }

    /// Restore an archived issue: the inverse of [`Store::archive`].
    ///
    /// An active issue is refused with the `not_archived` diagnostic.
    pub fn restore(&self, sel: &str) -> Result<Issue> {
        self.set_archived(sel, false)
    }

    pub(in crate::storage) fn set_archived(&self, sel: &str, archive: bool) -> Result<Issue> {
        // `resolve_issue` → `parse_selector` → `list_all`, so an archived issue
        // resolves here even though the default view hides it.
        let mut issue = self.resolve_issue(sel)?;
        if issue.archived == archive {
            return Err(TixError::InvalidInput(format!(
                "{}: issue #{} ({}) is already {}",
                if archive {
                    "already_archived"
                } else {
                    "not_archived"
                },
                issue.number,
                issue.filename,
                if archive { "archived" } else { "active" },
            )));
        }
        let old_rel = issue.filename.clone();
        let new_rel = flip_archived(&old_rel, archive).ok_or_else(|| {
            TixError::InvalidInput(format!(
                "cannot {} {old_rel}: its filename does not carry the archived marker in the \
                 expected position",
                if archive { "archive" } else { "restore" }
            ))
        })?;
        let old_path = self.issues_dir.join(&old_rel);
        let new_path = self.issues_dir.join(&new_rel);
        if !old_path.is_file() {
            return Err(TixError::NotFound(format!("file {old_rel}")));
        }
        if new_path.exists() {
            return Err(TixError::InvalidInput(format!(
                "cannot {} {old_rel}: {new_rel} already exists",
                if archive { "archive" } else { "restore" }
            )));
        }
        // Rename first: it is atomic, stays in the same directory, and cannot
        // lose content. Only then is the audit event written, at the new name.
        // A failure between the two leaves the file renamed but without the
        // event — never a duplicate, never lost data.
        //
        // The rename is recorded in the write journal (#1580), so a command-level
        // `Store::transaction` around `tix archive`/`tix restore` can undo it: the
        // event write below is a *second* mutation, and a failure there must put
        // the file back at its active name rather than leave it archived without
        // its audit entry.
        journal_record_move(&old_path, &new_path);
        std::fs::rename(&old_path, &new_path)?;
        issue.filename = new_rel.clone();
        issue.archived = archive;
        issue.history.append_now(
            &actor_identity(),
            if archive {
                HistoryEventKind::Archived
            } else {
                HistoryEventKind::Restored
            },
            &format!("{old_rel} -> {new_rel}"),
        );
        // No `bump_updated`: `updated` is a frontmatter field and archiving must
        // leave every frontmatter field byte-identical.
        issue.filename = self.update(&new_rel, &issue)?;
        Ok(issue)
    }

    /// Physically move an issue file into the assignee segment of a new
    /// assignee (FR-031, `contracts/file-format.md` rule 9).
    ///
    /// `filename` is a path relative to the issues dir: either a legacy flat
    /// basename (`2026-05-10-fix-login.md`, C-010) or a grouped path
    /// (`alice/2026-05-10-fix-login.md`). It is validated via the relaxed
    /// [`validate_filename`] before anything touches the filesystem, so
    /// traversal attempts are refused here as everywhere else.
    ///
    /// The old assignee segment is read from the current relative path, the
    /// new one derived with [`derive_segment`]; equal segments are a no-op.
    /// The target filename keeps the original `created-slug` stem; when the
    /// target already holds `<stem>.md`, a deterministic `-1`, `-2`, …
    /// suffix is appended by testing existence — an existing file is never
    /// overwritten or deleted. `number`/`created` live in the frontmatter and
    /// are untouched, so issue-number stability is guaranteed by construction
    /// (C-007).
    ///
    /// The move is a single `std::fs::rename` (atomic-ish, same filesystem).
    /// Returns the **new relative path** (matching how `issue.filename` is
    /// stored) on success, including the unchanged relative path for the
    /// equal-segment no-op.
    pub fn reassign(&self, filename: &str, new_assignee: Option<&str>) -> Result<PathBuf> {
        // `get` validates `filename` and errors NotFound when it is missing.
        let issue = self.get(IssueSelector::Filename(filename.to_string()))?;
        let mut updated = issue.clone();
        updated.assignee = new_assignee.map(str::to_string);
        let target_dir = crate::layout::render_directory(&self.config.layout, &updated);
        let (old_dir, base) = split_relative(filename).ok_or_else(|| {
            TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
        })?;
        if old_dir == target_dir {
            return Ok(PathBuf::from(filename));
        }
        let stem = base.strip_suffix(".md").unwrap_or(&base);
        self.move_issue_file(filename, &target_dir, stem)
    }

    /// Relocate an issue file to an exact store-relative `target` (used by
    /// `tix layout migrate`). Frontmatter and number are preserved because
    /// nothing is rewritten — it is a rename; an occupied target is refused; the
    /// `<slug>-assets/` dir moves alongside.
    pub fn migrate_issue(&self, filename: &str, target: &str) -> Result<()> {
        if target == filename {
            return Ok(());
        }
        validate_filename(target)?;
        let from = self.issues_dir.join(filename);
        if !from.is_file() {
            return Err(TixError::NotFound(format!("file {filename}")));
        }
        let to = self.issues_dir.join(target);
        if to.exists() {
            return Err(TixError::InvalidInput(format!(
                "cannot migrate {filename}: {target} already exists"
            )));
        }
        if let Some(parent) = to.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::rename(&from, &to)?;
        let (old_dir, base) = split_relative(filename).unwrap_or_default();
        let stem = base.strip_suffix(".md").unwrap_or(&base);
        let asset_name = format!("{}-assets", slug_from_stem(stem));
        let old_asset_dir = self.issues_dir.join(&old_dir).join(&asset_name);
        if old_asset_dir.exists()
            && let Some(new_dir) = to.parent()
        {
            let moved = resolve_asset_dir_name(new_dir, &asset_name);
            std::fs::rename(&old_asset_dir, new_dir.join(&moved))?;
        }
        Ok(())
    }

    /// Move an issue file (and its `<slug>-assets/` dir) from `filename` to
    /// `<target_dir>/<stem>[-N].md`, creating the directory as needed. A target
    /// collision takes the first free `-N` suffix; nothing is overwritten.
    pub(in crate::storage) fn move_issue_file(
        &self,
        filename: &str,
        target_dir: &str,
        stem: &str,
    ) -> Result<PathBuf> {
        let target_dir_path = self.issues_dir.join(target_dir);
        std::fs::create_dir_all(&target_dir_path)?;
        let new_name = resolve_target_name(&target_dir_path, stem);
        let old_dir = split_relative(filename).map_or_else(String::new, |(d, _)| d);
        let from = self.issues_dir.join(filename);
        let to = target_dir_path.join(&new_name);
        journal_record_move(&from, &to);
        std::fs::rename(from, to)?;
        journal_issue_change(Path::new(filename), false);
        journal_issue_change(Path::new(&format!("{target_dir}/{new_name}")), true);
        // Move the `<slug>-assets/` dir alongside the file (FR-007). On a
        // same-named dir in the target, disambiguate rather than overwrite.
        let slug = slug_from_stem(stem);
        let asset_name = format!("{slug}-assets");
        let old_asset_dir = self.issues_dir.join(&old_dir).join(&asset_name);
        if old_asset_dir.exists() {
            let moved = resolve_asset_dir_name(&target_dir_path, &asset_name);
            let asset_target = target_dir_path.join(&moved);
            journal_record_move(&old_asset_dir, &asset_target);
            std::fs::rename(&old_asset_dir, &asset_target)?;
        }
        Ok(PathBuf::from(format!("{target_dir}/{new_name}")))
    }

    /// Move an issue to the directory its templated fields now render to, when
    /// `move_on_change` is set. The basename is preserved. Returns whether it
    /// moved; a no-op when already in place or when `move_on_change` is off.
    pub fn move_to_layout(&self, issue: &mut Issue) -> Result<bool> {
        let before = issue.filename.clone();
        let after = self.reconcile_location(&before, issue)?;
        issue.filename = after.clone();
        Ok(after != before)
    }

    /// Copy an asset into the issue's `<slug>-assets/` directory (FR-006) and
    /// insert a relative markdown link at the end of the body, persisted via
    /// [`Store::update`] (NFR-004).
    ///
    /// The asset dir is derived from the issue filename per
    /// `contracts/comments-format.md`: `<slug>-assets/` where `<slug>` is the
    /// basename stem minus the leading `YYYY-MM-DD-` date prefix. The copy is
    /// atomic (temp + rename, NFR-004); on a name collision the file is stored
    /// as `<stem>-<n>.<ext>` (first free `n`) and the inserted link points at
    /// the actual written name. Image extensions (`.png` `.jpg` `.jpeg`
    /// `.gif` `.svg` `.webp` `.bmp`) get the `![…](…)` form, everything else
    /// the `[…](…)` form. The link path is relative to the issue file, so the
    /// segment prefix is never part of it.
    pub fn add_asset(&self, filename: &str, source_path: &Path) -> Result<AssetAdded> {
        validate_filename(filename)?;
        let mut issue = self.get(IssueSelector::Filename(filename.to_string()))?;

        let (segment, basename) = split_relative(filename).ok_or_else(|| {
            TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
        })?;
        let stem = basename
            .strip_suffix(".md")
            .ok_or_else(|| {
                TixError::InvalidInput(format!(
                    "asset target must be a `.md` issue file: {filename}"
                ))
            })?
            .to_string();
        let slug = slug_from_stem(&stem);
        let dir_name = format!("{slug}-assets");
        let asset_dir = self.issues_dir.join(&segment).join(&dir_name);
        std::fs::create_dir_all(&asset_dir)?;

        let source_name = source_path
            .file_name()
            .and_then(|n| n.to_str())
            .ok_or_else(|| {
                TixError::InvalidInput("asset source must have a file name".to_string())
            })?
            .to_string();
        let written_name = resolve_asset_name(&asset_dir, &source_name);
        atomic_copy(&asset_dir, &asset_dir.join(&written_name), source_path)?;

        let label = written_name
            .rsplit_once('.')
            .map(|(stem, _)| stem)
            .unwrap_or(&written_name);
        let link = if is_image_name(&written_name) {
            format!("![{label}]({dir_name}/{written_name})")
        } else {
            format!("[{label}]({dir_name}/{written_name})")
        };

        // Append the link as a new line at the end of the body, reusing the
        // comment-appender newline discipline so the description is never
        // rewritten (FR-007).
        if !issue.body.is_empty() {
            if !issue.body.ends_with('\n') {
                issue.body.push('\n');
            }
            if !issue.body.ends_with("\n\n") {
                issue.body.push('\n');
            }
        }
        issue.body.push_str(&link);
        issue.body.push('\n');
        issue.bump_updated();
        self.update(filename, &issue)?;

        Ok(AssetAdded { link, written_name })
    }
}