Skip to main content

tix/storage/
lifecycle.rs

1#[allow(clippy::wildcard_imports)]
2use super::*;
3
4impl Store {
5    /// Create an issue file atomically and assign its number by scanning the
6    /// live issue set (issue #52): the highest existing `number` plus one,
7    /// with a floor of `0` for an empty store.
8    ///
9    /// The number is resolved at creation time rather than read from a
10    /// persisted `[state].next_number` counter, so `tix.toml` is never
11    /// rewritten by `create`. A deleted high-numbered issue's number can be
12    /// reused by a later create; the frontmatter `number` stays stable for
13    /// every surviving issue (C-007).
14    ///
15    /// The file is written into the assignee segment derived from
16    /// `issue.assignee` (`unassigned/` fallback, FR-029/FR-030), creating the
17    /// segment directory as needed, and `issue.filename` is set to the
18    /// relative path so `create` output and later edits agree. Atomic-write
19    /// discipline (temp + rename, NFR-006) is preserved.
20    pub fn create(
21        &self,
22        issue: &mut Issue,
23        body: &str,
24        filename_stem: Option<&str>,
25    ) -> Result<PathBuf> {
26        issue.body = body.to_string();
27        issue.bump_updated();
28
29        std::fs::create_dir_all(&self.issues_dir)?;
30        issue.number = self.resolve_next_number()?;
31
32        // Claim the number **before** anything is serialised, and hold it across the
33        // whole write. `resolve_next_number` above reads the directory, takes `max`,
34        // and adds 1, which is only a *hint*: two processes reaching this line
35        // together both get the same answer and both proceed. See [`NumberClaim`]
36        // for what that cost and why an `O_EXCL` marker is the fix.
37        //
38        // **Before** the serialisation, specifically. `to_file_contents` embeds
39        // `number:` in the frontmatter, so serialising first and claiming after —
40        // which is the order this was first written in — writes a *stale* number into
41        // the file while the ledger records the correct one. The 24-create test still
42        // saw 8 distinct numbers with 24 valid claims in place, because every claim
43        // was correct and every file was wrong. A claim that does not reach the bytes
44        // on disk is not a claim.
45        let mut claim = NumberClaim::acquire(&self.issues_dir, issue.number)?;
46        // Belt and braces (#1352): the number actually claimed must be the number
47        // about to be written. `acquire` only climbs when the requested number is
48        // already taken, so this is normally an identity — but it means a future
49        // change to either side that drifts from the other fails loudly here
50        // instead of writing an issue whose `number:` disagrees with the ledger.
51        issue.number = claim.number().ok_or_else(|| {
52            TixError::Io(std::io::Error::new(
53                std::io::ErrorKind::InvalidData,
54                format!("claimed number is unreadable from {}", claim.path.display()),
55            ))
56        })?;
57
58        // Assign the globally unique identity before anything is serialised. The
59        // `id:` frontmatter line and the filename prefix both come from it, so it
60        // must be fixed before `to_file_contents` and before `layout::render`. A
61        // caller that already set one (an import, or a test) keeps it; this is the
62        // single place every create path — CLI, MCP, TUI, sync — flows through.
63        if issue.id.is_none() {
64            issue.id = Some(crate::id::generate_issue_id());
65        }
66
67        // WP02 layout, now configurable ([layout] in tix.toml): the directory is
68        // rendered from the configured template and may be nested; `filename_stem`
69        // still overrides the filename for callers that pin a stem.
70        let rel = crate::layout::render(&self.config.layout, issue);
71        let (dir, file) = rel.rsplit_once('/').unwrap_or(("", rel.as_str()));
72        std::fs::create_dir_all(self.issues_dir.join(dir))?;
73
74        let stem = match filename_stem {
75            Some(s) => s.to_string(),
76            None => file.strip_suffix(".md").unwrap_or(file).to_string(),
77        };
78        // Serialised **after** the number is fixed, so the `number:` in the file is
79        // the claimed one.
80        let contents = issue.to_file_contents();
81
82        let written = self.write_new_issue_file(dir, &stem, contents.as_bytes());
83        // The claim is now permanent: the issue file exists, so that file — not the
84        // marker — is the authority for the number, and dropping the marker would
85        // reopen the window this exists to close. A create that *failed* releases
86        // its claim instead, so a failure does not leak a number for nothing.
87        match written {
88            Ok((filename, path)) => {
89                claim.commit();
90                issue.filename = filename;
91                self.reconcile_mentions(issue, &issue.filename);
92                Ok(path)
93            }
94            Err(e) => {
95                claim.release();
96                Err(e)
97            }
98        }
99    }
100
101    /// Write **new** issue file, never overwriting an existing one.
102    ///
103    /// The stem was `{created}-{slugified title}` at the time of this bug (a
104    /// new-schema name now also carries the issue id:
105    /// `{created}-{id}-{slugified title}`), so it was a function of the issue's
106    /// creation date, its title, and its assignee segment — three things routinely
107    /// identical across two issues. Two `tix create --title "Same title"` runs on
108    /// the same day therefore resolved to the **same path**, and `atomic_write`
109    /// renames over the target, so the second create silently destroyed the first.
110    /// No concurrency was required, and both commands reported success:
111    ///
112    /// ```text
113    /// $ tix create --title "Same title" --priority high
114    /// Created herbert-maschke/2026-09-28-same-title.md
115    /// $ tix create --title "Same title" --priority low
116    /// Created herbert-maschke/2026-09-28-same-title.md
117    /// $ ls docs/issues/*/
118    /// 2026-09-28-same-title.md            # one file
119    /// $ tix list
120    /// NUM  TITLE       STATUS  PRIORITY
121    /// 2    Same title  todo    low       # issue #1 is gone
122    /// ```
123    ///
124    /// Two layers, because the first alone is still a race:
125    ///
126    /// 1. **Probe** for a free name, appending `-2`, `-3`, … to the stem. This
127    ///    handles the deterministic case, which is the common one.
128    /// 2. **Create the file with `O_EXCL`** via [`write_file_new`]. Probing cannot
129    ///    see a file another process creates between the probe and the write, and
130    ///    `rename` overwrites unconditionally, so the final step is the one that
131    ///    actually guarantees no clobber. Losing the race retries from step 1.
132    ///
133    /// Probing alone is the version that looks correct and is not: a second process
134    /// that writes the same stem in the same millisecond would still be destroyed,
135    /// and the retry-on-`AlreadyExists` below exists precisely because a reviewer
136    /// should not have to take that on trust.
137    pub(in crate::storage) fn write_new_issue_file(
138        &self,
139        segment: &str,
140        stem: &str,
141        contents: &[u8],
142    ) -> Result<(String, PathBuf)> {
143        const MAX_ATTEMPTS: usize = 1_000;
144        let mut suffix = 1usize;
145        for _ in 0..MAX_ATTEMPTS {
146            let filename = if suffix == 1 {
147                format!("{segment}/{stem}.md")
148            } else {
149                format!("{segment}/{stem}-{suffix}.md")
150            };
151            let path = self.issues_dir.join(&filename);
152            match write_file_new(&self.issues_dir, &path, contents) {
153                Ok(()) => {
154                    journal_issue_change(Path::new(&filename), true);
155                    return Ok((filename, path));
156                }
157                // Someone took this name between the probe and the write. The
158                // collision is benign — it is only a *name* — so take the next one.
159                Err(TixError::Io(e)) if e.kind() == std::io::ErrorKind::AlreadyExists => {
160                    suffix += 1;
161                }
162                Err(e) => return Err(e),
163            }
164        }
165        Err(TixError::Io(std::io::Error::new(
166            std::io::ErrorKind::AlreadyExists,
167            format!(
168                "could not find a free filename for {segment}/{stem}.md in {MAX_ATTEMPTS} attempts"
169            ),
170        )))
171    }
172
173    /// The number for the next created issue: `max(number)` over the live
174    /// issue set plus one, or `1` when no issues exist (issue #52 — no
175    /// persisted counter to advance). Scanning the same list that every
176    /// other command uses keeps number resolution conservative: reusing a
177    /// number requires first deleting every higher-numbered issue.
178    ///
179    /// **The empty case is `1`, not `0`.** It used to be `0`, because `max()` over
180    /// an empty set has no natural answer and `0` was the fallback — which made the
181    /// first issue in every fresh repository `#0`, and that number is then
182    /// indistinguishable from a sentinel, an uninitialised value, or a field that
183    /// failed to read. Found by running the binary rather than reading the code
184    /// (tix #1376); two places in the codebase had already written the opposite
185    /// assumption into a comment, and a third had used `0` as a
186    /// `HierarchyNode::NO_ISSUE_YET` marker.
187    ///
188    /// Only the empty case changes. With any issue present the answer is `max + 1`,
189    /// which is unaffected — so no existing repository renumbers, and no migration
190    /// is implied. (One real issue in this repository is numbered `0`, written
191    /// before this fix; it keeps its number, because renumbering is a migration and
192    /// the mission's doctrine makes those an escalation trigger.)
193    pub(in crate::storage) fn resolve_next_number(&self) -> Result<u32> {
194        // `list_all`, not `list`: an archived issue keeps its number forever.
195        // Reading only the active set would let a `create` reuse the number of
196        // the highest-numbered archived issue (issue #1492).
197        let issues = self.list_all()?;
198        match issues.iter().map(|i| i.number).max() {
199            None => Ok(1),
200            Some(max) => max
201                .checked_add(1)
202                .ok_or_else(|| TixError::InvalidInput("issue-number overflow".to_string())),
203        }
204    }
205
206    /// Write `issue` over the file at `filename`, then reconcile the file's
207    /// **location** with the configured `[layout]` (`move_on_change`).
208    ///
209    /// Returns the store-relative path the issue lives at afterwards — the same
210    /// one in the common case, a new one when a templated field (status, type,
211    /// assignee, cycle, …) now renders a different directory. **Every write path
212    /// goes through here**, so no surface (CLI, MCP, TUI, triage, sync) can
213    /// leave an issue at a path that disagrees with the layout.
214    pub fn update(&self, filename: &str, issue: &Issue) -> Result<String> {
215        validate_filename(filename)?;
216        let path = self.issues_dir.join(filename);
217        if !path.is_file() {
218            return Err(TixError::NotFound(format!("file {filename}")));
219        }
220        // The retired `links:` key is not in the model, so re-serializing drops
221        // it — and with it the `retired_links_key` finding, silently. The
222        // consequence is that `tix lint` reports a problem, the user runs an
223        // unrelated `tix edit`, and both the finding and the data vanish with no
224        // notice. That is the defect; C-001's no-migration intent is not.
225        //
226        // The write still proceeds, because refusing would make tix unusable
227        // across the repository: 1216 of 1290 issue files here carry `links:`.
228        // What changes is that the removal is *disclosed*, naming the field and
229        // the file, so nothing is lost quietly. Removing the key by hand first
230        // silences this.
231        if issue.has_retired_links_key {
232            // Recorded on the store, not printed here. `eprintln!` inside a
233            // library function is invisible to the MCP server and the TUI, so
234            // the same operation produced different observable output per
235            // surface, which `contracts/surface-parity.md` forbids.
236            //
237            // Drained at the three surface boundaries, not at each write path:
238            // `main` for every CLI command, the MCP `serve` loop per request,
239            // and the TUI event loop. Draining per command was tried and reached
240            // only `link` and `edit`, so every other write path — `mv`,
241            // `rollover`, planning, sync, the TUI, MCP — warned about nothing.
242            // That was worse than the `eprintln!` it replaced, which fired from
243            // inside `update` and so covered all of them. A count of write
244            // paths is deliberately not stated here: it drifts, and a figure a
245            // later reader trusts is worse than no figure. `tests/mcp.rs` pins
246            // the behaviour instead, for `main` and the MCP server.
247            retired_key_disclosure(filename);
248        }
249        let contents = issue.to_file_contents();
250        atomic_write(&self.issues_dir, &path, contents.as_bytes())?;
251        journal_issue_change(Path::new(filename), true);
252        // Unconditional auto-migration: after any write, the file is moved to the
253        // directory `[layout]` renders (subject only to `move_on_change`). A
254        // legacy or mismatched path is migrated, not left behind.
255        let relocated = self.reconcile_location(filename, issue)?;
256        // The mention records name the issue by id and record the path they saw it at, so a
257        // reconciliation after the move stores the path the issue actually lives at.
258        self.reconcile_mentions(issue, &relocated);
259        Ok(relocated)
260    }
261
262    /// Move the file at `filename` so its directory matches what `[layout]`
263    /// renders for `issue`, when `move_on_change` is set and the directory
264    /// differs. The basename (including the archived-marker dot) is preserved.
265    /// Returns the relative path afterwards (unchanged when no move happens).
266    pub(in crate::storage) fn reconcile_location(
267        &self,
268        filename: &str,
269        issue: &Issue,
270    ) -> Result<String> {
271        if !self.config.layout.move_on_change {
272            return Ok(filename.to_string());
273        }
274        let target_dir = crate::layout::render_directory(&self.config.layout, issue);
275        let Some((old_dir, base)) = split_relative(filename) else {
276            return Ok(filename.to_string());
277        };
278        if old_dir == target_dir {
279            return Ok(filename.to_string());
280        }
281        let stem = base.strip_suffix(".md").unwrap_or(&base);
282        let moved = self.move_issue_file(filename, &target_dir, stem)?;
283        Ok(moved.to_string_lossy().to_string())
284    }
285
286    pub fn delete(&self, filename: &str) -> Result<()> {
287        validate_filename(filename)?;
288        let path = self.issues_dir.join(filename);
289        if !path.is_file() {
290            return Err(TixError::NotFound(format!("file {filename}")));
291        }
292        // Read the number **before** the file goes, so the claim ledger can be told
293        // the number is free again (#1352).
294        //
295        // Releasing the claim is what preserves issue #52's documented behaviour:
296        // the next number is re-derived from the live set, so deleting the
297        // highest-numbered issue lets its number be reused. Without this the ledger
298        // holds that number forever, the next create climbs past it, and the feature
299        // stops working — which is a regression the existing test caught.
300        //
301        // The residual window, stated rather than hidden: if a `create` is in flight
302        // holding this claim while the `delete` runs, the claim is released and a
303        // later create could take the number too. That needs a delete and a create to
304        // interleave, and it was *unconditionally* broken before this ledger existed;
305        // the ledger removes the plain concurrent-create case, which is the one that
306        // actually happens. Repairing the delete/create interleave would need a lock,
307        // and this is the reason the ledger was chosen over one.
308        let freed = std::fs::read_to_string(&path)
309            .ok()
310            .and_then(|text| frontmatter_number(&text));
311        // The issue's canonical id, best-effort, for clearing its mention records. Read before
312        // the file goes, and read through the same parse the rest of the store uses rather than
313        // a second frontmatter scraper.
314        let identity = self
315            .get(IssueSelector::Filename(filename.to_string()))
316            .ok()
317            .and_then(|issue| issue.id);
318        journal_record_write(&path);
319        std::fs::remove_file(&path)?;
320        journal_issue_change(Path::new(filename), false);
321        if let Some(number) = freed {
322            NumberClaim::release_number(&self.issues_dir, number);
323        }
324        if let Some(id) = identity {
325            self.clear_mentions(&id);
326        }
327        // Best-effort cleanup of the `<slug>-assets/` dir (FR-008): the `.md`
328        // removal is the contract, so an asset-dir removal failure is only
329        // reported as a warning, never propagated.
330        if let Some((segment, basename)) = split_relative(filename) {
331            let stem = basename.strip_suffix(".md").unwrap_or(&basename);
332            let slug = slug_from_stem(stem);
333            let asset_dir = self
334                .issues_dir
335                .join(&segment)
336                .join(format!("{slug}-assets"));
337            if asset_dir.exists()
338                && let Err(e) = std::fs::remove_dir_all(&asset_dir)
339            {
340                eprintln!(
341                    "warning: failed to remove asset dir {}: {e}",
342                    asset_dir.display()
343                );
344            }
345        }
346        Ok(())
347    }
348
349    /// Archive an issue: rename its file to a dot-prefixed name **in place**.
350    ///
351    /// The marker is the filename (issue #1492), so this is a rename within the
352    /// same assignee segment (`x.md` ↔ `.x.md`) plus exactly one `## History`
353    /// event. `number`, `status`, `created` and every other frontmatter field
354    /// are left untouched, no data is dropped, and the file is never deleted or
355    /// moved between directories.
356    ///
357    /// An already-archived issue is refused with the `already_archived`
358    /// diagnostic rather than re-archived: the operation is not idempotent by
359    /// design, because a second call would append a second audit entry for a
360    /// mutation that did not happen.
361    pub fn archive(&self, sel: &str) -> Result<Issue> {
362        self.set_archived(sel, true)
363    }
364
365    /// Restore an archived issue: the inverse of [`Store::archive`].
366    ///
367    /// An active issue is refused with the `not_archived` diagnostic.
368    pub fn restore(&self, sel: &str) -> Result<Issue> {
369        self.set_archived(sel, false)
370    }
371
372    pub(in crate::storage) fn set_archived(&self, sel: &str, archive: bool) -> Result<Issue> {
373        // `resolve_issue` → `parse_selector` → `list_all`, so an archived issue
374        // resolves here even though the default view hides it.
375        let mut issue = self.resolve_issue(sel)?;
376        if issue.archived == archive {
377            return Err(TixError::InvalidInput(format!(
378                "{}: issue #{} ({}) is already {}",
379                if archive {
380                    "already_archived"
381                } else {
382                    "not_archived"
383                },
384                issue.number,
385                issue.filename,
386                if archive { "archived" } else { "active" },
387            )));
388        }
389        let old_rel = issue.filename.clone();
390        let new_rel = flip_archived(&old_rel, archive).ok_or_else(|| {
391            TixError::InvalidInput(format!(
392                "cannot {} {old_rel}: its filename does not carry the archived marker in the \
393                 expected position",
394                if archive { "archive" } else { "restore" }
395            ))
396        })?;
397        let old_path = self.issues_dir.join(&old_rel);
398        let new_path = self.issues_dir.join(&new_rel);
399        if !old_path.is_file() {
400            return Err(TixError::NotFound(format!("file {old_rel}")));
401        }
402        if new_path.exists() {
403            return Err(TixError::InvalidInput(format!(
404                "cannot {} {old_rel}: {new_rel} already exists",
405                if archive { "archive" } else { "restore" }
406            )));
407        }
408        // Rename first: it is atomic, stays in the same directory, and cannot
409        // lose content. Only then is the audit event written, at the new name.
410        // A failure between the two leaves the file renamed but without the
411        // event — never a duplicate, never lost data.
412        //
413        // The rename is recorded in the write journal (#1580), so a command-level
414        // `Store::transaction` around `tix archive`/`tix restore` can undo it: the
415        // event write below is a *second* mutation, and a failure there must put
416        // the file back at its active name rather than leave it archived without
417        // its audit entry.
418        journal_record_move(&old_path, &new_path);
419        std::fs::rename(&old_path, &new_path)?;
420        issue.filename = new_rel.clone();
421        issue.archived = archive;
422        issue.history.append_now(
423            &actor_identity(),
424            if archive {
425                HistoryEventKind::Archived
426            } else {
427                HistoryEventKind::Restored
428            },
429            &format!("{old_rel} -> {new_rel}"),
430        );
431        // No `bump_updated`: `updated` is a frontmatter field and archiving must
432        // leave every frontmatter field byte-identical.
433        issue.filename = self.update(&new_rel, &issue)?;
434        Ok(issue)
435    }
436
437    /// Physically move an issue file into the assignee segment of a new
438    /// assignee (FR-031, `contracts/file-format.md` rule 9).
439    ///
440    /// `filename` is a path relative to the issues dir: either a legacy flat
441    /// basename (`2026-05-10-fix-login.md`, C-010) or a grouped path
442    /// (`alice/2026-05-10-fix-login.md`). It is validated via the relaxed
443    /// [`validate_filename`] before anything touches the filesystem, so
444    /// traversal attempts are refused here as everywhere else.
445    ///
446    /// The old assignee segment is read from the current relative path, the
447    /// new one derived with [`derive_segment`]; equal segments are a no-op.
448    /// The target filename keeps the original `created-slug` stem; when the
449    /// target already holds `<stem>.md`, a deterministic `-1`, `-2`, …
450    /// suffix is appended by testing existence — an existing file is never
451    /// overwritten or deleted. `number`/`created` live in the frontmatter and
452    /// are untouched, so issue-number stability is guaranteed by construction
453    /// (C-007).
454    ///
455    /// The move is a single `std::fs::rename` (atomic-ish, same filesystem).
456    /// Returns the **new relative path** (matching how `issue.filename` is
457    /// stored) on success, including the unchanged relative path for the
458    /// equal-segment no-op.
459    pub fn reassign(&self, filename: &str, new_assignee: Option<&str>) -> Result<PathBuf> {
460        // `get` validates `filename` and errors NotFound when it is missing.
461        let issue = self.get(IssueSelector::Filename(filename.to_string()))?;
462        let mut updated = issue.clone();
463        updated.assignee = new_assignee.map(str::to_string);
464        let target_dir = crate::layout::render_directory(&self.config.layout, &updated);
465        let (old_dir, base) = split_relative(filename).ok_or_else(|| {
466            TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
467        })?;
468        if old_dir == target_dir {
469            return Ok(PathBuf::from(filename));
470        }
471        let stem = base.strip_suffix(".md").unwrap_or(&base);
472        self.move_issue_file(filename, &target_dir, stem)
473    }
474
475    /// Relocate an issue file to an exact store-relative `target` (used by
476    /// `tix layout migrate`). Frontmatter and number are preserved because
477    /// nothing is rewritten — it is a rename; an occupied target is refused; the
478    /// `<slug>-assets/` dir moves alongside.
479    pub fn migrate_issue(&self, filename: &str, target: &str) -> Result<()> {
480        if target == filename {
481            return Ok(());
482        }
483        validate_filename(target)?;
484        let from = self.issues_dir.join(filename);
485        if !from.is_file() {
486            return Err(TixError::NotFound(format!("file {filename}")));
487        }
488        let to = self.issues_dir.join(target);
489        if to.exists() {
490            return Err(TixError::InvalidInput(format!(
491                "cannot migrate {filename}: {target} already exists"
492            )));
493        }
494        if let Some(parent) = to.parent() {
495            std::fs::create_dir_all(parent)?;
496        }
497        std::fs::rename(&from, &to)?;
498        let (old_dir, base) = split_relative(filename).unwrap_or_default();
499        let stem = base.strip_suffix(".md").unwrap_or(&base);
500        let asset_name = format!("{}-assets", slug_from_stem(stem));
501        let old_asset_dir = self.issues_dir.join(&old_dir).join(&asset_name);
502        if old_asset_dir.exists()
503            && let Some(new_dir) = to.parent()
504        {
505            let moved = resolve_asset_dir_name(new_dir, &asset_name);
506            std::fs::rename(&old_asset_dir, new_dir.join(&moved))?;
507        }
508        Ok(())
509    }
510
511    /// Move an issue file (and its `<slug>-assets/` dir) from `filename` to
512    /// `<target_dir>/<stem>[-N].md`, creating the directory as needed. A target
513    /// collision takes the first free `-N` suffix; nothing is overwritten.
514    pub(in crate::storage) fn move_issue_file(
515        &self,
516        filename: &str,
517        target_dir: &str,
518        stem: &str,
519    ) -> Result<PathBuf> {
520        let target_dir_path = self.issues_dir.join(target_dir);
521        std::fs::create_dir_all(&target_dir_path)?;
522        let new_name = resolve_target_name(&target_dir_path, stem);
523        let old_dir = split_relative(filename).map_or_else(String::new, |(d, _)| d);
524        let from = self.issues_dir.join(filename);
525        let to = target_dir_path.join(&new_name);
526        journal_record_move(&from, &to);
527        std::fs::rename(from, to)?;
528        journal_issue_change(Path::new(filename), false);
529        journal_issue_change(Path::new(&format!("{target_dir}/{new_name}")), true);
530        // Move the `<slug>-assets/` dir alongside the file (FR-007). On a
531        // same-named dir in the target, disambiguate rather than overwrite.
532        let slug = slug_from_stem(stem);
533        let asset_name = format!("{slug}-assets");
534        let old_asset_dir = self.issues_dir.join(&old_dir).join(&asset_name);
535        if old_asset_dir.exists() {
536            let moved = resolve_asset_dir_name(&target_dir_path, &asset_name);
537            let asset_target = target_dir_path.join(&moved);
538            journal_record_move(&old_asset_dir, &asset_target);
539            std::fs::rename(&old_asset_dir, &asset_target)?;
540        }
541        Ok(PathBuf::from(format!("{target_dir}/{new_name}")))
542    }
543
544    /// Move an issue to the directory its templated fields now render to, when
545    /// `move_on_change` is set. The basename is preserved. Returns whether it
546    /// moved; a no-op when already in place or when `move_on_change` is off.
547    pub fn move_to_layout(&self, issue: &mut Issue) -> Result<bool> {
548        let before = issue.filename.clone();
549        let after = self.reconcile_location(&before, issue)?;
550        issue.filename = after.clone();
551        Ok(after != before)
552    }
553
554    /// Copy an asset into the issue's `<slug>-assets/` directory (FR-006) and
555    /// insert a relative markdown link at the end of the body, persisted via
556    /// [`Store::update`] (NFR-004).
557    ///
558    /// The asset dir is derived from the issue filename per
559    /// `contracts/comments-format.md`: `<slug>-assets/` where `<slug>` is the
560    /// basename stem minus the leading `YYYY-MM-DD-` date prefix. The copy is
561    /// atomic (temp + rename, NFR-004); on a name collision the file is stored
562    /// as `<stem>-<n>.<ext>` (first free `n`) and the inserted link points at
563    /// the actual written name. Image extensions (`.png` `.jpg` `.jpeg`
564    /// `.gif` `.svg` `.webp` `.bmp`) get the `![…](…)` form, everything else
565    /// the `[…](…)` form. The link path is relative to the issue file, so the
566    /// segment prefix is never part of it.
567    pub fn add_asset(&self, filename: &str, source_path: &Path) -> Result<AssetAdded> {
568        validate_filename(filename)?;
569        let mut issue = self.get(IssueSelector::Filename(filename.to_string()))?;
570
571        let (segment, basename) = split_relative(filename).ok_or_else(|| {
572            TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
573        })?;
574        let stem = basename
575            .strip_suffix(".md")
576            .ok_or_else(|| {
577                TixError::InvalidInput(format!(
578                    "asset target must be a `.md` issue file: {filename}"
579                ))
580            })?
581            .to_string();
582        let slug = slug_from_stem(&stem);
583        let dir_name = format!("{slug}-assets");
584        let asset_dir = self.issues_dir.join(&segment).join(&dir_name);
585        std::fs::create_dir_all(&asset_dir)?;
586
587        let source_name = source_path
588            .file_name()
589            .and_then(|n| n.to_str())
590            .ok_or_else(|| {
591                TixError::InvalidInput("asset source must have a file name".to_string())
592            })?
593            .to_string();
594        let written_name = resolve_asset_name(&asset_dir, &source_name);
595        atomic_copy(&asset_dir, &asset_dir.join(&written_name), source_path)?;
596
597        let label = written_name
598            .rsplit_once('.')
599            .map(|(stem, _)| stem)
600            .unwrap_or(&written_name);
601        let link = if is_image_name(&written_name) {
602            format!("![{label}]({dir_name}/{written_name})")
603        } else {
604            format!("[{label}]({dir_name}/{written_name})")
605        };
606
607        // Append the link as a new line at the end of the body, reusing the
608        // comment-appender newline discipline so the description is never
609        // rewritten (FR-007).
610        if !issue.body.is_empty() {
611            if !issue.body.ends_with('\n') {
612                issue.body.push('\n');
613            }
614            if !issue.body.ends_with("\n\n") {
615                issue.body.push('\n');
616            }
617        }
618        issue.body.push_str(&link);
619        issue.body.push('\n');
620        issue.bump_updated();
621        self.update(filename, &issue)?;
622
623        Ok(AssetAdded { link, written_name })
624    }
625}