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    /// Physically move an issue file into the assignee segment of a new
316    /// assignee (FR-031, `contracts/file-format.md` rule 9).
317    ///
318    /// `filename` is a path relative to the issues dir: either a legacy flat
319    /// basename (`2026-05-10-fix-login.md`, C-010) or a grouped path
320    /// (`alice/2026-05-10-fix-login.md`). It is validated via the relaxed
321    /// [`validate_filename`] before anything touches the filesystem, so
322    /// traversal attempts are refused here as everywhere else.
323    ///
324    /// The old assignee segment is read from the current relative path, the
325    /// new one derived with [`derive_segment`]; equal segments are a no-op.
326    /// The target filename keeps the original `created-slug` stem; when the
327    /// target already holds `<stem>.md`, a deterministic `-1`, `-2`, …
328    /// suffix is appended by testing existence — an existing file is never
329    /// overwritten or deleted. `number`/`created` live in the frontmatter and
330    /// are untouched, so issue-number stability is guaranteed by construction
331    /// (C-007).
332    ///
333    /// The move is a single `std::fs::rename` (atomic-ish, same filesystem).
334    /// Returns the **new relative path** (matching how `issue.filename` is
335    /// stored) on success, including the unchanged relative path for the
336    /// equal-segment no-op.
337    pub fn reassign(&self, filename: &str, new_assignee: Option<&str>) -> Result<PathBuf> {
338        // `get` validates `filename` and errors NotFound when it is missing.
339        self.get(IssueSelector::Filename(filename.to_string()))?;
340
341        let (old_segment, file_name) = split_relative(filename).ok_or_else(|| {
342            TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
343        })?;
344        let new_segment = derive_segment(new_assignee);
345        if old_segment == new_segment {
346            return Ok(PathBuf::from(filename));
347        }
348
349        let stem = file_name
350            .strip_suffix(".md")
351            .unwrap_or(&file_name)
352            .to_string();
353        let target_dir = self.issues_dir.join(&new_segment);
354        std::fs::create_dir_all(&target_dir)?;
355        let new_name = resolve_target_name(&target_dir, &stem);
356        std::fs::rename(self.issues_dir.join(filename), target_dir.join(&new_name))?;
357
358        // Move the `<slug>-assets/` dir alongside the file (FR-007). With no
359        // asset dir this is a no-op and reassign behaves exactly as before.
360        // On a same-named dir in the target segment, disambiguate with a
361        // suffixed dir name (deterministic existence probe, mirroring
362        // `resolve_target_name`) rather than overwriting.
363        let slug = slug_from_stem(&stem);
364        let asset_name = format!("{slug}-assets");
365        let old_asset_dir = self.issues_dir.join(&old_segment).join(&asset_name);
366        if old_asset_dir.exists() {
367            let moved = resolve_asset_dir_name(&target_dir, &asset_name);
368            std::fs::rename(&old_asset_dir, target_dir.join(&moved))?;
369        }
370        Ok(PathBuf::from(format!("{new_segment}/{new_name}")))
371    }
372}
373
374/// Validate an issue filename. A filename is either a legacy flat basename
375/// (`2026-05-10-fix-login.md`) or a grouped path with exactly one assignee
376/// segment prefix (`alice/2026-05-10-fix-login.md`, C-008/C-010).
377///
378/// Refused in all cases: NUL bytes, backslashes, the `..` component, empty
379/// components (leading/trailing/double slashes), absolute / leading-slash
380/// paths, and nesting deeper than one segment. Every refusal is a hard
381/// [`TixError::InvalidInput`] raised before any filesystem touch.
382fn validate_filename(filename: &str) -> Result<()> {
383    if filename.is_empty() {
384        return Err(TixError::InvalidInput(format!(
385            "filename must not be empty: {filename:?}"
386        )));
387    }
388    if filename.starts_with('/') {
389        return Err(TixError::InvalidInput(format!(
390            "filename must be relative (no leading `/`): {filename}"
391        )));
392    }
393    if filename.contains('\\') {
394        return Err(TixError::InvalidInput(format!(
395            "filename must not contain backslashes: {filename}"
396        )));
397    }
398    if filename.contains("..") {
399        return Err(TixError::InvalidInput(format!(
400            "filename must not contain `..`: {filename}"
401        )));
402    }
403    if filename.contains('\0') {
404        return Err(TixError::InvalidInput(format!(
405            "filename must not contain NUL: {filename}"
406        )));
407    }
408    if filename.split('/').count() > 2 {
409        return Err(TixError::InvalidInput(format!(
410            "filename may have at most one segment prefix: {filename}"
411        )));
412    }
413    if filename.split('/').any(|component| component.is_empty()) {
414        return Err(TixError::InvalidInput(format!(
415            "filename path components must not be empty: {filename}"
416        )));
417    }
418    Ok(())
419}
420
421/// Split a validated relative filename into `(segment, basename)`. Legacy
422/// flat filenames have an empty segment (`("", "2026-05-10-x.md")`), grouped
423/// ones carry exactly one segment (`("alice", "2026-05-10-x.md")`). [`None`]
424/// for anything that survived a bad path shape.
425fn split_relative(filename: &str) -> Option<(String, String)> {
426    let mut parts = filename.rsplitn(2, '/');
427    let basename = parts.next()?;
428    let segment = parts.next().unwrap_or("");
429    Some((segment.to_string(), basename.to_string()))
430}
431
432/// The first free `<stem>.md` / `<stem>-1.md` / `<stem>-2.md` … name in
433/// `dir` (deterministic existence probe). Never returns an existing name.
434fn resolve_target_name(dir: &Path, stem: &str) -> String {
435    let mut candidate = format!("{stem}.md");
436    let mut i = 1u32;
437    while dir.join(&candidate).exists() {
438        candidate = format!("{stem}-{i}.md");
439        i += 1;
440    }
441    candidate
442}
443
444/// The slug used for the asset dir: the issue filename stem minus the leading
445/// `YYYY-MM-DD-` date prefix (`2026-09-13-fix-login-bug` → `fix-login-bug`).
446/// When the stem has no date prefix it is used as-is (legacy fallback).
447fn slug_from_stem(stem: &str) -> String {
448    let bytes = stem.as_bytes();
449    if stem.len() > 10 && bytes[4] == b'-' && bytes[7] == b'-' && bytes[10] == b'-' {
450        stem[11..].to_string()
451    } else {
452        stem.to_string()
453    }
454}
455
456/// The first free `<name>` / `<name>-<n>.<ext>` name for an asset in `dir`.
457/// When `<name>` is taken the extension is kept and a deterministic
458/// numeric suffix is appended (FR-006).
459fn resolve_asset_name(dir: &Path, name: &str) -> String {
460    if !dir.join(name).exists() {
461        return name.to_string();
462    }
463    let (stem, ext) = match name.rsplit_once('.') {
464        Some((s, e)) => (s, format!(".{e}")),
465        None => (name, String::new()),
466    };
467    let mut i = 1u32;
468    loop {
469        let candidate = format!("{stem}-{i}{ext}");
470        if !dir.join(&candidate).exists() {
471            return candidate;
472        }
473        i += 1;
474    }
475}
476
477/// The first free `<name>` / `<name>-1` / `<name>-2` … directory name in
478/// `dir` (mirrors `resolve_target_name` for the `<slug>-assets/` move).
479fn resolve_asset_dir_name(dir: &Path, name: &str) -> String {
480    let mut candidate = name.to_string();
481    let mut i = 1u32;
482    while dir.join(&candidate).exists() {
483        candidate = format!("{name}-{i}");
484        i += 1;
485    }
486    candidate
487}
488
489/// Copy a file atomically into `dir` under `target` (temp + rename, NFR-004).
490/// The temp file is cleaned up on failure and never left behind.
491fn atomic_copy(dir: &Path, target: &Path, source: &Path) -> Result<()> {
492    let pid = std::process::id();
493    let nonce = nanos();
494    let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
495    let copy_result = (|| -> std::io::Result<()> {
496        std::fs::copy(source, &tmp)?;
497        let f = std::fs::File::open(&tmp)?;
498        f.sync_all()?;
499        Ok(())
500    })();
501    if let Err(e) = copy_result {
502        let _ = std::fs::remove_file(&tmp);
503        return Err(TixError::Io(e));
504    }
505    if let Err(e) = std::fs::rename(&tmp, target) {
506        let _ = std::fs::remove_file(&tmp);
507        return Err(TixError::Io(e));
508    }
509    Ok(())
510}
511
512/// Whether an asset file name is an image by extension (the `![…]` link form).
513fn is_image_name(name: &str) -> bool {
514    matches!(
515        name.rsplit('.')
516            .next()
517            .unwrap_or("")
518            .to_ascii_lowercase()
519            .as_str(),
520        "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp"
521    )
522}
523
524fn atomic_write(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
525    let pid = std::process::id();
526    let nonce = nanos();
527    let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
528    let write_result = (|| -> std::io::Result<()> {
529        let mut f = std::fs::File::create(&tmp)?;
530        use std::io::Write;
531        f.write_all(data)?;
532        f.sync_all()?;
533        Ok(())
534    })();
535    if let Err(e) = write_result {
536        let _ = std::fs::remove_file(&tmp);
537        return Err(TixError::Io(e));
538    }
539    if let Err(e) = std::fs::rename(&tmp, target) {
540        let _ = std::fs::remove_file(&tmp);
541        return Err(TixError::Io(e));
542    }
543    let _ = std::fs::File::open(dir).and_then(|f| f.sync_all());
544    Ok(())
545}
546
547fn nanos() -> u64 {
548    SystemTime::now()
549        .duration_since(UNIX_EPOCH)
550        .map(|d| d.as_nanos() as u64)
551        .unwrap_or(0)
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    use crate::model::{Priority, today};
558    use std::fs;
559
560    fn make_store() -> (tempfile::TempDir, Store) {
561        let tmp = tempfile::TempDir::new().unwrap();
562        let dir = tmp.path().join("issues");
563        fs::create_dir_all(&dir).unwrap();
564        let config_path = tmp.path().join("tix.toml");
565        let config = Config {
566            issues_dir: dir.clone(),
567            ..Config::default()
568        };
569        config.save(&config_path).unwrap();
570        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
571        let store = Store {
572            issues_dir: dir,
573            config,
574        };
575        (tmp, store)
576    }
577
578    fn write_issue(store: &Store, name: &str, contents: &str) {
579        fs::write(store.issues_dir.join(name), contents).unwrap();
580    }
581
582    fn sample_frontmatter(number: u32, title: &str, status: &str) -> String {
583        format!(
584            "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
585        )
586    }
587
588    #[test]
589    fn list_returns_sorted_by_filename() {
590        let (tmp, store) = make_store();
591        let _ = tmp;
592        write_issue(
593            &store,
594            "2026-03-01-c.md",
595            &sample_frontmatter(3, "C", "todo"),
596        );
597        write_issue(
598            &store,
599            "2026-01-01-a.md",
600            &sample_frontmatter(1, "A", "todo"),
601        );
602        write_issue(
603            &store,
604            "2026-02-01-b.md",
605            &sample_frontmatter(2, "B", "todo"),
606        );
607        let issues = store.list().unwrap();
608        assert_eq!(issues.len(), 3);
609        assert_eq!(issues[0].number, 1);
610        assert_eq!(issues[1].number, 2);
611        assert_eq!(issues[2].number, 3);
612        assert_eq!(issues[0].title, "A");
613    }
614
615    #[test]
616    fn list_ignores_non_md_files() {
617        let (tmp, store) = make_store();
618        let _ = tmp;
619        write_issue(
620            &store,
621            "2026-01-01-a.md",
622            &sample_frontmatter(1, "A", "todo"),
623        );
624        write_issue(&store, "readme.txt", "not an issue");
625        write_issue(&store, "2026-01-01-sub", "no extension");
626        let issues = store.list().unwrap();
627        assert_eq!(issues.len(), 1);
628    }
629
630    #[test]
631    fn list_errors_on_malformed_frontmatter() {
632        let (tmp, store) = make_store();
633        let _ = tmp;
634        write_issue(&store, "2026-01-01-bad.md", "no frontmatter here\n");
635        let err = store.list().unwrap_err();
636        assert!(matches!(err, TixError::Parse(_)), "got {err:?}");
637    }
638
639    #[test]
640    fn get_by_number() {
641        let (tmp, store) = make_store();
642        let _ = tmp;
643        write_issue(
644            &store,
645            "2026-01-01-x.md",
646            &sample_frontmatter(5, "X", "todo"),
647        );
648        let issue = store.get(IssueSelector::Number(5)).unwrap();
649        assert_eq!(issue.title, "X");
650    }
651
652    #[test]
653    fn get_by_filename() {
654        let (tmp, store) = make_store();
655        let _ = tmp;
656        write_issue(
657            &store,
658            "2026-01-01-y.md",
659            &sample_frontmatter(2, "Y", "done"),
660        );
661        let issue = store
662            .get(IssueSelector::Filename("2026-01-01-y.md".into()))
663            .unwrap();
664        assert_eq!(issue.status.display(), "done");
665    }
666
667    #[test]
668    fn get_by_slug() {
669        let (tmp, store) = make_store();
670        let _ = tmp;
671        write_issue(
672            &store,
673            "2026-01-01-z.md",
674            &sample_frontmatter(3, "Z", "todo"),
675        );
676        let issue = store.get(IssueSelector::Slug("z".into())).unwrap();
677        assert_eq!(issue.number, 3);
678    }
679
680    #[test]
681    fn get_not_found() {
682        let (tmp, store) = make_store();
683        let _ = tmp;
684        assert!(store.get(IssueSelector::Number(99)).is_err());
685    }
686
687    #[test]
688    fn create_assigns_number_from_live_issues_and_leaves_config_untouched() {
689        let (tmp, store) = make_store();
690        let _ = tmp;
691        assert_eq!(store.config.state.next_number, 0);
692        let mut issue = Issue {
693            number: 0,
694            title: "First".into(),
695            status: Status::default(),
696            labels: vec![],
697            priority: Priority::default(),
698            assignee: None,
699            estimate: None,
700            commit: None,
701            gitlab_id: None,
702            linear_id: None,
703            mission: None,
704            task: None,
705            created: "2026-01-01".into(),
706            updated: "2026-01-01".into(),
707            filename: String::new(),
708            body: String::new(),
709        };
710        let path = store.create(&mut issue, "Hello world", None).unwrap();
711        assert!(path.exists());
712        assert_eq!(issue.number, 0, "empty store starts at 0");
713        // Issue #52: create never rewrites tix.toml with a counter.
714        let config_path = tmp.path().join("tix.toml");
715        let on_disk = std::fs::read_to_string(&config_path).unwrap();
716        assert!(
717            !on_disk.contains("next_number"),
718            "create must not persist an issue-number counter:\n{on_disk}"
719        );
720        let reloaded = Config::load_with_warning(&config_path, |_| {}).unwrap();
721        assert_eq!(reloaded.state.next_number, 0, "state is never written");
722    }
723
724    #[test]
725    fn create_resolves_max_live_number_plus_one() {
726        let (tmp, store) = make_store();
727        let _ = tmp;
728        write_issue(
729            &store,
730            "2026-01-01-a.md",
731            &sample_frontmatter(0, "A", "todo"),
732        );
733        write_issue(
734            &store,
735            "2026-01-01-b.md",
736            &sample_frontmatter(1, "B", "todo"),
737        );
738        write_issue(
739            &store,
740            "2026-01-01-c.md",
741            &sample_frontmatter(2, "C", "todo"),
742        );
743        let mut issue = Issue {
744            number: 0,
745            title: "D".into(),
746            status: Status::default(),
747            labels: vec![],
748            priority: Priority::default(),
749            assignee: None,
750            estimate: None,
751            commit: None,
752            gitlab_id: None,
753            linear_id: None,
754            mission: None,
755            task: None,
756            created: "2026-01-01".into(),
757            updated: "2026-01-01".into(),
758            filename: String::new(),
759            body: String::new(),
760        };
761        store.create(&mut issue, "", None).unwrap();
762        assert_eq!(issue.number, 3);
763    }
764
765    #[test]
766    fn create_reuses_number_of_deleted_highest_issue() {
767        // Issue #52 tradeoff: without a persisted counter the next number is
768        // re-derived from the live set, so deleting the highest-numbered
769        // issue lets its number be reused by the next create.
770        let (_tmp, store) = make_store();
771        let mut issue_a = Issue {
772            number: 0,
773            title: "A".into(),
774            status: Status::default(),
775            labels: vec![],
776            priority: Priority::default(),
777            assignee: None,
778            estimate: None,
779            commit: None,
780            gitlab_id: None,
781            linear_id: None,
782            mission: None,
783            task: None,
784            created: "2026-01-01".into(),
785            updated: "2026-01-01".into(),
786            filename: String::new(),
787            body: String::new(),
788        };
789        store.create(&mut issue_a, "", None).unwrap();
790        let filename = issue_a.filename.clone();
791        assert_eq!(issue_a.number, 0);
792        store.delete(&filename).unwrap();
793        let mut issue_b = Issue {
794            number: 0,
795            title: "B".into(),
796            status: Status::default(),
797            labels: vec![],
798            priority: Priority::default(),
799            assignee: None,
800            estimate: None,
801            commit: None,
802            gitlab_id: None,
803            linear_id: None,
804            mission: None,
805            task: None,
806            created: "2026-01-02".into(),
807            updated: "2026-01-02".into(),
808            filename: String::new(),
809            body: String::new(),
810        };
811        store.create(&mut issue_b, "", None).unwrap();
812        assert_eq!(issue_b.number, 0, "empty store resumes at floor 0");
813    }
814
815    #[test]
816    fn create_with_custom_filename_stem() {
817        let (tmp, store) = make_store();
818        let _ = tmp;
819        let mut issue = Issue {
820            number: 0,
821            title: "T".into(),
822            status: Status::default(),
823            labels: vec![],
824            priority: Priority::default(),
825            assignee: None,
826            estimate: None,
827            commit: None,
828            gitlab_id: None,
829            linear_id: None,
830            mission: None,
831            task: None,
832            created: "2026-01-01".into(),
833            updated: "2026-01-01".into(),
834            filename: String::new(),
835            body: String::new(),
836        };
837        let path = store
838            .create(&mut issue, "body", Some("custom-stem"))
839            .unwrap();
840        assert!(path.to_string_lossy().contains("custom-stem.md"));
841    }
842
843    #[test]
844    fn create_ensures_issues_dir_exists() {
845        let tmp = tempfile::TempDir::new().unwrap();
846        let dir = tmp.path().join("nested").join("issues");
847        let config_path = tmp.path().join("tix.toml");
848        let config = Config {
849            issues_dir: dir.clone(),
850            ..Config::default()
851        };
852        config.save(&config_path).unwrap();
853        let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
854        let store = Store {
855            issues_dir: dir,
856            config,
857        };
858        let mut issue = Issue {
859            number: 0,
860            title: "T".into(),
861            status: Status::default(),
862            labels: vec![],
863            priority: Priority::default(),
864            assignee: None,
865            estimate: None,
866            commit: None,
867            gitlab_id: None,
868            linear_id: None,
869            mission: None,
870            task: None,
871            created: "2026-01-01".into(),
872            updated: "2026-01-01".into(),
873            filename: String::new(),
874            body: String::new(),
875        };
876        store.create(&mut issue, "", None).unwrap();
877        assert_eq!(issue.number, 0);
878    }
879
880    #[test]
881    fn update_replaces_content_atomically() {
882        let (tmp, store) = make_store();
883        let _ = tmp;
884        write_issue(
885            &store,
886            "2026-01-01-u.md",
887            &sample_frontmatter(1, "U", "todo"),
888        );
889        let mut issue = store
890            .get(IssueSelector::Filename("2026-01-01-u.md".into()))
891            .unwrap();
892        issue.title = "Updated".into();
893        issue.bump_updated();
894        store.update("2026-01-01-u.md", &issue).unwrap();
895        let reloaded = store
896            .get(IssueSelector::Filename("2026-01-01-u.md".into()))
897            .unwrap();
898        assert_eq!(reloaded.title, "Updated");
899    }
900
901    #[test]
902    fn update_not_found() {
903        let (tmp, store) = make_store();
904        let _ = tmp;
905        let issue = Issue {
906            number: 1,
907            title: "T".into(),
908            status: Status::default(),
909            labels: vec![],
910            priority: Priority::default(),
911            assignee: None,
912            estimate: None,
913            commit: None,
914            gitlab_id: None,
915            linear_id: None,
916            mission: None,
917            task: None,
918            created: "2026-01-01".into(),
919            updated: "2026-01-01".into(),
920            filename: String::new(),
921            body: String::new(),
922        };
923        assert!(store.update("nope.md", &issue).is_err());
924    }
925
926    #[test]
927    fn delete_removes_file() {
928        let (tmp, store) = make_store();
929        let _ = tmp;
930        write_issue(
931            &store,
932            "2026-01-01-d.md",
933            &sample_frontmatter(1, "D", "todo"),
934        );
935        assert!(store.issues_dir.join("2026-01-01-d.md").exists());
936        store.delete("2026-01-01-d.md").unwrap();
937        assert!(!store.issues_dir.join("2026-01-01-d.md").exists());
938    }
939
940    #[test]
941    fn delete_not_found() {
942        let (tmp, store) = make_store();
943        let _ = tmp;
944        assert!(store.delete("nope.md").is_err());
945    }
946
947    #[test]
948    fn stray_tmp_file_not_treated_as_issue() {
949        let (tmp, store) = make_store();
950        let _ = tmp;
951        write_issue(
952            &store,
953            "2026-01-01-a.md",
954            &sample_frontmatter(1, "A", "todo"),
955        );
956        fs::write(store.issues_dir.join(".tix-tmp-9999-0"), "orphaned data").unwrap();
957        let issues = store.list().unwrap();
958        assert_eq!(issues.len(), 1);
959    }
960
961    #[test]
962    fn set_status_changes_status_and_bumps_updated() {
963        let (tmp, store) = make_store();
964        let _ = tmp;
965        write_issue(
966            &store,
967            "2026-01-01-s.md",
968            &sample_frontmatter(1, "S", "todo"),
969        );
970        let issue = store.set_status("2026-01-01-s.md", "in progress").unwrap();
971        assert_eq!(issue.status.display(), "in progress");
972        assert_eq!(issue.updated, today());
973        assert_eq!(issue.title, "S");
974        assert_eq!(issue.body, "Body text.");
975    }
976
977    #[test]
978    fn set_status_preserves_file_path() {
979        let (tmp, store) = make_store();
980        let _ = tmp;
981        write_issue(
982            &store,
983            "2026-01-01-f.md",
984            &sample_frontmatter(1, "F", "todo"),
985        );
986        let _ = store.set_status("2026-01-01-f.md", "done").unwrap();
987        assert!(store.issues_dir.join("2026-01-01-f.md").exists());
988    }
989
990    #[test]
991    fn path_traversal_with_dotdot_refused() {
992        let err = validate_filename("../etc/passwd").unwrap_err();
993        assert!(matches!(err, TixError::InvalidInput(_)));
994    }
995
996    #[test]
997    fn path_traversal_with_slash_refused() {
998        let err = validate_filename("sub/../../x.md").unwrap_err();
999        assert!(matches!(err, TixError::InvalidInput(_)));
1000    }
1001
1002    #[test]
1003    fn path_traversal_with_nul_refused() {
1004        let err = validate_filename("file\0.md").unwrap_err();
1005        assert!(matches!(err, TixError::InvalidInput(_)));
1006    }
1007
1008    #[test]
1009    fn delete_refuses_traversal_before_touching_filesystem() {
1010        let (tmp, store) = make_store();
1011        let outside = tmp.path().join("escape.md");
1012        fs::write(&outside, "secret").unwrap();
1013        let err = store.delete("../escape.md").unwrap_err();
1014        assert!(matches!(err, TixError::InvalidInput(_)));
1015        assert!(
1016            outside.exists(),
1017            "traversal must not delete outside issues_dir"
1018        );
1019        assert_eq!(fs::read_to_string(&outside).unwrap(), "secret");
1020    }
1021
1022    #[test]
1023    fn numbers_stable_across_list_and_set_status() {
1024        let (tmp, store) = make_store();
1025        let _ = tmp;
1026        write_issue(
1027            &store,
1028            "2026-01-01-x.md",
1029            &sample_frontmatter(42, "X", "todo"),
1030        );
1031        let before = store.list().unwrap();
1032        assert_eq!(before[0].number, 42);
1033        let after = store.set_status("2026-01-01-x.md", "done").unwrap();
1034        assert_eq!(after.number, 42);
1035        let rescan = store.list().unwrap();
1036        assert_eq!(rescan[0].number, 42);
1037    }
1038
1039    #[test]
1040    fn atomic_write_no_temp_left_on_success() {
1041        let (tmp, store) = make_store();
1042        let _ = tmp;
1043        atomic_write(
1044            &store.issues_dir,
1045            &store.issues_dir.join("test.md"),
1046            b"hello",
1047        )
1048        .unwrap();
1049        let entries: Vec<_> = fs::read_dir(&store.issues_dir)
1050            .unwrap()
1051            .filter_map(|e| e.ok())
1052            .map(|e| e.file_name().to_string_lossy().to_string())
1053            .collect();
1054        assert_eq!(entries, vec!["test.md"]);
1055    }
1056
1057    // ------------------------------------------------------------------
1058    // T006 — recursive scan: grouped segments + legacy flat compat (C-010)
1059    // ------------------------------------------------------------------
1060
1061    #[test]
1062    fn list_mixes_grouped_and_flat_issues_sorted_by_relative_filename() {
1063        let (tmp, store) = make_store();
1064        let _ = tmp;
1065        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1066        fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
1067        write_issue(
1068            &store,
1069            "alice/2026-01-01-a.md",
1070            &sample_frontmatter(1, "A", "todo"),
1071        );
1072        write_issue(
1073            &store,
1074            "bob/2026-05-10-b.md",
1075            &sample_frontmatter(2, "B", "todo"),
1076        );
1077        write_issue(
1078            &store,
1079            "2026-02-01-c.md",
1080            &sample_frontmatter(3, "C", "todo"),
1081        );
1082        let issues = store.list().unwrap();
1083        let filenames: Vec<&str> = issues.iter().map(|i| i.filename.as_str()).collect();
1084        assert_eq!(issues.len(), 3);
1085        assert_eq!(
1086            filenames,
1087            vec![
1088                "2026-02-01-c.md",
1089                "alice/2026-01-01-a.md",
1090                "bob/2026-05-10-b.md"
1091            ],
1092            "deterministic sort by relative filename"
1093        );
1094    }
1095
1096    #[test]
1097    fn list_sorts_chronologically_within_a_group() {
1098        let (tmp, store) = make_store();
1099        let _ = tmp;
1100        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1101        write_issue(
1102            &store,
1103            "alice/2026-03-01-c.md",
1104            &sample_frontmatter(3, "C", "todo"),
1105        );
1106        write_issue(
1107            &store,
1108            "alice/2026-01-01-a.md",
1109            &sample_frontmatter(1, "A", "todo"),
1110        );
1111        write_issue(
1112            &store,
1113            "alice/2026-02-01-b.md",
1114            &sample_frontmatter(2, "B", "todo"),
1115        );
1116        let issues = store.list().unwrap();
1117        let numbers: Vec<u32> = issues.iter().map(|i| i.number).collect();
1118        assert_eq!(numbers, vec![1, 2, 3]);
1119    }
1120
1121    #[test]
1122    fn list_ignores_non_md_files_in_segments() {
1123        let (tmp, store) = make_store();
1124        let _ = tmp;
1125        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1126        write_issue(
1127            &store,
1128            "alice/2026-01-01-a.md",
1129            &sample_frontmatter(1, "A", "todo"),
1130        );
1131        fs::write(store.issues_dir.join("alice/notes.txt"), "not an issue").unwrap();
1132        fs::write(store.issues_dir.join("alice/.tix-tmp-123-456"), "orphan").unwrap();
1133        let issues = store.list().unwrap();
1134        assert_eq!(issues.len(), 1);
1135        assert_eq!(issues[0].filename, "alice/2026-01-01-a.md");
1136    }
1137
1138    #[test]
1139    fn list_skips_temp_marker_directories() {
1140        let (tmp, store) = make_store();
1141        let _ = tmp;
1142        fs::create_dir_all(store.issues_dir.join(".tix-tmp-999-0")).unwrap();
1143        write_issue(
1144            &store,
1145            ".tix-tmp-999-0/2026-01-01-junk.md",
1146            &sample_frontmatter(9, "J", "todo"),
1147        );
1148        write_issue(
1149            &store,
1150            "2026-01-01-a.md",
1151            &sample_frontmatter(1, "A", "todo"),
1152        );
1153        let issues = store.list().unwrap();
1154        assert_eq!(issues.len(), 1);
1155        assert_eq!(issues[0].filename, "2026-01-01-a.md");
1156    }
1157
1158    #[test]
1159    fn list_parse_error_carries_grouped_relative_path() {
1160        let (tmp, store) = make_store();
1161        let _ = tmp;
1162        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1163        fs::write(
1164            store.issues_dir.join("alice/2026-01-01-bad.md"),
1165            "no frontmatter here\n",
1166        )
1167        .unwrap();
1168        let err = store.list().unwrap_err();
1169        assert!(
1170            matches!(&err, TixError::Parse(m) if m.starts_with("alice/2026-01-01-bad.md:")),
1171            "parse error must carry the relative path, got {err:?}"
1172        );
1173    }
1174
1175    // ------------------------------------------------------------------
1176    // T007 — grouped create writes into the assignee segment (FR-029/30)
1177    // ------------------------------------------------------------------
1178
1179    #[test]
1180    fn create_writes_into_assignee_segment_and_records_relative_filename() {
1181        let (tmp, store) = make_store();
1182        let _ = tmp;
1183        let mut issue = Issue {
1184            number: 0,
1185            title: "T".into(),
1186            status: Status::default(),
1187            labels: vec![],
1188            priority: Priority::default(),
1189            assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
1190            estimate: None,
1191            commit: None,
1192            gitlab_id: None,
1193            linear_id: None,
1194            mission: None,
1195            task: None,
1196            created: "2026-01-01".into(),
1197            updated: "2026-01-01".into(),
1198            filename: String::new(),
1199            body: String::new(),
1200        };
1201        let path = store
1202            .create(&mut issue, "body", Some("custom-stem"))
1203            .unwrap();
1204        let rel = "herbert-maschke/custom-stem.md";
1205        assert_eq!(issue.filename, rel);
1206        assert_eq!(path, store.issues_dir.join(rel));
1207        assert!(store.issues_dir.join("herbert-maschke").is_dir());
1208        assert!(store.issues_dir.join(rel).is_file());
1209    }
1210
1211    #[test]
1212    fn create_without_assignee_writes_unassigned_segment() {
1213        let (tmp, store) = make_store();
1214        let _ = tmp;
1215        let mut issue = Issue {
1216            number: 0,
1217            title: "T".into(),
1218            status: Status::default(),
1219            labels: vec![],
1220            priority: Priority::default(),
1221            assignee: None,
1222            estimate: None,
1223            commit: None,
1224            gitlab_id: None,
1225            linear_id: None,
1226            mission: None,
1227            task: None,
1228            created: "2026-01-01".into(),
1229            updated: "2026-01-01".into(),
1230            filename: String::new(),
1231            body: String::new(),
1232        };
1233        let path = store.create(&mut issue, "", Some("stem")).unwrap();
1234        assert_eq!(issue.filename, "unassigned/stem.md");
1235        assert!(store.issues_dir.join("unassigned").is_dir());
1236        assert!(store.issues_dir.join("unassigned/stem.md").is_file());
1237        assert_eq!(path, store.issues_dir.join("unassigned/stem.md"));
1238    }
1239
1240    #[test]
1241    fn create_with_at_handle_derives_bare_segment() {
1242        let (tmp, store) = make_store();
1243        let _ = tmp;
1244        let mut issue = Issue {
1245            number: 0,
1246            title: "T".into(),
1247            status: Status::default(),
1248            labels: vec![],
1249            priority: Priority::default(),
1250            assignee: Some("@alice".into()),
1251            estimate: None,
1252            commit: None,
1253            gitlab_id: None,
1254            linear_id: None,
1255            mission: None,
1256            task: None,
1257            created: "2026-01-01".into(),
1258            updated: "2026-01-01".into(),
1259            filename: String::new(),
1260            body: String::new(),
1261        };
1262        store.create(&mut issue, "", Some("stem")).unwrap();
1263        assert_eq!(issue.filename, "alice/stem.md");
1264        assert!(store.issues_dir.join("alice/stem.md").is_file());
1265    }
1266
1267    // ------------------------------------------------------------------
1268    // T008 — reassign(): move-on-reassign (FR-031), never overwrites
1269    // ------------------------------------------------------------------
1270
1271    #[test]
1272    fn reassign_moves_file_between_segments() {
1273        let (tmp, store) = make_store();
1274        let _ = tmp;
1275        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1276        write_issue(
1277            &store,
1278            "alice/2026-01-01-x.md",
1279            &sample_frontmatter(1, "X", "todo"),
1280        );
1281        let new_path = store
1282            .reassign("alice/2026-01-01-x.md", Some("@bob"))
1283            .unwrap();
1284        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x.md"));
1285        assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
1286        assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
1287    }
1288
1289    #[test]
1290    fn reassign_into_occupied_segment_picks_deterministic_suffix() {
1291        let (tmp, store) = make_store();
1292        let _ = tmp;
1293        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1294        fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
1295        write_issue(
1296            &store,
1297            "alice/2026-01-01-x.md",
1298            &sample_frontmatter(1, "X", "todo"),
1299        );
1300        write_issue(
1301            &store,
1302            "bob/2026-01-01-x.md",
1303            &sample_frontmatter(2, "X", "todo"),
1304        );
1305        let new_path = store
1306            .reassign("alice/2026-01-01-x.md", Some("@bob"))
1307            .unwrap();
1308        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x-1.md"));
1309        assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
1310        assert!(store.issues_dir.join("bob/2026-01-01-x-1.md").is_file());
1311        assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
1312    }
1313
1314    #[test]
1315    fn reassign_legacy_flat_file_into_segment() {
1316        let (tmp, store) = make_store();
1317        let _ = tmp;
1318        write_issue(
1319            &store,
1320            "2026-01-01-y.md",
1321            &sample_frontmatter(1, "Y", "todo"),
1322        );
1323        let new_path = store.reassign("2026-01-01-y.md", Some("alice")).unwrap();
1324        assert_eq!(new_path, PathBuf::from("alice/2026-01-01-y.md"));
1325        assert!(store.issues_dir.join("alice/2026-01-01-y.md").is_file());
1326        assert!(!store.issues_dir.join("2026-01-01-y.md").exists());
1327    }
1328
1329    #[test]
1330    fn reassign_to_none_uses_unassigned_fallback() {
1331        let (tmp, store) = make_store();
1332        let _ = tmp;
1333        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1334        write_issue(
1335            &store,
1336            "alice/2026-01-01-z.md",
1337            &sample_frontmatter(1, "Z", "todo"),
1338        );
1339        let new_path = store.reassign("alice/2026-01-01-z.md", None).unwrap();
1340        assert_eq!(new_path, PathBuf::from("unassigned/2026-01-01-z.md"));
1341        assert!(
1342            store
1343                .issues_dir
1344                .join("unassigned/2026-01-01-z.md")
1345                .is_file()
1346        );
1347        assert!(!store.issues_dir.join("alice/2026-01-01-z.md").exists());
1348    }
1349
1350    #[test]
1351    fn reassign_keeps_number_and_created_stable() {
1352        let (tmp, store) = make_store();
1353        let _ = tmp;
1354        let mut issue = Issue {
1355            number: 0,
1356            title: "Stable".into(),
1357            status: Status::default(),
1358            labels: vec![],
1359            priority: Priority::default(),
1360            assignee: Some("alice".into()),
1361            estimate: None,
1362            commit: None,
1363            gitlab_id: None,
1364            linear_id: None,
1365            mission: None,
1366            task: None,
1367            created: "2026-01-01".into(),
1368            updated: "2026-01-01".into(),
1369            filename: String::new(),
1370            body: String::new(),
1371        };
1372        store.create(&mut issue, "body", None).unwrap();
1373        let number = issue.number;
1374        let created = issue.created.clone();
1375        let before = issue.filename.clone();
1376        let new_path = store.reassign(&before, Some("@bob")).unwrap();
1377        let rescan = store.list().unwrap();
1378        let moved = rescan
1379            .iter()
1380            .find(|i| i.filename == new_path)
1381            .expect("moved file must be listed at its new relative path");
1382        assert_eq!(moved.number, number);
1383        assert_eq!(moved.created, created);
1384        assert_eq!(moved.title, "Stable");
1385    }
1386
1387    #[test]
1388    fn reassign_equal_segment_is_noop() {
1389        let (tmp, store) = make_store();
1390        let _ = tmp;
1391        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1392        write_issue(
1393            &store,
1394            "alice/2026-01-01-x.md",
1395            &sample_frontmatter(1, "X", "todo"),
1396        );
1397        let new_path = store
1398            .reassign("alice/2026-01-01-x.md", Some("alice"))
1399            .unwrap();
1400        assert_eq!(new_path, PathBuf::from("alice/2026-01-01-x.md"));
1401        assert!(store.issues_dir.join("alice/2026-01-01-x.md").is_file());
1402    }
1403
1404    #[test]
1405    fn reassign_missing_file_is_not_found() {
1406        let (tmp, store) = make_store();
1407        let _ = tmp;
1408        let err = store.reassign("nope.md", Some("alice")).unwrap_err();
1409        assert!(matches!(err, TixError::NotFound(_)));
1410    }
1411
1412    // ------------------------------------------------------------------
1413    // T009 — validate_filename relaxation + relative-path ops (C-008/C-010)
1414    // ------------------------------------------------------------------
1415
1416    #[test]
1417    fn validate_filename_allows_flat_and_single_segment_prefix() {
1418        validate_filename("2026-01-01-x.md").unwrap();
1419        validate_filename("alice/2026-01-01-x.md").unwrap();
1420    }
1421
1422    #[test]
1423    fn validate_filename_refuses_deeper_segments() {
1424        let err = validate_filename("alice/bob/2026-01-01-x.md").unwrap_err();
1425        assert!(matches!(err, TixError::InvalidInput(_)));
1426    }
1427
1428    #[test]
1429    fn validate_filename_refuses_leading_and_trailing_slash_and_absolute() {
1430        let err = validate_filename("/alice/2026-01-01-x.md").unwrap_err();
1431        assert!(matches!(err, TixError::InvalidInput(_)));
1432        let err = validate_filename("/etc/passwd").unwrap_err();
1433        assert!(matches!(err, TixError::InvalidInput(_)));
1434        let err = validate_filename("alice/").unwrap_err();
1435        assert!(matches!(err, TixError::InvalidInput(_)));
1436    }
1437
1438    #[test]
1439    fn path_traversal_with_segment_dotdot_refused() {
1440        let err = validate_filename("alice/../../x.md").unwrap_err();
1441        assert!(matches!(err, TixError::InvalidInput(_)));
1442        let err = validate_filename("alice/../x.md").unwrap_err();
1443        assert!(matches!(err, TixError::InvalidInput(_)));
1444    }
1445
1446    #[test]
1447    fn get_update_delete_set_status_work_on_grouped_relative_paths() {
1448        let (tmp, store) = make_store();
1449        let _ = tmp;
1450        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1451        write_issue(
1452            &store,
1453            "alice/2026-01-01-g.md",
1454            &sample_frontmatter(7, "G", "todo"),
1455        );
1456        let got = store
1457            .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
1458            .unwrap();
1459        assert_eq!(got.number, 7);
1460        let done = store.set_status("alice/2026-01-01-g.md", "done").unwrap();
1461        assert_eq!(done.status.display(), "done");
1462        assert!(store.issues_dir.join("alice/2026-01-01-g.md").is_file());
1463        let mut edited = store
1464            .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
1465            .unwrap();
1466        edited.title = "Updated".into();
1467        store.update("alice/2026-01-01-g.md", &edited).unwrap();
1468        let reloaded = store
1469            .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
1470            .unwrap();
1471        assert_eq!(reloaded.title, "Updated");
1472        store.delete("alice/2026-01-01-g.md").unwrap();
1473        assert!(!store.issues_dir.join("alice/2026-01-01-g.md").exists());
1474    }
1475
1476    // ------------------------------------------------------------------
1477    // T010 — grouped round-trip sweep
1478    // ------------------------------------------------------------------
1479
1480    #[test]
1481    fn grouped_round_trip_create_list_get_update_delete() {
1482        let (tmp, store) = make_store();
1483        let _ = tmp;
1484        let mut issue = Issue {
1485            number: 0,
1486            title: "Round trip".into(),
1487            status: Status::default(),
1488            labels: vec!["bug".into()],
1489            priority: Priority::default(),
1490            assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
1491            estimate: None,
1492            commit: None,
1493            gitlab_id: None,
1494            linear_id: None,
1495            mission: None,
1496            task: None,
1497            created: "2026-01-01".into(),
1498            updated: "2026-01-01".into(),
1499            filename: String::new(),
1500            body: String::new(),
1501        };
1502        store.create(&mut issue, "initial body", None).unwrap();
1503        let rel = issue.filename.clone();
1504        assert_eq!(rel, "herbert-maschke/2026-01-01-round-trip.md");
1505
1506        let listed = store.list().unwrap();
1507        assert_eq!(listed.len(), 1);
1508        assert_eq!(listed[0].filename, rel);
1509        assert_eq!(listed[0].number, issue.number);
1510
1511        let got = store.get(IssueSelector::Filename(rel.clone())).unwrap();
1512        let mut edited = got.clone();
1513        edited.title = "Renamed".into();
1514        store.update(&rel, &edited).unwrap();
1515        let reloaded = store.get(IssueSelector::Filename(rel.clone())).unwrap();
1516        assert_eq!(reloaded.title, "Renamed");
1517
1518        store.delete(&rel).unwrap();
1519        assert!(!store.issues_dir.join(&rel).exists());
1520        assert!(store.list().unwrap().is_empty());
1521    }
1522
1523    #[test]
1524    fn legacy_flat_file_edit_and_status_still_work_after_layout_flip() {
1525        let (tmp, store) = make_store();
1526        let _ = tmp;
1527        write_issue(
1528            &store,
1529            "2026-01-01-legacy.md",
1530            &sample_frontmatter(5, "Legacy", "todo"),
1531        );
1532        let listed = store.list().unwrap();
1533        assert_eq!(listed.len(), 1);
1534        assert_eq!(listed[0].filename, "2026-01-01-legacy.md");
1535        let mutated = store
1536            .set_status("2026-01-01-legacy.md", "in progress")
1537            .unwrap();
1538        assert_eq!(mutated.status.display(), "in progress");
1539        assert!(store.issues_dir.join("2026-01-01-legacy.md").is_file());
1540    }
1541
1542    // ------------------------------------------------------------------
1543    // T004 — add_asset(): copy + link insert (FR-006, NFR-004)
1544    // ------------------------------------------------------------------
1545
1546    fn write_asset_source(tmp: &tempfile::TempDir, name: &str, contents: &[u8]) -> PathBuf {
1547        let path = tmp.path().join(name);
1548        fs::write(&path, contents).unwrap();
1549        path
1550    }
1551
1552    fn write_issue_with_basic_body(store: &Store, name: &str, number: u32) {
1553        write_issue(
1554            store,
1555            name,
1556            &sample_frontmatter(number, "Asset host", "todo"),
1557        );
1558    }
1559
1560    #[test]
1561    fn add_asset_image_inserts_image_link_and_writes_file() {
1562        let (tmp, store) = make_store();
1563        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1564        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
1565        let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
1566        let added = store
1567            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
1568            .unwrap();
1569        assert_eq!(added.written_name, "diagram.png");
1570        assert_eq!(added.link, "![diagram](fix-login-bug-assets/diagram.png)");
1571        assert!(
1572            store
1573                .issues_dir
1574                .join("alice/fix-login-bug-assets/diagram.png")
1575                .is_file()
1576        );
1577    }
1578
1579    #[test]
1580    fn add_asset_non_image_inserts_generic_markdown_link() {
1581        let (tmp, store) = make_store();
1582        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1583        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
1584        let src = write_asset_source(&tmp, "report.pdf", b"%PDF");
1585        let added = store
1586            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
1587            .unwrap();
1588        assert_eq!(added.link, "[report](fix-login-bug-assets/report.pdf)");
1589        assert!(
1590            store
1591                .issues_dir
1592                .join("alice/fix-login-bug-assets/report.pdf")
1593                .is_file()
1594        );
1595    }
1596
1597    #[test]
1598    fn add_asset_collision_resolves_with_numeric_suffix_and_link_points_at_it() {
1599        let (tmp, store) = make_store();
1600        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1601        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
1602        let src = write_asset_source(&tmp, "x.png", b"first");
1603        let first = store
1604            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
1605            .unwrap();
1606        assert_eq!(first.written_name, "x.png");
1607        let again = store
1608            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
1609            .unwrap();
1610        assert_eq!(again.written_name, "x-1.png");
1611        assert_eq!(again.link, "![x-1](fix-login-bug-assets/x-1.png)");
1612        assert!(
1613            store
1614                .issues_dir
1615                .join("alice/fix-login-bug-assets/x.png")
1616                .is_file()
1617        );
1618        assert!(
1619            store
1620                .issues_dir
1621                .join("alice/fix-login-bug-assets/x-1.png")
1622                .is_file()
1623        );
1624    }
1625
1626    #[test]
1627    fn add_asset_appends_single_link_and_preserves_description() {
1628        let (tmp, store) = make_store();
1629        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1630        write_issue(
1631            &store,
1632            "alice/2026-01-01-fix-login-bug.md",
1633            "---\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",
1634        );
1635        let src = write_asset_source(&tmp, "diagram.svg", b"<svg/>");
1636        store
1637            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
1638            .unwrap();
1639        let reloaded = store
1640            .get(crate::storage::IssueSelector::Filename(
1641                "alice/2026-01-01-fix-login-bug.md".into(),
1642            ))
1643            .unwrap();
1644        // Description (above the `## Comments` boundary) untouched.
1645        assert_eq!(reloaded.description(), "Title line.");
1646        // Exactly one link line, appended after the comments section.
1647        let link_count = reloaded.body.matches("![diagram]").count();
1648        assert_eq!(link_count, 1);
1649        assert!(
1650            reloaded
1651                .body
1652                .ends_with("fix-login-bug-assets/diagram.svg)\n")
1653        );
1654    }
1655
1656    #[test]
1657    fn add_asset_missing_issue_is_not_found() {
1658        let (tmp, store) = make_store();
1659        let _ = tmp;
1660        let src = write_asset_source(&tmp, "x.png", b"x");
1661        let err = store.add_asset("missing.md", &src).unwrap_err();
1662        assert!(matches!(err, TixError::NotFound(_)));
1663    }
1664
1665    #[test]
1666    fn add_asset_legacy_flat_file_uses_stem_assets_under_issues_dir() {
1667        let (tmp, store) = make_store();
1668        write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
1669        let src = write_asset_source(&tmp, "x.png", b"x");
1670        let added = store.add_asset("2026-01-01-legacy.md", &src).unwrap();
1671        assert_eq!(added.link, "![x](legacy-assets/x.png)");
1672        assert!(store.issues_dir.join("legacy-assets/x.png").is_file());
1673    }
1674
1675    // ------------------------------------------------------------------
1676    // T005 — reassign() moves the assets dir (FR-007)
1677    // ------------------------------------------------------------------
1678
1679    #[test]
1680    fn reassign_moves_asset_dir_with_the_file() {
1681        let (tmp, store) = make_store();
1682        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1683        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
1684        let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
1685        store
1686            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
1687            .unwrap();
1688        let new_path = store
1689            .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
1690            .unwrap();
1691        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
1692        assert!(
1693            store
1694                .issues_dir
1695                .join("bob/fix-login-bug-assets/diagram.png")
1696                .is_file()
1697        );
1698        assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
1699    }
1700
1701    #[test]
1702    fn reassign_without_asset_dir_behaves_as_before() {
1703        let (tmp, store) = make_store();
1704        let _ = tmp;
1705        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1706        write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
1707        let new_path = store
1708            .reassign("alice/2026-01-01-plain.md", Some("@bob"))
1709            .unwrap();
1710        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-plain.md"));
1711        assert!(store.issues_dir.join("bob/2026-01-01-plain.md").is_file());
1712        assert!(!store.issues_dir.join("bob/plain-assets").exists());
1713    }
1714
1715    #[test]
1716    fn reassign_into_segment_with_same_named_asset_dir_uses_suffix_no_loss() {
1717        let (tmp, store) = make_store();
1718        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1719        fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
1720        // Both segments already hold a same-named asset dir.
1721        let existing = store.issues_dir.join("bob/fix-login-bug-assets");
1722        fs::create_dir_all(&existing).unwrap();
1723        fs::write(existing.join("keep.txt"), "precious").unwrap();
1724        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
1725        let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
1726        store
1727            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
1728            .unwrap();
1729        let new_path = store
1730            .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
1731            .unwrap();
1732        assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
1733        // Pre-existing target assets untouched, moved dir suffixed.
1734        assert!(existing.join("keep.txt").is_file());
1735        assert!(
1736            store
1737                .issues_dir
1738                .join("bob/fix-login-bug-assets-1/diagram.png")
1739                .is_file()
1740        );
1741        assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
1742    }
1743
1744    #[test]
1745    fn reassign_preserves_markdown_link_resolution_in_new_segment() {
1746        let (tmp, store) = make_store();
1747        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1748        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
1749        let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
1750        store
1751            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
1752            .unwrap();
1753        store
1754            .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
1755            .unwrap();
1756        // The asset dir kept its base name, so the relative link
1757        // `fix-login-bug-assets/diagram.png` resolves in the new segment.
1758        assert!(
1759            store
1760                .issues_dir
1761                .join("bob/fix-login-bug-assets/diagram.png")
1762                .is_file()
1763        );
1764    }
1765
1766    // ------------------------------------------------------------------
1767    // T006 — delete() removes the assets dir (FR-008) + sweep
1768    // ------------------------------------------------------------------
1769
1770    #[test]
1771    fn delete_removes_asset_dir_no_orphans() {
1772        let (tmp, store) = make_store();
1773        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1774        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
1775        let src = write_asset_source(&tmp, "x.png", b"x");
1776        store
1777            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
1778            .unwrap();
1779        store.delete("alice/2026-01-01-fix-login-bug.md").unwrap();
1780        assert!(
1781            !store
1782                .issues_dir
1783                .join("alice/2026-01-01-fix-login-bug.md")
1784                .exists()
1785        );
1786        assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
1787    }
1788
1789    #[test]
1790    fn delete_without_asset_dir_still_deletes_file() {
1791        let (tmp, store) = make_store();
1792        let _ = tmp;
1793        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1794        write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
1795        store.delete("alice/2026-01-01-plain.md").unwrap();
1796        assert!(!store.issues_dir.join("alice/2026-01-01-plain.md").exists());
1797    }
1798
1799    #[test]
1800    fn list_ignores_asset_directories() {
1801        let (tmp, store) = make_store();
1802        write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
1803        let src = write_asset_source(&tmp, "x.png", b"x");
1804        store.add_asset("2026-01-01-legacy.md", &src).unwrap();
1805        // Legacy flat file → `legacy-assets/` sits directly under issues_dir,
1806        // where the `-assets` guard in `list()` must skip it.
1807        let issues = store.list().unwrap();
1808        assert_eq!(issues.len(), 1);
1809        assert_eq!(issues[0].filename, "2026-01-01-legacy.md");
1810    }
1811
1812    #[test]
1813    fn list_ignores_nested_asset_directories_under_segment() {
1814        let (_tmp, store) = make_store();
1815        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1816        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
1817        let src = write_asset_source(&_tmp, "x.png", b"x");
1818        store
1819            .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
1820            .unwrap();
1821        let issues = store.list().unwrap();
1822        assert_eq!(issues.len(), 1);
1823        assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
1824    }
1825
1826    #[test]
1827    fn asset_dir_with_formless_md_child_is_not_scanned_as_segment() {
1828        let (tmp, store) = make_store();
1829        let _ = tmp;
1830        fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
1831        write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
1832        // A `.md` straggler inside an `-assets` dir must never surface as an
1833        // issue segment (guard in `list()` for `<slug>-assets/`).
1834        let assets_dir = store.issues_dir.join("alice/fix-login-bug-assets");
1835        fs::create_dir_all(&assets_dir).unwrap();
1836        fs::write(assets_dir.join("notes.md"), "no frontmatter").unwrap();
1837        let issues = store.list().unwrap();
1838        assert_eq!(issues.len(), 1);
1839        assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
1840    }
1841}