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 reassign(&self, filename: &str, new_assignee: Option<&str>) -> Result<PathBuf> {
338 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 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
374fn 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
421fn 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
432fn 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
444fn 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
456fn 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
477fn 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
489fn 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
512fn 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 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 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 #[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 #[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 #[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 #[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 #[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 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, "");
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, "");
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 assert_eq!(reloaded.description(), "Title line.");
1646 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, "");
1672 assert!(store.issues_dir.join("legacy-assets/x.png").is_file());
1673 }
1674
1675 #[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 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 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 assert!(
1759 store
1760 .issues_dir
1761 .join("bob/fix-login-bug-assets/diagram.png")
1762 .is_file()
1763 );
1764 }
1765
1766 #[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 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 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}