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!("")
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}