1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use crate::assignee::derive_segment;
6use crate::config::Config;
7use crate::error::{Result, TixError, render_diagnostics};
8use crate::hierarchy::{self, HierarchyIndex};
9use crate::model::{HistoryEventKind, Issue, Status, TransitionDecision};
10use crate::relationships::{
11 CanonicalEdge, Diagnostic, IssueView, RelationKind, Relationship, RelationshipGraph,
12};
13use crate::slug::slugify;
14
15pub struct Store {
16 pub issues_dir: PathBuf,
17 pub config: Config,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct RelationshipOutcome {
29 pub written: Option<Issue>,
31 pub relationship: Relationship,
33 pub changed: bool,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum IssueSelector {
39 Number(u32),
40 Filename(String),
41 Slug(String),
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct AssetAdded {
48 pub link: String,
49 pub written_name: String,
50}
51
52thread_local! {
63 static RETIRED_KEY_DISCLOSURES: std::cell::RefCell<Vec<String>> =
64 const { std::cell::RefCell::new(Vec::new()) };
65}
66
67pub(crate) fn retired_key_disclosure(filename: &str) {
71 RETIRED_KEY_DISCLOSURES.with(|queue| {
72 queue.borrow_mut().push(format!(
73 "remove `links:` by hand to silence \u{2014} {filename} (retired by \
74 C-001; never read as typed data, no migration)"
75 ));
76 });
77}
78
79pub fn take_disclosures() -> Vec<String> {
81 RETIRED_KEY_DISCLOSURES.with(|queue| std::mem::take(&mut *queue.borrow_mut()))
82}
83
84pub fn actor_identity() -> String {
95 crate::assignee::git_identity()
96 .map(|id| id.to_string())
97 .unwrap_or_else(|| "unknown".to_string())
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct StatusChange<'a> {
119 pub column: &'a str,
122 pub commit: Option<&'a str>,
130 pub override_reason: Option<&'a str>,
138}
139
140impl<'a> StatusChange<'a> {
141 pub fn to(column: &'a str) -> Self {
146 Self {
147 column,
148 commit: None,
149 override_reason: None,
150 }
151 }
152
153 pub fn with_commit(mut self, commit: Option<&'a str>) -> Self {
155 self.commit = commit;
156 self
157 }
158
159 pub fn with_override(mut self, reason: Option<&'a str>) -> Self {
162 self.override_reason = reason;
163 self
164 }
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
176pub struct StatusOutcome {
177 pub issue: Issue,
181 pub decision: Option<TransitionDecision>,
184 pub written: bool,
191}
192
193struct TransitionAccepted {
201 changed: bool,
202 from: String,
203 to: String,
204 from_category: crate::workflow::WorkflowCategory,
205 to_category: crate::workflow::WorkflowCategory,
206 override_reason: Option<String>,
207}
208
209impl TransitionAccepted {
210 fn try_from(
212 outcome: crate::workflow::TransitionOutcome,
213 ) -> std::result::Result<Self, crate::workflow::WorkflowDiagnostic> {
214 match outcome {
215 crate::workflow::TransitionOutcome::Accepted {
216 changed,
217 from,
218 to,
219 from_category,
220 to_category,
221 override_reason,
222 } => Ok(Self {
223 changed,
224 from,
225 to,
226 from_category,
227 to_category,
228 override_reason,
229 }),
230 crate::workflow::TransitionOutcome::Rejected(diagnostic) => Err(diagnostic),
231 }
232 }
233}
234
235fn canonical_placeholder(canonical: &CanonicalEdge) -> Relationship {
242 Relationship {
243 id: String::new(),
244 from: canonical.from,
245 to: canonical.to,
246 kind: canonical.kind,
247 display: String::new(),
248 }
249}
250
251fn relationship_error(errors: &[Diagnostic]) -> TixError {
258 if errors.is_empty() {
259 return TixError::InvalidInput("relationship rejected".into());
260 }
261 TixError::InvalidInput(render_diagnostics(
262 errors.iter().map(|d| (d.kind.as_str(), d.message.as_str())),
263 ))
264}
265
266struct NumberClaim {
315 path: PathBuf,
316 armed: bool,
320}
321
322impl NumberClaim {
323 const DIR: &'static str = ".tix-numbers";
325
326 fn acquire(issues_dir: &Path, start: u32) -> Result<Self> {
333 const MAX_ATTEMPTS: u32 = 10_000;
334 let dir = claims_dir(issues_dir);
335 std::fs::create_dir_all(&dir)?;
336 let mut number = start;
337 for _ in 0..MAX_ATTEMPTS {
338 let path = dir.join(format!("{number}.claim"));
339 match std::fs::OpenOptions::new()
340 .write(true)
341 .create_new(true)
342 .open(&path)
343 {
344 Ok(_) => {
345 return Ok(Self { path, armed: true });
346 }
347 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
348 number += 1;
349 }
350 Err(e) => return Err(TixError::Io(e)),
351 }
352 }
353 Err(TixError::Io(std::io::Error::new(
354 std::io::ErrorKind::AlreadyExists,
355 format!(
356 "no free issue number at or above {start} after {MAX_ATTEMPTS} attempts; \
357 {} is probably corrupt",
358 dir.display()
359 ),
360 )))
361 }
362
363 fn number(&self) -> Option<u32> {
365 self.path.file_stem()?.to_str()?.parse::<u32>().ok()
366 }
367
368 fn release_number(issues_dir: &Path, number: u32) {
375 let _ = std::fs::remove_file(claims_dir(issues_dir).join(format!("{number}.claim")));
376 }
377
378 fn release(&mut self) {
381 if self.armed {
382 let _ = std::fs::remove_file(&self.path);
383 self.armed = false;
384 }
385 }
386
387 fn commit(&mut self) {
395 self.armed = false;
396 }
397}
398
399impl Drop for NumberClaim {
400 fn drop(&mut self) {
405 self.release();
406 }
407}
408
409impl std::fmt::Debug for NumberClaim {
410 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411 f.debug_struct("NumberClaim")
412 .field("path", &self.path)
413 .field("armed", &self.armed)
414 .finish()
415 }
416}
417
418impl PartialEq for NumberClaim {
419 fn eq(&self, other: &Self) -> bool {
420 self.path == other.path
421 }
422}
423impl Eq for NumberClaim {}
424
425#[derive(Debug, Clone)]
428pub struct ParentOutcome {
429 pub written: Option<Issue>,
437 pub parent: Option<u32>,
439 pub event: Option<crate::hierarchy::ParentEvent>,
444 pub changed: bool,
446}
447
448impl Store {
449 pub fn open(config: &Config) -> Result<Store> {
450 let issues_dir = &config.issues_dir;
451 if !issues_dir.exists() {
452 std::fs::create_dir_all(issues_dir)?;
453 }
454 Ok(Store {
455 issues_dir: issues_dir.clone(),
456 config: config.clone(),
457 })
458 }
459
460 pub fn list(&self) -> Result<Vec<Issue>> {
470 self.scan(false)
471 }
472
473 pub fn list_all(&self) -> Result<Vec<Issue>> {
480 self.scan(true)
481 }
482
483 fn scan(&self, include_archived: bool) -> Result<Vec<Issue>> {
489 let mut issues: Vec<Issue> = Vec::new();
490 if !self.issues_dir.exists() {
491 return Ok(issues);
492 }
493 for entry in std::fs::read_dir(&self.issues_dir)? {
494 let entry = entry?;
495 let path = entry.path();
496 let name = entry.file_name().to_string_lossy().to_string();
497 if path.is_dir() {
498 if name.starts_with(".tix-tmp-") || name.ends_with("-assets") {
503 continue;
504 }
505 for child in std::fs::read_dir(&path)? {
506 let child = child?;
507 let child_path = child.path();
508 if !child_path.is_file() {
509 continue;
510 }
511 let child_name = child.file_name().to_string_lossy().to_string();
512 if !child_name.ends_with(".md") {
513 continue;
514 }
515 let archived = child_name.starts_with('.');
519 if archived && !include_archived {
520 continue;
521 }
522 let rel = format!("{name}/{child_name}");
524 let contents = std::fs::read_to_string(&child_path)?;
525 let mut issue = Issue::parse(&contents).map_err(|e| match e {
526 TixError::Parse(msg) => TixError::Parse(format!("{rel}: {msg}")),
527 other => other,
528 })?;
529 issue.filename = rel;
530 issue.archived = archived;
531 issues.push(issue);
532 }
533 } else if path.is_file() && name.ends_with(".md") {
534 let archived = name.starts_with('.');
536 if archived && !include_archived {
537 continue;
538 }
539 let contents = std::fs::read_to_string(&path)?;
540 let mut issue = Issue::parse(&contents).map_err(|e| match e {
541 TixError::Parse(msg) => TixError::Parse(format!("{name}: {msg}")),
542 other => other,
543 })?;
544 issue.filename = name;
545 issue.archived = archived;
546 issues.push(issue);
547 }
548 }
549 issues.sort_by(|a, b| a.filename.cmp(&b.filename));
550 Ok(issues)
551 }
552
553 pub fn assignee_segments(&self) -> Result<Vec<String>> {
561 let mut segments = Vec::new();
562 if !self.issues_dir.exists() {
563 return Ok(segments);
564 }
565 for entry in std::fs::read_dir(&self.issues_dir)? {
566 let entry = entry?;
567 if !entry.path().is_dir() {
568 continue;
569 }
570 let name = entry.file_name().to_string_lossy().to_string();
571 if name == "unassigned" || name.starts_with(".tix-tmp-") || name.ends_with("-assets") {
572 continue;
573 }
574 segments.push(name);
575 }
576 segments.sort();
577 segments.dedup();
578 Ok(segments)
579 }
580
581 pub fn get(&self, selector: IssueSelector) -> Result<Issue> {
582 match selector {
583 IssueSelector::Number(num) => {
584 let issues = self.list_all()?;
585 issues
586 .into_iter()
587 .find(|i| i.number == num)
588 .ok_or_else(|| TixError::NotFound(format!("issue #{num}")))
589 }
590 IssueSelector::Filename(name) => {
591 validate_filename(&name)?;
592 let path = self.issues_dir.join(&name);
593 if !path.is_file() {
594 return Err(TixError::NotFound(format!("file {name}")));
595 }
596 let contents = std::fs::read_to_string(&path)?;
597 let mut issue = Issue::parse(&contents)?;
598 issue.filename = name;
599 Ok(issue)
600 }
601 IssueSelector::Slug(slug) => {
602 let issues = self.list_all()?;
603 issues
604 .into_iter()
605 .find(|i| slugify(&i.title) == slug)
606 .ok_or_else(|| TixError::NotFound(format!("slug {slug}")))
607 }
608 }
609 }
610
611 pub fn read_issue_at(&self, rel: &Path) -> Result<Issue> {
625 let filename = rel.to_string_lossy().to_string();
626 let path = self.issues_dir.join(rel);
627 if !path.is_file() {
628 return Err(TixError::NotFound(format!("file {filename}")));
629 }
630 let contents = std::fs::read_to_string(&path)?;
631 let mut issue = Issue::parse(&contents).map_err(|e| match e {
632 TixError::Parse(msg) => TixError::Parse(format!("{filename}: {msg}")),
633 other => other,
634 })?;
635 issue.filename = filename;
636 issue.archived = rel
637 .file_name()
638 .and_then(|name| name.to_str())
639 .is_some_and(|name| name.starts_with('.'));
640 Ok(issue)
641 }
642
643 pub fn archive(&self, sel: &str) -> Result<Issue> {
656 self.set_archived(sel, true)
657 }
658
659 pub fn restore(&self, sel: &str) -> Result<Issue> {
663 self.set_archived(sel, false)
664 }
665
666 fn set_archived(&self, sel: &str, archive: bool) -> Result<Issue> {
667 let mut issue = self.resolve_issue(sel)?;
670 if issue.archived == archive {
671 return Err(TixError::InvalidInput(format!(
672 "{}: issue #{} ({}) is already {}",
673 if archive {
674 "already_archived"
675 } else {
676 "not_archived"
677 },
678 issue.number,
679 issue.filename,
680 if archive { "archived" } else { "active" },
681 )));
682 }
683 let old_rel = issue.filename.clone();
684 let new_rel = flip_archived(&old_rel, archive).ok_or_else(|| {
685 TixError::InvalidInput(format!(
686 "cannot {} {old_rel}: its filename does not carry the archived marker in the \
687 expected position",
688 if archive { "archive" } else { "restore" }
689 ))
690 })?;
691 let old_path = self.issues_dir.join(&old_rel);
692 let new_path = self.issues_dir.join(&new_rel);
693 if !old_path.is_file() {
694 return Err(TixError::NotFound(format!("file {old_rel}")));
695 }
696 if new_path.exists() {
697 return Err(TixError::InvalidInput(format!(
698 "cannot {} {old_rel}: {new_rel} already exists",
699 if archive { "archive" } else { "restore" }
700 )));
701 }
702 std::fs::rename(&old_path, &new_path)?;
707 issue.filename = new_rel.clone();
708 issue.archived = archive;
709 issue.history.append_now(
710 &actor_identity(),
711 if archive {
712 HistoryEventKind::Archived
713 } else {
714 HistoryEventKind::Restored
715 },
716 &format!("{old_rel} -> {new_rel}"),
717 );
718 self.update(&new_rel, &issue)?;
721 Ok(issue)
722 }
723
724 pub fn create(
740 &self,
741 issue: &mut Issue,
742 body: &str,
743 filename_stem: Option<&str>,
744 ) -> Result<PathBuf> {
745 issue.body = body.to_string();
746 issue.bump_updated();
747
748 std::fs::create_dir_all(&self.issues_dir)?;
749 issue.number = self.resolve_next_number()?;
750
751 let mut claim = NumberClaim::acquire(&self.issues_dir, issue.number)?;
765 issue.number = claim.number().ok_or_else(|| {
771 TixError::Io(std::io::Error::new(
772 std::io::ErrorKind::InvalidData,
773 format!("claimed number is unreadable from {}", claim.path.display()),
774 ))
775 })?;
776
777 let segment = derive_segment(issue.assignee.as_deref());
781 std::fs::create_dir_all(self.issues_dir.join(&segment))?;
782
783 let stem = match filename_stem {
784 Some(s) => s.to_string(),
785 None => issue.filename_stem(),
786 };
787 let contents = issue.to_file_contents();
790
791 let written = self.write_new_issue_file(&segment, &stem, contents.as_bytes());
792 match written {
797 Ok((filename, path)) => {
798 claim.commit();
799 issue.filename = filename;
800 Ok(path)
801 }
802 Err(e) => {
803 claim.release();
804 Err(e)
805 }
806 }
807 }
808
809 fn write_new_issue_file(
844 &self,
845 segment: &str,
846 stem: &str,
847 contents: &[u8],
848 ) -> Result<(String, PathBuf)> {
849 const MAX_ATTEMPTS: usize = 1_000;
850 let mut suffix = 1usize;
851 for _ in 0..MAX_ATTEMPTS {
852 let filename = if suffix == 1 {
853 format!("{segment}/{stem}.md")
854 } else {
855 format!("{segment}/{stem}-{suffix}.md")
856 };
857 let path = self.issues_dir.join(&filename);
858 match write_file_new(&self.issues_dir, &path, contents) {
859 Ok(()) => return Ok((filename, path)),
860 Err(TixError::Io(e)) if e.kind() == std::io::ErrorKind::AlreadyExists => {
863 suffix += 1;
864 }
865 Err(e) => return Err(e),
866 }
867 }
868 Err(TixError::Io(std::io::Error::new(
869 std::io::ErrorKind::AlreadyExists,
870 format!(
871 "could not find a free filename for {segment}/{stem}.md in {MAX_ATTEMPTS} attempts"
872 ),
873 )))
874 }
875
876 fn resolve_next_number(&self) -> Result<u32> {
897 let issues = self.list_all()?;
901 match issues.iter().map(|i| i.number).max() {
902 None => Ok(1),
903 Some(max) => max
904 .checked_add(1)
905 .ok_or_else(|| TixError::InvalidInput("issue-number overflow".to_string())),
906 }
907 }
908
909 pub fn config_path(&self) -> Result<std::path::PathBuf> {
910 if let Some(root) = Config::project_root(&self.issues_dir) {
911 Ok(root.join("tix.toml"))
912 } else {
913 Err(TixError::Config(
914 "tix.toml not found in any parent directory of issues_dir".into(),
915 ))
916 }
917 }
918
919 pub fn update(&self, filename: &str, issue: &Issue) -> Result<()> {
920 validate_filename(filename)?;
921 let path = self.issues_dir.join(filename);
922 if !path.is_file() {
923 return Err(TixError::NotFound(format!("file {filename}")));
924 }
925 if issue.has_retired_links_key {
937 retired_key_disclosure(filename);
953 }
954 let contents = issue.to_file_contents();
955 atomic_write(&self.issues_dir, &path, contents.as_bytes())
956 }
957
958 pub fn add_asset(&self, filename: &str, source_path: &Path) -> Result<AssetAdded> {
972 validate_filename(filename)?;
973 let mut issue = self.get(IssueSelector::Filename(filename.to_string()))?;
974
975 let (segment, basename) = split_relative(filename).ok_or_else(|| {
976 TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
977 })?;
978 let stem = basename
979 .strip_suffix(".md")
980 .ok_or_else(|| {
981 TixError::InvalidInput(format!(
982 "asset target must be a `.md` issue file: {filename}"
983 ))
984 })?
985 .to_string();
986 let slug = slug_from_stem(&stem);
987 let dir_name = format!("{slug}-assets");
988 let asset_dir = self.issues_dir.join(&segment).join(&dir_name);
989 std::fs::create_dir_all(&asset_dir)?;
990
991 let source_name = source_path
992 .file_name()
993 .and_then(|n| n.to_str())
994 .ok_or_else(|| {
995 TixError::InvalidInput("asset source must have a file name".to_string())
996 })?
997 .to_string();
998 let written_name = resolve_asset_name(&asset_dir, &source_name);
999 atomic_copy(&asset_dir, &asset_dir.join(&written_name), source_path)?;
1000
1001 let label = written_name
1002 .rsplit_once('.')
1003 .map(|(stem, _)| stem)
1004 .unwrap_or(&written_name);
1005 let link = if is_image_name(&written_name) {
1006 format!("")
1007 } else {
1008 format!("[{label}]({dir_name}/{written_name})")
1009 };
1010
1011 if !issue.body.is_empty() {
1015 if !issue.body.ends_with('\n') {
1016 issue.body.push('\n');
1017 }
1018 if !issue.body.ends_with("\n\n") {
1019 issue.body.push('\n');
1020 }
1021 }
1022 issue.body.push_str(&link);
1023 issue.body.push('\n');
1024 issue.bump_updated();
1025 self.update(filename, &issue)?;
1026
1027 Ok(AssetAdded { link, written_name })
1028 }
1029
1030 pub fn delete(&self, filename: &str) -> Result<()> {
1031 validate_filename(filename)?;
1032 let path = self.issues_dir.join(filename);
1033 if !path.is_file() {
1034 return Err(TixError::NotFound(format!("file {filename}")));
1035 }
1036 let freed = std::fs::read_to_string(&path)
1053 .ok()
1054 .and_then(|text| frontmatter_number(&text));
1055 std::fs::remove_file(&path)?;
1056 if let Some(number) = freed {
1057 NumberClaim::release_number(&self.issues_dir, number);
1058 }
1059 if let Some((segment, basename)) = split_relative(filename) {
1063 let stem = basename.strip_suffix(".md").unwrap_or(&basename);
1064 let slug = slug_from_stem(stem);
1065 let asset_dir = self
1066 .issues_dir
1067 .join(&segment)
1068 .join(format!("{slug}-assets"));
1069 if asset_dir.exists()
1070 && let Err(e) = std::fs::remove_dir_all(&asset_dir)
1071 {
1072 eprintln!(
1073 "warning: failed to remove asset dir {}: {e}",
1074 asset_dir.display()
1075 );
1076 }
1077 }
1078 Ok(())
1079 }
1080
1081 pub fn set_status(&self, filename: &str, request: StatusChange<'_>) -> Result<StatusOutcome> {
1127 let mut issue = self.get(IssueSelector::Filename(filename.to_string()))?;
1128
1129 let (_, resolution) = crate::workflow::active_resolution(&self.config);
1132
1133 let outcome = crate::workflow::validate_transition(
1136 &issue,
1137 request.column,
1138 &resolution,
1139 &self.config,
1140 request.override_reason,
1141 );
1142 let accepted = match TransitionAccepted::try_from(outcome) {
1145 Ok(accepted) => accepted,
1146 Err(diagnostic) => return Err(TixError::Workflow(diagnostic)),
1147 };
1148
1149 if !accepted.changed {
1154 return Ok(StatusOutcome {
1155 issue,
1156 decision: None,
1157 written: false,
1158 });
1159 }
1160
1161 let was_complete = crate::workflow::is_completed(&issue, &resolution);
1182 let reaches_complete = resolution
1183 .resolve(&accepted.to)
1184 .is_some_and(crate::workflow::WorkflowCategory::is_complete);
1185 if reaches_complete {
1186 issue.commit = request.commit.map(str::to_string);
1187 } else if was_complete {
1188 issue.commit = None;
1189 }
1190
1191 issue.status = Status::new(accepted.to.clone());
1192 issue.bump_updated();
1193
1194 let decision = TransitionDecision::new(
1197 crate::model::utc_now_rfc3339(),
1198 actor_identity(),
1203 accepted.from.clone(),
1204 accepted.to.clone(),
1205 accepted.from_category.key(),
1206 accepted.to_category.key(),
1207 accepted.override_reason,
1208 );
1209 issue.history.append(decision.to_event());
1210
1211 self.update(filename, &issue)?;
1212 Ok(StatusOutcome {
1213 issue,
1214 decision: Some(decision),
1215 written: true,
1216 })
1217 }
1218
1219 pub fn relationship_done_column(&self) -> String {
1249 crate::workflow::completion_column_or_default(self.config.default_config(), &self.config)
1261 }
1262
1263 pub fn link(&self, a: &str, b: &str, kind: RelationKind) -> Result<RelationshipOutcome> {
1277 let issue_a = self.resolve_issue(a)?;
1278 let issue_b = self.resolve_issue(b)?;
1279 let issues = self.list_all()?;
1283 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1284 let done_column = self.relationship_done_column();
1285 let graph = RelationshipGraph::new(&views, &done_column);
1286
1287 let verdict = graph.validate_create(issue_a.number, issue_b.number, kind);
1288 if verdict.blocking() {
1289 return Err(relationship_error(&verdict.errors));
1290 }
1291
1292 if let Some(existing) = graph.find_canonical(issue_a.number, issue_b.number, kind) {
1295 return Ok(RelationshipOutcome {
1301 written: None,
1302 relationship: existing.clone(),
1303 changed: false,
1304 });
1305 }
1306
1307 let canonical = CanonicalEdge::new(issue_a.number, issue_b.number, kind);
1308 let mut owner = self.get(IssueSelector::Number(canonical.from))?;
1309 let target_title = self
1310 .get(IssueSelector::Number(canonical.to))
1311 .map(|i| i.title)
1312 .unwrap_or_default();
1313 let relationship =
1314 Relationship::new(canonical.from, canonical.to, canonical.kind, &target_title);
1315 let detail = format!("created {}: {}", relationship.id, relationship.display);
1316 owner.relationships.push(relationship.clone());
1317 owner.history.append_now(
1318 &actor_identity(),
1319 HistoryEventKind::RelationshipAdded,
1320 &detail,
1321 );
1322 owner.bump_updated();
1323 self.update(&owner.filename, &owner)?;
1324
1325 Ok(RelationshipOutcome {
1326 written: Some(owner),
1327 relationship,
1328 changed: true,
1329 })
1330 }
1331
1332 pub fn hierarchy_index(&self) -> Result<(HierarchyIndex, Vec<Issue>)> {
1357 let issues = self.list_all()?;
1358 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1359 Ok((HierarchyIndex::build(&views), issues))
1360 }
1361
1362 fn child_in(snapshot: &[Issue], number: u32) -> Result<Issue> {
1367 snapshot
1368 .iter()
1369 .find(|i| i.number == number)
1370 .cloned()
1371 .ok_or_else(|| {
1372 TixError::NotFound(format!(
1373 "issue #{number} vanished between validating and writing it"
1374 ))
1375 })
1376 }
1377
1378 pub fn set_parent(&self, child: &str, parent: &str) -> Result<ParentOutcome> {
1384 let child_issue = self.resolve_issue(child)?;
1385 let parent_issue = self.resolve_issue(parent)?;
1386 let (index, issues) = self.hierarchy_index()?;
1387 let change = hierarchy::set_parent(&index, child_issue.number, parent_issue.number)?;
1388 self.commit_parent_change(Store::child_in(&issues, child_issue.number)?, change)
1389 }
1390
1391 pub fn change_parent(&self, child: &str, parent: &str) -> Result<ParentOutcome> {
1393 let child_issue = self.resolve_issue(child)?;
1394 let parent_issue = self.resolve_issue(parent)?;
1395 let (index, issues) = self.hierarchy_index()?;
1396 let change = hierarchy::change_parent(&index, child_issue.number, parent_issue.number)?;
1397 self.commit_parent_change(Store::child_in(&issues, child_issue.number)?, change)
1398 }
1399
1400 pub fn clear_parent(&self, child: &str) -> Result<ParentOutcome> {
1402 let child_issue = self.resolve_issue(child)?;
1403 let (index, issues) = self.hierarchy_index()?;
1404 let change = hierarchy::clear_parent(&index, child_issue.number)?;
1405 self.commit_parent_change(Store::child_in(&issues, child_issue.number)?, change)
1406 }
1407
1408 fn commit_parent_change(
1415 &self,
1416 mut issue: Issue,
1417 change: hierarchy::ParentChange,
1418 ) -> Result<ParentOutcome> {
1419 let Some(event) = change.event else {
1420 return Ok(ParentOutcome {
1425 written: None,
1426 parent: change.parent,
1427 event: None,
1428 changed: false,
1429 });
1430 };
1431 let previous = issue.parent;
1436 let kind = HistoryEventKind::from_parent(event.kind);
1437 let detail = hierarchy::change_detail(previous, change.parent);
1442 debug_assert_ne!(
1451 previous, change.parent,
1452 "an accepted change must alter the parent, or the event records nothing"
1453 );
1454 issue.parent = change.parent;
1457 issue.history.append_now(&actor_identity(), kind, &detail);
1458 issue.bump_updated();
1459 self.update(&issue.filename, &issue)?;
1460 Ok(ParentOutcome {
1461 written: Some(issue),
1462 parent: change.parent,
1463 event: Some(event),
1464 changed: true,
1465 })
1466 }
1467
1468 pub fn relationships_for(&self, number: u32) -> Result<Vec<Relationship>> {
1482 let issues = self.list_all()?;
1483 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1484 let done_column = self.relationship_done_column();
1485 let graph = RelationshipGraph::new(&views, &done_column);
1486 Ok(graph
1487 .relationships_for(number)
1488 .into_iter()
1489 .cloned()
1490 .collect())
1491 }
1492
1493 pub fn unlink(&self, a: &str, b: &str, kind: RelationKind) -> Result<RelationshipOutcome> {
1498 let canonical = self.canonical_request(a, b, kind)?;
1499 let stored = self.find_stored_rows(&canonical)?;
1500
1501 if stored.is_empty() {
1502 return Ok(RelationshipOutcome {
1506 written: None,
1507 relationship: canonical_placeholder(&canonical),
1508 changed: false,
1509 });
1510 }
1511
1512 let mut by_file: HashMap<String, Vec<Relationship>> = HashMap::new();
1517 for (filename, rel) in stored {
1518 by_file.entry(filename).or_default().push(rel);
1519 }
1520
1521 let actor = actor_identity();
1522 let mut file_order: Vec<String> = by_file.keys().cloned().collect();
1524 file_order.sort_unstable();
1525
1526 let mut removed_row: Option<Relationship> = None;
1532
1533 let all_removed: Vec<Relationship> = by_file.values().flatten().cloned().collect();
1539 let summary = if all_removed.len() == 1 {
1540 let one = &all_removed[0];
1541 format!("removed {}: {}", one.id, one.display)
1542 } else {
1543 let kinds: Vec<String> = all_removed.iter().map(|r| r.kind.to_string()).collect();
1544 format!(
1545 "removed {} relationships ({})",
1546 all_removed.len(),
1547 kinds.join(", ")
1548 )
1549 };
1550
1551 let mut written: Option<Issue> = None;
1552 for filename in &file_order {
1553 let doomed = &by_file[filename];
1554 let mut owner = self.get(IssueSelector::Filename(filename.clone()))?;
1555 if removed_row.is_none() {
1556 removed_row = doomed.first().cloned();
1557 }
1558 owner.relationships.retain(|r| r.canonical() != canonical);
1559 owner
1560 .history
1561 .append_now(&actor, HistoryEventKind::RelationshipRemoved, &summary);
1562 owner.bump_updated();
1563 self.update(&owner.filename, &owner)?;
1564 written = Some(owner);
1565 }
1566
1567 let relationship = removed_row.unwrap_or_else(|| canonical_placeholder(&canonical));
1570
1571 Ok(RelationshipOutcome {
1572 written,
1573 relationship,
1574 changed: true,
1575 })
1576 }
1577
1578 pub fn edit_relation(
1593 &self,
1594 id: &str,
1595 new_to: Option<u32>,
1596 new_kind: Option<RelationKind>,
1597 ) -> Result<RelationshipOutcome> {
1598 let issues = self.list_all()?;
1599 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1600 let done_column = self.relationship_done_column();
1601 let graph = RelationshipGraph::new(&views, &done_column);
1602
1603 let Some(existing) = graph.find_relationship(id) else {
1604 let mut verdict = crate::relationships::RelationshipValidationResult::clean();
1608 verdict.errors.push(Diagnostic::unknown_relation(id));
1609 return Err(relationship_error(&verdict.errors));
1610 };
1611
1612 let verdict = graph.validate_edit(id, new_to, new_kind);
1613 if verdict.blocking() {
1614 return Err(relationship_error(&verdict.errors));
1615 }
1616
1617 let to = new_to.unwrap_or(existing.to);
1618 let kind = new_kind.unwrap_or(existing.kind);
1619 let old_canonical = existing.canonical();
1620
1621 let requested = CanonicalEdge::new(existing.from, to, kind);
1633 let mut updated = existing.clone();
1634 updated.from = requested.from;
1635 updated.to = requested.to;
1636 updated.kind = requested.kind;
1637
1638 let target_title = self
1642 .get(IssueSelector::Number(updated.to))
1643 .map(|i| i.title)
1644 .unwrap_or_default();
1645 updated.refresh_display(&target_title);
1646 let new_canonical = updated.canonical();
1647
1648 if old_canonical == new_canonical && updated.display == existing.display {
1652 return Ok(RelationshipOutcome {
1653 written: None,
1654 relationship: existing.clone(),
1655 changed: false,
1656 });
1657 }
1658
1659 let actor = actor_identity();
1660 let detail = format!("{} → {}: {}", existing.display, updated.display, updated.id);
1661
1662 let mut removed_anywhere = false;
1678 for issue in self.list_all()? {
1679 if !issue
1680 .relationships
1681 .iter()
1682 .any(|r| r.id == id && r.canonical() == old_canonical)
1683 {
1684 continue;
1685 }
1686 let mut owner = self.get(IssueSelector::Filename(issue.filename.clone()))?;
1687 owner
1688 .relationships
1689 .retain(|r| !(r.id == id && r.canonical() == old_canonical));
1690 removed_anywhere = true;
1691 owner.bump_updated();
1692 self.update(&owner.filename, &owner)?;
1693 }
1694 debug_assert!(removed_anywhere, "graph found the row, the scan did not");
1695
1696 let mut new_owner = self.get(IssueSelector::Number(new_canonical.from))?;
1701 new_owner.relationships.push(updated.clone());
1702 new_owner
1703 .history
1704 .append_now(&actor, HistoryEventKind::RelationshipRetargeted, &detail);
1705 new_owner.bump_updated();
1706 self.update(&new_owner.filename, &new_owner)?;
1707
1708 Ok(RelationshipOutcome {
1709 written: Some(self.get(IssueSelector::Number(new_canonical.from))?),
1710 relationship: updated,
1711 changed: true,
1712 })
1713 }
1714
1715 pub fn remove_relation(&self, id: &str) -> Result<RelationshipOutcome> {
1721 let stored = self.find_stored_rows_by_id(id)?;
1722 if stored.is_empty() {
1723 return Ok(RelationshipOutcome {
1727 written: None,
1728 relationship: Relationship {
1729 id: id.to_string(),
1730 ..canonical_placeholder(&CanonicalEdge::new(0, 0, RelationKind::Related))
1731 },
1732 changed: false,
1733 });
1734 }
1735
1736 let canonical = stored[0].1.canonical();
1737 let from = canonical.from.to_string();
1741 let to = canonical.to.to_string();
1742 self.unlink(&from, &to, canonical.kind)
1743 }
1744
1745 fn find_stored_rows_by_id(&self, id: &str) -> Result<Vec<(String, Relationship)>> {
1751 let mut found = Vec::new();
1752 for issue in self.list_all()? {
1753 for rel in &issue.relationships {
1754 if rel.id == id {
1755 found.push((issue.filename.clone(), rel.clone()));
1756 }
1757 }
1758 }
1759 Ok(found)
1760 }
1761
1762 pub fn graph_source(&self) -> Result<(Vec<Issue>, String)> {
1781 Ok((self.list()?, self.relationship_done_column()))
1782 }
1783
1784 fn canonical_request(&self, a: &str, b: &str, kind: RelationKind) -> Result<CanonicalEdge> {
1793 let lenient = |sel: &str| -> Result<u32> {
1794 match self.resolve_issue(sel) {
1795 Ok(issue) => Ok(issue.number),
1796 Err(e) => sel.trim().parse::<u32>().map_err(|_| e),
1797 }
1798 };
1799 let from = lenient(a)?;
1800 let to = lenient(b)?;
1801 Ok(CanonicalEdge::new(from, to, kind))
1802 }
1803
1804 fn find_stored_rows(&self, canonical: &CanonicalEdge) -> Result<Vec<(String, Relationship)>> {
1820 let mut found = Vec::new();
1821 for issue in self.list_all()? {
1822 for rel in &issue.relationships {
1823 if rel.canonical() == *canonical {
1824 found.push((issue.filename.clone(), rel.clone()));
1825 }
1826 }
1827 }
1828 Ok(found)
1829 }
1830
1831 pub fn set_relationships(
1846 &self,
1847 filename: &str,
1848 targets: &[(u32, RelationKind)],
1849 ) -> Result<Issue> {
1850 let issue = self.get(IssueSelector::Filename(filename.to_string()))?;
1851 let own = issue.number;
1852 let actor = actor_identity();
1853
1854 let mut wanted: Vec<CanonicalEdge> = Vec::with_capacity(targets.len());
1856 for (target, kind) in targets {
1857 if *target == own {
1858 return Err(TixError::InvalidInput(format!(
1859 "issue #{own} cannot relate to itself"
1860 )));
1861 }
1862 self.get(IssueSelector::Number(*target))?;
1863 let canonical = CanonicalEdge::new(own, *target, *kind);
1864 if !wanted.contains(&canonical) {
1867 wanted.push(canonical);
1868 }
1869 }
1870
1871 {
1880 let issues = self.list_all()?;
1881 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1882 let done_column = self.relationship_done_column();
1883 let graph = RelationshipGraph::new(&views, &done_column);
1884 for canonical in &wanted {
1885 let verdict = graph.validate_create(canonical.from, canonical.to, canonical.kind);
1886 if verdict.blocking() {
1887 return Err(relationship_error(&verdict.errors));
1888 }
1889 }
1890 }
1891
1892 let issues = self.list_all()?;
1910 let mut removals: HashMap<u32, Vec<Relationship>> = HashMap::new();
1911 for candidate in &issues {
1912 for rel in &candidate.relationships {
1913 let canonical = rel.canonical();
1914 let owned_by_us = canonical.from == own
1915 || (canonical.to == own && !canonical.kind.is_directional());
1916 if owned_by_us && !wanted.contains(&canonical) {
1917 removals
1918 .entry(canonical.from)
1919 .or_default()
1920 .push(rel.clone());
1921 }
1922 }
1923 }
1924
1925 let mut additions: HashMap<u32, Vec<(CanonicalEdge, String)>> = HashMap::new();
1928 for canonical in &wanted {
1929 let owner = match self.get(IssueSelector::Number(canonical.from)) {
1930 Ok(owner) => owner,
1931 Err(TixError::NotFound(_)) => continue,
1932 Err(e) => return Err(e),
1933 };
1934 if owner
1935 .relationships
1936 .iter()
1937 .any(|r| r.canonical() == *canonical)
1938 {
1939 continue;
1940 }
1941 let target_title = self
1942 .get(IssueSelector::Number(canonical.to))
1943 .map(|i| i.title)
1944 .unwrap_or_default();
1945 additions
1946 .entry(canonical.from)
1947 .or_default()
1948 .push((*canonical, target_title));
1949 }
1950
1951 if removals.is_empty() && additions.is_empty() {
1952 return Ok(issue);
1953 }
1954
1955 let mut removal_order: Vec<u32> = removals.keys().copied().collect();
1976 removal_order.sort_unstable();
1977 let mut addition_order: Vec<u32> = additions.keys().copied().collect();
1978 addition_order.sort_unstable();
1979
1980 fn apply(
1985 store: &Store,
1986 mut owner: Issue,
1987 undo: &mut Vec<(PathBuf, String)>,
1988 touched_named: &mut bool,
1989 named_path: &Path,
1990 ) -> Result<()> {
1991 let path = store.issues_dir.join(&owner.filename);
1992 if path == named_path {
1993 *touched_named = true;
1994 }
1995 if let Ok(previous) = std::fs::read_to_string(&path) {
1996 undo.push((path, previous));
1997 }
1998 owner.bump_updated();
1999 store.update(&owner.filename, &owner)
2000 }
2001
2002 let named_path = self.issues_dir.join(&issue.filename);
2003 let mut undo: Vec<(PathBuf, String)> = Vec::new();
2004 let mut touched_named = false;
2005
2006 let applied = (|| -> Result<()> {
2007 for owner_number in &removal_order {
2008 let doomed = &removals[owner_number];
2009 let mut owner = self.get(IssueSelector::Number(*owner_number))?;
2010 let mut changed = false;
2011 for rel in doomed {
2012 let canonical = rel.canonical();
2013 let before = owner.relationships.len();
2014 owner.relationships.retain(|r| r.canonical() != canonical);
2015 if owner.relationships.len() != before {
2016 let detail = format!("removed {}: {}", rel.id, rel.display);
2017 owner.history.append_now(
2018 &actor,
2019 HistoryEventKind::RelationshipRemoved,
2020 &detail,
2021 );
2022 changed = true;
2023 }
2024 }
2025 if changed {
2026 apply(self, owner, &mut undo, &mut touched_named, &named_path)?;
2027 }
2028 }
2029
2030 for owner_number in &addition_order {
2031 let wanted_here = &additions[owner_number];
2032 let mut owner = self.get(IssueSelector::Number(*owner_number))?;
2033 for (canonical, target_title) in wanted_here {
2034 let rel = Relationship::new(
2035 canonical.from,
2036 canonical.to,
2037 canonical.kind,
2038 target_title,
2039 );
2040 let detail = format!("created {}: {}", rel.id, rel.display);
2041 owner
2042 .history
2043 .append_now(&actor, HistoryEventKind::RelationshipAdded, &detail);
2044 owner.relationships.push(rel);
2045 }
2046 apply(self, owner, &mut undo, &mut touched_named, &named_path)?;
2047 }
2048
2049 if !touched_named {
2063 let named = self.get(IssueSelector::Filename(filename.to_string()))?;
2064 apply(self, named, &mut undo, &mut touched_named, &named_path)?;
2065 }
2066 Ok(())
2067 })();
2068
2069 if let Err(e) = applied {
2070 for (path, previous) in undo.iter().rev() {
2071 let _ = atomic_write(&self.issues_dir, path, previous.as_bytes());
2072 }
2073 return Err(e);
2074 }
2075
2076 self.get(IssueSelector::Filename(filename.to_string()))
2077 }
2078
2079 pub fn resolve_issue(&self, raw: &str) -> Result<Issue> {
2089 let selector = crate::commands::parse_selector(self, raw)?;
2090 self.get(selector)
2091 }
2092
2093 pub fn reassign(&self, filename: &str, new_assignee: Option<&str>) -> Result<PathBuf> {
2116 self.get(IssueSelector::Filename(filename.to_string()))?;
2118
2119 let (old_segment, file_name) = split_relative(filename).ok_or_else(|| {
2120 TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
2121 })?;
2122 let new_segment = derive_segment(new_assignee);
2123 if old_segment == new_segment {
2124 return Ok(PathBuf::from(filename));
2125 }
2126
2127 let stem = file_name
2128 .strip_suffix(".md")
2129 .unwrap_or(&file_name)
2130 .to_string();
2131 let target_dir = self.issues_dir.join(&new_segment);
2132 std::fs::create_dir_all(&target_dir)?;
2133 let new_name = resolve_target_name(&target_dir, &stem);
2134 std::fs::rename(self.issues_dir.join(filename), target_dir.join(&new_name))?;
2135
2136 let slug = slug_from_stem(&stem);
2142 let asset_name = format!("{slug}-assets");
2143 let old_asset_dir = self.issues_dir.join(&old_segment).join(&asset_name);
2144 if old_asset_dir.exists() {
2145 let moved = resolve_asset_dir_name(&target_dir, &asset_name);
2146 std::fs::rename(&old_asset_dir, target_dir.join(&moved))?;
2147 }
2148 Ok(PathBuf::from(format!("{new_segment}/{new_name}")))
2149 }
2150}
2151
2152pub(crate) fn is_issue_rel_path(rel: &Path) -> bool {
2163 let mut components = rel.components();
2164 let Some(std::path::Component::Normal(segment)) = components.next() else {
2165 return false;
2166 };
2167 let file_name = match components.next() {
2168 None => segment,
2170 Some(std::path::Component::Normal(name)) => {
2171 if components.next().is_some() {
2172 return false; }
2174 let Some(segment) = segment.to_str() else {
2175 return false;
2176 };
2177 if segment.starts_with(".tix-tmp-") || segment.ends_with("-assets") {
2178 return false;
2179 }
2180 name
2181 }
2182 Some(_) => return false,
2183 };
2184 file_name.to_str().is_some_and(|name| name.ends_with(".md"))
2185}
2186
2187fn validate_filename(filename: &str) -> Result<()> {
2196 if filename.is_empty() {
2197 return Err(TixError::InvalidInput(format!(
2198 "filename must not be empty: {filename:?}"
2199 )));
2200 }
2201 if filename.starts_with('/') {
2202 return Err(TixError::InvalidInput(format!(
2203 "filename must be relative (no leading `/`): {filename}"
2204 )));
2205 }
2206 if filename.contains('\\') {
2207 return Err(TixError::InvalidInput(format!(
2208 "filename must not contain backslashes: {filename}"
2209 )));
2210 }
2211 if filename.contains("..") {
2212 return Err(TixError::InvalidInput(format!(
2213 "filename must not contain `..`: {filename}"
2214 )));
2215 }
2216 if filename.contains('\0') {
2217 return Err(TixError::InvalidInput(format!(
2218 "filename must not contain NUL: {filename}"
2219 )));
2220 }
2221 if filename.split('/').count() > 2 {
2222 return Err(TixError::InvalidInput(format!(
2223 "filename may have at most one segment prefix: {filename}"
2224 )));
2225 }
2226 if filename.split('/').any(|component| component.is_empty()) {
2227 return Err(TixError::InvalidInput(format!(
2228 "filename path components must not be empty: {filename}"
2229 )));
2230 }
2231 Ok(())
2232}
2233
2234fn frontmatter_number(text: &str) -> Option<u32> {
2250 let mut lines = text.lines();
2251 if lines.next()?.trim() != "---" {
2253 return None;
2254 }
2255 for line in lines {
2256 let line = line.trim_end();
2257 if line == "---" {
2260 return None;
2261 }
2262 if let Some(rest) = line.strip_prefix("number:") {
2263 return rest.trim().parse().ok();
2264 }
2265 }
2266 None
2267}
2268
2269fn claims_dir(issues_dir: &Path) -> PathBuf {
2281 issues_dir
2282 .parent()
2283 .unwrap_or(issues_dir)
2284 .join(NumberClaim::DIR)
2285}
2286
2287fn split_relative(filename: &str) -> Option<(String, String)> {
2288 let mut parts = filename.rsplitn(2, '/');
2289 let basename = parts.next()?;
2290 let segment = parts.next().unwrap_or("");
2291 Some((segment.to_string(), basename.to_string()))
2292}
2293
2294fn flip_archived(filename: &str, archive: bool) -> Option<String> {
2301 let (segment, basename) = split_relative(filename)?;
2302 let flipped = if archive {
2303 if basename.starts_with('.') {
2304 return None;
2305 }
2306 format!(".{basename}")
2307 } else {
2308 basename.strip_prefix('.')?.to_string()
2309 };
2310 Some(if segment.is_empty() {
2311 flipped
2312 } else {
2313 format!("{segment}/{flipped}")
2314 })
2315}
2316
2317fn resolve_target_name(dir: &Path, stem: &str) -> String {
2320 let mut candidate = format!("{stem}.md");
2321 let mut i = 1u32;
2322 while dir.join(&candidate).exists() {
2323 candidate = format!("{stem}-{i}.md");
2324 i += 1;
2325 }
2326 candidate
2327}
2328
2329fn slug_from_stem(stem: &str) -> String {
2333 let bytes = stem.as_bytes();
2334 if stem.len() > 10 && bytes[4] == b'-' && bytes[7] == b'-' && bytes[10] == b'-' {
2335 stem[11..].to_string()
2336 } else {
2337 stem.to_string()
2338 }
2339}
2340
2341fn resolve_asset_name(dir: &Path, name: &str) -> String {
2345 if !dir.join(name).exists() {
2346 return name.to_string();
2347 }
2348 let (stem, ext) = match name.rsplit_once('.') {
2349 Some((s, e)) => (s, format!(".{e}")),
2350 None => (name, String::new()),
2351 };
2352 let mut i = 1u32;
2353 loop {
2354 let candidate = format!("{stem}-{i}{ext}");
2355 if !dir.join(&candidate).exists() {
2356 return candidate;
2357 }
2358 i += 1;
2359 }
2360}
2361
2362fn resolve_asset_dir_name(dir: &Path, name: &str) -> String {
2365 let mut candidate = name.to_string();
2366 let mut i = 1u32;
2367 while dir.join(&candidate).exists() {
2368 candidate = format!("{name}-{i}");
2369 i += 1;
2370 }
2371 candidate
2372}
2373
2374fn atomic_copy(dir: &Path, target: &Path, source: &Path) -> Result<()> {
2377 let pid = std::process::id();
2378 let nonce = nanos();
2379 let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
2380 let copy_result = (|| -> std::io::Result<()> {
2381 std::fs::copy(source, &tmp)?;
2382 let f = std::fs::File::open(&tmp)?;
2383 f.sync_all()?;
2384 Ok(())
2385 })();
2386 if let Err(e) = copy_result {
2387 let _ = std::fs::remove_file(&tmp);
2388 return Err(TixError::Io(e));
2389 }
2390 if let Err(e) = std::fs::rename(&tmp, target) {
2391 let _ = std::fs::remove_file(&tmp);
2392 return Err(TixError::Io(e));
2393 }
2394 Ok(())
2395}
2396
2397fn is_image_name(name: &str) -> bool {
2399 matches!(
2400 name.rsplit('.')
2401 .next()
2402 .unwrap_or("")
2403 .to_ascii_lowercase()
2404 .as_str(),
2405 "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp"
2406 )
2407}
2408
2409fn atomic_write(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
2410 write_file(dir, target, data, Link::Overwrite)
2411}
2412
2413fn write_file_new(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
2432 write_file(dir, target, data, Link::Exclusive)
2433}
2434
2435#[derive(Clone, Copy)]
2436enum Link {
2437 Overwrite,
2438 Exclusive,
2439}
2440
2441fn write_file(dir: &Path, target: &Path, data: &[u8], link: Link) -> Result<()> {
2442 let pid = std::process::id();
2443 let nonce = nanos();
2444 let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
2445 let write_result = (|| -> std::io::Result<()> {
2446 let mut f = std::fs::File::create(&tmp)?;
2447 use std::io::Write;
2448 f.write_all(data)?;
2449 f.sync_all()?;
2450 Ok(())
2451 })();
2452 if let Err(e) = write_result {
2453 let _ = std::fs::remove_file(&tmp);
2454 return Err(TixError::Io(e));
2455 }
2456 let link_result = match link {
2457 Link::Overwrite => std::fs::rename(&tmp, target),
2458 Link::Exclusive => match std::fs::hard_link(&tmp, target) {
2459 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e),
2463 Err(_) => std::fs::rename(&tmp, target),
2466 Ok(()) => {
2467 let _ = std::fs::remove_file(&tmp);
2468 Ok(())
2469 }
2470 },
2471 };
2472 if let Err(e) = link_result {
2473 let _ = std::fs::remove_file(&tmp);
2474 return Err(TixError::Io(e));
2475 }
2476 let _ = std::fs::File::open(dir).and_then(|f| f.sync_all());
2477 Ok(())
2478}
2479
2480fn nanos() -> u64 {
2481 SystemTime::now()
2482 .duration_since(UNIX_EPOCH)
2483 .map(|d| d.as_nanos() as u64)
2484 .unwrap_or(0)
2485}
2486
2487#[cfg(test)]
2488mod tests {
2489 use super::*;
2490 use crate::model::{Priority, today};
2492 use crate::relationships::DiagnosticKind;
2493 use std::fs;
2494 use tempfile::TempDir;
2495
2496 #[test]
2509 fn write_file_new_refuses_an_existing_target() {
2510 let dir = TempDir::new().unwrap();
2511 let target = dir.path().join("issue.md");
2512 write_file_new(dir.path(), &target, b"first").unwrap();
2513 assert_eq!(fs::read_to_string(&target).unwrap(), "first");
2514
2515 let err = write_file_new(dir.path(), &target, b"second").unwrap_err();
2516 let TixError::Io(io_err) = err else {
2517 panic!("expected an I/O error, so the caller can retry on a name collision")
2518 };
2519 assert_eq!(
2520 io_err.kind(),
2521 std::io::ErrorKind::AlreadyExists,
2522 "the error must be AlreadyExists, so the caller can retry on a name \\
2523 collision rather than treating it as a hard failure"
2524 );
2525 assert_eq!(
2526 fs::read_to_string(&target).unwrap(),
2527 "first",
2528 "the existing file must be byte-identical — not truncated, not replaced"
2529 );
2530 }
2531
2532 #[test]
2534 fn a_clobbered_name_is_retried_rather_than_failing_or_overwriting() {
2535 let tmp = TempDir::new().unwrap();
2536 let dir = tmp.path();
2537 let store = Store::open(&crate::config::Config {
2538 issues_dir: dir.to_path_buf(),
2539 ..Default::default()
2540 })
2541 .unwrap();
2542
2543 let segment_dir = dir.join("seg");
2546 fs::create_dir_all(&segment_dir).unwrap();
2547
2548 for name in ["2026-01-01-race.md", "2026-01-01-race-2.md"] {
2551 fs::write(segment_dir.join(name), "planted").unwrap();
2552 }
2553
2554 let (filename, path) = store
2555 .write_new_issue_file("seg", "2026-01-01-race", b"mine")
2556 .unwrap();
2557 assert_eq!(filename, "seg/2026-01-01-race-3.md");
2558 assert_eq!(fs::read_to_string(&path).unwrap(), "mine");
2559 assert_eq!(
2560 fs::read_to_string(segment_dir.join("2026-01-01-race.md")).unwrap(),
2561 "planted"
2562 );
2563 assert_eq!(
2564 fs::read_to_string(segment_dir.join("2026-01-01-race-2.md")).unwrap(),
2565 "planted"
2566 );
2567 }
2568
2569 #[test]
2575 fn no_temp_files_are_left_behind_on_success_or_collision() {
2576 let dir = TempDir::new().unwrap();
2577 let target = dir.path().join("issue.md");
2578 write_file_new(dir.path(), &target, b"first").unwrap();
2579 let _ = write_file_new(dir.path(), &target, b"second");
2580 let leftovers: Vec<String> = fs::read_dir(dir.path())
2581 .unwrap()
2582 .filter_map(|e| e.ok())
2583 .map(|e| e.file_name().to_string_lossy().to_string())
2584 .filter(|n| n.starts_with(".tix-tmp-"))
2585 .collect();
2586 assert!(
2587 leftovers.is_empty(),
2588 "temp files must not survive, found {leftovers:?}"
2589 );
2590 }
2591
2592 fn make_store() -> (tempfile::TempDir, Store) {
2593 let tmp = tempfile::TempDir::new().unwrap();
2594 let dir = tmp.path().join("issues");
2595 fs::create_dir_all(&dir).unwrap();
2596 let config_path = tmp.path().join("tix.toml");
2597 let config = Config {
2598 issues_dir: dir.clone(),
2599 ..Config::default()
2600 };
2601 config.save(&config_path).unwrap();
2602 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
2603 let store = Store {
2604 issues_dir: dir,
2605 config,
2606 };
2607 (tmp, store)
2608 }
2609
2610 fn write_issue(store: &Store, name: &str, contents: &str) {
2611 fs::write(store.issues_dir.join(name), contents).unwrap();
2612 }
2613
2614 fn sample_frontmatter(number: u32, title: &str, status: &str) -> String {
2615 format!(
2616 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
2617 )
2618 }
2619
2620 #[test]
2629 fn list_never_opens_an_archived_file_but_list_all_does() {
2630 let (tmp, store) = make_store();
2631 let _ = tmp;
2632 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2633 write_issue(
2634 &store,
2635 "alice/2026-01-01-active.md",
2636 &sample_frontmatter(1, "Active", "todo"),
2637 );
2638 fs::write(
2639 store.issues_dir.join("alice/.2026-01-02-broken.md"),
2640 "this file has no frontmatter at all\n",
2641 )
2642 .unwrap();
2643
2644 let active = store
2645 .list()
2646 .expect("the default walk must not read the archived file");
2647 assert_eq!(active.len(), 1);
2648 assert_eq!(active[0].number, 1);
2649 assert!(!active[0].archived);
2650
2651 let all = store.list_all();
2652 assert!(
2653 matches!(all, Err(TixError::Parse(_))),
2654 "list_all() reads it, so the broken file is only invisible to list(): {all:?}"
2655 );
2656 }
2657
2658 #[test]
2659 fn archive_renames_in_place_and_restore_is_the_inverse() {
2660 let (tmp, store) = make_store();
2661 let _ = tmp;
2662 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2663 write_issue(
2664 &store,
2665 "alice/2026-01-01-x.md",
2666 &sample_frontmatter(5, "X", "todo"),
2667 );
2668
2669 let archived = store.archive("5").unwrap();
2670 assert_eq!(archived.filename, "alice/.2026-01-01-x.md");
2671 assert!(archived.archived);
2672 assert!(store.issues_dir.join("alice/.2026-01-01-x.md").is_file());
2673 assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
2674 assert!(
2675 store.list().unwrap().is_empty(),
2676 "hidden from the default view"
2677 );
2678
2679 let all = store.list_all().unwrap();
2680 assert_eq!(all.len(), 1);
2681 assert!(all[0].archived);
2682 assert_eq!(all[0].number, 5, "the number is untouched");
2683
2684 assert_eq!(
2686 store.get(IssueSelector::Number(5)).unwrap().filename,
2687 "alice/.2026-01-01-x.md"
2688 );
2689 assert!(store.get(IssueSelector::Slug(slugify("X"))).is_ok());
2690
2691 let restored = store.restore("5").unwrap();
2692 assert_eq!(restored.filename, "alice/2026-01-01-x.md");
2693 assert!(!restored.archived);
2694 assert_eq!(store.list().unwrap().len(), 1, "visible again");
2695 }
2696
2697 #[test]
2698 fn archive_appends_one_event_and_leaves_the_frontmatter_untouched() {
2699 let (tmp, store) = make_store();
2700 let _ = tmp;
2701 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2702 write_issue(
2703 &store,
2704 "alice/2026-01-01-x.md",
2705 &sample_frontmatter(5, "X", "todo"),
2706 );
2707 let before = store
2708 .get(IssueSelector::Filename("alice/2026-01-01-x.md".into()))
2709 .unwrap();
2710
2711 let archived = store.archive("5").unwrap();
2712 assert_eq!(archived.history.len(), 1, "exactly one History event");
2713 assert_eq!(
2714 archived.history.events()[0].kind,
2715 HistoryEventKind::Archived
2716 );
2717
2718 let after = store.get(IssueSelector::Number(5)).unwrap();
2719 assert_eq!(after.number, before.number);
2723 assert_eq!(after.title, before.title);
2724 assert_eq!(after.status, before.status);
2725 assert_eq!(after.labels, before.labels);
2726 assert_eq!(after.priority, before.priority);
2727 assert_eq!(after.assignee, before.assignee);
2728 assert_eq!(after.created, before.created);
2729 assert_eq!(
2730 after.updated, before.updated,
2731 "archiving must not bump `updated`: it is a frontmatter field"
2732 );
2733 let raw = read_issue_file(&store, "alice/.2026-01-01-x.md");
2735 let fm = raw.split("\n---").next().unwrap();
2736 assert!(
2737 !fm.contains("archived"),
2738 "the archived flag must never reach the frontmatter: {fm}"
2739 );
2740 }
2741
2742 #[test]
2743 fn restore_appends_one_restored_event() {
2744 let (tmp, store) = make_store();
2745 let _ = tmp;
2746 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2747 write_issue(
2748 &store,
2749 "alice/2026-01-01-x.md",
2750 &sample_frontmatter(5, "X", "todo"),
2751 );
2752 store.archive("5").unwrap();
2753 let restored = store.restore("5").unwrap();
2754 assert_eq!(restored.history.len(), 2, "archive + restore");
2755 assert_eq!(
2756 restored.history.events()[1].kind,
2757 HistoryEventKind::Restored
2758 );
2759 }
2760
2761 #[test]
2762 fn archiving_the_highest_number_does_not_free_it_for_reuse() {
2763 let (tmp, store) = make_store();
2764 let _ = tmp;
2765 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2766 write_issue(
2767 &store,
2768 "alice/2026-01-01-a.md",
2769 &sample_frontmatter(1, "A", "todo"),
2770 );
2771 write_issue(
2772 &store,
2773 "alice/2026-01-01-b.md",
2774 &sample_frontmatter(7, "B", "todo"),
2775 );
2776
2777 store.archive("7").unwrap();
2778
2779 let mut fresh = Issue::parse(&sample_frontmatter(0, "New", "todo")).unwrap();
2780 store.create(&mut fresh, "body", None).unwrap();
2781 assert_eq!(
2782 fresh.number, 8,
2783 "an archived issue keeps its number: `create` must not reuse #7"
2784 );
2785 }
2786
2787 #[test]
2788 fn redundant_archive_and_restore_are_refused_by_name() {
2789 let (tmp, store) = make_store();
2790 let _ = tmp;
2791 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2792 write_issue(
2793 &store,
2794 "alice/2026-01-01-x.md",
2795 &sample_frontmatter(5, "X", "todo"),
2796 );
2797
2798 let err = store.restore("5").unwrap_err();
2799 assert!(
2800 err.to_string().contains("not_archived"),
2801 "an active issue cannot be restored: {err}"
2802 );
2803 assert!(
2804 store.issues_dir.join("alice/2026-01-01-x.md").is_file(),
2805 "the refused restore writes nothing"
2806 );
2807
2808 store.archive("5").unwrap();
2809 let err = store.archive("5").unwrap_err();
2810 assert!(
2811 err.to_string().contains("already_archived"),
2812 "an archived issue cannot be re-archived: {err}"
2813 );
2814 assert!(
2815 store.issues_dir.join("alice/.2026-01-01-x.md").is_file(),
2816 "the refused archive writes nothing"
2817 );
2818 }
2819
2820 #[test]
2821 fn archive_never_clobbers_an_occupied_target_name() {
2822 let (tmp, store) = make_store();
2823 let _ = tmp;
2824 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2825 write_issue(
2826 &store,
2827 "alice/2026-01-01-x.md",
2828 &sample_frontmatter(5, "X", "todo"),
2829 );
2830 write_issue(
2832 &store,
2833 "alice/.2026-01-01-x.md",
2834 &sample_frontmatter(6, "Other", "todo"),
2835 );
2836 let err = store.archive("5").unwrap_err();
2837 assert!(err.to_string().contains("already exists"), "got: {err}");
2838 assert!(store.issues_dir.join("alice/2026-01-01-x.md").is_file());
2839 assert!(store.issues_dir.join("alice/.2026-01-01-x.md").is_file());
2840 }
2841
2842 #[test]
2843 fn archive_and_restore_a_legacy_flat_file_in_place() {
2844 let (tmp, store) = make_store();
2845 let _ = tmp;
2846 write_issue(
2847 &store,
2848 "2026-01-01-legacy.md",
2849 &sample_frontmatter(3, "Legacy", "todo"),
2850 );
2851
2852 let archived = store.archive("3").unwrap();
2853 assert_eq!(archived.filename, ".2026-01-01-legacy.md");
2854 assert!(store.issues_dir.join(".2026-01-01-legacy.md").is_file());
2855 assert!(store.list().unwrap().is_empty());
2856 assert_eq!(store.list_all().unwrap().len(), 1);
2857
2858 let restored = store.restore("3").unwrap();
2859 assert_eq!(restored.filename, "2026-01-01-legacy.md");
2860 assert!(store.issues_dir.join("2026-01-01-legacy.md").is_file());
2861 }
2862
2863 #[test]
2864 fn list_returns_sorted_by_filename() {
2865 let (tmp, store) = make_store();
2866 let _ = tmp;
2867 write_issue(
2868 &store,
2869 "2026-03-01-c.md",
2870 &sample_frontmatter(3, "C", "todo"),
2871 );
2872 write_issue(
2873 &store,
2874 "2026-01-01-a.md",
2875 &sample_frontmatter(1, "A", "todo"),
2876 );
2877 write_issue(
2878 &store,
2879 "2026-02-01-b.md",
2880 &sample_frontmatter(2, "B", "todo"),
2881 );
2882 let issues = store.list().unwrap();
2883 assert_eq!(issues.len(), 3);
2884 assert_eq!(issues[0].number, 1);
2885 assert_eq!(issues[1].number, 2);
2886 assert_eq!(issues[2].number, 3);
2887 assert_eq!(issues[0].title, "A");
2888 }
2889
2890 #[test]
2893 fn is_issue_rel_path_matches_what_scan_walks() {
2894 for path in ["a.md", "alice/b.md", ".hidden.md", "alice/.hidden.md"] {
2896 assert!(
2897 is_issue_rel_path(Path::new(path)),
2898 "{path} is an issue file `scan` reads"
2899 );
2900 }
2901 for path in [
2903 "notes.txt",
2904 "alice/notes.txt",
2905 "alice",
2906 ".tix-tmp-x/a.md",
2907 "foo-assets/a.md",
2908 "deep/a/b.md",
2909 "alice/foo-assets/a.md",
2910 ] {
2911 assert!(
2912 !is_issue_rel_path(Path::new(path)),
2913 "{path} is not a path `scan` indexes"
2914 );
2915 }
2916 }
2917
2918 #[test]
2919 fn read_issue_at_sets_filename_and_archived_like_scan() {
2920 let (tmp, store) = make_store();
2921 let _ = tmp;
2922 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2923 write_issue(
2924 &store,
2925 "alice/2026-01-01-a.md",
2926 &sample_frontmatter(1, "A", "todo"),
2927 );
2928 write_issue(
2929 &store,
2930 "alice/.2026-01-02-b.md",
2931 &sample_frontmatter(2, "B", "todo"),
2932 );
2933
2934 let active = store
2935 .read_issue_at(Path::new("alice/2026-01-01-a.md"))
2936 .unwrap();
2937 assert_eq!(active.number, 1);
2938 assert_eq!(active.filename, "alice/2026-01-01-a.md");
2939 assert!(!active.archived);
2940
2941 let archived = store
2942 .read_issue_at(Path::new("alice/.2026-01-02-b.md"))
2943 .unwrap();
2944 assert_eq!(archived.filename, "alice/.2026-01-02-b.md");
2945 assert!(
2946 archived.archived,
2947 "the leading dot marks it archived, as `scan` does"
2948 );
2949
2950 write_issue(&store, "flat.md", &sample_frontmatter(3, "F", "todo"));
2952 assert_eq!(
2953 store.read_issue_at(Path::new("flat.md")).unwrap().filename,
2954 "flat.md"
2955 );
2956 assert!(matches!(
2957 store.read_issue_at(Path::new("alice/gone.md")),
2958 Err(TixError::NotFound(_))
2959 ));
2960 }
2961
2962 #[test]
2963 fn list_ignores_non_md_files() {
2964 let (tmp, store) = make_store();
2965 let _ = tmp;
2966 write_issue(
2967 &store,
2968 "2026-01-01-a.md",
2969 &sample_frontmatter(1, "A", "todo"),
2970 );
2971 write_issue(&store, "readme.txt", "not an issue");
2972 write_issue(&store, "2026-01-01-sub", "no extension");
2973 let issues = store.list().unwrap();
2974 assert_eq!(issues.len(), 1);
2975 }
2976
2977 #[test]
2978 fn list_errors_on_malformed_frontmatter() {
2979 let (tmp, store) = make_store();
2980 let _ = tmp;
2981 write_issue(&store, "2026-01-01-bad.md", "no frontmatter here\n");
2982 let err = store.list().unwrap_err();
2983 assert!(matches!(err, TixError::Parse(_)), "got {err:?}");
2984 }
2985
2986 #[test]
2987 fn get_by_number() {
2988 let (tmp, store) = make_store();
2989 let _ = tmp;
2990 write_issue(
2991 &store,
2992 "2026-01-01-x.md",
2993 &sample_frontmatter(5, "X", "todo"),
2994 );
2995 let issue = store.get(IssueSelector::Number(5)).unwrap();
2996 assert_eq!(issue.title, "X");
2997 }
2998
2999 #[test]
3000 fn get_by_filename() {
3001 let (tmp, store) = make_store();
3002 let _ = tmp;
3003 write_issue(
3004 &store,
3005 "2026-01-01-y.md",
3006 &sample_frontmatter(2, "Y", "done"),
3007 );
3008 let issue = store
3009 .get(IssueSelector::Filename("2026-01-01-y.md".into()))
3010 .unwrap();
3011 assert_eq!(issue.status.display(), "done");
3012 }
3013
3014 #[test]
3015 fn get_by_slug() {
3016 let (tmp, store) = make_store();
3017 let _ = tmp;
3018 write_issue(
3019 &store,
3020 "2026-01-01-z.md",
3021 &sample_frontmatter(3, "Z", "todo"),
3022 );
3023 let issue = store.get(IssueSelector::Slug("z".into())).unwrap();
3024 assert_eq!(issue.number, 3);
3025 }
3026
3027 #[test]
3028 fn get_not_found() {
3029 let (tmp, store) = make_store();
3030 let _ = tmp;
3031 assert!(store.get(IssueSelector::Number(99)).is_err());
3032 }
3033
3034 #[test]
3035 fn create_assigns_number_from_live_issues_and_leaves_config_untouched() {
3036 let (tmp, store) = make_store();
3037 let _ = tmp;
3038 assert_eq!(store.config.state.next_number, 0);
3039 let mut issue = Issue {
3040 number: 0,
3041 title: "First".into(),
3042 status: Status::default(),
3043 labels: vec![],
3044 priority: Priority::default(),
3045 assignee: None,
3046 estimate: None,
3047 commit: None,
3048 gitlab_id: None,
3049 linear_id: None,
3050 mission: None,
3051 task: None,
3052 relationships: vec![],
3053 files: vec![],
3054 cycles: None,
3055 milestones: None,
3056 reviewer: None,
3057 start_date: None,
3058 due_date: None,
3059 created: "2026-01-01".into(),
3060 updated: "2026-01-01".into(),
3061 filename: String::new(),
3062 body: String::new(),
3063 history: crate::model::History::new(),
3064 has_retired_links_key: false,
3065 parent: None,
3066 extra_frontmatter: std::collections::BTreeMap::new(),
3067 archived: false,
3068 entered_at: None,
3069 source: None,
3070 };
3071 let path = store.create(&mut issue, "Hello world", None).unwrap();
3072 assert!(path.exists());
3073 assert_eq!(
3074 issue.number, 1,
3075 "an empty store starts at 1, not 0 — #0 is indistinguishable from a \
3076 sentinel or a failed read (tix #1376)"
3077 );
3078 let config_path = tmp.path().join("tix.toml");
3080 let on_disk = std::fs::read_to_string(&config_path).unwrap();
3081 assert!(
3082 !on_disk.contains("next_number"),
3083 "create must not persist an issue-number counter:\n{on_disk}"
3084 );
3085 let reloaded = Config::load_with_warning(&config_path, |_| {}).unwrap();
3086 assert_eq!(reloaded.state.next_number, 0, "state is never written");
3087 }
3088
3089 #[test]
3090 fn create_resolves_max_live_number_plus_one() {
3091 let (tmp, store) = make_store();
3092 let _ = tmp;
3093 write_issue(
3094 &store,
3095 "2026-01-01-a.md",
3096 &sample_frontmatter(0, "A", "todo"),
3097 );
3098 write_issue(
3099 &store,
3100 "2026-01-01-b.md",
3101 &sample_frontmatter(1, "B", "todo"),
3102 );
3103 write_issue(
3104 &store,
3105 "2026-01-01-c.md",
3106 &sample_frontmatter(2, "C", "todo"),
3107 );
3108 let mut issue = Issue {
3109 number: 0,
3110 title: "D".into(),
3111 status: Status::default(),
3112 labels: vec![],
3113 priority: Priority::default(),
3114 assignee: None,
3115 estimate: None,
3116 commit: None,
3117 gitlab_id: None,
3118 linear_id: None,
3119 mission: None,
3120 task: None,
3121 relationships: vec![],
3122 files: vec![],
3123 cycles: None,
3124 milestones: None,
3125 reviewer: None,
3126 start_date: None,
3127 due_date: None,
3128 created: "2026-01-01".into(),
3129 updated: "2026-01-01".into(),
3130 filename: String::new(),
3131 body: String::new(),
3132 history: crate::model::History::new(),
3133 has_retired_links_key: false,
3134 parent: None,
3135 extra_frontmatter: std::collections::BTreeMap::new(),
3136 archived: false,
3137 entered_at: None,
3138 source: None,
3139 };
3140 store.create(&mut issue, "", None).unwrap();
3141 assert_eq!(issue.number, 3);
3142 }
3143
3144 #[test]
3145 fn create_reuses_number_of_deleted_highest_issue() {
3146 let (_tmp, store) = make_store();
3150 let mut issue_a = Issue {
3151 number: 0,
3152 title: "A".into(),
3153 status: Status::default(),
3154 labels: vec![],
3155 priority: Priority::default(),
3156 assignee: None,
3157 estimate: None,
3158 commit: None,
3159 gitlab_id: None,
3160 linear_id: None,
3161 mission: None,
3162 task: None,
3163 relationships: vec![],
3164 files: vec![],
3165 cycles: None,
3166 milestones: None,
3167 reviewer: None,
3168 start_date: None,
3169 due_date: None,
3170 created: "2026-01-01".into(),
3171 updated: "2026-01-01".into(),
3172 filename: String::new(),
3173 body: String::new(),
3174 history: crate::model::History::new(),
3175 has_retired_links_key: false,
3176 parent: None,
3177 extra_frontmatter: std::collections::BTreeMap::new(),
3178 archived: false,
3179 entered_at: None,
3180 source: None,
3181 };
3182 store.create(&mut issue_a, "", None).unwrap();
3183 let filename = issue_a.filename.clone();
3184 assert_eq!(issue_a.number, 1, "the first issue in a fresh store is #1");
3185 store.delete(&filename).unwrap();
3186 let mut issue_b = Issue {
3187 number: 0,
3188 title: "B".into(),
3189 status: Status::default(),
3190 labels: vec![],
3191 priority: Priority::default(),
3192 assignee: None,
3193 estimate: None,
3194 commit: None,
3195 gitlab_id: None,
3196 linear_id: None,
3197 mission: None,
3198 task: None,
3199 relationships: vec![],
3200 files: vec![],
3201 cycles: None,
3202 milestones: None,
3203 reviewer: None,
3204 start_date: None,
3205 due_date: None,
3206 created: "2026-01-02".into(),
3207 updated: "2026-01-02".into(),
3208 filename: String::new(),
3209 body: String::new(),
3210 history: crate::model::History::new(),
3211 has_retired_links_key: false,
3212 parent: None,
3213 extra_frontmatter: std::collections::BTreeMap::new(),
3214 archived: false,
3215 entered_at: None,
3216 source: None,
3217 };
3218 store.create(&mut issue_b, "", None).unwrap();
3219 assert_eq!(
3220 issue_b.number, 1,
3221 "an emptied store resumes at the same floor as a fresh one: 1"
3222 );
3223 }
3224
3225 #[test]
3226 fn create_with_custom_filename_stem() {
3227 let (tmp, store) = make_store();
3228 let _ = tmp;
3229 let mut issue = Issue {
3230 number: 0,
3231 title: "T".into(),
3232 status: Status::default(),
3233 labels: vec![],
3234 priority: Priority::default(),
3235 assignee: None,
3236 estimate: None,
3237 commit: None,
3238 gitlab_id: None,
3239 linear_id: None,
3240 mission: None,
3241 task: None,
3242 relationships: vec![],
3243 files: vec![],
3244 cycles: None,
3245 milestones: None,
3246 reviewer: None,
3247 start_date: None,
3248 due_date: None,
3249 created: "2026-01-01".into(),
3250 updated: "2026-01-01".into(),
3251 filename: String::new(),
3252 body: String::new(),
3253 history: crate::model::History::new(),
3254 has_retired_links_key: false,
3255 parent: None,
3256 extra_frontmatter: std::collections::BTreeMap::new(),
3257 archived: false,
3258 entered_at: None,
3259 source: None,
3260 };
3261 let path = store
3262 .create(&mut issue, "body", Some("custom-stem"))
3263 .unwrap();
3264 assert!(path.to_string_lossy().contains("custom-stem.md"));
3265 }
3266
3267 #[test]
3268 fn create_ensures_issues_dir_exists() {
3269 let tmp = tempfile::TempDir::new().unwrap();
3270 let dir = tmp.path().join("nested").join("issues");
3271 let config_path = tmp.path().join("tix.toml");
3272 let config = Config {
3273 issues_dir: dir.clone(),
3274 ..Config::default()
3275 };
3276 config.save(&config_path).unwrap();
3277 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
3278 let store = Store {
3279 issues_dir: dir,
3280 config,
3281 };
3282 let mut issue = Issue {
3283 number: 0,
3284 title: "T".into(),
3285 status: Status::default(),
3286 labels: vec![],
3287 priority: Priority::default(),
3288 assignee: None,
3289 estimate: None,
3290 commit: None,
3291 gitlab_id: None,
3292 linear_id: None,
3293 mission: None,
3294 task: None,
3295 relationships: vec![],
3296 files: vec![],
3297 cycles: None,
3298 milestones: None,
3299 reviewer: None,
3300 start_date: None,
3301 due_date: None,
3302 created: "2026-01-01".into(),
3303 updated: "2026-01-01".into(),
3304 filename: String::new(),
3305 body: String::new(),
3306 history: crate::model::History::new(),
3307 has_retired_links_key: false,
3308 parent: None,
3309 extra_frontmatter: std::collections::BTreeMap::new(),
3310 archived: false,
3311 entered_at: None,
3312 source: None,
3313 };
3314 store.create(&mut issue, "", None).unwrap();
3315 assert_eq!(issue.number, 1, "the first issue in a fresh store is #1");
3316 }
3317
3318 #[test]
3319 fn update_replaces_content_atomically() {
3320 let (tmp, store) = make_store();
3321 let _ = tmp;
3322 write_issue(
3323 &store,
3324 "2026-01-01-u.md",
3325 &sample_frontmatter(1, "U", "todo"),
3326 );
3327 let mut issue = store
3328 .get(IssueSelector::Filename("2026-01-01-u.md".into()))
3329 .unwrap();
3330 issue.title = "Updated".into();
3331 issue.bump_updated();
3332 store.update("2026-01-01-u.md", &issue).unwrap();
3333 let reloaded = store
3334 .get(IssueSelector::Filename("2026-01-01-u.md".into()))
3335 .unwrap();
3336 assert_eq!(reloaded.title, "Updated");
3337 }
3338
3339 #[test]
3340 fn update_not_found() {
3341 let (tmp, store) = make_store();
3342 let _ = tmp;
3343 let issue = Issue {
3344 number: 1,
3345 title: "T".into(),
3346 status: Status::default(),
3347 labels: vec![],
3348 priority: Priority::default(),
3349 assignee: None,
3350 estimate: None,
3351 commit: None,
3352 gitlab_id: None,
3353 linear_id: None,
3354 mission: None,
3355 task: None,
3356 relationships: vec![],
3357 files: vec![],
3358 cycles: None,
3359 milestones: None,
3360 reviewer: None,
3361 start_date: None,
3362 due_date: None,
3363 created: "2026-01-01".into(),
3364 updated: "2026-01-01".into(),
3365 filename: String::new(),
3366 body: String::new(),
3367 history: crate::model::History::new(),
3368 has_retired_links_key: false,
3369 parent: None,
3370 extra_frontmatter: std::collections::BTreeMap::new(),
3371 archived: false,
3372 entered_at: None,
3373 source: None,
3374 };
3375 assert!(store.update("nope.md", &issue).is_err());
3376 }
3377
3378 #[test]
3379 fn delete_removes_file() {
3380 let (tmp, store) = make_store();
3381 let _ = tmp;
3382 write_issue(
3383 &store,
3384 "2026-01-01-d.md",
3385 &sample_frontmatter(1, "D", "todo"),
3386 );
3387 assert!(store.issues_dir.join("2026-01-01-d.md").exists());
3388 store.delete("2026-01-01-d.md").unwrap();
3389 assert!(!store.issues_dir.join("2026-01-01-d.md").exists());
3390 }
3391
3392 #[test]
3393 fn delete_not_found() {
3394 let (tmp, store) = make_store();
3395 let _ = tmp;
3396 assert!(store.delete("nope.md").is_err());
3397 }
3398
3399 #[test]
3400 fn stray_tmp_file_not_treated_as_issue() {
3401 let (tmp, store) = make_store();
3402 let _ = tmp;
3403 write_issue(
3404 &store,
3405 "2026-01-01-a.md",
3406 &sample_frontmatter(1, "A", "todo"),
3407 );
3408 fs::write(store.issues_dir.join(".tix-tmp-9999-0"), "orphaned data").unwrap();
3409 let issues = store.list().unwrap();
3410 assert_eq!(issues.len(), 1);
3411 }
3412
3413 fn seam_store() -> (tempfile::TempDir, Store) {
3430 let tmp = tempfile::TempDir::new().unwrap();
3431 let dir = tmp.path().join("issues");
3432 fs::create_dir_all(&dir).unwrap();
3433 let config_path = tmp.path().join("tix.toml");
3434 let config = Config {
3435 issues_dir: dir.clone(),
3436 kanban: crate::config::KanbanSection {
3437 configurations: indexmap::IndexMap::from([(
3438 "default".to_string(),
3439 crate::config::BoardConfiguration {
3440 columns: vec![
3441 "triage".into(),
3442 "todo".into(),
3443 "doing".into(),
3444 "done".into(),
3445 ],
3446 done_column: Some("done".to_string()),
3447 category_map: indexmap::IndexMap::from([
3448 ("triage".to_string(), "backlog".to_string()),
3449 ("todo".to_string(), "unstarted".to_string()),
3450 ("doing".to_string(), "active".to_string()),
3451 ("done".to_string(), "completed".to_string()),
3452 ]),
3453 ..Default::default()
3454 },
3455 )]),
3456 },
3457 ..Config::default()
3458 };
3459 config.save(&config_path).unwrap();
3460 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
3461 (
3462 tmp,
3463 Store {
3464 issues_dir: dir,
3465 config,
3466 },
3467 )
3468 }
3469
3470 fn seam_issue(status: &str) -> (tempfile::TempDir, Store, String) {
3478 let (tmp, store) = seam_store();
3479 write_issue(
3480 &store,
3481 "2026-01-01-seam.md",
3482 &format!(
3483 "---\nnumber: 1\ntitle: \"Seam\"\nstatus: {status}\nlabels: []\n\
3484 priority: \"medium\"\nfiles: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody."
3485 ),
3486 );
3487 let before = read_issue_file(&store, "2026-01-01-seam.md");
3488 (tmp, store, before)
3489 }
3490
3491 fn assert_untouched(store: &Store, before: &str, what: &str) {
3493 let after = read_issue_file(store, "2026-01-01-seam.md");
3494 assert_eq!(after, before, "{what}: the file must be byte-identical");
3495 }
3496
3497 #[test]
3498 fn t015_accepted_transition_writes_status_bumps_updated_and_appends_one_decision() {
3499 let (_tmp, store, _before) = seam_issue("todo");
3500 let outcome = store
3501 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
3502 .unwrap();
3503 assert!(outcome.written, "an accepted, changing move writes");
3504 let decision = outcome
3505 .decision
3506 .clone()
3507 .expect("an accepted transition appends exactly one decision");
3508 assert_eq!(decision.from, "todo");
3509 assert_eq!(decision.to, "doing");
3510 assert_eq!(decision.from_category, "unstarted");
3511 assert_eq!(decision.to_category, "active");
3512 assert!(
3513 decision.override_reason.is_none(),
3514 "no override was supplied"
3515 );
3516 assert_eq!(
3517 outcome.issue.status.display(),
3518 "doing",
3519 "Issue.status holds the COLUMN name, never a category (C-001)"
3520 );
3521 assert_eq!(outcome.issue.updated, today(), "`updated` is bumped");
3522
3523 let reloaded = store
3525 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3526 .unwrap();
3527 assert_eq!(reloaded.status.display(), "doing");
3528 let events = reloaded.history.events();
3529 assert_eq!(
3530 events.len(),
3531 1,
3532 "exactly one History entry, and it is the decision: {events:?}"
3533 );
3534 assert_eq!(events[0].kind, HistoryEventKind::TransitionAccepted);
3535 assert_eq!(
3536 crate::model::TransitionDecision::from_event(events[0])
3537 .unwrap()
3538 .to,
3539 "doing",
3540 "and the decision round-trips out of the file with its fields intact"
3541 );
3542 }
3543
3544 #[test]
3545 fn t015_unknown_column_is_refused_by_name_and_writes_nothing() {
3546 let (_tmp, store, before) = seam_issue("todo");
3547 let err = store
3548 .set_status("2026-01-01-seam.md", StatusChange::to("nope"))
3549 .unwrap_err();
3550 assert!(
3551 matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "unknown_column"),
3552 "the shared named diagnostic, contract §6: {err:?}"
3553 );
3554 assert!(
3555 err.to_string()
3556 .contains("valid columns: triage, todo, doing, done"),
3557 "in BOARD order, not hash order: {err}"
3558 );
3559 assert_untouched(&store, &before, "unknown column");
3560 }
3561
3562 #[test]
3563 fn t015_terminal_exit_is_refused_and_writes_nothing() {
3564 let (_tmp, store, before) = seam_issue("done");
3565 let err = store
3566 .set_status("2026-01-01-seam.md", StatusChange::to("todo"))
3567 .unwrap_err();
3568 assert!(
3569 matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "invalid_transition"),
3570 "{err:?}"
3571 );
3572 assert_untouched(&store, &before, "terminal exit without a reason");
3573 }
3574
3575 #[test]
3576 fn t015_adjacency_restricted_backwards_move_is_refused_and_writes_nothing() {
3577 let (_tmp, store, before) = seam_issue("doing");
3581 let err = store
3582 .set_status("2026-01-01-seam.md", StatusChange::to("triage"))
3583 .unwrap_err();
3584 assert!(
3585 matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "invalid_transition"),
3586 "{err:?}"
3587 );
3588 assert!(
3589 err.to_string().contains("category policy"),
3590 "the message names the rule that refused it: {err}"
3591 );
3592 assert_untouched(&store, &before, "adjacency refusal");
3593 }
3594
3595 #[test]
3596 fn t015_terminal_exit_with_a_reason_is_accepted_and_the_reason_is_recorded() {
3597 let (_tmp, store, before) = seam_issue("done");
3598 let outcome = store
3599 .set_status(
3600 "2026-01-01-seam.md",
3601 StatusChange::to("todo").with_override(Some("reopening: wrong epic")),
3602 )
3603 .unwrap();
3604 assert!(outcome.written);
3605 let decision = outcome.decision.expect("one decision");
3606 assert_eq!(
3607 decision.override_reason.as_deref(),
3608 Some("reopening: wrong epic"),
3609 "an override with no recorded reason is not an audit record"
3610 );
3611 assert_ne!(
3612 read_issue_file(&store, "2026-01-01-seam.md"),
3613 before,
3614 "sanity: an accepted move must have changed the file at all"
3615 );
3616 }
3617
3618 #[test]
3619 fn t015_a_blank_reason_is_not_an_override() {
3620 let (_tmp, store, before) = seam_issue("done");
3625 let err = store
3626 .set_status(
3627 "2026-01-01-seam.md",
3628 StatusChange::to("todo").with_override(Some(" ")),
3629 )
3630 .unwrap_err();
3631 assert!(
3632 matches!(&err, TixError::Workflow(_)),
3633 "a blank reason is not a reason: {err:?}"
3634 );
3635 assert_untouched(&store, &before, "blank override reason");
3636 }
3637
3638 #[test]
3639 fn t015_same_column_is_accepted_and_writes_nothing() {
3640 let (_tmp, store, before) = seam_issue("doing");
3641 let outcome = store
3642 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
3643 .unwrap();
3644 assert!(!outcome.written, "a same-column re-apply is a no-op");
3645 assert!(
3646 outcome.decision.is_none(),
3647 "and appends no decision, contract §3.2"
3648 );
3649 assert_untouched(&store, &before, "same column");
3650 let reloaded = store
3653 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3654 .unwrap();
3655 assert_eq!(
3656 reloaded.updated, "2026-01-01",
3657 "the fixture's original date"
3658 );
3659 }
3660
3661 #[test]
3662 fn t015_no_new_frontmatter_key_was_introduced() {
3663 let (_tmp, store, before) = seam_issue("todo");
3667 store
3668 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
3669 .unwrap();
3670 let after = read_issue_file(&store, "2026-01-01-seam.md");
3671 let keys = |text: &str| -> Vec<String> {
3672 text.split("---\n")
3673 .nth(1)
3674 .unwrap()
3675 .lines()
3676 .filter_map(|l| l.split_once(':').map(|(k, _)| k.trim().to_string()))
3677 .filter(|k| !k.is_empty())
3678 .collect()
3679 };
3680 assert_eq!(
3681 keys(&after),
3682 keys(&before),
3683 "the seam must not add or remove a top-level frontmatter key"
3684 );
3685 }
3686
3687 #[test]
3688 fn t015_a_hand_edited_inconsistent_file_is_read_and_not_repaired() {
3689 let (_tmp, store, before) = seam_issue("doing");
3694 let edited = before.replace("status: doing", "status: triage");
3697 write_issue(&store, "2026-01-01-seam.md", &edited);
3698 let after_edit = read_issue_file(&store, "2026-01-01-seam.md");
3699
3700 let issue = store
3701 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3702 .unwrap();
3703 assert_eq!(
3704 issue.status.display(),
3705 "triage",
3706 "a hand-edited status is read, not rejected at load"
3707 );
3708 assert_eq!(
3709 read_issue_file(&store, "2026-01-01-seam.md"),
3710 after_edit,
3711 "reading must not repair it"
3712 );
3713 store
3716 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
3717 .unwrap();
3718 let moved = store
3719 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3720 .unwrap();
3721 assert_eq!(moved.status.display(), "doing");
3722 assert_eq!(
3723 moved.history.events().len(),
3724 1,
3725 "only the new decision; nothing was written to explain the old one"
3726 );
3727 }
3728
3729 #[test]
3730 fn t015_every_rejection_kind_is_one_of_the_contract_vocabulary() {
3731 let allowed = [
3735 "missing_completion_category",
3736 "unknown_category",
3737 "mapping_for_unknown_column",
3738 "unmapped_column_default",
3739 "invalid_transition",
3740 "unknown_column",
3741 "direct_edit_policy_violation",
3742 ];
3743 let cases: Vec<(&str, &str)> = vec![
3744 ("todo", "nope"), ("done", "todo"), ("doing", "triage"), ];
3748 for (from, to) in cases {
3749 let (_tmp, store, _before) = seam_issue(from);
3750 let err = store
3751 .set_status("2026-01-01-seam.md", StatusChange::to(to))
3752 .unwrap_err();
3753 let TixError::Workflow(d) = &err else {
3754 panic!("{from} -> {to} must refuse through the workflow variant: {err:?}");
3755 };
3756 assert!(
3757 allowed.contains(&d.kind.as_str()),
3758 "`{}` is not in the contract's vocabulary",
3759 d.kind.as_str()
3760 );
3761 }
3762 }
3763
3764 #[test]
3765 fn t015_a_legacy_board_gains_no_policy_but_keeps_completion() {
3766 let (tmp, store) = make_store();
3771 let _ = tmp;
3772 write_issue(
3773 &store,
3774 "2026-01-01-legacy.md",
3775 &sample_frontmatter(1, "Legacy", "done"),
3776 );
3777 let moved = store
3778 .set_status("2026-01-01-legacy.md", StatusChange::to("todo"))
3779 .unwrap();
3780 assert!(moved.written, "a legacy board imposes no policy");
3781 assert_eq!(moved.issue.status.display(), "todo");
3782
3783 let resolution =
3786 crate::workflow::resolution(&store.config, store.config.default_config(), "default");
3787 assert!(
3788 !resolution.explicit_policy,
3789 "precondition: this board declared no category_map"
3790 );
3791 assert!(
3792 resolution.has_completion(),
3793 "but it still has a done column"
3794 );
3795 assert!(
3796 !crate::workflow::is_completed(&moved.issue, &resolution),
3797 "and `todo` is not complete"
3798 );
3799 }
3800
3801 #[test]
3802 fn t015_a_mapped_board_keeps_the_commit_coupling() {
3803 let (_tmp, store, _before) = seam_issue("doing");
3807 store
3808 .set_status(
3809 "2026-01-01-seam.md",
3810 StatusChange::to("done").with_commit(Some("abc123")),
3811 )
3812 .unwrap();
3813 let at_done = store
3814 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3815 .unwrap();
3816 assert_eq!(at_done.commit.as_deref(), Some("abc123"));
3817
3818 store
3819 .set_status(
3820 "2026-01-01-seam.md",
3821 StatusChange::to("todo").with_override(Some("not actually finished")),
3822 )
3823 .unwrap();
3824 let off_done = store
3825 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3826 .unwrap();
3827 assert_eq!(
3828 off_done.commit, None,
3829 "leaving completion clears the recorded commit"
3830 );
3831 }
3832
3833 #[test]
3834 fn t015_a_decision_round_trips_through_a_file_unchanged() {
3835 let (_tmp, store, _before) = seam_issue("todo");
3839 store
3840 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
3841 .unwrap();
3842 let first = store
3843 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3844 .unwrap();
3845 let text = read_issue_file(&store, "2026-01-01-seam.md");
3846 let parsed = Issue::parse(&text).unwrap();
3848 let events = parsed.history.events();
3849 let decision = crate::model::TransitionDecision::from_event(events[0]).unwrap();
3850 assert_eq!(decision.to, "doing");
3851 assert_eq!(decision.from_category, "unstarted");
3852 assert_eq!(decision.to_category, "active");
3853 assert_eq!(
3856 parsed.to_file_contents(),
3857 text,
3858 "a parsed decision must re-render to the same bytes"
3859 );
3860 assert_eq!(parsed.history.events().len(), first.history.events().len());
3861 }
3862
3863 #[test]
3864 fn t015_two_transitions_append_two_decisions_in_order() {
3865 let (_tmp, store, _before) = seam_issue("triage");
3866 for to in ["todo", "doing"] {
3867 store
3868 .set_status("2026-01-01-seam.md", StatusChange::to(to))
3869 .unwrap();
3870 }
3871 let after = store
3872 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3873 .unwrap();
3874 let events = after.history.events();
3875 assert_eq!(events.len(), 2, "one per accepted transition");
3876 let tos: Vec<String> = events
3877 .iter()
3878 .map(|e| crate::model::TransitionDecision::from_event(e).unwrap().to)
3879 .collect();
3880 assert_eq!(tos, vec!["todo", "doing"], "append order, not sorted");
3881 }
3882
3883 #[test]
3884 fn set_status_changes_status_and_bumps_updated() {
3885 let (tmp, store) = make_store();
3886 let _ = tmp;
3887 write_issue(
3888 &store,
3889 "2026-01-01-s.md",
3890 &sample_frontmatter(1, "S", "todo"),
3891 );
3892 let outcome = store
3893 .set_status("2026-01-01-s.md", StatusChange::to("in progress"))
3894 .unwrap();
3895 let issue = &outcome.issue;
3896 assert_eq!(issue.status.display(), "in progress");
3897 assert_eq!(issue.updated, today());
3898 assert_eq!(issue.title, "S");
3899 assert_eq!(issue.body, "Body text.");
3900 }
3901
3902 #[test]
3903 fn set_status_preserves_file_path() {
3904 let (tmp, store) = make_store();
3905 let _ = tmp;
3906 write_issue(
3907 &store,
3908 "2026-01-01-f.md",
3909 &sample_frontmatter(1, "F", "todo"),
3910 );
3911 let _ = store
3912 .set_status("2026-01-01-f.md", StatusChange::to("done"))
3913 .unwrap();
3914 assert!(store.issues_dir.join("2026-01-01-f.md").exists());
3915 }
3916
3917 fn read_issue_file(store: &Store, filename: &str) -> String {
3923 std::fs::read_to_string(store.issues_dir.join(filename)).unwrap()
3924 }
3925
3926 fn bare_frontmatter(number: u32, title: &str) -> String {
3929 format!(
3930 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
3931 )
3932 }
3933
3934 fn legacy_links_frontmatter(number: u32, title: &str, links: &str) -> String {
3936 format!(
3937 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\nlinks: {links}\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
3938 )
3939 }
3940
3941 fn related_pair() -> (TempDir, Store, Issue, Issue) {
3943 let (tmp, store) = make_store();
3944 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
3945 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
3946 let a = store.get(IssueSelector::Number(1)).unwrap();
3947 let b = store.get(IssueSelector::Number(2)).unwrap();
3948 (tmp, store, a, b)
3949 }
3950
3951 #[test]
3952 fn t009_link_records_a_single_directed_edge() {
3953 let (_tmp, store, a, b) = related_pair();
3954 let outcome = store
3955 .link(
3956 &a.number.to_string(),
3957 &b.number.to_string(),
3958 RelationKind::Blocks,
3959 )
3960 .unwrap();
3961 assert!(outcome.changed);
3962
3963 let a_after = store.get(IssueSelector::Number(1)).unwrap();
3966 let b_after = store.get(IssueSelector::Number(2)).unwrap();
3967 assert_eq!(a_after.relationships.len(), 1);
3968 assert!(
3969 b_after.relationships.is_empty(),
3970 "no back-reference is written for a directed edge"
3971 );
3972 assert_eq!(a_after.relationships[0].kind, RelationKind::Blocks);
3973 assert_eq!(
3974 (a_after.relationships[0].from, a_after.relationships[0].to),
3975 (1, 2)
3976 );
3977 }
3978
3979 #[test]
3980 fn t009_link_appends_exactly_one_history_event() {
3981 let (_tmp, store, a, b) = related_pair();
3982 store
3983 .link(
3984 &a.number.to_string(),
3985 &b.number.to_string(),
3986 RelationKind::Blocks,
3987 )
3988 .unwrap();
3989 let a_after = store.get(IssueSelector::Number(1)).unwrap();
3990 assert_eq!(a_after.history.len(), 1);
3991 assert_eq!(
3992 a_after.history.events()[0].kind,
3993 HistoryEventKind::RelationshipAdded
3994 );
3995 assert!(!a_after.history.events()[0].actor.is_empty());
3997 }
3998
3999 #[test]
4000 fn t009_idempotent_reapply_writes_nothing_and_appends_no_second_event() {
4001 let (_tmp, store, a, b) = related_pair();
4002 let args = (a.number.to_string(), b.number.to_string());
4003 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4004 let first = store.get(IssueSelector::Number(1)).unwrap();
4005 let bytes_after_first = read_issue_file(&store, &first.filename);
4006
4007 let second = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4008 assert!(!second.changed, "re-applying an existing edge is a no-op");
4009 assert!(second.written.is_none(), "no file is written for a no-op");
4010
4011 let after = store.get(IssueSelector::Number(1)).unwrap();
4012 assert_eq!(after.relationships.len(), 1, "no duplicate edge");
4013 assert_eq!(after.history.len(), 1, "no second History event");
4014 assert_eq!(
4015 read_issue_file(&store, &after.filename),
4016 bytes_after_first,
4017 "the file is byte-identical after the no-op"
4018 );
4019 }
4020
4021 #[test]
4022 fn t009_inverse_spelling_resolves_to_the_same_stored_edge() {
4023 let (_tmp, store, a, b) = related_pair();
4024 let args = (a.number.to_string(), b.number.to_string());
4025 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4026 let again = store
4028 .link(&args.1, &args.0, RelationKind::BlockedBy)
4029 .unwrap();
4030 assert!(!again.changed, "the inverse spelling must not add an edge");
4031 let after = store.get(IssueSelector::Number(1)).unwrap();
4032 assert_eq!(after.relationships.len(), 1);
4033 assert_eq!(
4034 after.relationships[0].kind,
4035 RelationKind::Blocks,
4036 "the stored kind is always the canonical one"
4037 );
4038 }
4039
4040 #[test]
4041 fn t009_self_link_is_refused_and_writes_nothing() {
4042 let (_tmp, store, a, _b) = related_pair();
4043 let before = read_issue_file(&store, &a.filename);
4044 let err = store
4045 .link(
4046 &a.number.to_string(),
4047 &a.number.to_string(),
4048 RelationKind::Blocks,
4049 )
4050 .unwrap_err();
4051 assert!(matches!(err, TixError::InvalidInput(_)));
4052 assert!(
4053 err.to_string().contains("self_link"),
4054 "the diagnostic names the rule: {err}"
4055 );
4056 assert_eq!(
4057 read_issue_file(&store, &a.filename),
4058 before,
4059 "SC-005: a rejected mutation leaves the file byte-identical"
4060 );
4061 }
4062
4063 #[test]
4064 fn t009_cycle_closing_edge_is_refused_and_writes_nothing() {
4065 let (tmp, store) = make_store();
4066 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
4067 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
4068 write_issue(&store, "2026-01-01-c.md", &bare_frontmatter(3, "C"));
4069 let _ = tmp;
4070 store.link("1", "2", RelationKind::Blocks).unwrap();
4071 store.link("2", "3", RelationKind::Blocks).unwrap();
4072 let c_before = read_issue_file(&store, "2026-01-01-c.md");
4073
4074 let err = store.link("3", "1", RelationKind::Blocks).unwrap_err();
4075 assert!(err.to_string().contains("cycle"), "{err}");
4076 assert_eq!(
4077 read_issue_file(&store, "2026-01-01-c.md"),
4078 c_before,
4079 "SC-005: both files of a rejected two-issue mutation are unchanged"
4080 );
4081 }
4082
4083 #[test]
4084 fn t009_unlink_removes_the_edge_and_is_idempotent() {
4085 let (_tmp, store, a, b) = related_pair();
4086 let args = (a.number.to_string(), b.number.to_string());
4087 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4088
4089 let removed = store
4090 .unlink(&args.0, &args.1, RelationKind::Blocks)
4091 .unwrap();
4092 assert!(removed.changed);
4093 let after = store.get(IssueSelector::Number(1)).unwrap();
4094 assert!(after.relationships.is_empty());
4095 assert_eq!(after.history.len(), 2, "added then removed");
4096
4097 let again = store
4099 .unlink(&args.0, &args.1, RelationKind::Blocks)
4100 .unwrap();
4101 assert!(!again.changed);
4102 let after2 = store.get(IssueSelector::Number(1)).unwrap();
4103 assert_eq!(after2.history.len(), 2, "no third History event");
4104 }
4105
4106 #[test]
4107 fn t009_unlink_of_a_never_linked_pair_is_a_noop() {
4108 let (_tmp, store, a, b) = related_pair();
4109 let outcome = store
4110 .unlink(
4111 &a.number.to_string(),
4112 &b.number.to_string(),
4113 RelationKind::Blocks,
4114 )
4115 .unwrap();
4116 assert!(!outcome.changed);
4117 assert!(outcome.written.is_none());
4118 let after = store.get(IssueSelector::Number(1)).unwrap();
4119 assert!(after.relationships.is_empty());
4120 assert!(after.history.is_empty(), "a no-op appends nothing");
4121 }
4122
4123 #[test]
4124 fn t009_set_relationships_replaces_the_set_and_keeps_surviving_ids() {
4125 let (_tmp, store, _a, _b) = related_pair();
4126 store.link("1", "2", RelationKind::Blocks).unwrap();
4127 let before = store.get(IssueSelector::Number(1)).unwrap();
4128 let survivor_id = before.relationships[0].id.clone();
4129
4130 let updated = store
4132 .set_relationships(&before.filename, &[(2, RelationKind::Blocks)])
4133 .unwrap();
4134 assert_eq!(updated.relationships.len(), 1);
4135 assert_eq!(
4136 updated.relationships[0].id, survivor_id,
4137 "a surviving relationship keeps its stable id"
4138 );
4139 }
4140
4141 #[test]
4142 fn t009_set_relationships_rejects_unknown_target_and_self() {
4143 let (_tmp, store, a, _b) = related_pair();
4144 assert!(
4145 store
4146 .set_relationships(&a.filename, &[(404, RelationKind::Blocks)])
4147 .is_err(),
4148 "an unknown target is refused"
4149 );
4150 assert!(
4151 store
4152 .set_relationships(&a.filename, &[(1, RelationKind::Blocks)])
4153 .is_err(),
4154 "a self-link is refused"
4155 );
4156 }
4157
4158 fn seeded(store: &Store, number: u32, title: &str, extra: &str) {
4164 let slug = title.to_lowercase();
4165 write_issue(
4166 store,
4167 &format!("2026-01-01-{slug}.md"),
4168 &format!(
4169 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\n{extra}files: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n"
4170 ),
4171 );
4172 }
4173
4174 #[test]
4175 fn f1_set_relationships_refuses_a_reciprocal_pair_the_service_rejects() {
4176 let (_tmp, store) = make_store();
4183 seeded(&store, 1, "A", "");
4184 seeded(&store, 2, "B", "");
4185 store.link("1", "2", RelationKind::Blocks).unwrap();
4186
4187 let err = store
4188 .set_relationships("2026-01-01-b.md", &[(1, RelationKind::Blocks)])
4189 .unwrap_err();
4190 assert!(
4191 err.to_string().contains("cycle"),
4192 "the service's verdict is what refuses it: {err}"
4193 );
4194 let two = store.get(IssueSelector::Number(2)).unwrap();
4195 assert!(
4196 two.relationships.is_empty(),
4197 "SC-005: a refused mutation writes nothing"
4198 );
4199 }
4200
4201 #[test]
4202 fn f4_a_hand_ordered_related_row_is_removable() {
4203 let (_tmp, store) = make_store();
4208 seeded(&store, 2, "B", "");
4209 seeded(
4210 &store,
4211 5,
4212 "E",
4213 "relationships:\n- id: rel_handauthored\n from: 5\n to: 2\n kind: related\n display: \"related #2 B\"\n",
4214 );
4215 let out = store.unlink("5", "2", RelationKind::Related).unwrap();
4216 assert!(out.changed, "the row is found and removed");
4217 assert!(
4218 store
4219 .get(IssueSelector::Number(5))
4220 .unwrap()
4221 .relationships
4222 .is_empty()
4223 );
4224 }
4225
4226 #[test]
4227 fn f5_a_persisted_blocked_by_row_is_removable() {
4228 let (_tmp, store) = make_store();
4235 seeded(&store, 1, "A", "");
4236 seeded(
4237 &store,
4238 2,
4239 "B",
4240 "relationships:\n- id: rel_bb\n from: 2\n to: 1\n kind: blocked_by\n display: \"blocks #1 A\"\n",
4241 );
4242 let out = store.unlink("2", "1", RelationKind::BlockedBy).unwrap();
4243 assert!(out.changed, "the row is found and removed");
4244 assert!(
4245 store
4246 .get(IssueSelector::Number(2))
4247 .unwrap()
4248 .relationships
4249 .is_empty()
4250 );
4251 }
4252
4253 #[test]
4254 fn f13_a_dangling_edge_is_removable() {
4255 let (_tmp, store) = make_store();
4260 seeded(&store, 1, "A", "");
4261 seeded(&store, 2, "B", "");
4262 store.link("1", "2", RelationKind::Blocks).unwrap();
4263 std::fs::remove_file(store.issues_dir.join("2026-01-01-b.md")).unwrap();
4264
4265 let out = store.unlink("1", "2", RelationKind::Blocks).unwrap();
4266 assert!(out.changed, "a dangling edge is still removable");
4267 let one = store.get(IssueSelector::Number(1)).unwrap();
4268 assert!(one.relationships.is_empty());
4269 assert_eq!(one.history.len(), 2, "and the removal is recorded");
4270 }
4271
4272 #[test]
4273 fn m10_one_unlink_of_a_duplicated_edge_appends_one_event() {
4274 let (_tmp, store) = make_store();
4281 seeded(
4286 &store,
4287 1,
4288 "A",
4289 "relationships:\n- id: rel_dup_12\n from: 1\n to: 2\n kind: related\n display: \"related #2 B\"\n",
4290 );
4291 seeded(
4292 &store,
4293 2,
4294 "B",
4295 "relationships:\n- id: rel_dup_21\n from: 2\n to: 1\n kind: related\n display: \"related #1 A\"\n",
4296 );
4297
4298 let outcome = store.unlink("1", "2", RelationKind::Related).unwrap();
4299 assert!(outcome.changed, "both redundant rows are removed");
4300 let mut summaries = Vec::new();
4301 for number in [1u32, 2] {
4302 let issue = store.get(IssueSelector::Number(number)).unwrap();
4303 assert!(
4304 issue.relationships.is_empty(),
4305 "issue #{number} keeps no edge"
4306 );
4307 assert_eq!(
4308 issue.history.len(),
4309 1,
4310 "issue #{number} records exactly one event, got {:?}",
4311 issue.history.events()
4312 );
4313 let event = &issue.history.events()[0];
4314 assert!(
4315 event.detail.contains("removed 2 relationships"),
4316 "and the event says both spellings went: {}",
4317 event.detail
4318 );
4319 summaries.push(event.detail.clone());
4320 }
4321 assert_eq!(
4325 summaries[0], summaries[1],
4326 "both files record the same single event, not two differing ones"
4327 );
4328 }
4329
4330 #[test]
4331 fn m11_unlink_reports_the_row_it_removed_not_an_empty_placeholder() {
4332 let (_tmp, store, a, b) = related_pair();
4338 let args = (a.number.to_string(), b.number.to_string());
4339 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4340 let before = store.get(IssueSelector::Number(1)).unwrap();
4341 let real_id = before.relationships[0].id.clone();
4342
4343 let outcome = store
4344 .unlink(&args.0, &args.1, RelationKind::Blocks)
4345 .unwrap();
4346 assert!(outcome.changed);
4347 assert_eq!(
4348 outcome.relationship.id, real_id,
4349 "the reported row is the one that was actually removed"
4350 );
4351 assert!(!outcome.relationship.id.is_empty(), "so it is addressable");
4352 assert_eq!(
4353 outcome.relationship.display,
4354 before.relationships[0].display
4355 );
4356 }
4357
4358 #[test]
4359 fn m15_the_history_detail_names_the_relationship_id() {
4360 let (_tmp, store, a, b) = related_pair();
4364 let args = (a.number.to_string(), b.number.to_string());
4365 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4366 let one = store.get(IssueSelector::Number(1)).unwrap();
4367 let id = one.relationships[0].id.clone();
4368
4369 let detail = &one.history.events()[0].detail;
4370 assert!(
4371 detail.starts_with(&format!("created {id}:")),
4372 "the detail names the stable id: {detail}"
4373 );
4374 assert!(
4375 !detail.contains(&format!("created {}:", RelationKind::Blocks)),
4376 "and does not stutter the kind, which `display` already carries: {detail}"
4377 );
4378 }
4379
4380 #[test]
4381 fn f17_all_diagnostics_are_reported_not_just_the_first() {
4382 let (_tmp, store) = make_store();
4394 seeded(
4395 &store,
4396 1,
4397 "A",
4398 "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",
4399 );
4400 seeded(
4401 &store,
4402 2,
4403 "B",
4404 "relationships:\n- id: rel_b21\n from: 2\n to: 1\n kind: blocks\n display: blocks\n",
4405 );
4406
4407 let issues = store.list().unwrap();
4408 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
4409 let verdict = RelationshipGraph::new(&views, "done").validate_snapshot();
4410 assert!(
4411 verdict.errors.len() >= 2,
4412 "the fixture must really produce several diagnostics, got {:?}",
4413 verdict.errors
4414 );
4415
4416 let rendered = relationship_error(&verdict.errors).to_string();
4417 for kind in ["conflicting_edge", "cycle"] {
4418 assert!(
4419 rendered.contains(kind),
4420 "every diagnostic must be reported; {kind} missing from: {rendered}"
4421 );
4422 }
4423 assert!(
4424 !rendered.contains("conflicting_edge: conflicting_edge")
4425 && !rendered.contains("cycle: cycle:"),
4426 "no doubled kind prefix: {rendered}"
4427 );
4428 let dup = vec![
4434 Diagnostic::new(
4435 DiagnosticKind::SelfLink,
4436 "to",
4437 "#1 cannot block itself: a self-link is not a valid relationship",
4438 ),
4439 Diagnostic::new(
4440 DiagnosticKind::SelfLink,
4441 "to",
4442 "#1 cannot block itself: a self-link is not a valid relationship",
4443 ),
4444 Diagnostic::new(DiagnosticKind::Cycle, "to", "cycle: 1 -> 1 -> #1"),
4445 ];
4446 let rendered = relationship_error(&dup).to_string();
4447 assert_eq!(
4448 rendered.matches("cannot block itself").count(),
4449 1,
4450 "an identical diagnostic renders once, not twice: {rendered}"
4451 );
4452 assert!(
4453 rendered.contains("cycle"),
4454 "a distinct diagnostic is still reported: {rendered}"
4455 );
4456 assert!(
4457 !rendered.contains("self_link: self_link") && !rendered.contains("cycle: cycle:"),
4458 "and no kind is prefixed twice: {rendered}"
4459 );
4460 }
4461
4462 #[test]
4463 fn t009_clearing_a_set_removes_a_related_edge_owned_by_another_file() {
4464 let (_tmp, store) = make_store();
4469 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(3, "A"));
4470 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(5, "B"));
4471
4472 store.link("5", "3", RelationKind::Related).unwrap();
4473 let three = store.get(IssueSelector::Number(3)).unwrap();
4474 let five = store.get(IssueSelector::Number(5)).unwrap();
4475 assert_eq!(
4476 three.relationships.len(),
4477 1,
4478 "the edge is stored in the lower-numbered issue's file"
4479 );
4480 assert!(
4481 five.relationships.is_empty(),
4482 "and #5's own file holds none"
4483 );
4484
4485 store.set_relationships(&five.filename, &[]).unwrap();
4486
4487 let three_after = store.get(IssueSelector::Number(3)).unwrap();
4488 let five_after = store.get(IssueSelector::Number(5)).unwrap();
4489 assert!(
4490 three_after.relationships.is_empty(),
4491 "the association is gone from the file that actually stored it"
4492 );
4493 assert!(five_after.relationships.is_empty());
4494 }
4495
4496 #[test]
4497 fn t009_clearing_a_set_does_not_remove_an_inbound_directional_edge() {
4498 let (_tmp, store) = make_store();
4503 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(3, "A"));
4504 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(5, "B"));
4505
4506 store.link("3", "5", RelationKind::Blocks).unwrap();
4507 let five = store.get(IssueSelector::Number(5)).unwrap();
4508
4509 store.set_relationships(&five.filename, &[]).unwrap();
4510
4511 let three_after = store.get(IssueSelector::Number(3)).unwrap();
4512 assert!(
4513 three_after
4514 .relationships
4515 .iter()
4516 .any(|r| r.to == 5 && r.kind == RelationKind::Blocks),
4517 "#3's declaration that it blocks #5 survives: a blocked issue \
4518 cannot unilaterally clear the blocker's graph"
4519 );
4520 }
4521
4522 fn relationship_fixture(name: &str) -> String {
4524 format!(
4525 "{}/tests/fixtures/relationships/{name}",
4526 env!("CARGO_MANIFEST_DIR")
4527 )
4528 }
4529
4530 #[test]
4531 fn t010_typed_fixture_round_trips_byte_stable_including_history() {
4532 let original =
4535 std::fs::read_to_string(relationship_fixture("typed-relationships.md")).unwrap();
4536 let issue = Issue::parse(&original).unwrap();
4537 assert_eq!(issue.relationships.len(), 2);
4538 assert_eq!(issue.history.len(), 2);
4539 let rendered = issue.to_file_contents();
4540 assert_eq!(
4541 rendered, original,
4542 "a typed fixture must not churn on a read/write round trip"
4543 );
4544 assert_eq!(
4545 Issue::parse(&rendered).unwrap().to_file_contents(),
4546 rendered
4547 );
4548 assert_eq!(issue.relationships[0].id, "rel_01JQ8Z4W2M6N7P9R3S5T7V9X1");
4550 assert!(
4551 rendered.find("## History").unwrap() < rendered.find("Prose that follows").unwrap(),
4552 "History sits between the frontmatter and the body"
4553 );
4554 }
4555
4556 #[test]
4557 fn t010_issue_with_no_relationship_data_is_untouched() {
4558 let original =
4559 std::fs::read_to_string(relationship_fixture("no-relationship-data.md")).unwrap();
4560 let issue = Issue::parse(&original).unwrap();
4561 assert!(issue.relationships.is_empty());
4562 assert!(issue.history.is_empty());
4563 assert!(!issue.has_retired_links_key);
4564 let rendered = issue.to_file_contents();
4565 assert_eq!(
4566 rendered, original,
4567 "an issue with no relationship data must not churn"
4568 );
4569 assert!(!rendered.contains("relationships:"));
4570 assert!(!rendered.contains("## History"));
4571 }
4572
4573 #[test]
4574 fn t010_an_empty_history_section_is_dropped_not_preserved() {
4575 let original = std::fs::read_to_string(relationship_fixture("history-empty.md")).unwrap();
4580 assert!(
4581 original.contains("## History"),
4582 "the fixture must actually carry the empty heading"
4583 );
4584 let issue = Issue::parse(&original).unwrap();
4585 assert!(
4586 issue.history.is_empty(),
4587 "a heading with no entries parses to an empty log"
4588 );
4589 assert!(
4590 issue
4591 .body
4592 .contains("An issue whose History section is present but empty."),
4593 "the prose after the heading is body, not a dropped event: {:?}",
4594 issue.body
4595 );
4596 let rendered = issue.to_file_contents();
4597 assert!(
4598 !rendered.contains("## History"),
4599 "the empty section is omitted rather than kept as a bare heading"
4600 );
4601 assert!(rendered.contains("An issue whose History section is present but empty."));
4602 }
4603
4604 #[test]
4605 fn t010_issue_with_no_history_section_is_untouched() {
4606 let original = std::fs::read_to_string(relationship_fixture("history-absent.md")).unwrap();
4607 let issue = Issue::parse(&original).unwrap();
4608 assert!(issue.history.is_empty());
4609 let rendered = issue.to_file_contents();
4610 assert_eq!(
4611 rendered, original,
4612 "a file written before History existed must not gain an empty section"
4613 );
4614 }
4615
4616 #[test]
4617 fn f2_one_bad_history_line_does_not_make_the_store_unreadable() {
4618 let (_tmp, store) = make_store();
4627 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
4628 write_issue(
4629 &store,
4630 "2026-01-01-c.md",
4631 "---\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",
4632 );
4633
4634 let all = store.list().expect("the store must remain listable");
4635 assert_eq!(all.len(), 2, "both issues load");
4636 assert_eq!(
4637 store
4638 .get(IssueSelector::Number(1))
4639 .expect("an unrelated, valid issue must stay readable")
4640 .title,
4641 "A"
4642 );
4643 let three = store.get(IssueSelector::Number(3)).unwrap();
4649 assert!(three.history.events().is_empty(), "no event is fabricated");
4650 assert_eq!(
4651 three.history.unparsed().len(),
4652 1,
4653 "the line is preserved for diagnosis: {:?}",
4654 three.history.unparsed()
4655 );
4656 assert!(
4657 !three.body.contains("ascii hyphens"),
4658 "and is not demoted into the body: {:?}",
4659 three.body
4660 );
4661 }
4662
4663 #[cfg(unix)]
4666 fn seeded_in_segment(store: &Store, number: u32, title: &str, segment: &str) {
4667 let dir = store.issues_dir.join(segment);
4668 std::fs::create_dir_all(&dir).unwrap();
4669 std::fs::write(
4670 dir.join(format!("2026-01-01-{}.md", title.to_lowercase())),
4671 format!(
4672 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\nfiles: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n"
4673 ),
4674 )
4675 .unwrap();
4676 }
4677
4678 #[cfg(unix)]
4679 #[test]
4680 fn f6_a_mid_sequence_write_failure_rolls_every_file_back() {
4681 use std::os::unix::fs::PermissionsExt;
4682 let (tmp, store) = make_store();
4691 let _ = tmp;
4692 seeded_in_segment(&store, 1, "A", "alice");
4693 seeded_in_segment(&store, 2, "B", "bob");
4694 seeded_in_segment(&store, 3, "C", "carol");
4695 seeded_in_segment(&store, 5, "E", "erin");
4696
4697 let paths: Vec<std::path::PathBuf> = [
4698 "alice/2026-01-01-a.md",
4699 "bob/2026-01-01-b.md",
4700 "carol/2026-01-01-c.md",
4701 ]
4702 .iter()
4703 .map(|p| store.issues_dir.join(p))
4704 .collect();
4705 let before: Vec<String> = paths
4706 .iter()
4707 .map(|p| std::fs::read_to_string(p).unwrap())
4708 .collect();
4709
4710 let carol = store.issues_dir.join("carol");
4713 let mut perms = std::fs::metadata(&carol).unwrap().permissions();
4714 perms.set_mode(0o555);
4715 std::fs::set_permissions(&carol, perms).unwrap();
4716
4717 let result = store.set_relationships(
4718 "carol/2026-01-01-c.md",
4719 &[
4720 (1, RelationKind::Related),
4721 (2, RelationKind::Related),
4722 (5, RelationKind::Related),
4723 ],
4724 );
4725
4726 let mut perms = std::fs::metadata(&carol).unwrap().permissions();
4727 perms.set_mode(0o755);
4728 std::fs::set_permissions(&carol, perms).unwrap();
4729
4730 assert!(result.is_err(), "the blocked write must fail");
4731 for (path, previous) in paths.iter().zip(before.iter()) {
4732 assert_eq!(
4733 std::fs::read_to_string(path).unwrap(),
4734 *previous,
4735 "{} was left mutated by a failed mutation",
4736 path.display()
4737 );
4738 }
4739 }
4740
4741 #[cfg(unix)]
4742 #[test]
4743 fn f6b_the_updated_bump_is_itself_rollback_covered() {
4744 use std::os::unix::fs::PermissionsExt;
4754
4755 let (tmp, store) = make_store();
4756 let _ = tmp;
4757 seeded_in_segment(&store, 1, "A", "alice");
4758 seeded_in_segment(&store, 3, "C", "carol");
4759
4760 let alice = store.issues_dir.join("alice/2026-01-01-a.md");
4761 let before = std::fs::read_to_string(&alice).unwrap();
4762
4763 let carol = store.issues_dir.join("carol");
4764 let mut perms = std::fs::metadata(&carol).unwrap().permissions();
4765 perms.set_mode(0o555);
4766 std::fs::set_permissions(&carol, perms).unwrap();
4767
4768 let result =
4769 store.set_relationships("carol/2026-01-01-c.md", &[(1, RelationKind::Related)]);
4770
4771 let mut perms = std::fs::metadata(&carol).unwrap().permissions();
4772 perms.set_mode(0o755);
4773 std::fs::set_permissions(&carol, perms).unwrap();
4774
4775 assert!(result.is_err(), "the blocked bump must fail the call");
4776 assert_eq!(
4777 std::fs::read_to_string(&alice).unwrap(),
4778 before,
4779 "the relationship write that succeeded earlier must be rolled back \
4780 when the subsequent `updated` bump fails"
4781 );
4782 }
4783
4784 #[test]
4785 fn f9_relationships_for_reports_both_endpoints_of_a_one_sided_edge() {
4786 let (_tmp, store) = make_store();
4792 seeded(&store, 1, "A", "");
4793 seeded(&store, 2, "B", "");
4794 store.link("2", "1", RelationKind::Related).unwrap();
4795
4796 let one = store.get(IssueSelector::Number(1)).unwrap();
4798 let two = store.get(IssueSelector::Number(2)).unwrap();
4799 assert_eq!(one.relationships.len(), 1, "precondition: #1 stores it");
4800 assert!(
4801 two.relationships.is_empty(),
4802 "precondition: #2's own field is the partial view that caused F9"
4803 );
4804
4805 for number in [1u32, 2] {
4808 let resolved = store.relationships_for(number).unwrap();
4809 assert_eq!(
4810 resolved.len(),
4811 1,
4812 "issue #{number} must see the relationship that touches it"
4813 );
4814 assert_eq!(resolved[0].kind, RelationKind::Related);
4815 }
4816 }
4817
4818 #[test]
4819 fn f7_writing_an_issue_with_the_retired_key_discloses_that_it_is_removed() {
4820 let (tmp, store) = make_store();
4825 let _ = tmp;
4826 let _ = take_disclosures();
4827 write_issue(
4832 &store,
4833 "2026-01-01-legacy.md",
4834 &legacy_links_frontmatter(1, "Legacy", "['7']"),
4835 );
4836 let issue = store.get(IssueSelector::Number(1)).unwrap();
4837 assert!(
4838 issue.has_retired_links_key,
4839 "precondition: the key is present"
4840 );
4841
4842 store.update(&issue.filename, &issue).unwrap();
4843
4844 let disclosures = take_disclosures();
4846 assert_eq!(
4847 disclosures.len(),
4848 1,
4849 "the write must disclose that it dropped the key: {disclosures:?}"
4850 );
4851 let line = &disclosures[0];
4852 assert!(
4853 line.contains("`links:`"),
4854 "it names what was removed: {line}"
4855 );
4856 assert!(
4860 line.starts_with("remove `links:` by hand"),
4861 "and leads with what to do about it: {line}"
4862 );
4863 assert!(
4864 line.contains(&issue.filename),
4865 "and which file it was removed from: {line}"
4866 );
4867 assert!(
4868 line.contains("C-001"),
4869 "and that no migration was applied, so the user is not left \
4870 wondering where their data went: {line}"
4871 );
4872
4873 let after = read_issue_file(&store, &issue.filename);
4875 assert!(
4876 !after.contains("links:"),
4877 "the key is not re-emitted (C-001: no migration)"
4878 );
4879 let reread = store.get(IssueSelector::Number(1)).unwrap();
4882 assert!(!reread.has_retired_links_key);
4883 assert!(
4884 take_disclosures().is_empty(),
4885 "and it is disclosed once, not on every read"
4886 );
4887 }
4888
4889 #[test]
4890 fn f7b_writing_an_issue_without_the_retired_key_is_silent() {
4891 let (tmp, store) = make_store();
4894 let _ = tmp;
4895 let _ = take_disclosures();
4896 write_issue(&store, "2026-01-01-plain.md", &bare_frontmatter(1, "Plain"));
4897 let issue = store.get(IssueSelector::Number(1)).unwrap();
4898 assert!(!issue.has_retired_links_key);
4899 store.update(&issue.filename, &issue).unwrap();
4900 assert!(
4901 take_disclosures().is_empty(),
4902 "an ordinary write discloses nothing"
4903 );
4904 }
4905
4906 #[test]
4907 fn t010_legacy_fixture_reports_the_retired_key_and_yields_no_edges() {
4908 let original =
4911 std::fs::read_to_string(relationship_fixture("legacy-links-key.md")).unwrap();
4912 let issue = Issue::parse(&original).unwrap();
4913 assert!(issue.has_retired_links_key, "the retired key is reported");
4914 assert!(
4915 issue.relationships.is_empty(),
4916 "C-001: legacy values are never reinterpreted as typed edges"
4917 );
4918 assert!(
4919 issue.history.is_empty(),
4920 "a legacy file gains no History section from parsing"
4921 );
4922 let (_tmp, store) = make_store();
4926 write_issue(&store, "2026-01-01-legacy.md", &original);
4927 let findings = crate::commands::lint::collect(&store.config, &store).unwrap();
4928 let retired: Vec<_> = findings
4929 .iter()
4930 .filter(|f| f.kind == "retired_links_key")
4931 .collect();
4932 assert_eq!(
4933 retired.len(),
4934 1,
4935 "one finding for the retired key: {findings:?}"
4936 );
4937 assert_eq!(retired[0].value, "links", "the finding names the field");
4938 assert!(
4939 retired[0].message.contains("retired"),
4940 "the message says why: {}",
4941 retired[0].message
4942 );
4943 assert!(
4944 retired[0].line > 0,
4945 "the finding points into the frontmatter"
4946 );
4947 let rewritten = issue.to_file_contents();
4949 assert!(!rewritten.contains("kind: related"));
4950 assert!(!rewritten.contains("kind: blocks"));
4951 assert!(!rewritten.contains("links:"));
4952 }
4953
4954 #[test]
4955 fn c001_a_legacy_links_key_is_reported_and_yields_no_typed_edges() {
4956 let (tmp, store) = make_store();
4957 write_issue(
4958 &store,
4959 "2026-01-01-legacy.md",
4960 &legacy_links_frontmatter(1, "Legacy", "[\"12\", \"47\"]"),
4961 );
4962 let _ = tmp;
4963 let issue = store.get(IssueSelector::Number(1)).unwrap();
4964 assert!(
4965 issue.has_retired_links_key,
4966 "the retired key is recorded as a validation result"
4967 );
4968 assert!(
4969 issue.relationships.is_empty(),
4970 "C-001: legacy values are never read as typed edges"
4971 );
4972 }
4973
4974 #[test]
4975 fn c001_there_is_no_migration_path_from_the_retired_key() {
4976 let (tmp, store) = make_store();
4985 let _ = tmp;
4986 write_issue(
4987 &store,
4988 "2026-01-01-legacy.md",
4989 &legacy_links_frontmatter(1, "Legacy", "[\"12\", \"47\"]"),
4990 );
4991 let issue = store.get(IssueSelector::Number(1)).unwrap();
4992 assert!(
4993 issue.has_retired_links_key,
4994 "precondition: the key is present"
4995 );
4996 assert!(
4997 issue.relationships.is_empty(),
4998 "C-001: legacy values are never read as typed edges"
4999 );
5000
5001 store.update(&issue.filename, &issue).unwrap();
5002 let written = read_issue_file(&store, &issue.filename);
5003
5004 let reparsed = store.get(IssueSelector::Number(1)).unwrap();
5013 assert!(
5014 reparsed.relationships.is_empty(),
5015 "the file on disk parses to zero typed edges, not just the in-memory copy"
5016 );
5017 for kind in ["blocks", "blocked_by", "duplicates", "related"] {
5018 assert!(
5019 !written.contains(&format!("kind: {kind}")),
5020 "a legacy link became a `{kind}` edge:\n{written}"
5021 );
5022 }
5023 assert!(
5024 !written.contains("kind:"),
5025 "no typed edge was written at all"
5026 );
5027 assert!(!written.contains("links:"), "the key is not re-emitted");
5030 let reread = store.get(IssueSelector::Number(1)).unwrap();
5031 assert!(
5032 reread.history.is_empty(),
5033 "a format change is not a relationship mutation, so it records no event"
5034 );
5035 }
5036
5037 #[test]
5038 fn f15_the_idempotent_no_op_reports_the_real_addressable_row() {
5039 let (_tmp, store, a, b) = related_pair();
5045 let args = (a.number.to_string(), b.number.to_string());
5046 let first = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5047 let second = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5048
5049 assert!(first.changed);
5050 assert!(!second.changed, "the second apply is a no-op");
5051 assert_eq!(
5052 second.relationship.id, first.relationship.id,
5053 "the no-op reports the real, addressable row"
5054 );
5055 assert!(
5056 !second.relationship.id.is_empty(),
5057 "an empty id is not addressable"
5058 );
5059 assert_eq!(second.relationship.display, first.relationship.display);
5060 }
5061
5062 #[test]
5063 fn f16_clearing_a_set_bumps_updated_on_the_named_issue() {
5064 let (_tmp, store, _a, _b) = related_pair();
5069 store.link("2", "1", RelationKind::Related).unwrap();
5070 let before = store.get(IssueSelector::Number(2)).unwrap();
5071 let updated_before = before.updated.clone();
5072
5073 store.set_relationships(&before.filename, &[]).unwrap();
5074
5075 let after = store.get(IssueSelector::Number(2)).unwrap();
5076 assert_ne!(
5077 after.updated, updated_before,
5078 "the named issue's relationship set changed, so `updated` must move"
5079 );
5080 }
5081
5082 #[test]
5083 fn c002_an_issue_with_no_relationship_data_round_trips_unchanged() {
5084 let (tmp, store) = make_store();
5085 let _ = tmp;
5086 write_issue(&store, "2026-01-01-plain.md", &bare_frontmatter(1, "Plain"));
5091 let issue = store.get(IssueSelector::Number(1)).unwrap();
5092 let first = issue.to_file_contents();
5093 assert!(
5094 !first.contains("relationships:"),
5095 "the empty typed field is omitted, not written as `[]`"
5096 );
5097 assert!(
5098 !first.contains("## History"),
5099 "an empty History section is omitted entirely"
5100 );
5101 let reparsed = Issue::parse(&first).unwrap();
5104 let second = reparsed.to_file_contents();
5105 assert_eq!(second, first);
5106 }
5107
5108 #[test]
5109 fn c002_history_section_round_trips_byte_stable() {
5110 let (tmp, store) = make_store();
5111 write_issue(&store, "2026-01-01-h.md", &bare_frontmatter(1, "H"));
5112 let _ = tmp;
5113 let mut issue = store.get(IssueSelector::Number(1)).unwrap();
5114 issue.history.append_now(
5115 "alice",
5116 HistoryEventKind::RelationshipAdded,
5117 "created blocks: blocks #2 B",
5118 );
5119 issue.history.append_now(
5120 "bob",
5121 HistoryEventKind::BlockedMoveAcknowledged,
5122 "acknowledged 1 open blocker",
5123 );
5124 let first = issue.to_file_contents();
5125 assert!(first.contains("## History"), "{first}");
5126
5127 let reparsed = Issue::parse(&first).unwrap();
5129 assert_eq!(reparsed.to_file_contents(), first);
5130 assert_eq!(reparsed.history.len(), 2);
5131 assert_eq!(reparsed.history.events()[0].actor, "alice");
5132 assert_eq!(
5133 reparsed.history.events()[1].kind,
5134 HistoryEventKind::BlockedMoveAcknowledged
5135 );
5136 assert!(
5137 !reparsed.body.contains("History"),
5138 "the section is stripped from the body: {:?}",
5139 reparsed.body
5140 );
5141 }
5142
5143 #[test]
5144 fn f1_a_hand_edit_last_in_the_section_stays_in_it() {
5145 let (tmp, store) = make_store();
5164 let _ = tmp;
5165 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";
5166 write_issue(&store, "2026-01-01-a.md", original);
5167
5168 let before = store.get(IssueSelector::Number(1)).unwrap();
5171 assert_eq!(
5172 before.history.events().len(),
5173 1,
5174 "precondition: the real event parsed"
5175 );
5176 assert_eq!(
5177 before.history.unparsed(),
5178 vec![" - my hand edit"],
5179 "precondition: the hand edit is inside the section"
5180 );
5181 assert_eq!(before.body, "", "precondition: body is empty");
5182
5183 let mut moved = before.clone();
5185 moved.status = crate::model::Status::new("in progress");
5186 store.update(&moved.filename, &moved).unwrap();
5187
5188 let after = store.get(IssueSelector::Number(1)).unwrap();
5189 assert_eq!(
5190 after.history.unparsed(),
5191 vec![" - my hand edit"],
5192 "the hand edit is still in the audit log, not demoted to the body"
5193 );
5194 assert_eq!(
5195 after.history.events().len(),
5196 1,
5197 "and the real event survived"
5198 );
5199 assert_eq!(after.body, "", "nothing leaked into the body");
5200
5201 store
5204 .link("1", "2", RelationKind::Related)
5205 .ok()
5206 .or_else(|| {
5207 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
5208 store.link("1", "2", RelationKind::Related).ok()
5209 });
5210 let linked = store.get(IssueSelector::Number(1)).unwrap();
5211 let rendered = linked.to_file_contents();
5212 assert!(
5213 rendered.contains("## History"),
5214 "the heading survived an unrelated write: {rendered}"
5215 );
5216 let hand = rendered
5217 .find("my hand edit")
5218 .expect("the hand edit is present");
5219 let newest = rendered
5220 .find("relationship_added")
5221 .expect("the new event is present");
5222 assert!(
5223 newest < hand,
5224 "the new event is appended AFTER the pre-existing content, so file \
5225 order is still append order: {rendered}"
5226 );
5227 }
5228
5229 #[test]
5230 fn f1_but_a_bare_paragraph_at_end_of_section_is_still_prose() {
5231 let (tmp, store) = make_store();
5236 let _ = tmp;
5237 write_issue(
5238 &store,
5239 "2026-01-01-a.md",
5240 "---\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",
5241 );
5242 let issue = store.get(IssueSelector::Number(1)).unwrap();
5243 assert_eq!(issue.history.events().len(), 1, "the real event parsed");
5244 assert!(
5245 issue.history.unparsed().is_empty(),
5246 "the paragraph is not in the audit log: {:?}",
5247 issue.history.unparsed()
5248 );
5249 assert!(
5250 issue.body.contains("just a closing paragraph"),
5251 "it is body prose: {:?}",
5252 issue.body
5253 );
5254 }
5255
5256 #[test]
5257 fn f1_a_hand_edit_survives_body_prose_and_a_later_body_replacement() {
5258 let (_tmp, store) = make_store();
5273 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
5274 let hand = "- 2026-02-02T00:00:00Z - bob - relationship_removed - by hand";
5275 write_issue(
5276 &store,
5277 "2026-01-01-a.md",
5278 &format!(
5279 "---\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"
5280 ),
5281 );
5282
5283 let before = store.get(IssueSelector::Number(1)).unwrap();
5284 assert_eq!(before.history.events().len(), 1, "the real event parsed");
5285 assert_eq!(
5286 before.history.unparsed(),
5287 vec![hand],
5288 "the hand-edited entry is retained even though body prose follows"
5289 );
5290 assert!(
5291 before.body.contains("Some body prose."),
5292 "and the prose is still body text: {:?}",
5293 before.body
5294 );
5295
5296 let mut moved = before.clone();
5298 moved.status = crate::model::Status::new("in progress");
5299 store.update(&moved.filename, &moved).unwrap();
5300 assert_eq!(
5301 store
5302 .get(IssueSelector::Number(1))
5303 .unwrap()
5304 .history
5305 .unparsed(),
5306 vec![hand],
5307 "a `tix move` keeps it in the audit log"
5308 );
5309
5310 let mut replaced = store.get(IssueSelector::Number(1)).unwrap();
5313 replaced.body = "Completely new body text.".to_string();
5314 store.update(&replaced.filename, &replaced).unwrap();
5315 let after = store.get(IssueSelector::Number(1)).unwrap();
5316 assert_eq!(
5317 after.history.unparsed(),
5318 vec![hand],
5319 "the hand-edited entry survives a wholesale body replacement"
5320 );
5321 assert_eq!(after.body, "Completely new body text.");
5322
5323 store.link("1", "2", RelationKind::Related).unwrap();
5325 let rendered = store
5326 .get(IssueSelector::Number(1))
5327 .unwrap()
5328 .to_file_contents();
5329 assert!(
5330 rendered.contains("## History"),
5331 "the heading survived: {rendered}"
5332 );
5333 let hand_at = rendered.find("by hand").expect("the hand edit is present");
5334 let new_at = rendered
5335 .find("created rel_")
5336 .expect("the new event is present");
5337 assert!(
5338 new_at > hand_at,
5339 "the new event is appended AFTER the pre-existing content, so file \
5340 order is still append order: {rendered}"
5341 );
5342 }
5343
5344 #[test]
5345 fn f1_a_well_formed_star_marked_entry_is_retained_with_body_prose_after_it() {
5346 let (_tmp, store) = make_store();
5356 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
5357 let hand =
5358 "* 2026-02-02T00:00:00Z \u{2014} bob \u{2014} relationship_removed \u{2014} by hand";
5359 write_issue(
5360 &store,
5361 "2026-01-01-a.md",
5362 &format!(
5363 "---\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"
5364 ),
5365 );
5366
5367 let before = store.get(IssueSelector::Number(1)).unwrap();
5368 assert_eq!(before.history.events().len(), 1, "the real event parsed");
5369 assert_eq!(
5370 before.history.unparsed(),
5371 vec![hand],
5372 "a well-formed `*` entry is retained even with body prose after it"
5373 );
5374 assert!(
5375 before.body.contains("Some body prose."),
5376 "and the prose is still body text: {:?}",
5377 before.body
5378 );
5379
5380 let mut replaced = before.clone();
5382 replaced.body = "Completely new body text.".to_string();
5383 store.update(&replaced.filename, &replaced).unwrap();
5384 assert_eq!(
5385 store
5386 .get(IssueSelector::Number(1))
5387 .unwrap()
5388 .history
5389 .unparsed(),
5390 vec![hand],
5391 "it survives a wholesale body replacement"
5392 );
5393
5394 store.link("1", "2", RelationKind::Related).unwrap();
5395 let rendered = store
5396 .get(IssueSelector::Number(1))
5397 .unwrap()
5398 .to_file_contents();
5399 assert_eq!(
5400 rendered.matches("## History").count(),
5401 1,
5402 "still exactly one section: {rendered}"
5403 );
5404 let hand_at = rendered.find("by hand").expect("the hand edit is present");
5405 let new_at = rendered
5406 .find("created rel_")
5407 .expect("the new event is present");
5408 assert!(
5409 new_at > hand_at,
5410 "appended after, so order is append order: {rendered}"
5411 );
5412 }
5413
5414 #[test]
5415 fn f1_a_three_field_ascii_hyphen_entry_is_retained_with_body_prose_after_it() {
5416 let (_tmp, store) = make_store();
5422 let hand = "- 2026-02-02T00:00:00Z - bob - relationship_removed";
5423 write_issue(
5424 &store,
5425 "2026-01-01-a.md",
5426 &format!(
5427 "---\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"
5428 ),
5429 );
5430
5431 let twin = Issue::parse(
5434 "---\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",
5435 )
5436 .unwrap();
5437 assert_eq!(
5438 twin.history.events().len(),
5439 1,
5440 "three fields with em-dashes is a valid event, detail optional"
5441 );
5442
5443 let before = store.get(IssueSelector::Number(1)).unwrap();
5444 assert_eq!(
5445 before.history.unparsed(),
5446 vec![hand],
5447 "so its ASCII-hyphen twin is retained, not demoted"
5448 );
5449 let mut replaced = before.clone();
5450 replaced.body = "Completely new body text.".to_string();
5451 store.update(&replaced.filename, &replaced).unwrap();
5452 assert_eq!(
5453 store
5454 .get(IssueSelector::Number(1))
5455 .unwrap()
5456 .history
5457 .unparsed(),
5458 vec![hand],
5459 "and survives a body replacement"
5460 );
5461 }
5462
5463 #[test]
5464 fn f1_a_heading_with_trailing_spaces_is_still_the_heading() {
5465 let (_tmp, store) = make_store();
5471 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
5472 for (label, heading) in [
5473 ("plain", "## History"),
5474 ("trailing spaces", "## History "),
5475 ] {
5476 write_issue(
5477 &store,
5478 "2026-01-01-a.md",
5479 &format!(
5480 "---\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"
5481 ),
5482 );
5483 let before = store.get(IssueSelector::Number(1)).unwrap();
5484 assert_eq!(
5485 before.history.events().len(),
5486 1,
5487 "{label}: the section is recognised"
5488 );
5489 store.link("1", "2", RelationKind::Related).unwrap();
5490 let rendered = store
5491 .get(IssueSelector::Number(1))
5492 .unwrap()
5493 .to_file_contents();
5494 assert_eq!(
5495 rendered.matches("## History").count(),
5496 1,
5497 "{label}: exactly one section after a write, got {rendered}"
5498 );
5499 assert!(
5500 !rendered.lines().any(|l| l != l.trim_end()),
5501 "{label}: no line keeps trailing whitespace, so the heading is \
5502 normalised: {rendered}"
5503 );
5504 }
5505 }
5506
5507 #[test]
5508 fn set_status_discloses_the_retired_key_rather_than_dropping_it_silently() {
5509 let (_tmp, store) = make_store();
5516 write_issue(
5517 &store,
5518 "2026-01-01-a.md",
5519 "---\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",
5520 );
5521 let before = store.get(IssueSelector::Number(1)).unwrap();
5522 assert!(
5523 before.has_retired_links_key,
5524 "precondition: the file carries the retired key"
5525 );
5526 let _ = crate::storage::take_disclosures();
5527
5528 store
5529 .set_status("2026-01-01-a.md", StatusChange::to("in progress"))
5530 .unwrap();
5531
5532 let disclosures = crate::storage::take_disclosures();
5533 assert_eq!(
5534 disclosures.len(),
5535 1,
5536 "set_status must disclose the removal, got {disclosures:?}"
5537 );
5538 assert!(
5539 disclosures[0].contains("links:") && disclosures[0].contains("a.md"),
5540 "naming the field and the file: {disclosures:?}"
5541 );
5542 assert!(
5543 !store
5544 .get(IssueSelector::Number(1))
5545 .unwrap()
5546 .has_retired_links_key,
5547 "and the key is gone, as C-001 intends"
5548 );
5549 }
5550
5551 #[test]
5552 fn f1_but_ordinary_prose_bullets_still_end_the_section() {
5553 let (_tmp, store) = make_store();
5558 write_issue(
5559 &store,
5560 "2026-01-01-a.md",
5561 concat!(
5562 "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\n",
5563 "created: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n",
5564 "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n\n",
5565 "- step one\n- step two\n",
5566 ),
5567 );
5568 let issue = store.get(IssueSelector::Number(1)).unwrap();
5569 assert_eq!(
5570 issue.history.events().len(),
5571 1,
5572 "the real event is an event"
5573 );
5574 assert!(
5575 issue.history.unparsed().is_empty(),
5576 "ordinary bullets are not retained: {:?}",
5577 issue.history.unparsed()
5578 );
5579 assert!(
5580 issue.body.contains("step one") && issue.body.contains("step two"),
5581 "they stay body text: {:?}",
5582 issue.body
5583 );
5584 }
5585
5586 #[test]
5587 fn a_year_is_not_an_ordered_list_marker() {
5588 for prose in [
5592 "2026. Q1 goals",
5593 "2026. roadmap review",
5594 "007. padded",
5595 "99999999999999999999. big",
5596 ] {
5597 assert!(
5598 !crate::model::looks_like_an_unrecognised_entry(prose)
5599 && Issue::parse(&format!(
5600 "---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{prose}\n"
5601 ))
5602 .unwrap()
5603 .history
5604 .is_empty(),
5605 "{prose:?} must be body prose, not a retained entry"
5606 );
5607 }
5608 }
5609
5610 #[test]
5611 fn f2_a_recognised_entry_with_a_bad_kind_keeps_its_trailing_whitespace() {
5612 let line = " - 2026-01-01T00:00:00Z \u{2014} alice \u{2014} bogus_kind \u{2014} d ";
5619 let raw = format!(
5620 "---\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"
5621 );
5622 let issue = Issue::parse(&raw).unwrap();
5623 assert!(issue.history.events().is_empty(), "no event was fabricated");
5624 assert_eq!(
5625 issue.history.unparsed(),
5626 vec![line],
5627 "kept byte-for-byte, trailing spaces included"
5628 );
5629
5630 let rendered = issue.to_file_contents();
5632 assert!(
5633 rendered.contains(&format!("{line}\n")),
5634 "re-emitted exactly, so an unrelated `tix edit` does not rewrite the \
5635 author's audit log: {rendered}"
5636 );
5637 assert_eq!(
5638 Issue::parse(&rendered).unwrap().to_file_contents(),
5639 rendered,
5640 "and the file is byte-stable from here"
5641 );
5642 }
5643
5644 #[test]
5645 fn c002_history_is_append_only() {
5646 let (tmp, store) = make_store();
5647 write_issue(&store, "2026-01-01-h.md", &bare_frontmatter(1, "H"));
5648 let _ = tmp;
5649 let mut issue = store.get(IssueSelector::Number(1)).unwrap();
5650 issue
5651 .history
5652 .append_now("alice", HistoryEventKind::RelationshipAdded, "first");
5653 let after_first = issue.history.clone();
5654 issue
5655 .history
5656 .append_now("alice", HistoryEventKind::RelationshipRemoved, "second");
5657
5658 assert_eq!(issue.history.len(), 2, "appending never replaces");
5659 assert_eq!(
5660 &issue.history.events()[..1],
5661 after_first.events(),
5662 "the earlier entry is untouched and still first"
5663 );
5664 }
5665
5666 #[test]
5667 fn c002_an_unknown_history_kind_is_kept_verbatim_not_coerced() {
5668 let line = "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} bogus_kind \u{2014} something";
5679 let raw = format!(
5680 "---\nnumber: 1\ntitle: \"H\"\nstatus: \"todo\"\n---\n\n## History\n\n{line}\n"
5681 );
5682 let issue = Issue::parse(&raw).expect("an unknown kind must not fail the parse");
5683
5684 assert!(
5685 issue.history.events().is_empty(),
5686 "no event was fabricated for an unknown kind"
5687 );
5688 assert_eq!(
5689 issue.history.unparsed(),
5690 vec![line],
5691 "the line is kept verbatim: not dropped, not repaired"
5692 );
5693 let rendered = issue.to_file_contents();
5697 assert!(rendered.contains(line), "the line survives the write");
5698 assert_eq!(
5699 Issue::parse(&rendered).unwrap().to_file_contents(),
5700 rendered,
5701 "and the file round-trips byte-identically"
5702 );
5703 }
5704
5705 #[test]
5706 fn list_mixes_grouped_and_flat_issues_sorted_by_relative_filename() {
5707 let (tmp, store) = make_store();
5708 let _ = tmp;
5709 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5710 fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
5711 write_issue(
5712 &store,
5713 "alice/2026-01-01-a.md",
5714 &sample_frontmatter(1, "A", "todo"),
5715 );
5716 write_issue(
5717 &store,
5718 "bob/2026-05-10-b.md",
5719 &sample_frontmatter(2, "B", "todo"),
5720 );
5721 write_issue(
5722 &store,
5723 "2026-02-01-c.md",
5724 &sample_frontmatter(3, "C", "todo"),
5725 );
5726 let issues = store.list().unwrap();
5727 let filenames: Vec<&str> = issues.iter().map(|i| i.filename.as_str()).collect();
5728 assert_eq!(issues.len(), 3);
5729 assert_eq!(
5730 filenames,
5731 vec![
5732 "2026-02-01-c.md",
5733 "alice/2026-01-01-a.md",
5734 "bob/2026-05-10-b.md"
5735 ],
5736 "deterministic sort by relative filename"
5737 );
5738 }
5739
5740 #[test]
5741 fn list_sorts_chronologically_within_a_group() {
5742 let (tmp, store) = make_store();
5743 let _ = tmp;
5744 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5745 write_issue(
5746 &store,
5747 "alice/2026-03-01-c.md",
5748 &sample_frontmatter(3, "C", "todo"),
5749 );
5750 write_issue(
5751 &store,
5752 "alice/2026-01-01-a.md",
5753 &sample_frontmatter(1, "A", "todo"),
5754 );
5755 write_issue(
5756 &store,
5757 "alice/2026-02-01-b.md",
5758 &sample_frontmatter(2, "B", "todo"),
5759 );
5760 let issues = store.list().unwrap();
5761 let numbers: Vec<u32> = issues.iter().map(|i| i.number).collect();
5762 assert_eq!(numbers, vec![1, 2, 3]);
5763 }
5764
5765 #[test]
5766 fn list_ignores_non_md_files_in_segments() {
5767 let (tmp, store) = make_store();
5768 let _ = tmp;
5769 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5770 write_issue(
5771 &store,
5772 "alice/2026-01-01-a.md",
5773 &sample_frontmatter(1, "A", "todo"),
5774 );
5775 fs::write(store.issues_dir.join("alice/notes.txt"), "not an issue").unwrap();
5776 fs::write(store.issues_dir.join("alice/.tix-tmp-123-456"), "orphan").unwrap();
5777 let issues = store.list().unwrap();
5778 assert_eq!(issues.len(), 1);
5779 assert_eq!(issues[0].filename, "alice/2026-01-01-a.md");
5780 }
5781
5782 #[test]
5783 fn list_skips_temp_marker_directories() {
5784 let (tmp, store) = make_store();
5785 let _ = tmp;
5786 fs::create_dir_all(store.issues_dir.join(".tix-tmp-999-0")).unwrap();
5787 write_issue(
5788 &store,
5789 ".tix-tmp-999-0/2026-01-01-junk.md",
5790 &sample_frontmatter(9, "J", "todo"),
5791 );
5792 write_issue(
5793 &store,
5794 "2026-01-01-a.md",
5795 &sample_frontmatter(1, "A", "todo"),
5796 );
5797 let issues = store.list().unwrap();
5798 assert_eq!(issues.len(), 1);
5799 assert_eq!(issues[0].filename, "2026-01-01-a.md");
5800 }
5801
5802 #[test]
5803 fn list_parse_error_carries_grouped_relative_path() {
5804 let (tmp, store) = make_store();
5805 let _ = tmp;
5806 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5807 fs::write(
5808 store.issues_dir.join("alice/2026-01-01-bad.md"),
5809 "no frontmatter here\n",
5810 )
5811 .unwrap();
5812 let err = store.list().unwrap_err();
5813 assert!(
5814 matches!(&err, TixError::Parse(m) if m.starts_with("alice/2026-01-01-bad.md:")),
5815 "parse error must carry the relative path, got {err:?}"
5816 );
5817 }
5818
5819 #[test]
5824 fn create_writes_into_assignee_segment_and_records_relative_filename() {
5825 let (tmp, store) = make_store();
5826 let _ = tmp;
5827 let mut issue = Issue {
5828 number: 0,
5829 title: "T".into(),
5830 status: Status::default(),
5831 labels: vec![],
5832 priority: Priority::default(),
5833 assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
5834 estimate: None,
5835 commit: None,
5836 gitlab_id: None,
5837 linear_id: None,
5838 mission: None,
5839 task: None,
5840 relationships: vec![],
5841 files: vec![],
5842 cycles: None,
5843 milestones: None,
5844 reviewer: None,
5845 start_date: None,
5846 due_date: None,
5847 created: "2026-01-01".into(),
5848 updated: "2026-01-01".into(),
5849 filename: String::new(),
5850 body: String::new(),
5851 history: crate::model::History::new(),
5852 has_retired_links_key: false,
5853 parent: None,
5854 extra_frontmatter: std::collections::BTreeMap::new(),
5855 archived: false,
5856 entered_at: None,
5857 source: None,
5858 };
5859 let path = store
5860 .create(&mut issue, "body", Some("custom-stem"))
5861 .unwrap();
5862 let rel = "herbert-maschke/custom-stem.md";
5863 assert_eq!(issue.filename, rel);
5864 assert_eq!(path, store.issues_dir.join(rel));
5865 assert!(store.issues_dir.join("herbert-maschke").is_dir());
5866 assert!(store.issues_dir.join(rel).is_file());
5867 }
5868
5869 #[test]
5870 fn create_without_assignee_writes_unassigned_segment() {
5871 let (tmp, store) = make_store();
5872 let _ = tmp;
5873 let mut issue = Issue {
5874 number: 0,
5875 title: "T".into(),
5876 status: Status::default(),
5877 labels: vec![],
5878 priority: Priority::default(),
5879 assignee: None,
5880 estimate: None,
5881 commit: None,
5882 gitlab_id: None,
5883 linear_id: None,
5884 mission: None,
5885 task: None,
5886 relationships: vec![],
5887 files: vec![],
5888 cycles: None,
5889 milestones: None,
5890 reviewer: None,
5891 start_date: None,
5892 due_date: None,
5893 created: "2026-01-01".into(),
5894 updated: "2026-01-01".into(),
5895 filename: String::new(),
5896 body: String::new(),
5897 history: crate::model::History::new(),
5898 has_retired_links_key: false,
5899 parent: None,
5900 extra_frontmatter: std::collections::BTreeMap::new(),
5901 archived: false,
5902 entered_at: None,
5903 source: None,
5904 };
5905 let path = store.create(&mut issue, "", Some("stem")).unwrap();
5906 assert_eq!(issue.filename, "unassigned/stem.md");
5907 assert!(store.issues_dir.join("unassigned").is_dir());
5908 assert!(store.issues_dir.join("unassigned/stem.md").is_file());
5909 assert_eq!(path, store.issues_dir.join("unassigned/stem.md"));
5910 }
5911
5912 #[test]
5913 fn create_with_at_handle_derives_bare_segment() {
5914 let (tmp, store) = make_store();
5915 let _ = tmp;
5916 let mut issue = Issue {
5917 number: 0,
5918 title: "T".into(),
5919 status: Status::default(),
5920 labels: vec![],
5921 priority: Priority::default(),
5922 assignee: Some("@alice".into()),
5923 estimate: None,
5924 commit: None,
5925 gitlab_id: None,
5926 linear_id: None,
5927 mission: None,
5928 task: None,
5929 relationships: vec![],
5930 files: vec![],
5931 cycles: None,
5932 milestones: None,
5933 reviewer: None,
5934 start_date: None,
5935 due_date: None,
5936 created: "2026-01-01".into(),
5937 updated: "2026-01-01".into(),
5938 filename: String::new(),
5939 body: String::new(),
5940 history: crate::model::History::new(),
5941 has_retired_links_key: false,
5942 parent: None,
5943 extra_frontmatter: std::collections::BTreeMap::new(),
5944 archived: false,
5945 entered_at: None,
5946 source: None,
5947 };
5948 store.create(&mut issue, "", Some("stem")).unwrap();
5949 assert_eq!(issue.filename, "alice/stem.md");
5950 assert!(store.issues_dir.join("alice/stem.md").is_file());
5951 }
5952
5953 #[test]
5958 fn reassign_moves_file_between_segments() {
5959 let (tmp, store) = make_store();
5960 let _ = tmp;
5961 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5962 write_issue(
5963 &store,
5964 "alice/2026-01-01-x.md",
5965 &sample_frontmatter(1, "X", "todo"),
5966 );
5967 let new_path = store
5968 .reassign("alice/2026-01-01-x.md", Some("@bob"))
5969 .unwrap();
5970 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x.md"));
5971 assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
5972 assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
5973 }
5974
5975 #[test]
5976 fn reassign_into_occupied_segment_picks_deterministic_suffix() {
5977 let (tmp, store) = make_store();
5978 let _ = tmp;
5979 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5980 fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
5981 write_issue(
5982 &store,
5983 "alice/2026-01-01-x.md",
5984 &sample_frontmatter(1, "X", "todo"),
5985 );
5986 write_issue(
5987 &store,
5988 "bob/2026-01-01-x.md",
5989 &sample_frontmatter(2, "X", "todo"),
5990 );
5991 let new_path = store
5992 .reassign("alice/2026-01-01-x.md", Some("@bob"))
5993 .unwrap();
5994 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x-1.md"));
5995 assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
5996 assert!(store.issues_dir.join("bob/2026-01-01-x-1.md").is_file());
5997 assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
5998 }
5999
6000 #[test]
6001 fn reassign_legacy_flat_file_into_segment() {
6002 let (tmp, store) = make_store();
6003 let _ = tmp;
6004 write_issue(
6005 &store,
6006 "2026-01-01-y.md",
6007 &sample_frontmatter(1, "Y", "todo"),
6008 );
6009 let new_path = store.reassign("2026-01-01-y.md", Some("alice")).unwrap();
6010 assert_eq!(new_path, PathBuf::from("alice/2026-01-01-y.md"));
6011 assert!(store.issues_dir.join("alice/2026-01-01-y.md").is_file());
6012 assert!(!store.issues_dir.join("2026-01-01-y.md").exists());
6013 }
6014
6015 #[test]
6016 fn reassign_to_none_uses_unassigned_fallback() {
6017 let (tmp, store) = make_store();
6018 let _ = tmp;
6019 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6020 write_issue(
6021 &store,
6022 "alice/2026-01-01-z.md",
6023 &sample_frontmatter(1, "Z", "todo"),
6024 );
6025 let new_path = store.reassign("alice/2026-01-01-z.md", None).unwrap();
6026 assert_eq!(new_path, PathBuf::from("unassigned/2026-01-01-z.md"));
6027 assert!(
6028 store
6029 .issues_dir
6030 .join("unassigned/2026-01-01-z.md")
6031 .is_file()
6032 );
6033 assert!(!store.issues_dir.join("alice/2026-01-01-z.md").exists());
6034 }
6035
6036 #[test]
6037 fn reassign_keeps_number_and_created_stable() {
6038 let (tmp, store) = make_store();
6039 let _ = tmp;
6040 let mut issue = Issue {
6041 number: 0,
6042 title: "Stable".into(),
6043 status: Status::default(),
6044 labels: vec![],
6045 priority: Priority::default(),
6046 assignee: Some("alice".into()),
6047 estimate: None,
6048 commit: None,
6049 gitlab_id: None,
6050 linear_id: None,
6051 mission: None,
6052 task: None,
6053 relationships: vec![],
6054 files: vec![],
6055 cycles: None,
6056 milestones: None,
6057 reviewer: None,
6058 start_date: None,
6059 due_date: None,
6060 created: "2026-01-01".into(),
6061 updated: "2026-01-01".into(),
6062 filename: String::new(),
6063 body: String::new(),
6064 history: crate::model::History::new(),
6065 has_retired_links_key: false,
6066 parent: None,
6067 extra_frontmatter: std::collections::BTreeMap::new(),
6068 archived: false,
6069 entered_at: None,
6070 source: None,
6071 };
6072 store.create(&mut issue, "body", None).unwrap();
6073 let number = issue.number;
6074 let created = issue.created.clone();
6075 let before = issue.filename.clone();
6076 let new_path = store.reassign(&before, Some("@bob")).unwrap();
6077 let rescan = store.list().unwrap();
6078 let moved = rescan
6079 .iter()
6080 .find(|i| i.filename == new_path)
6081 .expect("moved file must be listed at its new relative path");
6082 assert_eq!(moved.number, number);
6083 assert_eq!(moved.created, created);
6084 assert_eq!(moved.title, "Stable");
6085 }
6086
6087 #[test]
6088 fn reassign_equal_segment_is_noop() {
6089 let (tmp, store) = make_store();
6090 let _ = tmp;
6091 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6092 write_issue(
6093 &store,
6094 "alice/2026-01-01-x.md",
6095 &sample_frontmatter(1, "X", "todo"),
6096 );
6097 let new_path = store
6098 .reassign("alice/2026-01-01-x.md", Some("alice"))
6099 .unwrap();
6100 assert_eq!(new_path, PathBuf::from("alice/2026-01-01-x.md"));
6101 assert!(store.issues_dir.join("alice/2026-01-01-x.md").is_file());
6102 }
6103
6104 #[test]
6105 fn reassign_missing_file_is_not_found() {
6106 let (tmp, store) = make_store();
6107 let _ = tmp;
6108 let err = store.reassign("nope.md", Some("alice")).unwrap_err();
6109 assert!(matches!(err, TixError::NotFound(_)));
6110 }
6111
6112 #[test]
6117 fn validate_filename_allows_flat_and_single_segment_prefix() {
6118 validate_filename("2026-01-01-x.md").unwrap();
6119 validate_filename("alice/2026-01-01-x.md").unwrap();
6120 }
6121
6122 #[test]
6123 fn validate_filename_refuses_deeper_segments() {
6124 let err = validate_filename("alice/bob/2026-01-01-x.md").unwrap_err();
6125 assert!(matches!(err, TixError::InvalidInput(_)));
6126 }
6127
6128 #[test]
6129 fn validate_filename_refuses_leading_and_trailing_slash_and_absolute() {
6130 let err = validate_filename("/alice/2026-01-01-x.md").unwrap_err();
6131 assert!(matches!(err, TixError::InvalidInput(_)));
6132 let err = validate_filename("/etc/passwd").unwrap_err();
6133 assert!(matches!(err, TixError::InvalidInput(_)));
6134 let err = validate_filename("alice/").unwrap_err();
6135 assert!(matches!(err, TixError::InvalidInput(_)));
6136 }
6137
6138 #[test]
6139 fn path_traversal_with_segment_dotdot_refused() {
6140 let err = validate_filename("alice/../../x.md").unwrap_err();
6141 assert!(matches!(err, TixError::InvalidInput(_)));
6142 let err = validate_filename("alice/../x.md").unwrap_err();
6143 assert!(matches!(err, TixError::InvalidInput(_)));
6144 }
6145
6146 #[test]
6147 fn get_update_delete_set_status_work_on_grouped_relative_paths() {
6148 let (tmp, store) = make_store();
6149 let _ = tmp;
6150 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6151 write_issue(
6152 &store,
6153 "alice/2026-01-01-g.md",
6154 &sample_frontmatter(7, "G", "todo"),
6155 );
6156 let got = store
6157 .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
6158 .unwrap();
6159 assert_eq!(got.number, 7);
6160 let done = store
6161 .set_status("alice/2026-01-01-g.md", StatusChange::to("done"))
6162 .unwrap();
6163 assert_eq!(done.issue.status.display(), "done");
6164 assert!(store.issues_dir.join("alice/2026-01-01-g.md").is_file());
6165 let mut edited = store
6166 .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
6167 .unwrap();
6168 edited.title = "Updated".into();
6169 store.update("alice/2026-01-01-g.md", &edited).unwrap();
6170 let reloaded = store
6171 .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
6172 .unwrap();
6173 assert_eq!(reloaded.title, "Updated");
6174 store.delete("alice/2026-01-01-g.md").unwrap();
6175 assert!(!store.issues_dir.join("alice/2026-01-01-g.md").exists());
6176 }
6177
6178 #[test]
6183 fn grouped_round_trip_create_list_get_update_delete() {
6184 let (tmp, store) = make_store();
6185 let _ = tmp;
6186 let mut issue = Issue {
6187 number: 0,
6188 title: "Round trip".into(),
6189 status: Status::default(),
6190 labels: vec!["bug".into()],
6191 priority: Priority::default(),
6192 assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
6193 estimate: None,
6194 commit: None,
6195 gitlab_id: None,
6196 linear_id: None,
6197 mission: None,
6198 task: None,
6199 relationships: vec![],
6200 files: vec![],
6201 cycles: None,
6202 milestones: None,
6203 reviewer: None,
6204 start_date: None,
6205 due_date: None,
6206 created: "2026-01-01".into(),
6207 updated: "2026-01-01".into(),
6208 filename: String::new(),
6209 body: String::new(),
6210 history: crate::model::History::new(),
6211 has_retired_links_key: false,
6212 parent: None,
6213 extra_frontmatter: std::collections::BTreeMap::new(),
6214 archived: false,
6215 entered_at: None,
6216 source: None,
6217 };
6218 store.create(&mut issue, "initial body", None).unwrap();
6219 let rel = issue.filename.clone();
6220 assert_eq!(rel, "herbert-maschke/2026-01-01-round-trip.md");
6221
6222 let listed = store.list().unwrap();
6223 assert_eq!(listed.len(), 1);
6224 assert_eq!(listed[0].filename, rel);
6225 assert_eq!(listed[0].number, issue.number);
6226
6227 let got = store.get(IssueSelector::Filename(rel.clone())).unwrap();
6228 let mut edited = got.clone();
6229 edited.title = "Renamed".into();
6230 store.update(&rel, &edited).unwrap();
6231 let reloaded = store.get(IssueSelector::Filename(rel.clone())).unwrap();
6232 assert_eq!(reloaded.title, "Renamed");
6233
6234 store.delete(&rel).unwrap();
6235 assert!(!store.issues_dir.join(&rel).exists());
6236 assert!(store.list().unwrap().is_empty());
6237 }
6238
6239 #[test]
6240 fn legacy_flat_file_edit_and_status_still_work_after_layout_flip() {
6241 let (tmp, store) = make_store();
6242 let _ = tmp;
6243 write_issue(
6244 &store,
6245 "2026-01-01-legacy.md",
6246 &sample_frontmatter(5, "Legacy", "todo"),
6247 );
6248 let listed = store.list().unwrap();
6249 assert_eq!(listed.len(), 1);
6250 assert_eq!(listed[0].filename, "2026-01-01-legacy.md");
6251 let mutated = store
6252 .set_status("2026-01-01-legacy.md", StatusChange::to("in progress"))
6253 .unwrap();
6254 assert_eq!(mutated.issue.status.display(), "in progress");
6255 assert!(store.issues_dir.join("2026-01-01-legacy.md").is_file());
6256 }
6257
6258 fn write_asset_source(tmp: &tempfile::TempDir, name: &str, contents: &[u8]) -> PathBuf {
6263 let path = tmp.path().join(name);
6264 fs::write(&path, contents).unwrap();
6265 path
6266 }
6267
6268 fn write_issue_with_basic_body(store: &Store, name: &str, number: u32) {
6269 write_issue(
6270 store,
6271 name,
6272 &sample_frontmatter(number, "Asset host", "todo"),
6273 );
6274 }
6275
6276 #[test]
6277 fn add_asset_image_inserts_image_link_and_writes_file() {
6278 let (tmp, store) = make_store();
6279 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6280 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6281 let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
6282 let added = store
6283 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6284 .unwrap();
6285 assert_eq!(added.written_name, "diagram.png");
6286 assert_eq!(added.link, "");
6287 assert!(
6288 store
6289 .issues_dir
6290 .join("alice/fix-login-bug-assets/diagram.png")
6291 .is_file()
6292 );
6293 }
6294
6295 #[test]
6296 fn add_asset_non_image_inserts_generic_markdown_link() {
6297 let (tmp, store) = make_store();
6298 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6299 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6300 let src = write_asset_source(&tmp, "report.pdf", b"%PDF");
6301 let added = store
6302 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6303 .unwrap();
6304 assert_eq!(added.link, "[report](fix-login-bug-assets/report.pdf)");
6305 assert!(
6306 store
6307 .issues_dir
6308 .join("alice/fix-login-bug-assets/report.pdf")
6309 .is_file()
6310 );
6311 }
6312
6313 #[test]
6314 fn add_asset_collision_resolves_with_numeric_suffix_and_link_points_at_it() {
6315 let (tmp, store) = make_store();
6316 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6317 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6318 let src = write_asset_source(&tmp, "x.png", b"first");
6319 let first = store
6320 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6321 .unwrap();
6322 assert_eq!(first.written_name, "x.png");
6323 let again = store
6324 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6325 .unwrap();
6326 assert_eq!(again.written_name, "x-1.png");
6327 assert_eq!(again.link, "");
6328 assert!(
6329 store
6330 .issues_dir
6331 .join("alice/fix-login-bug-assets/x.png")
6332 .is_file()
6333 );
6334 assert!(
6335 store
6336 .issues_dir
6337 .join("alice/fix-login-bug-assets/x-1.png")
6338 .is_file()
6339 );
6340 }
6341
6342 #[test]
6343 fn add_asset_appends_single_link_and_preserves_description() {
6344 let (tmp, store) = make_store();
6345 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6346 write_issue(
6347 &store,
6348 "alice/2026-01-01-fix-login-bug.md",
6349 "---\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",
6350 );
6351 let src = write_asset_source(&tmp, "diagram.svg", b"<svg/>");
6352 store
6353 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6354 .unwrap();
6355 let reloaded = store
6356 .get(crate::storage::IssueSelector::Filename(
6357 "alice/2026-01-01-fix-login-bug.md".into(),
6358 ))
6359 .unwrap();
6360 assert_eq!(reloaded.description(), "Title line.");
6362 let link_count = reloaded.body.matches("![diagram]").count();
6364 assert_eq!(link_count, 1);
6365 assert!(
6366 reloaded
6367 .body
6368 .ends_with("fix-login-bug-assets/diagram.svg)\n")
6369 );
6370 }
6371
6372 #[test]
6373 fn add_asset_missing_issue_is_not_found() {
6374 let (tmp, store) = make_store();
6375 let _ = tmp;
6376 let src = write_asset_source(&tmp, "x.png", b"x");
6377 let err = store.add_asset("missing.md", &src).unwrap_err();
6378 assert!(matches!(err, TixError::NotFound(_)));
6379 }
6380
6381 #[test]
6382 fn add_asset_legacy_flat_file_uses_stem_assets_under_issues_dir() {
6383 let (tmp, store) = make_store();
6384 write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
6385 let src = write_asset_source(&tmp, "x.png", b"x");
6386 let added = store.add_asset("2026-01-01-legacy.md", &src).unwrap();
6387 assert_eq!(added.link, "");
6388 assert!(store.issues_dir.join("legacy-assets/x.png").is_file());
6389 }
6390
6391 #[test]
6396 fn reassign_moves_asset_dir_with_the_file() {
6397 let (tmp, store) = make_store();
6398 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6399 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6400 let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
6401 store
6402 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6403 .unwrap();
6404 let new_path = store
6405 .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
6406 .unwrap();
6407 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
6408 assert!(
6409 store
6410 .issues_dir
6411 .join("bob/fix-login-bug-assets/diagram.png")
6412 .is_file()
6413 );
6414 assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
6415 }
6416
6417 #[test]
6418 fn reassign_without_asset_dir_behaves_as_before() {
6419 let (tmp, store) = make_store();
6420 let _ = tmp;
6421 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6422 write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
6423 let new_path = store
6424 .reassign("alice/2026-01-01-plain.md", Some("@bob"))
6425 .unwrap();
6426 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-plain.md"));
6427 assert!(store.issues_dir.join("bob/2026-01-01-plain.md").is_file());
6428 assert!(!store.issues_dir.join("bob/plain-assets").exists());
6429 }
6430
6431 #[test]
6432 fn reassign_into_segment_with_same_named_asset_dir_uses_suffix_no_loss() {
6433 let (tmp, store) = make_store();
6434 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6435 fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
6436 let existing = store.issues_dir.join("bob/fix-login-bug-assets");
6438 fs::create_dir_all(&existing).unwrap();
6439 fs::write(existing.join("keep.txt"), "precious").unwrap();
6440 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6441 let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
6442 store
6443 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6444 .unwrap();
6445 let new_path = store
6446 .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
6447 .unwrap();
6448 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
6449 assert!(existing.join("keep.txt").is_file());
6451 assert!(
6452 store
6453 .issues_dir
6454 .join("bob/fix-login-bug-assets-1/diagram.png")
6455 .is_file()
6456 );
6457 assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
6458 }
6459
6460 #[test]
6461 fn reassign_preserves_markdown_link_resolution_in_new_segment() {
6462 let (tmp, store) = make_store();
6463 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6464 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6465 let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
6466 store
6467 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6468 .unwrap();
6469 store
6470 .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
6471 .unwrap();
6472 assert!(
6475 store
6476 .issues_dir
6477 .join("bob/fix-login-bug-assets/diagram.png")
6478 .is_file()
6479 );
6480 }
6481
6482 #[test]
6487 fn delete_removes_asset_dir_no_orphans() {
6488 let (tmp, store) = make_store();
6489 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6490 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6491 let src = write_asset_source(&tmp, "x.png", b"x");
6492 store
6493 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6494 .unwrap();
6495 store.delete("alice/2026-01-01-fix-login-bug.md").unwrap();
6496 assert!(
6497 !store
6498 .issues_dir
6499 .join("alice/2026-01-01-fix-login-bug.md")
6500 .exists()
6501 );
6502 assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
6503 }
6504
6505 #[test]
6506 fn delete_without_asset_dir_still_deletes_file() {
6507 let (tmp, store) = make_store();
6508 let _ = tmp;
6509 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6510 write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
6511 store.delete("alice/2026-01-01-plain.md").unwrap();
6512 assert!(!store.issues_dir.join("alice/2026-01-01-plain.md").exists());
6513 }
6514
6515 #[test]
6516 fn list_ignores_asset_directories() {
6517 let (tmp, store) = make_store();
6518 write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
6519 let src = write_asset_source(&tmp, "x.png", b"x");
6520 store.add_asset("2026-01-01-legacy.md", &src).unwrap();
6521 let issues = store.list().unwrap();
6524 assert_eq!(issues.len(), 1);
6525 assert_eq!(issues[0].filename, "2026-01-01-legacy.md");
6526 }
6527
6528 #[test]
6529 fn list_ignores_nested_asset_directories_under_segment() {
6530 let (tmp, store) = make_store();
6531 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6532 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6533 let src = write_asset_source(&tmp, "x.png", b"x");
6534 store
6535 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6536 .unwrap();
6537 let issues = store.list().unwrap();
6538 assert_eq!(issues.len(), 1);
6539 assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
6540 }
6541
6542 #[test]
6543 fn asset_dir_with_formless_md_child_is_not_scanned_as_segment() {
6544 let (tmp, store) = make_store();
6545 let _ = tmp;
6546 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6547 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6548 let assets_dir = store.issues_dir.join("alice/fix-login-bug-assets");
6551 fs::create_dir_all(&assets_dir).unwrap();
6552 fs::write(assets_dir.join("notes.md"), "no frontmatter").unwrap();
6553 let issues = store.list().unwrap();
6554 assert_eq!(issues.len(), 1);
6555 assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
6556 }
6557
6558 fn hierarchy_trio() -> (TempDir, Store) {
6572 let (tmp, store) = make_store();
6573 for (n, title) in [(1, "A"), (2, "B"), (3, "C")] {
6574 write_issue(
6575 &store,
6576 &format!("2026-01-01-{}.md", title.to_lowercase()),
6577 &bare_frontmatter(n, title),
6578 );
6579 }
6580 (tmp, store)
6581 }
6582
6583 fn store_from_hierarchy_fixture(name: &str) -> (TempDir, Store) {
6590 let (tmp, store) = make_store();
6591 write_issue(
6592 &store,
6593 name,
6594 &std::fs::read_to_string(format!(
6595 "{}/tests/fixtures/hierarchy/{name}",
6596 env!("CARGO_MANIFEST_DIR")
6597 ))
6598 .unwrap(),
6599 );
6600 (tmp, store)
6601 }
6602
6603 fn bytes_of(store: &Store, number: u32) -> String {
6604 let issue = store.get(IssueSelector::Number(number)).unwrap();
6605 read_issue_file(store, &issue.filename)
6606 }
6607
6608 #[test]
6609 fn t008_set_writes_the_field_and_exactly_one_history_event() {
6610 let (_tmp, store) = hierarchy_trio();
6611 let outcome = store.set_parent("2", "1").unwrap();
6612 assert!(outcome.changed);
6613 assert_eq!(outcome.parent, Some(1));
6614
6615 let child = store.get(IssueSelector::Number(2)).unwrap();
6616 assert_eq!(child.parent, Some(1), "C-001: the number is stored");
6617 assert_eq!(child.history.len(), 1, "C-004: exactly one event");
6618 let event = &child.history.events()[0];
6619 assert_eq!(event.kind, HistoryEventKind::ParentSet);
6620 assert!(
6621 !event.actor.is_empty(),
6622 "an event without an actor is not an audit record"
6623 );
6624 assert_eq!(event.detail, "parent none -> #1", "got: {}", event.detail);
6626 }
6627
6628 #[test]
6629 fn t008_change_repoints_and_records_parent_changed() {
6630 let (_tmp, store) = hierarchy_trio();
6631 store.set_parent("3", "1").unwrap();
6632 let outcome = store.change_parent("3", "2").unwrap();
6633 assert!(outcome.changed);
6634 assert_eq!(outcome.parent, Some(2));
6635
6636 let child = store.get(IssueSelector::Number(3)).unwrap();
6637 assert_eq!(child.parent, Some(2));
6638 assert_eq!(child.history.len(), 2, "one event per accepted change");
6639 let events = child.history.events();
6640 let last = events.last().unwrap();
6641 assert_eq!(last.kind, HistoryEventKind::ParentChanged);
6642 assert_eq!(last.detail, "parent #1 -> #2", "got: {}", last.detail);
6645 }
6646
6647 #[test]
6648 fn t008_clear_removes_the_field_and_records_parent_cleared() {
6649 let (_tmp, store) = hierarchy_trio();
6650 store.set_parent("2", "1").unwrap();
6651 let outcome = store.clear_parent("2").unwrap();
6652 assert!(outcome.changed);
6653 assert_eq!(outcome.parent, None);
6654
6655 let child = store.get(IssueSelector::Number(2)).unwrap();
6656 assert_eq!(child.parent, None);
6657 assert_eq!(child.history.len(), 2);
6658 let events = child.history.events();
6659 let last = events.last().unwrap();
6660 assert_eq!(last.kind, HistoryEventKind::ParentCleared);
6661 assert_eq!(last.detail, "parent #1 -> none", "got: {}", last.detail);
6662 }
6663
6664 #[test]
6665 fn t008_reapplying_the_same_parent_is_a_no_op_with_no_second_event() {
6666 let (_tmp, store) = hierarchy_trio();
6667 store.set_parent("2", "1").unwrap();
6668 let after_first = bytes_of(&store, 2);
6669
6670 let second = store.set_parent("2", "1").unwrap();
6671 assert!(!second.changed, "an unchanged parent is not a change");
6672 assert!(second.event.is_none(), "a no-op records nothing (C-004)");
6673 assert_eq!(
6674 bytes_of(&store, 2),
6675 after_first,
6676 "a no-op must not rewrite the file at all"
6677 );
6678 }
6679
6680 #[test]
6681 fn t008_clearing_a_child_with_no_parent_is_a_no_op() {
6682 let (_tmp, store) = hierarchy_trio();
6683 let before = bytes_of(&store, 2);
6684 let outcome = store.clear_parent("2").unwrap();
6685 assert!(!outcome.changed);
6686 assert!(outcome.event.is_none());
6687 assert_eq!(bytes_of(&store, 2), before);
6688 }
6689
6690 #[test]
6691 fn t008_sc004_a_rejected_cycle_leaves_every_file_byte_identical() {
6692 let (_tmp, store) = hierarchy_trio();
6694 store.set_parent("2", "1").unwrap();
6695 store.set_parent("3", "2").unwrap();
6696 let before: Vec<String> = (1..=3).map(|n| bytes_of(&store, n)).collect();
6697
6698 let err = store
6699 .set_parent("1", "3")
6700 .expect_err("1 -> 2 -> 3 -> 1 is a cycle and must be refused");
6701 let msg = err.to_string();
6702 assert!(
6703 msg.contains("cycle"),
6704 "the diagnostic must name the rule: {msg}"
6705 );
6706
6707 for n in 1..=3 {
6709 assert_eq!(
6710 bytes_of(&store, n),
6711 before[(n - 1) as usize],
6712 "issue {n} must be byte-identical after a rejected cycle"
6713 );
6714 }
6715 }
6716
6717 #[test]
6718 fn t008_a_self_parent_is_rejected_and_writes_nothing() {
6719 let (_tmp, store) = hierarchy_trio();
6720 let before = bytes_of(&store, 2);
6721 let err = store
6722 .set_parent("2", "2")
6723 .expect_err("an issue cannot be its own parent");
6724 assert!(err.to_string().contains("self_parent"), "got: {err}");
6725 assert_eq!(bytes_of(&store, 2), before);
6726 }
6727
6728 #[test]
6729 fn t008_a_self_parent_already_on_disk_is_still_refused() {
6730 let (_tmp, store) = store_from_hierarchy_fixture("self-parent.md");
6738 let before = bytes_of(&store, 7);
6739 let err = store
6740 .set_parent("7", "7")
6741 .expect_err("a stored self-parent must still be refused on a re-set");
6742 assert!(
6743 err.to_string().contains("self_parent"),
6744 "refused for the wrong reason: {err}"
6745 );
6746 assert_eq!(
6747 bytes_of(&store, 7),
6748 before,
6749 "and the refused write must leave the file byte-identical"
6750 );
6751 assert!(store.change_parent("7", "7").is_err());
6753 assert_eq!(store.get(IssueSelector::Number(7)).unwrap().parent, Some(7));
6755 }
6756
6757 #[test]
6758 fn t008_c003_setting_a_child_writes_nothing_to_the_parents_file() {
6759 let (_tmp, store) = hierarchy_trio();
6762 let parent_before = bytes_of(&store, 1);
6763 store.set_parent("2", "1").unwrap();
6764 store.set_parent("3", "1").unwrap();
6765 assert_eq!(
6766 bytes_of(&store, 1),
6767 parent_before,
6768 "the parent's file must be untouched by its children appearing"
6769 );
6770 assert!(
6771 !parent_before.contains("parent:"),
6772 "the parent is a top of the hierarchy and has no parent itself"
6773 );
6774 }
6775
6776 #[test]
6777 fn t008_a_legacy_file_without_the_field_is_unchanged_by_a_no_op_write() {
6778 let (_tmp, store) = hierarchy_trio();
6780 let before = bytes_of(&store, 2);
6781 assert!(!before.contains("parent:"), "the fixture must be legacy");
6782
6783 store.clear_parent("2").unwrap();
6784 assert_eq!(bytes_of(&store, 2), before, "a no-op must not add the key");
6785 }
6786
6787 #[test]
6788 fn t008_an_unresolvable_selector_is_refused_before_any_write() {
6789 let (_tmp, store) = hierarchy_trio();
6790 let before = bytes_of(&store, 2);
6791 assert!(store.set_parent("2", "no such issue").is_err());
6792 assert!(store.change_parent("2", "999").is_err());
6793 assert_eq!(bytes_of(&store, 2), before);
6794 }
6795
6796 #[test]
6797 fn t008_t008_contains_no_hierarchy_rule_of_its_own() {
6798 let (_tmp, store) = hierarchy_trio();
6814 store.set_parent("2", "1").unwrap();
6815 store.set_parent("3", "2").unwrap();
6816
6817 let issues = store.list().unwrap();
6818 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
6819 let index = HierarchyIndex::build(&views);
6820 let service =
6821 hierarchy::set_parent(&index, 1, 3).expect_err("the service refuses this too");
6822 let storage = store.set_parent("1", "3").expect_err("and so does storage");
6823
6824 assert_eq!(
6825 storage.to_string(),
6826 TixError::from(service).to_string(),
6827 "storage must surface the service's diagnosis verbatim, not its own"
6828 );
6829 let text = storage.to_string();
6832 assert!(
6833 text.contains("cycle:") && !text.contains("cycle: cycle:"),
6834 "a lookalike renderer or a doubled prefix: {text}"
6835 );
6836 assert!(
6837 text.starts_with("invalid input: "),
6838 "a hierarchy refusal must carry the same prefix as every other: {text}"
6839 );
6840 }
6841
6842 #[test]
6843 fn t008_a_no_op_reports_no_written_issue_but_still_reports_the_current_parent() {
6844 let (_tmp, store) = hierarchy_trio();
6848 store.set_parent("2", "1").unwrap();
6849 let again = store.set_parent("2", "1").unwrap();
6850 assert!(!again.changed);
6851 assert!(
6852 again.written.is_none(),
6853 "a no-op wrote nothing, so there is no written issue to hand back"
6854 );
6855 assert!(
6856 again.event.is_none(),
6857 "and no event, or a retry would pad the audit trail"
6858 );
6859 assert_eq!(
6860 again.parent,
6861 Some(1),
6862 "but the resulting state is still reported, so a caller need not re-read"
6863 );
6864 }
6865}