Skip to main content

tix/
storage.rs

1use std::path::{Path, PathBuf};
2use std::time::{SystemTime, UNIX_EPOCH};
3
4use crate::assignee::derive_segment;
5use crate::config::Config;
6use crate::error::{Result, TixError};
7use crate::model::{Issue, Status};
8use crate::slug::slugify;
9
10pub struct Store {
11    pub issues_dir: PathBuf,
12    pub config: Config,
13}
14
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub enum IssueSelector {
17    Number(u32),
18    Filename(String),
19    Slug(String),
20}
21
22/// Result of `Store::add_asset`: the relative markdown link inserted into the
23/// body and the actual written file name (collision-resolved).
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct AssetAdded {
26    pub link: String,
27    pub written_name: String,
28}
29
30impl Store {
31    pub fn open(config: &Config) -> Result<Store> {
32        let issues_dir = &config.issues_dir;
33        if !issues_dir.exists() {
34            std::fs::create_dir_all(issues_dir)?;
35        }
36        Ok(Store {
37            issues_dir: issues_dir.clone(),
38            config: config.clone(),
39        })
40    }
41
42    pub fn list(&self) -> Result<Vec<Issue>> {
43        let mut issues: Vec<Issue> = Vec::new();
44        if !self.issues_dir.exists() {
45            return Ok(issues);
46        }
47        for entry in std::fs::read_dir(&self.issues_dir)? {
48            let entry = entry?;
49            let path = entry.path();
50            let name = entry.file_name().to_string_lossy().to_string();
51            if path.is_dir() {
52                // One level of assignee segments. Temp/backup marker dirs are
53                // never treated as segments (their .md children are clutter);
54                // neither are `<slug>-assets/` dirs (FR-006) — they hold
55                // binary assets, never issues.
56                if name.starts_with(".tix-tmp-") || name.ends_with("-assets") {
57                    continue;
58                }
59                for child in std::fs::read_dir(&path)? {
60                    let child = child?;
61                    let child_path = child.path();
62                    if !child_path.is_file() {
63                        continue;
64                    }
65                    let child_name = child.file_name().to_string_lossy().to_string();
66                    if !child_name.ends_with(".md") {
67                        continue;
68                    }
69                    // Grouped issue: relative filename is `<segment>/<basename>`.
70                    let rel = format!("{name}/{child_name}");
71                    let contents = std::fs::read_to_string(&child_path)?;
72                    let mut issue = Issue::parse(&contents).map_err(|e| match e {
73                        TixError::Parse(msg) => TixError::Parse(format!("{rel}: {msg}")),
74                        other => other,
75                    })?;
76                    issue.filename = rel;
77                    issues.push(issue);
78                }
79            } else if path.is_file() && name.ends_with(".md") {
80                // Legacy flat file directly under the issues dir (C-010).
81                let contents = std::fs::read_to_string(&path)?;
82                let mut issue = Issue::parse(&contents).map_err(|e| match e {
83                    TixError::Parse(msg) => TixError::Parse(format!("{name}: {msg}")),
84                    other => other,
85                })?;
86                issue.filename = name;
87                issues.push(issue);
88            }
89        }
90        issues.sort_by(|a, b| a.filename.cmp(&b.filename));
91        Ok(issues)
92    }
93
94    pub fn get(&self, selector: IssueSelector) -> Result<Issue> {
95        match selector {
96            IssueSelector::Number(num) => {
97                let issues = self.list()?;
98                issues
99                    .into_iter()
100                    .find(|i| i.number == num)
101                    .ok_or_else(|| TixError::NotFound(format!("issue #{num}")))
102            }
103            IssueSelector::Filename(name) => {
104                validate_filename(&name)?;
105                let path = self.issues_dir.join(&name);
106                if !path.is_file() {
107                    return Err(TixError::NotFound(format!("file {name}")));
108                }
109                let contents = std::fs::read_to_string(&path)?;
110                let mut issue = Issue::parse(&contents)?;
111                issue.filename = name;
112                Ok(issue)
113            }
114            IssueSelector::Slug(slug) => {
115                let issues = self.list()?;
116                issues
117                    .into_iter()
118                    .find(|i| slugify(&i.title) == slug)
119                    .ok_or_else(|| TixError::NotFound(format!("slug {slug}")))
120            }
121        }
122    }
123
124    /// Create an issue file atomically and assign its number by scanning the
125    /// live issue set (issue #52): the highest existing `number` plus one,
126    /// with a floor of `0` for an empty store.
127    ///
128    /// The number is resolved at creation time rather than read from a
129    /// persisted `[state].next_number` counter, so `tix.toml` is never
130    /// rewritten by `create`. A deleted high-numbered issue's number can be
131    /// reused by a later create; the frontmatter `number` stays stable for
132    /// every surviving issue (C-007).
133    ///
134    /// The file is written into the assignee segment derived from
135    /// `issue.assignee` (`unassigned/` fallback, FR-029/FR-030), creating the
136    /// segment directory as needed, and `issue.filename` is set to the
137    /// relative path so `create` output and later edits agree. Atomic-write
138    /// discipline (temp + rename, NFR-006) is preserved.
139    pub fn create(
140        &self,
141        issue: &mut Issue,
142        body: &str,
143        filename_stem: Option<&str>,
144    ) -> Result<PathBuf> {
145        issue.body = body.to_string();
146        issue.bump_updated();
147
148        std::fs::create_dir_all(&self.issues_dir)?;
149        issue.number = self.resolve_next_number()?;
150
151        // WP02 layout: files live under `<assignee-segment>/` with `unassigned/`
152        // as the fallback (FR-029, FR-030). The segment is generated by
153        // `derive_segment`, which is filesystem-safe by construction.
154        let segment = derive_segment(issue.assignee.as_deref());
155        std::fs::create_dir_all(self.issues_dir.join(&segment))?;
156
157        let stem = match filename_stem {
158            Some(s) => s.to_string(),
159            None => issue.filename_stem(),
160        };
161        let filename = format!("{segment}/{stem}.md");
162        issue.filename = filename.clone();
163        let path = self.issues_dir.join(&filename);
164        let contents = issue.to_file_contents();
165        atomic_write(&self.issues_dir, &path, contents.as_bytes())?;
166        Ok(path)
167    }
168
169    /// The number for the next created issue: `max(number)` over the live
170    /// issue set plus one, or `0` when no issues exist (issue #52 — no
171    /// persisted counter to advance). Scanning the same list that every
172    /// other command uses keeps number resolution conservative: reusing a
173    /// number requires first deleting every higher-numbered issue.
174    fn resolve_next_number(&self) -> Result<u32> {
175        let issues = self.list()?;
176        match issues.iter().map(|i| i.number).max() {
177            None => Ok(0),
178            Some(max) => max
179                .checked_add(1)
180                .ok_or_else(|| TixError::InvalidInput("issue-number overflow".to_string())),
181        }
182    }
183
184    pub fn config_path(&self) -> Result<std::path::PathBuf> {
185        if let Some(root) = Config::project_root(&self.issues_dir) {
186            Ok(root.join("tix.toml"))
187        } else {
188            Err(TixError::Config(
189                "tix.toml not found in any parent directory of issues_dir".into(),
190            ))
191        }
192    }
193
194    pub fn update(&self, filename: &str, issue: &Issue) -> Result<()> {
195        validate_filename(filename)?;
196        let path = self.issues_dir.join(filename);
197        if !path.is_file() {
198            return Err(TixError::NotFound(format!("file {filename}")));
199        }
200        let contents = issue.to_file_contents();
201        atomic_write(&self.issues_dir, &path, contents.as_bytes())
202    }
203
204    /// Copy an asset into the issue's `<slug>-assets/` directory (FR-006) and
205    /// insert a relative markdown link at the end of the body, persisted via
206    /// [`Store::update`] (NFR-004).
207    ///
208    /// The asset dir is derived from the issue filename per
209    /// `contracts/comments-format.md`: `<slug>-assets/` where `<slug>` is the
210    /// basename stem minus the leading `YYYY-MM-DD-` date prefix. The copy is
211    /// atomic (temp + rename, NFR-004); on a name collision the file is stored
212    /// as `<stem>-<n>.<ext>` (first free `n`) and the inserted link points at
213    /// the actual written name. Image extensions (`.png` `.jpg` `.jpeg`
214    /// `.gif` `.svg` `.webp` `.bmp`) get the `![…](…)` form, everything else
215    /// the `[…](…)` form. The link path is relative to the issue file, so the
216    /// segment prefix is never part of it.
217    pub fn add_asset(&self, filename: &str, source_path: &Path) -> Result<AssetAdded> {
218        validate_filename(filename)?;
219        let mut issue = self.get(IssueSelector::Filename(filename.to_string()))?;
220
221        let (segment, basename) = split_relative(filename).ok_or_else(|| {
222            TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
223        })?;
224        let stem = basename
225            .strip_suffix(".md")
226            .ok_or_else(|| {
227                TixError::InvalidInput(format!(
228                    "asset target must be a `.md` issue file: {filename}"
229                ))
230            })?
231            .to_string();
232        let slug = slug_from_stem(&stem);
233        let dir_name = format!("{slug}-assets");
234        let asset_dir = self.issues_dir.join(&segment).join(&dir_name);
235        std::fs::create_dir_all(&asset_dir)?;
236
237        let source_name = source_path
238            .file_name()
239            .and_then(|n| n.to_str())
240            .ok_or_else(|| {
241                TixError::InvalidInput("asset source must have a file name".to_string())
242            })?
243            .to_string();
244        let written_name = resolve_asset_name(&asset_dir, &source_name);
245        atomic_copy(&asset_dir, &asset_dir.join(&written_name), source_path)?;
246
247        let label = written_name
248            .rsplit_once('.')
249            .map(|(stem, _)| stem)
250            .unwrap_or(&written_name);
251        let link = if is_image_name(&written_name) {
252            format!("![{label}]({dir_name}/{written_name})")
253        } else {
254            format!("[{label}]({dir_name}/{written_name})")
255        };
256
257        // Append the link as a new line at the end of the body, reusing the
258        // comment-appender newline discipline so the description is never
259        // rewritten (FR-007).
260        if !issue.body.is_empty() {
261            if !issue.body.ends_with('\n') {
262                issue.body.push('\n');
263            }
264            if !issue.body.ends_with("\n\n") {
265                issue.body.push('\n');
266            }
267        }
268        issue.body.push_str(&link);
269        issue.body.push('\n');
270        issue.bump_updated();
271        self.update(filename, &issue)?;
272
273        Ok(AssetAdded { link, written_name })
274    }
275
276    pub fn delete(&self, filename: &str) -> Result<()> {
277        validate_filename(filename)?;
278        let path = self.issues_dir.join(filename);
279        if !path.is_file() {
280            return Err(TixError::NotFound(format!("file {filename}")));
281        }
282        std::fs::remove_file(&path)?;
283        // Best-effort cleanup of the `<slug>-assets/` dir (FR-008): the `.md`
284        // removal is the contract, so an asset-dir removal failure is only
285        // reported as a warning, never propagated.
286        if let Some((segment, basename)) = split_relative(filename) {
287            let stem = basename.strip_suffix(".md").unwrap_or(&basename);
288            let slug = slug_from_stem(stem);
289            let asset_dir = self
290                .issues_dir
291                .join(&segment)
292                .join(format!("{slug}-assets"));
293            if asset_dir.exists()
294                && let Err(e) = std::fs::remove_dir_all(&asset_dir)
295            {
296                eprintln!(
297                    "warning: failed to remove asset dir {}: {e}",
298                    asset_dir.display()
299                );
300            }
301        }
302        Ok(())
303    }
304
305    pub fn set_status(&self, filename: &str, new_status: &str) -> Result<Issue> {
306        let mut issue = self.get(IssueSelector::Filename(filename.to_string()))?;
307        issue.status = Status::new(new_status);
308        issue.bump_updated();
309        let path = self.issues_dir.join(filename);
310        let contents = issue.to_file_contents();
311        atomic_write(&self.issues_dir, &path, contents.as_bytes())?;
312        Ok(issue)
313    }
314
315    /// Symmetrically link two issues by their selectors (D2).
316    ///
317    /// Both selectors are resolved with the same grammar and errors as the
318    /// selector-taking commands (`tix edit`/`tix move`, `parse_selector`);
319    /// linking an issue to itself is `InvalidInput`. The two identifiers are
320    /// number-based, so self-links are detected by comparing resolved numbers.
321    /// Each issue gains the other's number in its `links` list if absent
322    /// (idempotent). Both files are written atomically with `updated` bumped.
323    pub fn link(&self, a: &str, b: &str) -> Result<(Issue, Issue)> {
324        let mut issue_a = self.resolve_issue(a)?;
325        let mut issue_b = self.resolve_issue(b)?;
326        if issue_a.number == issue_b.number {
327            return Err(TixError::InvalidInput(format!(
328                "cannot link issue #{} to itself",
329                issue_a.number
330            )));
331        }
332        let a_num = issue_a.number.to_string();
333        let b_num = issue_b.number.to_string();
334        let mut changed = false;
335        if !issue_a.links.contains(&b_num) {
336            issue_a.links.push(b_num);
337            changed = true;
338        }
339        if !issue_b.links.contains(&a_num) {
340            issue_b.links.push(a_num.clone());
341            changed = true;
342        }
343        if changed {
344            issue_a.bump_updated();
345            issue_b.bump_updated();
346            self.update(&issue_a.filename, &issue_a)?;
347            self.update(&issue_b.filename, &issue_b)?;
348        }
349        Ok((issue_a, issue_b))
350    }
351
352    /// Symmetrically remove the link between two issues (D2).
353    ///
354    /// Both selectors are resolved like [`Store::link`]. Removing a pair that
355    /// was never linked is a no-op: neither file is written. Each issue drops
356    /// the other's number from its `links` list and gets `updated` bumped when
357    /// something actually changed.
358    pub fn unlink(&self, a: &str, b: &str) -> Result<(Issue, Issue)> {
359        let mut issue_a = self.resolve_issue(a)?;
360        let mut issue_b = self.resolve_issue(b)?;
361        let a_num = issue_a.number.to_string();
362        let b_num = issue_b.number.to_string();
363        let a_before = issue_a.links.len();
364        let b_before = issue_b.links.len();
365        issue_a.links.retain(|l| l != &b_num);
366        issue_b.links.retain(|l| l != &a_num);
367        if issue_a.links.len() != a_before || issue_b.links.len() != b_before {
368            issue_a.bump_updated();
369            issue_b.bump_updated();
370            self.update(&issue_a.filename, &issue_a)?;
371            self.update(&issue_b.filename, &issue_b)?;
372        }
373        Ok((issue_a, issue_b))
374    }
375
376    /// Replace one issue's `links` list and reconcile back-references (D2).
377    ///
378    /// Only numeric targets are accepted; every referenced number must already
379    /// exist (`NotFound` otherwise) and a self-link is `InvalidInput`. Numbers
380    /// newly added get this issue's number appended to their `links`; numbers
381    /// dropped have it removed. Each touched issue is written exactly once with
382    /// `updated` bumped; the target issue itself always wins on the default
383    /// non-numeric tolerance (hand-edited values untouched by this path).
384    pub fn set_links(&self, filename: &str, new: Vec<u32>) -> Result<Issue> {
385        let mut issue = self.get(IssueSelector::Filename(filename.to_string()))?;
386        let own = issue.number;
387        let mut seen = std::collections::HashSet::new();
388        let mut added: Vec<u32> = Vec::with_capacity(new.len());
389        for n in new {
390            if n == own {
391                return Err(TixError::InvalidInput(format!(
392                    "issue #{own} cannot link to itself"
393                )));
394            }
395            if seen.insert(n) {
396                self.get(IssueSelector::Number(n))?;
397                added.push(n);
398            }
399        }
400        let old: Vec<String> = issue.links.clone();
401        let new_list: Vec<String> = added.iter().map(|n| n.to_string()).collect();
402        issue.links = new_list;
403        issue.bump_updated();
404        self.update(filename, &issue)?;
405
406        let own_str = own.to_string();
407        for n in &added {
408            let mut target = self.get(IssueSelector::Number(*n))?;
409            if !target.links.contains(&own_str) {
410                target.links.push(own_str.clone());
411                target.bump_updated();
412                self.update(&target.filename, &target)?;
413            }
414        }
415        for old_link in &old {
416            if let Ok(n) = old_link.parse::<u32>() {
417                if !added.contains(&n) {
418                    let mut target = self.get(IssueSelector::Number(n))?;
419                    let before = target.links.len();
420                    target.links.retain(|l| l != &own_str);
421                    if target.links.len() != before {
422                        target.bump_updated();
423                        self.update(&target.filename, &target)?;
424                    }
425                }
426            }
427        }
428        Ok(issue)
429    }
430
431    /// Resolve a selector string exactly like the selector-taking commands
432    /// (`parse_selector`) and fetch the issue (D2: "Resolver SHALL match
433    /// `tix edit`/`tix move`").
434    fn resolve_issue(&self, raw: &str) -> Result<Issue> {
435        let selector = crate::commands::parse_selector(self, raw)?;
436        self.get(selector)
437    }
438
439    /// Physically move an issue file into the assignee segment of a new
440    /// assignee (FR-031, `contracts/file-format.md` rule 9).
441    ///
442    /// `filename` is a path relative to the issues dir: either a legacy flat
443    /// basename (`2026-05-10-fix-login.md`, C-010) or a grouped path
444    /// (`alice/2026-05-10-fix-login.md`). It is validated via the relaxed
445    /// [`validate_filename`] before anything touches the filesystem, so
446    /// traversal attempts are refused here as everywhere else.
447    ///
448    /// The old assignee segment is read from the current relative path, the
449    /// new one derived with [`derive_segment`]; equal segments are a no-op.
450    /// The target filename keeps the original `created-slug` stem; when the
451    /// target already holds `<stem>.md`, a deterministic `-1`, `-2`, …
452    /// suffix is appended by testing existence — an existing file is never
453    /// overwritten or deleted. `number`/`created` live in the frontmatter and
454    /// are untouched, so issue-number stability is guaranteed by construction
455    /// (C-007).
456    ///
457    /// The move is a single `std::fs::rename` (atomic-ish, same filesystem).
458    /// Returns the **new relative path** (matching how `issue.filename` is
459    /// stored) on success, including the unchanged relative path for the
460    /// equal-segment no-op.
461    pub fn reassign(&self, filename: &str, new_assignee: Option<&str>) -> Result<PathBuf> {
462        // `get` validates `filename` and errors NotFound when it is missing.
463        self.get(IssueSelector::Filename(filename.to_string()))?;
464
465        let (old_segment, file_name) = split_relative(filename).ok_or_else(|| {
466            TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
467        })?;
468        let new_segment = derive_segment(new_assignee);
469        if old_segment == new_segment {
470            return Ok(PathBuf::from(filename));
471        }
472
473        let stem = file_name
474            .strip_suffix(".md")
475            .unwrap_or(&file_name)
476            .to_string();
477        let target_dir = self.issues_dir.join(&new_segment);
478        std::fs::create_dir_all(&target_dir)?;
479        let new_name = resolve_target_name(&target_dir, &stem);
480        std::fs::rename(self.issues_dir.join(filename), target_dir.join(&new_name))?;
481
482        // Move the `<slug>-assets/` dir alongside the file (FR-007). With no
483        // asset dir this is a no-op and reassign behaves exactly as before.
484        // On a same-named dir in the target segment, disambiguate with a
485        // suffixed dir name (deterministic existence probe, mirroring
486        // `resolve_target_name`) rather than overwriting.
487        let slug = slug_from_stem(&stem);
488        let asset_name = format!("{slug}-assets");
489        let old_asset_dir = self.issues_dir.join(&old_segment).join(&asset_name);
490        if old_asset_dir.exists() {
491            let moved = resolve_asset_dir_name(&target_dir, &asset_name);
492            std::fs::rename(&old_asset_dir, target_dir.join(&moved))?;
493        }
494        Ok(PathBuf::from(format!("{new_segment}/{new_name}")))
495    }
496}
497
498/// Validate an issue filename. A filename is either a legacy flat basename
499/// (`2026-05-10-fix-login.md`) or a grouped path with exactly one assignee
500/// segment prefix (`alice/2026-05-10-fix-login.md`, C-008/C-010).
501///
502/// Refused in all cases: NUL bytes, backslashes, the `..` component, empty
503/// components (leading/trailing/double slashes), absolute / leading-slash
504/// paths, and nesting deeper than one segment. Every refusal is a hard
505/// [`TixError::InvalidInput`] raised before any filesystem touch.
506fn validate_filename(filename: &str) -> Result<()> {
507    if filename.is_empty() {
508        return Err(TixError::InvalidInput(format!(
509            "filename must not be empty: {filename:?}"
510        )));
511    }
512    if filename.starts_with('/') {
513        return Err(TixError::InvalidInput(format!(
514            "filename must be relative (no leading `/`): {filename}"
515        )));
516    }
517    if filename.contains('\\') {
518        return Err(TixError::InvalidInput(format!(
519            "filename must not contain backslashes: {filename}"
520        )));
521    }
522    if filename.contains("..") {
523        return Err(TixError::InvalidInput(format!(
524            "filename must not contain `..`: {filename}"
525        )));
526    }
527    if filename.contains('\0') {
528        return Err(TixError::InvalidInput(format!(
529            "filename must not contain NUL: {filename}"
530        )));
531    }
532    if filename.split('/').count() > 2 {
533        return Err(TixError::InvalidInput(format!(
534            "filename may have at most one segment prefix: {filename}"
535        )));
536    }
537    if filename.split('/').any(|component| component.is_empty()) {
538        return Err(TixError::InvalidInput(format!(
539            "filename path components must not be empty: {filename}"
540        )));
541    }
542    Ok(())
543}
544
545/// Split a validated relative filename into `(segment, basename)`. Legacy
546/// flat filenames have an empty segment (`("", "2026-05-10-x.md")`), grouped
547/// ones carry exactly one segment (`("alice", "2026-05-10-x.md")`). [`None`]
548/// for anything that survived a bad path shape.
549fn split_relative(filename: &str) -> Option<(String, String)> {
550    let mut parts = filename.rsplitn(2, '/');
551    let basename = parts.next()?;
552    let segment = parts.next().unwrap_or("");
553    Some((segment.to_string(), basename.to_string()))
554}
555
556/// The first free `<stem>.md` / `<stem>-1.md` / `<stem>-2.md` … name in
557/// `dir` (deterministic existence probe). Never returns an existing name.
558fn resolve_target_name(dir: &Path, stem: &str) -> String {
559    let mut candidate = format!("{stem}.md");
560    let mut i = 1u32;
561    while dir.join(&candidate).exists() {
562        candidate = format!("{stem}-{i}.md");
563        i += 1;
564    }
565    candidate
566}
567
568/// The slug used for the asset dir: the issue filename stem minus the leading
569/// `YYYY-MM-DD-` date prefix (`2026-09-13-fix-login-bug` → `fix-login-bug`).
570/// When the stem has no date prefix it is used as-is (legacy fallback).
571fn slug_from_stem(stem: &str) -> String {
572    let bytes = stem.as_bytes();
573    if stem.len() > 10 && bytes[4] == b'-' && bytes[7] == b'-' && bytes[10] == b'-' {
574        stem[11..].to_string()
575    } else {
576        stem.to_string()
577    }
578}
579
580/// The first free `<name>` / `<name>-<n>.<ext>` name for an asset in `dir`.
581/// When `<name>` is taken the extension is kept and a deterministic
582/// numeric suffix is appended (FR-006).
583fn resolve_asset_name(dir: &Path, name: &str) -> String {
584    if !dir.join(name).exists() {
585        return name.to_string();
586    }
587    let (stem, ext) = match name.rsplit_once('.') {
588        Some((s, e)) => (s, format!(".{e}")),
589        None => (name, String::new()),
590    };
591    let mut i = 1u32;
592    loop {
593        let candidate = format!("{stem}-{i}{ext}");
594        if !dir.join(&candidate).exists() {
595            return candidate;
596        }
597        i += 1;
598    }
599}
600
601/// The first free `<name>` / `<name>-1` / `<name>-2` … directory name in
602/// `dir` (mirrors `resolve_target_name` for the `<slug>-assets/` move).
603fn resolve_asset_dir_name(dir: &Path, name: &str) -> String {
604    let mut candidate = name.to_string();
605    let mut i = 1u32;
606    while dir.join(&candidate).exists() {
607        candidate = format!("{name}-{i}");
608        i += 1;
609    }
610    candidate
611}
612
613/// Copy a file atomically into `dir` under `target` (temp + rename, NFR-004).
614/// The temp file is cleaned up on failure and never left behind.
615fn atomic_copy(dir: &Path, target: &Path, source: &Path) -> Result<()> {
616    let pid = std::process::id();
617    let nonce = nanos();
618    let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
619    let copy_result = (|| -> std::io::Result<()> {
620        std::fs::copy(source, &tmp)?;
621        let f = std::fs::File::open(&tmp)?;
622        f.sync_all()?;
623        Ok(())
624    })();
625    if let Err(e) = copy_result {
626        let _ = std::fs::remove_file(&tmp);
627        return Err(TixError::Io(e));
628    }
629    if let Err(e) = std::fs::rename(&tmp, target) {
630        let _ = std::fs::remove_file(&tmp);
631        return Err(TixError::Io(e));
632    }
633    Ok(())
634}
635
636/// Whether an asset file name is an image by extension (the `![…]` link form).
637fn is_image_name(name: &str) -> bool {
638    matches!(
639        name.rsplit('.')
640            .next()
641            .unwrap_or("")
642            .to_ascii_lowercase()
643            .as_str(),
644        "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp"
645    )
646}
647
648fn atomic_write(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
649    let pid = std::process::id();
650    let nonce = nanos();
651    let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
652    let write_result = (|| -> std::io::Result<()> {
653        let mut f = std::fs::File::create(&tmp)?;
654        use std::io::Write;
655        f.write_all(data)?;
656        f.sync_all()?;
657        Ok(())
658    })();
659    if let Err(e) = write_result {
660        let _ = std::fs::remove_file(&tmp);
661        return Err(TixError::Io(e));
662    }
663    if let Err(e) = std::fs::rename(&tmp, target) {
664        let _ = std::fs::remove_file(&tmp);
665        return Err(TixError::Io(e));
666    }
667    let _ = std::fs::File::open(dir).and_then(|f| f.sync_all());
668    Ok(())
669}
670
671fn nanos() -> u64 {
672    SystemTime::now()
673        .duration_since(UNIX_EPOCH)
674        .map(|d| d.as_nanos() as u64)
675        .unwrap_or(0)
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681    use crate::model::{Priority, today};
682    use std::fs;
683
684    fn make_store() -> (tempfile::TempDir, Store) {
685        let tmp = tempfile::TempDir::new().unwrap();
686        let dir = tmp.path().join("issues");
687        fs::create_dir_all(&dir).unwrap();
688        let config_path = tmp.path().join("tix.toml");
689        let config = Config {
690            issues_dir: dir.clone(),
691            ..Config::default()
692        };
693        config.save(&config_path).unwrap();
694        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
695        let store = Store {
696            issues_dir: dir,
697            config,
698        };
699        (tmp, store)
700    }
701
702    fn write_issue(store: &Store, name: &str, contents: &str) {
703        fs::write(store.issues_dir.join(name), contents).unwrap();
704    }
705
706    fn sample_frontmatter(number: u32, title: &str, status: &str) -> String {
707        format!(
708            "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
709        )
710    }
711
712    #[test]
713    fn list_returns_sorted_by_filename() {
714        let (tmp, store) = make_store();
715        let _ = tmp;
716        write_issue(
717            &store,
718            "2026-03-01-c.md",
719            &sample_frontmatter(3, "C", "todo"),
720        );
721        write_issue(
722            &store,
723            "2026-01-01-a.md",
724            &sample_frontmatter(1, "A", "todo"),
725        );
726        write_issue(
727            &store,
728            "2026-02-01-b.md",
729            &sample_frontmatter(2, "B", "todo"),
730        );
731        let issues = store.list().unwrap();
732        assert_eq!(issues.len(), 3);
733        assert_eq!(issues[0].number, 1);
734        assert_eq!(issues[1].number, 2);
735        assert_eq!(issues[2].number, 3);
736        assert_eq!(issues[0].title, "A");
737    }
738
739    #[test]
740    fn list_ignores_non_md_files() {
741        let (tmp, store) = make_store();
742        let _ = tmp;
743        write_issue(
744            &store,
745            "2026-01-01-a.md",
746            &sample_frontmatter(1, "A", "todo"),
747        );
748        write_issue(&store, "readme.txt", "not an issue");
749        write_issue(&store, "2026-01-01-sub", "no extension");
750        let issues = store.list().unwrap();
751        assert_eq!(issues.len(), 1);
752    }
753
754    #[test]
755    fn list_errors_on_malformed_frontmatter() {
756        let (tmp, store) = make_store();
757        let _ = tmp;
758        write_issue(&store, "2026-01-01-bad.md", "no frontmatter here\n");
759        let err = store.list().unwrap_err();
760        assert!(matches!(err, TixError::Parse(_)), "got {err:?}");
761    }
762
763    #[test]
764    fn get_by_number() {
765        let (tmp, store) = make_store();
766        let _ = tmp;
767        write_issue(
768            &store,
769            "2026-01-01-x.md",
770            &sample_frontmatter(5, "X", "todo"),
771        );
772        let issue = store.get(IssueSelector::Number(5)).unwrap();
773        assert_eq!(issue.title, "X");
774    }
775
776    #[test]
777    fn get_by_filename() {
778        let (tmp, store) = make_store();
779        let _ = tmp;
780        write_issue(
781            &store,
782            "2026-01-01-y.md",
783            &sample_frontmatter(2, "Y", "done"),
784        );
785        let issue = store
786            .get(IssueSelector::Filename("2026-01-01-y.md".into()))
787            .unwrap();
788        assert_eq!(issue.status.display(), "done");
789    }
790
791    #[test]
792    fn get_by_slug() {
793        let (tmp, store) = make_store();
794        let _ = tmp;
795        write_issue(
796            &store,
797            "2026-01-01-z.md",
798            &sample_frontmatter(3, "Z", "todo"),
799        );
800        let issue = store.get(IssueSelector::Slug("z".into())).unwrap();
801        assert_eq!(issue.number, 3);
802    }
803
804    #[test]
805    fn get_not_found() {
806        let (tmp, store) = make_store();
807        let _ = tmp;
808        assert!(store.get(IssueSelector::Number(99)).is_err());
809    }
810
811    #[test]
812    fn create_assigns_number_from_live_issues_and_leaves_config_untouched() {
813        let (tmp, store) = make_store();
814        let _ = tmp;
815        assert_eq!(store.config.state.next_number, 0);
816        let mut issue = Issue {
817            number: 0,
818            title: "First".into(),
819            status: Status::default(),
820            labels: vec![],
821            priority: Priority::default(),
822            assignee: None,
823            estimate: None,
824            commit: None,
825            gitlab_id: None,
826            linear_id: None,
827            mission: None,
828            task: None,
829            links: vec![],
830            files: vec![],
831            created: "2026-01-01".into(),
832            updated: "2026-01-01".into(),
833            filename: String::new(),
834            body: String::new(),
835        };
836        let path = store.create(&mut issue, "Hello world", None).unwrap();
837        assert!(path.exists());
838        assert_eq!(issue.number, 0, "empty store starts at 0");
839        // Issue #52: create never rewrites tix.toml with a counter.
840        let config_path = tmp.path().join("tix.toml");
841        let on_disk = std::fs::read_to_string(&config_path).unwrap();
842        assert!(
843            !on_disk.contains("next_number"),
844            "create must not persist an issue-number counter:\n{on_disk}"
845        );
846        let reloaded = Config::load_with_warning(&config_path, |_| {}).unwrap();
847        assert_eq!(reloaded.state.next_number, 0, "state is never written");
848    }
849
850    #[test]
851    fn create_resolves_max_live_number_plus_one() {
852        let (tmp, store) = make_store();
853        let _ = tmp;
854        write_issue(
855            &store,
856            "2026-01-01-a.md",
857            &sample_frontmatter(0, "A", "todo"),
858        );
859        write_issue(
860            &store,
861            "2026-01-01-b.md",
862            &sample_frontmatter(1, "B", "todo"),
863        );
864        write_issue(
865            &store,
866            "2026-01-01-c.md",
867            &sample_frontmatter(2, "C", "todo"),
868        );
869        let mut issue = Issue {
870            number: 0,
871            title: "D".into(),
872            status: Status::default(),
873            labels: vec![],
874            priority: Priority::default(),
875            assignee: None,
876            estimate: None,
877            commit: None,
878            gitlab_id: None,
879            linear_id: None,
880            mission: None,
881            task: None,
882            links: vec![],
883            files: vec![],
884            created: "2026-01-01".into(),
885            updated: "2026-01-01".into(),
886            filename: String::new(),
887            body: String::new(),
888        };
889        store.create(&mut issue, "", None).unwrap();
890        assert_eq!(issue.number, 3);
891    }
892
893    #[test]
894    fn create_reuses_number_of_deleted_highest_issue() {
895        // Issue #52 tradeoff: without a persisted counter the next number is
896        // re-derived from the live set, so deleting the highest-numbered
897        // issue lets its number be reused by the next create.
898        let (_tmp, store) = make_store();
899        let mut issue_a = Issue {
900            number: 0,
901            title: "A".into(),
902            status: Status::default(),
903            labels: vec![],
904            priority: Priority::default(),
905            assignee: None,
906            estimate: None,
907            commit: None,
908            gitlab_id: None,
909            linear_id: None,
910            mission: None,
911            task: None,
912            links: vec![],
913            files: vec![],
914            created: "2026-01-01".into(),
915            updated: "2026-01-01".into(),
916            filename: String::new(),
917            body: String::new(),
918        };
919        store.create(&mut issue_a, "", None).unwrap();
920        let filename = issue_a.filename.clone();
921        assert_eq!(issue_a.number, 0);
922        store.delete(&filename).unwrap();
923        let mut issue_b = Issue {
924            number: 0,
925            title: "B".into(),
926            status: Status::default(),
927            labels: vec![],
928            priority: Priority::default(),
929            assignee: None,
930            estimate: None,
931            commit: None,
932            gitlab_id: None,
933            linear_id: None,
934            mission: None,
935            task: None,
936            links: vec![],
937            files: vec![],
938            created: "2026-01-02".into(),
939            updated: "2026-01-02".into(),
940            filename: String::new(),
941            body: String::new(),
942        };
943        store.create(&mut issue_b, "", None).unwrap();
944        assert_eq!(issue_b.number, 0, "empty store resumes at floor 0");
945    }
946
947    #[test]
948    fn create_with_custom_filename_stem() {
949        let (tmp, store) = make_store();
950        let _ = tmp;
951        let mut issue = Issue {
952            number: 0,
953            title: "T".into(),
954            status: Status::default(),
955            labels: vec![],
956            priority: Priority::default(),
957            assignee: None,
958            estimate: None,
959            commit: None,
960            gitlab_id: None,
961            linear_id: None,
962            mission: None,
963            task: None,
964            links: vec![],
965            files: vec![],
966            created: "2026-01-01".into(),
967            updated: "2026-01-01".into(),
968            filename: String::new(),
969            body: String::new(),
970        };
971        let path = store
972            .create(&mut issue, "body", Some("custom-stem"))
973            .unwrap();
974        assert!(path.to_string_lossy().contains("custom-stem.md"));
975    }
976
977    #[test]
978    fn create_ensures_issues_dir_exists() {
979        let tmp = tempfile::TempDir::new().unwrap();
980        let dir = tmp.path().join("nested").join("issues");
981        let config_path = tmp.path().join("tix.toml");
982        let config = Config {
983            issues_dir: dir.clone(),
984            ..Config::default()
985        };
986        config.save(&config_path).unwrap();
987        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
988        let store = Store {
989            issues_dir: dir,
990            config,
991        };
992        let mut issue = Issue {
993            number: 0,
994            title: "T".into(),
995            status: Status::default(),
996            labels: vec![],
997            priority: Priority::default(),
998            assignee: None,
999            estimate: None,
1000            commit: None,
1001            gitlab_id: None,
1002            linear_id: None,
1003            mission: None,
1004            task: None,
1005            links: vec![],
1006            files: vec![],
1007            created: "2026-01-01".into(),
1008            updated: "2026-01-01".into(),
1009            filename: String::new(),
1010            body: String::new(),
1011        };
1012        store.create(&mut issue, "", None).unwrap();
1013        assert_eq!(issue.number, 0);
1014    }
1015
1016    #[test]
1017    fn update_replaces_content_atomically() {
1018        let (tmp, store) = make_store();
1019        let _ = tmp;
1020        write_issue(
1021            &store,
1022            "2026-01-01-u.md",
1023            &sample_frontmatter(1, "U", "todo"),
1024        );
1025        let mut issue = store
1026            .get(IssueSelector::Filename("2026-01-01-u.md".into()))
1027            .unwrap();
1028        issue.title = "Updated".into();
1029        issue.bump_updated();
1030        store.update("2026-01-01-u.md", &issue).unwrap();
1031        let reloaded = store
1032            .get(IssueSelector::Filename("2026-01-01-u.md".into()))
1033            .unwrap();
1034        assert_eq!(reloaded.title, "Updated");
1035    }
1036
1037    #[test]
1038    fn update_not_found() {
1039        let (tmp, store) = make_store();
1040        let _ = tmp;
1041        let issue = Issue {
1042            number: 1,
1043            title: "T".into(),
1044            status: Status::default(),
1045            labels: vec![],
1046            priority: Priority::default(),
1047            assignee: None,
1048            estimate: None,
1049            commit: None,
1050            gitlab_id: None,
1051            linear_id: None,
1052            mission: None,
1053            task: None,
1054            links: vec![],
1055            files: vec![],
1056            created: "2026-01-01".into(),
1057            updated: "2026-01-01".into(),
1058            filename: String::new(),
1059            body: String::new(),
1060        };
1061        assert!(store.update("nope.md", &issue).is_err());
1062    }
1063
1064    #[test]
1065    fn delete_removes_file() {
1066        let (tmp, store) = make_store();
1067        let _ = tmp;
1068        write_issue(
1069            &store,
1070            "2026-01-01-d.md",
1071            &sample_frontmatter(1, "D", "todo"),
1072        );
1073        assert!(store.issues_dir.join("2026-01-01-d.md").exists());
1074        store.delete("2026-01-01-d.md").unwrap();
1075        assert!(!store.issues_dir.join("2026-01-01-d.md").exists());
1076    }
1077
1078    #[test]
1079    fn delete_not_found() {
1080        let (tmp, store) = make_store();
1081        let _ = tmp;
1082        assert!(store.delete("nope.md").is_err());
1083    }
1084
1085    #[test]
1086    fn stray_tmp_file_not_treated_as_issue() {
1087        let (tmp, store) = make_store();
1088        let _ = tmp;
1089        write_issue(
1090            &store,
1091            "2026-01-01-a.md",
1092            &sample_frontmatter(1, "A", "todo"),
1093        );
1094        fs::write(store.issues_dir.join(".tix-tmp-9999-0"), "orphaned data").unwrap();
1095        let issues = store.list().unwrap();
1096        assert_eq!(issues.len(), 1);
1097    }
1098
1099    #[test]
1100    fn set_status_changes_status_and_bumps_updated() {
1101        let (tmp, store) = make_store();
1102        let _ = tmp;
1103        write_issue(
1104            &store,
1105            "2026-01-01-s.md",
1106            &sample_frontmatter(1, "S", "todo"),
1107        );
1108        let issue = store.set_status("2026-01-01-s.md", "in progress").unwrap();
1109        assert_eq!(issue.status.display(), "in progress");
1110        assert_eq!(issue.updated, today());
1111        assert_eq!(issue.title, "S");
1112        assert_eq!(issue.body, "Body text.");
1113    }
1114
1115    #[test]
1116    fn set_status_preserves_file_path() {
1117        let (tmp, store) = make_store();
1118        let _ = tmp;
1119        write_issue(
1120            &store,
1121            "2026-01-01-f.md",
1122            &sample_frontmatter(1, "F", "todo"),
1123        );
1124        let _ = store.set_status("2026-01-01-f.md", "done").unwrap();
1125        assert!(store.issues_dir.join("2026-01-01-f.md").exists());
1126    }
1127
1128    // --- WP: link / unlink / set_links (D2) ---
1129
1130    fn linked_frontmatter(number: u32, title: &str, links: &str) -> String {
1131        format!(
1132            "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\nlinks: {links}\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
1133        )
1134    }
1135
1136    #[test]
1137    fn link_adds_back_references_both_ways() {
1138        let (tmp, store) = make_store();
1139        let _ = tmp;
1140        write_issue(
1141            &store,
1142            "2026-01-01-a.md",
1143            &sample_frontmatter(12, "A", "todo"),
1144        );
1145        write_issue(
1146            &store,
1147            "2026-01-01-b.md",
1148            &sample_frontmatter(47, "B", "todo"),
1149        );
1150        let (a, b) = store.link("12", "47").unwrap();
1151        assert_eq!(a.links, ["47"]);
1152        assert_eq!(b.links, ["12"]);
1153        let reread_a = store.get(IssueSelector::Number(12)).unwrap();
1154        let reread_b = store.get(IssueSelector::Number(47)).unwrap();
1155        assert_eq!(reread_a.links, ["47"]);
1156        assert_eq!(reread_b.links, ["12"]);
1157    }
1158
1159    #[test]
1160    fn link_is_idempotent() {
1161        let (tmp, store) = make_store();
1162        let _ = tmp;
1163        write_issue(
1164            &store,
1165            "2026-01-01-a.md",
1166            &sample_frontmatter(12, "A", "todo"),
1167        );
1168        write_issue(
1169            &store,
1170            "2026-01-01-b.md",
1171            &sample_frontmatter(47, "B", "todo"),
1172        );
1173        store.link("12", "47").unwrap();
1174        let (a, b) = store.link("12", "47").unwrap();
1175        assert_eq!(a.links, ["47"]);
1176        assert_eq!(b.links, ["12"]);
1177    }
1178
1179    #[test]
1180    fn link_to_missing_issue_errors() {
1181        let (tmp, store) = make_store();
1182        let _ = tmp;
1183        write_issue(
1184            &store,
1185            "2026-01-01-a.md",
1186            &sample_frontmatter(12, "A", "todo"),
1187        );
1188        let err = store.link("12", "999").unwrap_err();
1189        assert!(matches!(err, TixError::NotFound(_)), "got {err:?}");
1190    }
1191
1192    #[test]
1193    fn link_to_self_is_invalid_input() {
1194        let (tmp, store) = make_store();
1195        let _ = tmp;
1196        write_issue(
1197            &store,
1198            "2026-01-01-a.md",
1199            &sample_frontmatter(12, "A", "todo"),
1200        );
1201        let err = store.link("12", "12").unwrap_err();
1202        assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
1203    }
1204
1205    #[test]
1206    fn unlink_clears_both_sides() {
1207        let (tmp, store) = make_store();
1208        let _ = tmp;
1209        write_issue(
1210            &store,
1211            "2026-01-01-a.md",
1212            &linked_frontmatter(12, "A", "[47]"),
1213        );
1214        write_issue(
1215            &store,
1216            "2026-01-01-b.md",
1217            &linked_frontmatter(47, "B", "[12]"),
1218        );
1219        let (a, b) = store.unlink("12", "47").unwrap();
1220        assert!(a.links.is_empty());
1221        assert!(b.links.is_empty());
1222        let reread_a = store.get(IssueSelector::Number(12)).unwrap();
1223        assert!(reread_a.links.is_empty());
1224    }
1225
1226    #[test]
1227    fn unlink_of_never_linked_pair_is_noop() {
1228        let (tmp, store) = make_store();
1229        let _ = tmp;
1230        write_issue(
1231            &store,
1232            "2026-01-01-a.md",
1233            &sample_frontmatter(12, "A", "todo"),
1234        );
1235        write_issue(
1236            &store,
1237            "2026-01-01-b.md",
1238            &sample_frontmatter(47, "B", "todo"),
1239        );
1240        let (a, b) = store.unlink("12", "47").unwrap();
1241        assert!(a.links.is_empty());
1242        assert!(b.links.is_empty());
1243    }
1244
1245    #[test]
1246    fn set_links_writes_back_references_for_added_targets() {
1247        let (tmp, store) = make_store();
1248        let _ = tmp;
1249        write_issue(
1250            &store,
1251            "2026-01-01-a.md",
1252            &linked_frontmatter(5, "A", "[12, 47]"),
1253        );
1254        write_issue(
1255            &store,
1256            "2026-01-01-b.md",
1257            &sample_frontmatter(12, "B", "todo"),
1258        );
1259        write_issue(
1260            &store,
1261            "2026-01-01-c.md",
1262            &linked_frontmatter(47, "C", "[5]"),
1263        );
1264        write_issue(
1265            &store,
1266            "2026-01-01-d.md",
1267            &sample_frontmatter(9, "D", "todo"),
1268        );
1269        let issue = store.set_links("2026-01-01-a.md", vec![47, 9]).unwrap();
1270        assert_eq!(issue.links, ["47", "9"]);
1271        let nine = store.get(IssueSelector::Number(9)).unwrap();
1272        assert_eq!(nine.links, ["5"]);
1273        let twelve = store.get(IssueSelector::Number(12)).unwrap();
1274        assert!(
1275            twelve.links.is_empty(),
1276            "dropped target keeps back-ref: {:?}",
1277            twelve.links
1278        );
1279        let forty_seven = store.get(IssueSelector::Number(47)).unwrap();
1280        assert_eq!(forty_seven.links, ["5"], "kept target keeps back-ref");
1281    }
1282
1283    #[test]
1284    fn set_links_clears_and_removes_all_back_references() {
1285        let (tmp, store) = make_store();
1286        let _ = tmp;
1287        write_issue(
1288            &store,
1289            "2026-01-01-a.md",
1290            &linked_frontmatter(5, "A", "[12, 47]"),
1291        );
1292        write_issue(
1293            &store,
1294            "2026-01-01-b.md",
1295            &linked_frontmatter(12, "B", "[5]"),
1296        );
1297        write_issue(
1298            &store,
1299            "2026-01-01-c.md",
1300            &linked_frontmatter(47, "C", "[5]"),
1301        );
1302        let issue = store.set_links("2026-01-01-a.md", vec![]).unwrap();
1303        assert!(issue.links.is_empty());
1304        let twelve = store.get(IssueSelector::Number(12)).unwrap();
1305        assert!(twelve.links.is_empty());
1306        let forty_seven = store.get(IssueSelector::Number(47)).unwrap();
1307        assert!(forty_seven.links.is_empty());
1308    }
1309
1310    #[test]
1311    fn set_links_rejects_unknown_target() {
1312        let (tmp, store) = make_store();
1313        let _ = tmp;
1314        write_issue(
1315            &store,
1316            "2026-01-01-a.md",
1317            &sample_frontmatter(5, "A", "todo"),
1318        );
1319        let err = store.set_links("2026-01-01-a.md", vec![999]).unwrap_err();
1320        assert!(matches!(err, TixError::NotFound(_)), "got {err:?}");
1321    }
1322
1323    #[test]
1324    fn set_links_rejects_self_link() {
1325        let (tmp, store) = make_store();
1326        let _ = tmp;
1327        write_issue(
1328            &store,
1329            "2026-01-01-a.md",
1330            &sample_frontmatter(5, "A", "todo"),
1331        );
1332        let err = store.set_links("2026-01-01-a.md", vec![5]).unwrap_err();
1333        assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
1334    }
1335
1336    #[test]
1337    fn path_traversal_with_dotdot_refused() {
1338        let err = validate_filename("../etc/passwd").unwrap_err();
1339        assert!(matches!(err, TixError::InvalidInput(_)));
1340    }
1341
1342    #[test]
1343    fn path_traversal_with_slash_refused() {
1344        let err = validate_filename("sub/../../x.md").unwrap_err();
1345        assert!(matches!(err, TixError::InvalidInput(_)));
1346    }
1347
1348    #[test]
1349    fn path_traversal_with_nul_refused() {
1350        let err = validate_filename("file\0.md").unwrap_err();
1351        assert!(matches!(err, TixError::InvalidInput(_)));
1352    }
1353
1354    #[test]
1355    fn delete_refuses_traversal_before_touching_filesystem() {
1356        let (tmp, store) = make_store();
1357        let outside = tmp.path().join("escape.md");
1358        fs::write(&outside, "secret").unwrap();
1359        let err = store.delete("../escape.md").unwrap_err();
1360        assert!(matches!(err, TixError::InvalidInput(_)));
1361        assert!(
1362            outside.exists(),
1363            "traversal must not delete outside issues_dir"
1364        );
1365        assert_eq!(fs::read_to_string(&outside).unwrap(), "secret");
1366    }
1367
1368    #[test]
1369    fn numbers_stable_across_list_and_set_status() {
1370        let (tmp, store) = make_store();
1371        let _ = tmp;
1372        write_issue(
1373            &store,
1374            "2026-01-01-x.md",
1375            &sample_frontmatter(42, "X", "todo"),
1376        );
1377        let before = store.list().unwrap();
1378        assert_eq!(before[0].number, 42);
1379        let after = store.set_status("2026-01-01-x.md", "done").unwrap();
1380        assert_eq!(after.number, 42);
1381        let rescan = store.list().unwrap();
1382        assert_eq!(rescan[0].number, 42);
1383    }
1384
1385    #[test]
1386    fn atomic_write_no_temp_left_on_success() {
1387        let (tmp, store) = make_store();
1388        let _ = tmp;
1389        atomic_write(
1390            &store.issues_dir,
1391            &store.issues_dir.join("test.md"),
1392            b"hello",
1393        )
1394        .unwrap();
1395        let entries: Vec<_> = fs::read_dir(&store.issues_dir)
1396            .unwrap()
1397            .filter_map(|e| e.ok())
1398            .map(|e| e.file_name().to_string_lossy().to_string())
1399            .collect();
1400        assert_eq!(entries, vec!["test.md"]);
1401    }
1402
1403    // ------------------------------------------------------------------
1404    // T006 — recursive scan: grouped segments + legacy flat compat (C-010)
1405    // ------------------------------------------------------------------
1406
1407    #[test]
1408    fn list_mixes_grouped_and_flat_issues_sorted_by_relative_filename() {
1409        let (tmp, store) = make_store();
1410        let _ = tmp;
1411        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1412        fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
1413        write_issue(
1414            &store,
1415            "alice/2026-01-01-a.md",
1416            &sample_frontmatter(1, "A", "todo"),
1417        );
1418        write_issue(
1419            &store,
1420            "bob/2026-05-10-b.md",
1421            &sample_frontmatter(2, "B", "todo"),
1422        );
1423        write_issue(
1424            &store,
1425            "2026-02-01-c.md",
1426            &sample_frontmatter(3, "C", "todo"),
1427        );
1428        let issues = store.list().unwrap();
1429        let filenames: Vec<&str> = issues.iter().map(|i| i.filename.as_str()).collect();
1430        assert_eq!(issues.len(), 3);
1431        assert_eq!(
1432            filenames,
1433            vec![
1434                "2026-02-01-c.md",
1435                "alice/2026-01-01-a.md",
1436                "bob/2026-05-10-b.md"
1437            ],
1438            "deterministic sort by relative filename"
1439        );
1440    }
1441
1442    #[test]
1443    fn list_sorts_chronologically_within_a_group() {
1444        let (tmp, store) = make_store();
1445        let _ = tmp;
1446        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1447        write_issue(
1448            &store,
1449            "alice/2026-03-01-c.md",
1450            &sample_frontmatter(3, "C", "todo"),
1451        );
1452        write_issue(
1453            &store,
1454            "alice/2026-01-01-a.md",
1455            &sample_frontmatter(1, "A", "todo"),
1456        );
1457        write_issue(
1458            &store,
1459            "alice/2026-02-01-b.md",
1460            &sample_frontmatter(2, "B", "todo"),
1461        );
1462        let issues = store.list().unwrap();
1463        let numbers: Vec<u32> = issues.iter().map(|i| i.number).collect();
1464        assert_eq!(numbers, vec![1, 2, 3]);
1465    }
1466
1467    #[test]
1468    fn list_ignores_non_md_files_in_segments() {
1469        let (tmp, store) = make_store();
1470        let _ = tmp;
1471        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1472        write_issue(
1473            &store,
1474            "alice/2026-01-01-a.md",
1475            &sample_frontmatter(1, "A", "todo"),
1476        );
1477        fs::write(store.issues_dir.join("alice/notes.txt"), "not an issue").unwrap();
1478        fs::write(store.issues_dir.join("alice/.tix-tmp-123-456"), "orphan").unwrap();
1479        let issues = store.list().unwrap();
1480        assert_eq!(issues.len(), 1);
1481        assert_eq!(issues[0].filename, "alice/2026-01-01-a.md");
1482    }
1483
1484    #[test]
1485    fn list_skips_temp_marker_directories() {
1486        let (tmp, store) = make_store();
1487        let _ = tmp;
1488        fs::create_dir_all(store.issues_dir.join(".tix-tmp-999-0")).unwrap();
1489        write_issue(
1490            &store,
1491            ".tix-tmp-999-0/2026-01-01-junk.md",
1492            &sample_frontmatter(9, "J", "todo"),
1493        );
1494        write_issue(
1495            &store,
1496            "2026-01-01-a.md",
1497            &sample_frontmatter(1, "A", "todo"),
1498        );
1499        let issues = store.list().unwrap();
1500        assert_eq!(issues.len(), 1);
1501        assert_eq!(issues[0].filename, "2026-01-01-a.md");
1502    }
1503
1504    #[test]
1505    fn list_parse_error_carries_grouped_relative_path() {
1506        let (tmp, store) = make_store();
1507        let _ = tmp;
1508        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1509        fs::write(
1510            store.issues_dir.join("alice/2026-01-01-bad.md"),
1511            "no frontmatter here\n",
1512        )
1513        .unwrap();
1514        let err = store.list().unwrap_err();
1515        assert!(
1516            matches!(&err, TixError::Parse(m) if m.starts_with("alice/2026-01-01-bad.md:")),
1517            "parse error must carry the relative path, got {err:?}"
1518        );
1519    }
1520
1521    // ------------------------------------------------------------------
1522    // T007 — grouped create writes into the assignee segment (FR-029/30)
1523    // ------------------------------------------------------------------
1524
1525    #[test]
1526    fn create_writes_into_assignee_segment_and_records_relative_filename() {
1527        let (tmp, store) = make_store();
1528        let _ = tmp;
1529        let mut issue = Issue {
1530            number: 0,
1531            title: "T".into(),
1532            status: Status::default(),
1533            labels: vec![],
1534            priority: Priority::default(),
1535            assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
1536            estimate: None,
1537            commit: None,
1538            gitlab_id: None,
1539            linear_id: None,
1540            mission: None,
1541            task: None,
1542            links: vec![],
1543            files: vec![],
1544            created: "2026-01-01".into(),
1545            updated: "2026-01-01".into(),
1546            filename: String::new(),
1547            body: String::new(),
1548        };
1549        let path = store
1550            .create(&mut issue, "body", Some("custom-stem"))
1551            .unwrap();
1552        let rel = "herbert-maschke/custom-stem.md";
1553        assert_eq!(issue.filename, rel);
1554        assert_eq!(path, store.issues_dir.join(rel));
1555        assert!(store.issues_dir.join("herbert-maschke").is_dir());
1556        assert!(store.issues_dir.join(rel).is_file());
1557    }
1558
1559    #[test]
1560    fn create_without_assignee_writes_unassigned_segment() {
1561        let (tmp, store) = make_store();
1562        let _ = tmp;
1563        let mut issue = Issue {
1564            number: 0,
1565            title: "T".into(),
1566            status: Status::default(),
1567            labels: vec![],
1568            priority: Priority::default(),
1569            assignee: None,
1570            estimate: None,
1571            commit: None,
1572            gitlab_id: None,
1573            linear_id: None,
1574            mission: None,
1575            task: None,
1576            links: vec![],
1577            files: vec![],
1578            created: "2026-01-01".into(),
1579            updated: "2026-01-01".into(),
1580            filename: String::new(),
1581            body: String::new(),
1582        };
1583        let path = store.create(&mut issue, "", Some("stem")).unwrap();
1584        assert_eq!(issue.filename, "unassigned/stem.md");
1585        assert!(store.issues_dir.join("unassigned").is_dir());
1586        assert!(store.issues_dir.join("unassigned/stem.md").is_file());
1587        assert_eq!(path, store.issues_dir.join("unassigned/stem.md"));
1588    }
1589
1590    #[test]
1591    fn create_with_at_handle_derives_bare_segment() {
1592        let (tmp, store) = make_store();
1593        let _ = tmp;
1594        let mut issue = Issue {
1595            number: 0,
1596            title: "T".into(),
1597            status: Status::default(),
1598            labels: vec![],
1599            priority: Priority::default(),
1600            assignee: Some("@alice".into()),
1601            estimate: None,
1602            commit: None,
1603            gitlab_id: None,
1604            linear_id: None,
1605            mission: None,
1606            task: None,
1607            links: vec![],
1608            files: vec![],
1609            created: "2026-01-01".into(),
1610            updated: "2026-01-01".into(),
1611            filename: String::new(),
1612            body: String::new(),
1613        };
1614        store.create(&mut issue, "", Some("stem")).unwrap();
1615        assert_eq!(issue.filename, "alice/stem.md");
1616        assert!(store.issues_dir.join("alice/stem.md").is_file());
1617    }
1618
1619    // ------------------------------------------------------------------
1620    // T008 — reassign(): move-on-reassign (FR-031), never overwrites
1621    // ------------------------------------------------------------------
1622
1623    #[test]
1624    fn reassign_moves_file_between_segments() {
1625        let (tmp, store) = make_store();
1626        let _ = tmp;
1627        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1628        write_issue(
1629            &store,
1630            "alice/2026-01-01-x.md",
1631            &sample_frontmatter(1, "X", "todo"),
1632        );
1633        let new_path = store
1634            .reassign("alice/2026-01-01-x.md", Some("@bob"))
1635            .unwrap();
1636        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x.md"));
1637        assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
1638        assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
1639    }
1640
1641    #[test]
1642    fn reassign_into_occupied_segment_picks_deterministic_suffix() {
1643        let (tmp, store) = make_store();
1644        let _ = tmp;
1645        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1646        fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
1647        write_issue(
1648            &store,
1649            "alice/2026-01-01-x.md",
1650            &sample_frontmatter(1, "X", "todo"),
1651        );
1652        write_issue(
1653            &store,
1654            "bob/2026-01-01-x.md",
1655            &sample_frontmatter(2, "X", "todo"),
1656        );
1657        let new_path = store
1658            .reassign("alice/2026-01-01-x.md", Some("@bob"))
1659            .unwrap();
1660        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x-1.md"));
1661        assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
1662        assert!(store.issues_dir.join("bob/2026-01-01-x-1.md").is_file());
1663        assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
1664    }
1665
1666    #[test]
1667    fn reassign_legacy_flat_file_into_segment() {
1668        let (tmp, store) = make_store();
1669        let _ = tmp;
1670        write_issue(
1671            &store,
1672            "2026-01-01-y.md",
1673            &sample_frontmatter(1, "Y", "todo"),
1674        );
1675        let new_path = store.reassign("2026-01-01-y.md", Some("alice")).unwrap();
1676        assert_eq!(new_path, PathBuf::from("alice/2026-01-01-y.md"));
1677        assert!(store.issues_dir.join("alice/2026-01-01-y.md").is_file());
1678        assert!(!store.issues_dir.join("2026-01-01-y.md").exists());
1679    }
1680
1681    #[test]
1682    fn reassign_to_none_uses_unassigned_fallback() {
1683        let (tmp, store) = make_store();
1684        let _ = tmp;
1685        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1686        write_issue(
1687            &store,
1688            "alice/2026-01-01-z.md",
1689            &sample_frontmatter(1, "Z", "todo"),
1690        );
1691        let new_path = store.reassign("alice/2026-01-01-z.md", None).unwrap();
1692        assert_eq!(new_path, PathBuf::from("unassigned/2026-01-01-z.md"));
1693        assert!(
1694            store
1695                .issues_dir
1696                .join("unassigned/2026-01-01-z.md")
1697                .is_file()
1698        );
1699        assert!(!store.issues_dir.join("alice/2026-01-01-z.md").exists());
1700    }
1701
1702    #[test]
1703    fn reassign_keeps_number_and_created_stable() {
1704        let (tmp, store) = make_store();
1705        let _ = tmp;
1706        let mut issue = Issue {
1707            number: 0,
1708            title: "Stable".into(),
1709            status: Status::default(),
1710            labels: vec![],
1711            priority: Priority::default(),
1712            assignee: Some("alice".into()),
1713            estimate: None,
1714            commit: None,
1715            gitlab_id: None,
1716            linear_id: None,
1717            mission: None,
1718            task: None,
1719            links: vec![],
1720            files: vec![],
1721            created: "2026-01-01".into(),
1722            updated: "2026-01-01".into(),
1723            filename: String::new(),
1724            body: String::new(),
1725        };
1726        store.create(&mut issue, "body", None).unwrap();
1727        let number = issue.number;
1728        let created = issue.created.clone();
1729        let before = issue.filename.clone();
1730        let new_path = store.reassign(&before, Some("@bob")).unwrap();
1731        let rescan = store.list().unwrap();
1732        let moved = rescan
1733            .iter()
1734            .find(|i| i.filename == new_path)
1735            .expect("moved file must be listed at its new relative path");
1736        assert_eq!(moved.number, number);
1737        assert_eq!(moved.created, created);
1738        assert_eq!(moved.title, "Stable");
1739    }
1740
1741    #[test]
1742    fn reassign_equal_segment_is_noop() {
1743        let (tmp, store) = make_store();
1744        let _ = tmp;
1745        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1746        write_issue(
1747            &store,
1748            "alice/2026-01-01-x.md",
1749            &sample_frontmatter(1, "X", "todo"),
1750        );
1751        let new_path = store
1752            .reassign("alice/2026-01-01-x.md", Some("alice"))
1753            .unwrap();
1754        assert_eq!(new_path, PathBuf::from("alice/2026-01-01-x.md"));
1755        assert!(store.issues_dir.join("alice/2026-01-01-x.md").is_file());
1756    }
1757
1758    #[test]
1759    fn reassign_missing_file_is_not_found() {
1760        let (tmp, store) = make_store();
1761        let _ = tmp;
1762        let err = store.reassign("nope.md", Some("alice")).unwrap_err();
1763        assert!(matches!(err, TixError::NotFound(_)));
1764    }
1765
1766    // ------------------------------------------------------------------
1767    // T009 — validate_filename relaxation + relative-path ops (C-008/C-010)
1768    // ------------------------------------------------------------------
1769
1770    #[test]
1771    fn validate_filename_allows_flat_and_single_segment_prefix() {
1772        validate_filename("2026-01-01-x.md").unwrap();
1773        validate_filename("alice/2026-01-01-x.md").unwrap();
1774    }
1775
1776    #[test]
1777    fn validate_filename_refuses_deeper_segments() {
1778        let err = validate_filename("alice/bob/2026-01-01-x.md").unwrap_err();
1779        assert!(matches!(err, TixError::InvalidInput(_)));
1780    }
1781
1782    #[test]
1783    fn validate_filename_refuses_leading_and_trailing_slash_and_absolute() {
1784        let err = validate_filename("/alice/2026-01-01-x.md").unwrap_err();
1785        assert!(matches!(err, TixError::InvalidInput(_)));
1786        let err = validate_filename("/etc/passwd").unwrap_err();
1787        assert!(matches!(err, TixError::InvalidInput(_)));
1788        let err = validate_filename("alice/").unwrap_err();
1789        assert!(matches!(err, TixError::InvalidInput(_)));
1790    }
1791
1792    #[test]
1793    fn path_traversal_with_segment_dotdot_refused() {
1794        let err = validate_filename("alice/../../x.md").unwrap_err();
1795        assert!(matches!(err, TixError::InvalidInput(_)));
1796        let err = validate_filename("alice/../x.md").unwrap_err();
1797        assert!(matches!(err, TixError::InvalidInput(_)));
1798    }
1799
1800    #[test]
1801    fn get_update_delete_set_status_work_on_grouped_relative_paths() {
1802        let (tmp, store) = make_store();
1803        let _ = tmp;
1804        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1805        write_issue(
1806            &store,
1807            "alice/2026-01-01-g.md",
1808            &sample_frontmatter(7, "G", "todo"),
1809        );
1810        let got = store
1811            .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
1812            .unwrap();
1813        assert_eq!(got.number, 7);
1814        let done = store.set_status("alice/2026-01-01-g.md", "done").unwrap();
1815        assert_eq!(done.status.display(), "done");
1816        assert!(store.issues_dir.join("alice/2026-01-01-g.md").is_file());
1817        let mut edited = store
1818            .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
1819            .unwrap();
1820        edited.title = "Updated".into();
1821        store.update("alice/2026-01-01-g.md", &edited).unwrap();
1822        let reloaded = store
1823            .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
1824            .unwrap();
1825        assert_eq!(reloaded.title, "Updated");
1826        store.delete("alice/2026-01-01-g.md").unwrap();
1827        assert!(!store.issues_dir.join("alice/2026-01-01-g.md").exists());
1828    }
1829
1830    // ------------------------------------------------------------------
1831    // T010 — grouped round-trip sweep
1832    // ------------------------------------------------------------------
1833
1834    #[test]
1835    fn grouped_round_trip_create_list_get_update_delete() {
1836        let (tmp, store) = make_store();
1837        let _ = tmp;
1838        let mut issue = Issue {
1839            number: 0,
1840            title: "Round trip".into(),
1841            status: Status::default(),
1842            labels: vec!["bug".into()],
1843            priority: Priority::default(),
1844            assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
1845            estimate: None,
1846            commit: None,
1847            gitlab_id: None,
1848            linear_id: None,
1849            mission: None,
1850            task: None,
1851            links: vec![],
1852            files: vec![],
1853            created: "2026-01-01".into(),
1854            updated: "2026-01-01".into(),
1855            filename: String::new(),
1856            body: String::new(),
1857        };
1858        store.create(&mut issue, "initial body", None).unwrap();
1859        let rel = issue.filename.clone();
1860        assert_eq!(rel, "herbert-maschke/2026-01-01-round-trip.md");
1861
1862        let listed = store.list().unwrap();
1863        assert_eq!(listed.len(), 1);
1864        assert_eq!(listed[0].filename, rel);
1865        assert_eq!(listed[0].number, issue.number);
1866
1867        let got = store.get(IssueSelector::Filename(rel.clone())).unwrap();
1868        let mut edited = got.clone();
1869        edited.title = "Renamed".into();
1870        store.update(&rel, &edited).unwrap();
1871        let reloaded = store.get(IssueSelector::Filename(rel.clone())).unwrap();
1872        assert_eq!(reloaded.title, "Renamed");
1873
1874        store.delete(&rel).unwrap();
1875        assert!(!store.issues_dir.join(&rel).exists());
1876        assert!(store.list().unwrap().is_empty());
1877    }
1878
1879    #[test]
1880    fn legacy_flat_file_edit_and_status_still_work_after_layout_flip() {
1881        let (tmp, store) = make_store();
1882        let _ = tmp;
1883        write_issue(
1884            &store,
1885            "2026-01-01-legacy.md",
1886            &sample_frontmatter(5, "Legacy", "todo"),
1887        );
1888        let listed = store.list().unwrap();
1889        assert_eq!(listed.len(), 1);
1890        assert_eq!(listed[0].filename, "2026-01-01-legacy.md");
1891        let mutated = store
1892            .set_status("2026-01-01-legacy.md", "in progress")
1893            .unwrap();
1894        assert_eq!(mutated.status.display(), "in progress");
1895        assert!(store.issues_dir.join("2026-01-01-legacy.md").is_file());
1896    }
1897
1898    // ------------------------------------------------------------------
1899    // T004 — add_asset(): copy + link insert (FR-006, NFR-004)
1900    // ------------------------------------------------------------------
1901
1902    fn write_asset_source(tmp: &tempfile::TempDir, name: &str, contents: &[u8]) -> PathBuf {
1903        let path = tmp.path().join(name);
1904        fs::write(&path, contents).unwrap();
1905        path
1906    }
1907
1908    fn write_issue_with_basic_body(store: &Store, name: &str, number: u32) {
1909        write_issue(
1910            store,
1911            name,
1912            &sample_frontmatter(number, "Asset host", "todo"),
1913        );
1914    }
1915
1916    #[test]
1917    fn add_asset_image_inserts_image_link_and_writes_file() {
1918        let (tmp, store) = make_store();
1919        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1920        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
1921        let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
1922        let added = store
1923            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
1924            .unwrap();
1925        assert_eq!(added.written_name, "diagram.png");
1926        assert_eq!(added.link, "![diagram](fix-login-bug-assets/diagram.png)");
1927        assert!(
1928            store
1929                .issues_dir
1930                .join("alice/fix-login-bug-assets/diagram.png")
1931                .is_file()
1932        );
1933    }
1934
1935    #[test]
1936    fn add_asset_non_image_inserts_generic_markdown_link() {
1937        let (tmp, store) = make_store();
1938        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1939        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
1940        let src = write_asset_source(&tmp, "report.pdf", b"%PDF");
1941        let added = store
1942            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
1943            .unwrap();
1944        assert_eq!(added.link, "[report](fix-login-bug-assets/report.pdf)");
1945        assert!(
1946            store
1947                .issues_dir
1948                .join("alice/fix-login-bug-assets/report.pdf")
1949                .is_file()
1950        );
1951    }
1952
1953    #[test]
1954    fn add_asset_collision_resolves_with_numeric_suffix_and_link_points_at_it() {
1955        let (tmp, store) = make_store();
1956        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1957        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
1958        let src = write_asset_source(&tmp, "x.png", b"first");
1959        let first = store
1960            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
1961            .unwrap();
1962        assert_eq!(first.written_name, "x.png");
1963        let again = store
1964            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
1965            .unwrap();
1966        assert_eq!(again.written_name, "x-1.png");
1967        assert_eq!(again.link, "![x-1](fix-login-bug-assets/x-1.png)");
1968        assert!(
1969            store
1970                .issues_dir
1971                .join("alice/fix-login-bug-assets/x.png")
1972                .is_file()
1973        );
1974        assert!(
1975            store
1976                .issues_dir
1977                .join("alice/fix-login-bug-assets/x-1.png")
1978                .is_file()
1979        );
1980    }
1981
1982    #[test]
1983    fn add_asset_appends_single_link_and_preserves_description() {
1984        let (tmp, store) = make_store();
1985        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1986        write_issue(
1987            &store,
1988            "alice/2026-01-01-fix-login-bug.md",
1989            "---\nnumber: 1\ntitle: \"H\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nTitle line.\n\n## Comments\n\n### alice (2026-01-01)\n\nExisting comment.\n",
1990        );
1991        let src = write_asset_source(&tmp, "diagram.svg", b"<svg/>");
1992        store
1993            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
1994            .unwrap();
1995        let reloaded = store
1996            .get(crate::storage::IssueSelector::Filename(
1997                "alice/2026-01-01-fix-login-bug.md".into(),
1998            ))
1999            .unwrap();
2000        // Description (above the `## Comments` boundary) untouched.
2001        assert_eq!(reloaded.description(), "Title line.");
2002        // Exactly one link line, appended after the comments section.
2003        let link_count = reloaded.body.matches("![diagram]").count();
2004        assert_eq!(link_count, 1);
2005        assert!(
2006            reloaded
2007                .body
2008                .ends_with("fix-login-bug-assets/diagram.svg)\n")
2009        );
2010    }
2011
2012    #[test]
2013    fn add_asset_missing_issue_is_not_found() {
2014        let (tmp, store) = make_store();
2015        let _ = tmp;
2016        let src = write_asset_source(&tmp, "x.png", b"x");
2017        let err = store.add_asset("missing.md", &src).unwrap_err();
2018        assert!(matches!(err, TixError::NotFound(_)));
2019    }
2020
2021    #[test]
2022    fn add_asset_legacy_flat_file_uses_stem_assets_under_issues_dir() {
2023        let (tmp, store) = make_store();
2024        write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
2025        let src = write_asset_source(&tmp, "x.png", b"x");
2026        let added = store.add_asset("2026-01-01-legacy.md", &src).unwrap();
2027        assert_eq!(added.link, "![x](legacy-assets/x.png)");
2028        assert!(store.issues_dir.join("legacy-assets/x.png").is_file());
2029    }
2030
2031    // ------------------------------------------------------------------
2032    // T005 — reassign() moves the assets dir (FR-007)
2033    // ------------------------------------------------------------------
2034
2035    #[test]
2036    fn reassign_moves_asset_dir_with_the_file() {
2037        let (tmp, store) = make_store();
2038        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2039        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
2040        let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
2041        store
2042            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
2043            .unwrap();
2044        let new_path = store
2045            .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
2046            .unwrap();
2047        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
2048        assert!(
2049            store
2050                .issues_dir
2051                .join("bob/fix-login-bug-assets/diagram.png")
2052                .is_file()
2053        );
2054        assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
2055    }
2056
2057    #[test]
2058    fn reassign_without_asset_dir_behaves_as_before() {
2059        let (tmp, store) = make_store();
2060        let _ = tmp;
2061        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2062        write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
2063        let new_path = store
2064            .reassign("alice/2026-01-01-plain.md", Some("@bob"))
2065            .unwrap();
2066        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-plain.md"));
2067        assert!(store.issues_dir.join("bob/2026-01-01-plain.md").is_file());
2068        assert!(!store.issues_dir.join("bob/plain-assets").exists());
2069    }
2070
2071    #[test]
2072    fn reassign_into_segment_with_same_named_asset_dir_uses_suffix_no_loss() {
2073        let (tmp, store) = make_store();
2074        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2075        fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
2076        // Both segments already hold a same-named asset dir.
2077        let existing = store.issues_dir.join("bob/fix-login-bug-assets");
2078        fs::create_dir_all(&existing).unwrap();
2079        fs::write(existing.join("keep.txt"), "precious").unwrap();
2080        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
2081        let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
2082        store
2083            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
2084            .unwrap();
2085        let new_path = store
2086            .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
2087            .unwrap();
2088        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
2089        // Pre-existing target assets untouched, moved dir suffixed.
2090        assert!(existing.join("keep.txt").is_file());
2091        assert!(
2092            store
2093                .issues_dir
2094                .join("bob/fix-login-bug-assets-1/diagram.png")
2095                .is_file()
2096        );
2097        assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
2098    }
2099
2100    #[test]
2101    fn reassign_preserves_markdown_link_resolution_in_new_segment() {
2102        let (tmp, store) = make_store();
2103        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2104        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
2105        let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
2106        store
2107            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
2108            .unwrap();
2109        store
2110            .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
2111            .unwrap();
2112        // The asset dir kept its base name, so the relative link
2113        // `fix-login-bug-assets/diagram.png` resolves in the new segment.
2114        assert!(
2115            store
2116                .issues_dir
2117                .join("bob/fix-login-bug-assets/diagram.png")
2118                .is_file()
2119        );
2120    }
2121
2122    // ------------------------------------------------------------------
2123    // T006 — delete() removes the assets dir (FR-008) + sweep
2124    // ------------------------------------------------------------------
2125
2126    #[test]
2127    fn delete_removes_asset_dir_no_orphans() {
2128        let (tmp, store) = make_store();
2129        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2130        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
2131        let src = write_asset_source(&tmp, "x.png", b"x");
2132        store
2133            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
2134            .unwrap();
2135        store.delete("alice/2026-01-01-fix-login-bug.md").unwrap();
2136        assert!(
2137            !store
2138                .issues_dir
2139                .join("alice/2026-01-01-fix-login-bug.md")
2140                .exists()
2141        );
2142        assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
2143    }
2144
2145    #[test]
2146    fn delete_without_asset_dir_still_deletes_file() {
2147        let (tmp, store) = make_store();
2148        let _ = tmp;
2149        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2150        write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
2151        store.delete("alice/2026-01-01-plain.md").unwrap();
2152        assert!(!store.issues_dir.join("alice/2026-01-01-plain.md").exists());
2153    }
2154
2155    #[test]
2156    fn list_ignores_asset_directories() {
2157        let (tmp, store) = make_store();
2158        write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
2159        let src = write_asset_source(&tmp, "x.png", b"x");
2160        store.add_asset("2026-01-01-legacy.md", &src).unwrap();
2161        // Legacy flat file → `legacy-assets/` sits directly under issues_dir,
2162        // where the `-assets` guard in `list()` must skip it.
2163        let issues = store.list().unwrap();
2164        assert_eq!(issues.len(), 1);
2165        assert_eq!(issues[0].filename, "2026-01-01-legacy.md");
2166    }
2167
2168    #[test]
2169    fn list_ignores_nested_asset_directories_under_segment() {
2170        let (_tmp, store) = make_store();
2171        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2172        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
2173        let src = write_asset_source(&_tmp, "x.png", b"x");
2174        store
2175            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
2176            .unwrap();
2177        let issues = store.list().unwrap();
2178        assert_eq!(issues.len(), 1);
2179        assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
2180    }
2181
2182    #[test]
2183    fn asset_dir_with_formless_md_child_is_not_scanned_as_segment() {
2184        let (tmp, store) = make_store();
2185        let _ = tmp;
2186        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2187        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
2188        // A `.md` straggler inside an `-assets` dir must never surface as an
2189        // issue segment (guard in `list()` for `<slug>-assets/`).
2190        let assets_dir = store.issues_dir.join("alice/fix-login-bug-assets");
2191        fs::create_dir_all(&assets_dir).unwrap();
2192        fs::write(assets_dir.join("notes.md"), "no frontmatter").unwrap();
2193        let issues = store.list().unwrap();
2194        assert_eq!(issues.len(), 1);
2195        assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
2196    }
2197}