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 get(&self, selector: IssueSelector) -> Result<Issue> {
554 match selector {
555 IssueSelector::Number(num) => {
556 let issues = self.list_all()?;
557 issues
558 .into_iter()
559 .find(|i| i.number == num)
560 .ok_or_else(|| TixError::NotFound(format!("issue #{num}")))
561 }
562 IssueSelector::Filename(name) => {
563 validate_filename(&name)?;
564 let path = self.issues_dir.join(&name);
565 if !path.is_file() {
566 return Err(TixError::NotFound(format!("file {name}")));
567 }
568 let contents = std::fs::read_to_string(&path)?;
569 let mut issue = Issue::parse(&contents)?;
570 issue.filename = name;
571 Ok(issue)
572 }
573 IssueSelector::Slug(slug) => {
574 let issues = self.list_all()?;
575 issues
576 .into_iter()
577 .find(|i| slugify(&i.title) == slug)
578 .ok_or_else(|| TixError::NotFound(format!("slug {slug}")))
579 }
580 }
581 }
582
583 pub fn archive(&self, sel: &str) -> Result<Issue> {
596 self.set_archived(sel, true)
597 }
598
599 pub fn restore(&self, sel: &str) -> Result<Issue> {
603 self.set_archived(sel, false)
604 }
605
606 fn set_archived(&self, sel: &str, archive: bool) -> Result<Issue> {
607 let mut issue = self.resolve_issue(sel)?;
610 if issue.archived == archive {
611 return Err(TixError::InvalidInput(format!(
612 "{}: issue #{} ({}) is already {}",
613 if archive {
614 "already_archived"
615 } else {
616 "not_archived"
617 },
618 issue.number,
619 issue.filename,
620 if archive { "archived" } else { "active" },
621 )));
622 }
623 let old_rel = issue.filename.clone();
624 let new_rel = flip_archived(&old_rel, archive).ok_or_else(|| {
625 TixError::InvalidInput(format!(
626 "cannot {} {old_rel}: its filename does not carry the archived marker in the \
627 expected position",
628 if archive { "archive" } else { "restore" }
629 ))
630 })?;
631 let old_path = self.issues_dir.join(&old_rel);
632 let new_path = self.issues_dir.join(&new_rel);
633 if !old_path.is_file() {
634 return Err(TixError::NotFound(format!("file {old_rel}")));
635 }
636 if new_path.exists() {
637 return Err(TixError::InvalidInput(format!(
638 "cannot {} {old_rel}: {new_rel} already exists",
639 if archive { "archive" } else { "restore" }
640 )));
641 }
642 std::fs::rename(&old_path, &new_path)?;
647 issue.filename = new_rel.clone();
648 issue.archived = archive;
649 issue.history.append_now(
650 &actor_identity(),
651 if archive {
652 HistoryEventKind::Archived
653 } else {
654 HistoryEventKind::Restored
655 },
656 &format!("{old_rel} -> {new_rel}"),
657 );
658 self.update(&new_rel, &issue)?;
661 Ok(issue)
662 }
663
664 pub fn create(
680 &self,
681 issue: &mut Issue,
682 body: &str,
683 filename_stem: Option<&str>,
684 ) -> Result<PathBuf> {
685 issue.body = body.to_string();
686 issue.bump_updated();
687
688 std::fs::create_dir_all(&self.issues_dir)?;
689 issue.number = self.resolve_next_number()?;
690
691 let mut claim = NumberClaim::acquire(&self.issues_dir, issue.number)?;
705 issue.number = claim.number().ok_or_else(|| {
711 TixError::Io(std::io::Error::new(
712 std::io::ErrorKind::InvalidData,
713 format!("claimed number is unreadable from {}", claim.path.display()),
714 ))
715 })?;
716
717 let segment = derive_segment(issue.assignee.as_deref());
721 std::fs::create_dir_all(self.issues_dir.join(&segment))?;
722
723 let stem = match filename_stem {
724 Some(s) => s.to_string(),
725 None => issue.filename_stem(),
726 };
727 let contents = issue.to_file_contents();
730
731 let written = self.write_new_issue_file(&segment, &stem, contents.as_bytes());
732 match written {
737 Ok((filename, path)) => {
738 claim.commit();
739 issue.filename = filename;
740 Ok(path)
741 }
742 Err(e) => {
743 claim.release();
744 Err(e)
745 }
746 }
747 }
748
749 fn write_new_issue_file(
784 &self,
785 segment: &str,
786 stem: &str,
787 contents: &[u8],
788 ) -> Result<(String, PathBuf)> {
789 const MAX_ATTEMPTS: usize = 1_000;
790 let mut suffix = 1usize;
791 for _ in 0..MAX_ATTEMPTS {
792 let filename = if suffix == 1 {
793 format!("{segment}/{stem}.md")
794 } else {
795 format!("{segment}/{stem}-{suffix}.md")
796 };
797 let path = self.issues_dir.join(&filename);
798 match write_file_new(&self.issues_dir, &path, contents) {
799 Ok(()) => return Ok((filename, path)),
800 Err(TixError::Io(e)) if e.kind() == std::io::ErrorKind::AlreadyExists => {
803 suffix += 1;
804 }
805 Err(e) => return Err(e),
806 }
807 }
808 Err(TixError::Io(std::io::Error::new(
809 std::io::ErrorKind::AlreadyExists,
810 format!(
811 "could not find a free filename for {segment}/{stem}.md in {MAX_ATTEMPTS} attempts"
812 ),
813 )))
814 }
815
816 fn resolve_next_number(&self) -> Result<u32> {
837 let issues = self.list_all()?;
841 match issues.iter().map(|i| i.number).max() {
842 None => Ok(1),
843 Some(max) => max
844 .checked_add(1)
845 .ok_or_else(|| TixError::InvalidInput("issue-number overflow".to_string())),
846 }
847 }
848
849 pub fn config_path(&self) -> Result<std::path::PathBuf> {
850 if let Some(root) = Config::project_root(&self.issues_dir) {
851 Ok(root.join("tix.toml"))
852 } else {
853 Err(TixError::Config(
854 "tix.toml not found in any parent directory of issues_dir".into(),
855 ))
856 }
857 }
858
859 pub fn update(&self, filename: &str, issue: &Issue) -> Result<()> {
860 validate_filename(filename)?;
861 let path = self.issues_dir.join(filename);
862 if !path.is_file() {
863 return Err(TixError::NotFound(format!("file {filename}")));
864 }
865 if issue.has_retired_links_key {
877 retired_key_disclosure(filename);
893 }
894 let contents = issue.to_file_contents();
895 atomic_write(&self.issues_dir, &path, contents.as_bytes())
896 }
897
898 pub fn add_asset(&self, filename: &str, source_path: &Path) -> Result<AssetAdded> {
912 validate_filename(filename)?;
913 let mut issue = self.get(IssueSelector::Filename(filename.to_string()))?;
914
915 let (segment, basename) = split_relative(filename).ok_or_else(|| {
916 TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
917 })?;
918 let stem = basename
919 .strip_suffix(".md")
920 .ok_or_else(|| {
921 TixError::InvalidInput(format!(
922 "asset target must be a `.md` issue file: {filename}"
923 ))
924 })?
925 .to_string();
926 let slug = slug_from_stem(&stem);
927 let dir_name = format!("{slug}-assets");
928 let asset_dir = self.issues_dir.join(&segment).join(&dir_name);
929 std::fs::create_dir_all(&asset_dir)?;
930
931 let source_name = source_path
932 .file_name()
933 .and_then(|n| n.to_str())
934 .ok_or_else(|| {
935 TixError::InvalidInput("asset source must have a file name".to_string())
936 })?
937 .to_string();
938 let written_name = resolve_asset_name(&asset_dir, &source_name);
939 atomic_copy(&asset_dir, &asset_dir.join(&written_name), source_path)?;
940
941 let label = written_name
942 .rsplit_once('.')
943 .map(|(stem, _)| stem)
944 .unwrap_or(&written_name);
945 let link = if is_image_name(&written_name) {
946 format!("")
947 } else {
948 format!("[{label}]({dir_name}/{written_name})")
949 };
950
951 if !issue.body.is_empty() {
955 if !issue.body.ends_with('\n') {
956 issue.body.push('\n');
957 }
958 if !issue.body.ends_with("\n\n") {
959 issue.body.push('\n');
960 }
961 }
962 issue.body.push_str(&link);
963 issue.body.push('\n');
964 issue.bump_updated();
965 self.update(filename, &issue)?;
966
967 Ok(AssetAdded { link, written_name })
968 }
969
970 pub fn delete(&self, filename: &str) -> Result<()> {
971 validate_filename(filename)?;
972 let path = self.issues_dir.join(filename);
973 if !path.is_file() {
974 return Err(TixError::NotFound(format!("file {filename}")));
975 }
976 let freed = std::fs::read_to_string(&path)
993 .ok()
994 .and_then(|text| frontmatter_number(&text));
995 std::fs::remove_file(&path)?;
996 if let Some(number) = freed {
997 NumberClaim::release_number(&self.issues_dir, number);
998 }
999 if let Some((segment, basename)) = split_relative(filename) {
1003 let stem = basename.strip_suffix(".md").unwrap_or(&basename);
1004 let slug = slug_from_stem(stem);
1005 let asset_dir = self
1006 .issues_dir
1007 .join(&segment)
1008 .join(format!("{slug}-assets"));
1009 if asset_dir.exists()
1010 && let Err(e) = std::fs::remove_dir_all(&asset_dir)
1011 {
1012 eprintln!(
1013 "warning: failed to remove asset dir {}: {e}",
1014 asset_dir.display()
1015 );
1016 }
1017 }
1018 Ok(())
1019 }
1020
1021 pub fn set_status(&self, filename: &str, request: StatusChange<'_>) -> Result<StatusOutcome> {
1067 let mut issue = self.get(IssueSelector::Filename(filename.to_string()))?;
1068
1069 let (_, resolution) = crate::workflow::active_resolution(&self.config);
1072
1073 let outcome = crate::workflow::validate_transition(
1076 &issue,
1077 request.column,
1078 &resolution,
1079 &self.config,
1080 request.override_reason,
1081 );
1082 let accepted = match TransitionAccepted::try_from(outcome) {
1085 Ok(accepted) => accepted,
1086 Err(diagnostic) => return Err(TixError::Workflow(diagnostic)),
1087 };
1088
1089 if !accepted.changed {
1094 return Ok(StatusOutcome {
1095 issue,
1096 decision: None,
1097 written: false,
1098 });
1099 }
1100
1101 let was_complete = crate::workflow::is_completed(&issue, &resolution);
1122 let reaches_complete = resolution
1123 .resolve(&accepted.to)
1124 .is_some_and(crate::workflow::WorkflowCategory::is_complete);
1125 if reaches_complete {
1126 issue.commit = request.commit.map(str::to_string);
1127 } else if was_complete {
1128 issue.commit = None;
1129 }
1130
1131 issue.status = Status::new(accepted.to.clone());
1132 issue.bump_updated();
1133
1134 let decision = TransitionDecision::new(
1137 crate::model::utc_now_rfc3339(),
1138 actor_identity(),
1143 accepted.from.clone(),
1144 accepted.to.clone(),
1145 accepted.from_category.key(),
1146 accepted.to_category.key(),
1147 accepted.override_reason,
1148 );
1149 issue.history.append(decision.to_event());
1150
1151 self.update(filename, &issue)?;
1152 Ok(StatusOutcome {
1153 issue,
1154 decision: Some(decision),
1155 written: true,
1156 })
1157 }
1158
1159 pub fn relationship_done_column(&self) -> String {
1189 crate::workflow::completion_column_or_default(self.config.default_config(), &self.config)
1201 }
1202
1203 pub fn link(&self, a: &str, b: &str, kind: RelationKind) -> Result<RelationshipOutcome> {
1217 let issue_a = self.resolve_issue(a)?;
1218 let issue_b = self.resolve_issue(b)?;
1219 let issues = self.list_all()?;
1223 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1224 let done_column = self.relationship_done_column();
1225 let graph = RelationshipGraph::new(&views, &done_column);
1226
1227 let verdict = graph.validate_create(issue_a.number, issue_b.number, kind);
1228 if verdict.blocking() {
1229 return Err(relationship_error(&verdict.errors));
1230 }
1231
1232 if let Some(existing) = graph.find_canonical(issue_a.number, issue_b.number, kind) {
1235 return Ok(RelationshipOutcome {
1241 written: None,
1242 relationship: existing.clone(),
1243 changed: false,
1244 });
1245 }
1246
1247 let canonical = CanonicalEdge::new(issue_a.number, issue_b.number, kind);
1248 let mut owner = self.get(IssueSelector::Number(canonical.from))?;
1249 let target_title = self
1250 .get(IssueSelector::Number(canonical.to))
1251 .map(|i| i.title)
1252 .unwrap_or_default();
1253 let relationship =
1254 Relationship::new(canonical.from, canonical.to, canonical.kind, &target_title);
1255 let detail = format!("created {}: {}", relationship.id, relationship.display);
1256 owner.relationships.push(relationship.clone());
1257 owner.history.append_now(
1258 &actor_identity(),
1259 HistoryEventKind::RelationshipAdded,
1260 &detail,
1261 );
1262 owner.bump_updated();
1263 self.update(&owner.filename, &owner)?;
1264
1265 Ok(RelationshipOutcome {
1266 written: Some(owner),
1267 relationship,
1268 changed: true,
1269 })
1270 }
1271
1272 pub fn hierarchy_index(&self) -> Result<(HierarchyIndex, Vec<Issue>)> {
1297 let issues = self.list_all()?;
1298 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1299 Ok((HierarchyIndex::build(&views), issues))
1300 }
1301
1302 fn child_in(snapshot: &[Issue], number: u32) -> Result<Issue> {
1307 snapshot
1308 .iter()
1309 .find(|i| i.number == number)
1310 .cloned()
1311 .ok_or_else(|| {
1312 TixError::NotFound(format!(
1313 "issue #{number} vanished between validating and writing it"
1314 ))
1315 })
1316 }
1317
1318 pub fn set_parent(&self, child: &str, parent: &str) -> Result<ParentOutcome> {
1324 let child_issue = self.resolve_issue(child)?;
1325 let parent_issue = self.resolve_issue(parent)?;
1326 let (index, issues) = self.hierarchy_index()?;
1327 let change = hierarchy::set_parent(&index, child_issue.number, parent_issue.number)?;
1328 self.commit_parent_change(Store::child_in(&issues, child_issue.number)?, change)
1329 }
1330
1331 pub fn change_parent(&self, child: &str, parent: &str) -> Result<ParentOutcome> {
1333 let child_issue = self.resolve_issue(child)?;
1334 let parent_issue = self.resolve_issue(parent)?;
1335 let (index, issues) = self.hierarchy_index()?;
1336 let change = hierarchy::change_parent(&index, child_issue.number, parent_issue.number)?;
1337 self.commit_parent_change(Store::child_in(&issues, child_issue.number)?, change)
1338 }
1339
1340 pub fn clear_parent(&self, child: &str) -> Result<ParentOutcome> {
1342 let child_issue = self.resolve_issue(child)?;
1343 let (index, issues) = self.hierarchy_index()?;
1344 let change = hierarchy::clear_parent(&index, child_issue.number)?;
1345 self.commit_parent_change(Store::child_in(&issues, child_issue.number)?, change)
1346 }
1347
1348 fn commit_parent_change(
1355 &self,
1356 mut issue: Issue,
1357 change: hierarchy::ParentChange,
1358 ) -> Result<ParentOutcome> {
1359 let Some(event) = change.event else {
1360 return Ok(ParentOutcome {
1365 written: None,
1366 parent: change.parent,
1367 event: None,
1368 changed: false,
1369 });
1370 };
1371 let previous = issue.parent;
1376 let kind = HistoryEventKind::from_parent(event.kind);
1377 let detail = hierarchy::change_detail(previous, change.parent);
1382 debug_assert_ne!(
1391 previous, change.parent,
1392 "an accepted change must alter the parent, or the event records nothing"
1393 );
1394 issue.parent = change.parent;
1397 issue.history.append_now(&actor_identity(), kind, &detail);
1398 issue.bump_updated();
1399 self.update(&issue.filename, &issue)?;
1400 Ok(ParentOutcome {
1401 written: Some(issue),
1402 parent: change.parent,
1403 event: Some(event),
1404 changed: true,
1405 })
1406 }
1407
1408 pub fn relationships_for(&self, number: u32) -> Result<Vec<Relationship>> {
1422 let issues = self.list_all()?;
1423 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1424 let done_column = self.relationship_done_column();
1425 let graph = RelationshipGraph::new(&views, &done_column);
1426 Ok(graph
1427 .relationships_for(number)
1428 .into_iter()
1429 .cloned()
1430 .collect())
1431 }
1432
1433 pub fn unlink(&self, a: &str, b: &str, kind: RelationKind) -> Result<RelationshipOutcome> {
1438 let canonical = self.canonical_request(a, b, kind)?;
1439 let stored = self.find_stored_rows(&canonical)?;
1440
1441 if stored.is_empty() {
1442 return Ok(RelationshipOutcome {
1446 written: None,
1447 relationship: canonical_placeholder(&canonical),
1448 changed: false,
1449 });
1450 }
1451
1452 let mut by_file: HashMap<String, Vec<Relationship>> = HashMap::new();
1457 for (filename, rel) in stored {
1458 by_file.entry(filename).or_default().push(rel);
1459 }
1460
1461 let actor = actor_identity();
1462 let mut file_order: Vec<String> = by_file.keys().cloned().collect();
1464 file_order.sort_unstable();
1465
1466 let mut removed_row: Option<Relationship> = None;
1472
1473 let all_removed: Vec<Relationship> = by_file.values().flatten().cloned().collect();
1479 let summary = if all_removed.len() == 1 {
1480 let one = &all_removed[0];
1481 format!("removed {}: {}", one.id, one.display)
1482 } else {
1483 let kinds: Vec<String> = all_removed.iter().map(|r| r.kind.to_string()).collect();
1484 format!(
1485 "removed {} relationships ({})",
1486 all_removed.len(),
1487 kinds.join(", ")
1488 )
1489 };
1490
1491 let mut written: Option<Issue> = None;
1492 for filename in &file_order {
1493 let doomed = &by_file[filename];
1494 let mut owner = self.get(IssueSelector::Filename(filename.clone()))?;
1495 if removed_row.is_none() {
1496 removed_row = doomed.first().cloned();
1497 }
1498 owner.relationships.retain(|r| r.canonical() != canonical);
1499 owner
1500 .history
1501 .append_now(&actor, HistoryEventKind::RelationshipRemoved, &summary);
1502 owner.bump_updated();
1503 self.update(&owner.filename, &owner)?;
1504 written = Some(owner);
1505 }
1506
1507 let relationship = removed_row.unwrap_or_else(|| canonical_placeholder(&canonical));
1510
1511 Ok(RelationshipOutcome {
1512 written,
1513 relationship,
1514 changed: true,
1515 })
1516 }
1517
1518 pub fn edit_relation(
1533 &self,
1534 id: &str,
1535 new_to: Option<u32>,
1536 new_kind: Option<RelationKind>,
1537 ) -> Result<RelationshipOutcome> {
1538 let issues = self.list_all()?;
1539 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1540 let done_column = self.relationship_done_column();
1541 let graph = RelationshipGraph::new(&views, &done_column);
1542
1543 let Some(existing) = graph.find_relationship(id) else {
1544 let mut verdict = crate::relationships::RelationshipValidationResult::clean();
1548 verdict.errors.push(Diagnostic::unknown_relation(id));
1549 return Err(relationship_error(&verdict.errors));
1550 };
1551
1552 let verdict = graph.validate_edit(id, new_to, new_kind);
1553 if verdict.blocking() {
1554 return Err(relationship_error(&verdict.errors));
1555 }
1556
1557 let to = new_to.unwrap_or(existing.to);
1558 let kind = new_kind.unwrap_or(existing.kind);
1559 let old_canonical = existing.canonical();
1560
1561 let requested = CanonicalEdge::new(existing.from, to, kind);
1573 let mut updated = existing.clone();
1574 updated.from = requested.from;
1575 updated.to = requested.to;
1576 updated.kind = requested.kind;
1577
1578 let target_title = self
1582 .get(IssueSelector::Number(updated.to))
1583 .map(|i| i.title)
1584 .unwrap_or_default();
1585 updated.refresh_display(&target_title);
1586 let new_canonical = updated.canonical();
1587
1588 if old_canonical == new_canonical && updated.display == existing.display {
1592 return Ok(RelationshipOutcome {
1593 written: None,
1594 relationship: existing.clone(),
1595 changed: false,
1596 });
1597 }
1598
1599 let actor = actor_identity();
1600 let detail = format!("{} → {}: {}", existing.display, updated.display, updated.id);
1601
1602 let mut removed_anywhere = false;
1618 for issue in self.list_all()? {
1619 if !issue
1620 .relationships
1621 .iter()
1622 .any(|r| r.id == id && r.canonical() == old_canonical)
1623 {
1624 continue;
1625 }
1626 let mut owner = self.get(IssueSelector::Filename(issue.filename.clone()))?;
1627 owner
1628 .relationships
1629 .retain(|r| !(r.id == id && r.canonical() == old_canonical));
1630 removed_anywhere = true;
1631 owner.bump_updated();
1632 self.update(&owner.filename, &owner)?;
1633 }
1634 debug_assert!(removed_anywhere, "graph found the row, the scan did not");
1635
1636 let mut new_owner = self.get(IssueSelector::Number(new_canonical.from))?;
1641 new_owner.relationships.push(updated.clone());
1642 new_owner
1643 .history
1644 .append_now(&actor, HistoryEventKind::RelationshipRetargeted, &detail);
1645 new_owner.bump_updated();
1646 self.update(&new_owner.filename, &new_owner)?;
1647
1648 Ok(RelationshipOutcome {
1649 written: Some(self.get(IssueSelector::Number(new_canonical.from))?),
1650 relationship: updated,
1651 changed: true,
1652 })
1653 }
1654
1655 pub fn remove_relation(&self, id: &str) -> Result<RelationshipOutcome> {
1661 let stored = self.find_stored_rows_by_id(id)?;
1662 if stored.is_empty() {
1663 return Ok(RelationshipOutcome {
1667 written: None,
1668 relationship: Relationship {
1669 id: id.to_string(),
1670 ..canonical_placeholder(&CanonicalEdge::new(0, 0, RelationKind::Related))
1671 },
1672 changed: false,
1673 });
1674 }
1675
1676 let canonical = stored[0].1.canonical();
1677 let from = canonical.from.to_string();
1681 let to = canonical.to.to_string();
1682 self.unlink(&from, &to, canonical.kind)
1683 }
1684
1685 fn find_stored_rows_by_id(&self, id: &str) -> Result<Vec<(String, Relationship)>> {
1691 let mut found = Vec::new();
1692 for issue in self.list_all()? {
1693 for rel in &issue.relationships {
1694 if rel.id == id {
1695 found.push((issue.filename.clone(), rel.clone()));
1696 }
1697 }
1698 }
1699 Ok(found)
1700 }
1701
1702 pub fn graph_source(&self) -> Result<(Vec<Issue>, String)> {
1721 Ok((self.list()?, self.relationship_done_column()))
1722 }
1723
1724 fn canonical_request(&self, a: &str, b: &str, kind: RelationKind) -> Result<CanonicalEdge> {
1733 let lenient = |sel: &str| -> Result<u32> {
1734 match self.resolve_issue(sel) {
1735 Ok(issue) => Ok(issue.number),
1736 Err(e) => sel.trim().parse::<u32>().map_err(|_| e),
1737 }
1738 };
1739 let from = lenient(a)?;
1740 let to = lenient(b)?;
1741 Ok(CanonicalEdge::new(from, to, kind))
1742 }
1743
1744 fn find_stored_rows(&self, canonical: &CanonicalEdge) -> Result<Vec<(String, Relationship)>> {
1760 let mut found = Vec::new();
1761 for issue in self.list_all()? {
1762 for rel in &issue.relationships {
1763 if rel.canonical() == *canonical {
1764 found.push((issue.filename.clone(), rel.clone()));
1765 }
1766 }
1767 }
1768 Ok(found)
1769 }
1770
1771 pub fn set_relationships(
1786 &self,
1787 filename: &str,
1788 targets: &[(u32, RelationKind)],
1789 ) -> Result<Issue> {
1790 let issue = self.get(IssueSelector::Filename(filename.to_string()))?;
1791 let own = issue.number;
1792 let actor = actor_identity();
1793
1794 let mut wanted: Vec<CanonicalEdge> = Vec::with_capacity(targets.len());
1796 for (target, kind) in targets {
1797 if *target == own {
1798 return Err(TixError::InvalidInput(format!(
1799 "issue #{own} cannot relate to itself"
1800 )));
1801 }
1802 self.get(IssueSelector::Number(*target))?;
1803 let canonical = CanonicalEdge::new(own, *target, *kind);
1804 if !wanted.contains(&canonical) {
1807 wanted.push(canonical);
1808 }
1809 }
1810
1811 {
1820 let issues = self.list_all()?;
1821 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1822 let done_column = self.relationship_done_column();
1823 let graph = RelationshipGraph::new(&views, &done_column);
1824 for canonical in &wanted {
1825 let verdict = graph.validate_create(canonical.from, canonical.to, canonical.kind);
1826 if verdict.blocking() {
1827 return Err(relationship_error(&verdict.errors));
1828 }
1829 }
1830 }
1831
1832 let issues = self.list_all()?;
1850 let mut removals: HashMap<u32, Vec<Relationship>> = HashMap::new();
1851 for candidate in &issues {
1852 for rel in &candidate.relationships {
1853 let canonical = rel.canonical();
1854 let owned_by_us = canonical.from == own
1855 || (canonical.to == own && !canonical.kind.is_directional());
1856 if owned_by_us && !wanted.contains(&canonical) {
1857 removals
1858 .entry(canonical.from)
1859 .or_default()
1860 .push(rel.clone());
1861 }
1862 }
1863 }
1864
1865 let mut additions: HashMap<u32, Vec<(CanonicalEdge, String)>> = HashMap::new();
1868 for canonical in &wanted {
1869 let owner = match self.get(IssueSelector::Number(canonical.from)) {
1870 Ok(owner) => owner,
1871 Err(TixError::NotFound(_)) => continue,
1872 Err(e) => return Err(e),
1873 };
1874 if owner
1875 .relationships
1876 .iter()
1877 .any(|r| r.canonical() == *canonical)
1878 {
1879 continue;
1880 }
1881 let target_title = self
1882 .get(IssueSelector::Number(canonical.to))
1883 .map(|i| i.title)
1884 .unwrap_or_default();
1885 additions
1886 .entry(canonical.from)
1887 .or_default()
1888 .push((*canonical, target_title));
1889 }
1890
1891 if removals.is_empty() && additions.is_empty() {
1892 return Ok(issue);
1893 }
1894
1895 let mut removal_order: Vec<u32> = removals.keys().copied().collect();
1916 removal_order.sort_unstable();
1917 let mut addition_order: Vec<u32> = additions.keys().copied().collect();
1918 addition_order.sort_unstable();
1919
1920 fn apply(
1925 store: &Store,
1926 mut owner: Issue,
1927 undo: &mut Vec<(PathBuf, String)>,
1928 touched_named: &mut bool,
1929 named_path: &Path,
1930 ) -> Result<()> {
1931 let path = store.issues_dir.join(&owner.filename);
1932 if path == named_path {
1933 *touched_named = true;
1934 }
1935 if let Ok(previous) = std::fs::read_to_string(&path) {
1936 undo.push((path, previous));
1937 }
1938 owner.bump_updated();
1939 store.update(&owner.filename, &owner)
1940 }
1941
1942 let named_path = self.issues_dir.join(&issue.filename);
1943 let mut undo: Vec<(PathBuf, String)> = Vec::new();
1944 let mut touched_named = false;
1945
1946 let applied = (|| -> Result<()> {
1947 for owner_number in &removal_order {
1948 let doomed = &removals[owner_number];
1949 let mut owner = self.get(IssueSelector::Number(*owner_number))?;
1950 let mut changed = false;
1951 for rel in doomed {
1952 let canonical = rel.canonical();
1953 let before = owner.relationships.len();
1954 owner.relationships.retain(|r| r.canonical() != canonical);
1955 if owner.relationships.len() != before {
1956 let detail = format!("removed {}: {}", rel.id, rel.display);
1957 owner.history.append_now(
1958 &actor,
1959 HistoryEventKind::RelationshipRemoved,
1960 &detail,
1961 );
1962 changed = true;
1963 }
1964 }
1965 if changed {
1966 apply(self, owner, &mut undo, &mut touched_named, &named_path)?;
1967 }
1968 }
1969
1970 for owner_number in &addition_order {
1971 let wanted_here = &additions[owner_number];
1972 let mut owner = self.get(IssueSelector::Number(*owner_number))?;
1973 for (canonical, target_title) in wanted_here {
1974 let rel = Relationship::new(
1975 canonical.from,
1976 canonical.to,
1977 canonical.kind,
1978 target_title,
1979 );
1980 let detail = format!("created {}: {}", rel.id, rel.display);
1981 owner
1982 .history
1983 .append_now(&actor, HistoryEventKind::RelationshipAdded, &detail);
1984 owner.relationships.push(rel);
1985 }
1986 apply(self, owner, &mut undo, &mut touched_named, &named_path)?;
1987 }
1988
1989 if !touched_named {
2003 let named = self.get(IssueSelector::Filename(filename.to_string()))?;
2004 apply(self, named, &mut undo, &mut touched_named, &named_path)?;
2005 }
2006 Ok(())
2007 })();
2008
2009 if let Err(e) = applied {
2010 for (path, previous) in undo.iter().rev() {
2011 let _ = atomic_write(&self.issues_dir, path, previous.as_bytes());
2012 }
2013 return Err(e);
2014 }
2015
2016 self.get(IssueSelector::Filename(filename.to_string()))
2017 }
2018
2019 pub fn resolve_issue(&self, raw: &str) -> Result<Issue> {
2029 let selector = crate::commands::parse_selector(self, raw)?;
2030 self.get(selector)
2031 }
2032
2033 pub fn reassign(&self, filename: &str, new_assignee: Option<&str>) -> Result<PathBuf> {
2056 self.get(IssueSelector::Filename(filename.to_string()))?;
2058
2059 let (old_segment, file_name) = split_relative(filename).ok_or_else(|| {
2060 TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
2061 })?;
2062 let new_segment = derive_segment(new_assignee);
2063 if old_segment == new_segment {
2064 return Ok(PathBuf::from(filename));
2065 }
2066
2067 let stem = file_name
2068 .strip_suffix(".md")
2069 .unwrap_or(&file_name)
2070 .to_string();
2071 let target_dir = self.issues_dir.join(&new_segment);
2072 std::fs::create_dir_all(&target_dir)?;
2073 let new_name = resolve_target_name(&target_dir, &stem);
2074 std::fs::rename(self.issues_dir.join(filename), target_dir.join(&new_name))?;
2075
2076 let slug = slug_from_stem(&stem);
2082 let asset_name = format!("{slug}-assets");
2083 let old_asset_dir = self.issues_dir.join(&old_segment).join(&asset_name);
2084 if old_asset_dir.exists() {
2085 let moved = resolve_asset_dir_name(&target_dir, &asset_name);
2086 std::fs::rename(&old_asset_dir, target_dir.join(&moved))?;
2087 }
2088 Ok(PathBuf::from(format!("{new_segment}/{new_name}")))
2089 }
2090}
2091
2092fn validate_filename(filename: &str) -> Result<()> {
2101 if filename.is_empty() {
2102 return Err(TixError::InvalidInput(format!(
2103 "filename must not be empty: {filename:?}"
2104 )));
2105 }
2106 if filename.starts_with('/') {
2107 return Err(TixError::InvalidInput(format!(
2108 "filename must be relative (no leading `/`): {filename}"
2109 )));
2110 }
2111 if filename.contains('\\') {
2112 return Err(TixError::InvalidInput(format!(
2113 "filename must not contain backslashes: {filename}"
2114 )));
2115 }
2116 if filename.contains("..") {
2117 return Err(TixError::InvalidInput(format!(
2118 "filename must not contain `..`: {filename}"
2119 )));
2120 }
2121 if filename.contains('\0') {
2122 return Err(TixError::InvalidInput(format!(
2123 "filename must not contain NUL: {filename}"
2124 )));
2125 }
2126 if filename.split('/').count() > 2 {
2127 return Err(TixError::InvalidInput(format!(
2128 "filename may have at most one segment prefix: {filename}"
2129 )));
2130 }
2131 if filename.split('/').any(|component| component.is_empty()) {
2132 return Err(TixError::InvalidInput(format!(
2133 "filename path components must not be empty: {filename}"
2134 )));
2135 }
2136 Ok(())
2137}
2138
2139fn frontmatter_number(text: &str) -> Option<u32> {
2155 let mut lines = text.lines();
2156 if lines.next()?.trim() != "---" {
2158 return None;
2159 }
2160 for line in lines {
2161 let line = line.trim_end();
2162 if line == "---" {
2165 return None;
2166 }
2167 if let Some(rest) = line.strip_prefix("number:") {
2168 return rest.trim().parse().ok();
2169 }
2170 }
2171 None
2172}
2173
2174fn claims_dir(issues_dir: &Path) -> PathBuf {
2186 issues_dir
2187 .parent()
2188 .unwrap_or(issues_dir)
2189 .join(NumberClaim::DIR)
2190}
2191
2192fn split_relative(filename: &str) -> Option<(String, String)> {
2193 let mut parts = filename.rsplitn(2, '/');
2194 let basename = parts.next()?;
2195 let segment = parts.next().unwrap_or("");
2196 Some((segment.to_string(), basename.to_string()))
2197}
2198
2199fn flip_archived(filename: &str, archive: bool) -> Option<String> {
2206 let (segment, basename) = split_relative(filename)?;
2207 let flipped = if archive {
2208 if basename.starts_with('.') {
2209 return None;
2210 }
2211 format!(".{basename}")
2212 } else {
2213 basename.strip_prefix('.')?.to_string()
2214 };
2215 Some(if segment.is_empty() {
2216 flipped
2217 } else {
2218 format!("{segment}/{flipped}")
2219 })
2220}
2221
2222fn resolve_target_name(dir: &Path, stem: &str) -> String {
2225 let mut candidate = format!("{stem}.md");
2226 let mut i = 1u32;
2227 while dir.join(&candidate).exists() {
2228 candidate = format!("{stem}-{i}.md");
2229 i += 1;
2230 }
2231 candidate
2232}
2233
2234fn slug_from_stem(stem: &str) -> String {
2238 let bytes = stem.as_bytes();
2239 if stem.len() > 10 && bytes[4] == b'-' && bytes[7] == b'-' && bytes[10] == b'-' {
2240 stem[11..].to_string()
2241 } else {
2242 stem.to_string()
2243 }
2244}
2245
2246fn resolve_asset_name(dir: &Path, name: &str) -> String {
2250 if !dir.join(name).exists() {
2251 return name.to_string();
2252 }
2253 let (stem, ext) = match name.rsplit_once('.') {
2254 Some((s, e)) => (s, format!(".{e}")),
2255 None => (name, String::new()),
2256 };
2257 let mut i = 1u32;
2258 loop {
2259 let candidate = format!("{stem}-{i}{ext}");
2260 if !dir.join(&candidate).exists() {
2261 return candidate;
2262 }
2263 i += 1;
2264 }
2265}
2266
2267fn resolve_asset_dir_name(dir: &Path, name: &str) -> String {
2270 let mut candidate = name.to_string();
2271 let mut i = 1u32;
2272 while dir.join(&candidate).exists() {
2273 candidate = format!("{name}-{i}");
2274 i += 1;
2275 }
2276 candidate
2277}
2278
2279fn atomic_copy(dir: &Path, target: &Path, source: &Path) -> Result<()> {
2282 let pid = std::process::id();
2283 let nonce = nanos();
2284 let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
2285 let copy_result = (|| -> std::io::Result<()> {
2286 std::fs::copy(source, &tmp)?;
2287 let f = std::fs::File::open(&tmp)?;
2288 f.sync_all()?;
2289 Ok(())
2290 })();
2291 if let Err(e) = copy_result {
2292 let _ = std::fs::remove_file(&tmp);
2293 return Err(TixError::Io(e));
2294 }
2295 if let Err(e) = std::fs::rename(&tmp, target) {
2296 let _ = std::fs::remove_file(&tmp);
2297 return Err(TixError::Io(e));
2298 }
2299 Ok(())
2300}
2301
2302fn is_image_name(name: &str) -> bool {
2304 matches!(
2305 name.rsplit('.')
2306 .next()
2307 .unwrap_or("")
2308 .to_ascii_lowercase()
2309 .as_str(),
2310 "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp"
2311 )
2312}
2313
2314fn atomic_write(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
2315 write_file(dir, target, data, Link::Overwrite)
2316}
2317
2318fn write_file_new(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
2337 write_file(dir, target, data, Link::Exclusive)
2338}
2339
2340#[derive(Clone, Copy)]
2341enum Link {
2342 Overwrite,
2343 Exclusive,
2344}
2345
2346fn write_file(dir: &Path, target: &Path, data: &[u8], link: Link) -> Result<()> {
2347 let pid = std::process::id();
2348 let nonce = nanos();
2349 let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
2350 let write_result = (|| -> std::io::Result<()> {
2351 let mut f = std::fs::File::create(&tmp)?;
2352 use std::io::Write;
2353 f.write_all(data)?;
2354 f.sync_all()?;
2355 Ok(())
2356 })();
2357 if let Err(e) = write_result {
2358 let _ = std::fs::remove_file(&tmp);
2359 return Err(TixError::Io(e));
2360 }
2361 let link_result = match link {
2362 Link::Overwrite => std::fs::rename(&tmp, target),
2363 Link::Exclusive => match std::fs::hard_link(&tmp, target) {
2364 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e),
2368 Err(_) => std::fs::rename(&tmp, target),
2371 Ok(()) => {
2372 let _ = std::fs::remove_file(&tmp);
2373 Ok(())
2374 }
2375 },
2376 };
2377 if let Err(e) = link_result {
2378 let _ = std::fs::remove_file(&tmp);
2379 return Err(TixError::Io(e));
2380 }
2381 let _ = std::fs::File::open(dir).and_then(|f| f.sync_all());
2382 Ok(())
2383}
2384
2385fn nanos() -> u64 {
2386 SystemTime::now()
2387 .duration_since(UNIX_EPOCH)
2388 .map(|d| d.as_nanos() as u64)
2389 .unwrap_or(0)
2390}
2391
2392#[cfg(test)]
2393mod tests {
2394 use super::*;
2395 use crate::model::{Priority, today};
2397 use crate::relationships::DiagnosticKind;
2398 use std::fs;
2399 use tempfile::TempDir;
2400
2401 #[test]
2414 fn write_file_new_refuses_an_existing_target() {
2415 let dir = TempDir::new().unwrap();
2416 let target = dir.path().join("issue.md");
2417 write_file_new(dir.path(), &target, b"first").unwrap();
2418 assert_eq!(fs::read_to_string(&target).unwrap(), "first");
2419
2420 let err = write_file_new(dir.path(), &target, b"second").unwrap_err();
2421 let TixError::Io(io_err) = err else {
2422 panic!("expected an I/O error, so the caller can retry on a name collision")
2423 };
2424 assert_eq!(
2425 io_err.kind(),
2426 std::io::ErrorKind::AlreadyExists,
2427 "the error must be AlreadyExists, so the caller can retry on a name \\
2428 collision rather than treating it as a hard failure"
2429 );
2430 assert_eq!(
2431 fs::read_to_string(&target).unwrap(),
2432 "first",
2433 "the existing file must be byte-identical — not truncated, not replaced"
2434 );
2435 }
2436
2437 #[test]
2439 fn a_clobbered_name_is_retried_rather_than_failing_or_overwriting() {
2440 let tmp = TempDir::new().unwrap();
2441 let dir = tmp.path();
2442 let store = Store::open(&crate::config::Config {
2443 issues_dir: dir.to_path_buf(),
2444 ..Default::default()
2445 })
2446 .unwrap();
2447
2448 let segment_dir = dir.join("seg");
2451 fs::create_dir_all(&segment_dir).unwrap();
2452
2453 for name in ["2026-01-01-race.md", "2026-01-01-race-2.md"] {
2456 fs::write(segment_dir.join(name), "planted").unwrap();
2457 }
2458
2459 let (filename, path) = store
2460 .write_new_issue_file("seg", "2026-01-01-race", b"mine")
2461 .unwrap();
2462 assert_eq!(filename, "seg/2026-01-01-race-3.md");
2463 assert_eq!(fs::read_to_string(&path).unwrap(), "mine");
2464 assert_eq!(
2465 fs::read_to_string(segment_dir.join("2026-01-01-race.md")).unwrap(),
2466 "planted"
2467 );
2468 assert_eq!(
2469 fs::read_to_string(segment_dir.join("2026-01-01-race-2.md")).unwrap(),
2470 "planted"
2471 );
2472 }
2473
2474 #[test]
2480 fn no_temp_files_are_left_behind_on_success_or_collision() {
2481 let dir = TempDir::new().unwrap();
2482 let target = dir.path().join("issue.md");
2483 write_file_new(dir.path(), &target, b"first").unwrap();
2484 let _ = write_file_new(dir.path(), &target, b"second");
2485 let leftovers: Vec<String> = fs::read_dir(dir.path())
2486 .unwrap()
2487 .filter_map(|e| e.ok())
2488 .map(|e| e.file_name().to_string_lossy().to_string())
2489 .filter(|n| n.starts_with(".tix-tmp-"))
2490 .collect();
2491 assert!(
2492 leftovers.is_empty(),
2493 "temp files must not survive, found {leftovers:?}"
2494 );
2495 }
2496
2497 fn make_store() -> (tempfile::TempDir, Store) {
2498 let tmp = tempfile::TempDir::new().unwrap();
2499 let dir = tmp.path().join("issues");
2500 fs::create_dir_all(&dir).unwrap();
2501 let config_path = tmp.path().join("tix.toml");
2502 let config = Config {
2503 issues_dir: dir.clone(),
2504 ..Config::default()
2505 };
2506 config.save(&config_path).unwrap();
2507 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
2508 let store = Store {
2509 issues_dir: dir,
2510 config,
2511 };
2512 (tmp, store)
2513 }
2514
2515 fn write_issue(store: &Store, name: &str, contents: &str) {
2516 fs::write(store.issues_dir.join(name), contents).unwrap();
2517 }
2518
2519 fn sample_frontmatter(number: u32, title: &str, status: &str) -> String {
2520 format!(
2521 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
2522 )
2523 }
2524
2525 #[test]
2534 fn list_never_opens_an_archived_file_but_list_all_does() {
2535 let (tmp, store) = make_store();
2536 let _ = tmp;
2537 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2538 write_issue(
2539 &store,
2540 "alice/2026-01-01-active.md",
2541 &sample_frontmatter(1, "Active", "todo"),
2542 );
2543 fs::write(
2544 store.issues_dir.join("alice/.2026-01-02-broken.md"),
2545 "this file has no frontmatter at all\n",
2546 )
2547 .unwrap();
2548
2549 let active = store
2550 .list()
2551 .expect("the default walk must not read the archived file");
2552 assert_eq!(active.len(), 1);
2553 assert_eq!(active[0].number, 1);
2554 assert!(!active[0].archived);
2555
2556 let all = store.list_all();
2557 assert!(
2558 matches!(all, Err(TixError::Parse(_))),
2559 "list_all() reads it, so the broken file is only invisible to list(): {all:?}"
2560 );
2561 }
2562
2563 #[test]
2564 fn archive_renames_in_place_and_restore_is_the_inverse() {
2565 let (tmp, store) = make_store();
2566 let _ = tmp;
2567 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2568 write_issue(
2569 &store,
2570 "alice/2026-01-01-x.md",
2571 &sample_frontmatter(5, "X", "todo"),
2572 );
2573
2574 let archived = store.archive("5").unwrap();
2575 assert_eq!(archived.filename, "alice/.2026-01-01-x.md");
2576 assert!(archived.archived);
2577 assert!(store.issues_dir.join("alice/.2026-01-01-x.md").is_file());
2578 assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
2579 assert!(
2580 store.list().unwrap().is_empty(),
2581 "hidden from the default view"
2582 );
2583
2584 let all = store.list_all().unwrap();
2585 assert_eq!(all.len(), 1);
2586 assert!(all[0].archived);
2587 assert_eq!(all[0].number, 5, "the number is untouched");
2588
2589 assert_eq!(
2591 store.get(IssueSelector::Number(5)).unwrap().filename,
2592 "alice/.2026-01-01-x.md"
2593 );
2594 assert!(store.get(IssueSelector::Slug(slugify("X"))).is_ok());
2595
2596 let restored = store.restore("5").unwrap();
2597 assert_eq!(restored.filename, "alice/2026-01-01-x.md");
2598 assert!(!restored.archived);
2599 assert_eq!(store.list().unwrap().len(), 1, "visible again");
2600 }
2601
2602 #[test]
2603 fn archive_appends_one_event_and_leaves_the_frontmatter_untouched() {
2604 let (tmp, store) = make_store();
2605 let _ = tmp;
2606 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2607 write_issue(
2608 &store,
2609 "alice/2026-01-01-x.md",
2610 &sample_frontmatter(5, "X", "todo"),
2611 );
2612 let before = store
2613 .get(IssueSelector::Filename("alice/2026-01-01-x.md".into()))
2614 .unwrap();
2615
2616 let archived = store.archive("5").unwrap();
2617 assert_eq!(archived.history.len(), 1, "exactly one History event");
2618 assert_eq!(
2619 archived.history.events()[0].kind,
2620 HistoryEventKind::Archived
2621 );
2622
2623 let after = store.get(IssueSelector::Number(5)).unwrap();
2624 assert_eq!(after.number, before.number);
2628 assert_eq!(after.title, before.title);
2629 assert_eq!(after.status, before.status);
2630 assert_eq!(after.labels, before.labels);
2631 assert_eq!(after.priority, before.priority);
2632 assert_eq!(after.assignee, before.assignee);
2633 assert_eq!(after.created, before.created);
2634 assert_eq!(
2635 after.updated, before.updated,
2636 "archiving must not bump `updated`: it is a frontmatter field"
2637 );
2638 let raw = read_issue_file(&store, "alice/.2026-01-01-x.md");
2640 let fm = raw.split("\n---").next().unwrap();
2641 assert!(
2642 !fm.contains("archived"),
2643 "the archived flag must never reach the frontmatter: {fm}"
2644 );
2645 }
2646
2647 #[test]
2648 fn restore_appends_one_restored_event() {
2649 let (tmp, store) = make_store();
2650 let _ = tmp;
2651 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2652 write_issue(
2653 &store,
2654 "alice/2026-01-01-x.md",
2655 &sample_frontmatter(5, "X", "todo"),
2656 );
2657 store.archive("5").unwrap();
2658 let restored = store.restore("5").unwrap();
2659 assert_eq!(restored.history.len(), 2, "archive + restore");
2660 assert_eq!(
2661 restored.history.events()[1].kind,
2662 HistoryEventKind::Restored
2663 );
2664 }
2665
2666 #[test]
2667 fn archiving_the_highest_number_does_not_free_it_for_reuse() {
2668 let (tmp, store) = make_store();
2669 let _ = tmp;
2670 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2671 write_issue(
2672 &store,
2673 "alice/2026-01-01-a.md",
2674 &sample_frontmatter(1, "A", "todo"),
2675 );
2676 write_issue(
2677 &store,
2678 "alice/2026-01-01-b.md",
2679 &sample_frontmatter(7, "B", "todo"),
2680 );
2681
2682 store.archive("7").unwrap();
2683
2684 let mut fresh = Issue::parse(&sample_frontmatter(0, "New", "todo")).unwrap();
2685 store.create(&mut fresh, "body", None).unwrap();
2686 assert_eq!(
2687 fresh.number, 8,
2688 "an archived issue keeps its number: `create` must not reuse #7"
2689 );
2690 }
2691
2692 #[test]
2693 fn redundant_archive_and_restore_are_refused_by_name() {
2694 let (tmp, store) = make_store();
2695 let _ = tmp;
2696 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2697 write_issue(
2698 &store,
2699 "alice/2026-01-01-x.md",
2700 &sample_frontmatter(5, "X", "todo"),
2701 );
2702
2703 let err = store.restore("5").unwrap_err();
2704 assert!(
2705 err.to_string().contains("not_archived"),
2706 "an active issue cannot be restored: {err}"
2707 );
2708 assert!(
2709 store.issues_dir.join("alice/2026-01-01-x.md").is_file(),
2710 "the refused restore writes nothing"
2711 );
2712
2713 store.archive("5").unwrap();
2714 let err = store.archive("5").unwrap_err();
2715 assert!(
2716 err.to_string().contains("already_archived"),
2717 "an archived issue cannot be re-archived: {err}"
2718 );
2719 assert!(
2720 store.issues_dir.join("alice/.2026-01-01-x.md").is_file(),
2721 "the refused archive writes nothing"
2722 );
2723 }
2724
2725 #[test]
2726 fn archive_never_clobbers_an_occupied_target_name() {
2727 let (tmp, store) = make_store();
2728 let _ = tmp;
2729 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
2730 write_issue(
2731 &store,
2732 "alice/2026-01-01-x.md",
2733 &sample_frontmatter(5, "X", "todo"),
2734 );
2735 write_issue(
2737 &store,
2738 "alice/.2026-01-01-x.md",
2739 &sample_frontmatter(6, "Other", "todo"),
2740 );
2741 let err = store.archive("5").unwrap_err();
2742 assert!(err.to_string().contains("already exists"), "got: {err}");
2743 assert!(store.issues_dir.join("alice/2026-01-01-x.md").is_file());
2744 assert!(store.issues_dir.join("alice/.2026-01-01-x.md").is_file());
2745 }
2746
2747 #[test]
2748 fn archive_and_restore_a_legacy_flat_file_in_place() {
2749 let (tmp, store) = make_store();
2750 let _ = tmp;
2751 write_issue(
2752 &store,
2753 "2026-01-01-legacy.md",
2754 &sample_frontmatter(3, "Legacy", "todo"),
2755 );
2756
2757 let archived = store.archive("3").unwrap();
2758 assert_eq!(archived.filename, ".2026-01-01-legacy.md");
2759 assert!(store.issues_dir.join(".2026-01-01-legacy.md").is_file());
2760 assert!(store.list().unwrap().is_empty());
2761 assert_eq!(store.list_all().unwrap().len(), 1);
2762
2763 let restored = store.restore("3").unwrap();
2764 assert_eq!(restored.filename, "2026-01-01-legacy.md");
2765 assert!(store.issues_dir.join("2026-01-01-legacy.md").is_file());
2766 }
2767
2768 #[test]
2769 fn list_returns_sorted_by_filename() {
2770 let (tmp, store) = make_store();
2771 let _ = tmp;
2772 write_issue(
2773 &store,
2774 "2026-03-01-c.md",
2775 &sample_frontmatter(3, "C", "todo"),
2776 );
2777 write_issue(
2778 &store,
2779 "2026-01-01-a.md",
2780 &sample_frontmatter(1, "A", "todo"),
2781 );
2782 write_issue(
2783 &store,
2784 "2026-02-01-b.md",
2785 &sample_frontmatter(2, "B", "todo"),
2786 );
2787 let issues = store.list().unwrap();
2788 assert_eq!(issues.len(), 3);
2789 assert_eq!(issues[0].number, 1);
2790 assert_eq!(issues[1].number, 2);
2791 assert_eq!(issues[2].number, 3);
2792 assert_eq!(issues[0].title, "A");
2793 }
2794
2795 #[test]
2796 fn list_ignores_non_md_files() {
2797 let (tmp, store) = make_store();
2798 let _ = tmp;
2799 write_issue(
2800 &store,
2801 "2026-01-01-a.md",
2802 &sample_frontmatter(1, "A", "todo"),
2803 );
2804 write_issue(&store, "readme.txt", "not an issue");
2805 write_issue(&store, "2026-01-01-sub", "no extension");
2806 let issues = store.list().unwrap();
2807 assert_eq!(issues.len(), 1);
2808 }
2809
2810 #[test]
2811 fn list_errors_on_malformed_frontmatter() {
2812 let (tmp, store) = make_store();
2813 let _ = tmp;
2814 write_issue(&store, "2026-01-01-bad.md", "no frontmatter here\n");
2815 let err = store.list().unwrap_err();
2816 assert!(matches!(err, TixError::Parse(_)), "got {err:?}");
2817 }
2818
2819 #[test]
2820 fn get_by_number() {
2821 let (tmp, store) = make_store();
2822 let _ = tmp;
2823 write_issue(
2824 &store,
2825 "2026-01-01-x.md",
2826 &sample_frontmatter(5, "X", "todo"),
2827 );
2828 let issue = store.get(IssueSelector::Number(5)).unwrap();
2829 assert_eq!(issue.title, "X");
2830 }
2831
2832 #[test]
2833 fn get_by_filename() {
2834 let (tmp, store) = make_store();
2835 let _ = tmp;
2836 write_issue(
2837 &store,
2838 "2026-01-01-y.md",
2839 &sample_frontmatter(2, "Y", "done"),
2840 );
2841 let issue = store
2842 .get(IssueSelector::Filename("2026-01-01-y.md".into()))
2843 .unwrap();
2844 assert_eq!(issue.status.display(), "done");
2845 }
2846
2847 #[test]
2848 fn get_by_slug() {
2849 let (tmp, store) = make_store();
2850 let _ = tmp;
2851 write_issue(
2852 &store,
2853 "2026-01-01-z.md",
2854 &sample_frontmatter(3, "Z", "todo"),
2855 );
2856 let issue = store.get(IssueSelector::Slug("z".into())).unwrap();
2857 assert_eq!(issue.number, 3);
2858 }
2859
2860 #[test]
2861 fn get_not_found() {
2862 let (tmp, store) = make_store();
2863 let _ = tmp;
2864 assert!(store.get(IssueSelector::Number(99)).is_err());
2865 }
2866
2867 #[test]
2868 fn create_assigns_number_from_live_issues_and_leaves_config_untouched() {
2869 let (tmp, store) = make_store();
2870 let _ = tmp;
2871 assert_eq!(store.config.state.next_number, 0);
2872 let mut issue = Issue {
2873 number: 0,
2874 title: "First".into(),
2875 status: Status::default(),
2876 labels: vec![],
2877 priority: Priority::default(),
2878 assignee: None,
2879 estimate: None,
2880 commit: None,
2881 gitlab_id: None,
2882 linear_id: None,
2883 mission: None,
2884 task: None,
2885 relationships: vec![],
2886 files: vec![],
2887 cycles: None,
2888 milestones: None,
2889 reviewer: None,
2890 start_date: None,
2891 due_date: None,
2892 created: "2026-01-01".into(),
2893 updated: "2026-01-01".into(),
2894 filename: String::new(),
2895 body: String::new(),
2896 history: crate::model::History::new(),
2897 has_retired_links_key: false,
2898 parent: None,
2899 extra_frontmatter: std::collections::BTreeMap::new(),
2900 archived: false,
2901 entered_at: None,
2902 source: None,
2903 };
2904 let path = store.create(&mut issue, "Hello world", None).unwrap();
2905 assert!(path.exists());
2906 assert_eq!(
2907 issue.number, 1,
2908 "an empty store starts at 1, not 0 — #0 is indistinguishable from a \
2909 sentinel or a failed read (tix #1376)"
2910 );
2911 let config_path = tmp.path().join("tix.toml");
2913 let on_disk = std::fs::read_to_string(&config_path).unwrap();
2914 assert!(
2915 !on_disk.contains("next_number"),
2916 "create must not persist an issue-number counter:\n{on_disk}"
2917 );
2918 let reloaded = Config::load_with_warning(&config_path, |_| {}).unwrap();
2919 assert_eq!(reloaded.state.next_number, 0, "state is never written");
2920 }
2921
2922 #[test]
2923 fn create_resolves_max_live_number_plus_one() {
2924 let (tmp, store) = make_store();
2925 let _ = tmp;
2926 write_issue(
2927 &store,
2928 "2026-01-01-a.md",
2929 &sample_frontmatter(0, "A", "todo"),
2930 );
2931 write_issue(
2932 &store,
2933 "2026-01-01-b.md",
2934 &sample_frontmatter(1, "B", "todo"),
2935 );
2936 write_issue(
2937 &store,
2938 "2026-01-01-c.md",
2939 &sample_frontmatter(2, "C", "todo"),
2940 );
2941 let mut issue = Issue {
2942 number: 0,
2943 title: "D".into(),
2944 status: Status::default(),
2945 labels: vec![],
2946 priority: Priority::default(),
2947 assignee: None,
2948 estimate: None,
2949 commit: None,
2950 gitlab_id: None,
2951 linear_id: None,
2952 mission: None,
2953 task: None,
2954 relationships: vec![],
2955 files: vec![],
2956 cycles: None,
2957 milestones: None,
2958 reviewer: None,
2959 start_date: None,
2960 due_date: None,
2961 created: "2026-01-01".into(),
2962 updated: "2026-01-01".into(),
2963 filename: String::new(),
2964 body: String::new(),
2965 history: crate::model::History::new(),
2966 has_retired_links_key: false,
2967 parent: None,
2968 extra_frontmatter: std::collections::BTreeMap::new(),
2969 archived: false,
2970 entered_at: None,
2971 source: None,
2972 };
2973 store.create(&mut issue, "", None).unwrap();
2974 assert_eq!(issue.number, 3);
2975 }
2976
2977 #[test]
2978 fn create_reuses_number_of_deleted_highest_issue() {
2979 let (_tmp, store) = make_store();
2983 let mut issue_a = Issue {
2984 number: 0,
2985 title: "A".into(),
2986 status: Status::default(),
2987 labels: vec![],
2988 priority: Priority::default(),
2989 assignee: None,
2990 estimate: None,
2991 commit: None,
2992 gitlab_id: None,
2993 linear_id: None,
2994 mission: None,
2995 task: None,
2996 relationships: vec![],
2997 files: vec![],
2998 cycles: None,
2999 milestones: None,
3000 reviewer: None,
3001 start_date: None,
3002 due_date: None,
3003 created: "2026-01-01".into(),
3004 updated: "2026-01-01".into(),
3005 filename: String::new(),
3006 body: String::new(),
3007 history: crate::model::History::new(),
3008 has_retired_links_key: false,
3009 parent: None,
3010 extra_frontmatter: std::collections::BTreeMap::new(),
3011 archived: false,
3012 entered_at: None,
3013 source: None,
3014 };
3015 store.create(&mut issue_a, "", None).unwrap();
3016 let filename = issue_a.filename.clone();
3017 assert_eq!(issue_a.number, 1, "the first issue in a fresh store is #1");
3018 store.delete(&filename).unwrap();
3019 let mut issue_b = Issue {
3020 number: 0,
3021 title: "B".into(),
3022 status: Status::default(),
3023 labels: vec![],
3024 priority: Priority::default(),
3025 assignee: None,
3026 estimate: None,
3027 commit: None,
3028 gitlab_id: None,
3029 linear_id: None,
3030 mission: None,
3031 task: None,
3032 relationships: vec![],
3033 files: vec![],
3034 cycles: None,
3035 milestones: None,
3036 reviewer: None,
3037 start_date: None,
3038 due_date: None,
3039 created: "2026-01-02".into(),
3040 updated: "2026-01-02".into(),
3041 filename: String::new(),
3042 body: String::new(),
3043 history: crate::model::History::new(),
3044 has_retired_links_key: false,
3045 parent: None,
3046 extra_frontmatter: std::collections::BTreeMap::new(),
3047 archived: false,
3048 entered_at: None,
3049 source: None,
3050 };
3051 store.create(&mut issue_b, "", None).unwrap();
3052 assert_eq!(
3053 issue_b.number, 1,
3054 "an emptied store resumes at the same floor as a fresh one: 1"
3055 );
3056 }
3057
3058 #[test]
3059 fn create_with_custom_filename_stem() {
3060 let (tmp, store) = make_store();
3061 let _ = tmp;
3062 let mut issue = Issue {
3063 number: 0,
3064 title: "T".into(),
3065 status: Status::default(),
3066 labels: vec![],
3067 priority: Priority::default(),
3068 assignee: None,
3069 estimate: None,
3070 commit: None,
3071 gitlab_id: None,
3072 linear_id: None,
3073 mission: None,
3074 task: None,
3075 relationships: vec![],
3076 files: vec![],
3077 cycles: None,
3078 milestones: None,
3079 reviewer: None,
3080 start_date: None,
3081 due_date: None,
3082 created: "2026-01-01".into(),
3083 updated: "2026-01-01".into(),
3084 filename: String::new(),
3085 body: String::new(),
3086 history: crate::model::History::new(),
3087 has_retired_links_key: false,
3088 parent: None,
3089 extra_frontmatter: std::collections::BTreeMap::new(),
3090 archived: false,
3091 entered_at: None,
3092 source: None,
3093 };
3094 let path = store
3095 .create(&mut issue, "body", Some("custom-stem"))
3096 .unwrap();
3097 assert!(path.to_string_lossy().contains("custom-stem.md"));
3098 }
3099
3100 #[test]
3101 fn create_ensures_issues_dir_exists() {
3102 let tmp = tempfile::TempDir::new().unwrap();
3103 let dir = tmp.path().join("nested").join("issues");
3104 let config_path = tmp.path().join("tix.toml");
3105 let config = Config {
3106 issues_dir: dir.clone(),
3107 ..Config::default()
3108 };
3109 config.save(&config_path).unwrap();
3110 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
3111 let store = Store {
3112 issues_dir: dir,
3113 config,
3114 };
3115 let mut issue = Issue {
3116 number: 0,
3117 title: "T".into(),
3118 status: Status::default(),
3119 labels: vec![],
3120 priority: Priority::default(),
3121 assignee: None,
3122 estimate: None,
3123 commit: None,
3124 gitlab_id: None,
3125 linear_id: None,
3126 mission: None,
3127 task: None,
3128 relationships: vec![],
3129 files: vec![],
3130 cycles: None,
3131 milestones: None,
3132 reviewer: None,
3133 start_date: None,
3134 due_date: None,
3135 created: "2026-01-01".into(),
3136 updated: "2026-01-01".into(),
3137 filename: String::new(),
3138 body: String::new(),
3139 history: crate::model::History::new(),
3140 has_retired_links_key: false,
3141 parent: None,
3142 extra_frontmatter: std::collections::BTreeMap::new(),
3143 archived: false,
3144 entered_at: None,
3145 source: None,
3146 };
3147 store.create(&mut issue, "", None).unwrap();
3148 assert_eq!(issue.number, 1, "the first issue in a fresh store is #1");
3149 }
3150
3151 #[test]
3152 fn update_replaces_content_atomically() {
3153 let (tmp, store) = make_store();
3154 let _ = tmp;
3155 write_issue(
3156 &store,
3157 "2026-01-01-u.md",
3158 &sample_frontmatter(1, "U", "todo"),
3159 );
3160 let mut issue = store
3161 .get(IssueSelector::Filename("2026-01-01-u.md".into()))
3162 .unwrap();
3163 issue.title = "Updated".into();
3164 issue.bump_updated();
3165 store.update("2026-01-01-u.md", &issue).unwrap();
3166 let reloaded = store
3167 .get(IssueSelector::Filename("2026-01-01-u.md".into()))
3168 .unwrap();
3169 assert_eq!(reloaded.title, "Updated");
3170 }
3171
3172 #[test]
3173 fn update_not_found() {
3174 let (tmp, store) = make_store();
3175 let _ = tmp;
3176 let issue = Issue {
3177 number: 1,
3178 title: "T".into(),
3179 status: Status::default(),
3180 labels: vec![],
3181 priority: Priority::default(),
3182 assignee: None,
3183 estimate: None,
3184 commit: None,
3185 gitlab_id: None,
3186 linear_id: None,
3187 mission: None,
3188 task: None,
3189 relationships: vec![],
3190 files: vec![],
3191 cycles: None,
3192 milestones: None,
3193 reviewer: None,
3194 start_date: None,
3195 due_date: None,
3196 created: "2026-01-01".into(),
3197 updated: "2026-01-01".into(),
3198 filename: String::new(),
3199 body: String::new(),
3200 history: crate::model::History::new(),
3201 has_retired_links_key: false,
3202 parent: None,
3203 extra_frontmatter: std::collections::BTreeMap::new(),
3204 archived: false,
3205 entered_at: None,
3206 source: None,
3207 };
3208 assert!(store.update("nope.md", &issue).is_err());
3209 }
3210
3211 #[test]
3212 fn delete_removes_file() {
3213 let (tmp, store) = make_store();
3214 let _ = tmp;
3215 write_issue(
3216 &store,
3217 "2026-01-01-d.md",
3218 &sample_frontmatter(1, "D", "todo"),
3219 );
3220 assert!(store.issues_dir.join("2026-01-01-d.md").exists());
3221 store.delete("2026-01-01-d.md").unwrap();
3222 assert!(!store.issues_dir.join("2026-01-01-d.md").exists());
3223 }
3224
3225 #[test]
3226 fn delete_not_found() {
3227 let (tmp, store) = make_store();
3228 let _ = tmp;
3229 assert!(store.delete("nope.md").is_err());
3230 }
3231
3232 #[test]
3233 fn stray_tmp_file_not_treated_as_issue() {
3234 let (tmp, store) = make_store();
3235 let _ = tmp;
3236 write_issue(
3237 &store,
3238 "2026-01-01-a.md",
3239 &sample_frontmatter(1, "A", "todo"),
3240 );
3241 fs::write(store.issues_dir.join(".tix-tmp-9999-0"), "orphaned data").unwrap();
3242 let issues = store.list().unwrap();
3243 assert_eq!(issues.len(), 1);
3244 }
3245
3246 fn seam_store() -> (tempfile::TempDir, Store) {
3263 let tmp = tempfile::TempDir::new().unwrap();
3264 let dir = tmp.path().join("issues");
3265 fs::create_dir_all(&dir).unwrap();
3266 let config_path = tmp.path().join("tix.toml");
3267 let config = Config {
3268 issues_dir: dir.clone(),
3269 kanban: crate::config::KanbanSection {
3270 configurations: indexmap::IndexMap::from([(
3271 "default".to_string(),
3272 crate::config::BoardConfiguration {
3273 columns: vec![
3274 "triage".into(),
3275 "todo".into(),
3276 "doing".into(),
3277 "done".into(),
3278 ],
3279 done_column: Some("done".to_string()),
3280 category_map: indexmap::IndexMap::from([
3281 ("triage".to_string(), "backlog".to_string()),
3282 ("todo".to_string(), "unstarted".to_string()),
3283 ("doing".to_string(), "active".to_string()),
3284 ("done".to_string(), "completed".to_string()),
3285 ]),
3286 ..Default::default()
3287 },
3288 )]),
3289 },
3290 ..Config::default()
3291 };
3292 config.save(&config_path).unwrap();
3293 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
3294 (
3295 tmp,
3296 Store {
3297 issues_dir: dir,
3298 config,
3299 },
3300 )
3301 }
3302
3303 fn seam_issue(status: &str) -> (tempfile::TempDir, Store, String) {
3311 let (tmp, store) = seam_store();
3312 write_issue(
3313 &store,
3314 "2026-01-01-seam.md",
3315 &format!(
3316 "---\nnumber: 1\ntitle: \"Seam\"\nstatus: {status}\nlabels: []\n\
3317 priority: \"medium\"\nfiles: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody."
3318 ),
3319 );
3320 let before = read_issue_file(&store, "2026-01-01-seam.md");
3321 (tmp, store, before)
3322 }
3323
3324 fn assert_untouched(store: &Store, before: &str, what: &str) {
3326 let after = read_issue_file(store, "2026-01-01-seam.md");
3327 assert_eq!(after, before, "{what}: the file must be byte-identical");
3328 }
3329
3330 #[test]
3331 fn t015_accepted_transition_writes_status_bumps_updated_and_appends_one_decision() {
3332 let (_tmp, store, _before) = seam_issue("todo");
3333 let outcome = store
3334 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
3335 .unwrap();
3336 assert!(outcome.written, "an accepted, changing move writes");
3337 let decision = outcome
3338 .decision
3339 .clone()
3340 .expect("an accepted transition appends exactly one decision");
3341 assert_eq!(decision.from, "todo");
3342 assert_eq!(decision.to, "doing");
3343 assert_eq!(decision.from_category, "unstarted");
3344 assert_eq!(decision.to_category, "active");
3345 assert!(
3346 decision.override_reason.is_none(),
3347 "no override was supplied"
3348 );
3349 assert_eq!(
3350 outcome.issue.status.display(),
3351 "doing",
3352 "Issue.status holds the COLUMN name, never a category (C-001)"
3353 );
3354 assert_eq!(outcome.issue.updated, today(), "`updated` is bumped");
3355
3356 let reloaded = store
3358 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3359 .unwrap();
3360 assert_eq!(reloaded.status.display(), "doing");
3361 let events = reloaded.history.events();
3362 assert_eq!(
3363 events.len(),
3364 1,
3365 "exactly one History entry, and it is the decision: {events:?}"
3366 );
3367 assert_eq!(events[0].kind, HistoryEventKind::TransitionAccepted);
3368 assert_eq!(
3369 crate::model::TransitionDecision::from_event(events[0])
3370 .unwrap()
3371 .to,
3372 "doing",
3373 "and the decision round-trips out of the file with its fields intact"
3374 );
3375 }
3376
3377 #[test]
3378 fn t015_unknown_column_is_refused_by_name_and_writes_nothing() {
3379 let (_tmp, store, before) = seam_issue("todo");
3380 let err = store
3381 .set_status("2026-01-01-seam.md", StatusChange::to("nope"))
3382 .unwrap_err();
3383 assert!(
3384 matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "unknown_column"),
3385 "the shared named diagnostic, contract §6: {err:?}"
3386 );
3387 assert!(
3388 err.to_string()
3389 .contains("valid columns: triage, todo, doing, done"),
3390 "in BOARD order, not hash order: {err}"
3391 );
3392 assert_untouched(&store, &before, "unknown column");
3393 }
3394
3395 #[test]
3396 fn t015_terminal_exit_is_refused_and_writes_nothing() {
3397 let (_tmp, store, before) = seam_issue("done");
3398 let err = store
3399 .set_status("2026-01-01-seam.md", StatusChange::to("todo"))
3400 .unwrap_err();
3401 assert!(
3402 matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "invalid_transition"),
3403 "{err:?}"
3404 );
3405 assert_untouched(&store, &before, "terminal exit without a reason");
3406 }
3407
3408 #[test]
3409 fn t015_adjacency_restricted_backwards_move_is_refused_and_writes_nothing() {
3410 let (_tmp, store, before) = seam_issue("doing");
3414 let err = store
3415 .set_status("2026-01-01-seam.md", StatusChange::to("triage"))
3416 .unwrap_err();
3417 assert!(
3418 matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "invalid_transition"),
3419 "{err:?}"
3420 );
3421 assert!(
3422 err.to_string().contains("category policy"),
3423 "the message names the rule that refused it: {err}"
3424 );
3425 assert_untouched(&store, &before, "adjacency refusal");
3426 }
3427
3428 #[test]
3429 fn t015_terminal_exit_with_a_reason_is_accepted_and_the_reason_is_recorded() {
3430 let (_tmp, store, before) = seam_issue("done");
3431 let outcome = store
3432 .set_status(
3433 "2026-01-01-seam.md",
3434 StatusChange::to("todo").with_override(Some("reopening: wrong epic")),
3435 )
3436 .unwrap();
3437 assert!(outcome.written);
3438 let decision = outcome.decision.expect("one decision");
3439 assert_eq!(
3440 decision.override_reason.as_deref(),
3441 Some("reopening: wrong epic"),
3442 "an override with no recorded reason is not an audit record"
3443 );
3444 assert_ne!(
3445 read_issue_file(&store, "2026-01-01-seam.md"),
3446 before,
3447 "sanity: an accepted move must have changed the file at all"
3448 );
3449 }
3450
3451 #[test]
3452 fn t015_a_blank_reason_is_not_an_override() {
3453 let (_tmp, store, before) = seam_issue("done");
3458 let err = store
3459 .set_status(
3460 "2026-01-01-seam.md",
3461 StatusChange::to("todo").with_override(Some(" ")),
3462 )
3463 .unwrap_err();
3464 assert!(
3465 matches!(&err, TixError::Workflow(_)),
3466 "a blank reason is not a reason: {err:?}"
3467 );
3468 assert_untouched(&store, &before, "blank override reason");
3469 }
3470
3471 #[test]
3472 fn t015_same_column_is_accepted_and_writes_nothing() {
3473 let (_tmp, store, before) = seam_issue("doing");
3474 let outcome = store
3475 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
3476 .unwrap();
3477 assert!(!outcome.written, "a same-column re-apply is a no-op");
3478 assert!(
3479 outcome.decision.is_none(),
3480 "and appends no decision, contract §3.2"
3481 );
3482 assert_untouched(&store, &before, "same column");
3483 let reloaded = store
3486 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3487 .unwrap();
3488 assert_eq!(
3489 reloaded.updated, "2026-01-01",
3490 "the fixture's original date"
3491 );
3492 }
3493
3494 #[test]
3495 fn t015_no_new_frontmatter_key_was_introduced() {
3496 let (_tmp, store, before) = seam_issue("todo");
3500 store
3501 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
3502 .unwrap();
3503 let after = read_issue_file(&store, "2026-01-01-seam.md");
3504 let keys = |text: &str| -> Vec<String> {
3505 text.split("---\n")
3506 .nth(1)
3507 .unwrap()
3508 .lines()
3509 .filter_map(|l| l.split_once(':').map(|(k, _)| k.trim().to_string()))
3510 .filter(|k| !k.is_empty())
3511 .collect()
3512 };
3513 assert_eq!(
3514 keys(&after),
3515 keys(&before),
3516 "the seam must not add or remove a top-level frontmatter key"
3517 );
3518 }
3519
3520 #[test]
3521 fn t015_a_hand_edited_inconsistent_file_is_read_and_not_repaired() {
3522 let (_tmp, store, before) = seam_issue("doing");
3527 let edited = before.replace("status: doing", "status: triage");
3530 write_issue(&store, "2026-01-01-seam.md", &edited);
3531 let after_edit = read_issue_file(&store, "2026-01-01-seam.md");
3532
3533 let issue = store
3534 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3535 .unwrap();
3536 assert_eq!(
3537 issue.status.display(),
3538 "triage",
3539 "a hand-edited status is read, not rejected at load"
3540 );
3541 assert_eq!(
3542 read_issue_file(&store, "2026-01-01-seam.md"),
3543 after_edit,
3544 "reading must not repair it"
3545 );
3546 store
3549 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
3550 .unwrap();
3551 let moved = store
3552 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3553 .unwrap();
3554 assert_eq!(moved.status.display(), "doing");
3555 assert_eq!(
3556 moved.history.events().len(),
3557 1,
3558 "only the new decision; nothing was written to explain the old one"
3559 );
3560 }
3561
3562 #[test]
3563 fn t015_every_rejection_kind_is_one_of_the_contract_vocabulary() {
3564 let allowed = [
3568 "missing_completion_category",
3569 "unknown_category",
3570 "mapping_for_unknown_column",
3571 "unmapped_column_default",
3572 "invalid_transition",
3573 "unknown_column",
3574 "direct_edit_policy_violation",
3575 ];
3576 let cases: Vec<(&str, &str)> = vec![
3577 ("todo", "nope"), ("done", "todo"), ("doing", "triage"), ];
3581 for (from, to) in cases {
3582 let (_tmp, store, _before) = seam_issue(from);
3583 let err = store
3584 .set_status("2026-01-01-seam.md", StatusChange::to(to))
3585 .unwrap_err();
3586 let TixError::Workflow(d) = &err else {
3587 panic!("{from} -> {to} must refuse through the workflow variant: {err:?}");
3588 };
3589 assert!(
3590 allowed.contains(&d.kind.as_str()),
3591 "`{}` is not in the contract's vocabulary",
3592 d.kind.as_str()
3593 );
3594 }
3595 }
3596
3597 #[test]
3598 fn t015_a_legacy_board_gains_no_policy_but_keeps_completion() {
3599 let (tmp, store) = make_store();
3604 let _ = tmp;
3605 write_issue(
3606 &store,
3607 "2026-01-01-legacy.md",
3608 &sample_frontmatter(1, "Legacy", "done"),
3609 );
3610 let moved = store
3611 .set_status("2026-01-01-legacy.md", StatusChange::to("todo"))
3612 .unwrap();
3613 assert!(moved.written, "a legacy board imposes no policy");
3614 assert_eq!(moved.issue.status.display(), "todo");
3615
3616 let resolution =
3619 crate::workflow::resolution(&store.config, store.config.default_config(), "default");
3620 assert!(
3621 !resolution.explicit_policy,
3622 "precondition: this board declared no category_map"
3623 );
3624 assert!(
3625 resolution.has_completion(),
3626 "but it still has a done column"
3627 );
3628 assert!(
3629 !crate::workflow::is_completed(&moved.issue, &resolution),
3630 "and `todo` is not complete"
3631 );
3632 }
3633
3634 #[test]
3635 fn t015_a_mapped_board_keeps_the_commit_coupling() {
3636 let (_tmp, store, _before) = seam_issue("doing");
3640 store
3641 .set_status(
3642 "2026-01-01-seam.md",
3643 StatusChange::to("done").with_commit(Some("abc123")),
3644 )
3645 .unwrap();
3646 let at_done = store
3647 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3648 .unwrap();
3649 assert_eq!(at_done.commit.as_deref(), Some("abc123"));
3650
3651 store
3652 .set_status(
3653 "2026-01-01-seam.md",
3654 StatusChange::to("todo").with_override(Some("not actually finished")),
3655 )
3656 .unwrap();
3657 let off_done = store
3658 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3659 .unwrap();
3660 assert_eq!(
3661 off_done.commit, None,
3662 "leaving completion clears the recorded commit"
3663 );
3664 }
3665
3666 #[test]
3667 fn t015_a_decision_round_trips_through_a_file_unchanged() {
3668 let (_tmp, store, _before) = seam_issue("todo");
3672 store
3673 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
3674 .unwrap();
3675 let first = store
3676 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3677 .unwrap();
3678 let text = read_issue_file(&store, "2026-01-01-seam.md");
3679 let parsed = Issue::parse(&text).unwrap();
3681 let events = parsed.history.events();
3682 let decision = crate::model::TransitionDecision::from_event(events[0]).unwrap();
3683 assert_eq!(decision.to, "doing");
3684 assert_eq!(decision.from_category, "unstarted");
3685 assert_eq!(decision.to_category, "active");
3686 assert_eq!(
3689 parsed.to_file_contents(),
3690 text,
3691 "a parsed decision must re-render to the same bytes"
3692 );
3693 assert_eq!(parsed.history.events().len(), first.history.events().len());
3694 }
3695
3696 #[test]
3697 fn t015_two_transitions_append_two_decisions_in_order() {
3698 let (_tmp, store, _before) = seam_issue("triage");
3699 for to in ["todo", "doing"] {
3700 store
3701 .set_status("2026-01-01-seam.md", StatusChange::to(to))
3702 .unwrap();
3703 }
3704 let after = store
3705 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
3706 .unwrap();
3707 let events = after.history.events();
3708 assert_eq!(events.len(), 2, "one per accepted transition");
3709 let tos: Vec<String> = events
3710 .iter()
3711 .map(|e| crate::model::TransitionDecision::from_event(e).unwrap().to)
3712 .collect();
3713 assert_eq!(tos, vec!["todo", "doing"], "append order, not sorted");
3714 }
3715
3716 #[test]
3717 fn set_status_changes_status_and_bumps_updated() {
3718 let (tmp, store) = make_store();
3719 let _ = tmp;
3720 write_issue(
3721 &store,
3722 "2026-01-01-s.md",
3723 &sample_frontmatter(1, "S", "todo"),
3724 );
3725 let outcome = store
3726 .set_status("2026-01-01-s.md", StatusChange::to("in progress"))
3727 .unwrap();
3728 let issue = &outcome.issue;
3729 assert_eq!(issue.status.display(), "in progress");
3730 assert_eq!(issue.updated, today());
3731 assert_eq!(issue.title, "S");
3732 assert_eq!(issue.body, "Body text.");
3733 }
3734
3735 #[test]
3736 fn set_status_preserves_file_path() {
3737 let (tmp, store) = make_store();
3738 let _ = tmp;
3739 write_issue(
3740 &store,
3741 "2026-01-01-f.md",
3742 &sample_frontmatter(1, "F", "todo"),
3743 );
3744 let _ = store
3745 .set_status("2026-01-01-f.md", StatusChange::to("done"))
3746 .unwrap();
3747 assert!(store.issues_dir.join("2026-01-01-f.md").exists());
3748 }
3749
3750 fn read_issue_file(store: &Store, filename: &str) -> String {
3756 std::fs::read_to_string(store.issues_dir.join(filename)).unwrap()
3757 }
3758
3759 fn bare_frontmatter(number: u32, title: &str) -> String {
3762 format!(
3763 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
3764 )
3765 }
3766
3767 fn legacy_links_frontmatter(number: u32, title: &str, links: &str) -> String {
3769 format!(
3770 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\nlinks: {links}\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
3771 )
3772 }
3773
3774 fn related_pair() -> (TempDir, Store, Issue, Issue) {
3776 let (tmp, store) = make_store();
3777 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
3778 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
3779 let a = store.get(IssueSelector::Number(1)).unwrap();
3780 let b = store.get(IssueSelector::Number(2)).unwrap();
3781 (tmp, store, a, b)
3782 }
3783
3784 #[test]
3785 fn t009_link_records_a_single_directed_edge() {
3786 let (_tmp, store, a, b) = related_pair();
3787 let outcome = store
3788 .link(
3789 &a.number.to_string(),
3790 &b.number.to_string(),
3791 RelationKind::Blocks,
3792 )
3793 .unwrap();
3794 assert!(outcome.changed);
3795
3796 let a_after = store.get(IssueSelector::Number(1)).unwrap();
3799 let b_after = store.get(IssueSelector::Number(2)).unwrap();
3800 assert_eq!(a_after.relationships.len(), 1);
3801 assert!(
3802 b_after.relationships.is_empty(),
3803 "no back-reference is written for a directed edge"
3804 );
3805 assert_eq!(a_after.relationships[0].kind, RelationKind::Blocks);
3806 assert_eq!(
3807 (a_after.relationships[0].from, a_after.relationships[0].to),
3808 (1, 2)
3809 );
3810 }
3811
3812 #[test]
3813 fn t009_link_appends_exactly_one_history_event() {
3814 let (_tmp, store, a, b) = related_pair();
3815 store
3816 .link(
3817 &a.number.to_string(),
3818 &b.number.to_string(),
3819 RelationKind::Blocks,
3820 )
3821 .unwrap();
3822 let a_after = store.get(IssueSelector::Number(1)).unwrap();
3823 assert_eq!(a_after.history.len(), 1);
3824 assert_eq!(
3825 a_after.history.events()[0].kind,
3826 HistoryEventKind::RelationshipAdded
3827 );
3828 assert!(!a_after.history.events()[0].actor.is_empty());
3830 }
3831
3832 #[test]
3833 fn t009_idempotent_reapply_writes_nothing_and_appends_no_second_event() {
3834 let (_tmp, store, a, b) = related_pair();
3835 let args = (a.number.to_string(), b.number.to_string());
3836 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
3837 let first = store.get(IssueSelector::Number(1)).unwrap();
3838 let bytes_after_first = read_issue_file(&store, &first.filename);
3839
3840 let second = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
3841 assert!(!second.changed, "re-applying an existing edge is a no-op");
3842 assert!(second.written.is_none(), "no file is written for a no-op");
3843
3844 let after = store.get(IssueSelector::Number(1)).unwrap();
3845 assert_eq!(after.relationships.len(), 1, "no duplicate edge");
3846 assert_eq!(after.history.len(), 1, "no second History event");
3847 assert_eq!(
3848 read_issue_file(&store, &after.filename),
3849 bytes_after_first,
3850 "the file is byte-identical after the no-op"
3851 );
3852 }
3853
3854 #[test]
3855 fn t009_inverse_spelling_resolves_to_the_same_stored_edge() {
3856 let (_tmp, store, a, b) = related_pair();
3857 let args = (a.number.to_string(), b.number.to_string());
3858 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
3859 let again = store
3861 .link(&args.1, &args.0, RelationKind::BlockedBy)
3862 .unwrap();
3863 assert!(!again.changed, "the inverse spelling must not add an edge");
3864 let after = store.get(IssueSelector::Number(1)).unwrap();
3865 assert_eq!(after.relationships.len(), 1);
3866 assert_eq!(
3867 after.relationships[0].kind,
3868 RelationKind::Blocks,
3869 "the stored kind is always the canonical one"
3870 );
3871 }
3872
3873 #[test]
3874 fn t009_self_link_is_refused_and_writes_nothing() {
3875 let (_tmp, store, a, _b) = related_pair();
3876 let before = read_issue_file(&store, &a.filename);
3877 let err = store
3878 .link(
3879 &a.number.to_string(),
3880 &a.number.to_string(),
3881 RelationKind::Blocks,
3882 )
3883 .unwrap_err();
3884 assert!(matches!(err, TixError::InvalidInput(_)));
3885 assert!(
3886 err.to_string().contains("self_link"),
3887 "the diagnostic names the rule: {err}"
3888 );
3889 assert_eq!(
3890 read_issue_file(&store, &a.filename),
3891 before,
3892 "SC-005: a rejected mutation leaves the file byte-identical"
3893 );
3894 }
3895
3896 #[test]
3897 fn t009_cycle_closing_edge_is_refused_and_writes_nothing() {
3898 let (tmp, store) = make_store();
3899 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
3900 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
3901 write_issue(&store, "2026-01-01-c.md", &bare_frontmatter(3, "C"));
3902 let _ = tmp;
3903 store.link("1", "2", RelationKind::Blocks).unwrap();
3904 store.link("2", "3", RelationKind::Blocks).unwrap();
3905 let c_before = read_issue_file(&store, "2026-01-01-c.md");
3906
3907 let err = store.link("3", "1", RelationKind::Blocks).unwrap_err();
3908 assert!(err.to_string().contains("cycle"), "{err}");
3909 assert_eq!(
3910 read_issue_file(&store, "2026-01-01-c.md"),
3911 c_before,
3912 "SC-005: both files of a rejected two-issue mutation are unchanged"
3913 );
3914 }
3915
3916 #[test]
3917 fn t009_unlink_removes_the_edge_and_is_idempotent() {
3918 let (_tmp, store, a, b) = related_pair();
3919 let args = (a.number.to_string(), b.number.to_string());
3920 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
3921
3922 let removed = store
3923 .unlink(&args.0, &args.1, RelationKind::Blocks)
3924 .unwrap();
3925 assert!(removed.changed);
3926 let after = store.get(IssueSelector::Number(1)).unwrap();
3927 assert!(after.relationships.is_empty());
3928 assert_eq!(after.history.len(), 2, "added then removed");
3929
3930 let again = store
3932 .unlink(&args.0, &args.1, RelationKind::Blocks)
3933 .unwrap();
3934 assert!(!again.changed);
3935 let after2 = store.get(IssueSelector::Number(1)).unwrap();
3936 assert_eq!(after2.history.len(), 2, "no third History event");
3937 }
3938
3939 #[test]
3940 fn t009_unlink_of_a_never_linked_pair_is_a_noop() {
3941 let (_tmp, store, a, b) = related_pair();
3942 let outcome = store
3943 .unlink(
3944 &a.number.to_string(),
3945 &b.number.to_string(),
3946 RelationKind::Blocks,
3947 )
3948 .unwrap();
3949 assert!(!outcome.changed);
3950 assert!(outcome.written.is_none());
3951 let after = store.get(IssueSelector::Number(1)).unwrap();
3952 assert!(after.relationships.is_empty());
3953 assert!(after.history.is_empty(), "a no-op appends nothing");
3954 }
3955
3956 #[test]
3957 fn t009_set_relationships_replaces_the_set_and_keeps_surviving_ids() {
3958 let (_tmp, store, _a, _b) = related_pair();
3959 store.link("1", "2", RelationKind::Blocks).unwrap();
3960 let before = store.get(IssueSelector::Number(1)).unwrap();
3961 let survivor_id = before.relationships[0].id.clone();
3962
3963 let updated = store
3965 .set_relationships(&before.filename, &[(2, RelationKind::Blocks)])
3966 .unwrap();
3967 assert_eq!(updated.relationships.len(), 1);
3968 assert_eq!(
3969 updated.relationships[0].id, survivor_id,
3970 "a surviving relationship keeps its stable id"
3971 );
3972 }
3973
3974 #[test]
3975 fn t009_set_relationships_rejects_unknown_target_and_self() {
3976 let (_tmp, store, a, _b) = related_pair();
3977 assert!(
3978 store
3979 .set_relationships(&a.filename, &[(404, RelationKind::Blocks)])
3980 .is_err(),
3981 "an unknown target is refused"
3982 );
3983 assert!(
3984 store
3985 .set_relationships(&a.filename, &[(1, RelationKind::Blocks)])
3986 .is_err(),
3987 "a self-link is refused"
3988 );
3989 }
3990
3991 fn seeded(store: &Store, number: u32, title: &str, extra: &str) {
3997 let slug = title.to_lowercase();
3998 write_issue(
3999 store,
4000 &format!("2026-01-01-{slug}.md"),
4001 &format!(
4002 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\n{extra}files: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n"
4003 ),
4004 );
4005 }
4006
4007 #[test]
4008 fn f1_set_relationships_refuses_a_reciprocal_pair_the_service_rejects() {
4009 let (_tmp, store) = make_store();
4016 seeded(&store, 1, "A", "");
4017 seeded(&store, 2, "B", "");
4018 store.link("1", "2", RelationKind::Blocks).unwrap();
4019
4020 let err = store
4021 .set_relationships("2026-01-01-b.md", &[(1, RelationKind::Blocks)])
4022 .unwrap_err();
4023 assert!(
4024 err.to_string().contains("cycle"),
4025 "the service's verdict is what refuses it: {err}"
4026 );
4027 let two = store.get(IssueSelector::Number(2)).unwrap();
4028 assert!(
4029 two.relationships.is_empty(),
4030 "SC-005: a refused mutation writes nothing"
4031 );
4032 }
4033
4034 #[test]
4035 fn f4_a_hand_ordered_related_row_is_removable() {
4036 let (_tmp, store) = make_store();
4041 seeded(&store, 2, "B", "");
4042 seeded(
4043 &store,
4044 5,
4045 "E",
4046 "relationships:\n- id: rel_handauthored\n from: 5\n to: 2\n kind: related\n display: \"related #2 B\"\n",
4047 );
4048 let out = store.unlink("5", "2", RelationKind::Related).unwrap();
4049 assert!(out.changed, "the row is found and removed");
4050 assert!(
4051 store
4052 .get(IssueSelector::Number(5))
4053 .unwrap()
4054 .relationships
4055 .is_empty()
4056 );
4057 }
4058
4059 #[test]
4060 fn f5_a_persisted_blocked_by_row_is_removable() {
4061 let (_tmp, store) = make_store();
4068 seeded(&store, 1, "A", "");
4069 seeded(
4070 &store,
4071 2,
4072 "B",
4073 "relationships:\n- id: rel_bb\n from: 2\n to: 1\n kind: blocked_by\n display: \"blocks #1 A\"\n",
4074 );
4075 let out = store.unlink("2", "1", RelationKind::BlockedBy).unwrap();
4076 assert!(out.changed, "the row is found and removed");
4077 assert!(
4078 store
4079 .get(IssueSelector::Number(2))
4080 .unwrap()
4081 .relationships
4082 .is_empty()
4083 );
4084 }
4085
4086 #[test]
4087 fn f13_a_dangling_edge_is_removable() {
4088 let (_tmp, store) = make_store();
4093 seeded(&store, 1, "A", "");
4094 seeded(&store, 2, "B", "");
4095 store.link("1", "2", RelationKind::Blocks).unwrap();
4096 std::fs::remove_file(store.issues_dir.join("2026-01-01-b.md")).unwrap();
4097
4098 let out = store.unlink("1", "2", RelationKind::Blocks).unwrap();
4099 assert!(out.changed, "a dangling edge is still removable");
4100 let one = store.get(IssueSelector::Number(1)).unwrap();
4101 assert!(one.relationships.is_empty());
4102 assert_eq!(one.history.len(), 2, "and the removal is recorded");
4103 }
4104
4105 #[test]
4106 fn m10_one_unlink_of_a_duplicated_edge_appends_one_event() {
4107 let (_tmp, store) = make_store();
4114 seeded(
4119 &store,
4120 1,
4121 "A",
4122 "relationships:\n- id: rel_dup_12\n from: 1\n to: 2\n kind: related\n display: \"related #2 B\"\n",
4123 );
4124 seeded(
4125 &store,
4126 2,
4127 "B",
4128 "relationships:\n- id: rel_dup_21\n from: 2\n to: 1\n kind: related\n display: \"related #1 A\"\n",
4129 );
4130
4131 let outcome = store.unlink("1", "2", RelationKind::Related).unwrap();
4132 assert!(outcome.changed, "both redundant rows are removed");
4133 let mut summaries = Vec::new();
4134 for number in [1u32, 2] {
4135 let issue = store.get(IssueSelector::Number(number)).unwrap();
4136 assert!(
4137 issue.relationships.is_empty(),
4138 "issue #{number} keeps no edge"
4139 );
4140 assert_eq!(
4141 issue.history.len(),
4142 1,
4143 "issue #{number} records exactly one event, got {:?}",
4144 issue.history.events()
4145 );
4146 let event = &issue.history.events()[0];
4147 assert!(
4148 event.detail.contains("removed 2 relationships"),
4149 "and the event says both spellings went: {}",
4150 event.detail
4151 );
4152 summaries.push(event.detail.clone());
4153 }
4154 assert_eq!(
4158 summaries[0], summaries[1],
4159 "both files record the same single event, not two differing ones"
4160 );
4161 }
4162
4163 #[test]
4164 fn m11_unlink_reports_the_row_it_removed_not_an_empty_placeholder() {
4165 let (_tmp, store, a, b) = related_pair();
4171 let args = (a.number.to_string(), b.number.to_string());
4172 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4173 let before = store.get(IssueSelector::Number(1)).unwrap();
4174 let real_id = before.relationships[0].id.clone();
4175
4176 let outcome = store
4177 .unlink(&args.0, &args.1, RelationKind::Blocks)
4178 .unwrap();
4179 assert!(outcome.changed);
4180 assert_eq!(
4181 outcome.relationship.id, real_id,
4182 "the reported row is the one that was actually removed"
4183 );
4184 assert!(!outcome.relationship.id.is_empty(), "so it is addressable");
4185 assert_eq!(
4186 outcome.relationship.display,
4187 before.relationships[0].display
4188 );
4189 }
4190
4191 #[test]
4192 fn m15_the_history_detail_names_the_relationship_id() {
4193 let (_tmp, store, a, b) = related_pair();
4197 let args = (a.number.to_string(), b.number.to_string());
4198 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4199 let one = store.get(IssueSelector::Number(1)).unwrap();
4200 let id = one.relationships[0].id.clone();
4201
4202 let detail = &one.history.events()[0].detail;
4203 assert!(
4204 detail.starts_with(&format!("created {id}:")),
4205 "the detail names the stable id: {detail}"
4206 );
4207 assert!(
4208 !detail.contains(&format!("created {}:", RelationKind::Blocks)),
4209 "and does not stutter the kind, which `display` already carries: {detail}"
4210 );
4211 }
4212
4213 #[test]
4214 fn f17_all_diagnostics_are_reported_not_just_the_first() {
4215 let (_tmp, store) = make_store();
4227 seeded(
4228 &store,
4229 1,
4230 "A",
4231 "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",
4232 );
4233 seeded(
4234 &store,
4235 2,
4236 "B",
4237 "relationships:\n- id: rel_b21\n from: 2\n to: 1\n kind: blocks\n display: blocks\n",
4238 );
4239
4240 let issues = store.list().unwrap();
4241 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
4242 let verdict = RelationshipGraph::new(&views, "done").validate_snapshot();
4243 assert!(
4244 verdict.errors.len() >= 2,
4245 "the fixture must really produce several diagnostics, got {:?}",
4246 verdict.errors
4247 );
4248
4249 let rendered = relationship_error(&verdict.errors).to_string();
4250 for kind in ["conflicting_edge", "cycle"] {
4251 assert!(
4252 rendered.contains(kind),
4253 "every diagnostic must be reported; {kind} missing from: {rendered}"
4254 );
4255 }
4256 assert!(
4257 !rendered.contains("conflicting_edge: conflicting_edge")
4258 && !rendered.contains("cycle: cycle:"),
4259 "no doubled kind prefix: {rendered}"
4260 );
4261 let dup = vec![
4267 Diagnostic::new(
4268 DiagnosticKind::SelfLink,
4269 "to",
4270 "#1 cannot block itself: a self-link is not a valid relationship",
4271 ),
4272 Diagnostic::new(
4273 DiagnosticKind::SelfLink,
4274 "to",
4275 "#1 cannot block itself: a self-link is not a valid relationship",
4276 ),
4277 Diagnostic::new(DiagnosticKind::Cycle, "to", "cycle: 1 -> 1 -> #1"),
4278 ];
4279 let rendered = relationship_error(&dup).to_string();
4280 assert_eq!(
4281 rendered.matches("cannot block itself").count(),
4282 1,
4283 "an identical diagnostic renders once, not twice: {rendered}"
4284 );
4285 assert!(
4286 rendered.contains("cycle"),
4287 "a distinct diagnostic is still reported: {rendered}"
4288 );
4289 assert!(
4290 !rendered.contains("self_link: self_link") && !rendered.contains("cycle: cycle:"),
4291 "and no kind is prefixed twice: {rendered}"
4292 );
4293 }
4294
4295 #[test]
4296 fn t009_clearing_a_set_removes_a_related_edge_owned_by_another_file() {
4297 let (_tmp, store) = make_store();
4302 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(3, "A"));
4303 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(5, "B"));
4304
4305 store.link("5", "3", RelationKind::Related).unwrap();
4306 let three = store.get(IssueSelector::Number(3)).unwrap();
4307 let five = store.get(IssueSelector::Number(5)).unwrap();
4308 assert_eq!(
4309 three.relationships.len(),
4310 1,
4311 "the edge is stored in the lower-numbered issue's file"
4312 );
4313 assert!(
4314 five.relationships.is_empty(),
4315 "and #5's own file holds none"
4316 );
4317
4318 store.set_relationships(&five.filename, &[]).unwrap();
4319
4320 let three_after = store.get(IssueSelector::Number(3)).unwrap();
4321 let five_after = store.get(IssueSelector::Number(5)).unwrap();
4322 assert!(
4323 three_after.relationships.is_empty(),
4324 "the association is gone from the file that actually stored it"
4325 );
4326 assert!(five_after.relationships.is_empty());
4327 }
4328
4329 #[test]
4330 fn t009_clearing_a_set_does_not_remove_an_inbound_directional_edge() {
4331 let (_tmp, store) = make_store();
4336 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(3, "A"));
4337 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(5, "B"));
4338
4339 store.link("3", "5", RelationKind::Blocks).unwrap();
4340 let five = store.get(IssueSelector::Number(5)).unwrap();
4341
4342 store.set_relationships(&five.filename, &[]).unwrap();
4343
4344 let three_after = store.get(IssueSelector::Number(3)).unwrap();
4345 assert!(
4346 three_after
4347 .relationships
4348 .iter()
4349 .any(|r| r.to == 5 && r.kind == RelationKind::Blocks),
4350 "#3's declaration that it blocks #5 survives: a blocked issue \
4351 cannot unilaterally clear the blocker's graph"
4352 );
4353 }
4354
4355 fn relationship_fixture(name: &str) -> String {
4357 format!(
4358 "{}/tests/fixtures/relationships/{name}",
4359 env!("CARGO_MANIFEST_DIR")
4360 )
4361 }
4362
4363 #[test]
4364 fn t010_typed_fixture_round_trips_byte_stable_including_history() {
4365 let original =
4368 std::fs::read_to_string(relationship_fixture("typed-relationships.md")).unwrap();
4369 let issue = Issue::parse(&original).unwrap();
4370 assert_eq!(issue.relationships.len(), 2);
4371 assert_eq!(issue.history.len(), 2);
4372 let rendered = issue.to_file_contents();
4373 assert_eq!(
4374 rendered, original,
4375 "a typed fixture must not churn on a read/write round trip"
4376 );
4377 assert_eq!(
4378 Issue::parse(&rendered).unwrap().to_file_contents(),
4379 rendered
4380 );
4381 assert_eq!(issue.relationships[0].id, "rel_01JQ8Z4W2M6N7P9R3S5T7V9X1");
4383 assert!(
4384 rendered.find("## History").unwrap() < rendered.find("Prose that follows").unwrap(),
4385 "History sits between the frontmatter and the body"
4386 );
4387 }
4388
4389 #[test]
4390 fn t010_issue_with_no_relationship_data_is_untouched() {
4391 let original =
4392 std::fs::read_to_string(relationship_fixture("no-relationship-data.md")).unwrap();
4393 let issue = Issue::parse(&original).unwrap();
4394 assert!(issue.relationships.is_empty());
4395 assert!(issue.history.is_empty());
4396 assert!(!issue.has_retired_links_key);
4397 let rendered = issue.to_file_contents();
4398 assert_eq!(
4399 rendered, original,
4400 "an issue with no relationship data must not churn"
4401 );
4402 assert!(!rendered.contains("relationships:"));
4403 assert!(!rendered.contains("## History"));
4404 }
4405
4406 #[test]
4407 fn t010_an_empty_history_section_is_dropped_not_preserved() {
4408 let original = std::fs::read_to_string(relationship_fixture("history-empty.md")).unwrap();
4413 assert!(
4414 original.contains("## History"),
4415 "the fixture must actually carry the empty heading"
4416 );
4417 let issue = Issue::parse(&original).unwrap();
4418 assert!(
4419 issue.history.is_empty(),
4420 "a heading with no entries parses to an empty log"
4421 );
4422 assert!(
4423 issue
4424 .body
4425 .contains("An issue whose History section is present but empty."),
4426 "the prose after the heading is body, not a dropped event: {:?}",
4427 issue.body
4428 );
4429 let rendered = issue.to_file_contents();
4430 assert!(
4431 !rendered.contains("## History"),
4432 "the empty section is omitted rather than kept as a bare heading"
4433 );
4434 assert!(rendered.contains("An issue whose History section is present but empty."));
4435 }
4436
4437 #[test]
4438 fn t010_issue_with_no_history_section_is_untouched() {
4439 let original = std::fs::read_to_string(relationship_fixture("history-absent.md")).unwrap();
4440 let issue = Issue::parse(&original).unwrap();
4441 assert!(issue.history.is_empty());
4442 let rendered = issue.to_file_contents();
4443 assert_eq!(
4444 rendered, original,
4445 "a file written before History existed must not gain an empty section"
4446 );
4447 }
4448
4449 #[test]
4450 fn f2_one_bad_history_line_does_not_make_the_store_unreadable() {
4451 let (_tmp, store) = make_store();
4460 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
4461 write_issue(
4462 &store,
4463 "2026-01-01-c.md",
4464 "---\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",
4465 );
4466
4467 let all = store.list().expect("the store must remain listable");
4468 assert_eq!(all.len(), 2, "both issues load");
4469 assert_eq!(
4470 store
4471 .get(IssueSelector::Number(1))
4472 .expect("an unrelated, valid issue must stay readable")
4473 .title,
4474 "A"
4475 );
4476 let three = store.get(IssueSelector::Number(3)).unwrap();
4482 assert!(three.history.events().is_empty(), "no event is fabricated");
4483 assert_eq!(
4484 three.history.unparsed().len(),
4485 1,
4486 "the line is preserved for diagnosis: {:?}",
4487 three.history.unparsed()
4488 );
4489 assert!(
4490 !three.body.contains("ascii hyphens"),
4491 "and is not demoted into the body: {:?}",
4492 three.body
4493 );
4494 }
4495
4496 #[cfg(unix)]
4499 fn seeded_in_segment(store: &Store, number: u32, title: &str, segment: &str) {
4500 let dir = store.issues_dir.join(segment);
4501 std::fs::create_dir_all(&dir).unwrap();
4502 std::fs::write(
4503 dir.join(format!("2026-01-01-{}.md", title.to_lowercase())),
4504 format!(
4505 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\nfiles: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n"
4506 ),
4507 )
4508 .unwrap();
4509 }
4510
4511 #[cfg(unix)]
4512 #[test]
4513 fn f6_a_mid_sequence_write_failure_rolls_every_file_back() {
4514 use std::os::unix::fs::PermissionsExt;
4515 let (tmp, store) = make_store();
4524 let _ = tmp;
4525 seeded_in_segment(&store, 1, "A", "alice");
4526 seeded_in_segment(&store, 2, "B", "bob");
4527 seeded_in_segment(&store, 3, "C", "carol");
4528 seeded_in_segment(&store, 5, "E", "erin");
4529
4530 let paths: Vec<std::path::PathBuf> = [
4531 "alice/2026-01-01-a.md",
4532 "bob/2026-01-01-b.md",
4533 "carol/2026-01-01-c.md",
4534 ]
4535 .iter()
4536 .map(|p| store.issues_dir.join(p))
4537 .collect();
4538 let before: Vec<String> = paths
4539 .iter()
4540 .map(|p| std::fs::read_to_string(p).unwrap())
4541 .collect();
4542
4543 let carol = store.issues_dir.join("carol");
4546 let mut perms = std::fs::metadata(&carol).unwrap().permissions();
4547 perms.set_mode(0o555);
4548 std::fs::set_permissions(&carol, perms).unwrap();
4549
4550 let result = store.set_relationships(
4551 "carol/2026-01-01-c.md",
4552 &[
4553 (1, RelationKind::Related),
4554 (2, RelationKind::Related),
4555 (5, RelationKind::Related),
4556 ],
4557 );
4558
4559 let mut perms = std::fs::metadata(&carol).unwrap().permissions();
4560 perms.set_mode(0o755);
4561 std::fs::set_permissions(&carol, perms).unwrap();
4562
4563 assert!(result.is_err(), "the blocked write must fail");
4564 for (path, previous) in paths.iter().zip(before.iter()) {
4565 assert_eq!(
4566 std::fs::read_to_string(path).unwrap(),
4567 *previous,
4568 "{} was left mutated by a failed mutation",
4569 path.display()
4570 );
4571 }
4572 }
4573
4574 #[cfg(unix)]
4575 #[test]
4576 fn f6b_the_updated_bump_is_itself_rollback_covered() {
4577 use std::os::unix::fs::PermissionsExt;
4587
4588 let (tmp, store) = make_store();
4589 let _ = tmp;
4590 seeded_in_segment(&store, 1, "A", "alice");
4591 seeded_in_segment(&store, 3, "C", "carol");
4592
4593 let alice = store.issues_dir.join("alice/2026-01-01-a.md");
4594 let before = std::fs::read_to_string(&alice).unwrap();
4595
4596 let carol = store.issues_dir.join("carol");
4597 let mut perms = std::fs::metadata(&carol).unwrap().permissions();
4598 perms.set_mode(0o555);
4599 std::fs::set_permissions(&carol, perms).unwrap();
4600
4601 let result =
4602 store.set_relationships("carol/2026-01-01-c.md", &[(1, RelationKind::Related)]);
4603
4604 let mut perms = std::fs::metadata(&carol).unwrap().permissions();
4605 perms.set_mode(0o755);
4606 std::fs::set_permissions(&carol, perms).unwrap();
4607
4608 assert!(result.is_err(), "the blocked bump must fail the call");
4609 assert_eq!(
4610 std::fs::read_to_string(&alice).unwrap(),
4611 before,
4612 "the relationship write that succeeded earlier must be rolled back \
4613 when the subsequent `updated` bump fails"
4614 );
4615 }
4616
4617 #[test]
4618 fn f9_relationships_for_reports_both_endpoints_of_a_one_sided_edge() {
4619 let (_tmp, store) = make_store();
4625 seeded(&store, 1, "A", "");
4626 seeded(&store, 2, "B", "");
4627 store.link("2", "1", RelationKind::Related).unwrap();
4628
4629 let one = store.get(IssueSelector::Number(1)).unwrap();
4631 let two = store.get(IssueSelector::Number(2)).unwrap();
4632 assert_eq!(one.relationships.len(), 1, "precondition: #1 stores it");
4633 assert!(
4634 two.relationships.is_empty(),
4635 "precondition: #2's own field is the partial view that caused F9"
4636 );
4637
4638 for number in [1u32, 2] {
4641 let resolved = store.relationships_for(number).unwrap();
4642 assert_eq!(
4643 resolved.len(),
4644 1,
4645 "issue #{number} must see the relationship that touches it"
4646 );
4647 assert_eq!(resolved[0].kind, RelationKind::Related);
4648 }
4649 }
4650
4651 #[test]
4652 fn f7_writing_an_issue_with_the_retired_key_discloses_that_it_is_removed() {
4653 let (tmp, store) = make_store();
4658 let _ = tmp;
4659 let _ = take_disclosures();
4660 write_issue(
4665 &store,
4666 "2026-01-01-legacy.md",
4667 &legacy_links_frontmatter(1, "Legacy", "['7']"),
4668 );
4669 let issue = store.get(IssueSelector::Number(1)).unwrap();
4670 assert!(
4671 issue.has_retired_links_key,
4672 "precondition: the key is present"
4673 );
4674
4675 store.update(&issue.filename, &issue).unwrap();
4676
4677 let disclosures = take_disclosures();
4679 assert_eq!(
4680 disclosures.len(),
4681 1,
4682 "the write must disclose that it dropped the key: {disclosures:?}"
4683 );
4684 let line = &disclosures[0];
4685 assert!(
4686 line.contains("`links:`"),
4687 "it names what was removed: {line}"
4688 );
4689 assert!(
4693 line.starts_with("remove `links:` by hand"),
4694 "and leads with what to do about it: {line}"
4695 );
4696 assert!(
4697 line.contains(&issue.filename),
4698 "and which file it was removed from: {line}"
4699 );
4700 assert!(
4701 line.contains("C-001"),
4702 "and that no migration was applied, so the user is not left \
4703 wondering where their data went: {line}"
4704 );
4705
4706 let after = read_issue_file(&store, &issue.filename);
4708 assert!(
4709 !after.contains("links:"),
4710 "the key is not re-emitted (C-001: no migration)"
4711 );
4712 let reread = store.get(IssueSelector::Number(1)).unwrap();
4715 assert!(!reread.has_retired_links_key);
4716 assert!(
4717 take_disclosures().is_empty(),
4718 "and it is disclosed once, not on every read"
4719 );
4720 }
4721
4722 #[test]
4723 fn f7b_writing_an_issue_without_the_retired_key_is_silent() {
4724 let (tmp, store) = make_store();
4727 let _ = tmp;
4728 let _ = take_disclosures();
4729 write_issue(&store, "2026-01-01-plain.md", &bare_frontmatter(1, "Plain"));
4730 let issue = store.get(IssueSelector::Number(1)).unwrap();
4731 assert!(!issue.has_retired_links_key);
4732 store.update(&issue.filename, &issue).unwrap();
4733 assert!(
4734 take_disclosures().is_empty(),
4735 "an ordinary write discloses nothing"
4736 );
4737 }
4738
4739 #[test]
4740 fn t010_legacy_fixture_reports_the_retired_key_and_yields_no_edges() {
4741 let original =
4744 std::fs::read_to_string(relationship_fixture("legacy-links-key.md")).unwrap();
4745 let issue = Issue::parse(&original).unwrap();
4746 assert!(issue.has_retired_links_key, "the retired key is reported");
4747 assert!(
4748 issue.relationships.is_empty(),
4749 "C-001: legacy values are never reinterpreted as typed edges"
4750 );
4751 assert!(
4752 issue.history.is_empty(),
4753 "a legacy file gains no History section from parsing"
4754 );
4755 let (_tmp, store) = make_store();
4759 write_issue(&store, "2026-01-01-legacy.md", &original);
4760 let findings = crate::commands::lint::collect(&store.config, &store).unwrap();
4761 let retired: Vec<_> = findings
4762 .iter()
4763 .filter(|f| f.kind == "retired_links_key")
4764 .collect();
4765 assert_eq!(
4766 retired.len(),
4767 1,
4768 "one finding for the retired key: {findings:?}"
4769 );
4770 assert_eq!(retired[0].value, "links", "the finding names the field");
4771 assert!(
4772 retired[0].message.contains("retired"),
4773 "the message says why: {}",
4774 retired[0].message
4775 );
4776 assert!(
4777 retired[0].line > 0,
4778 "the finding points into the frontmatter"
4779 );
4780 let rewritten = issue.to_file_contents();
4782 assert!(!rewritten.contains("kind: related"));
4783 assert!(!rewritten.contains("kind: blocks"));
4784 assert!(!rewritten.contains("links:"));
4785 }
4786
4787 #[test]
4788 fn c001_a_legacy_links_key_is_reported_and_yields_no_typed_edges() {
4789 let (tmp, store) = make_store();
4790 write_issue(
4791 &store,
4792 "2026-01-01-legacy.md",
4793 &legacy_links_frontmatter(1, "Legacy", "[\"12\", \"47\"]"),
4794 );
4795 let _ = tmp;
4796 let issue = store.get(IssueSelector::Number(1)).unwrap();
4797 assert!(
4798 issue.has_retired_links_key,
4799 "the retired key is recorded as a validation result"
4800 );
4801 assert!(
4802 issue.relationships.is_empty(),
4803 "C-001: legacy values are never read as typed edges"
4804 );
4805 }
4806
4807 #[test]
4808 fn c001_there_is_no_migration_path_from_the_retired_key() {
4809 let (tmp, store) = make_store();
4818 let _ = tmp;
4819 write_issue(
4820 &store,
4821 "2026-01-01-legacy.md",
4822 &legacy_links_frontmatter(1, "Legacy", "[\"12\", \"47\"]"),
4823 );
4824 let issue = store.get(IssueSelector::Number(1)).unwrap();
4825 assert!(
4826 issue.has_retired_links_key,
4827 "precondition: the key is present"
4828 );
4829 assert!(
4830 issue.relationships.is_empty(),
4831 "C-001: legacy values are never read as typed edges"
4832 );
4833
4834 store.update(&issue.filename, &issue).unwrap();
4835 let written = read_issue_file(&store, &issue.filename);
4836
4837 let reparsed = store.get(IssueSelector::Number(1)).unwrap();
4846 assert!(
4847 reparsed.relationships.is_empty(),
4848 "the file on disk parses to zero typed edges, not just the in-memory copy"
4849 );
4850 for kind in ["blocks", "blocked_by", "duplicates", "related"] {
4851 assert!(
4852 !written.contains(&format!("kind: {kind}")),
4853 "a legacy link became a `{kind}` edge:\n{written}"
4854 );
4855 }
4856 assert!(
4857 !written.contains("kind:"),
4858 "no typed edge was written at all"
4859 );
4860 assert!(!written.contains("links:"), "the key is not re-emitted");
4863 let reread = store.get(IssueSelector::Number(1)).unwrap();
4864 assert!(
4865 reread.history.is_empty(),
4866 "a format change is not a relationship mutation, so it records no event"
4867 );
4868 }
4869
4870 #[test]
4871 fn f15_the_idempotent_no_op_reports_the_real_addressable_row() {
4872 let (_tmp, store, a, b) = related_pair();
4878 let args = (a.number.to_string(), b.number.to_string());
4879 let first = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4880 let second = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4881
4882 assert!(first.changed);
4883 assert!(!second.changed, "the second apply is a no-op");
4884 assert_eq!(
4885 second.relationship.id, first.relationship.id,
4886 "the no-op reports the real, addressable row"
4887 );
4888 assert!(
4889 !second.relationship.id.is_empty(),
4890 "an empty id is not addressable"
4891 );
4892 assert_eq!(second.relationship.display, first.relationship.display);
4893 }
4894
4895 #[test]
4896 fn f16_clearing_a_set_bumps_updated_on_the_named_issue() {
4897 let (_tmp, store, _a, _b) = related_pair();
4902 store.link("2", "1", RelationKind::Related).unwrap();
4903 let before = store.get(IssueSelector::Number(2)).unwrap();
4904 let updated_before = before.updated.clone();
4905
4906 store.set_relationships(&before.filename, &[]).unwrap();
4907
4908 let after = store.get(IssueSelector::Number(2)).unwrap();
4909 assert_ne!(
4910 after.updated, updated_before,
4911 "the named issue's relationship set changed, so `updated` must move"
4912 );
4913 }
4914
4915 #[test]
4916 fn c002_an_issue_with_no_relationship_data_round_trips_unchanged() {
4917 let (tmp, store) = make_store();
4918 let _ = tmp;
4919 write_issue(&store, "2026-01-01-plain.md", &bare_frontmatter(1, "Plain"));
4924 let issue = store.get(IssueSelector::Number(1)).unwrap();
4925 let first = issue.to_file_contents();
4926 assert!(
4927 !first.contains("relationships:"),
4928 "the empty typed field is omitted, not written as `[]`"
4929 );
4930 assert!(
4931 !first.contains("## History"),
4932 "an empty History section is omitted entirely"
4933 );
4934 let reparsed = Issue::parse(&first).unwrap();
4937 let second = reparsed.to_file_contents();
4938 assert_eq!(second, first);
4939 }
4940
4941 #[test]
4942 fn c002_history_section_round_trips_byte_stable() {
4943 let (tmp, store) = make_store();
4944 write_issue(&store, "2026-01-01-h.md", &bare_frontmatter(1, "H"));
4945 let _ = tmp;
4946 let mut issue = store.get(IssueSelector::Number(1)).unwrap();
4947 issue.history.append_now(
4948 "alice",
4949 HistoryEventKind::RelationshipAdded,
4950 "created blocks: blocks #2 B",
4951 );
4952 issue.history.append_now(
4953 "bob",
4954 HistoryEventKind::BlockedMoveAcknowledged,
4955 "acknowledged 1 open blocker",
4956 );
4957 let first = issue.to_file_contents();
4958 assert!(first.contains("## History"), "{first}");
4959
4960 let reparsed = Issue::parse(&first).unwrap();
4962 assert_eq!(reparsed.to_file_contents(), first);
4963 assert_eq!(reparsed.history.len(), 2);
4964 assert_eq!(reparsed.history.events()[0].actor, "alice");
4965 assert_eq!(
4966 reparsed.history.events()[1].kind,
4967 HistoryEventKind::BlockedMoveAcknowledged
4968 );
4969 assert!(
4970 !reparsed.body.contains("History"),
4971 "the section is stripped from the body: {:?}",
4972 reparsed.body
4973 );
4974 }
4975
4976 #[test]
4977 fn f1_a_hand_edit_last_in_the_section_stays_in_it() {
4978 let (tmp, store) = make_store();
4997 let _ = tmp;
4998 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";
4999 write_issue(&store, "2026-01-01-a.md", original);
5000
5001 let before = store.get(IssueSelector::Number(1)).unwrap();
5004 assert_eq!(
5005 before.history.events().len(),
5006 1,
5007 "precondition: the real event parsed"
5008 );
5009 assert_eq!(
5010 before.history.unparsed(),
5011 vec![" - my hand edit"],
5012 "precondition: the hand edit is inside the section"
5013 );
5014 assert_eq!(before.body, "", "precondition: body is empty");
5015
5016 let mut moved = before.clone();
5018 moved.status = crate::model::Status::new("in progress");
5019 store.update(&moved.filename, &moved).unwrap();
5020
5021 let after = store.get(IssueSelector::Number(1)).unwrap();
5022 assert_eq!(
5023 after.history.unparsed(),
5024 vec![" - my hand edit"],
5025 "the hand edit is still in the audit log, not demoted to the body"
5026 );
5027 assert_eq!(
5028 after.history.events().len(),
5029 1,
5030 "and the real event survived"
5031 );
5032 assert_eq!(after.body, "", "nothing leaked into the body");
5033
5034 store
5037 .link("1", "2", RelationKind::Related)
5038 .ok()
5039 .or_else(|| {
5040 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
5041 store.link("1", "2", RelationKind::Related).ok()
5042 });
5043 let linked = store.get(IssueSelector::Number(1)).unwrap();
5044 let rendered = linked.to_file_contents();
5045 assert!(
5046 rendered.contains("## History"),
5047 "the heading survived an unrelated write: {rendered}"
5048 );
5049 let hand = rendered
5050 .find("my hand edit")
5051 .expect("the hand edit is present");
5052 let newest = rendered
5053 .find("relationship_added")
5054 .expect("the new event is present");
5055 assert!(
5056 newest < hand,
5057 "the new event is appended AFTER the pre-existing content, so file \
5058 order is still append order: {rendered}"
5059 );
5060 }
5061
5062 #[test]
5063 fn f1_but_a_bare_paragraph_at_end_of_section_is_still_prose() {
5064 let (tmp, store) = make_store();
5069 let _ = tmp;
5070 write_issue(
5071 &store,
5072 "2026-01-01-a.md",
5073 "---\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",
5074 );
5075 let issue = store.get(IssueSelector::Number(1)).unwrap();
5076 assert_eq!(issue.history.events().len(), 1, "the real event parsed");
5077 assert!(
5078 issue.history.unparsed().is_empty(),
5079 "the paragraph is not in the audit log: {:?}",
5080 issue.history.unparsed()
5081 );
5082 assert!(
5083 issue.body.contains("just a closing paragraph"),
5084 "it is body prose: {:?}",
5085 issue.body
5086 );
5087 }
5088
5089 #[test]
5090 fn f1_a_hand_edit_survives_body_prose_and_a_later_body_replacement() {
5091 let (_tmp, store) = make_store();
5106 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
5107 let hand = "- 2026-02-02T00:00:00Z - bob - relationship_removed - by hand";
5108 write_issue(
5109 &store,
5110 "2026-01-01-a.md",
5111 &format!(
5112 "---\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"
5113 ),
5114 );
5115
5116 let before = store.get(IssueSelector::Number(1)).unwrap();
5117 assert_eq!(before.history.events().len(), 1, "the real event parsed");
5118 assert_eq!(
5119 before.history.unparsed(),
5120 vec![hand],
5121 "the hand-edited entry is retained even though body prose follows"
5122 );
5123 assert!(
5124 before.body.contains("Some body prose."),
5125 "and the prose is still body text: {:?}",
5126 before.body
5127 );
5128
5129 let mut moved = before.clone();
5131 moved.status = crate::model::Status::new("in progress");
5132 store.update(&moved.filename, &moved).unwrap();
5133 assert_eq!(
5134 store
5135 .get(IssueSelector::Number(1))
5136 .unwrap()
5137 .history
5138 .unparsed(),
5139 vec![hand],
5140 "a `tix move` keeps it in the audit log"
5141 );
5142
5143 let mut replaced = store.get(IssueSelector::Number(1)).unwrap();
5146 replaced.body = "Completely new body text.".to_string();
5147 store.update(&replaced.filename, &replaced).unwrap();
5148 let after = store.get(IssueSelector::Number(1)).unwrap();
5149 assert_eq!(
5150 after.history.unparsed(),
5151 vec![hand],
5152 "the hand-edited entry survives a wholesale body replacement"
5153 );
5154 assert_eq!(after.body, "Completely new body text.");
5155
5156 store.link("1", "2", RelationKind::Related).unwrap();
5158 let rendered = store
5159 .get(IssueSelector::Number(1))
5160 .unwrap()
5161 .to_file_contents();
5162 assert!(
5163 rendered.contains("## History"),
5164 "the heading survived: {rendered}"
5165 );
5166 let hand_at = rendered.find("by hand").expect("the hand edit is present");
5167 let new_at = rendered
5168 .find("created rel_")
5169 .expect("the new event is present");
5170 assert!(
5171 new_at > hand_at,
5172 "the new event is appended AFTER the pre-existing content, so file \
5173 order is still append order: {rendered}"
5174 );
5175 }
5176
5177 #[test]
5178 fn f1_a_well_formed_star_marked_entry_is_retained_with_body_prose_after_it() {
5179 let (_tmp, store) = make_store();
5189 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
5190 let hand =
5191 "* 2026-02-02T00:00:00Z \u{2014} bob \u{2014} relationship_removed \u{2014} by hand";
5192 write_issue(
5193 &store,
5194 "2026-01-01-a.md",
5195 &format!(
5196 "---\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"
5197 ),
5198 );
5199
5200 let before = store.get(IssueSelector::Number(1)).unwrap();
5201 assert_eq!(before.history.events().len(), 1, "the real event parsed");
5202 assert_eq!(
5203 before.history.unparsed(),
5204 vec![hand],
5205 "a well-formed `*` entry is retained even with body prose after it"
5206 );
5207 assert!(
5208 before.body.contains("Some body prose."),
5209 "and the prose is still body text: {:?}",
5210 before.body
5211 );
5212
5213 let mut replaced = before.clone();
5215 replaced.body = "Completely new body text.".to_string();
5216 store.update(&replaced.filename, &replaced).unwrap();
5217 assert_eq!(
5218 store
5219 .get(IssueSelector::Number(1))
5220 .unwrap()
5221 .history
5222 .unparsed(),
5223 vec![hand],
5224 "it survives a wholesale body replacement"
5225 );
5226
5227 store.link("1", "2", RelationKind::Related).unwrap();
5228 let rendered = store
5229 .get(IssueSelector::Number(1))
5230 .unwrap()
5231 .to_file_contents();
5232 assert_eq!(
5233 rendered.matches("## History").count(),
5234 1,
5235 "still exactly one section: {rendered}"
5236 );
5237 let hand_at = rendered.find("by hand").expect("the hand edit is present");
5238 let new_at = rendered
5239 .find("created rel_")
5240 .expect("the new event is present");
5241 assert!(
5242 new_at > hand_at,
5243 "appended after, so order is append order: {rendered}"
5244 );
5245 }
5246
5247 #[test]
5248 fn f1_a_three_field_ascii_hyphen_entry_is_retained_with_body_prose_after_it() {
5249 let (_tmp, store) = make_store();
5255 let hand = "- 2026-02-02T00:00:00Z - bob - relationship_removed";
5256 write_issue(
5257 &store,
5258 "2026-01-01-a.md",
5259 &format!(
5260 "---\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"
5261 ),
5262 );
5263
5264 let twin = Issue::parse(
5267 "---\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",
5268 )
5269 .unwrap();
5270 assert_eq!(
5271 twin.history.events().len(),
5272 1,
5273 "three fields with em-dashes is a valid event, detail optional"
5274 );
5275
5276 let before = store.get(IssueSelector::Number(1)).unwrap();
5277 assert_eq!(
5278 before.history.unparsed(),
5279 vec![hand],
5280 "so its ASCII-hyphen twin is retained, not demoted"
5281 );
5282 let mut replaced = before.clone();
5283 replaced.body = "Completely new body text.".to_string();
5284 store.update(&replaced.filename, &replaced).unwrap();
5285 assert_eq!(
5286 store
5287 .get(IssueSelector::Number(1))
5288 .unwrap()
5289 .history
5290 .unparsed(),
5291 vec![hand],
5292 "and survives a body replacement"
5293 );
5294 }
5295
5296 #[test]
5297 fn f1_a_heading_with_trailing_spaces_is_still_the_heading() {
5298 let (_tmp, store) = make_store();
5304 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
5305 for (label, heading) in [
5306 ("plain", "## History"),
5307 ("trailing spaces", "## History "),
5308 ] {
5309 write_issue(
5310 &store,
5311 "2026-01-01-a.md",
5312 &format!(
5313 "---\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"
5314 ),
5315 );
5316 let before = store.get(IssueSelector::Number(1)).unwrap();
5317 assert_eq!(
5318 before.history.events().len(),
5319 1,
5320 "{label}: the section is recognised"
5321 );
5322 store.link("1", "2", RelationKind::Related).unwrap();
5323 let rendered = store
5324 .get(IssueSelector::Number(1))
5325 .unwrap()
5326 .to_file_contents();
5327 assert_eq!(
5328 rendered.matches("## History").count(),
5329 1,
5330 "{label}: exactly one section after a write, got {rendered}"
5331 );
5332 assert!(
5333 !rendered.lines().any(|l| l != l.trim_end()),
5334 "{label}: no line keeps trailing whitespace, so the heading is \
5335 normalised: {rendered}"
5336 );
5337 }
5338 }
5339
5340 #[test]
5341 fn set_status_discloses_the_retired_key_rather_than_dropping_it_silently() {
5342 let (_tmp, store) = make_store();
5349 write_issue(
5350 &store,
5351 "2026-01-01-a.md",
5352 "---\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",
5353 );
5354 let before = store.get(IssueSelector::Number(1)).unwrap();
5355 assert!(
5356 before.has_retired_links_key,
5357 "precondition: the file carries the retired key"
5358 );
5359 let _ = crate::storage::take_disclosures();
5360
5361 store
5362 .set_status("2026-01-01-a.md", StatusChange::to("in progress"))
5363 .unwrap();
5364
5365 let disclosures = crate::storage::take_disclosures();
5366 assert_eq!(
5367 disclosures.len(),
5368 1,
5369 "set_status must disclose the removal, got {disclosures:?}"
5370 );
5371 assert!(
5372 disclosures[0].contains("links:") && disclosures[0].contains("a.md"),
5373 "naming the field and the file: {disclosures:?}"
5374 );
5375 assert!(
5376 !store
5377 .get(IssueSelector::Number(1))
5378 .unwrap()
5379 .has_retired_links_key,
5380 "and the key is gone, as C-001 intends"
5381 );
5382 }
5383
5384 #[test]
5385 fn f1_but_ordinary_prose_bullets_still_end_the_section() {
5386 let (_tmp, store) = make_store();
5391 write_issue(
5392 &store,
5393 "2026-01-01-a.md",
5394 concat!(
5395 "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\n",
5396 "created: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n",
5397 "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n\n",
5398 "- step one\n- step two\n",
5399 ),
5400 );
5401 let issue = store.get(IssueSelector::Number(1)).unwrap();
5402 assert_eq!(
5403 issue.history.events().len(),
5404 1,
5405 "the real event is an event"
5406 );
5407 assert!(
5408 issue.history.unparsed().is_empty(),
5409 "ordinary bullets are not retained: {:?}",
5410 issue.history.unparsed()
5411 );
5412 assert!(
5413 issue.body.contains("step one") && issue.body.contains("step two"),
5414 "they stay body text: {:?}",
5415 issue.body
5416 );
5417 }
5418
5419 #[test]
5420 fn a_year_is_not_an_ordered_list_marker() {
5421 for prose in [
5425 "2026. Q1 goals",
5426 "2026. roadmap review",
5427 "007. padded",
5428 "99999999999999999999. big",
5429 ] {
5430 assert!(
5431 !crate::model::looks_like_an_unrecognised_entry(prose)
5432 && Issue::parse(&format!(
5433 "---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{prose}\n"
5434 ))
5435 .unwrap()
5436 .history
5437 .is_empty(),
5438 "{prose:?} must be body prose, not a retained entry"
5439 );
5440 }
5441 }
5442
5443 #[test]
5444 fn f2_a_recognised_entry_with_a_bad_kind_keeps_its_trailing_whitespace() {
5445 let line = " - 2026-01-01T00:00:00Z \u{2014} alice \u{2014} bogus_kind \u{2014} d ";
5452 let raw = format!(
5453 "---\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"
5454 );
5455 let issue = Issue::parse(&raw).unwrap();
5456 assert!(issue.history.events().is_empty(), "no event was fabricated");
5457 assert_eq!(
5458 issue.history.unparsed(),
5459 vec![line],
5460 "kept byte-for-byte, trailing spaces included"
5461 );
5462
5463 let rendered = issue.to_file_contents();
5465 assert!(
5466 rendered.contains(&format!("{line}\n")),
5467 "re-emitted exactly, so an unrelated `tix edit` does not rewrite the \
5468 author's audit log: {rendered}"
5469 );
5470 assert_eq!(
5471 Issue::parse(&rendered).unwrap().to_file_contents(),
5472 rendered,
5473 "and the file is byte-stable from here"
5474 );
5475 }
5476
5477 #[test]
5478 fn c002_history_is_append_only() {
5479 let (tmp, store) = make_store();
5480 write_issue(&store, "2026-01-01-h.md", &bare_frontmatter(1, "H"));
5481 let _ = tmp;
5482 let mut issue = store.get(IssueSelector::Number(1)).unwrap();
5483 issue
5484 .history
5485 .append_now("alice", HistoryEventKind::RelationshipAdded, "first");
5486 let after_first = issue.history.clone();
5487 issue
5488 .history
5489 .append_now("alice", HistoryEventKind::RelationshipRemoved, "second");
5490
5491 assert_eq!(issue.history.len(), 2, "appending never replaces");
5492 assert_eq!(
5493 &issue.history.events()[..1],
5494 after_first.events(),
5495 "the earlier entry is untouched and still first"
5496 );
5497 }
5498
5499 #[test]
5500 fn c002_an_unknown_history_kind_is_kept_verbatim_not_coerced() {
5501 let line = "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} bogus_kind \u{2014} something";
5512 let raw = format!(
5513 "---\nnumber: 1\ntitle: \"H\"\nstatus: \"todo\"\n---\n\n## History\n\n{line}\n"
5514 );
5515 let issue = Issue::parse(&raw).expect("an unknown kind must not fail the parse");
5516
5517 assert!(
5518 issue.history.events().is_empty(),
5519 "no event was fabricated for an unknown kind"
5520 );
5521 assert_eq!(
5522 issue.history.unparsed(),
5523 vec![line],
5524 "the line is kept verbatim: not dropped, not repaired"
5525 );
5526 let rendered = issue.to_file_contents();
5530 assert!(rendered.contains(line), "the line survives the write");
5531 assert_eq!(
5532 Issue::parse(&rendered).unwrap().to_file_contents(),
5533 rendered,
5534 "and the file round-trips byte-identically"
5535 );
5536 }
5537
5538 #[test]
5539 fn list_mixes_grouped_and_flat_issues_sorted_by_relative_filename() {
5540 let (tmp, store) = make_store();
5541 let _ = tmp;
5542 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5543 fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
5544 write_issue(
5545 &store,
5546 "alice/2026-01-01-a.md",
5547 &sample_frontmatter(1, "A", "todo"),
5548 );
5549 write_issue(
5550 &store,
5551 "bob/2026-05-10-b.md",
5552 &sample_frontmatter(2, "B", "todo"),
5553 );
5554 write_issue(
5555 &store,
5556 "2026-02-01-c.md",
5557 &sample_frontmatter(3, "C", "todo"),
5558 );
5559 let issues = store.list().unwrap();
5560 let filenames: Vec<&str> = issues.iter().map(|i| i.filename.as_str()).collect();
5561 assert_eq!(issues.len(), 3);
5562 assert_eq!(
5563 filenames,
5564 vec![
5565 "2026-02-01-c.md",
5566 "alice/2026-01-01-a.md",
5567 "bob/2026-05-10-b.md"
5568 ],
5569 "deterministic sort by relative filename"
5570 );
5571 }
5572
5573 #[test]
5574 fn list_sorts_chronologically_within_a_group() {
5575 let (tmp, store) = make_store();
5576 let _ = tmp;
5577 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5578 write_issue(
5579 &store,
5580 "alice/2026-03-01-c.md",
5581 &sample_frontmatter(3, "C", "todo"),
5582 );
5583 write_issue(
5584 &store,
5585 "alice/2026-01-01-a.md",
5586 &sample_frontmatter(1, "A", "todo"),
5587 );
5588 write_issue(
5589 &store,
5590 "alice/2026-02-01-b.md",
5591 &sample_frontmatter(2, "B", "todo"),
5592 );
5593 let issues = store.list().unwrap();
5594 let numbers: Vec<u32> = issues.iter().map(|i| i.number).collect();
5595 assert_eq!(numbers, vec![1, 2, 3]);
5596 }
5597
5598 #[test]
5599 fn list_ignores_non_md_files_in_segments() {
5600 let (tmp, store) = make_store();
5601 let _ = tmp;
5602 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5603 write_issue(
5604 &store,
5605 "alice/2026-01-01-a.md",
5606 &sample_frontmatter(1, "A", "todo"),
5607 );
5608 fs::write(store.issues_dir.join("alice/notes.txt"), "not an issue").unwrap();
5609 fs::write(store.issues_dir.join("alice/.tix-tmp-123-456"), "orphan").unwrap();
5610 let issues = store.list().unwrap();
5611 assert_eq!(issues.len(), 1);
5612 assert_eq!(issues[0].filename, "alice/2026-01-01-a.md");
5613 }
5614
5615 #[test]
5616 fn list_skips_temp_marker_directories() {
5617 let (tmp, store) = make_store();
5618 let _ = tmp;
5619 fs::create_dir_all(store.issues_dir.join(".tix-tmp-999-0")).unwrap();
5620 write_issue(
5621 &store,
5622 ".tix-tmp-999-0/2026-01-01-junk.md",
5623 &sample_frontmatter(9, "J", "todo"),
5624 );
5625 write_issue(
5626 &store,
5627 "2026-01-01-a.md",
5628 &sample_frontmatter(1, "A", "todo"),
5629 );
5630 let issues = store.list().unwrap();
5631 assert_eq!(issues.len(), 1);
5632 assert_eq!(issues[0].filename, "2026-01-01-a.md");
5633 }
5634
5635 #[test]
5636 fn list_parse_error_carries_grouped_relative_path() {
5637 let (tmp, store) = make_store();
5638 let _ = tmp;
5639 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5640 fs::write(
5641 store.issues_dir.join("alice/2026-01-01-bad.md"),
5642 "no frontmatter here\n",
5643 )
5644 .unwrap();
5645 let err = store.list().unwrap_err();
5646 assert!(
5647 matches!(&err, TixError::Parse(m) if m.starts_with("alice/2026-01-01-bad.md:")),
5648 "parse error must carry the relative path, got {err:?}"
5649 );
5650 }
5651
5652 #[test]
5657 fn create_writes_into_assignee_segment_and_records_relative_filename() {
5658 let (tmp, store) = make_store();
5659 let _ = tmp;
5660 let mut issue = Issue {
5661 number: 0,
5662 title: "T".into(),
5663 status: Status::default(),
5664 labels: vec![],
5665 priority: Priority::default(),
5666 assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
5667 estimate: None,
5668 commit: None,
5669 gitlab_id: None,
5670 linear_id: None,
5671 mission: None,
5672 task: None,
5673 relationships: vec![],
5674 files: vec![],
5675 cycles: None,
5676 milestones: None,
5677 reviewer: None,
5678 start_date: None,
5679 due_date: None,
5680 created: "2026-01-01".into(),
5681 updated: "2026-01-01".into(),
5682 filename: String::new(),
5683 body: String::new(),
5684 history: crate::model::History::new(),
5685 has_retired_links_key: false,
5686 parent: None,
5687 extra_frontmatter: std::collections::BTreeMap::new(),
5688 archived: false,
5689 entered_at: None,
5690 source: None,
5691 };
5692 let path = store
5693 .create(&mut issue, "body", Some("custom-stem"))
5694 .unwrap();
5695 let rel = "herbert-maschke/custom-stem.md";
5696 assert_eq!(issue.filename, rel);
5697 assert_eq!(path, store.issues_dir.join(rel));
5698 assert!(store.issues_dir.join("herbert-maschke").is_dir());
5699 assert!(store.issues_dir.join(rel).is_file());
5700 }
5701
5702 #[test]
5703 fn create_without_assignee_writes_unassigned_segment() {
5704 let (tmp, store) = make_store();
5705 let _ = tmp;
5706 let mut issue = Issue {
5707 number: 0,
5708 title: "T".into(),
5709 status: Status::default(),
5710 labels: vec![],
5711 priority: Priority::default(),
5712 assignee: None,
5713 estimate: None,
5714 commit: None,
5715 gitlab_id: None,
5716 linear_id: None,
5717 mission: None,
5718 task: None,
5719 relationships: vec![],
5720 files: vec![],
5721 cycles: None,
5722 milestones: None,
5723 reviewer: None,
5724 start_date: None,
5725 due_date: None,
5726 created: "2026-01-01".into(),
5727 updated: "2026-01-01".into(),
5728 filename: String::new(),
5729 body: String::new(),
5730 history: crate::model::History::new(),
5731 has_retired_links_key: false,
5732 parent: None,
5733 extra_frontmatter: std::collections::BTreeMap::new(),
5734 archived: false,
5735 entered_at: None,
5736 source: None,
5737 };
5738 let path = store.create(&mut issue, "", Some("stem")).unwrap();
5739 assert_eq!(issue.filename, "unassigned/stem.md");
5740 assert!(store.issues_dir.join("unassigned").is_dir());
5741 assert!(store.issues_dir.join("unassigned/stem.md").is_file());
5742 assert_eq!(path, store.issues_dir.join("unassigned/stem.md"));
5743 }
5744
5745 #[test]
5746 fn create_with_at_handle_derives_bare_segment() {
5747 let (tmp, store) = make_store();
5748 let _ = tmp;
5749 let mut issue = Issue {
5750 number: 0,
5751 title: "T".into(),
5752 status: Status::default(),
5753 labels: vec![],
5754 priority: Priority::default(),
5755 assignee: Some("@alice".into()),
5756 estimate: None,
5757 commit: None,
5758 gitlab_id: None,
5759 linear_id: None,
5760 mission: None,
5761 task: None,
5762 relationships: vec![],
5763 files: vec![],
5764 cycles: None,
5765 milestones: None,
5766 reviewer: None,
5767 start_date: None,
5768 due_date: None,
5769 created: "2026-01-01".into(),
5770 updated: "2026-01-01".into(),
5771 filename: String::new(),
5772 body: String::new(),
5773 history: crate::model::History::new(),
5774 has_retired_links_key: false,
5775 parent: None,
5776 extra_frontmatter: std::collections::BTreeMap::new(),
5777 archived: false,
5778 entered_at: None,
5779 source: None,
5780 };
5781 store.create(&mut issue, "", Some("stem")).unwrap();
5782 assert_eq!(issue.filename, "alice/stem.md");
5783 assert!(store.issues_dir.join("alice/stem.md").is_file());
5784 }
5785
5786 #[test]
5791 fn reassign_moves_file_between_segments() {
5792 let (tmp, store) = make_store();
5793 let _ = tmp;
5794 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5795 write_issue(
5796 &store,
5797 "alice/2026-01-01-x.md",
5798 &sample_frontmatter(1, "X", "todo"),
5799 );
5800 let new_path = store
5801 .reassign("alice/2026-01-01-x.md", Some("@bob"))
5802 .unwrap();
5803 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x.md"));
5804 assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
5805 assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
5806 }
5807
5808 #[test]
5809 fn reassign_into_occupied_segment_picks_deterministic_suffix() {
5810 let (tmp, store) = make_store();
5811 let _ = tmp;
5812 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5813 fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
5814 write_issue(
5815 &store,
5816 "alice/2026-01-01-x.md",
5817 &sample_frontmatter(1, "X", "todo"),
5818 );
5819 write_issue(
5820 &store,
5821 "bob/2026-01-01-x.md",
5822 &sample_frontmatter(2, "X", "todo"),
5823 );
5824 let new_path = store
5825 .reassign("alice/2026-01-01-x.md", Some("@bob"))
5826 .unwrap();
5827 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x-1.md"));
5828 assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
5829 assert!(store.issues_dir.join("bob/2026-01-01-x-1.md").is_file());
5830 assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
5831 }
5832
5833 #[test]
5834 fn reassign_legacy_flat_file_into_segment() {
5835 let (tmp, store) = make_store();
5836 let _ = tmp;
5837 write_issue(
5838 &store,
5839 "2026-01-01-y.md",
5840 &sample_frontmatter(1, "Y", "todo"),
5841 );
5842 let new_path = store.reassign("2026-01-01-y.md", Some("alice")).unwrap();
5843 assert_eq!(new_path, PathBuf::from("alice/2026-01-01-y.md"));
5844 assert!(store.issues_dir.join("alice/2026-01-01-y.md").is_file());
5845 assert!(!store.issues_dir.join("2026-01-01-y.md").exists());
5846 }
5847
5848 #[test]
5849 fn reassign_to_none_uses_unassigned_fallback() {
5850 let (tmp, store) = make_store();
5851 let _ = tmp;
5852 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5853 write_issue(
5854 &store,
5855 "alice/2026-01-01-z.md",
5856 &sample_frontmatter(1, "Z", "todo"),
5857 );
5858 let new_path = store.reassign("alice/2026-01-01-z.md", None).unwrap();
5859 assert_eq!(new_path, PathBuf::from("unassigned/2026-01-01-z.md"));
5860 assert!(
5861 store
5862 .issues_dir
5863 .join("unassigned/2026-01-01-z.md")
5864 .is_file()
5865 );
5866 assert!(!store.issues_dir.join("alice/2026-01-01-z.md").exists());
5867 }
5868
5869 #[test]
5870 fn reassign_keeps_number_and_created_stable() {
5871 let (tmp, store) = make_store();
5872 let _ = tmp;
5873 let mut issue = Issue {
5874 number: 0,
5875 title: "Stable".into(),
5876 status: Status::default(),
5877 labels: vec![],
5878 priority: Priority::default(),
5879 assignee: Some("alice".into()),
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 store.create(&mut issue, "body", None).unwrap();
5906 let number = issue.number;
5907 let created = issue.created.clone();
5908 let before = issue.filename.clone();
5909 let new_path = store.reassign(&before, Some("@bob")).unwrap();
5910 let rescan = store.list().unwrap();
5911 let moved = rescan
5912 .iter()
5913 .find(|i| i.filename == new_path)
5914 .expect("moved file must be listed at its new relative path");
5915 assert_eq!(moved.number, number);
5916 assert_eq!(moved.created, created);
5917 assert_eq!(moved.title, "Stable");
5918 }
5919
5920 #[test]
5921 fn reassign_equal_segment_is_noop() {
5922 let (tmp, store) = make_store();
5923 let _ = tmp;
5924 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5925 write_issue(
5926 &store,
5927 "alice/2026-01-01-x.md",
5928 &sample_frontmatter(1, "X", "todo"),
5929 );
5930 let new_path = store
5931 .reassign("alice/2026-01-01-x.md", Some("alice"))
5932 .unwrap();
5933 assert_eq!(new_path, PathBuf::from("alice/2026-01-01-x.md"));
5934 assert!(store.issues_dir.join("alice/2026-01-01-x.md").is_file());
5935 }
5936
5937 #[test]
5938 fn reassign_missing_file_is_not_found() {
5939 let (tmp, store) = make_store();
5940 let _ = tmp;
5941 let err = store.reassign("nope.md", Some("alice")).unwrap_err();
5942 assert!(matches!(err, TixError::NotFound(_)));
5943 }
5944
5945 #[test]
5950 fn validate_filename_allows_flat_and_single_segment_prefix() {
5951 validate_filename("2026-01-01-x.md").unwrap();
5952 validate_filename("alice/2026-01-01-x.md").unwrap();
5953 }
5954
5955 #[test]
5956 fn validate_filename_refuses_deeper_segments() {
5957 let err = validate_filename("alice/bob/2026-01-01-x.md").unwrap_err();
5958 assert!(matches!(err, TixError::InvalidInput(_)));
5959 }
5960
5961 #[test]
5962 fn validate_filename_refuses_leading_and_trailing_slash_and_absolute() {
5963 let err = validate_filename("/alice/2026-01-01-x.md").unwrap_err();
5964 assert!(matches!(err, TixError::InvalidInput(_)));
5965 let err = validate_filename("/etc/passwd").unwrap_err();
5966 assert!(matches!(err, TixError::InvalidInput(_)));
5967 let err = validate_filename("alice/").unwrap_err();
5968 assert!(matches!(err, TixError::InvalidInput(_)));
5969 }
5970
5971 #[test]
5972 fn path_traversal_with_segment_dotdot_refused() {
5973 let err = validate_filename("alice/../../x.md").unwrap_err();
5974 assert!(matches!(err, TixError::InvalidInput(_)));
5975 let err = validate_filename("alice/../x.md").unwrap_err();
5976 assert!(matches!(err, TixError::InvalidInput(_)));
5977 }
5978
5979 #[test]
5980 fn get_update_delete_set_status_work_on_grouped_relative_paths() {
5981 let (tmp, store) = make_store();
5982 let _ = tmp;
5983 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
5984 write_issue(
5985 &store,
5986 "alice/2026-01-01-g.md",
5987 &sample_frontmatter(7, "G", "todo"),
5988 );
5989 let got = store
5990 .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
5991 .unwrap();
5992 assert_eq!(got.number, 7);
5993 let done = store
5994 .set_status("alice/2026-01-01-g.md", StatusChange::to("done"))
5995 .unwrap();
5996 assert_eq!(done.issue.status.display(), "done");
5997 assert!(store.issues_dir.join("alice/2026-01-01-g.md").is_file());
5998 let mut edited = store
5999 .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
6000 .unwrap();
6001 edited.title = "Updated".into();
6002 store.update("alice/2026-01-01-g.md", &edited).unwrap();
6003 let reloaded = store
6004 .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
6005 .unwrap();
6006 assert_eq!(reloaded.title, "Updated");
6007 store.delete("alice/2026-01-01-g.md").unwrap();
6008 assert!(!store.issues_dir.join("alice/2026-01-01-g.md").exists());
6009 }
6010
6011 #[test]
6016 fn grouped_round_trip_create_list_get_update_delete() {
6017 let (tmp, store) = make_store();
6018 let _ = tmp;
6019 let mut issue = Issue {
6020 number: 0,
6021 title: "Round trip".into(),
6022 status: Status::default(),
6023 labels: vec!["bug".into()],
6024 priority: Priority::default(),
6025 assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
6026 estimate: None,
6027 commit: None,
6028 gitlab_id: None,
6029 linear_id: None,
6030 mission: None,
6031 task: None,
6032 relationships: vec![],
6033 files: vec![],
6034 cycles: None,
6035 milestones: None,
6036 reviewer: None,
6037 start_date: None,
6038 due_date: None,
6039 created: "2026-01-01".into(),
6040 updated: "2026-01-01".into(),
6041 filename: String::new(),
6042 body: String::new(),
6043 history: crate::model::History::new(),
6044 has_retired_links_key: false,
6045 parent: None,
6046 extra_frontmatter: std::collections::BTreeMap::new(),
6047 archived: false,
6048 entered_at: None,
6049 source: None,
6050 };
6051 store.create(&mut issue, "initial body", None).unwrap();
6052 let rel = issue.filename.clone();
6053 assert_eq!(rel, "herbert-maschke/2026-01-01-round-trip.md");
6054
6055 let listed = store.list().unwrap();
6056 assert_eq!(listed.len(), 1);
6057 assert_eq!(listed[0].filename, rel);
6058 assert_eq!(listed[0].number, issue.number);
6059
6060 let got = store.get(IssueSelector::Filename(rel.clone())).unwrap();
6061 let mut edited = got.clone();
6062 edited.title = "Renamed".into();
6063 store.update(&rel, &edited).unwrap();
6064 let reloaded = store.get(IssueSelector::Filename(rel.clone())).unwrap();
6065 assert_eq!(reloaded.title, "Renamed");
6066
6067 store.delete(&rel).unwrap();
6068 assert!(!store.issues_dir.join(&rel).exists());
6069 assert!(store.list().unwrap().is_empty());
6070 }
6071
6072 #[test]
6073 fn legacy_flat_file_edit_and_status_still_work_after_layout_flip() {
6074 let (tmp, store) = make_store();
6075 let _ = tmp;
6076 write_issue(
6077 &store,
6078 "2026-01-01-legacy.md",
6079 &sample_frontmatter(5, "Legacy", "todo"),
6080 );
6081 let listed = store.list().unwrap();
6082 assert_eq!(listed.len(), 1);
6083 assert_eq!(listed[0].filename, "2026-01-01-legacy.md");
6084 let mutated = store
6085 .set_status("2026-01-01-legacy.md", StatusChange::to("in progress"))
6086 .unwrap();
6087 assert_eq!(mutated.issue.status.display(), "in progress");
6088 assert!(store.issues_dir.join("2026-01-01-legacy.md").is_file());
6089 }
6090
6091 fn write_asset_source(tmp: &tempfile::TempDir, name: &str, contents: &[u8]) -> PathBuf {
6096 let path = tmp.path().join(name);
6097 fs::write(&path, contents).unwrap();
6098 path
6099 }
6100
6101 fn write_issue_with_basic_body(store: &Store, name: &str, number: u32) {
6102 write_issue(
6103 store,
6104 name,
6105 &sample_frontmatter(number, "Asset host", "todo"),
6106 );
6107 }
6108
6109 #[test]
6110 fn add_asset_image_inserts_image_link_and_writes_file() {
6111 let (tmp, store) = make_store();
6112 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6113 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6114 let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
6115 let added = store
6116 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6117 .unwrap();
6118 assert_eq!(added.written_name, "diagram.png");
6119 assert_eq!(added.link, "");
6120 assert!(
6121 store
6122 .issues_dir
6123 .join("alice/fix-login-bug-assets/diagram.png")
6124 .is_file()
6125 );
6126 }
6127
6128 #[test]
6129 fn add_asset_non_image_inserts_generic_markdown_link() {
6130 let (tmp, store) = make_store();
6131 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6132 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6133 let src = write_asset_source(&tmp, "report.pdf", b"%PDF");
6134 let added = store
6135 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6136 .unwrap();
6137 assert_eq!(added.link, "[report](fix-login-bug-assets/report.pdf)");
6138 assert!(
6139 store
6140 .issues_dir
6141 .join("alice/fix-login-bug-assets/report.pdf")
6142 .is_file()
6143 );
6144 }
6145
6146 #[test]
6147 fn add_asset_collision_resolves_with_numeric_suffix_and_link_points_at_it() {
6148 let (tmp, store) = make_store();
6149 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6150 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6151 let src = write_asset_source(&tmp, "x.png", b"first");
6152 let first = store
6153 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6154 .unwrap();
6155 assert_eq!(first.written_name, "x.png");
6156 let again = store
6157 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6158 .unwrap();
6159 assert_eq!(again.written_name, "x-1.png");
6160 assert_eq!(again.link, "");
6161 assert!(
6162 store
6163 .issues_dir
6164 .join("alice/fix-login-bug-assets/x.png")
6165 .is_file()
6166 );
6167 assert!(
6168 store
6169 .issues_dir
6170 .join("alice/fix-login-bug-assets/x-1.png")
6171 .is_file()
6172 );
6173 }
6174
6175 #[test]
6176 fn add_asset_appends_single_link_and_preserves_description() {
6177 let (tmp, store) = make_store();
6178 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6179 write_issue(
6180 &store,
6181 "alice/2026-01-01-fix-login-bug.md",
6182 "---\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",
6183 );
6184 let src = write_asset_source(&tmp, "diagram.svg", b"<svg/>");
6185 store
6186 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6187 .unwrap();
6188 let reloaded = store
6189 .get(crate::storage::IssueSelector::Filename(
6190 "alice/2026-01-01-fix-login-bug.md".into(),
6191 ))
6192 .unwrap();
6193 assert_eq!(reloaded.description(), "Title line.");
6195 let link_count = reloaded.body.matches("![diagram]").count();
6197 assert_eq!(link_count, 1);
6198 assert!(
6199 reloaded
6200 .body
6201 .ends_with("fix-login-bug-assets/diagram.svg)\n")
6202 );
6203 }
6204
6205 #[test]
6206 fn add_asset_missing_issue_is_not_found() {
6207 let (tmp, store) = make_store();
6208 let _ = tmp;
6209 let src = write_asset_source(&tmp, "x.png", b"x");
6210 let err = store.add_asset("missing.md", &src).unwrap_err();
6211 assert!(matches!(err, TixError::NotFound(_)));
6212 }
6213
6214 #[test]
6215 fn add_asset_legacy_flat_file_uses_stem_assets_under_issues_dir() {
6216 let (tmp, store) = make_store();
6217 write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
6218 let src = write_asset_source(&tmp, "x.png", b"x");
6219 let added = store.add_asset("2026-01-01-legacy.md", &src).unwrap();
6220 assert_eq!(added.link, "");
6221 assert!(store.issues_dir.join("legacy-assets/x.png").is_file());
6222 }
6223
6224 #[test]
6229 fn reassign_moves_asset_dir_with_the_file() {
6230 let (tmp, store) = make_store();
6231 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6232 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6233 let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
6234 store
6235 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6236 .unwrap();
6237 let new_path = store
6238 .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
6239 .unwrap();
6240 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
6241 assert!(
6242 store
6243 .issues_dir
6244 .join("bob/fix-login-bug-assets/diagram.png")
6245 .is_file()
6246 );
6247 assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
6248 }
6249
6250 #[test]
6251 fn reassign_without_asset_dir_behaves_as_before() {
6252 let (tmp, store) = make_store();
6253 let _ = tmp;
6254 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6255 write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
6256 let new_path = store
6257 .reassign("alice/2026-01-01-plain.md", Some("@bob"))
6258 .unwrap();
6259 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-plain.md"));
6260 assert!(store.issues_dir.join("bob/2026-01-01-plain.md").is_file());
6261 assert!(!store.issues_dir.join("bob/plain-assets").exists());
6262 }
6263
6264 #[test]
6265 fn reassign_into_segment_with_same_named_asset_dir_uses_suffix_no_loss() {
6266 let (tmp, store) = make_store();
6267 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6268 fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
6269 let existing = store.issues_dir.join("bob/fix-login-bug-assets");
6271 fs::create_dir_all(&existing).unwrap();
6272 fs::write(existing.join("keep.txt"), "precious").unwrap();
6273 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6274 let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
6275 store
6276 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6277 .unwrap();
6278 let new_path = store
6279 .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
6280 .unwrap();
6281 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
6282 assert!(existing.join("keep.txt").is_file());
6284 assert!(
6285 store
6286 .issues_dir
6287 .join("bob/fix-login-bug-assets-1/diagram.png")
6288 .is_file()
6289 );
6290 assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
6291 }
6292
6293 #[test]
6294 fn reassign_preserves_markdown_link_resolution_in_new_segment() {
6295 let (tmp, store) = make_store();
6296 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6297 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6298 let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
6299 store
6300 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6301 .unwrap();
6302 store
6303 .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
6304 .unwrap();
6305 assert!(
6308 store
6309 .issues_dir
6310 .join("bob/fix-login-bug-assets/diagram.png")
6311 .is_file()
6312 );
6313 }
6314
6315 #[test]
6320 fn delete_removes_asset_dir_no_orphans() {
6321 let (tmp, store) = make_store();
6322 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6323 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6324 let src = write_asset_source(&tmp, "x.png", b"x");
6325 store
6326 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6327 .unwrap();
6328 store.delete("alice/2026-01-01-fix-login-bug.md").unwrap();
6329 assert!(
6330 !store
6331 .issues_dir
6332 .join("alice/2026-01-01-fix-login-bug.md")
6333 .exists()
6334 );
6335 assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
6336 }
6337
6338 #[test]
6339 fn delete_without_asset_dir_still_deletes_file() {
6340 let (tmp, store) = make_store();
6341 let _ = tmp;
6342 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6343 write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
6344 store.delete("alice/2026-01-01-plain.md").unwrap();
6345 assert!(!store.issues_dir.join("alice/2026-01-01-plain.md").exists());
6346 }
6347
6348 #[test]
6349 fn list_ignores_asset_directories() {
6350 let (tmp, store) = make_store();
6351 write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
6352 let src = write_asset_source(&tmp, "x.png", b"x");
6353 store.add_asset("2026-01-01-legacy.md", &src).unwrap();
6354 let issues = store.list().unwrap();
6357 assert_eq!(issues.len(), 1);
6358 assert_eq!(issues[0].filename, "2026-01-01-legacy.md");
6359 }
6360
6361 #[test]
6362 fn list_ignores_nested_asset_directories_under_segment() {
6363 let (tmp, store) = make_store();
6364 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6365 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6366 let src = write_asset_source(&tmp, "x.png", b"x");
6367 store
6368 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
6369 .unwrap();
6370 let issues = store.list().unwrap();
6371 assert_eq!(issues.len(), 1);
6372 assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
6373 }
6374
6375 #[test]
6376 fn asset_dir_with_formless_md_child_is_not_scanned_as_segment() {
6377 let (tmp, store) = make_store();
6378 let _ = tmp;
6379 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6380 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
6381 let assets_dir = store.issues_dir.join("alice/fix-login-bug-assets");
6384 fs::create_dir_all(&assets_dir).unwrap();
6385 fs::write(assets_dir.join("notes.md"), "no frontmatter").unwrap();
6386 let issues = store.list().unwrap();
6387 assert_eq!(issues.len(), 1);
6388 assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
6389 }
6390
6391 fn hierarchy_trio() -> (TempDir, Store) {
6405 let (tmp, store) = make_store();
6406 for (n, title) in [(1, "A"), (2, "B"), (3, "C")] {
6407 write_issue(
6408 &store,
6409 &format!("2026-01-01-{}.md", title.to_lowercase()),
6410 &bare_frontmatter(n, title),
6411 );
6412 }
6413 (tmp, store)
6414 }
6415
6416 fn store_from_hierarchy_fixture(name: &str) -> (TempDir, Store) {
6423 let (tmp, store) = make_store();
6424 write_issue(
6425 &store,
6426 name,
6427 &std::fs::read_to_string(format!(
6428 "{}/tests/fixtures/hierarchy/{name}",
6429 env!("CARGO_MANIFEST_DIR")
6430 ))
6431 .unwrap(),
6432 );
6433 (tmp, store)
6434 }
6435
6436 fn bytes_of(store: &Store, number: u32) -> String {
6437 let issue = store.get(IssueSelector::Number(number)).unwrap();
6438 read_issue_file(store, &issue.filename)
6439 }
6440
6441 #[test]
6442 fn t008_set_writes_the_field_and_exactly_one_history_event() {
6443 let (_tmp, store) = hierarchy_trio();
6444 let outcome = store.set_parent("2", "1").unwrap();
6445 assert!(outcome.changed);
6446 assert_eq!(outcome.parent, Some(1));
6447
6448 let child = store.get(IssueSelector::Number(2)).unwrap();
6449 assert_eq!(child.parent, Some(1), "C-001: the number is stored");
6450 assert_eq!(child.history.len(), 1, "C-004: exactly one event");
6451 let event = &child.history.events()[0];
6452 assert_eq!(event.kind, HistoryEventKind::ParentSet);
6453 assert!(
6454 !event.actor.is_empty(),
6455 "an event without an actor is not an audit record"
6456 );
6457 assert_eq!(event.detail, "parent none -> #1", "got: {}", event.detail);
6459 }
6460
6461 #[test]
6462 fn t008_change_repoints_and_records_parent_changed() {
6463 let (_tmp, store) = hierarchy_trio();
6464 store.set_parent("3", "1").unwrap();
6465 let outcome = store.change_parent("3", "2").unwrap();
6466 assert!(outcome.changed);
6467 assert_eq!(outcome.parent, Some(2));
6468
6469 let child = store.get(IssueSelector::Number(3)).unwrap();
6470 assert_eq!(child.parent, Some(2));
6471 assert_eq!(child.history.len(), 2, "one event per accepted change");
6472 let events = child.history.events();
6473 let last = events.last().unwrap();
6474 assert_eq!(last.kind, HistoryEventKind::ParentChanged);
6475 assert_eq!(last.detail, "parent #1 -> #2", "got: {}", last.detail);
6478 }
6479
6480 #[test]
6481 fn t008_clear_removes_the_field_and_records_parent_cleared() {
6482 let (_tmp, store) = hierarchy_trio();
6483 store.set_parent("2", "1").unwrap();
6484 let outcome = store.clear_parent("2").unwrap();
6485 assert!(outcome.changed);
6486 assert_eq!(outcome.parent, None);
6487
6488 let child = store.get(IssueSelector::Number(2)).unwrap();
6489 assert_eq!(child.parent, None);
6490 assert_eq!(child.history.len(), 2);
6491 let events = child.history.events();
6492 let last = events.last().unwrap();
6493 assert_eq!(last.kind, HistoryEventKind::ParentCleared);
6494 assert_eq!(last.detail, "parent #1 -> none", "got: {}", last.detail);
6495 }
6496
6497 #[test]
6498 fn t008_reapplying_the_same_parent_is_a_no_op_with_no_second_event() {
6499 let (_tmp, store) = hierarchy_trio();
6500 store.set_parent("2", "1").unwrap();
6501 let after_first = bytes_of(&store, 2);
6502
6503 let second = store.set_parent("2", "1").unwrap();
6504 assert!(!second.changed, "an unchanged parent is not a change");
6505 assert!(second.event.is_none(), "a no-op records nothing (C-004)");
6506 assert_eq!(
6507 bytes_of(&store, 2),
6508 after_first,
6509 "a no-op must not rewrite the file at all"
6510 );
6511 }
6512
6513 #[test]
6514 fn t008_clearing_a_child_with_no_parent_is_a_no_op() {
6515 let (_tmp, store) = hierarchy_trio();
6516 let before = bytes_of(&store, 2);
6517 let outcome = store.clear_parent("2").unwrap();
6518 assert!(!outcome.changed);
6519 assert!(outcome.event.is_none());
6520 assert_eq!(bytes_of(&store, 2), before);
6521 }
6522
6523 #[test]
6524 fn t008_sc004_a_rejected_cycle_leaves_every_file_byte_identical() {
6525 let (_tmp, store) = hierarchy_trio();
6527 store.set_parent("2", "1").unwrap();
6528 store.set_parent("3", "2").unwrap();
6529 let before: Vec<String> = (1..=3).map(|n| bytes_of(&store, n)).collect();
6530
6531 let err = store
6532 .set_parent("1", "3")
6533 .expect_err("1 -> 2 -> 3 -> 1 is a cycle and must be refused");
6534 let msg = err.to_string();
6535 assert!(
6536 msg.contains("cycle"),
6537 "the diagnostic must name the rule: {msg}"
6538 );
6539
6540 for n in 1..=3 {
6542 assert_eq!(
6543 bytes_of(&store, n),
6544 before[(n - 1) as usize],
6545 "issue {n} must be byte-identical after a rejected cycle"
6546 );
6547 }
6548 }
6549
6550 #[test]
6551 fn t008_a_self_parent_is_rejected_and_writes_nothing() {
6552 let (_tmp, store) = hierarchy_trio();
6553 let before = bytes_of(&store, 2);
6554 let err = store
6555 .set_parent("2", "2")
6556 .expect_err("an issue cannot be its own parent");
6557 assert!(err.to_string().contains("self_parent"), "got: {err}");
6558 assert_eq!(bytes_of(&store, 2), before);
6559 }
6560
6561 #[test]
6562 fn t008_a_self_parent_already_on_disk_is_still_refused() {
6563 let (_tmp, store) = store_from_hierarchy_fixture("self-parent.md");
6571 let before = bytes_of(&store, 7);
6572 let err = store
6573 .set_parent("7", "7")
6574 .expect_err("a stored self-parent must still be refused on a re-set");
6575 assert!(
6576 err.to_string().contains("self_parent"),
6577 "refused for the wrong reason: {err}"
6578 );
6579 assert_eq!(
6580 bytes_of(&store, 7),
6581 before,
6582 "and the refused write must leave the file byte-identical"
6583 );
6584 assert!(store.change_parent("7", "7").is_err());
6586 assert_eq!(store.get(IssueSelector::Number(7)).unwrap().parent, Some(7));
6588 }
6589
6590 #[test]
6591 fn t008_c003_setting_a_child_writes_nothing_to_the_parents_file() {
6592 let (_tmp, store) = hierarchy_trio();
6595 let parent_before = bytes_of(&store, 1);
6596 store.set_parent("2", "1").unwrap();
6597 store.set_parent("3", "1").unwrap();
6598 assert_eq!(
6599 bytes_of(&store, 1),
6600 parent_before,
6601 "the parent's file must be untouched by its children appearing"
6602 );
6603 assert!(
6604 !parent_before.contains("parent:"),
6605 "the parent is a top of the hierarchy and has no parent itself"
6606 );
6607 }
6608
6609 #[test]
6610 fn t008_a_legacy_file_without_the_field_is_unchanged_by_a_no_op_write() {
6611 let (_tmp, store) = hierarchy_trio();
6613 let before = bytes_of(&store, 2);
6614 assert!(!before.contains("parent:"), "the fixture must be legacy");
6615
6616 store.clear_parent("2").unwrap();
6617 assert_eq!(bytes_of(&store, 2), before, "a no-op must not add the key");
6618 }
6619
6620 #[test]
6621 fn t008_an_unresolvable_selector_is_refused_before_any_write() {
6622 let (_tmp, store) = hierarchy_trio();
6623 let before = bytes_of(&store, 2);
6624 assert!(store.set_parent("2", "no such issue").is_err());
6625 assert!(store.change_parent("2", "999").is_err());
6626 assert_eq!(bytes_of(&store, 2), before);
6627 }
6628
6629 #[test]
6630 fn t008_t008_contains_no_hierarchy_rule_of_its_own() {
6631 let (_tmp, store) = hierarchy_trio();
6647 store.set_parent("2", "1").unwrap();
6648 store.set_parent("3", "2").unwrap();
6649
6650 let issues = store.list().unwrap();
6651 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
6652 let index = HierarchyIndex::build(&views);
6653 let service =
6654 hierarchy::set_parent(&index, 1, 3).expect_err("the service refuses this too");
6655 let storage = store.set_parent("1", "3").expect_err("and so does storage");
6656
6657 assert_eq!(
6658 storage.to_string(),
6659 TixError::from(service).to_string(),
6660 "storage must surface the service's diagnosis verbatim, not its own"
6661 );
6662 let text = storage.to_string();
6665 assert!(
6666 text.contains("cycle:") && !text.contains("cycle: cycle:"),
6667 "a lookalike renderer or a doubled prefix: {text}"
6668 );
6669 assert!(
6670 text.starts_with("invalid input: "),
6671 "a hierarchy refusal must carry the same prefix as every other: {text}"
6672 );
6673 }
6674
6675 #[test]
6676 fn t008_a_no_op_reports_no_written_issue_but_still_reports_the_current_parent() {
6677 let (_tmp, store) = hierarchy_trio();
6681 store.set_parent("2", "1").unwrap();
6682 let again = store.set_parent("2", "1").unwrap();
6683 assert!(!again.changed);
6684 assert!(
6685 again.written.is_none(),
6686 "a no-op wrote nothing, so there is no written issue to hand back"
6687 );
6688 assert!(
6689 again.event.is_none(),
6690 "and no event, or a retry would pad the audit trail"
6691 );
6692 assert_eq!(
6693 again.parent,
6694 Some(1),
6695 "but the resulting state is still reported, so a caller need not re-read"
6696 );
6697 }
6698}