1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use crate::config::Config;
6use crate::error::{Result, TixError, render_diagnostics};
7use crate::hierarchy::{self, HierarchyIndex};
8use crate::model::{HistoryEventKind, Issue, Status, TransitionDecision};
9use crate::relationships::{
10 CanonicalEdge, Diagnostic, IssueView, RelationKind, Relationship, RelationshipGraph,
11};
12use crate::slug::slugify;
13
14pub struct Store {
15 pub issues_dir: PathBuf,
16 pub config: Config,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct RelationshipOutcome {
28 pub written: Option<Issue>,
30 pub relationship: Relationship,
32 pub changed: bool,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum IssueSelector {
38 Number(u32),
39 Filename(String),
40 Slug(String),
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct AssetAdded {
47 pub link: String,
48 pub written_name: String,
49}
50
51thread_local! {
62 static RETIRED_KEY_DISCLOSURES: std::cell::RefCell<Vec<String>> =
63 const { std::cell::RefCell::new(Vec::new()) };
64}
65
66pub(crate) fn retired_key_disclosure(filename: &str) {
70 RETIRED_KEY_DISCLOSURES.with(|queue| {
71 queue.borrow_mut().push(format!(
72 "remove `links:` by hand to silence \u{2014} {filename} (retired by \
73 C-001; never read as typed data, no migration)"
74 ));
75 });
76}
77
78pub fn take_disclosures() -> Vec<String> {
80 RETIRED_KEY_DISCLOSURES.with(|queue| std::mem::take(&mut *queue.borrow_mut()))
81}
82
83pub fn actor_identity() -> String {
94 crate::assignee::git_identity()
95 .map(|id| id.to_string())
96 .unwrap_or_else(|| "unknown".to_string())
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct StatusChange<'a> {
118 pub column: &'a str,
121 pub commit: Option<&'a str>,
129 pub override_reason: Option<&'a str>,
137}
138
139impl<'a> StatusChange<'a> {
140 pub fn to(column: &'a str) -> Self {
145 Self {
146 column,
147 commit: None,
148 override_reason: None,
149 }
150 }
151
152 pub fn with_commit(mut self, commit: Option<&'a str>) -> Self {
154 self.commit = commit;
155 self
156 }
157
158 pub fn with_override(mut self, reason: Option<&'a str>) -> Self {
161 self.override_reason = reason;
162 self
163 }
164}
165
166#[derive(Debug, Clone, PartialEq, Eq)]
175pub struct StatusOutcome {
176 pub issue: Issue,
180 pub decision: Option<TransitionDecision>,
183 pub written: bool,
190}
191
192struct TransitionAccepted {
200 changed: bool,
201 from: String,
202 to: String,
203 from_category: crate::workflow::WorkflowCategory,
204 to_category: crate::workflow::WorkflowCategory,
205 override_reason: Option<String>,
206}
207
208impl TransitionAccepted {
209 fn try_from(
211 outcome: crate::workflow::TransitionOutcome,
212 ) -> std::result::Result<Self, crate::workflow::WorkflowDiagnostic> {
213 match outcome {
214 crate::workflow::TransitionOutcome::Accepted {
215 changed,
216 from,
217 to,
218 from_category,
219 to_category,
220 override_reason,
221 } => Ok(Self {
222 changed,
223 from,
224 to,
225 from_category,
226 to_category,
227 override_reason,
228 }),
229 crate::workflow::TransitionOutcome::Rejected(diagnostic) => Err(diagnostic),
230 }
231 }
232}
233
234fn canonical_placeholder(canonical: &CanonicalEdge) -> Relationship {
241 Relationship {
242 id: String::new(),
243 from: canonical.from,
244 to: canonical.to,
245 kind: canonical.kind,
246 display: String::new(),
247 }
248}
249
250fn relationship_error(errors: &[Diagnostic]) -> TixError {
257 if errors.is_empty() {
258 return TixError::InvalidInput("relationship rejected".into());
259 }
260 TixError::InvalidInput(render_diagnostics(
261 errors.iter().map(|d| (d.kind.as_str(), d.message.as_str())),
262 ))
263}
264
265struct NumberClaim {
314 path: PathBuf,
315 armed: bool,
319}
320
321impl NumberClaim {
322 const DIR: &'static str = ".tix-numbers";
324
325 fn acquire(issues_dir: &Path, start: u32) -> Result<Self> {
332 const MAX_ATTEMPTS: u32 = 10_000;
333 let dir = claims_dir(issues_dir);
334 std::fs::create_dir_all(&dir)?;
335 let mut number = start;
336 for _ in 0..MAX_ATTEMPTS {
337 let path = dir.join(format!("{number}.claim"));
338 match std::fs::OpenOptions::new()
339 .write(true)
340 .create_new(true)
341 .open(&path)
342 {
343 Ok(_) => {
344 return Ok(Self { path, armed: true });
345 }
346 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
347 number += 1;
348 }
349 Err(e) => return Err(TixError::Io(e)),
350 }
351 }
352 Err(TixError::Io(std::io::Error::new(
353 std::io::ErrorKind::AlreadyExists,
354 format!(
355 "no free issue number at or above {start} after {MAX_ATTEMPTS} attempts; \
356 {} is probably corrupt",
357 dir.display()
358 ),
359 )))
360 }
361
362 fn number(&self) -> Option<u32> {
364 self.path.file_stem()?.to_str()?.parse::<u32>().ok()
365 }
366
367 fn release_number(issues_dir: &Path, number: u32) {
374 let _ = std::fs::remove_file(claims_dir(issues_dir).join(format!("{number}.claim")));
375 }
376
377 fn release(&mut self) {
380 if self.armed {
381 let _ = std::fs::remove_file(&self.path);
382 self.armed = false;
383 }
384 }
385
386 fn commit(&mut self) {
394 self.armed = false;
395 }
396}
397
398impl Drop for NumberClaim {
399 fn drop(&mut self) {
404 self.release();
405 }
406}
407
408impl std::fmt::Debug for NumberClaim {
409 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
410 f.debug_struct("NumberClaim")
411 .field("path", &self.path)
412 .field("armed", &self.armed)
413 .finish()
414 }
415}
416
417impl PartialEq for NumberClaim {
418 fn eq(&self, other: &Self) -> bool {
419 self.path == other.path
420 }
421}
422impl Eq for NumberClaim {}
423
424#[derive(Debug, Clone)]
427pub struct ParentOutcome {
428 pub written: Option<Issue>,
436 pub parent: Option<u32>,
438 pub event: Option<crate::hierarchy::ParentEvent>,
443 pub changed: bool,
445}
446
447impl Store {
448 pub fn open(config: &Config) -> Result<Store> {
449 let issues_dir = &config.issues_dir;
450 if !issues_dir.exists() {
451 std::fs::create_dir_all(issues_dir)?;
452 }
453 Ok(Store {
454 issues_dir: issues_dir.clone(),
455 config: config.clone(),
456 })
457 }
458
459 pub fn list(&self) -> Result<Vec<Issue>> {
469 self.scan(false)
470 }
471
472 pub fn list_all(&self) -> Result<Vec<Issue>> {
479 self.scan(true)
480 }
481
482 fn scan(&self, include_archived: bool) -> Result<Vec<Issue>> {
492 if !self.issues_dir.exists() {
493 return Ok(Vec::new());
494 }
495 let mut files: Vec<(PathBuf, String, bool)> = Vec::new();
496 Self::collect_issue_files(&self.issues_dir, "", include_archived, &mut files)?;
497 files.sort_by(|a, b| a.1.cmp(&b.1));
500 let mut issues = Self::parse_issue_files(&files)?;
501 issues.sort_by(|a, b| a.filename.cmp(&b.filename));
502 Ok(issues)
503 }
504
505 const PARALLEL_SCAN_THRESHOLD: usize = 256;
509 const PARALLEL_SCAN_THREADS: usize = 8;
512
513 fn collect_issue_files(
520 dir: &Path,
521 prefix: &str,
522 include_archived: bool,
523 out: &mut Vec<(PathBuf, String, bool)>,
524 ) -> Result<()> {
525 for entry in std::fs::read_dir(dir)? {
526 let entry = entry?;
527 let path = entry.path();
528 let name = entry.file_name().to_string_lossy().to_string();
529 if path.is_dir() {
530 if name.starts_with('.')
531 || name.starts_with(".tix-tmp-")
532 || name.ends_with("-assets")
533 {
534 continue;
535 }
536 let child = if prefix.is_empty() {
537 name
538 } else {
539 format!("{prefix}/{name}")
540 };
541 Self::collect_issue_files(&path, &child, include_archived, out)?;
542 } else if path.is_file() && name.ends_with(".md") {
543 let archived = name.starts_with('.');
544 if archived && !include_archived {
545 continue;
546 }
547 let rel = if prefix.is_empty() {
548 name
549 } else {
550 format!("{prefix}/{name}")
551 };
552 out.push((path, rel, archived));
553 }
554 }
555 Ok(())
556 }
557
558 fn parse_issue_files(files: &[(PathBuf, String, bool)]) -> Result<Vec<Issue>> {
567 if files.len() < Self::PARALLEL_SCAN_THRESHOLD {
568 return files
569 .iter()
570 .map(|(path, rel, archived)| Self::read_issue_file(path, rel, *archived))
571 .collect();
572 }
573 let threads = std::thread::available_parallelism()
574 .map(|n| n.get())
575 .unwrap_or(1)
576 .clamp(1, Self::PARALLEL_SCAN_THREADS);
577 if threads < 2 {
578 return files
579 .iter()
580 .map(|(path, rel, archived)| Self::read_issue_file(path, rel, *archived))
581 .collect();
582 }
583 let chunk = files.len().div_ceil(threads);
584 let parts: Vec<Vec<Result<Issue>>> = std::thread::scope(|scope| {
585 let handles: Vec<_> = files
586 .chunks(chunk)
587 .map(|slice| {
588 scope.spawn(move || {
589 slice
590 .iter()
591 .map(|(path, rel, archived)| {
592 Self::read_issue_file(path, rel, *archived)
593 })
594 .collect::<Vec<_>>()
595 })
596 })
597 .collect();
598 handles
599 .into_iter()
600 .map(|handle| handle.join().expect("issue-scan worker panicked"))
601 .collect()
602 });
603 let mut issues = Vec::with_capacity(files.len());
604 for part in parts {
605 for issue in part {
606 issues.push(issue?);
607 }
608 }
609 Ok(issues)
610 }
611
612 fn read_issue_file(path: &Path, rel: &str, archived: bool) -> Result<Issue> {
616 let contents = std::fs::read_to_string(path)?;
617 let mut issue = Issue::parse(&contents).map_err(|e| match e {
618 TixError::Parse(msg) => TixError::Parse(format!("{rel}: {msg}")),
619 other => other,
620 })?;
621 issue.filename = rel.to_string();
622 issue.archived = archived;
623 Ok(issue)
624 }
625
626 pub fn assignee_segments(&self) -> Result<Vec<String>> {
634 let mut segments = Vec::new();
635 if !self.issues_dir.exists() {
636 return Ok(segments);
637 }
638 for entry in std::fs::read_dir(&self.issues_dir)? {
639 let entry = entry?;
640 if !entry.path().is_dir() {
641 continue;
642 }
643 let name = entry.file_name().to_string_lossy().to_string();
644 if name == "unassigned" || name.starts_with(".tix-tmp-") || name.ends_with("-assets") {
645 continue;
646 }
647 segments.push(name);
648 }
649 segments.sort();
650 segments.dedup();
651 Ok(segments)
652 }
653
654 pub fn get(&self, selector: IssueSelector) -> Result<Issue> {
655 match selector {
656 IssueSelector::Number(num) => {
657 let issues = self.list_all()?;
658 issues
659 .into_iter()
660 .find(|i| i.number == num)
661 .ok_or_else(|| TixError::NotFound(format!("issue #{num}")))
662 }
663 IssueSelector::Filename(name) => {
664 validate_filename(&name)?;
665 let path = self.issues_dir.join(&name);
666 if !path.is_file() {
667 return Err(TixError::NotFound(format!("file {name}")));
668 }
669 let contents = std::fs::read_to_string(&path)?;
670 let mut issue = Issue::parse(&contents)?;
671 issue.filename = name;
672 Ok(issue)
673 }
674 IssueSelector::Slug(slug) => {
675 let issues = self.list_all()?;
676 issues
677 .into_iter()
678 .find(|i| slugify(&i.title) == slug)
679 .ok_or_else(|| TixError::NotFound(format!("slug {slug}")))
680 }
681 }
682 }
683
684 pub fn read_issue_at(&self, rel: &Path) -> Result<Issue> {
698 let filename = rel.to_string_lossy().to_string();
699 let path = self.issues_dir.join(rel);
700 if !path.is_file() {
701 return Err(TixError::NotFound(format!("file {filename}")));
702 }
703 let contents = std::fs::read_to_string(&path)?;
704 let mut issue = Issue::parse(&contents).map_err(|e| match e {
705 TixError::Parse(msg) => TixError::Parse(format!("{filename}: {msg}")),
706 other => other,
707 })?;
708 issue.filename = filename;
709 issue.archived = rel
710 .file_name()
711 .and_then(|name| name.to_str())
712 .is_some_and(|name| name.starts_with('.'));
713 Ok(issue)
714 }
715
716 pub fn archive(&self, sel: &str) -> Result<Issue> {
729 self.set_archived(sel, true)
730 }
731
732 pub fn restore(&self, sel: &str) -> Result<Issue> {
736 self.set_archived(sel, false)
737 }
738
739 fn set_archived(&self, sel: &str, archive: bool) -> Result<Issue> {
740 let mut issue = self.resolve_issue(sel)?;
743 if issue.archived == archive {
744 return Err(TixError::InvalidInput(format!(
745 "{}: issue #{} ({}) is already {}",
746 if archive {
747 "already_archived"
748 } else {
749 "not_archived"
750 },
751 issue.number,
752 issue.filename,
753 if archive { "archived" } else { "active" },
754 )));
755 }
756 let old_rel = issue.filename.clone();
757 let new_rel = flip_archived(&old_rel, archive).ok_or_else(|| {
758 TixError::InvalidInput(format!(
759 "cannot {} {old_rel}: its filename does not carry the archived marker in the \
760 expected position",
761 if archive { "archive" } else { "restore" }
762 ))
763 })?;
764 let old_path = self.issues_dir.join(&old_rel);
765 let new_path = self.issues_dir.join(&new_rel);
766 if !old_path.is_file() {
767 return Err(TixError::NotFound(format!("file {old_rel}")));
768 }
769 if new_path.exists() {
770 return Err(TixError::InvalidInput(format!(
771 "cannot {} {old_rel}: {new_rel} already exists",
772 if archive { "archive" } else { "restore" }
773 )));
774 }
775 std::fs::rename(&old_path, &new_path)?;
780 issue.filename = new_rel.clone();
781 issue.archived = archive;
782 issue.history.append_now(
783 &actor_identity(),
784 if archive {
785 HistoryEventKind::Archived
786 } else {
787 HistoryEventKind::Restored
788 },
789 &format!("{old_rel} -> {new_rel}"),
790 );
791 issue.filename = self.update(&new_rel, &issue)?;
794 Ok(issue)
795 }
796
797 pub fn create(
813 &self,
814 issue: &mut Issue,
815 body: &str,
816 filename_stem: Option<&str>,
817 ) -> Result<PathBuf> {
818 issue.body = body.to_string();
819 issue.bump_updated();
820
821 std::fs::create_dir_all(&self.issues_dir)?;
822 issue.number = self.resolve_next_number()?;
823
824 let mut claim = NumberClaim::acquire(&self.issues_dir, issue.number)?;
838 issue.number = claim.number().ok_or_else(|| {
844 TixError::Io(std::io::Error::new(
845 std::io::ErrorKind::InvalidData,
846 format!("claimed number is unreadable from {}", claim.path.display()),
847 ))
848 })?;
849
850 let rel = crate::layout::render(&self.config.layout, issue);
854 let (dir, file) = rel.rsplit_once('/').unwrap_or(("", rel.as_str()));
855 std::fs::create_dir_all(self.issues_dir.join(dir))?;
856
857 let stem = match filename_stem {
858 Some(s) => s.to_string(),
859 None => file.strip_suffix(".md").unwrap_or(file).to_string(),
860 };
861 let contents = issue.to_file_contents();
864
865 let written = self.write_new_issue_file(dir, &stem, contents.as_bytes());
866 match written {
871 Ok((filename, path)) => {
872 claim.commit();
873 issue.filename = filename;
874 Ok(path)
875 }
876 Err(e) => {
877 claim.release();
878 Err(e)
879 }
880 }
881 }
882
883 fn write_new_issue_file(
918 &self,
919 segment: &str,
920 stem: &str,
921 contents: &[u8],
922 ) -> Result<(String, PathBuf)> {
923 const MAX_ATTEMPTS: usize = 1_000;
924 let mut suffix = 1usize;
925 for _ in 0..MAX_ATTEMPTS {
926 let filename = if suffix == 1 {
927 format!("{segment}/{stem}.md")
928 } else {
929 format!("{segment}/{stem}-{suffix}.md")
930 };
931 let path = self.issues_dir.join(&filename);
932 match write_file_new(&self.issues_dir, &path, contents) {
933 Ok(()) => return Ok((filename, path)),
934 Err(TixError::Io(e)) if e.kind() == std::io::ErrorKind::AlreadyExists => {
937 suffix += 1;
938 }
939 Err(e) => return Err(e),
940 }
941 }
942 Err(TixError::Io(std::io::Error::new(
943 std::io::ErrorKind::AlreadyExists,
944 format!(
945 "could not find a free filename for {segment}/{stem}.md in {MAX_ATTEMPTS} attempts"
946 ),
947 )))
948 }
949
950 fn resolve_next_number(&self) -> Result<u32> {
971 let issues = self.list_all()?;
975 match issues.iter().map(|i| i.number).max() {
976 None => Ok(1),
977 Some(max) => max
978 .checked_add(1)
979 .ok_or_else(|| TixError::InvalidInput("issue-number overflow".to_string())),
980 }
981 }
982
983 pub fn config_path(&self) -> Result<std::path::PathBuf> {
984 if let Some(root) = Config::project_root(&self.issues_dir) {
985 Ok(root.join("tix.toml"))
986 } else {
987 Err(TixError::Config(
988 "tix.toml not found in any parent directory of issues_dir".into(),
989 ))
990 }
991 }
992
993 pub fn update(&self, filename: &str, issue: &Issue) -> Result<String> {
1002 validate_filename(filename)?;
1003 let path = self.issues_dir.join(filename);
1004 if !path.is_file() {
1005 return Err(TixError::NotFound(format!("file {filename}")));
1006 }
1007 if issue.has_retired_links_key {
1019 retired_key_disclosure(filename);
1035 }
1036 let contents = issue.to_file_contents();
1037 atomic_write(&self.issues_dir, &path, contents.as_bytes())?;
1038 self.reconcile_location(filename, issue)
1042 }
1043
1044 fn reconcile_location(&self, filename: &str, issue: &Issue) -> Result<String> {
1049 if !self.config.layout.move_on_change {
1050 return Ok(filename.to_string());
1051 }
1052 let target_dir = crate::layout::render_directory(&self.config.layout, issue);
1053 let Some((old_dir, base)) = split_relative(filename) else {
1054 return Ok(filename.to_string());
1055 };
1056 if old_dir == target_dir {
1057 return Ok(filename.to_string());
1058 }
1059 let stem = base.strip_suffix(".md").unwrap_or(&base);
1060 let moved = self.move_issue_file(filename, &target_dir, stem)?;
1061 Ok(moved.to_string_lossy().to_string())
1062 }
1063
1064 pub fn add_asset(&self, filename: &str, source_path: &Path) -> Result<AssetAdded> {
1078 validate_filename(filename)?;
1079 let mut issue = self.get(IssueSelector::Filename(filename.to_string()))?;
1080
1081 let (segment, basename) = split_relative(filename).ok_or_else(|| {
1082 TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
1083 })?;
1084 let stem = basename
1085 .strip_suffix(".md")
1086 .ok_or_else(|| {
1087 TixError::InvalidInput(format!(
1088 "asset target must be a `.md` issue file: {filename}"
1089 ))
1090 })?
1091 .to_string();
1092 let slug = slug_from_stem(&stem);
1093 let dir_name = format!("{slug}-assets");
1094 let asset_dir = self.issues_dir.join(&segment).join(&dir_name);
1095 std::fs::create_dir_all(&asset_dir)?;
1096
1097 let source_name = source_path
1098 .file_name()
1099 .and_then(|n| n.to_str())
1100 .ok_or_else(|| {
1101 TixError::InvalidInput("asset source must have a file name".to_string())
1102 })?
1103 .to_string();
1104 let written_name = resolve_asset_name(&asset_dir, &source_name);
1105 atomic_copy(&asset_dir, &asset_dir.join(&written_name), source_path)?;
1106
1107 let label = written_name
1108 .rsplit_once('.')
1109 .map(|(stem, _)| stem)
1110 .unwrap_or(&written_name);
1111 let link = if is_image_name(&written_name) {
1112 format!("")
1113 } else {
1114 format!("[{label}]({dir_name}/{written_name})")
1115 };
1116
1117 if !issue.body.is_empty() {
1121 if !issue.body.ends_with('\n') {
1122 issue.body.push('\n');
1123 }
1124 if !issue.body.ends_with("\n\n") {
1125 issue.body.push('\n');
1126 }
1127 }
1128 issue.body.push_str(&link);
1129 issue.body.push('\n');
1130 issue.bump_updated();
1131 self.update(filename, &issue)?;
1132
1133 Ok(AssetAdded { link, written_name })
1134 }
1135
1136 pub fn delete(&self, filename: &str) -> Result<()> {
1137 validate_filename(filename)?;
1138 let path = self.issues_dir.join(filename);
1139 if !path.is_file() {
1140 return Err(TixError::NotFound(format!("file {filename}")));
1141 }
1142 let freed = std::fs::read_to_string(&path)
1159 .ok()
1160 .and_then(|text| frontmatter_number(&text));
1161 std::fs::remove_file(&path)?;
1162 if let Some(number) = freed {
1163 NumberClaim::release_number(&self.issues_dir, number);
1164 }
1165 if let Some((segment, basename)) = split_relative(filename) {
1169 let stem = basename.strip_suffix(".md").unwrap_or(&basename);
1170 let slug = slug_from_stem(stem);
1171 let asset_dir = self
1172 .issues_dir
1173 .join(&segment)
1174 .join(format!("{slug}-assets"));
1175 if asset_dir.exists()
1176 && let Err(e) = std::fs::remove_dir_all(&asset_dir)
1177 {
1178 eprintln!(
1179 "warning: failed to remove asset dir {}: {e}",
1180 asset_dir.display()
1181 );
1182 }
1183 }
1184 Ok(())
1185 }
1186
1187 pub fn set_status(&self, filename: &str, request: StatusChange<'_>) -> Result<StatusOutcome> {
1233 let mut issue = self.get(IssueSelector::Filename(filename.to_string()))?;
1234
1235 let (_, resolution) = crate::workflow::active_resolution(&self.config);
1238
1239 let outcome = crate::workflow::validate_transition(
1242 &issue,
1243 request.column,
1244 &resolution,
1245 &self.config,
1246 request.override_reason,
1247 );
1248 let accepted = match TransitionAccepted::try_from(outcome) {
1251 Ok(accepted) => accepted,
1252 Err(diagnostic) => return Err(TixError::Workflow(diagnostic)),
1253 };
1254
1255 if !accepted.changed {
1260 return Ok(StatusOutcome {
1261 issue,
1262 decision: None,
1263 written: false,
1264 });
1265 }
1266
1267 let was_complete = crate::workflow::is_completed(&issue, &resolution);
1288 let reaches_complete = resolution
1289 .resolve(&accepted.to)
1290 .is_some_and(crate::workflow::WorkflowCategory::is_complete);
1291 if reaches_complete {
1292 issue.commit = request.commit.map(str::to_string);
1293 } else if was_complete {
1294 issue.commit = None;
1295 }
1296
1297 issue.status = Status::new(accepted.to.clone());
1298 issue.bump_updated();
1299
1300 let decision = TransitionDecision::new(
1303 crate::model::utc_now_rfc3339(),
1304 actor_identity(),
1309 accepted.from.clone(),
1310 accepted.to.clone(),
1311 accepted.from_category.key(),
1312 accepted.to_category.key(),
1313 accepted.override_reason,
1314 );
1315 issue.history.append(decision.to_event());
1316
1317 issue.filename = self.update(filename, &issue)?;
1318 Ok(StatusOutcome {
1319 issue,
1320 decision: Some(decision),
1321 written: true,
1322 })
1323 }
1324
1325 pub fn relationship_done_column(&self) -> String {
1355 crate::workflow::completion_column_or_default(self.config.default_config(), &self.config)
1367 }
1368
1369 pub fn link(&self, a: &str, b: &str, kind: RelationKind) -> Result<RelationshipOutcome> {
1383 let issue_a = self.resolve_issue(a)?;
1384 let issue_b = self.resolve_issue(b)?;
1385 let issues = self.list_all()?;
1389 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1390 let done_column = self.relationship_done_column();
1391 let graph = RelationshipGraph::new(&views, &done_column);
1392
1393 let verdict = graph.validate_create(issue_a.number, issue_b.number, kind);
1394 if verdict.blocking() {
1395 return Err(relationship_error(&verdict.errors));
1396 }
1397
1398 if let Some(existing) = graph.find_canonical(issue_a.number, issue_b.number, kind) {
1401 return Ok(RelationshipOutcome {
1407 written: None,
1408 relationship: existing.clone(),
1409 changed: false,
1410 });
1411 }
1412
1413 let canonical = CanonicalEdge::new(issue_a.number, issue_b.number, kind);
1414 let mut owner = self.get(IssueSelector::Number(canonical.from))?;
1415 let target_title = self
1416 .get(IssueSelector::Number(canonical.to))
1417 .map(|i| i.title)
1418 .unwrap_or_default();
1419 let relationship =
1420 Relationship::new(canonical.from, canonical.to, canonical.kind, &target_title);
1421 let detail = format!("created {}: {}", relationship.id, relationship.display);
1422 owner.relationships.push(relationship.clone());
1423 owner.history.append_now(
1424 &actor_identity(),
1425 HistoryEventKind::RelationshipAdded,
1426 &detail,
1427 );
1428 owner.bump_updated();
1429 owner.filename = self.update(&owner.filename, &owner)?;
1430
1431 Ok(RelationshipOutcome {
1432 written: Some(owner),
1433 relationship,
1434 changed: true,
1435 })
1436 }
1437
1438 pub fn hierarchy_index(&self) -> Result<(HierarchyIndex, Vec<Issue>)> {
1463 let issues = self.list_all()?;
1464 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1465 Ok((HierarchyIndex::build(&views), issues))
1466 }
1467
1468 fn child_in(snapshot: &[Issue], number: u32) -> Result<Issue> {
1473 snapshot
1474 .iter()
1475 .find(|i| i.number == number)
1476 .cloned()
1477 .ok_or_else(|| {
1478 TixError::NotFound(format!(
1479 "issue #{number} vanished between validating and writing it"
1480 ))
1481 })
1482 }
1483
1484 pub fn set_parent(&self, child: &str, parent: &str) -> Result<ParentOutcome> {
1490 let child_issue = self.resolve_issue(child)?;
1491 let parent_issue = self.resolve_issue(parent)?;
1492 let (index, issues) = self.hierarchy_index()?;
1493 let change = hierarchy::set_parent(&index, child_issue.number, parent_issue.number)?;
1494 self.commit_parent_change(Store::child_in(&issues, child_issue.number)?, change)
1495 }
1496
1497 pub fn change_parent(&self, child: &str, parent: &str) -> Result<ParentOutcome> {
1499 let child_issue = self.resolve_issue(child)?;
1500 let parent_issue = self.resolve_issue(parent)?;
1501 let (index, issues) = self.hierarchy_index()?;
1502 let change = hierarchy::change_parent(&index, child_issue.number, parent_issue.number)?;
1503 self.commit_parent_change(Store::child_in(&issues, child_issue.number)?, change)
1504 }
1505
1506 pub fn clear_parent(&self, child: &str) -> Result<ParentOutcome> {
1508 let child_issue = self.resolve_issue(child)?;
1509 let (index, issues) = self.hierarchy_index()?;
1510 let change = hierarchy::clear_parent(&index, child_issue.number)?;
1511 self.commit_parent_change(Store::child_in(&issues, child_issue.number)?, change)
1512 }
1513
1514 fn commit_parent_change(
1521 &self,
1522 mut issue: Issue,
1523 change: hierarchy::ParentChange,
1524 ) -> Result<ParentOutcome> {
1525 let Some(event) = change.event else {
1526 return Ok(ParentOutcome {
1531 written: None,
1532 parent: change.parent,
1533 event: None,
1534 changed: false,
1535 });
1536 };
1537 let previous = issue.parent;
1542 let kind = HistoryEventKind::from_parent(event.kind);
1543 let detail = hierarchy::change_detail(previous, change.parent);
1548 debug_assert_ne!(
1557 previous, change.parent,
1558 "an accepted change must alter the parent, or the event records nothing"
1559 );
1560 issue.parent = change.parent;
1563 issue.history.append_now(&actor_identity(), kind, &detail);
1564 issue.bump_updated();
1565 self.update(&issue.filename, &issue)?;
1566 Ok(ParentOutcome {
1567 written: Some(issue),
1568 parent: change.parent,
1569 event: Some(event),
1570 changed: true,
1571 })
1572 }
1573
1574 pub fn relationships_for(&self, number: u32) -> Result<Vec<Relationship>> {
1588 let issues = self.list_all()?;
1589 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1590 let done_column = self.relationship_done_column();
1591 let graph = RelationshipGraph::new(&views, &done_column);
1592 Ok(graph
1593 .relationships_for(number)
1594 .into_iter()
1595 .cloned()
1596 .collect())
1597 }
1598
1599 pub fn unlink(&self, a: &str, b: &str, kind: RelationKind) -> Result<RelationshipOutcome> {
1604 let canonical = self.canonical_request(a, b, kind)?;
1605 let stored = self.find_stored_rows(&canonical)?;
1606
1607 if stored.is_empty() {
1608 return Ok(RelationshipOutcome {
1612 written: None,
1613 relationship: canonical_placeholder(&canonical),
1614 changed: false,
1615 });
1616 }
1617
1618 let mut by_file: HashMap<String, Vec<Relationship>> = HashMap::new();
1623 for (filename, rel) in stored {
1624 by_file.entry(filename).or_default().push(rel);
1625 }
1626
1627 let actor = actor_identity();
1628 let mut file_order: Vec<String> = by_file.keys().cloned().collect();
1630 file_order.sort_unstable();
1631
1632 let mut removed_row: Option<Relationship> = None;
1638
1639 let all_removed: Vec<Relationship> = by_file.values().flatten().cloned().collect();
1645 let summary = if all_removed.len() == 1 {
1646 let one = &all_removed[0];
1647 format!("removed {}: {}", one.id, one.display)
1648 } else {
1649 let kinds: Vec<String> = all_removed.iter().map(|r| r.kind.to_string()).collect();
1650 format!(
1651 "removed {} relationships ({})",
1652 all_removed.len(),
1653 kinds.join(", ")
1654 )
1655 };
1656
1657 let mut written: Option<Issue> = None;
1658 for filename in &file_order {
1659 let doomed = &by_file[filename];
1660 let mut owner = self.get(IssueSelector::Filename(filename.clone()))?;
1661 if removed_row.is_none() {
1662 removed_row = doomed.first().cloned();
1663 }
1664 owner.relationships.retain(|r| r.canonical() != canonical);
1665 owner
1666 .history
1667 .append_now(&actor, HistoryEventKind::RelationshipRemoved, &summary);
1668 owner.bump_updated();
1669 owner.filename = self.update(&owner.filename, &owner)?;
1670 written = Some(owner);
1671 }
1672
1673 let relationship = removed_row.unwrap_or_else(|| canonical_placeholder(&canonical));
1676
1677 Ok(RelationshipOutcome {
1678 written,
1679 relationship,
1680 changed: true,
1681 })
1682 }
1683
1684 pub fn edit_relation(
1699 &self,
1700 id: &str,
1701 new_to: Option<u32>,
1702 new_kind: Option<RelationKind>,
1703 ) -> Result<RelationshipOutcome> {
1704 let issues = self.list_all()?;
1705 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1706 let done_column = self.relationship_done_column();
1707 let graph = RelationshipGraph::new(&views, &done_column);
1708
1709 let Some(existing) = graph.find_relationship(id) else {
1710 let mut verdict = crate::relationships::RelationshipValidationResult::clean();
1714 verdict.errors.push(Diagnostic::unknown_relation(id));
1715 return Err(relationship_error(&verdict.errors));
1716 };
1717
1718 let verdict = graph.validate_edit(id, new_to, new_kind);
1719 if verdict.blocking() {
1720 return Err(relationship_error(&verdict.errors));
1721 }
1722
1723 let to = new_to.unwrap_or(existing.to);
1724 let kind = new_kind.unwrap_or(existing.kind);
1725 let old_canonical = existing.canonical();
1726
1727 let requested = CanonicalEdge::new(existing.from, to, kind);
1739 let mut updated = existing.clone();
1740 updated.from = requested.from;
1741 updated.to = requested.to;
1742 updated.kind = requested.kind;
1743
1744 let target_title = self
1748 .get(IssueSelector::Number(updated.to))
1749 .map(|i| i.title)
1750 .unwrap_or_default();
1751 updated.refresh_display(&target_title);
1752 let new_canonical = updated.canonical();
1753
1754 if old_canonical == new_canonical && updated.display == existing.display {
1758 return Ok(RelationshipOutcome {
1759 written: None,
1760 relationship: existing.clone(),
1761 changed: false,
1762 });
1763 }
1764
1765 let actor = actor_identity();
1766 let detail = format!("{} → {}: {}", existing.display, updated.display, updated.id);
1767
1768 let mut removed_anywhere = false;
1784 for issue in self.list_all()? {
1785 if !issue
1786 .relationships
1787 .iter()
1788 .any(|r| r.id == id && r.canonical() == old_canonical)
1789 {
1790 continue;
1791 }
1792 let mut owner = self.get(IssueSelector::Filename(issue.filename.clone()))?;
1793 owner
1794 .relationships
1795 .retain(|r| !(r.id == id && r.canonical() == old_canonical));
1796 removed_anywhere = true;
1797 owner.bump_updated();
1798 owner.filename = self.update(&owner.filename, &owner)?;
1799 }
1800 debug_assert!(removed_anywhere, "graph found the row, the scan did not");
1801
1802 let mut new_owner = self.get(IssueSelector::Number(new_canonical.from))?;
1807 new_owner.relationships.push(updated.clone());
1808 new_owner
1809 .history
1810 .append_now(&actor, HistoryEventKind::RelationshipRetargeted, &detail);
1811 new_owner.bump_updated();
1812 new_owner.filename = self.update(&new_owner.filename, &new_owner)?;
1813
1814 Ok(RelationshipOutcome {
1815 written: Some(self.get(IssueSelector::Number(new_canonical.from))?),
1816 relationship: updated,
1817 changed: true,
1818 })
1819 }
1820
1821 pub fn remove_relation(&self, id: &str) -> Result<RelationshipOutcome> {
1827 let stored = self.find_stored_rows_by_id(id)?;
1828 if stored.is_empty() {
1829 return Ok(RelationshipOutcome {
1833 written: None,
1834 relationship: Relationship {
1835 id: id.to_string(),
1836 ..canonical_placeholder(&CanonicalEdge::new(0, 0, RelationKind::Related))
1837 },
1838 changed: false,
1839 });
1840 }
1841
1842 let canonical = stored[0].1.canonical();
1843 let from = canonical.from.to_string();
1847 let to = canonical.to.to_string();
1848 self.unlink(&from, &to, canonical.kind)
1849 }
1850
1851 fn find_stored_rows_by_id(&self, id: &str) -> Result<Vec<(String, Relationship)>> {
1857 let mut found = Vec::new();
1858 for issue in self.list_all()? {
1859 for rel in &issue.relationships {
1860 if rel.id == id {
1861 found.push((issue.filename.clone(), rel.clone()));
1862 }
1863 }
1864 }
1865 Ok(found)
1866 }
1867
1868 pub fn graph_source(&self) -> Result<(Vec<Issue>, String)> {
1887 Ok((self.list()?, self.relationship_done_column()))
1888 }
1889
1890 fn canonical_request(&self, a: &str, b: &str, kind: RelationKind) -> Result<CanonicalEdge> {
1899 let lenient = |sel: &str| -> Result<u32> {
1900 match self.resolve_issue(sel) {
1901 Ok(issue) => Ok(issue.number),
1902 Err(e) => sel.trim().parse::<u32>().map_err(|_| e),
1903 }
1904 };
1905 let from = lenient(a)?;
1906 let to = lenient(b)?;
1907 Ok(CanonicalEdge::new(from, to, kind))
1908 }
1909
1910 fn find_stored_rows(&self, canonical: &CanonicalEdge) -> Result<Vec<(String, Relationship)>> {
1926 let mut found = Vec::new();
1927 for issue in self.list_all()? {
1928 for rel in &issue.relationships {
1929 if rel.canonical() == *canonical {
1930 found.push((issue.filename.clone(), rel.clone()));
1931 }
1932 }
1933 }
1934 Ok(found)
1935 }
1936
1937 pub fn set_relationships(
1952 &self,
1953 filename: &str,
1954 targets: &[(u32, RelationKind)],
1955 ) -> Result<Issue> {
1956 let issue = self.get(IssueSelector::Filename(filename.to_string()))?;
1957 let own = issue.number;
1958 let actor = actor_identity();
1959
1960 let mut wanted: Vec<CanonicalEdge> = Vec::with_capacity(targets.len());
1962 for (target, kind) in targets {
1963 if *target == own {
1964 return Err(TixError::InvalidInput(format!(
1965 "issue #{own} cannot relate to itself"
1966 )));
1967 }
1968 self.get(IssueSelector::Number(*target))?;
1969 let canonical = CanonicalEdge::new(own, *target, *kind);
1970 if !wanted.contains(&canonical) {
1973 wanted.push(canonical);
1974 }
1975 }
1976
1977 {
1986 let issues = self.list_all()?;
1987 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1988 let done_column = self.relationship_done_column();
1989 let graph = RelationshipGraph::new(&views, &done_column);
1990 for canonical in &wanted {
1991 let verdict = graph.validate_create(canonical.from, canonical.to, canonical.kind);
1992 if verdict.blocking() {
1993 return Err(relationship_error(&verdict.errors));
1994 }
1995 }
1996 }
1997
1998 let issues = self.list_all()?;
2016 let mut removals: HashMap<u32, Vec<Relationship>> = HashMap::new();
2017 for candidate in &issues {
2018 for rel in &candidate.relationships {
2019 let canonical = rel.canonical();
2020 let owned_by_us = canonical.from == own
2021 || (canonical.to == own && !canonical.kind.is_directional());
2022 if owned_by_us && !wanted.contains(&canonical) {
2023 removals
2024 .entry(canonical.from)
2025 .or_default()
2026 .push(rel.clone());
2027 }
2028 }
2029 }
2030
2031 let mut additions: HashMap<u32, Vec<(CanonicalEdge, String)>> = HashMap::new();
2034 for canonical in &wanted {
2035 let owner = match self.get(IssueSelector::Number(canonical.from)) {
2036 Ok(owner) => owner,
2037 Err(TixError::NotFound(_)) => continue,
2038 Err(e) => return Err(e),
2039 };
2040 if owner
2041 .relationships
2042 .iter()
2043 .any(|r| r.canonical() == *canonical)
2044 {
2045 continue;
2046 }
2047 let target_title = self
2048 .get(IssueSelector::Number(canonical.to))
2049 .map(|i| i.title)
2050 .unwrap_or_default();
2051 additions
2052 .entry(canonical.from)
2053 .or_default()
2054 .push((*canonical, target_title));
2055 }
2056
2057 if removals.is_empty() && additions.is_empty() {
2058 return Ok(issue);
2059 }
2060
2061 let mut removal_order: Vec<u32> = removals.keys().copied().collect();
2082 removal_order.sort_unstable();
2083 let mut addition_order: Vec<u32> = additions.keys().copied().collect();
2084 addition_order.sort_unstable();
2085
2086 fn apply(
2091 store: &Store,
2092 mut owner: Issue,
2093 undo: &mut Vec<(PathBuf, String)>,
2094 touched_named: &mut bool,
2095 named_path: &Path,
2096 ) -> Result<()> {
2097 let path = store.issues_dir.join(&owner.filename);
2098 if path == named_path {
2099 *touched_named = true;
2100 }
2101 if let Ok(previous) = std::fs::read_to_string(&path) {
2102 undo.push((path, previous));
2103 }
2104 owner.bump_updated();
2105 store.update(&owner.filename, &owner).map(|_| ())
2106 }
2107
2108 let named_path = self.issues_dir.join(&issue.filename);
2109 let mut undo: Vec<(PathBuf, String)> = Vec::new();
2110 let mut touched_named = false;
2111
2112 let applied = (|| -> Result<()> {
2113 for owner_number in &removal_order {
2114 let doomed = &removals[owner_number];
2115 let mut owner = self.get(IssueSelector::Number(*owner_number))?;
2116 let mut changed = false;
2117 for rel in doomed {
2118 let canonical = rel.canonical();
2119 let before = owner.relationships.len();
2120 owner.relationships.retain(|r| r.canonical() != canonical);
2121 if owner.relationships.len() != before {
2122 let detail = format!("removed {}: {}", rel.id, rel.display);
2123 owner.history.append_now(
2124 &actor,
2125 HistoryEventKind::RelationshipRemoved,
2126 &detail,
2127 );
2128 changed = true;
2129 }
2130 }
2131 if changed {
2132 apply(self, owner, &mut undo, &mut touched_named, &named_path)?;
2133 }
2134 }
2135
2136 for owner_number in &addition_order {
2137 let wanted_here = &additions[owner_number];
2138 let mut owner = self.get(IssueSelector::Number(*owner_number))?;
2139 for (canonical, target_title) in wanted_here {
2140 let rel = Relationship::new(
2141 canonical.from,
2142 canonical.to,
2143 canonical.kind,
2144 target_title,
2145 );
2146 let detail = format!("created {}: {}", rel.id, rel.display);
2147 owner
2148 .history
2149 .append_now(&actor, HistoryEventKind::RelationshipAdded, &detail);
2150 owner.relationships.push(rel);
2151 }
2152 apply(self, owner, &mut undo, &mut touched_named, &named_path)?;
2153 }
2154
2155 if !touched_named {
2169 let named = self.get(IssueSelector::Filename(filename.to_string()))?;
2170 apply(self, named, &mut undo, &mut touched_named, &named_path)?;
2171 }
2172 Ok(())
2173 })();
2174
2175 if let Err(e) = applied {
2176 for (path, previous) in undo.iter().rev() {
2177 let _ = atomic_write(&self.issues_dir, path, previous.as_bytes());
2178 }
2179 return Err(e);
2180 }
2181
2182 self.get(IssueSelector::Filename(filename.to_string()))
2183 }
2184
2185 pub fn resolve_issue(&self, raw: &str) -> Result<Issue> {
2195 let selector = crate::commands::parse_selector(self, raw)?;
2196 self.get(selector)
2197 }
2198
2199 pub fn reassign(&self, filename: &str, new_assignee: Option<&str>) -> Result<PathBuf> {
2222 let issue = self.get(IssueSelector::Filename(filename.to_string()))?;
2224 let mut updated = issue.clone();
2225 updated.assignee = new_assignee.map(str::to_string);
2226 let target_dir = crate::layout::render_directory(&self.config.layout, &updated);
2227 let (old_dir, base) = split_relative(filename).ok_or_else(|| {
2228 TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
2229 })?;
2230 if old_dir == target_dir {
2231 return Ok(PathBuf::from(filename));
2232 }
2233 let stem = base.strip_suffix(".md").unwrap_or(&base);
2234 self.move_issue_file(filename, &target_dir, stem)
2235 }
2236
2237 pub fn migrate_issue(&self, filename: &str, target: &str) -> Result<()> {
2242 if target == filename {
2243 return Ok(());
2244 }
2245 validate_filename(target)?;
2246 let from = self.issues_dir.join(filename);
2247 if !from.is_file() {
2248 return Err(TixError::NotFound(format!("file {filename}")));
2249 }
2250 let to = self.issues_dir.join(target);
2251 if to.exists() {
2252 return Err(TixError::InvalidInput(format!(
2253 "cannot migrate {filename}: {target} already exists"
2254 )));
2255 }
2256 if let Some(parent) = to.parent() {
2257 std::fs::create_dir_all(parent)?;
2258 }
2259 std::fs::rename(&from, &to)?;
2260 let (old_dir, base) = split_relative(filename).unwrap_or_default();
2261 let stem = base.strip_suffix(".md").unwrap_or(&base);
2262 let asset_name = format!("{}-assets", slug_from_stem(stem));
2263 let old_asset_dir = self.issues_dir.join(&old_dir).join(&asset_name);
2264 if old_asset_dir.exists()
2265 && let Some(new_dir) = to.parent()
2266 {
2267 let moved = resolve_asset_dir_name(new_dir, &asset_name);
2268 std::fs::rename(&old_asset_dir, new_dir.join(&moved))?;
2269 }
2270 Ok(())
2271 }
2272
2273 fn move_issue_file(&self, filename: &str, target_dir: &str, stem: &str) -> Result<PathBuf> {
2277 let target_dir_path = self.issues_dir.join(target_dir);
2278 std::fs::create_dir_all(&target_dir_path)?;
2279 let new_name = resolve_target_name(&target_dir_path, stem);
2280 let old_dir = split_relative(filename).map_or_else(String::new, |(d, _)| d);
2281 std::fs::rename(
2282 self.issues_dir.join(filename),
2283 target_dir_path.join(&new_name),
2284 )?;
2285 let slug = slug_from_stem(stem);
2288 let asset_name = format!("{slug}-assets");
2289 let old_asset_dir = self.issues_dir.join(&old_dir).join(&asset_name);
2290 if old_asset_dir.exists() {
2291 let moved = resolve_asset_dir_name(&target_dir_path, &asset_name);
2292 std::fs::rename(&old_asset_dir, target_dir_path.join(&moved))?;
2293 }
2294 Ok(PathBuf::from(format!("{target_dir}/{new_name}")))
2295 }
2296
2297 #[must_use]
2302 pub fn path_is_laid_out(&self, filename: &str, issue: &Issue) -> bool {
2303 split_relative(filename).is_some_and(|(dir, _)| {
2304 dir == crate::layout::render_directory(&self.config.layout, issue)
2305 })
2306 }
2307
2308 pub fn move_to_layout(&self, issue: &mut Issue) -> Result<bool> {
2312 let before = issue.filename.clone();
2313 let after = self.reconcile_location(&before, issue)?;
2314 issue.filename = after.clone();
2315 Ok(after != before)
2316 }
2317}
2318
2319pub(crate) fn is_issue_rel_path(rel: &Path) -> bool {
2327 let mut file_name: Option<&std::ffi::OsStr> = None;
2328 for component in rel.components() {
2329 match component {
2330 std::path::Component::Normal(segment) => {
2331 let Some(s) = segment.to_str() else {
2332 return false;
2333 };
2334 if s.starts_with(".tix-tmp-") || s.ends_with("-assets") {
2335 return false;
2336 }
2337 file_name = Some(segment);
2338 }
2339 std::path::Component::CurDir => {}
2340 _ => return false,
2342 }
2343 }
2344 file_name.is_some_and(|name| name.to_string_lossy().ends_with(".md"))
2345}
2346
2347fn validate_filename(filename: &str) -> Result<()> {
2356 if filename.is_empty() {
2357 return Err(TixError::InvalidInput(format!(
2358 "filename must not be empty: {filename:?}"
2359 )));
2360 }
2361 if filename.starts_with('/') {
2362 return Err(TixError::InvalidInput(format!(
2363 "filename must be relative (no leading `/`): {filename}"
2364 )));
2365 }
2366 if filename.contains('\\') {
2367 return Err(TixError::InvalidInput(format!(
2368 "filename must not contain backslashes: {filename}"
2369 )));
2370 }
2371 if filename.contains("..") {
2372 return Err(TixError::InvalidInput(format!(
2373 "filename must not contain `..`: {filename}"
2374 )));
2375 }
2376 if filename.contains('\0') {
2377 return Err(TixError::InvalidInput(format!(
2378 "filename must not contain NUL: {filename}"
2379 )));
2380 }
2381 if filename.split('/').any(|component| component.is_empty()) {
2382 return Err(TixError::InvalidInput(format!(
2383 "filename path components must not be empty: {filename}"
2384 )));
2385 }
2386 Ok(())
2387}
2388
2389fn frontmatter_number(text: &str) -> Option<u32> {
2405 let mut lines = text.lines();
2406 if lines.next()?.trim() != "---" {
2408 return None;
2409 }
2410 for line in lines {
2411 let line = line.trim_end();
2412 if line == "---" {
2415 return None;
2416 }
2417 if let Some(rest) = line.strip_prefix("number:") {
2418 return rest.trim().parse().ok();
2419 }
2420 }
2421 None
2422}
2423
2424fn claims_dir(issues_dir: &Path) -> PathBuf {
2436 issues_dir
2437 .parent()
2438 .unwrap_or(issues_dir)
2439 .join(NumberClaim::DIR)
2440}
2441
2442fn split_relative(filename: &str) -> Option<(String, String)> {
2443 let mut parts = filename.rsplitn(2, '/');
2444 let basename = parts.next()?;
2445 let segment = parts.next().unwrap_or("");
2446 Some((segment.to_string(), basename.to_string()))
2447}
2448
2449fn flip_archived(filename: &str, archive: bool) -> Option<String> {
2456 let (segment, basename) = split_relative(filename)?;
2457 let flipped = if archive {
2458 if basename.starts_with('.') {
2459 return None;
2460 }
2461 format!(".{basename}")
2462 } else {
2463 basename.strip_prefix('.')?.to_string()
2464 };
2465 Some(if segment.is_empty() {
2466 flipped
2467 } else {
2468 format!("{segment}/{flipped}")
2469 })
2470}
2471
2472fn resolve_target_name(dir: &Path, stem: &str) -> String {
2475 let mut candidate = format!("{stem}.md");
2476 let mut i = 1u32;
2477 while dir.join(&candidate).exists() {
2478 candidate = format!("{stem}-{i}.md");
2479 i += 1;
2480 }
2481 candidate
2482}
2483
2484fn slug_from_stem(stem: &str) -> String {
2488 let bytes = stem.as_bytes();
2489 if stem.len() > 10 && bytes[4] == b'-' && bytes[7] == b'-' && bytes[10] == b'-' {
2490 stem[11..].to_string()
2491 } else {
2492 stem.to_string()
2493 }
2494}
2495
2496fn resolve_asset_name(dir: &Path, name: &str) -> String {
2500 if !dir.join(name).exists() {
2501 return name.to_string();
2502 }
2503 let (stem, ext) = match name.rsplit_once('.') {
2504 Some((s, e)) => (s, format!(".{e}")),
2505 None => (name, String::new()),
2506 };
2507 let mut i = 1u32;
2508 loop {
2509 let candidate = format!("{stem}-{i}{ext}");
2510 if !dir.join(&candidate).exists() {
2511 return candidate;
2512 }
2513 i += 1;
2514 }
2515}
2516
2517fn resolve_asset_dir_name(dir: &Path, name: &str) -> String {
2520 let mut candidate = name.to_string();
2521 let mut i = 1u32;
2522 while dir.join(&candidate).exists() {
2523 candidate = format!("{name}-{i}");
2524 i += 1;
2525 }
2526 candidate
2527}
2528
2529fn atomic_copy(dir: &Path, target: &Path, source: &Path) -> Result<()> {
2532 let pid = std::process::id();
2533 let nonce = nanos();
2534 let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
2535 let copy_result = (|| -> std::io::Result<()> {
2536 std::fs::copy(source, &tmp)?;
2537 let f = std::fs::File::open(&tmp)?;
2538 f.sync_all()?;
2539 Ok(())
2540 })();
2541 if let Err(e) = copy_result {
2542 let _ = std::fs::remove_file(&tmp);
2543 return Err(TixError::Io(e));
2544 }
2545 if let Err(e) = std::fs::rename(&tmp, target) {
2546 let _ = std::fs::remove_file(&tmp);
2547 return Err(TixError::Io(e));
2548 }
2549 Ok(())
2550}
2551
2552fn is_image_name(name: &str) -> bool {
2554 matches!(
2555 name.rsplit('.')
2556 .next()
2557 .unwrap_or("")
2558 .to_ascii_lowercase()
2559 .as_str(),
2560 "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp"
2561 )
2562}
2563
2564fn atomic_write(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
2565 write_file(dir, target, data, Link::Overwrite)
2566}
2567
2568fn write_file_new(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
2587 write_file(dir, target, data, Link::Exclusive)
2588}
2589
2590#[derive(Clone, Copy)]
2591enum Link {
2592 Overwrite,
2593 Exclusive,
2594}
2595
2596fn write_file(dir: &Path, target: &Path, data: &[u8], link: Link) -> Result<()> {
2597 let pid = std::process::id();
2598 let nonce = nanos();
2599 let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
2600 let write_result = (|| -> std::io::Result<()> {
2601 let mut f = std::fs::File::create(&tmp)?;
2602 use std::io::Write;
2603 f.write_all(data)?;
2604 f.sync_all()?;
2605 Ok(())
2606 })();
2607 if let Err(e) = write_result {
2608 let _ = std::fs::remove_file(&tmp);
2609 return Err(TixError::Io(e));
2610 }
2611 let link_result = match link {
2612 Link::Overwrite => std::fs::rename(&tmp, target),
2613 Link::Exclusive => match std::fs::hard_link(&tmp, target) {
2614 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e),
2618 Err(_) => std::fs::rename(&tmp, target),
2621 Ok(()) => {
2622 let _ = std::fs::remove_file(&tmp);
2623 Ok(())
2624 }
2625 },
2626 };
2627 if let Err(e) = link_result {
2628 let _ = std::fs::remove_file(&tmp);
2629 return Err(TixError::Io(e));
2630 }
2631 let _ = std::fs::File::open(dir).and_then(|f| f.sync_all());
2632 Ok(())
2633}
2634
2635fn nanos() -> u64 {
2636 SystemTime::now()
2637 .duration_since(UNIX_EPOCH)
2638 .map(|d| d.as_nanos() as u64)
2639 .unwrap_or(0)
2640}
2641
2642#[cfg(test)]
2643mod tests {
2644 use super::*;
2645 use crate::model::{Priority, today};
2647 use crate::relationships::DiagnosticKind;
2648 use std::fs;
2649 use tempfile::TempDir;
2650
2651 #[test]
2664 fn write_file_new_refuses_an_existing_target() {
2665 let dir = TempDir::new().unwrap();
2666 let target = dir.path().join("issue.md");
2667 write_file_new(dir.path(), &target, b"first").unwrap();
2668 assert_eq!(fs::read_to_string(&target).unwrap(), "first");
2669
2670 let err = write_file_new(dir.path(), &target, b"second").unwrap_err();
2671 let TixError::Io(io_err) = err else {
2672 panic!("expected an I/O error, so the caller can retry on a name collision")
2673 };
2674 assert_eq!(
2675 io_err.kind(),
2676 std::io::ErrorKind::AlreadyExists,
2677 "the error must be AlreadyExists, so the caller can retry on a name \\
2678 collision rather than treating it as a hard failure"
2679 );
2680 assert_eq!(
2681 fs::read_to_string(&target).unwrap(),
2682 "first",
2683 "the existing file must be byte-identical — not truncated, not replaced"
2684 );
2685 }
2686
2687 #[test]
2689 fn a_clobbered_name_is_retried_rather_than_failing_or_overwriting() {
2690 let tmp = TempDir::new().unwrap();
2691 let dir = tmp.path();
2692 let store = Store::open(&crate::config::Config {
2693 issues_dir: dir.to_path_buf(),
2694 ..Default::default()
2695 })
2696 .unwrap();
2697
2698 let segment_dir = dir.join("seg");
2701 fs::create_dir_all(&segment_dir).unwrap();
2702
2703 for name in ["2026-01-01-race.md", "2026-01-01-race-2.md"] {
2706 fs::write(segment_dir.join(name), "planted").unwrap();
2707 }
2708
2709 let (filename, path) = store
2710 .write_new_issue_file("seg", "2026-01-01-race", b"mine")
2711 .unwrap();
2712 assert_eq!(filename, "seg/2026-01-01-race-3.md");
2713 assert_eq!(fs::read_to_string(&path).unwrap(), "mine");
2714 assert_eq!(
2715 fs::read_to_string(segment_dir.join("2026-01-01-race.md")).unwrap(),
2716 "planted"
2717 );
2718 assert_eq!(
2719 fs::read_to_string(segment_dir.join("2026-01-01-race-2.md")).unwrap(),
2720 "planted"
2721 );
2722 }
2723
2724 #[test]
2730 fn no_temp_files_are_left_behind_on_success_or_collision() {
2731 let dir = TempDir::new().unwrap();
2732 let target = dir.path().join("issue.md");
2733 write_file_new(dir.path(), &target, b"first").unwrap();
2734 let _ = write_file_new(dir.path(), &target, b"second");
2735 let leftovers: Vec<String> = fs::read_dir(dir.path())
2736 .unwrap()
2737 .filter_map(|e| e.ok())
2738 .map(|e| e.file_name().to_string_lossy().to_string())
2739 .filter(|n| n.starts_with(".tix-tmp-"))
2740 .collect();
2741 assert!(
2742 leftovers.is_empty(),
2743 "temp files must not survive, found {leftovers:?}"
2744 );
2745 }
2746
2747 fn make_store() -> (tempfile::TempDir, Store) {
2748 let tmp = tempfile::TempDir::new().unwrap();
2749 let dir = tmp.path().join("issues");
2750 fs::create_dir_all(&dir).unwrap();
2751 let config_path = tmp.path().join("tix.toml");
2752 let mut config = Config {
2753 issues_dir: dir.clone(),
2754 ..Config::default()
2755 };
2756 config.layout.directory = "{assignee}".to_string();
2761 config.layout.move_on_change = false;
2762 config.save(&config_path).unwrap();
2763 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
2764 let store = Store {
2765 issues_dir: dir,
2766 config,
2767 };
2768 (tmp, store)
2769 }
2770
2771 fn write_issue(store: &Store, name: &str, contents: &str) {
2772 fs::write(store.issues_dir.join(name), contents).unwrap();
2773 }
2774
2775 fn sample_frontmatter(number: u32, title: &str, status: &str) -> String {
2776 format!(
2777 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
2778 )
2779 }
2780
2781 #[test]
2790 fn list_never_opens_an_archived_file_but_list_all_does() {
2791 let (tmp, store) = make_store();
2792 let _ = tmp;
2793 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2794 write_issue(
2795 &store,
2796 "alice/2026-01-01-active.md",
2797 &sample_frontmatter(1, "Active", "todo"),
2798 );
2799 fs::write(
2800 store.issues_dir.join("alice/.2026-01-02-broken.md"),
2801 "this file has no frontmatter at all\n",
2802 )
2803 .unwrap();
2804
2805 let active = store
2806 .list()
2807 .expect("the default walk must not read the archived file");
2808 assert_eq!(active.len(), 1);
2809 assert_eq!(active[0].number, 1);
2810 assert!(!active[0].archived);
2811
2812 let all = store.list_all();
2813 assert!(
2814 matches!(all, Err(TixError::Parse(_))),
2815 "list_all() reads it, so the broken file is only invisible to list(): {all:?}"
2816 );
2817 }
2818
2819 #[test]
2820 fn archive_renames_in_place_and_restore_is_the_inverse() {
2821 let (tmp, store) = make_store();
2822 let _ = tmp;
2823 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2824 write_issue(
2825 &store,
2826 "alice/2026-01-01-x.md",
2827 &sample_frontmatter(5, "X", "todo"),
2828 );
2829
2830 let archived = store.archive("5").unwrap();
2831 assert_eq!(archived.filename, "alice/.2026-01-01-x.md");
2832 assert!(archived.archived);
2833 assert!(store.issues_dir.join("alice/.2026-01-01-x.md").is_file());
2834 assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
2835 assert!(
2836 store.list().unwrap().is_empty(),
2837 "hidden from the default view"
2838 );
2839
2840 let all = store.list_all().unwrap();
2841 assert_eq!(all.len(), 1);
2842 assert!(all[0].archived);
2843 assert_eq!(all[0].number, 5, "the number is untouched");
2844
2845 assert_eq!(
2847 store.get(IssueSelector::Number(5)).unwrap().filename,
2848 "alice/.2026-01-01-x.md"
2849 );
2850 assert!(store.get(IssueSelector::Slug(slugify("X"))).is_ok());
2851
2852 let restored = store.restore("5").unwrap();
2853 assert_eq!(restored.filename, "alice/2026-01-01-x.md");
2854 assert!(!restored.archived);
2855 assert_eq!(store.list().unwrap().len(), 1, "visible again");
2856 }
2857
2858 #[test]
2859 fn archive_appends_one_event_and_leaves_the_frontmatter_untouched() {
2860 let (tmp, store) = make_store();
2861 let _ = tmp;
2862 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2863 write_issue(
2864 &store,
2865 "alice/2026-01-01-x.md",
2866 &sample_frontmatter(5, "X", "todo"),
2867 );
2868 let before = store
2869 .get(IssueSelector::Filename("alice/2026-01-01-x.md".into()))
2870 .unwrap();
2871
2872 let archived = store.archive("5").unwrap();
2873 assert_eq!(archived.history.len(), 1, "exactly one History event");
2874 assert_eq!(
2875 archived.history.events()[0].kind,
2876 HistoryEventKind::Archived
2877 );
2878
2879 let after = store.get(IssueSelector::Number(5)).unwrap();
2880 assert_eq!(after.number, before.number);
2884 assert_eq!(after.title, before.title);
2885 assert_eq!(after.status, before.status);
2886 assert_eq!(after.labels, before.labels);
2887 assert_eq!(after.priority, before.priority);
2888 assert_eq!(after.assignee, before.assignee);
2889 assert_eq!(after.created, before.created);
2890 assert_eq!(
2891 after.updated, before.updated,
2892 "archiving must not bump `updated`: it is a frontmatter field"
2893 );
2894 let raw = read_issue_file(&store, "alice/.2026-01-01-x.md");
2896 let fm = raw.split("\n---").next().unwrap();
2897 assert!(
2898 !fm.contains("archived"),
2899 "the archived flag must never reach the frontmatter: {fm}"
2900 );
2901 }
2902
2903 #[test]
2904 fn restore_appends_one_restored_event() {
2905 let (tmp, store) = make_store();
2906 let _ = tmp;
2907 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2908 write_issue(
2909 &store,
2910 "alice/2026-01-01-x.md",
2911 &sample_frontmatter(5, "X", "todo"),
2912 );
2913 store.archive("5").unwrap();
2914 let restored = store.restore("5").unwrap();
2915 assert_eq!(restored.history.len(), 2, "archive + restore");
2916 assert_eq!(
2917 restored.history.events()[1].kind,
2918 HistoryEventKind::Restored
2919 );
2920 }
2921
2922 #[test]
2923 fn archiving_the_highest_number_does_not_free_it_for_reuse() {
2924 let (tmp, store) = make_store();
2925 let _ = tmp;
2926 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2927 write_issue(
2928 &store,
2929 "alice/2026-01-01-a.md",
2930 &sample_frontmatter(1, "A", "todo"),
2931 );
2932 write_issue(
2933 &store,
2934 "alice/2026-01-01-b.md",
2935 &sample_frontmatter(7, "B", "todo"),
2936 );
2937
2938 store.archive("7").unwrap();
2939
2940 let mut fresh = Issue::parse(&sample_frontmatter(0, "New", "todo")).unwrap();
2941 store.create(&mut fresh, "body", None).unwrap();
2942 assert_eq!(
2943 fresh.number, 8,
2944 "an archived issue keeps its number: `create` must not reuse #7"
2945 );
2946 }
2947
2948 #[test]
2949 fn redundant_archive_and_restore_are_refused_by_name() {
2950 let (tmp, store) = make_store();
2951 let _ = tmp;
2952 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2953 write_issue(
2954 &store,
2955 "alice/2026-01-01-x.md",
2956 &sample_frontmatter(5, "X", "todo"),
2957 );
2958
2959 let err = store.restore("5").unwrap_err();
2960 assert!(
2961 err.to_string().contains("not_archived"),
2962 "an active issue cannot be restored: {err}"
2963 );
2964 assert!(
2965 store.issues_dir.join("alice/2026-01-01-x.md").is_file(),
2966 "the refused restore writes nothing"
2967 );
2968
2969 store.archive("5").unwrap();
2970 let err = store.archive("5").unwrap_err();
2971 assert!(
2972 err.to_string().contains("already_archived"),
2973 "an archived issue cannot be re-archived: {err}"
2974 );
2975 assert!(
2976 store.issues_dir.join("alice/.2026-01-01-x.md").is_file(),
2977 "the refused archive writes nothing"
2978 );
2979 }
2980
2981 #[test]
2982 fn archive_never_clobbers_an_occupied_target_name() {
2983 let (tmp, store) = make_store();
2984 let _ = tmp;
2985 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2986 write_issue(
2987 &store,
2988 "alice/2026-01-01-x.md",
2989 &sample_frontmatter(5, "X", "todo"),
2990 );
2991 write_issue(
2993 &store,
2994 "alice/.2026-01-01-x.md",
2995 &sample_frontmatter(6, "Other", "todo"),
2996 );
2997 let err = store.archive("5").unwrap_err();
2998 assert!(err.to_string().contains("already exists"), "got: {err}");
2999 assert!(store.issues_dir.join("alice/2026-01-01-x.md").is_file());
3000 assert!(store.issues_dir.join("alice/.2026-01-01-x.md").is_file());
3001 }
3002
3003 #[test]
3004 fn archive_and_restore_a_legacy_flat_file_in_place() {
3005 let (tmp, store) = make_store();
3006 let _ = tmp;
3007 write_issue(
3008 &store,
3009 "2026-01-01-legacy.md",
3010 &sample_frontmatter(3, "Legacy", "todo"),
3011 );
3012
3013 let archived = store.archive("3").unwrap();
3014 assert_eq!(archived.filename, ".2026-01-01-legacy.md");
3015 assert!(store.issues_dir.join(".2026-01-01-legacy.md").is_file());
3016 assert!(store.list().unwrap().is_empty());
3017 assert_eq!(store.list_all().unwrap().len(), 1);
3018
3019 let restored = store.restore("3").unwrap();
3020 assert_eq!(restored.filename, "2026-01-01-legacy.md");
3021 assert!(store.issues_dir.join("2026-01-01-legacy.md").is_file());
3022 }
3023
3024 #[test]
3025 fn list_returns_sorted_by_filename() {
3026 let (tmp, store) = make_store();
3027 let _ = tmp;
3028 write_issue(
3029 &store,
3030 "2026-03-01-c.md",
3031 &sample_frontmatter(3, "C", "todo"),
3032 );
3033 write_issue(
3034 &store,
3035 "2026-01-01-a.md",
3036 &sample_frontmatter(1, "A", "todo"),
3037 );
3038 write_issue(
3039 &store,
3040 "2026-02-01-b.md",
3041 &sample_frontmatter(2, "B", "todo"),
3042 );
3043 let issues = store.list().unwrap();
3044 assert_eq!(issues.len(), 3);
3045 assert_eq!(issues[0].number, 1);
3046 assert_eq!(issues[1].number, 2);
3047 assert_eq!(issues[2].number, 3);
3048 assert_eq!(issues[0].title, "A");
3049 }
3050
3051 #[test]
3054 fn is_issue_rel_path_matches_what_scan_walks() {
3055 for path in [
3057 "a.md",
3058 "alice/b.md",
3059 ".hidden.md",
3060 "alice/.hidden.md",
3061 "deep/a/b.md",
3062 ] {
3063 assert!(
3064 is_issue_rel_path(Path::new(path)),
3065 "{path} is an issue file `scan` reads"
3066 );
3067 }
3068 for path in [
3070 "notes.txt",
3071 "alice/notes.txt",
3072 "alice",
3073 ".tix-tmp-x/a.md",
3074 "foo-assets/a.md",
3075 "alice/foo-assets/a.md",
3076 ] {
3077 assert!(
3078 !is_issue_rel_path(Path::new(path)),
3079 "{path} is not a path `scan` indexes"
3080 );
3081 }
3082 }
3083
3084 #[test]
3085 fn scan_above_the_parallel_threshold_is_sorted_and_complete() {
3086 let (tmp, store) = make_store();
3090 let _ = tmp;
3091 let n = Store::PARALLEL_SCAN_THRESHOLD + 44;
3092 for i in (1..=n).rev() {
3093 write_issue(
3094 &store,
3095 &format!("2026-01-01-{i:04}.md"),
3096 &sample_frontmatter(i as u32, &format!("T{i}"), "todo"),
3097 );
3098 }
3099 let issues = store.list().unwrap();
3100 assert_eq!(
3101 issues.len(),
3102 n,
3103 "every file is read, none dropped or doubled"
3104 );
3105 let names: Vec<&str> = issues.iter().map(|i| i.filename.as_str()).collect();
3106 let mut sorted = names.clone();
3107 sorted.sort_unstable();
3108 assert_eq!(names, sorted, "sorted by filename as `scan` promises");
3109 assert_eq!(issues[0].number, 1, "content came from the first file");
3110 assert_eq!(issues[n - 1].number, n as u32, "and from the last");
3111 }
3112
3113 #[test]
3114 fn read_issue_at_sets_filename_and_archived_like_scan() {
3115 let (tmp, store) = make_store();
3116 let _ = tmp;
3117 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3118 write_issue(
3119 &store,
3120 "alice/2026-01-01-a.md",
3121 &sample_frontmatter(1, "A", "todo"),
3122 );
3123 write_issue(
3124 &store,
3125 "alice/.2026-01-02-b.md",
3126 &sample_frontmatter(2, "B", "todo"),
3127 );
3128
3129 let active = store
3130 .read_issue_at(Path::new("alice/2026-01-01-a.md"))
3131 .unwrap();
3132 assert_eq!(active.number, 1);
3133 assert_eq!(active.filename, "alice/2026-01-01-a.md");
3134 assert!(!active.archived);
3135
3136 let archived = store
3137 .read_issue_at(Path::new("alice/.2026-01-02-b.md"))
3138 .unwrap();
3139 assert_eq!(archived.filename, "alice/.2026-01-02-b.md");
3140 assert!(
3141 archived.archived,
3142 "the leading dot marks it archived, as `scan` does"
3143 );
3144
3145 write_issue(&store, "flat.md", &sample_frontmatter(3, "F", "todo"));
3147 assert_eq!(
3148 store.read_issue_at(Path::new("flat.md")).unwrap().filename,
3149 "flat.md"
3150 );
3151 assert!(matches!(
3152 store.read_issue_at(Path::new("alice/gone.md")),
3153 Err(TixError::NotFound(_))
3154 ));
3155 }
3156
3157 #[test]
3158 fn list_ignores_non_md_files() {
3159 let (tmp, store) = make_store();
3160 let _ = tmp;
3161 write_issue(
3162 &store,
3163 "2026-01-01-a.md",
3164 &sample_frontmatter(1, "A", "todo"),
3165 );
3166 write_issue(&store, "readme.txt", "not an issue");
3167 write_issue(&store, "2026-01-01-sub", "no extension");
3168 let issues = store.list().unwrap();
3169 assert_eq!(issues.len(), 1);
3170 }
3171
3172 #[test]
3173 fn list_errors_on_malformed_frontmatter() {
3174 let (tmp, store) = make_store();
3175 let _ = tmp;
3176 write_issue(&store, "2026-01-01-bad.md", "no frontmatter here\n");
3177 let err = store.list().unwrap_err();
3178 assert!(matches!(err, TixError::Parse(_)), "got {err:?}");
3179 }
3180
3181 #[test]
3182 fn get_by_number() {
3183 let (tmp, store) = make_store();
3184 let _ = tmp;
3185 write_issue(
3186 &store,
3187 "2026-01-01-x.md",
3188 &sample_frontmatter(5, "X", "todo"),
3189 );
3190 let issue = store.get(IssueSelector::Number(5)).unwrap();
3191 assert_eq!(issue.title, "X");
3192 }
3193
3194 #[test]
3195 fn get_by_filename() {
3196 let (tmp, store) = make_store();
3197 let _ = tmp;
3198 write_issue(
3199 &store,
3200 "2026-01-01-y.md",
3201 &sample_frontmatter(2, "Y", "done"),
3202 );
3203 let issue = store
3204 .get(IssueSelector::Filename("2026-01-01-y.md".into()))
3205 .unwrap();
3206 assert_eq!(issue.status.display(), "done");
3207 }
3208
3209 #[test]
3210 fn get_by_slug() {
3211 let (tmp, store) = make_store();
3212 let _ = tmp;
3213 write_issue(
3214 &store,
3215 "2026-01-01-z.md",
3216 &sample_frontmatter(3, "Z", "todo"),
3217 );
3218 let issue = store.get(IssueSelector::Slug("z".into())).unwrap();
3219 assert_eq!(issue.number, 3);
3220 }
3221
3222 #[test]
3223 fn get_not_found() {
3224 let (tmp, store) = make_store();
3225 let _ = tmp;
3226 assert!(store.get(IssueSelector::Number(99)).is_err());
3227 }
3228
3229 #[test]
3230 fn create_assigns_number_from_live_issues_and_leaves_config_untouched() {
3231 let (tmp, store) = make_store();
3232 let _ = tmp;
3233 assert_eq!(store.config.state.next_number, 0);
3234 let mut issue = Issue {
3235 number: 0,
3236 title: "First".into(),
3237 status: Status::default(),
3238 labels: vec![],
3239 priority: Priority::default(),
3240 assignee: None,
3241 estimate: None,
3242 commit: None,
3243 gitlab_id: None,
3244 linear_id: None,
3245 mission: None,
3246 task: None,
3247 relationships: vec![],
3248 related_issues: vec![],
3249 files: vec![],
3250 cycles: None,
3251 milestones: None,
3252 reviewer: None,
3253 start_date: None,
3254 due_date: None,
3255 created: "2026-01-01".into(),
3256 type_: None,
3257 updated: "2026-01-01".into(),
3258 filename: String::new(),
3259 body: String::new(),
3260 history: crate::model::History::new(),
3261 has_retired_links_key: false,
3262 parent: None,
3263 extra_frontmatter: std::collections::BTreeMap::new(),
3264 archived: false,
3265 entered_at: None,
3266 source: None,
3267 };
3268 let path = store.create(&mut issue, "Hello world", None).unwrap();
3269 assert!(path.exists());
3270 assert_eq!(
3271 issue.number, 1,
3272 "an empty store starts at 1, not 0 — #0 is indistinguishable from a \
3273 sentinel or a failed read (tix #1376)"
3274 );
3275 let config_path = tmp.path().join("tix.toml");
3277 let on_disk = std::fs::read_to_string(&config_path).unwrap();
3278 assert!(
3279 !on_disk.contains("next_number"),
3280 "create must not persist an issue-number counter:\n{on_disk}"
3281 );
3282 let reloaded = Config::load_with_warning(&config_path, |_| {}).unwrap();
3283 assert_eq!(reloaded.state.next_number, 0, "state is never written");
3284 }
3285
3286 #[test]
3287 fn create_resolves_max_live_number_plus_one() {
3288 let (tmp, store) = make_store();
3289 let _ = tmp;
3290 write_issue(
3291 &store,
3292 "2026-01-01-a.md",
3293 &sample_frontmatter(0, "A", "todo"),
3294 );
3295 write_issue(
3296 &store,
3297 "2026-01-01-b.md",
3298 &sample_frontmatter(1, "B", "todo"),
3299 );
3300 write_issue(
3301 &store,
3302 "2026-01-01-c.md",
3303 &sample_frontmatter(2, "C", "todo"),
3304 );
3305 let mut issue = Issue {
3306 number: 0,
3307 title: "D".into(),
3308 status: Status::default(),
3309 labels: vec![],
3310 priority: Priority::default(),
3311 assignee: None,
3312 estimate: None,
3313 commit: None,
3314 gitlab_id: None,
3315 linear_id: None,
3316 mission: None,
3317 task: None,
3318 relationships: vec![],
3319 related_issues: vec![],
3320 files: vec![],
3321 cycles: None,
3322 milestones: None,
3323 reviewer: None,
3324 start_date: None,
3325 due_date: None,
3326 created: "2026-01-01".into(),
3327 type_: None,
3328 updated: "2026-01-01".into(),
3329 filename: String::new(),
3330 body: String::new(),
3331 history: crate::model::History::new(),
3332 has_retired_links_key: false,
3333 parent: None,
3334 extra_frontmatter: std::collections::BTreeMap::new(),
3335 archived: false,
3336 entered_at: None,
3337 source: None,
3338 };
3339 store.create(&mut issue, "", None).unwrap();
3340 assert_eq!(issue.number, 3);
3341 }
3342
3343 #[test]
3344 fn create_reuses_number_of_deleted_highest_issue() {
3345 let (_tmp, store) = make_store();
3349 let mut issue_a = Issue {
3350 number: 0,
3351 title: "A".into(),
3352 status: Status::default(),
3353 labels: vec![],
3354 priority: Priority::default(),
3355 assignee: None,
3356 estimate: None,
3357 commit: None,
3358 gitlab_id: None,
3359 linear_id: None,
3360 mission: None,
3361 task: None,
3362 relationships: vec![],
3363 related_issues: vec![],
3364 files: vec![],
3365 cycles: None,
3366 milestones: None,
3367 reviewer: None,
3368 start_date: None,
3369 due_date: None,
3370 created: "2026-01-01".into(),
3371 type_: None,
3372 updated: "2026-01-01".into(),
3373 filename: String::new(),
3374 body: String::new(),
3375 history: crate::model::History::new(),
3376 has_retired_links_key: false,
3377 parent: None,
3378 extra_frontmatter: std::collections::BTreeMap::new(),
3379 archived: false,
3380 entered_at: None,
3381 source: None,
3382 };
3383 store.create(&mut issue_a, "", None).unwrap();
3384 let filename = issue_a.filename.clone();
3385 assert_eq!(issue_a.number, 1, "the first issue in a fresh store is #1");
3386 store.delete(&filename).unwrap();
3387 let mut issue_b = Issue {
3388 number: 0,
3389 title: "B".into(),
3390 status: Status::default(),
3391 labels: vec![],
3392 priority: Priority::default(),
3393 assignee: None,
3394 estimate: None,
3395 commit: None,
3396 gitlab_id: None,
3397 linear_id: None,
3398 mission: None,
3399 task: None,
3400 relationships: vec![],
3401 related_issues: vec![],
3402 files: vec![],
3403 cycles: None,
3404 milestones: None,
3405 reviewer: None,
3406 start_date: None,
3407 due_date: None,
3408 created: "2026-01-02".into(),
3409 type_: None,
3410 updated: "2026-01-02".into(),
3411 filename: String::new(),
3412 body: String::new(),
3413 history: crate::model::History::new(),
3414 has_retired_links_key: false,
3415 parent: None,
3416 extra_frontmatter: std::collections::BTreeMap::new(),
3417 archived: false,
3418 entered_at: None,
3419 source: None,
3420 };
3421 store.create(&mut issue_b, "", None).unwrap();
3422 assert_eq!(
3423 issue_b.number, 1,
3424 "an emptied store resumes at the same floor as a fresh one: 1"
3425 );
3426 }
3427
3428 #[test]
3429 fn create_with_custom_filename_stem() {
3430 let (tmp, store) = make_store();
3431 let _ = tmp;
3432 let mut issue = Issue {
3433 number: 0,
3434 title: "T".into(),
3435 status: Status::default(),
3436 labels: vec![],
3437 priority: Priority::default(),
3438 assignee: None,
3439 estimate: None,
3440 commit: None,
3441 gitlab_id: None,
3442 linear_id: None,
3443 mission: None,
3444 task: None,
3445 relationships: vec![],
3446 related_issues: vec![],
3447 files: vec![],
3448 cycles: None,
3449 milestones: None,
3450 reviewer: None,
3451 start_date: None,
3452 due_date: None,
3453 created: "2026-01-01".into(),
3454 type_: None,
3455 updated: "2026-01-01".into(),
3456 filename: String::new(),
3457 body: String::new(),
3458 history: crate::model::History::new(),
3459 has_retired_links_key: false,
3460 parent: None,
3461 extra_frontmatter: std::collections::BTreeMap::new(),
3462 archived: false,
3463 entered_at: None,
3464 source: None,
3465 };
3466 let path = store
3467 .create(&mut issue, "body", Some("custom-stem"))
3468 .unwrap();
3469 assert!(path.to_string_lossy().contains("custom-stem.md"));
3470 }
3471
3472 #[test]
3473 fn create_ensures_issues_dir_exists() {
3474 let tmp = tempfile::TempDir::new().unwrap();
3475 let dir = tmp.path().join("nested").join("issues");
3476 let config_path = tmp.path().join("tix.toml");
3477 let config = Config {
3478 issues_dir: dir.clone(),
3479 ..Config::default()
3480 };
3481 config.save(&config_path).unwrap();
3482 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
3483 let store = Store {
3484 issues_dir: dir,
3485 config,
3486 };
3487 let mut issue = Issue {
3488 number: 0,
3489 title: "T".into(),
3490 status: Status::default(),
3491 labels: vec![],
3492 priority: Priority::default(),
3493 assignee: None,
3494 estimate: None,
3495 commit: None,
3496 gitlab_id: None,
3497 linear_id: None,
3498 mission: None,
3499 task: None,
3500 relationships: vec![],
3501 related_issues: vec![],
3502 files: vec![],
3503 cycles: None,
3504 milestones: None,
3505 reviewer: None,
3506 start_date: None,
3507 due_date: None,
3508 created: "2026-01-01".into(),
3509 type_: None,
3510 updated: "2026-01-01".into(),
3511 filename: String::new(),
3512 body: String::new(),
3513 history: crate::model::History::new(),
3514 has_retired_links_key: false,
3515 parent: None,
3516 extra_frontmatter: std::collections::BTreeMap::new(),
3517 archived: false,
3518 entered_at: None,
3519 source: None,
3520 };
3521 store.create(&mut issue, "", None).unwrap();
3522 assert_eq!(issue.number, 1, "the first issue in a fresh store is #1");
3523 }
3524
3525 #[test]
3526 fn update_replaces_content_atomically() {
3527 let (tmp, store) = make_store();
3528 let _ = tmp;
3529 write_issue(
3530 &store,
3531 "2026-01-01-u.md",
3532 &sample_frontmatter(1, "U", "todo"),
3533 );
3534 let mut issue = store
3535 .get(IssueSelector::Filename("2026-01-01-u.md".into()))
3536 .unwrap();
3537 issue.title = "Updated".into();
3538 issue.bump_updated();
3539 store.update("2026-01-01-u.md", &issue).unwrap();
3540 let reloaded = store
3541 .get(IssueSelector::Filename("2026-01-01-u.md".into()))
3542 .unwrap();
3543 assert_eq!(reloaded.title, "Updated");
3544 }
3545
3546 #[test]
3547 fn update_not_found() {
3548 let (tmp, store) = make_store();
3549 let _ = tmp;
3550 let issue = Issue {
3551 number: 1,
3552 title: "T".into(),
3553 status: Status::default(),
3554 labels: vec![],
3555 priority: Priority::default(),
3556 assignee: None,
3557 estimate: None,
3558 commit: None,
3559 gitlab_id: None,
3560 linear_id: None,
3561 mission: None,
3562 task: None,
3563 relationships: vec![],
3564 related_issues: vec![],
3565 files: vec![],
3566 cycles: None,
3567 milestones: None,
3568 reviewer: None,
3569 start_date: None,
3570 due_date: None,
3571 created: "2026-01-01".into(),
3572 type_: None,
3573 updated: "2026-01-01".into(),
3574 filename: String::new(),
3575 body: String::new(),
3576 history: crate::model::History::new(),
3577 has_retired_links_key: false,
3578 parent: None,
3579 extra_frontmatter: std::collections::BTreeMap::new(),
3580 archived: false,
3581 entered_at: None,
3582 source: None,
3583 };
3584 assert!(store.update("nope.md", &issue).is_err());
3585 }
3586
3587 #[test]
3588 fn delete_removes_file() {
3589 let (tmp, store) = make_store();
3590 let _ = tmp;
3591 write_issue(
3592 &store,
3593 "2026-01-01-d.md",
3594 &sample_frontmatter(1, "D", "todo"),
3595 );
3596 assert!(store.issues_dir.join("2026-01-01-d.md").exists());
3597 store.delete("2026-01-01-d.md").unwrap();
3598 assert!(!store.issues_dir.join("2026-01-01-d.md").exists());
3599 }
3600
3601 #[test]
3602 fn delete_not_found() {
3603 let (tmp, store) = make_store();
3604 let _ = tmp;
3605 assert!(store.delete("nope.md").is_err());
3606 }
3607
3608 #[test]
3609 fn stray_tmp_file_not_treated_as_issue() {
3610 let (tmp, store) = make_store();
3611 let _ = tmp;
3612 write_issue(
3613 &store,
3614 "2026-01-01-a.md",
3615 &sample_frontmatter(1, "A", "todo"),
3616 );
3617 fs::write(store.issues_dir.join(".tix-tmp-9999-0"), "orphaned data").unwrap();
3618 let issues = store.list().unwrap();
3619 assert_eq!(issues.len(), 1);
3620 }
3621
3622 fn seam_store() -> (tempfile::TempDir, Store) {
3639 let tmp = tempfile::TempDir::new().unwrap();
3640 let dir = tmp.path().join("issues");
3641 fs::create_dir_all(&dir).unwrap();
3642 let config_path = tmp.path().join("tix.toml");
3643 let mut config = Config {
3644 issues_dir: dir.clone(),
3645 kanban: crate::config::KanbanSection {
3646 configurations: indexmap::IndexMap::from([(
3647 "default".to_string(),
3648 crate::config::BoardConfiguration {
3649 columns: vec![
3650 "triage".into(),
3651 "todo".into(),
3652 "doing".into(),
3653 "done".into(),
3654 ],
3655 done_column: Some("done".to_string()),
3656 category_map: indexmap::IndexMap::from([
3657 ("triage".to_string(), "backlog".to_string()),
3658 ("todo".to_string(), "unstarted".to_string()),
3659 ("doing".to_string(), "active".to_string()),
3660 ("done".to_string(), "completed".to_string()),
3661 ]),
3662 ..Default::default()
3663 },
3664 )]),
3665 },
3666 ..Config::default()
3667 };
3668 config.layout.move_on_change = false;
3669 config.save(&config_path).unwrap();
3670 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
3671 (
3672 tmp,
3673 Store {
3674 issues_dir: dir,
3675 config,
3676 },
3677 )
3678 }
3679
3680 fn seam_issue(status: &str) -> (tempfile::TempDir, Store, String) {
3688 let (tmp, store) = seam_store();
3689 write_issue(
3690 &store,
3691 "2026-01-01-seam.md",
3692 &format!(
3693 "---\nnumber: 1\ntitle: \"Seam\"\nstatus: {status}\nlabels: []\n\
3694 priority: \"medium\"\nfiles: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody."
3695 ),
3696 );
3697 let before = read_issue_file(&store, "2026-01-01-seam.md");
3698 (tmp, store, before)
3699 }
3700
3701 fn assert_untouched(store: &Store, before: &str, what: &str) {
3703 let after = read_issue_file(store, "2026-01-01-seam.md");
3704 assert_eq!(after, before, "{what}: the file must be byte-identical");
3705 }
3706
3707 #[test]
3708 fn t015_accepted_transition_writes_status_bumps_updated_and_appends_one_decision() {
3709 let (_tmp, store, _before) = seam_issue("todo");
3710 let outcome = store
3711 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
3712 .unwrap();
3713 assert!(outcome.written, "an accepted, changing move writes");
3714 let decision = outcome
3715 .decision
3716 .clone()
3717 .expect("an accepted transition appends exactly one decision");
3718 assert_eq!(decision.from, "todo");
3719 assert_eq!(decision.to, "doing");
3720 assert_eq!(decision.from_category, "unstarted");
3721 assert_eq!(decision.to_category, "active");
3722 assert!(
3723 decision.override_reason.is_none(),
3724 "no override was supplied"
3725 );
3726 assert_eq!(
3727 outcome.issue.status.display(),
3728 "doing",
3729 "Issue.status holds the COLUMN name, never a category (C-001)"
3730 );
3731 assert_eq!(outcome.issue.updated, today(), "`updated` is bumped");
3732
3733 let reloaded = store
3735 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3736 .unwrap();
3737 assert_eq!(reloaded.status.display(), "doing");
3738 let events = reloaded.history.events();
3739 assert_eq!(
3740 events.len(),
3741 1,
3742 "exactly one History entry, and it is the decision: {events:?}"
3743 );
3744 assert_eq!(events[0].kind, HistoryEventKind::TransitionAccepted);
3745 assert_eq!(
3746 crate::model::TransitionDecision::from_event(events[0])
3747 .unwrap()
3748 .to,
3749 "doing",
3750 "and the decision round-trips out of the file with its fields intact"
3751 );
3752 }
3753
3754 #[test]
3755 fn t015_unknown_column_is_refused_by_name_and_writes_nothing() {
3756 let (_tmp, store, before) = seam_issue("todo");
3757 let err = store
3758 .set_status("2026-01-01-seam.md", StatusChange::to("nope"))
3759 .unwrap_err();
3760 assert!(
3761 matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "unknown_column"),
3762 "the shared named diagnostic, contract §6: {err:?}"
3763 );
3764 assert!(
3765 err.to_string()
3766 .contains("valid columns: triage, todo, doing, done"),
3767 "in BOARD order, not hash order: {err}"
3768 );
3769 assert_untouched(&store, &before, "unknown column");
3770 }
3771
3772 #[test]
3773 fn t015_terminal_exit_is_refused_and_writes_nothing() {
3774 let (_tmp, store, before) = seam_issue("done");
3775 let err = store
3776 .set_status("2026-01-01-seam.md", StatusChange::to("todo"))
3777 .unwrap_err();
3778 assert!(
3779 matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "invalid_transition"),
3780 "{err:?}"
3781 );
3782 assert_untouched(&store, &before, "terminal exit without a reason");
3783 }
3784
3785 #[test]
3786 fn t015_adjacency_restricted_backwards_move_is_refused_and_writes_nothing() {
3787 let (_tmp, store, before) = seam_issue("doing");
3791 let err = store
3792 .set_status("2026-01-01-seam.md", StatusChange::to("triage"))
3793 .unwrap_err();
3794 assert!(
3795 matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "invalid_transition"),
3796 "{err:?}"
3797 );
3798 assert!(
3799 err.to_string().contains("category policy"),
3800 "the message names the rule that refused it: {err}"
3801 );
3802 assert_untouched(&store, &before, "adjacency refusal");
3803 }
3804
3805 #[test]
3806 fn t015_terminal_exit_with_a_reason_is_accepted_and_the_reason_is_recorded() {
3807 let (_tmp, store, before) = seam_issue("done");
3808 let outcome = store
3809 .set_status(
3810 "2026-01-01-seam.md",
3811 StatusChange::to("todo").with_override(Some("reopening: wrong epic")),
3812 )
3813 .unwrap();
3814 assert!(outcome.written);
3815 let decision = outcome.decision.expect("one decision");
3816 assert_eq!(
3817 decision.override_reason.as_deref(),
3818 Some("reopening: wrong epic"),
3819 "an override with no recorded reason is not an audit record"
3820 );
3821 assert_ne!(
3822 read_issue_file(&store, "2026-01-01-seam.md"),
3823 before,
3824 "sanity: an accepted move must have changed the file at all"
3825 );
3826 }
3827
3828 #[test]
3829 fn t015_a_blank_reason_is_not_an_override() {
3830 let (_tmp, store, before) = seam_issue("done");
3835 let err = store
3836 .set_status(
3837 "2026-01-01-seam.md",
3838 StatusChange::to("todo").with_override(Some(" ")),
3839 )
3840 .unwrap_err();
3841 assert!(
3842 matches!(&err, TixError::Workflow(_)),
3843 "a blank reason is not a reason: {err:?}"
3844 );
3845 assert_untouched(&store, &before, "blank override reason");
3846 }
3847
3848 #[test]
3849 fn t015_same_column_is_accepted_and_writes_nothing() {
3850 let (_tmp, store, before) = seam_issue("doing");
3851 let outcome = store
3852 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
3853 .unwrap();
3854 assert!(!outcome.written, "a same-column re-apply is a no-op");
3855 assert!(
3856 outcome.decision.is_none(),
3857 "and appends no decision, contract §3.2"
3858 );
3859 assert_untouched(&store, &before, "same column");
3860 let reloaded = store
3863 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3864 .unwrap();
3865 assert_eq!(
3866 reloaded.updated, "2026-01-01",
3867 "the fixture's original date"
3868 );
3869 }
3870
3871 #[test]
3872 fn t015_no_new_frontmatter_key_was_introduced() {
3873 let (_tmp, store, before) = seam_issue("todo");
3877 store
3878 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
3879 .unwrap();
3880 let after = read_issue_file(&store, "2026-01-01-seam.md");
3881 let keys = |text: &str| -> Vec<String> {
3882 text.split("---\n")
3883 .nth(1)
3884 .unwrap()
3885 .lines()
3886 .filter_map(|l| l.split_once(':').map(|(k, _)| k.trim().to_string()))
3887 .filter(|k| !k.is_empty())
3888 .collect()
3889 };
3890 assert_eq!(
3891 keys(&after),
3892 keys(&before),
3893 "the seam must not add or remove a top-level frontmatter key"
3894 );
3895 }
3896
3897 #[test]
3898 fn t015_a_hand_edited_inconsistent_file_is_read_and_not_repaired() {
3899 let (_tmp, store, before) = seam_issue("doing");
3904 let edited = before.replace("status: doing", "status: triage");
3907 write_issue(&store, "2026-01-01-seam.md", &edited);
3908 let after_edit = read_issue_file(&store, "2026-01-01-seam.md");
3909
3910 let issue = store
3911 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3912 .unwrap();
3913 assert_eq!(
3914 issue.status.display(),
3915 "triage",
3916 "a hand-edited status is read, not rejected at load"
3917 );
3918 assert_eq!(
3919 read_issue_file(&store, "2026-01-01-seam.md"),
3920 after_edit,
3921 "reading must not repair it"
3922 );
3923 store
3926 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
3927 .unwrap();
3928 let moved = store
3929 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3930 .unwrap();
3931 assert_eq!(moved.status.display(), "doing");
3932 assert_eq!(
3933 moved.history.events().len(),
3934 1,
3935 "only the new decision; nothing was written to explain the old one"
3936 );
3937 }
3938
3939 #[test]
3940 fn t015_every_rejection_kind_is_one_of_the_contract_vocabulary() {
3941 let allowed = [
3945 "missing_completion_category",
3946 "unknown_category",
3947 "mapping_for_unknown_column",
3948 "unmapped_column_default",
3949 "invalid_transition",
3950 "unknown_column",
3951 "direct_edit_policy_violation",
3952 ];
3953 let cases: Vec<(&str, &str)> = vec![
3954 ("todo", "nope"), ("done", "todo"), ("doing", "triage"), ];
3958 for (from, to) in cases {
3959 let (_tmp, store, _before) = seam_issue(from);
3960 let err = store
3961 .set_status("2026-01-01-seam.md", StatusChange::to(to))
3962 .unwrap_err();
3963 let TixError::Workflow(d) = &err else {
3964 panic!("{from} -> {to} must refuse through the workflow variant: {err:?}");
3965 };
3966 assert!(
3967 allowed.contains(&d.kind.as_str()),
3968 "`{}` is not in the contract's vocabulary",
3969 d.kind.as_str()
3970 );
3971 }
3972 }
3973
3974 #[test]
3975 fn t015_a_legacy_board_gains_no_policy_but_keeps_completion() {
3976 let (tmp, store) = make_store();
3981 let _ = tmp;
3982 write_issue(
3983 &store,
3984 "2026-01-01-legacy.md",
3985 &sample_frontmatter(1, "Legacy", "done"),
3986 );
3987 let moved = store
3988 .set_status("2026-01-01-legacy.md", StatusChange::to("todo"))
3989 .unwrap();
3990 assert!(moved.written, "a legacy board imposes no policy");
3991 assert_eq!(moved.issue.status.display(), "todo");
3992
3993 let resolution =
3996 crate::workflow::resolution(&store.config, store.config.default_config(), "default");
3997 assert!(
3998 !resolution.explicit_policy,
3999 "precondition: this board declared no category_map"
4000 );
4001 assert!(
4002 resolution.has_completion(),
4003 "but it still has a done column"
4004 );
4005 assert!(
4006 !crate::workflow::is_completed(&moved.issue, &resolution),
4007 "and `todo` is not complete"
4008 );
4009 }
4010
4011 #[test]
4012 fn t015_a_mapped_board_keeps_the_commit_coupling() {
4013 let (_tmp, store, _before) = seam_issue("doing");
4017 store
4018 .set_status(
4019 "2026-01-01-seam.md",
4020 StatusChange::to("done").with_commit(Some("abc123")),
4021 )
4022 .unwrap();
4023 let at_done = store
4024 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4025 .unwrap();
4026 assert_eq!(at_done.commit.as_deref(), Some("abc123"));
4027
4028 store
4029 .set_status(
4030 "2026-01-01-seam.md",
4031 StatusChange::to("todo").with_override(Some("not actually finished")),
4032 )
4033 .unwrap();
4034 let off_done = store
4035 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4036 .unwrap();
4037 assert_eq!(
4038 off_done.commit, None,
4039 "leaving completion clears the recorded commit"
4040 );
4041 }
4042
4043 #[test]
4044 fn t015_a_decision_round_trips_through_a_file_unchanged() {
4045 let (_tmp, store, _before) = seam_issue("todo");
4049 store
4050 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
4051 .unwrap();
4052 let first = store
4053 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4054 .unwrap();
4055 let text = read_issue_file(&store, "2026-01-01-seam.md");
4056 let parsed = Issue::parse(&text).unwrap();
4058 let events = parsed.history.events();
4059 let decision = crate::model::TransitionDecision::from_event(events[0]).unwrap();
4060 assert_eq!(decision.to, "doing");
4061 assert_eq!(decision.from_category, "unstarted");
4062 assert_eq!(decision.to_category, "active");
4063 assert_eq!(
4066 parsed.to_file_contents(),
4067 text,
4068 "a parsed decision must re-render to the same bytes"
4069 );
4070 assert_eq!(parsed.history.events().len(), first.history.events().len());
4071 }
4072
4073 #[test]
4074 fn t015_two_transitions_append_two_decisions_in_order() {
4075 let (_tmp, store, _before) = seam_issue("triage");
4076 for to in ["todo", "doing"] {
4077 store
4078 .set_status("2026-01-01-seam.md", StatusChange::to(to))
4079 .unwrap();
4080 }
4081 let after = store
4082 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4083 .unwrap();
4084 let events = after.history.events();
4085 assert_eq!(events.len(), 2, "one per accepted transition");
4086 let tos: Vec<String> = events
4087 .iter()
4088 .map(|e| crate::model::TransitionDecision::from_event(e).unwrap().to)
4089 .collect();
4090 assert_eq!(tos, vec!["todo", "doing"], "append order, not sorted");
4091 }
4092
4093 #[test]
4094 fn set_status_changes_status_and_bumps_updated() {
4095 let (tmp, store) = make_store();
4096 let _ = tmp;
4097 write_issue(
4098 &store,
4099 "2026-01-01-s.md",
4100 &sample_frontmatter(1, "S", "todo"),
4101 );
4102 let outcome = store
4103 .set_status("2026-01-01-s.md", StatusChange::to("in progress"))
4104 .unwrap();
4105 let issue = &outcome.issue;
4106 assert_eq!(issue.status.display(), "in progress");
4107 assert_eq!(issue.updated, today());
4108 assert_eq!(issue.title, "S");
4109 assert_eq!(issue.body, "Body text.");
4110 }
4111
4112 #[test]
4113 fn set_status_preserves_file_path() {
4114 let (tmp, store) = make_store();
4115 let _ = tmp;
4116 write_issue(
4117 &store,
4118 "2026-01-01-f.md",
4119 &sample_frontmatter(1, "F", "todo"),
4120 );
4121 let _ = store
4122 .set_status("2026-01-01-f.md", StatusChange::to("done"))
4123 .unwrap();
4124 assert!(store.issues_dir.join("2026-01-01-f.md").exists());
4125 }
4126
4127 fn read_issue_file(store: &Store, filename: &str) -> String {
4133 std::fs::read_to_string(store.issues_dir.join(filename)).unwrap()
4134 }
4135
4136 fn bare_frontmatter(number: u32, title: &str) -> String {
4139 format!(
4140 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
4141 )
4142 }
4143
4144 fn legacy_links_frontmatter(number: u32, title: &str, links: &str) -> String {
4146 format!(
4147 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\nlinks: {links}\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
4148 )
4149 }
4150
4151 fn related_pair() -> (TempDir, Store, Issue, Issue) {
4153 let (tmp, store) = make_store();
4154 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
4155 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
4156 let a = store.get(IssueSelector::Number(1)).unwrap();
4157 let b = store.get(IssueSelector::Number(2)).unwrap();
4158 (tmp, store, a, b)
4159 }
4160
4161 #[test]
4162 fn t009_link_records_a_single_directed_edge() {
4163 let (_tmp, store, a, b) = related_pair();
4164 let outcome = store
4165 .link(
4166 &a.number.to_string(),
4167 &b.number.to_string(),
4168 RelationKind::Blocks,
4169 )
4170 .unwrap();
4171 assert!(outcome.changed);
4172
4173 let a_after = store.get(IssueSelector::Number(1)).unwrap();
4176 let b_after = store.get(IssueSelector::Number(2)).unwrap();
4177 assert_eq!(a_after.relationships.len(), 1);
4178 assert!(
4179 b_after.relationships.is_empty(),
4180 "no back-reference is written for a directed edge"
4181 );
4182 assert_eq!(a_after.relationships[0].kind, RelationKind::Blocks);
4183 assert_eq!(
4184 (a_after.relationships[0].from, a_after.relationships[0].to),
4185 (1, 2)
4186 );
4187 }
4188
4189 #[test]
4190 fn t009_link_appends_exactly_one_history_event() {
4191 let (_tmp, store, a, b) = related_pair();
4192 store
4193 .link(
4194 &a.number.to_string(),
4195 &b.number.to_string(),
4196 RelationKind::Blocks,
4197 )
4198 .unwrap();
4199 let a_after = store.get(IssueSelector::Number(1)).unwrap();
4200 assert_eq!(a_after.history.len(), 1);
4201 assert_eq!(
4202 a_after.history.events()[0].kind,
4203 HistoryEventKind::RelationshipAdded
4204 );
4205 assert!(!a_after.history.events()[0].actor.is_empty());
4207 }
4208
4209 #[test]
4210 fn t009_idempotent_reapply_writes_nothing_and_appends_no_second_event() {
4211 let (_tmp, store, a, b) = related_pair();
4212 let args = (a.number.to_string(), b.number.to_string());
4213 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4214 let first = store.get(IssueSelector::Number(1)).unwrap();
4215 let bytes_after_first = read_issue_file(&store, &first.filename);
4216
4217 let second = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4218 assert!(!second.changed, "re-applying an existing edge is a no-op");
4219 assert!(second.written.is_none(), "no file is written for a no-op");
4220
4221 let after = store.get(IssueSelector::Number(1)).unwrap();
4222 assert_eq!(after.relationships.len(), 1, "no duplicate edge");
4223 assert_eq!(after.history.len(), 1, "no second History event");
4224 assert_eq!(
4225 read_issue_file(&store, &after.filename),
4226 bytes_after_first,
4227 "the file is byte-identical after the no-op"
4228 );
4229 }
4230
4231 #[test]
4232 fn t009_inverse_spelling_resolves_to_the_same_stored_edge() {
4233 let (_tmp, store, a, b) = related_pair();
4234 let args = (a.number.to_string(), b.number.to_string());
4235 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4236 let again = store
4238 .link(&args.1, &args.0, RelationKind::BlockedBy)
4239 .unwrap();
4240 assert!(!again.changed, "the inverse spelling must not add an edge");
4241 let after = store.get(IssueSelector::Number(1)).unwrap();
4242 assert_eq!(after.relationships.len(), 1);
4243 assert_eq!(
4244 after.relationships[0].kind,
4245 RelationKind::Blocks,
4246 "the stored kind is always the canonical one"
4247 );
4248 }
4249
4250 #[test]
4251 fn t009_self_link_is_refused_and_writes_nothing() {
4252 let (_tmp, store, a, _b) = related_pair();
4253 let before = read_issue_file(&store, &a.filename);
4254 let err = store
4255 .link(
4256 &a.number.to_string(),
4257 &a.number.to_string(),
4258 RelationKind::Blocks,
4259 )
4260 .unwrap_err();
4261 assert!(matches!(err, TixError::InvalidInput(_)));
4262 assert!(
4263 err.to_string().contains("self_link"),
4264 "the diagnostic names the rule: {err}"
4265 );
4266 assert_eq!(
4267 read_issue_file(&store, &a.filename),
4268 before,
4269 "SC-005: a rejected mutation leaves the file byte-identical"
4270 );
4271 }
4272
4273 #[test]
4274 fn t009_cycle_closing_edge_is_refused_and_writes_nothing() {
4275 let (tmp, store) = make_store();
4276 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
4277 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
4278 write_issue(&store, "2026-01-01-c.md", &bare_frontmatter(3, "C"));
4279 let _ = tmp;
4280 store.link("1", "2", RelationKind::Blocks).unwrap();
4281 store.link("2", "3", RelationKind::Blocks).unwrap();
4282 let c_before = read_issue_file(&store, "2026-01-01-c.md");
4283
4284 let err = store.link("3", "1", RelationKind::Blocks).unwrap_err();
4285 assert!(err.to_string().contains("cycle"), "{err}");
4286 assert_eq!(
4287 read_issue_file(&store, "2026-01-01-c.md"),
4288 c_before,
4289 "SC-005: both files of a rejected two-issue mutation are unchanged"
4290 );
4291 }
4292
4293 #[test]
4294 fn t009_unlink_removes_the_edge_and_is_idempotent() {
4295 let (_tmp, store, a, b) = related_pair();
4296 let args = (a.number.to_string(), b.number.to_string());
4297 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4298
4299 let removed = store
4300 .unlink(&args.0, &args.1, RelationKind::Blocks)
4301 .unwrap();
4302 assert!(removed.changed);
4303 let after = store.get(IssueSelector::Number(1)).unwrap();
4304 assert!(after.relationships.is_empty());
4305 assert_eq!(after.history.len(), 2, "added then removed");
4306
4307 let again = store
4309 .unlink(&args.0, &args.1, RelationKind::Blocks)
4310 .unwrap();
4311 assert!(!again.changed);
4312 let after2 = store.get(IssueSelector::Number(1)).unwrap();
4313 assert_eq!(after2.history.len(), 2, "no third History event");
4314 }
4315
4316 #[test]
4317 fn t009_unlink_of_a_never_linked_pair_is_a_noop() {
4318 let (_tmp, store, a, b) = related_pair();
4319 let outcome = store
4320 .unlink(
4321 &a.number.to_string(),
4322 &b.number.to_string(),
4323 RelationKind::Blocks,
4324 )
4325 .unwrap();
4326 assert!(!outcome.changed);
4327 assert!(outcome.written.is_none());
4328 let after = store.get(IssueSelector::Number(1)).unwrap();
4329 assert!(after.relationships.is_empty());
4330 assert!(after.history.is_empty(), "a no-op appends nothing");
4331 }
4332
4333 #[test]
4334 fn t009_set_relationships_replaces_the_set_and_keeps_surviving_ids() {
4335 let (_tmp, store, _a, _b) = related_pair();
4336 store.link("1", "2", RelationKind::Blocks).unwrap();
4337 let before = store.get(IssueSelector::Number(1)).unwrap();
4338 let survivor_id = before.relationships[0].id.clone();
4339
4340 let updated = store
4342 .set_relationships(&before.filename, &[(2, RelationKind::Blocks)])
4343 .unwrap();
4344 assert_eq!(updated.relationships.len(), 1);
4345 assert_eq!(
4346 updated.relationships[0].id, survivor_id,
4347 "a surviving relationship keeps its stable id"
4348 );
4349 }
4350
4351 #[test]
4352 fn t009_set_relationships_rejects_unknown_target_and_self() {
4353 let (_tmp, store, a, _b) = related_pair();
4354 assert!(
4355 store
4356 .set_relationships(&a.filename, &[(404, RelationKind::Blocks)])
4357 .is_err(),
4358 "an unknown target is refused"
4359 );
4360 assert!(
4361 store
4362 .set_relationships(&a.filename, &[(1, RelationKind::Blocks)])
4363 .is_err(),
4364 "a self-link is refused"
4365 );
4366 }
4367
4368 fn seeded(store: &Store, number: u32, title: &str, extra: &str) {
4374 let slug = title.to_lowercase();
4375 write_issue(
4376 store,
4377 &format!("2026-01-01-{slug}.md"),
4378 &format!(
4379 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\n{extra}files: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n"
4380 ),
4381 );
4382 }
4383
4384 #[test]
4385 fn f1_set_relationships_refuses_a_reciprocal_pair_the_service_rejects() {
4386 let (_tmp, store) = make_store();
4393 seeded(&store, 1, "A", "");
4394 seeded(&store, 2, "B", "");
4395 store.link("1", "2", RelationKind::Blocks).unwrap();
4396
4397 let err = store
4398 .set_relationships("2026-01-01-b.md", &[(1, RelationKind::Blocks)])
4399 .unwrap_err();
4400 assert!(
4401 err.to_string().contains("cycle"),
4402 "the service's verdict is what refuses it: {err}"
4403 );
4404 let two = store.get(IssueSelector::Number(2)).unwrap();
4405 assert!(
4406 two.relationships.is_empty(),
4407 "SC-005: a refused mutation writes nothing"
4408 );
4409 }
4410
4411 #[test]
4412 fn f4_a_hand_ordered_related_row_is_removable() {
4413 let (_tmp, store) = make_store();
4418 seeded(&store, 2, "B", "");
4419 seeded(
4420 &store,
4421 5,
4422 "E",
4423 "relationships:\n- id: rel_handauthored\n from: 5\n to: 2\n kind: related\n display: \"related #2 B\"\n",
4424 );
4425 let out = store.unlink("5", "2", RelationKind::Related).unwrap();
4426 assert!(out.changed, "the row is found and removed");
4427 assert!(
4428 store
4429 .get(IssueSelector::Number(5))
4430 .unwrap()
4431 .relationships
4432 .is_empty()
4433 );
4434 }
4435
4436 #[test]
4437 fn f5_a_persisted_blocked_by_row_is_removable() {
4438 let (_tmp, store) = make_store();
4445 seeded(&store, 1, "A", "");
4446 seeded(
4447 &store,
4448 2,
4449 "B",
4450 "relationships:\n- id: rel_bb\n from: 2\n to: 1\n kind: blocked_by\n display: \"blocks #1 A\"\n",
4451 );
4452 let out = store.unlink("2", "1", RelationKind::BlockedBy).unwrap();
4453 assert!(out.changed, "the row is found and removed");
4454 assert!(
4455 store
4456 .get(IssueSelector::Number(2))
4457 .unwrap()
4458 .relationships
4459 .is_empty()
4460 );
4461 }
4462
4463 #[test]
4464 fn f13_a_dangling_edge_is_removable() {
4465 let (_tmp, store) = make_store();
4470 seeded(&store, 1, "A", "");
4471 seeded(&store, 2, "B", "");
4472 store.link("1", "2", RelationKind::Blocks).unwrap();
4473 std::fs::remove_file(store.issues_dir.join("2026-01-01-b.md")).unwrap();
4474
4475 let out = store.unlink("1", "2", RelationKind::Blocks).unwrap();
4476 assert!(out.changed, "a dangling edge is still removable");
4477 let one = store.get(IssueSelector::Number(1)).unwrap();
4478 assert!(one.relationships.is_empty());
4479 assert_eq!(one.history.len(), 2, "and the removal is recorded");
4480 }
4481
4482 #[test]
4483 fn m10_one_unlink_of_a_duplicated_edge_appends_one_event() {
4484 let (_tmp, store) = make_store();
4491 seeded(
4496 &store,
4497 1,
4498 "A",
4499 "relationships:\n- id: rel_dup_12\n from: 1\n to: 2\n kind: related\n display: \"related #2 B\"\n",
4500 );
4501 seeded(
4502 &store,
4503 2,
4504 "B",
4505 "relationships:\n- id: rel_dup_21\n from: 2\n to: 1\n kind: related\n display: \"related #1 A\"\n",
4506 );
4507
4508 let outcome = store.unlink("1", "2", RelationKind::Related).unwrap();
4509 assert!(outcome.changed, "both redundant rows are removed");
4510 let mut summaries = Vec::new();
4511 for number in [1u32, 2] {
4512 let issue = store.get(IssueSelector::Number(number)).unwrap();
4513 assert!(
4514 issue.relationships.is_empty(),
4515 "issue #{number} keeps no edge"
4516 );
4517 assert_eq!(
4518 issue.history.len(),
4519 1,
4520 "issue #{number} records exactly one event, got {:?}",
4521 issue.history.events()
4522 );
4523 let event = &issue.history.events()[0];
4524 assert!(
4525 event.detail.contains("removed 2 relationships"),
4526 "and the event says both spellings went: {}",
4527 event.detail
4528 );
4529 summaries.push(event.detail.clone());
4530 }
4531 assert_eq!(
4535 summaries[0], summaries[1],
4536 "both files record the same single event, not two differing ones"
4537 );
4538 }
4539
4540 #[test]
4541 fn m11_unlink_reports_the_row_it_removed_not_an_empty_placeholder() {
4542 let (_tmp, store, a, b) = related_pair();
4548 let args = (a.number.to_string(), b.number.to_string());
4549 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4550 let before = store.get(IssueSelector::Number(1)).unwrap();
4551 let real_id = before.relationships[0].id.clone();
4552
4553 let outcome = store
4554 .unlink(&args.0, &args.1, RelationKind::Blocks)
4555 .unwrap();
4556 assert!(outcome.changed);
4557 assert_eq!(
4558 outcome.relationship.id, real_id,
4559 "the reported row is the one that was actually removed"
4560 );
4561 assert!(!outcome.relationship.id.is_empty(), "so it is addressable");
4562 assert_eq!(
4563 outcome.relationship.display,
4564 before.relationships[0].display
4565 );
4566 }
4567
4568 #[test]
4569 fn m15_the_history_detail_names_the_relationship_id() {
4570 let (_tmp, store, a, b) = related_pair();
4574 let args = (a.number.to_string(), b.number.to_string());
4575 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4576 let one = store.get(IssueSelector::Number(1)).unwrap();
4577 let id = one.relationships[0].id.clone();
4578
4579 let detail = &one.history.events()[0].detail;
4580 assert!(
4581 detail.starts_with(&format!("created {id}:")),
4582 "the detail names the stable id: {detail}"
4583 );
4584 assert!(
4585 !detail.contains(&format!("created {}:", RelationKind::Blocks)),
4586 "and does not stutter the kind, which `display` already carries: {detail}"
4587 );
4588 }
4589
4590 #[test]
4591 fn f17_all_diagnostics_are_reported_not_just_the_first() {
4592 let (_tmp, store) = make_store();
4604 seeded(
4605 &store,
4606 1,
4607 "A",
4608 "relationships:\n- id: rel_b12\n from: 1\n to: 2\n kind: blocks\n display: blocks\n- id: rel_d12\n from: 1\n to: 2\n kind: duplicates\n display: dup\n",
4609 );
4610 seeded(
4611 &store,
4612 2,
4613 "B",
4614 "relationships:\n- id: rel_b21\n from: 2\n to: 1\n kind: blocks\n display: blocks\n",
4615 );
4616
4617 let issues = store.list().unwrap();
4618 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
4619 let verdict = RelationshipGraph::new(&views, "done").validate_snapshot();
4620 assert!(
4621 verdict.errors.len() >= 2,
4622 "the fixture must really produce several diagnostics, got {:?}",
4623 verdict.errors
4624 );
4625
4626 let rendered = relationship_error(&verdict.errors).to_string();
4627 for kind in ["conflicting_edge", "cycle"] {
4628 assert!(
4629 rendered.contains(kind),
4630 "every diagnostic must be reported; {kind} missing from: {rendered}"
4631 );
4632 }
4633 assert!(
4634 !rendered.contains("conflicting_edge: conflicting_edge")
4635 && !rendered.contains("cycle: cycle:"),
4636 "no doubled kind prefix: {rendered}"
4637 );
4638 let dup = vec![
4644 Diagnostic::new(
4645 DiagnosticKind::SelfLink,
4646 "to",
4647 "#1 cannot block itself: a self-link is not a valid relationship",
4648 ),
4649 Diagnostic::new(
4650 DiagnosticKind::SelfLink,
4651 "to",
4652 "#1 cannot block itself: a self-link is not a valid relationship",
4653 ),
4654 Diagnostic::new(DiagnosticKind::Cycle, "to", "cycle: 1 -> 1 -> #1"),
4655 ];
4656 let rendered = relationship_error(&dup).to_string();
4657 assert_eq!(
4658 rendered.matches("cannot block itself").count(),
4659 1,
4660 "an identical diagnostic renders once, not twice: {rendered}"
4661 );
4662 assert!(
4663 rendered.contains("cycle"),
4664 "a distinct diagnostic is still reported: {rendered}"
4665 );
4666 assert!(
4667 !rendered.contains("self_link: self_link") && !rendered.contains("cycle: cycle:"),
4668 "and no kind is prefixed twice: {rendered}"
4669 );
4670 }
4671
4672 #[test]
4673 fn t009_clearing_a_set_removes_a_related_edge_owned_by_another_file() {
4674 let (_tmp, store) = make_store();
4679 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(3, "A"));
4680 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(5, "B"));
4681
4682 store.link("5", "3", RelationKind::Related).unwrap();
4683 let three = store.get(IssueSelector::Number(3)).unwrap();
4684 let five = store.get(IssueSelector::Number(5)).unwrap();
4685 assert_eq!(
4686 three.relationships.len(),
4687 1,
4688 "the edge is stored in the lower-numbered issue's file"
4689 );
4690 assert!(
4691 five.relationships.is_empty(),
4692 "and #5's own file holds none"
4693 );
4694
4695 store.set_relationships(&five.filename, &[]).unwrap();
4696
4697 let three_after = store.get(IssueSelector::Number(3)).unwrap();
4698 let five_after = store.get(IssueSelector::Number(5)).unwrap();
4699 assert!(
4700 three_after.relationships.is_empty(),
4701 "the association is gone from the file that actually stored it"
4702 );
4703 assert!(five_after.relationships.is_empty());
4704 }
4705
4706 #[test]
4707 fn t009_clearing_a_set_does_not_remove_an_inbound_directional_edge() {
4708 let (_tmp, store) = make_store();
4713 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(3, "A"));
4714 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(5, "B"));
4715
4716 store.link("3", "5", RelationKind::Blocks).unwrap();
4717 let five = store.get(IssueSelector::Number(5)).unwrap();
4718
4719 store.set_relationships(&five.filename, &[]).unwrap();
4720
4721 let three_after = store.get(IssueSelector::Number(3)).unwrap();
4722 assert!(
4723 three_after
4724 .relationships
4725 .iter()
4726 .any(|r| r.to == 5 && r.kind == RelationKind::Blocks),
4727 "#3's declaration that it blocks #5 survives: a blocked issue \
4728 cannot unilaterally clear the blocker's graph"
4729 );
4730 }
4731
4732 fn relationship_fixture(name: &str) -> String {
4734 format!(
4735 "{}/tests/fixtures/relationships/{name}",
4736 env!("CARGO_MANIFEST_DIR")
4737 )
4738 }
4739
4740 #[test]
4741 fn t010_typed_fixture_round_trips_byte_stable_including_history() {
4742 let original =
4745 std::fs::read_to_string(relationship_fixture("typed-relationships.md")).unwrap();
4746 let issue = Issue::parse(&original).unwrap();
4747 assert_eq!(issue.relationships.len(), 2);
4748 assert_eq!(issue.history.len(), 2);
4749 let rendered = issue.to_file_contents();
4750 assert_eq!(
4751 rendered, original,
4752 "a typed fixture must not churn on a read/write round trip"
4753 );
4754 assert_eq!(
4755 Issue::parse(&rendered).unwrap().to_file_contents(),
4756 rendered
4757 );
4758 assert_eq!(issue.relationships[0].id, "rel_01JQ8Z4W2M6N7P9R3S5T7V9X1");
4760 assert!(
4761 rendered.find("## History").unwrap() < rendered.find("Prose that follows").unwrap(),
4762 "History sits between the frontmatter and the body"
4763 );
4764 }
4765
4766 #[test]
4767 fn t010_issue_with_no_relationship_data_is_untouched() {
4768 let original =
4769 std::fs::read_to_string(relationship_fixture("no-relationship-data.md")).unwrap();
4770 let issue = Issue::parse(&original).unwrap();
4771 assert!(issue.relationships.is_empty());
4772 assert!(issue.history.is_empty());
4773 assert!(!issue.has_retired_links_key);
4774 let rendered = issue.to_file_contents();
4775 assert_eq!(
4776 rendered, original,
4777 "an issue with no relationship data must not churn"
4778 );
4779 assert!(!rendered.contains("relationships:"));
4780 assert!(!rendered.contains("## History"));
4781 }
4782
4783 #[test]
4784 fn t010_an_empty_history_section_is_dropped_not_preserved() {
4785 let original = std::fs::read_to_string(relationship_fixture("history-empty.md")).unwrap();
4790 assert!(
4791 original.contains("## History"),
4792 "the fixture must actually carry the empty heading"
4793 );
4794 let issue = Issue::parse(&original).unwrap();
4795 assert!(
4796 issue.history.is_empty(),
4797 "a heading with no entries parses to an empty log"
4798 );
4799 assert!(
4800 issue
4801 .body
4802 .contains("An issue whose History section is present but empty."),
4803 "the prose after the heading is body, not a dropped event: {:?}",
4804 issue.body
4805 );
4806 let rendered = issue.to_file_contents();
4807 assert!(
4808 !rendered.contains("## History"),
4809 "the empty section is omitted rather than kept as a bare heading"
4810 );
4811 assert!(rendered.contains("An issue whose History section is present but empty."));
4812 }
4813
4814 #[test]
4815 fn t010_issue_with_no_history_section_is_untouched() {
4816 let original = std::fs::read_to_string(relationship_fixture("history-absent.md")).unwrap();
4817 let issue = Issue::parse(&original).unwrap();
4818 assert!(issue.history.is_empty());
4819 let rendered = issue.to_file_contents();
4820 assert_eq!(
4821 rendered, original,
4822 "a file written before History existed must not gain an empty section"
4823 );
4824 }
4825
4826 #[test]
4827 fn f2_one_bad_history_line_does_not_make_the_store_unreadable() {
4828 let (_tmp, store) = make_store();
4837 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
4838 write_issue(
4839 &store,
4840 "2026-01-01-c.md",
4841 "---\nnumber: 3\ntitle: \"C\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\n## History\n\n- 2026-01-01T00:00:00Z - alice - relationship_added - ascii hyphens\n",
4842 );
4843
4844 let all = store.list().expect("the store must remain listable");
4845 assert_eq!(all.len(), 2, "both issues load");
4846 assert_eq!(
4847 store
4848 .get(IssueSelector::Number(1))
4849 .expect("an unrelated, valid issue must stay readable")
4850 .title,
4851 "A"
4852 );
4853 let three = store.get(IssueSelector::Number(3)).unwrap();
4859 assert!(three.history.events().is_empty(), "no event is fabricated");
4860 assert_eq!(
4861 three.history.unparsed().len(),
4862 1,
4863 "the line is preserved for diagnosis: {:?}",
4864 three.history.unparsed()
4865 );
4866 assert!(
4867 !three.body.contains("ascii hyphens"),
4868 "and is not demoted into the body: {:?}",
4869 three.body
4870 );
4871 }
4872
4873 #[cfg(unix)]
4876 fn seeded_in_segment(store: &Store, number: u32, title: &str, segment: &str) {
4877 let dir = store.issues_dir.join(segment);
4878 std::fs::create_dir_all(&dir).unwrap();
4879 std::fs::write(
4880 dir.join(format!("2026-01-01-{}.md", title.to_lowercase())),
4881 format!(
4882 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\nfiles: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n"
4883 ),
4884 )
4885 .unwrap();
4886 }
4887
4888 #[cfg(unix)]
4889 #[test]
4890 fn f6_a_mid_sequence_write_failure_rolls_every_file_back() {
4891 use std::os::unix::fs::PermissionsExt;
4892 let (tmp, store) = make_store();
4901 let _ = tmp;
4902 seeded_in_segment(&store, 1, "A", "alice");
4903 seeded_in_segment(&store, 2, "B", "bob");
4904 seeded_in_segment(&store, 3, "C", "carol");
4905 seeded_in_segment(&store, 5, "E", "erin");
4906
4907 let paths: Vec<std::path::PathBuf> = [
4908 "alice/2026-01-01-a.md",
4909 "bob/2026-01-01-b.md",
4910 "carol/2026-01-01-c.md",
4911 ]
4912 .iter()
4913 .map(|p| store.issues_dir.join(p))
4914 .collect();
4915 let before: Vec<String> = paths
4916 .iter()
4917 .map(|p| std::fs::read_to_string(p).unwrap())
4918 .collect();
4919
4920 let carol = store.issues_dir.join("carol");
4923 let mut perms = std::fs::metadata(&carol).unwrap().permissions();
4924 perms.set_mode(0o555);
4925 std::fs::set_permissions(&carol, perms).unwrap();
4926
4927 let result = store.set_relationships(
4928 "carol/2026-01-01-c.md",
4929 &[
4930 (1, RelationKind::Related),
4931 (2, RelationKind::Related),
4932 (5, RelationKind::Related),
4933 ],
4934 );
4935
4936 let mut perms = std::fs::metadata(&carol).unwrap().permissions();
4937 perms.set_mode(0o755);
4938 std::fs::set_permissions(&carol, perms).unwrap();
4939
4940 assert!(result.is_err(), "the blocked write must fail");
4941 for (path, previous) in paths.iter().zip(before.iter()) {
4942 assert_eq!(
4943 std::fs::read_to_string(path).unwrap(),
4944 *previous,
4945 "{} was left mutated by a failed mutation",
4946 path.display()
4947 );
4948 }
4949 }
4950
4951 #[cfg(unix)]
4952 #[test]
4953 fn f6b_the_updated_bump_is_itself_rollback_covered() {
4954 use std::os::unix::fs::PermissionsExt;
4964
4965 let (tmp, store) = make_store();
4966 let _ = tmp;
4967 seeded_in_segment(&store, 1, "A", "alice");
4968 seeded_in_segment(&store, 3, "C", "carol");
4969
4970 let alice = store.issues_dir.join("alice/2026-01-01-a.md");
4971 let before = std::fs::read_to_string(&alice).unwrap();
4972
4973 let carol = store.issues_dir.join("carol");
4974 let mut perms = std::fs::metadata(&carol).unwrap().permissions();
4975 perms.set_mode(0o555);
4976 std::fs::set_permissions(&carol, perms).unwrap();
4977
4978 let result =
4979 store.set_relationships("carol/2026-01-01-c.md", &[(1, RelationKind::Related)]);
4980
4981 let mut perms = std::fs::metadata(&carol).unwrap().permissions();
4982 perms.set_mode(0o755);
4983 std::fs::set_permissions(&carol, perms).unwrap();
4984
4985 assert!(result.is_err(), "the blocked bump must fail the call");
4986 assert_eq!(
4987 std::fs::read_to_string(&alice).unwrap(),
4988 before,
4989 "the relationship write that succeeded earlier must be rolled back \
4990 when the subsequent `updated` bump fails"
4991 );
4992 }
4993
4994 #[test]
4995 fn f9_relationships_for_reports_both_endpoints_of_a_one_sided_edge() {
4996 let (_tmp, store) = make_store();
5002 seeded(&store, 1, "A", "");
5003 seeded(&store, 2, "B", "");
5004 store.link("2", "1", RelationKind::Related).unwrap();
5005
5006 let one = store.get(IssueSelector::Number(1)).unwrap();
5008 let two = store.get(IssueSelector::Number(2)).unwrap();
5009 assert_eq!(one.relationships.len(), 1, "precondition: #1 stores it");
5010 assert!(
5011 two.relationships.is_empty(),
5012 "precondition: #2's own field is the partial view that caused F9"
5013 );
5014
5015 for number in [1u32, 2] {
5018 let resolved = store.relationships_for(number).unwrap();
5019 assert_eq!(
5020 resolved.len(),
5021 1,
5022 "issue #{number} must see the relationship that touches it"
5023 );
5024 assert_eq!(resolved[0].kind, RelationKind::Related);
5025 }
5026 }
5027
5028 #[test]
5029 fn f7_writing_an_issue_with_the_retired_key_discloses_that_it_is_removed() {
5030 let (tmp, store) = make_store();
5035 let _ = tmp;
5036 let _ = take_disclosures();
5037 write_issue(
5042 &store,
5043 "2026-01-01-legacy.md",
5044 &legacy_links_frontmatter(1, "Legacy", "['7']"),
5045 );
5046 let issue = store.get(IssueSelector::Number(1)).unwrap();
5047 assert!(
5048 issue.has_retired_links_key,
5049 "precondition: the key is present"
5050 );
5051
5052 store.update(&issue.filename, &issue).unwrap();
5053
5054 let disclosures = take_disclosures();
5056 assert_eq!(
5057 disclosures.len(),
5058 1,
5059 "the write must disclose that it dropped the key: {disclosures:?}"
5060 );
5061 let line = &disclosures[0];
5062 assert!(
5063 line.contains("`links:`"),
5064 "it names what was removed: {line}"
5065 );
5066 assert!(
5070 line.starts_with("remove `links:` by hand"),
5071 "and leads with what to do about it: {line}"
5072 );
5073 assert!(
5074 line.contains(&issue.filename),
5075 "and which file it was removed from: {line}"
5076 );
5077 assert!(
5078 line.contains("C-001"),
5079 "and that no migration was applied, so the user is not left \
5080 wondering where their data went: {line}"
5081 );
5082
5083 let after = read_issue_file(&store, &issue.filename);
5085 assert!(
5086 !after.contains("links:"),
5087 "the key is not re-emitted (C-001: no migration)"
5088 );
5089 let reread = store.get(IssueSelector::Number(1)).unwrap();
5092 assert!(!reread.has_retired_links_key);
5093 assert!(
5094 take_disclosures().is_empty(),
5095 "and it is disclosed once, not on every read"
5096 );
5097 }
5098
5099 #[test]
5100 fn f7b_writing_an_issue_without_the_retired_key_is_silent() {
5101 let (tmp, store) = make_store();
5104 let _ = tmp;
5105 let _ = take_disclosures();
5106 write_issue(&store, "2026-01-01-plain.md", &bare_frontmatter(1, "Plain"));
5107 let issue = store.get(IssueSelector::Number(1)).unwrap();
5108 assert!(!issue.has_retired_links_key);
5109 store.update(&issue.filename, &issue).unwrap();
5110 assert!(
5111 take_disclosures().is_empty(),
5112 "an ordinary write discloses nothing"
5113 );
5114 }
5115
5116 #[test]
5117 fn t010_legacy_fixture_reports_the_retired_key_and_yields_no_edges() {
5118 let original =
5121 std::fs::read_to_string(relationship_fixture("legacy-links-key.md")).unwrap();
5122 let issue = Issue::parse(&original).unwrap();
5123 assert!(issue.has_retired_links_key, "the retired key is reported");
5124 assert!(
5125 issue.relationships.is_empty(),
5126 "C-001: legacy values are never reinterpreted as typed edges"
5127 );
5128 assert!(
5129 issue.history.is_empty(),
5130 "a legacy file gains no History section from parsing"
5131 );
5132 let (_tmp, store) = make_store();
5136 write_issue(&store, "2026-01-01-legacy.md", &original);
5137 let findings = crate::commands::lint::collect(&store.config, &store).unwrap();
5138 let retired: Vec<_> = findings
5139 .iter()
5140 .filter(|f| f.kind == "retired_links_key")
5141 .collect();
5142 assert_eq!(
5143 retired.len(),
5144 1,
5145 "one finding for the retired key: {findings:?}"
5146 );
5147 assert_eq!(retired[0].value, "links", "the finding names the field");
5148 assert!(
5149 retired[0].message.contains("retired"),
5150 "the message says why: {}",
5151 retired[0].message
5152 );
5153 assert!(
5154 retired[0].line > 0,
5155 "the finding points into the frontmatter"
5156 );
5157 let rewritten = issue.to_file_contents();
5159 assert!(!rewritten.contains("kind: related"));
5160 assert!(!rewritten.contains("kind: blocks"));
5161 assert!(!rewritten.contains("links:"));
5162 }
5163
5164 #[test]
5165 fn c001_a_legacy_links_key_is_reported_and_yields_no_typed_edges() {
5166 let (tmp, store) = make_store();
5167 write_issue(
5168 &store,
5169 "2026-01-01-legacy.md",
5170 &legacy_links_frontmatter(1, "Legacy", "[\"12\", \"47\"]"),
5171 );
5172 let _ = tmp;
5173 let issue = store.get(IssueSelector::Number(1)).unwrap();
5174 assert!(
5175 issue.has_retired_links_key,
5176 "the retired key is recorded as a validation result"
5177 );
5178 assert!(
5179 issue.relationships.is_empty(),
5180 "C-001: legacy values are never read as typed edges"
5181 );
5182 }
5183
5184 #[test]
5185 fn c001_there_is_no_migration_path_from_the_retired_key() {
5186 let (tmp, store) = make_store();
5195 let _ = tmp;
5196 write_issue(
5197 &store,
5198 "2026-01-01-legacy.md",
5199 &legacy_links_frontmatter(1, "Legacy", "[\"12\", \"47\"]"),
5200 );
5201 let issue = store.get(IssueSelector::Number(1)).unwrap();
5202 assert!(
5203 issue.has_retired_links_key,
5204 "precondition: the key is present"
5205 );
5206 assert!(
5207 issue.relationships.is_empty(),
5208 "C-001: legacy values are never read as typed edges"
5209 );
5210
5211 store.update(&issue.filename, &issue).unwrap();
5212 let written = read_issue_file(&store, &issue.filename);
5213
5214 let reparsed = store.get(IssueSelector::Number(1)).unwrap();
5223 assert!(
5224 reparsed.relationships.is_empty(),
5225 "the file on disk parses to zero typed edges, not just the in-memory copy"
5226 );
5227 for kind in ["blocks", "blocked_by", "duplicates", "related"] {
5228 assert!(
5229 !written.contains(&format!("kind: {kind}")),
5230 "a legacy link became a `{kind}` edge:\n{written}"
5231 );
5232 }
5233 assert!(
5234 !written.contains("kind:"),
5235 "no typed edge was written at all"
5236 );
5237 assert!(!written.contains("links:"), "the key is not re-emitted");
5240 let reread = store.get(IssueSelector::Number(1)).unwrap();
5241 assert!(
5242 reread.history.is_empty(),
5243 "a format change is not a relationship mutation, so it records no event"
5244 );
5245 }
5246
5247 #[test]
5248 fn f15_the_idempotent_no_op_reports_the_real_addressable_row() {
5249 let (_tmp, store, a, b) = related_pair();
5255 let args = (a.number.to_string(), b.number.to_string());
5256 let first = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5257 let second = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5258
5259 assert!(first.changed);
5260 assert!(!second.changed, "the second apply is a no-op");
5261 assert_eq!(
5262 second.relationship.id, first.relationship.id,
5263 "the no-op reports the real, addressable row"
5264 );
5265 assert!(
5266 !second.relationship.id.is_empty(),
5267 "an empty id is not addressable"
5268 );
5269 assert_eq!(second.relationship.display, first.relationship.display);
5270 }
5271
5272 #[test]
5273 fn f16_clearing_a_set_bumps_updated_on_the_named_issue() {
5274 let (_tmp, store, _a, _b) = related_pair();
5279 store.link("2", "1", RelationKind::Related).unwrap();
5280 let before = store.get(IssueSelector::Number(2)).unwrap();
5281 let updated_before = before.updated.clone();
5282
5283 store.set_relationships(&before.filename, &[]).unwrap();
5284
5285 let after = store.get(IssueSelector::Number(2)).unwrap();
5286 assert_ne!(
5287 after.updated, updated_before,
5288 "the named issue's relationship set changed, so `updated` must move"
5289 );
5290 }
5291
5292 #[test]
5293 fn c002_an_issue_with_no_relationship_data_round_trips_unchanged() {
5294 let (tmp, store) = make_store();
5295 let _ = tmp;
5296 write_issue(&store, "2026-01-01-plain.md", &bare_frontmatter(1, "Plain"));
5301 let issue = store.get(IssueSelector::Number(1)).unwrap();
5302 let first = issue.to_file_contents();
5303 assert!(
5304 !first.contains("relationships:"),
5305 "the empty typed field is omitted, not written as `[]`"
5306 );
5307 assert!(
5308 !first.contains("## History"),
5309 "an empty History section is omitted entirely"
5310 );
5311 let reparsed = Issue::parse(&first).unwrap();
5314 let second = reparsed.to_file_contents();
5315 assert_eq!(second, first);
5316 }
5317
5318 #[test]
5319 fn c002_history_section_round_trips_byte_stable() {
5320 let (tmp, store) = make_store();
5321 write_issue(&store, "2026-01-01-h.md", &bare_frontmatter(1, "H"));
5322 let _ = tmp;
5323 let mut issue = store.get(IssueSelector::Number(1)).unwrap();
5324 issue.history.append_now(
5325 "alice",
5326 HistoryEventKind::RelationshipAdded,
5327 "created blocks: blocks #2 B",
5328 );
5329 issue.history.append_now(
5330 "bob",
5331 HistoryEventKind::BlockedMoveAcknowledged,
5332 "acknowledged 1 open blocker",
5333 );
5334 let first = issue.to_file_contents();
5335 assert!(first.contains("## History"), "{first}");
5336
5337 let reparsed = Issue::parse(&first).unwrap();
5339 assert_eq!(reparsed.to_file_contents(), first);
5340 assert_eq!(reparsed.history.len(), 2);
5341 assert_eq!(reparsed.history.events()[0].actor, "alice");
5342 assert_eq!(
5343 reparsed.history.events()[1].kind,
5344 HistoryEventKind::BlockedMoveAcknowledged
5345 );
5346 assert!(
5347 !reparsed.body.contains("History"),
5348 "the section is stripped from the body: {:?}",
5349 reparsed.body
5350 );
5351 }
5352
5353 #[test]
5354 fn f1_a_hand_edit_last_in_the_section_stays_in_it() {
5355 let (tmp, store) = make_store();
5374 let _ = tmp;
5375 let original = "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n - my hand edit\n";
5376 write_issue(&store, "2026-01-01-a.md", original);
5377
5378 let before = store.get(IssueSelector::Number(1)).unwrap();
5381 assert_eq!(
5382 before.history.events().len(),
5383 1,
5384 "precondition: the real event parsed"
5385 );
5386 assert_eq!(
5387 before.history.unparsed(),
5388 vec![" - my hand edit"],
5389 "precondition: the hand edit is inside the section"
5390 );
5391 assert_eq!(before.body, "", "precondition: body is empty");
5392
5393 let mut moved = before.clone();
5395 moved.status = crate::model::Status::new("in progress");
5396 store.update(&moved.filename, &moved).unwrap();
5397
5398 let after = store.get(IssueSelector::Number(1)).unwrap();
5399 assert_eq!(
5400 after.history.unparsed(),
5401 vec![" - my hand edit"],
5402 "the hand edit is still in the audit log, not demoted to the body"
5403 );
5404 assert_eq!(
5405 after.history.events().len(),
5406 1,
5407 "and the real event survived"
5408 );
5409 assert_eq!(after.body, "", "nothing leaked into the body");
5410
5411 store
5414 .link("1", "2", RelationKind::Related)
5415 .ok()
5416 .or_else(|| {
5417 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
5418 store.link("1", "2", RelationKind::Related).ok()
5419 });
5420 let linked = store.get(IssueSelector::Number(1)).unwrap();
5421 let rendered = linked.to_file_contents();
5422 assert!(
5423 rendered.contains("## History"),
5424 "the heading survived an unrelated write: {rendered}"
5425 );
5426 let hand = rendered
5427 .find("my hand edit")
5428 .expect("the hand edit is present");
5429 let newest = rendered
5430 .find("relationship_added")
5431 .expect("the new event is present");
5432 assert!(
5433 newest < hand,
5434 "the new event is appended AFTER the pre-existing content, so file \
5435 order is still append order: {rendered}"
5436 );
5437 }
5438
5439 #[test]
5440 fn f1_but_a_bare_paragraph_at_end_of_section_is_still_prose() {
5441 let (tmp, store) = make_store();
5446 let _ = tmp;
5447 write_issue(
5448 &store,
5449 "2026-01-01-a.md",
5450 "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n\njust a closing paragraph\n",
5451 );
5452 let issue = store.get(IssueSelector::Number(1)).unwrap();
5453 assert_eq!(issue.history.events().len(), 1, "the real event parsed");
5454 assert!(
5455 issue.history.unparsed().is_empty(),
5456 "the paragraph is not in the audit log: {:?}",
5457 issue.history.unparsed()
5458 );
5459 assert!(
5460 issue.body.contains("just a closing paragraph"),
5461 "it is body prose: {:?}",
5462 issue.body
5463 );
5464 }
5465
5466 #[test]
5467 fn f1_a_hand_edit_survives_body_prose_and_a_later_body_replacement() {
5468 let (_tmp, store) = make_store();
5483 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
5484 let hand = "- 2026-02-02T00:00:00Z - bob - relationship_removed - by hand";
5485 write_issue(
5486 &store,
5487 "2026-01-01-a.md",
5488 &format!(
5489 "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n{hand}\n\nSome body prose.\n"
5490 ),
5491 );
5492
5493 let before = store.get(IssueSelector::Number(1)).unwrap();
5494 assert_eq!(before.history.events().len(), 1, "the real event parsed");
5495 assert_eq!(
5496 before.history.unparsed(),
5497 vec![hand],
5498 "the hand-edited entry is retained even though body prose follows"
5499 );
5500 assert!(
5501 before.body.contains("Some body prose."),
5502 "and the prose is still body text: {:?}",
5503 before.body
5504 );
5505
5506 let mut moved = before.clone();
5508 moved.status = crate::model::Status::new("in progress");
5509 store.update(&moved.filename, &moved).unwrap();
5510 assert_eq!(
5511 store
5512 .get(IssueSelector::Number(1))
5513 .unwrap()
5514 .history
5515 .unparsed(),
5516 vec![hand],
5517 "a `tix move` keeps it in the audit log"
5518 );
5519
5520 let mut replaced = store.get(IssueSelector::Number(1)).unwrap();
5523 replaced.body = "Completely new body text.".to_string();
5524 store.update(&replaced.filename, &replaced).unwrap();
5525 let after = store.get(IssueSelector::Number(1)).unwrap();
5526 assert_eq!(
5527 after.history.unparsed(),
5528 vec![hand],
5529 "the hand-edited entry survives a wholesale body replacement"
5530 );
5531 assert_eq!(after.body, "Completely new body text.");
5532
5533 store.link("1", "2", RelationKind::Related).unwrap();
5535 let rendered = store
5536 .get(IssueSelector::Number(1))
5537 .unwrap()
5538 .to_file_contents();
5539 assert!(
5540 rendered.contains("## History"),
5541 "the heading survived: {rendered}"
5542 );
5543 let hand_at = rendered.find("by hand").expect("the hand edit is present");
5544 let new_at = rendered
5545 .find("created rel_")
5546 .expect("the new event is present");
5547 assert!(
5548 new_at > hand_at,
5549 "the new event is appended AFTER the pre-existing content, so file \
5550 order is still append order: {rendered}"
5551 );
5552 }
5553
5554 #[test]
5555 fn f1_a_well_formed_star_marked_entry_is_retained_with_body_prose_after_it() {
5556 let (_tmp, store) = make_store();
5566 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
5567 let hand =
5568 "* 2026-02-02T00:00:00Z \u{2014} bob \u{2014} relationship_removed \u{2014} by hand";
5569 write_issue(
5570 &store,
5571 "2026-01-01-a.md",
5572 &format!(
5573 "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n{hand}\n\nSome body prose.\n"
5574 ),
5575 );
5576
5577 let before = store.get(IssueSelector::Number(1)).unwrap();
5578 assert_eq!(before.history.events().len(), 1, "the real event parsed");
5579 assert_eq!(
5580 before.history.unparsed(),
5581 vec![hand],
5582 "a well-formed `*` entry is retained even with body prose after it"
5583 );
5584 assert!(
5585 before.body.contains("Some body prose."),
5586 "and the prose is still body text: {:?}",
5587 before.body
5588 );
5589
5590 let mut replaced = before.clone();
5592 replaced.body = "Completely new body text.".to_string();
5593 store.update(&replaced.filename, &replaced).unwrap();
5594 assert_eq!(
5595 store
5596 .get(IssueSelector::Number(1))
5597 .unwrap()
5598 .history
5599 .unparsed(),
5600 vec![hand],
5601 "it survives a wholesale body replacement"
5602 );
5603
5604 store.link("1", "2", RelationKind::Related).unwrap();
5605 let rendered = store
5606 .get(IssueSelector::Number(1))
5607 .unwrap()
5608 .to_file_contents();
5609 assert_eq!(
5610 rendered.matches("## History").count(),
5611 1,
5612 "still exactly one section: {rendered}"
5613 );
5614 let hand_at = rendered.find("by hand").expect("the hand edit is present");
5615 let new_at = rendered
5616 .find("created rel_")
5617 .expect("the new event is present");
5618 assert!(
5619 new_at > hand_at,
5620 "appended after, so order is append order: {rendered}"
5621 );
5622 }
5623
5624 #[test]
5625 fn f1_a_three_field_ascii_hyphen_entry_is_retained_with_body_prose_after_it() {
5626 let (_tmp, store) = make_store();
5632 let hand = "- 2026-02-02T00:00:00Z - bob - relationship_removed";
5633 write_issue(
5634 &store,
5635 "2026-01-01-a.md",
5636 &format!(
5637 "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n{hand}\n\nSome body prose.\n"
5638 ),
5639 );
5640
5641 let twin = Issue::parse(
5644 "---\nnumber: 2\ntitle: \"T\"\nstatus: todo\n---\n\n## History\n\n- 2026-02-02T00:00:00Z \u{2014} bob \u{2014} relationship_removed\n",
5645 )
5646 .unwrap();
5647 assert_eq!(
5648 twin.history.events().len(),
5649 1,
5650 "three fields with em-dashes is a valid event, detail optional"
5651 );
5652
5653 let before = store.get(IssueSelector::Number(1)).unwrap();
5654 assert_eq!(
5655 before.history.unparsed(),
5656 vec![hand],
5657 "so its ASCII-hyphen twin is retained, not demoted"
5658 );
5659 let mut replaced = before.clone();
5660 replaced.body = "Completely new body text.".to_string();
5661 store.update(&replaced.filename, &replaced).unwrap();
5662 assert_eq!(
5663 store
5664 .get(IssueSelector::Number(1))
5665 .unwrap()
5666 .history
5667 .unparsed(),
5668 vec![hand],
5669 "and survives a body replacement"
5670 );
5671 }
5672
5673 #[test]
5674 fn f1_a_heading_with_trailing_spaces_is_still_the_heading() {
5675 let (_tmp, store) = make_store();
5681 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
5682 for (label, heading) in [
5683 ("plain", "## History"),
5684 ("trailing spaces", "## History "),
5685 ] {
5686 write_issue(
5687 &store,
5688 "2026-01-01-a.md",
5689 &format!(
5690 "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n{heading}\n\n- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n"
5691 ),
5692 );
5693 let before = store.get(IssueSelector::Number(1)).unwrap();
5694 assert_eq!(
5695 before.history.events().len(),
5696 1,
5697 "{label}: the section is recognised"
5698 );
5699 store.link("1", "2", RelationKind::Related).unwrap();
5700 let rendered = store
5701 .get(IssueSelector::Number(1))
5702 .unwrap()
5703 .to_file_contents();
5704 assert_eq!(
5705 rendered.matches("## History").count(),
5706 1,
5707 "{label}: exactly one section after a write, got {rendered}"
5708 );
5709 assert!(
5710 !rendered.lines().any(|l| l != l.trim_end()),
5711 "{label}: no line keeps trailing whitespace, so the heading is \
5712 normalised: {rendered}"
5713 );
5714 }
5715 }
5716
5717 #[test]
5718 fn set_status_discloses_the_retired_key_rather_than_dropping_it_silently() {
5719 let (_tmp, store) = make_store();
5726 write_issue(
5727 &store,
5728 "2026-01-01-a.md",
5729 "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nlinks:\n- \"2\"\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.\n",
5730 );
5731 let before = store.get(IssueSelector::Number(1)).unwrap();
5732 assert!(
5733 before.has_retired_links_key,
5734 "precondition: the file carries the retired key"
5735 );
5736 let _ = crate::storage::take_disclosures();
5737
5738 store
5739 .set_status("2026-01-01-a.md", StatusChange::to("in progress"))
5740 .unwrap();
5741
5742 let disclosures = crate::storage::take_disclosures();
5743 assert_eq!(
5744 disclosures.len(),
5745 1,
5746 "set_status must disclose the removal, got {disclosures:?}"
5747 );
5748 assert!(
5749 disclosures[0].contains("links:") && disclosures[0].contains("a.md"),
5750 "naming the field and the file: {disclosures:?}"
5751 );
5752 assert!(
5753 !store
5754 .get(IssueSelector::Number(1))
5755 .unwrap()
5756 .has_retired_links_key,
5757 "and the key is gone, as C-001 intends"
5758 );
5759 }
5760
5761 #[test]
5762 fn f1_but_ordinary_prose_bullets_still_end_the_section() {
5763 let (_tmp, store) = make_store();
5768 write_issue(
5769 &store,
5770 "2026-01-01-a.md",
5771 concat!(
5772 "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\n",
5773 "created: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n",
5774 "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n\n",
5775 "- step one\n- step two\n",
5776 ),
5777 );
5778 let issue = store.get(IssueSelector::Number(1)).unwrap();
5779 assert_eq!(
5780 issue.history.events().len(),
5781 1,
5782 "the real event is an event"
5783 );
5784 assert!(
5785 issue.history.unparsed().is_empty(),
5786 "ordinary bullets are not retained: {:?}",
5787 issue.history.unparsed()
5788 );
5789 assert!(
5790 issue.body.contains("step one") && issue.body.contains("step two"),
5791 "they stay body text: {:?}",
5792 issue.body
5793 );
5794 }
5795
5796 #[test]
5797 fn a_year_is_not_an_ordered_list_marker() {
5798 for prose in [
5802 "2026. Q1 goals",
5803 "2026. roadmap review",
5804 "007. padded",
5805 "99999999999999999999. big",
5806 ] {
5807 assert!(
5808 !crate::model::looks_like_an_unrecognised_entry(prose)
5809 && Issue::parse(&format!(
5810 "---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{prose}\n"
5811 ))
5812 .unwrap()
5813 .history
5814 .is_empty(),
5815 "{prose:?} must be body prose, not a retained entry"
5816 );
5817 }
5818 }
5819
5820 #[test]
5821 fn f2_a_recognised_entry_with_a_bad_kind_keeps_its_trailing_whitespace() {
5822 let line = " - 2026-01-01T00:00:00Z \u{2014} alice \u{2014} bogus_kind \u{2014} d ";
5829 let raw = format!(
5830 "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n{line}\n"
5831 );
5832 let issue = Issue::parse(&raw).unwrap();
5833 assert!(issue.history.events().is_empty(), "no event was fabricated");
5834 assert_eq!(
5835 issue.history.unparsed(),
5836 vec![line],
5837 "kept byte-for-byte, trailing spaces included"
5838 );
5839
5840 let rendered = issue.to_file_contents();
5842 assert!(
5843 rendered.contains(&format!("{line}\n")),
5844 "re-emitted exactly, so an unrelated `tix edit` does not rewrite the \
5845 author's audit log: {rendered}"
5846 );
5847 assert_eq!(
5848 Issue::parse(&rendered).unwrap().to_file_contents(),
5849 rendered,
5850 "and the file is byte-stable from here"
5851 );
5852 }
5853
5854 #[test]
5855 fn c002_history_is_append_only() {
5856 let (tmp, store) = make_store();
5857 write_issue(&store, "2026-01-01-h.md", &bare_frontmatter(1, "H"));
5858 let _ = tmp;
5859 let mut issue = store.get(IssueSelector::Number(1)).unwrap();
5860 issue
5861 .history
5862 .append_now("alice", HistoryEventKind::RelationshipAdded, "first");
5863 let after_first = issue.history.clone();
5864 issue
5865 .history
5866 .append_now("alice", HistoryEventKind::RelationshipRemoved, "second");
5867
5868 assert_eq!(issue.history.len(), 2, "appending never replaces");
5869 assert_eq!(
5870 &issue.history.events()[..1],
5871 after_first.events(),
5872 "the earlier entry is untouched and still first"
5873 );
5874 }
5875
5876 #[test]
5877 fn c002_an_unknown_history_kind_is_kept_verbatim_not_coerced() {
5878 let line = "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} bogus_kind \u{2014} something";
5889 let raw = format!(
5890 "---\nnumber: 1\ntitle: \"H\"\nstatus: \"todo\"\n---\n\n## History\n\n{line}\n"
5891 );
5892 let issue = Issue::parse(&raw).expect("an unknown kind must not fail the parse");
5893
5894 assert!(
5895 issue.history.events().is_empty(),
5896 "no event was fabricated for an unknown kind"
5897 );
5898 assert_eq!(
5899 issue.history.unparsed(),
5900 vec![line],
5901 "the line is kept verbatim: not dropped, not repaired"
5902 );
5903 let rendered = issue.to_file_contents();
5907 assert!(rendered.contains(line), "the line survives the write");
5908 assert_eq!(
5909 Issue::parse(&rendered).unwrap().to_file_contents(),
5910 rendered,
5911 "and the file round-trips byte-identically"
5912 );
5913 }
5914
5915 #[test]
5916 fn list_mixes_grouped_and_flat_issues_sorted_by_relative_filename() {
5917 let (tmp, store) = make_store();
5918 let _ = tmp;
5919 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5920 fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
5921 write_issue(
5922 &store,
5923 "alice/2026-01-01-a.md",
5924 &sample_frontmatter(1, "A", "todo"),
5925 );
5926 write_issue(
5927 &store,
5928 "bob/2026-05-10-b.md",
5929 &sample_frontmatter(2, "B", "todo"),
5930 );
5931 write_issue(
5932 &store,
5933 "2026-02-01-c.md",
5934 &sample_frontmatter(3, "C", "todo"),
5935 );
5936 let issues = store.list().unwrap();
5937 let filenames: Vec<&str> = issues.iter().map(|i| i.filename.as_str()).collect();
5938 assert_eq!(issues.len(), 3);
5939 assert_eq!(
5940 filenames,
5941 vec![
5942 "2026-02-01-c.md",
5943 "alice/2026-01-01-a.md",
5944 "bob/2026-05-10-b.md"
5945 ],
5946 "deterministic sort by relative filename"
5947 );
5948 }
5949
5950 #[test]
5951 fn list_sorts_chronologically_within_a_group() {
5952 let (tmp, store) = make_store();
5953 let _ = tmp;
5954 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5955 write_issue(
5956 &store,
5957 "alice/2026-03-01-c.md",
5958 &sample_frontmatter(3, "C", "todo"),
5959 );
5960 write_issue(
5961 &store,
5962 "alice/2026-01-01-a.md",
5963 &sample_frontmatter(1, "A", "todo"),
5964 );
5965 write_issue(
5966 &store,
5967 "alice/2026-02-01-b.md",
5968 &sample_frontmatter(2, "B", "todo"),
5969 );
5970 let issues = store.list().unwrap();
5971 let numbers: Vec<u32> = issues.iter().map(|i| i.number).collect();
5972 assert_eq!(numbers, vec![1, 2, 3]);
5973 }
5974
5975 #[test]
5976 fn list_ignores_non_md_files_in_segments() {
5977 let (tmp, store) = make_store();
5978 let _ = tmp;
5979 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5980 write_issue(
5981 &store,
5982 "alice/2026-01-01-a.md",
5983 &sample_frontmatter(1, "A", "todo"),
5984 );
5985 fs::write(store.issues_dir.join("alice/notes.txt"), "not an issue").unwrap();
5986 fs::write(store.issues_dir.join("alice/.tix-tmp-123-456"), "orphan").unwrap();
5987 let issues = store.list().unwrap();
5988 assert_eq!(issues.len(), 1);
5989 assert_eq!(issues[0].filename, "alice/2026-01-01-a.md");
5990 }
5991
5992 #[test]
5993 fn list_skips_temp_marker_directories() {
5994 let (tmp, store) = make_store();
5995 let _ = tmp;
5996 fs::create_dir_all(store.issues_dir.join(".tix-tmp-999-0")).unwrap();
5997 write_issue(
5998 &store,
5999 ".tix-tmp-999-0/2026-01-01-junk.md",
6000 &sample_frontmatter(9, "J", "todo"),
6001 );
6002 write_issue(
6003 &store,
6004 "2026-01-01-a.md",
6005 &sample_frontmatter(1, "A", "todo"),
6006 );
6007 let issues = store.list().unwrap();
6008 assert_eq!(issues.len(), 1);
6009 assert_eq!(issues[0].filename, "2026-01-01-a.md");
6010 }
6011
6012 #[test]
6013 fn list_parse_error_carries_grouped_relative_path() {
6014 let (tmp, store) = make_store();
6015 let _ = tmp;
6016 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6017 fs::write(
6018 store.issues_dir.join("alice/2026-01-01-bad.md"),
6019 "no frontmatter here\n",
6020 )
6021 .unwrap();
6022 let err = store.list().unwrap_err();
6023 assert!(
6024 matches!(&err, TixError::Parse(m) if m.starts_with("alice/2026-01-01-bad.md:")),
6025 "parse error must carry the relative path, got {err:?}"
6026 );
6027 }
6028
6029 #[test]
6034 fn create_uses_the_default_layout_template() {
6035 let tmp = tempfile::TempDir::new().unwrap();
6039 let dir = tmp.path().join("issues");
6040 fs::create_dir_all(&dir).unwrap();
6041 let store = Store {
6042 issues_dir: dir,
6043 config: Config::default(),
6044 };
6045 let mut issue = Issue {
6046 number: 0,
6047 title: "Round trip".into(),
6048 status: Status::new("todo"),
6049 labels: vec![],
6050 priority: Priority::default(),
6051 assignee: Some("@alice".into()),
6052 estimate: None,
6053 commit: None,
6054 gitlab_id: None,
6055 linear_id: None,
6056 mission: None,
6057 task: None,
6058 relationships: vec![],
6059 related_issues: vec![],
6060 parent: None,
6061 files: vec![],
6062 cycles: None,
6063 milestones: None,
6064 reviewer: None,
6065 start_date: None,
6066 due_date: None,
6067 entered_at: None,
6068 source: None,
6069 created: "2026-01-01".into(),
6070 type_: None,
6071 updated: "2026-01-01".into(),
6072 filename: String::new(),
6073 body: String::new(),
6074 history: crate::model::History::new(),
6075 has_retired_links_key: false,
6076 extra_frontmatter: std::collections::BTreeMap::new(),
6077 archived: false,
6078 };
6079 store.create(&mut issue, "", None).unwrap();
6080 assert_eq!(
6081 issue.filename,
6082 "alice/default/todo/2026-01-01-round-trip.md"
6083 );
6084 assert!(
6085 store
6086 .issues_dir
6087 .join("alice/default/todo/2026-01-01-round-trip.md")
6088 .is_file()
6089 );
6090 }
6091
6092 #[test]
6093 fn update_always_reconciles_the_file_location() {
6094 let tmp = tempfile::TempDir::new().unwrap();
6097 let dir = tmp.path().join("issues");
6098 fs::create_dir_all(&dir).unwrap();
6099 let store = Store {
6100 issues_dir: dir,
6101 config: Config::default(),
6102 };
6103 let mut issue = Issue {
6104 number: 0,
6105 type_: None,
6106 title: "Round trip".into(),
6107 status: Status::new("todo"),
6108 labels: vec![],
6109 priority: Priority::default(),
6110 assignee: Some("@alice".into()),
6111 estimate: None,
6112 commit: None,
6113 gitlab_id: None,
6114 linear_id: None,
6115 mission: None,
6116 task: None,
6117 relationships: vec![],
6118 related_issues: vec![],
6119 parent: None,
6120 files: vec![],
6121 cycles: None,
6122 milestones: None,
6123 reviewer: None,
6124 start_date: None,
6125 due_date: None,
6126 entered_at: None,
6127 source: None,
6128 created: "2026-01-01".into(),
6129 updated: "2026-01-01".into(),
6130 filename: String::new(),
6131 body: String::new(),
6132 history: crate::model::History::new(),
6133 has_retired_links_key: false,
6134 extra_frontmatter: std::collections::BTreeMap::new(),
6135 archived: false,
6136 };
6137 store.create(&mut issue, "", None).unwrap();
6138 let old = issue.filename.clone();
6139
6140 issue.status = Status::new("done");
6142 issue.filename = store.update(&old, &issue).unwrap();
6143 assert_eq!(
6144 issue.filename,
6145 "alice/default/done/2026-01-01-round-trip.md"
6146 );
6147 assert!(!store.issues_dir.join(&old).exists());
6148
6149 let flat = "2026-01-01-legacy.md";
6151 fs::write(
6152 store.issues_dir.join(flat),
6153 "---\nnumber: 9\ntitle: L\nstatus: todo\nlabels: []\npriority: medium\n\
6154 files: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nbody\n",
6155 )
6156 .unwrap();
6157 let mut legacy = store
6158 .get(IssueSelector::Filename(flat.to_string()))
6159 .unwrap();
6160 legacy.status = Status::new("done");
6161 let after = store.update(flat, &legacy).unwrap();
6162 assert_eq!(after, "unassigned/default/done/2026-01-01-legacy.md");
6163 assert!(!store.issues_dir.join(flat).exists(), "migrated, not left");
6164 assert!(store.issues_dir.join(&after).is_file());
6165 }
6166
6167 #[test]
6168 fn create_writes_into_assignee_segment_and_records_relative_filename() {
6169 let (tmp, store) = make_store();
6170 let _ = tmp;
6171 let mut issue = Issue {
6172 number: 0,
6173 title: "T".into(),
6174 status: Status::default(),
6175 labels: vec![],
6176 priority: Priority::default(),
6177 assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
6178 estimate: None,
6179 commit: None,
6180 gitlab_id: None,
6181 linear_id: None,
6182 mission: None,
6183 task: None,
6184 relationships: vec![],
6185 related_issues: vec![],
6186 files: vec![],
6187 cycles: None,
6188 milestones: None,
6189 reviewer: None,
6190 start_date: None,
6191 due_date: None,
6192 created: "2026-01-01".into(),
6193 type_: None,
6194 updated: "2026-01-01".into(),
6195 filename: String::new(),
6196 body: String::new(),
6197 history: crate::model::History::new(),
6198 has_retired_links_key: false,
6199 parent: None,
6200 extra_frontmatter: std::collections::BTreeMap::new(),
6201 archived: false,
6202 entered_at: None,
6203 source: None,
6204 };
6205 let path = store
6206 .create(&mut issue, "body", Some("custom-stem"))
6207 .unwrap();
6208 let rel = "herbert-maschke/custom-stem.md";
6209 assert_eq!(issue.filename, rel);
6210 assert_eq!(path, store.issues_dir.join(rel));
6211 assert!(store.issues_dir.join("herbert-maschke").is_dir());
6212 assert!(store.issues_dir.join(rel).is_file());
6213 }
6214
6215 #[test]
6216 fn create_without_assignee_writes_unassigned_segment() {
6217 let (tmp, store) = make_store();
6218 let _ = tmp;
6219 let mut issue = Issue {
6220 number: 0,
6221 title: "T".into(),
6222 status: Status::default(),
6223 labels: vec![],
6224 priority: Priority::default(),
6225 assignee: None,
6226 estimate: None,
6227 commit: None,
6228 gitlab_id: None,
6229 linear_id: None,
6230 mission: None,
6231 task: None,
6232 relationships: vec![],
6233 related_issues: vec![],
6234 files: vec![],
6235 cycles: None,
6236 milestones: None,
6237 reviewer: None,
6238 start_date: None,
6239 due_date: None,
6240 created: "2026-01-01".into(),
6241 type_: None,
6242 updated: "2026-01-01".into(),
6243 filename: String::new(),
6244 body: String::new(),
6245 history: crate::model::History::new(),
6246 has_retired_links_key: false,
6247 parent: None,
6248 extra_frontmatter: std::collections::BTreeMap::new(),
6249 archived: false,
6250 entered_at: None,
6251 source: None,
6252 };
6253 let path = store.create(&mut issue, "", Some("stem")).unwrap();
6254 assert_eq!(issue.filename, "unassigned/stem.md");
6255 assert!(store.issues_dir.join("unassigned").is_dir());
6256 assert!(store.issues_dir.join("unassigned/stem.md").is_file());
6257 assert_eq!(path, store.issues_dir.join("unassigned/stem.md"));
6258 }
6259
6260 #[test]
6261 fn create_with_at_handle_derives_bare_segment() {
6262 let (tmp, store) = make_store();
6263 let _ = tmp;
6264 let mut issue = Issue {
6265 number: 0,
6266 title: "T".into(),
6267 status: Status::default(),
6268 labels: vec![],
6269 priority: Priority::default(),
6270 assignee: Some("@alice".into()),
6271 estimate: None,
6272 commit: None,
6273 gitlab_id: None,
6274 linear_id: None,
6275 mission: None,
6276 task: None,
6277 relationships: vec![],
6278 related_issues: vec![],
6279 files: vec![],
6280 cycles: None,
6281 milestones: None,
6282 reviewer: None,
6283 start_date: None,
6284 due_date: None,
6285 created: "2026-01-01".into(),
6286 type_: None,
6287 updated: "2026-01-01".into(),
6288 filename: String::new(),
6289 body: String::new(),
6290 history: crate::model::History::new(),
6291 has_retired_links_key: false,
6292 parent: None,
6293 extra_frontmatter: std::collections::BTreeMap::new(),
6294 archived: false,
6295 entered_at: None,
6296 source: None,
6297 };
6298 store.create(&mut issue, "", Some("stem")).unwrap();
6299 assert_eq!(issue.filename, "alice/stem.md");
6300 assert!(store.issues_dir.join("alice/stem.md").is_file());
6301 }
6302
6303 #[test]
6308 fn reassign_moves_file_between_segments() {
6309 let (tmp, store) = make_store();
6310 let _ = tmp;
6311 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6312 write_issue(
6313 &store,
6314 "alice/2026-01-01-x.md",
6315 &sample_frontmatter(1, "X", "todo"),
6316 );
6317 let new_path = store
6318 .reassign("alice/2026-01-01-x.md", Some("@bob"))
6319 .unwrap();
6320 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x.md"));
6321 assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
6322 assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
6323 }
6324
6325 #[test]
6326 fn reassign_into_occupied_segment_picks_deterministic_suffix() {
6327 let (tmp, store) = make_store();
6328 let _ = tmp;
6329 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6330 fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
6331 write_issue(
6332 &store,
6333 "alice/2026-01-01-x.md",
6334 &sample_frontmatter(1, "X", "todo"),
6335 );
6336 write_issue(
6337 &store,
6338 "bob/2026-01-01-x.md",
6339 &sample_frontmatter(2, "X", "todo"),
6340 );
6341 let new_path = store
6342 .reassign("alice/2026-01-01-x.md", Some("@bob"))
6343 .unwrap();
6344 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x-1.md"));
6345 assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
6346 assert!(store.issues_dir.join("bob/2026-01-01-x-1.md").is_file());
6347 assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
6348 }
6349
6350 #[test]
6351 fn reassign_legacy_flat_file_into_segment() {
6352 let (tmp, store) = make_store();
6353 let _ = tmp;
6354 write_issue(
6355 &store,
6356 "2026-01-01-y.md",
6357 &sample_frontmatter(1, "Y", "todo"),
6358 );
6359 let new_path = store.reassign("2026-01-01-y.md", Some("alice")).unwrap();
6360 assert_eq!(new_path, PathBuf::from("alice/2026-01-01-y.md"));
6361 assert!(store.issues_dir.join("alice/2026-01-01-y.md").is_file());
6362 assert!(!store.issues_dir.join("2026-01-01-y.md").exists());
6363 }
6364
6365 #[test]
6366 fn reassign_to_none_uses_unassigned_fallback() {
6367 let (tmp, store) = make_store();
6368 let _ = tmp;
6369 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6370 write_issue(
6371 &store,
6372 "alice/2026-01-01-z.md",
6373 &sample_frontmatter(1, "Z", "todo"),
6374 );
6375 let new_path = store.reassign("alice/2026-01-01-z.md", None).unwrap();
6376 assert_eq!(new_path, PathBuf::from("unassigned/2026-01-01-z.md"));
6377 assert!(
6378 store
6379 .issues_dir
6380 .join("unassigned/2026-01-01-z.md")
6381 .is_file()
6382 );
6383 assert!(!store.issues_dir.join("alice/2026-01-01-z.md").exists());
6384 }
6385
6386 #[test]
6387 fn reassign_keeps_number_and_created_stable() {
6388 let (tmp, store) = make_store();
6389 let _ = tmp;
6390 let mut issue = Issue {
6391 number: 0,
6392 title: "Stable".into(),
6393 status: Status::default(),
6394 labels: vec![],
6395 priority: Priority::default(),
6396 assignee: Some("alice".into()),
6397 estimate: None,
6398 commit: None,
6399 gitlab_id: None,
6400 linear_id: None,
6401 mission: None,
6402 task: None,
6403 relationships: vec![],
6404 related_issues: vec![],
6405 files: vec![],
6406 cycles: None,
6407 milestones: None,
6408 reviewer: None,
6409 start_date: None,
6410 due_date: None,
6411 created: "2026-01-01".into(),
6412 type_: None,
6413 updated: "2026-01-01".into(),
6414 filename: String::new(),
6415 body: String::new(),
6416 history: crate::model::History::new(),
6417 has_retired_links_key: false,
6418 parent: None,
6419 extra_frontmatter: std::collections::BTreeMap::new(),
6420 archived: false,
6421 entered_at: None,
6422 source: None,
6423 };
6424 store.create(&mut issue, "body", None).unwrap();
6425 let number = issue.number;
6426 let created = issue.created.clone();
6427 let before = issue.filename.clone();
6428 let new_path = store.reassign(&before, Some("@bob")).unwrap();
6429 let rescan = store.list().unwrap();
6430 let moved = rescan
6431 .iter()
6432 .find(|i| i.filename == new_path)
6433 .expect("moved file must be listed at its new relative path");
6434 assert_eq!(moved.number, number);
6435 assert_eq!(moved.created, created);
6436 assert_eq!(moved.title, "Stable");
6437 }
6438
6439 #[test]
6440 fn reassign_equal_segment_is_noop() {
6441 let (tmp, store) = make_store();
6442 let _ = tmp;
6443 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6444 write_issue(
6445 &store,
6446 "alice/2026-01-01-x.md",
6447 &sample_frontmatter(1, "X", "todo"),
6448 );
6449 let new_path = store
6450 .reassign("alice/2026-01-01-x.md", Some("alice"))
6451 .unwrap();
6452 assert_eq!(new_path, PathBuf::from("alice/2026-01-01-x.md"));
6453 assert!(store.issues_dir.join("alice/2026-01-01-x.md").is_file());
6454 }
6455
6456 #[test]
6457 fn reassign_missing_file_is_not_found() {
6458 let (tmp, store) = make_store();
6459 let _ = tmp;
6460 let err = store.reassign("nope.md", Some("alice")).unwrap_err();
6461 assert!(matches!(err, TixError::NotFound(_)));
6462 }
6463
6464 #[test]
6469 fn validate_filename_allows_flat_and_single_segment_prefix() {
6470 validate_filename("2026-01-01-x.md").unwrap();
6471 validate_filename("alice/2026-01-01-x.md").unwrap();
6472 }
6473
6474 #[test]
6475 fn validate_filename_allows_deeper_segments() {
6476 validate_filename("alice/bug/in-progress/2026-01-01-x.md").unwrap();
6478 }
6479
6480 #[test]
6481 fn validate_filename_refuses_leading_and_trailing_slash_and_absolute() {
6482 let err = validate_filename("/alice/2026-01-01-x.md").unwrap_err();
6483 assert!(matches!(err, TixError::InvalidInput(_)));
6484 let err = validate_filename("/etc/passwd").unwrap_err();
6485 assert!(matches!(err, TixError::InvalidInput(_)));
6486 let err = validate_filename("alice/").unwrap_err();
6487 assert!(matches!(err, TixError::InvalidInput(_)));
6488 }
6489
6490 #[test]
6491 fn path_traversal_with_segment_dotdot_refused() {
6492 let err = validate_filename("alice/../../x.md").unwrap_err();
6493 assert!(matches!(err, TixError::InvalidInput(_)));
6494 let err = validate_filename("alice/../x.md").unwrap_err();
6495 assert!(matches!(err, TixError::InvalidInput(_)));
6496 }
6497
6498 #[test]
6499 fn get_update_delete_set_status_work_on_grouped_relative_paths() {
6500 let (tmp, store) = make_store();
6501 let _ = tmp;
6502 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6503 write_issue(
6504 &store,
6505 "alice/2026-01-01-g.md",
6506 &sample_frontmatter(7, "G", "todo"),
6507 );
6508 let got = store
6509 .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
6510 .unwrap();
6511 assert_eq!(got.number, 7);
6512 let done = store
6513 .set_status("alice/2026-01-01-g.md", StatusChange::to("done"))
6514 .unwrap();
6515 assert_eq!(done.issue.status.display(), "done");
6516 assert!(store.issues_dir.join("alice/2026-01-01-g.md").is_file());
6517 let mut edited = store
6518 .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
6519 .unwrap();
6520 edited.title = "Updated".into();
6521 store.update("alice/2026-01-01-g.md", &edited).unwrap();
6522 let reloaded = store
6523 .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
6524 .unwrap();
6525 assert_eq!(reloaded.title, "Updated");
6526 store.delete("alice/2026-01-01-g.md").unwrap();
6527 assert!(!store.issues_dir.join("alice/2026-01-01-g.md").exists());
6528 }
6529
6530 #[test]
6535 fn grouped_round_trip_create_list_get_update_delete() {
6536 let (tmp, store) = make_store();
6537 let _ = tmp;
6538 let mut issue = Issue {
6539 number: 0,
6540 title: "Round trip".into(),
6541 status: Status::default(),
6542 labels: vec!["bug".into()],
6543 priority: Priority::default(),
6544 assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
6545 estimate: None,
6546 commit: None,
6547 gitlab_id: None,
6548 linear_id: None,
6549 mission: None,
6550 task: None,
6551 relationships: vec![],
6552 related_issues: vec![],
6553 files: vec![],
6554 cycles: None,
6555 milestones: None,
6556 reviewer: None,
6557 start_date: None,
6558 due_date: None,
6559 created: "2026-01-01".into(),
6560 type_: None,
6561 updated: "2026-01-01".into(),
6562 filename: String::new(),
6563 body: String::new(),
6564 history: crate::model::History::new(),
6565 has_retired_links_key: false,
6566 parent: None,
6567 extra_frontmatter: std::collections::BTreeMap::new(),
6568 archived: false,
6569 entered_at: None,
6570 source: None,
6571 };
6572 store.create(&mut issue, "initial body", None).unwrap();
6573 let rel = issue.filename.clone();
6574 assert_eq!(rel, "herbert-maschke/2026-01-01-round-trip.md");
6575
6576 let listed = store.list().unwrap();
6577 assert_eq!(listed.len(), 1);
6578 assert_eq!(listed[0].filename, rel);
6579 assert_eq!(listed[0].number, issue.number);
6580
6581 let got = store.get(IssueSelector::Filename(rel.clone())).unwrap();
6582 let mut edited = got.clone();
6583 edited.title = "Renamed".into();
6584 store.update(&rel, &edited).unwrap();
6585 let reloaded = store.get(IssueSelector::Filename(rel.clone())).unwrap();
6586 assert_eq!(reloaded.title, "Renamed");
6587
6588 store.delete(&rel).unwrap();
6589 assert!(!store.issues_dir.join(&rel).exists());
6590 assert!(store.list().unwrap().is_empty());
6591 }
6592
6593 #[test]
6594 fn legacy_flat_file_edit_and_status_still_work_after_layout_flip() {
6595 let (tmp, store) = make_store();
6596 let _ = tmp;
6597 write_issue(
6598 &store,
6599 "2026-01-01-legacy.md",
6600 &sample_frontmatter(5, "Legacy", "todo"),
6601 );
6602 let listed = store.list().unwrap();
6603 assert_eq!(listed.len(), 1);
6604 assert_eq!(listed[0].filename, "2026-01-01-legacy.md");
6605 let mutated = store
6606 .set_status("2026-01-01-legacy.md", StatusChange::to("in progress"))
6607 .unwrap();
6608 assert_eq!(mutated.issue.status.display(), "in progress");
6609 assert!(store.issues_dir.join("2026-01-01-legacy.md").is_file());
6610 }
6611
6612 fn write_asset_source(tmp: &tempfile::TempDir, name: &str, contents: &[u8]) -> PathBuf {
6617 let path = tmp.path().join(name);
6618 fs::write(&path, contents).unwrap();
6619 path
6620 }
6621
6622 fn write_issue_with_basic_body(store: &Store, name: &str, number: u32) {
6623 write_issue(
6624 store,
6625 name,
6626 &sample_frontmatter(number, "Asset host", "todo"),
6627 );
6628 }
6629
6630 #[test]
6631 fn add_asset_image_inserts_image_link_and_writes_file() {
6632 let (tmp, store) = make_store();
6633 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6634 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6635 let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
6636 let added = store
6637 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6638 .unwrap();
6639 assert_eq!(added.written_name, "diagram.png");
6640 assert_eq!(added.link, "");
6641 assert!(
6642 store
6643 .issues_dir
6644 .join("alice/fix-login-bug-assets/diagram.png")
6645 .is_file()
6646 );
6647 }
6648
6649 #[test]
6650 fn add_asset_non_image_inserts_generic_markdown_link() {
6651 let (tmp, store) = make_store();
6652 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6653 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6654 let src = write_asset_source(&tmp, "report.pdf", b"%PDF");
6655 let added = store
6656 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6657 .unwrap();
6658 assert_eq!(added.link, "[report](fix-login-bug-assets/report.pdf)");
6659 assert!(
6660 store
6661 .issues_dir
6662 .join("alice/fix-login-bug-assets/report.pdf")
6663 .is_file()
6664 );
6665 }
6666
6667 #[test]
6668 fn add_asset_collision_resolves_with_numeric_suffix_and_link_points_at_it() {
6669 let (tmp, store) = make_store();
6670 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6671 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6672 let src = write_asset_source(&tmp, "x.png", b"first");
6673 let first = store
6674 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6675 .unwrap();
6676 assert_eq!(first.written_name, "x.png");
6677 let again = store
6678 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6679 .unwrap();
6680 assert_eq!(again.written_name, "x-1.png");
6681 assert_eq!(again.link, "");
6682 assert!(
6683 store
6684 .issues_dir
6685 .join("alice/fix-login-bug-assets/x.png")
6686 .is_file()
6687 );
6688 assert!(
6689 store
6690 .issues_dir
6691 .join("alice/fix-login-bug-assets/x-1.png")
6692 .is_file()
6693 );
6694 }
6695
6696 #[test]
6697 fn add_asset_appends_single_link_and_preserves_description() {
6698 let (tmp, store) = make_store();
6699 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6700 write_issue(
6701 &store,
6702 "alice/2026-01-01-fix-login-bug.md",
6703 "---\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",
6704 );
6705 let src = write_asset_source(&tmp, "diagram.svg", b"<svg/>");
6706 store
6707 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6708 .unwrap();
6709 let reloaded = store
6710 .get(crate::storage::IssueSelector::Filename(
6711 "alice/2026-01-01-fix-login-bug.md".into(),
6712 ))
6713 .unwrap();
6714 assert_eq!(reloaded.description(), "Title line.");
6716 let link_count = reloaded.body.matches("![diagram]").count();
6718 assert_eq!(link_count, 1);
6719 assert!(
6720 reloaded
6721 .body
6722 .ends_with("fix-login-bug-assets/diagram.svg)\n")
6723 );
6724 }
6725
6726 #[test]
6727 fn add_asset_missing_issue_is_not_found() {
6728 let (tmp, store) = make_store();
6729 let _ = tmp;
6730 let src = write_asset_source(&tmp, "x.png", b"x");
6731 let err = store.add_asset("missing.md", &src).unwrap_err();
6732 assert!(matches!(err, TixError::NotFound(_)));
6733 }
6734
6735 #[test]
6736 fn add_asset_legacy_flat_file_uses_stem_assets_under_issues_dir() {
6737 let (tmp, store) = make_store();
6738 write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
6739 let src = write_asset_source(&tmp, "x.png", b"x");
6740 let added = store.add_asset("2026-01-01-legacy.md", &src).unwrap();
6741 assert_eq!(added.link, "");
6742 assert!(store.issues_dir.join("legacy-assets/x.png").is_file());
6743 }
6744
6745 #[test]
6750 fn reassign_moves_asset_dir_with_the_file() {
6751 let (tmp, store) = make_store();
6752 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6753 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6754 let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
6755 store
6756 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6757 .unwrap();
6758 let new_path = store
6759 .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
6760 .unwrap();
6761 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
6762 assert!(
6763 store
6764 .issues_dir
6765 .join("bob/fix-login-bug-assets/diagram.png")
6766 .is_file()
6767 );
6768 assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
6769 }
6770
6771 #[test]
6772 fn reassign_without_asset_dir_behaves_as_before() {
6773 let (tmp, store) = make_store();
6774 let _ = tmp;
6775 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6776 write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
6777 let new_path = store
6778 .reassign("alice/2026-01-01-plain.md", Some("@bob"))
6779 .unwrap();
6780 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-plain.md"));
6781 assert!(store.issues_dir.join("bob/2026-01-01-plain.md").is_file());
6782 assert!(!store.issues_dir.join("bob/plain-assets").exists());
6783 }
6784
6785 #[test]
6786 fn reassign_into_segment_with_same_named_asset_dir_uses_suffix_no_loss() {
6787 let (tmp, store) = make_store();
6788 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6789 fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
6790 let existing = store.issues_dir.join("bob/fix-login-bug-assets");
6792 fs::create_dir_all(&existing).unwrap();
6793 fs::write(existing.join("keep.txt"), "precious").unwrap();
6794 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6795 let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
6796 store
6797 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6798 .unwrap();
6799 let new_path = store
6800 .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
6801 .unwrap();
6802 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
6803 assert!(existing.join("keep.txt").is_file());
6805 assert!(
6806 store
6807 .issues_dir
6808 .join("bob/fix-login-bug-assets-1/diagram.png")
6809 .is_file()
6810 );
6811 assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
6812 }
6813
6814 #[test]
6815 fn reassign_preserves_markdown_link_resolution_in_new_segment() {
6816 let (tmp, store) = make_store();
6817 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6818 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6819 let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
6820 store
6821 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6822 .unwrap();
6823 store
6824 .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
6825 .unwrap();
6826 assert!(
6829 store
6830 .issues_dir
6831 .join("bob/fix-login-bug-assets/diagram.png")
6832 .is_file()
6833 );
6834 }
6835
6836 #[test]
6841 fn delete_removes_asset_dir_no_orphans() {
6842 let (tmp, store) = make_store();
6843 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6844 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6845 let src = write_asset_source(&tmp, "x.png", b"x");
6846 store
6847 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6848 .unwrap();
6849 store.delete("alice/2026-01-01-fix-login-bug.md").unwrap();
6850 assert!(
6851 !store
6852 .issues_dir
6853 .join("alice/2026-01-01-fix-login-bug.md")
6854 .exists()
6855 );
6856 assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
6857 }
6858
6859 #[test]
6860 fn delete_without_asset_dir_still_deletes_file() {
6861 let (tmp, store) = make_store();
6862 let _ = tmp;
6863 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6864 write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
6865 store.delete("alice/2026-01-01-plain.md").unwrap();
6866 assert!(!store.issues_dir.join("alice/2026-01-01-plain.md").exists());
6867 }
6868
6869 #[test]
6870 fn list_ignores_asset_directories() {
6871 let (tmp, store) = make_store();
6872 write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
6873 let src = write_asset_source(&tmp, "x.png", b"x");
6874 store.add_asset("2026-01-01-legacy.md", &src).unwrap();
6875 let issues = store.list().unwrap();
6878 assert_eq!(issues.len(), 1);
6879 assert_eq!(issues[0].filename, "2026-01-01-legacy.md");
6880 }
6881
6882 #[test]
6883 fn list_ignores_nested_asset_directories_under_segment() {
6884 let (tmp, store) = make_store();
6885 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6886 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6887 let src = write_asset_source(&tmp, "x.png", b"x");
6888 store
6889 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6890 .unwrap();
6891 let issues = store.list().unwrap();
6892 assert_eq!(issues.len(), 1);
6893 assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
6894 }
6895
6896 #[test]
6897 fn asset_dir_with_formless_md_child_is_not_scanned_as_segment() {
6898 let (tmp, store) = make_store();
6899 let _ = tmp;
6900 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6901 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6902 let assets_dir = store.issues_dir.join("alice/fix-login-bug-assets");
6905 fs::create_dir_all(&assets_dir).unwrap();
6906 fs::write(assets_dir.join("notes.md"), "no frontmatter").unwrap();
6907 let issues = store.list().unwrap();
6908 assert_eq!(issues.len(), 1);
6909 assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
6910 }
6911
6912 fn hierarchy_trio() -> (TempDir, Store) {
6926 let (tmp, store) = make_store();
6927 for (n, title) in [(1, "A"), (2, "B"), (3, "C")] {
6928 write_issue(
6929 &store,
6930 &format!("2026-01-01-{}.md", title.to_lowercase()),
6931 &bare_frontmatter(n, title),
6932 );
6933 }
6934 (tmp, store)
6935 }
6936
6937 fn store_from_hierarchy_fixture(name: &str) -> (TempDir, Store) {
6944 let (tmp, store) = make_store();
6945 write_issue(
6946 &store,
6947 name,
6948 &std::fs::read_to_string(format!(
6949 "{}/tests/fixtures/hierarchy/{name}",
6950 env!("CARGO_MANIFEST_DIR")
6951 ))
6952 .unwrap(),
6953 );
6954 (tmp, store)
6955 }
6956
6957 fn bytes_of(store: &Store, number: u32) -> String {
6958 let issue = store.get(IssueSelector::Number(number)).unwrap();
6959 read_issue_file(store, &issue.filename)
6960 }
6961
6962 #[test]
6963 fn t008_set_writes_the_field_and_exactly_one_history_event() {
6964 let (_tmp, store) = hierarchy_trio();
6965 let outcome = store.set_parent("2", "1").unwrap();
6966 assert!(outcome.changed);
6967 assert_eq!(outcome.parent, Some(1));
6968
6969 let child = store.get(IssueSelector::Number(2)).unwrap();
6970 assert_eq!(child.parent, Some(1), "C-001: the number is stored");
6971 assert_eq!(child.history.len(), 1, "C-004: exactly one event");
6972 let event = &child.history.events()[0];
6973 assert_eq!(event.kind, HistoryEventKind::ParentSet);
6974 assert!(
6975 !event.actor.is_empty(),
6976 "an event without an actor is not an audit record"
6977 );
6978 assert_eq!(event.detail, "parent none -> #1", "got: {}", event.detail);
6980 }
6981
6982 #[test]
6983 fn t008_change_repoints_and_records_parent_changed() {
6984 let (_tmp, store) = hierarchy_trio();
6985 store.set_parent("3", "1").unwrap();
6986 let outcome = store.change_parent("3", "2").unwrap();
6987 assert!(outcome.changed);
6988 assert_eq!(outcome.parent, Some(2));
6989
6990 let child = store.get(IssueSelector::Number(3)).unwrap();
6991 assert_eq!(child.parent, Some(2));
6992 assert_eq!(child.history.len(), 2, "one event per accepted change");
6993 let events = child.history.events();
6994 let last = events.last().unwrap();
6995 assert_eq!(last.kind, HistoryEventKind::ParentChanged);
6996 assert_eq!(last.detail, "parent #1 -> #2", "got: {}", last.detail);
6999 }
7000
7001 #[test]
7002 fn t008_clear_removes_the_field_and_records_parent_cleared() {
7003 let (_tmp, store) = hierarchy_trio();
7004 store.set_parent("2", "1").unwrap();
7005 let outcome = store.clear_parent("2").unwrap();
7006 assert!(outcome.changed);
7007 assert_eq!(outcome.parent, None);
7008
7009 let child = store.get(IssueSelector::Number(2)).unwrap();
7010 assert_eq!(child.parent, None);
7011 assert_eq!(child.history.len(), 2);
7012 let events = child.history.events();
7013 let last = events.last().unwrap();
7014 assert_eq!(last.kind, HistoryEventKind::ParentCleared);
7015 assert_eq!(last.detail, "parent #1 -> none", "got: {}", last.detail);
7016 }
7017
7018 #[test]
7019 fn t008_reapplying_the_same_parent_is_a_no_op_with_no_second_event() {
7020 let (_tmp, store) = hierarchy_trio();
7021 store.set_parent("2", "1").unwrap();
7022 let after_first = bytes_of(&store, 2);
7023
7024 let second = store.set_parent("2", "1").unwrap();
7025 assert!(!second.changed, "an unchanged parent is not a change");
7026 assert!(second.event.is_none(), "a no-op records nothing (C-004)");
7027 assert_eq!(
7028 bytes_of(&store, 2),
7029 after_first,
7030 "a no-op must not rewrite the file at all"
7031 );
7032 }
7033
7034 #[test]
7035 fn t008_clearing_a_child_with_no_parent_is_a_no_op() {
7036 let (_tmp, store) = hierarchy_trio();
7037 let before = bytes_of(&store, 2);
7038 let outcome = store.clear_parent("2").unwrap();
7039 assert!(!outcome.changed);
7040 assert!(outcome.event.is_none());
7041 assert_eq!(bytes_of(&store, 2), before);
7042 }
7043
7044 #[test]
7045 fn t008_sc004_a_rejected_cycle_leaves_every_file_byte_identical() {
7046 let (_tmp, store) = hierarchy_trio();
7048 store.set_parent("2", "1").unwrap();
7049 store.set_parent("3", "2").unwrap();
7050 let before: Vec<String> = (1..=3).map(|n| bytes_of(&store, n)).collect();
7051
7052 let err = store
7053 .set_parent("1", "3")
7054 .expect_err("1 -> 2 -> 3 -> 1 is a cycle and must be refused");
7055 let msg = err.to_string();
7056 assert!(
7057 msg.contains("cycle"),
7058 "the diagnostic must name the rule: {msg}"
7059 );
7060
7061 for n in 1..=3 {
7063 assert_eq!(
7064 bytes_of(&store, n),
7065 before[(n - 1) as usize],
7066 "issue {n} must be byte-identical after a rejected cycle"
7067 );
7068 }
7069 }
7070
7071 #[test]
7072 fn t008_a_self_parent_is_rejected_and_writes_nothing() {
7073 let (_tmp, store) = hierarchy_trio();
7074 let before = bytes_of(&store, 2);
7075 let err = store
7076 .set_parent("2", "2")
7077 .expect_err("an issue cannot be its own parent");
7078 assert!(err.to_string().contains("self_parent"), "got: {err}");
7079 assert_eq!(bytes_of(&store, 2), before);
7080 }
7081
7082 #[test]
7083 fn t008_a_self_parent_already_on_disk_is_still_refused() {
7084 let (_tmp, store) = store_from_hierarchy_fixture("self-parent.md");
7092 let before = bytes_of(&store, 7);
7093 let err = store
7094 .set_parent("7", "7")
7095 .expect_err("a stored self-parent must still be refused on a re-set");
7096 assert!(
7097 err.to_string().contains("self_parent"),
7098 "refused for the wrong reason: {err}"
7099 );
7100 assert_eq!(
7101 bytes_of(&store, 7),
7102 before,
7103 "and the refused write must leave the file byte-identical"
7104 );
7105 assert!(store.change_parent("7", "7").is_err());
7107 assert_eq!(store.get(IssueSelector::Number(7)).unwrap().parent, Some(7));
7109 }
7110
7111 #[test]
7112 fn t008_c003_setting_a_child_writes_nothing_to_the_parents_file() {
7113 let (_tmp, store) = hierarchy_trio();
7116 let parent_before = bytes_of(&store, 1);
7117 store.set_parent("2", "1").unwrap();
7118 store.set_parent("3", "1").unwrap();
7119 assert_eq!(
7120 bytes_of(&store, 1),
7121 parent_before,
7122 "the parent's file must be untouched by its children appearing"
7123 );
7124 assert!(
7125 !parent_before.contains("parent:"),
7126 "the parent is a top of the hierarchy and has no parent itself"
7127 );
7128 }
7129
7130 #[test]
7131 fn t008_a_legacy_file_without_the_field_is_unchanged_by_a_no_op_write() {
7132 let (_tmp, store) = hierarchy_trio();
7134 let before = bytes_of(&store, 2);
7135 assert!(!before.contains("parent:"), "the fixture must be legacy");
7136
7137 store.clear_parent("2").unwrap();
7138 assert_eq!(bytes_of(&store, 2), before, "a no-op must not add the key");
7139 }
7140
7141 #[test]
7142 fn t008_an_unresolvable_selector_is_refused_before_any_write() {
7143 let (_tmp, store) = hierarchy_trio();
7144 let before = bytes_of(&store, 2);
7145 assert!(store.set_parent("2", "no such issue").is_err());
7146 assert!(store.change_parent("2", "999").is_err());
7147 assert_eq!(bytes_of(&store, 2), before);
7148 }
7149
7150 #[test]
7151 fn t008_t008_contains_no_hierarchy_rule_of_its_own() {
7152 let (_tmp, store) = hierarchy_trio();
7168 store.set_parent("2", "1").unwrap();
7169 store.set_parent("3", "2").unwrap();
7170
7171 let issues = store.list().unwrap();
7172 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
7173 let index = HierarchyIndex::build(&views);
7174 let service =
7175 hierarchy::set_parent(&index, 1, 3).expect_err("the service refuses this too");
7176 let storage = store.set_parent("1", "3").expect_err("and so does storage");
7177
7178 assert_eq!(
7179 storage.to_string(),
7180 TixError::from(service).to_string(),
7181 "storage must surface the service's diagnosis verbatim, not its own"
7182 );
7183 let text = storage.to_string();
7186 assert!(
7187 text.contains("cycle:") && !text.contains("cycle: cycle:"),
7188 "a lookalike renderer or a doubled prefix: {text}"
7189 );
7190 assert!(
7191 text.starts_with("invalid input: "),
7192 "a hierarchy refusal must carry the same prefix as every other: {text}"
7193 );
7194 }
7195
7196 #[test]
7197 fn t008_a_no_op_reports_no_written_issue_but_still_reports_the_current_parent() {
7198 let (_tmp, store) = hierarchy_trio();
7202 store.set_parent("2", "1").unwrap();
7203 let again = store.set_parent("2", "1").unwrap();
7204 assert!(!again.changed);
7205 assert!(
7206 again.written.is_none(),
7207 "a no-op wrote nothing, so there is no written issue to hand back"
7208 );
7209 assert!(
7210 again.event.is_none(),
7211 "and no event, or a retry would pad the audit trail"
7212 );
7213 assert_eq!(
7214 again.parent,
7215 Some(1),
7216 "but the resulting state is still reported, so a caller need not re-read"
7217 );
7218 }
7219}