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#[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 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 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 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 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 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 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 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!("")
253 } else {
254 format!("[{label}]({dir_name}/{written_name})")
255 };
256
257 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 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 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 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 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 fn resolve_issue(&self, raw: &str) -> Result<Issue> {
435 let selector = crate::commands::parse_selector(self, raw)?;
436 self.get(selector)
437 }
438
439 pub fn reassign(&self, filename: &str, new_assignee: Option<&str>) -> Result<PathBuf> {
462 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 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
498fn 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
545fn 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
556fn 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
568fn 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
580fn 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
601fn 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
613fn 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
636fn 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 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 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 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 #[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 #[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 #[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 #[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 #[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 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, "");
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, "");
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 assert_eq!(reloaded.description(), "Title line.");
2002 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, "");
2028 assert!(store.issues_dir.join("legacy-assets/x.png").is_file());
2029 }
2030
2031 #[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 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 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 assert!(
2115 store
2116 .issues_dir
2117 .join("bob/fix-login-bug-assets/diagram.png")
2118 .is_file()
2119 );
2120 }
2121
2122 #[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 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 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}