1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use crate::config::Config;
6use crate::error::{Result, TixError, render_diagnostics};
7use crate::hierarchy::{self, HierarchyIndex};
8use crate::model::{HistoryEventKind, Issue, Status, TransitionDecision};
9use crate::relationships::{
10 CanonicalEdge, Diagnostic, IssueView, RelationKind, Relationship, RelationshipGraph,
11};
12use crate::slug::slugify;
13
14pub struct Store {
15 pub issues_dir: PathBuf,
16 pub config: Config,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct RelationshipOutcome {
28 pub written: Option<Issue>,
30 pub relationship: Relationship,
32 pub changed: bool,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub enum IssueSelector {
38 Number(u32),
39 Filename(String),
40 Slug(String),
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct AssetAdded {
47 pub link: String,
48 pub written_name: String,
49}
50
51thread_local! {
62 static WRITE_DISCLOSURES: std::cell::RefCell<Vec<String>> =
63 const { std::cell::RefCell::new(Vec::new()) };
64}
65
66pub(crate) fn push_disclosure(message: String) {
68 WRITE_DISCLOSURES.with(|queue| queue.borrow_mut().push(message));
69}
70
71pub(crate) fn mention_disclosure(message: String) {
73 push_disclosure(message);
74}
75
76pub(crate) fn retired_key_disclosure(filename: &str) {
80 push_disclosure(format!(
81 "remove `links:` by hand to silence \u{2014} {filename} (retired by \
82 C-001; never read as typed data, no migration)"
83 ));
84}
85
86pub fn take_disclosures() -> Vec<String> {
88 WRITE_DISCLOSURES.with(|queue| std::mem::take(&mut *queue.borrow_mut()))
89}
90
91thread_local! {
105 static WRITE_JOURNAL: std::cell::RefCell<Option<Vec<Undo>>> =
106 const { std::cell::RefCell::new(None) };
107}
108
109enum Undo {
111 Write {
115 path: PathBuf,
116 before: Option<Vec<u8>>,
117 },
118 Move { from: PathBuf, to: PathBuf },
121}
122
123pub(crate) fn journal_record_write(path: &Path) {
134 WRITE_JOURNAL.with(|journal| {
135 let mut borrow = journal.borrow_mut();
136 let Some(entries) = borrow.as_mut() else {
137 return;
138 };
139 if entries
140 .iter()
141 .any(|undo| matches!(undo, Undo::Write { path: seen, .. } if seen == path))
142 {
143 return;
144 }
145 entries.push(Undo::Write {
146 path: path.to_path_buf(),
147 before: std::fs::read(path).ok(),
148 });
149 });
150}
151
152fn journal_record_move(from: &Path, to: &Path) {
154 WRITE_JOURNAL.with(|journal| {
155 let mut borrow = journal.borrow_mut();
156 let Some(entries) = borrow.as_mut() else {
157 return;
158 };
159 entries.push(Undo::Move {
160 from: from.to_path_buf(),
161 to: to.to_path_buf(),
162 });
163 });
164}
165
166fn rollback(entries: Vec<Undo>) {
172 for entry in entries.into_iter().rev() {
173 match entry {
174 Undo::Write { path, before } => match before {
175 Some(bytes) => {
176 let _ = std::fs::write(&path, bytes);
177 }
178 None => {
179 let _ = std::fs::remove_file(&path);
180 }
181 },
182 Undo::Move { from, to } => {
183 let _ = std::fs::rename(&to, &from);
184 }
185 }
186 }
187}
188
189pub fn actor_identity() -> String {
200 crate::assignee::git_identity()
201 .map(|id| id.to_string())
202 .unwrap_or_else(|| "unknown".to_string())
203}
204
205#[derive(Debug, Clone, PartialEq, Eq)]
223pub struct StatusChange<'a> {
224 pub column: &'a str,
227 pub commit: Option<&'a str>,
235 pub override_reason: Option<&'a str>,
243}
244
245impl<'a> StatusChange<'a> {
246 pub fn to(column: &'a str) -> Self {
251 Self {
252 column,
253 commit: None,
254 override_reason: None,
255 }
256 }
257
258 pub fn with_commit(mut self, commit: Option<&'a str>) -> Self {
260 self.commit = commit;
261 self
262 }
263
264 pub fn with_override(mut self, reason: Option<&'a str>) -> Self {
267 self.override_reason = reason;
268 self
269 }
270}
271
272#[derive(Debug, Clone, PartialEq, Eq)]
281pub struct StatusOutcome {
282 pub issue: Issue,
286 pub decision: Option<TransitionDecision>,
289 pub written: bool,
296}
297
298struct TransitionAccepted {
306 changed: bool,
307 from: String,
308 to: String,
309 from_category: crate::workflow::WorkflowCategory,
310 to_category: crate::workflow::WorkflowCategory,
311 override_reason: Option<String>,
312}
313
314impl TransitionAccepted {
315 fn try_from(
317 outcome: crate::workflow::TransitionOutcome,
318 ) -> std::result::Result<Self, crate::workflow::WorkflowDiagnostic> {
319 match outcome {
320 crate::workflow::TransitionOutcome::Accepted {
321 changed,
322 from,
323 to,
324 from_category,
325 to_category,
326 override_reason,
327 } => Ok(Self {
328 changed,
329 from,
330 to,
331 from_category,
332 to_category,
333 override_reason,
334 }),
335 crate::workflow::TransitionOutcome::Rejected(diagnostic) => Err(diagnostic),
336 }
337 }
338}
339
340fn canonical_placeholder(canonical: &CanonicalEdge) -> Relationship {
347 Relationship {
348 id: String::new(),
349 from: canonical.from,
350 to: canonical.to,
351 kind: canonical.kind,
352 display: String::new(),
353 }
354}
355
356fn relationship_error(errors: &[Diagnostic]) -> TixError {
363 if errors.is_empty() {
364 return TixError::InvalidInput("relationship rejected".into());
365 }
366 TixError::Refused(render_diagnostics(
367 "E-tix-relationship",
368 errors.iter().map(|d| (d.kind.as_str(), d.message.as_str())),
369 ))
370}
371
372struct NumberClaim {
421 path: PathBuf,
422 armed: bool,
426}
427
428impl NumberClaim {
429 const DIR: &'static str = ".tix-numbers";
431
432 fn acquire(issues_dir: &Path, start: u32) -> Result<Self> {
439 const MAX_ATTEMPTS: u32 = 10_000;
440 let dir = claims_dir(issues_dir);
441 std::fs::create_dir_all(&dir)?;
442 let mut number = start;
443 for _ in 0..MAX_ATTEMPTS {
444 let path = dir.join(format!("{number}.claim"));
445 match std::fs::OpenOptions::new()
446 .write(true)
447 .create_new(true)
448 .open(&path)
449 {
450 Ok(_) => {
451 return Ok(Self { path, armed: true });
452 }
453 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
454 number += 1;
455 }
456 Err(e) => return Err(TixError::Io(e)),
457 }
458 }
459 Err(TixError::Io(std::io::Error::new(
460 std::io::ErrorKind::AlreadyExists,
461 format!(
462 "no free issue number at or above {start} after {MAX_ATTEMPTS} attempts; \
463 {} is probably corrupt",
464 dir.display()
465 ),
466 )))
467 }
468
469 fn number(&self) -> Option<u32> {
471 self.path.file_stem()?.to_str()?.parse::<u32>().ok()
472 }
473
474 fn release_number(issues_dir: &Path, number: u32) {
481 let _ = std::fs::remove_file(claims_dir(issues_dir).join(format!("{number}.claim")));
482 }
483
484 fn release(&mut self) {
487 if self.armed {
488 let _ = std::fs::remove_file(&self.path);
489 self.armed = false;
490 }
491 }
492
493 fn commit(&mut self) {
501 self.armed = false;
502 }
503}
504
505impl Drop for NumberClaim {
506 fn drop(&mut self) {
511 self.release();
512 }
513}
514
515impl std::fmt::Debug for NumberClaim {
516 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
517 f.debug_struct("NumberClaim")
518 .field("path", &self.path)
519 .field("armed", &self.armed)
520 .finish()
521 }
522}
523
524impl PartialEq for NumberClaim {
525 fn eq(&self, other: &Self) -> bool {
526 self.path == other.path
527 }
528}
529impl Eq for NumberClaim {}
530
531#[derive(Debug, Clone)]
534pub struct ParentOutcome {
535 pub written: Option<Issue>,
543 pub parent: Option<u32>,
545 pub event: Option<crate::hierarchy::ParentEvent>,
550 pub changed: bool,
552}
553
554#[derive(Debug, Clone, PartialEq, Eq)]
561pub struct ProjectAssociationOutcome {
562 pub written: Option<Issue>,
564 pub project_ids: Vec<String>,
567 pub event: Option<crate::projects::ProjectEvent>,
569 pub changed: bool,
571}
572
573#[derive(Debug)]
579pub struct BulkAssociationOutcome {
580 pub selector: String,
582 pub outcome: Result<ProjectAssociationOutcome>,
584}
585
586#[derive(Debug, Clone, PartialEq, Eq)]
594pub struct EpicReferenceOutcome {
595 pub written: Option<Issue>,
597 pub epic_ids: Vec<String>,
600 pub event: Option<crate::epics::EpicEvent>,
602 pub changed: bool,
604}
605
606#[derive(Debug)]
612pub struct BulkReferenceOutcome {
613 pub selector: String,
615 pub outcome: Result<EpicReferenceOutcome>,
617}
618
619impl Store {
620 pub fn open(config: &Config) -> Result<Store> {
621 let issues_dir = if config.issues_dir.is_relative() {
631 match Config::project_root(&std::env::current_dir().unwrap_or_default()) {
632 Some(root) => root.join(&config.issues_dir),
633 None => config.issues_dir.clone(),
634 }
635 } else {
636 config.issues_dir.clone()
637 };
638 if !issues_dir.exists() {
639 std::fs::create_dir_all(&issues_dir)?;
640 }
641 Ok(Store {
642 issues_dir,
643 config: config.clone(),
644 })
645 }
646
647 pub fn transaction<T>(&self, f: impl FnOnce() -> Result<T>) -> Result<T> {
672 if WRITE_JOURNAL.with(|journal| journal.borrow().is_some()) {
673 return f();
675 }
676 WRITE_JOURNAL.with(|journal| *journal.borrow_mut() = Some(Vec::new()));
677 let result = f();
678 let entries = WRITE_JOURNAL.with(|journal| journal.borrow_mut().take());
679 if result.is_err() {
680 rollback(entries.unwrap_or_default());
681 }
682 result
683 }
684
685 pub fn list(&self) -> Result<Vec<Issue>> {
695 self.scan(false)
696 }
697
698 pub fn list_all(&self) -> Result<Vec<Issue>> {
705 self.scan(true)
706 }
707
708 fn scan(&self, include_archived: bool) -> Result<Vec<Issue>> {
718 if !self.issues_dir.exists() {
719 return Ok(Vec::new());
720 }
721 let mut files: Vec<(PathBuf, String, bool)> = Vec::new();
722 Self::collect_issue_files(&self.issues_dir, "", include_archived, &mut files)?;
723 files.sort_by(|a, b| a.1.cmp(&b.1));
726 let mut issues = Self::parse_issue_files(&files)?;
727 issues.sort_by(|a, b| a.filename.cmp(&b.filename));
728 Ok(issues)
729 }
730
731 const PARALLEL_SCAN_THRESHOLD: usize = 256;
735 const PARALLEL_SCAN_THREADS: usize = 8;
738
739 fn collect_issue_files(
746 dir: &Path,
747 prefix: &str,
748 include_archived: bool,
749 out: &mut Vec<(PathBuf, String, bool)>,
750 ) -> Result<()> {
751 for entry in std::fs::read_dir(dir)? {
752 let entry = entry?;
753 let path = entry.path();
754 let name = entry.file_name().to_string_lossy().to_string();
755 if path.is_dir() {
756 if name.starts_with('.')
757 || name.starts_with(".tix-tmp-")
758 || name.ends_with("-assets")
759 {
760 continue;
761 }
762 let child = if prefix.is_empty() {
763 name
764 } else {
765 format!("{prefix}/{name}")
766 };
767 Self::collect_issue_files(&path, &child, include_archived, out)?;
768 } else if path.is_file() && name.ends_with(".md") {
769 let archived = name.starts_with('.');
770 if archived && !include_archived {
771 continue;
772 }
773 let rel = if prefix.is_empty() {
774 name
775 } else {
776 format!("{prefix}/{name}")
777 };
778 out.push((path, rel, archived));
779 }
780 }
781 Ok(())
782 }
783
784 fn parse_issue_files(files: &[(PathBuf, String, bool)]) -> Result<Vec<Issue>> {
793 if files.len() < Self::PARALLEL_SCAN_THRESHOLD {
794 return files
795 .iter()
796 .map(|(path, rel, archived)| Self::read_issue_file(path, rel, *archived))
797 .collect();
798 }
799 let threads = std::thread::available_parallelism()
800 .map(|n| n.get())
801 .unwrap_or(1)
802 .clamp(1, Self::PARALLEL_SCAN_THREADS);
803 if threads < 2 {
804 return files
805 .iter()
806 .map(|(path, rel, archived)| Self::read_issue_file(path, rel, *archived))
807 .collect();
808 }
809 let chunk = files.len().div_ceil(threads);
810 let parts: Vec<Vec<Result<Issue>>> = std::thread::scope(|scope| {
811 let handles: Vec<_> = files
812 .chunks(chunk)
813 .map(|slice| {
814 scope.spawn(move || {
815 slice
816 .iter()
817 .map(|(path, rel, archived)| {
818 Self::read_issue_file(path, rel, *archived)
819 })
820 .collect::<Vec<_>>()
821 })
822 })
823 .collect();
824 handles
825 .into_iter()
826 .map(|handle| handle.join().expect("issue-scan worker panicked"))
827 .collect()
828 });
829 let mut issues = Vec::with_capacity(files.len());
830 for part in parts {
831 for issue in part {
832 issues.push(issue?);
833 }
834 }
835 Ok(issues)
836 }
837
838 fn read_issue_file(path: &Path, rel: &str, archived: bool) -> Result<Issue> {
842 let contents = std::fs::read_to_string(path)?;
843 let mut issue = Issue::parse(&contents).map_err(|e| match e {
844 TixError::Parse(msg) => TixError::Parse(format!("{rel}: {msg}")),
845 other => other,
846 })?;
847 issue.filename = rel.to_string();
848 issue.archived = archived;
849 Ok(issue)
850 }
851
852 pub fn assignee_segments(&self) -> Result<Vec<String>> {
860 let mut segments = Vec::new();
861 if !self.issues_dir.exists() {
862 return Ok(segments);
863 }
864 for entry in std::fs::read_dir(&self.issues_dir)? {
865 let entry = entry?;
866 if !entry.path().is_dir() {
867 continue;
868 }
869 let name = entry.file_name().to_string_lossy().to_string();
870 if name == "unassigned" || name.starts_with(".tix-tmp-") || name.ends_with("-assets") {
871 continue;
872 }
873 segments.push(name);
874 }
875 segments.sort();
876 segments.dedup();
877 Ok(segments)
878 }
879
880 pub fn get(&self, selector: IssueSelector) -> Result<Issue> {
881 match selector {
882 IssueSelector::Number(num) => {
883 let issues = self.list_all()?;
884 issues
885 .into_iter()
886 .find(|i| i.number == num)
887 .ok_or_else(|| TixError::NotFound(format!("issue #{num}")))
888 }
889 IssueSelector::Filename(name) => {
890 validate_filename(&name)?;
891 let path = self.issues_dir.join(&name);
892 if !path.is_file() {
893 return Err(TixError::NotFound(format!("file {name}")));
894 }
895 let contents = std::fs::read_to_string(&path)?;
896 let mut issue = Issue::parse(&contents)?;
897 issue.filename = name;
898 Ok(issue)
899 }
900 IssueSelector::Slug(slug) => {
901 let issues = self.list_all()?;
902 issues
903 .into_iter()
904 .find(|i| slugify(&i.title) == slug)
905 .ok_or_else(|| TixError::NotFound(format!("slug {slug}")))
906 }
907 }
908 }
909
910 pub fn read_issue_at(&self, rel: &Path) -> Result<Issue> {
924 let filename = rel.to_string_lossy().to_string();
925 let path = self.issues_dir.join(rel);
926 if !path.is_file() {
927 return Err(TixError::NotFound(format!("file {filename}")));
928 }
929 let contents = std::fs::read_to_string(&path)?;
930 let mut issue = Issue::parse(&contents).map_err(|e| match e {
931 TixError::Parse(msg) => TixError::Parse(format!("{filename}: {msg}")),
932 other => other,
933 })?;
934 issue.filename = filename;
935 issue.archived = rel
936 .file_name()
937 .and_then(|name| name.to_str())
938 .is_some_and(|name| name.starts_with('.'));
939 Ok(issue)
940 }
941
942 pub fn archive(&self, sel: &str) -> Result<Issue> {
955 self.set_archived(sel, true)
956 }
957
958 pub fn restore(&self, sel: &str) -> Result<Issue> {
962 self.set_archived(sel, false)
963 }
964
965 fn set_archived(&self, sel: &str, archive: bool) -> Result<Issue> {
966 let mut issue = self.resolve_issue(sel)?;
969 if issue.archived == archive {
970 return Err(TixError::InvalidInput(format!(
971 "{}: issue #{} ({}) is already {}",
972 if archive {
973 "already_archived"
974 } else {
975 "not_archived"
976 },
977 issue.number,
978 issue.filename,
979 if archive { "archived" } else { "active" },
980 )));
981 }
982 let old_rel = issue.filename.clone();
983 let new_rel = flip_archived(&old_rel, archive).ok_or_else(|| {
984 TixError::InvalidInput(format!(
985 "cannot {} {old_rel}: its filename does not carry the archived marker in the \
986 expected position",
987 if archive { "archive" } else { "restore" }
988 ))
989 })?;
990 let old_path = self.issues_dir.join(&old_rel);
991 let new_path = self.issues_dir.join(&new_rel);
992 if !old_path.is_file() {
993 return Err(TixError::NotFound(format!("file {old_rel}")));
994 }
995 if new_path.exists() {
996 return Err(TixError::InvalidInput(format!(
997 "cannot {} {old_rel}: {new_rel} already exists",
998 if archive { "archive" } else { "restore" }
999 )));
1000 }
1001 journal_record_move(&old_path, &new_path);
1012 std::fs::rename(&old_path, &new_path)?;
1013 issue.filename = new_rel.clone();
1014 issue.archived = archive;
1015 issue.history.append_now(
1016 &actor_identity(),
1017 if archive {
1018 HistoryEventKind::Archived
1019 } else {
1020 HistoryEventKind::Restored
1021 },
1022 &format!("{old_rel} -> {new_rel}"),
1023 );
1024 issue.filename = self.update(&new_rel, &issue)?;
1027 Ok(issue)
1028 }
1029
1030 pub fn create(
1046 &self,
1047 issue: &mut Issue,
1048 body: &str,
1049 filename_stem: Option<&str>,
1050 ) -> Result<PathBuf> {
1051 issue.body = body.to_string();
1052 issue.bump_updated();
1053
1054 std::fs::create_dir_all(&self.issues_dir)?;
1055 issue.number = self.resolve_next_number()?;
1056
1057 let mut claim = NumberClaim::acquire(&self.issues_dir, issue.number)?;
1071 issue.number = claim.number().ok_or_else(|| {
1077 TixError::Io(std::io::Error::new(
1078 std::io::ErrorKind::InvalidData,
1079 format!("claimed number is unreadable from {}", claim.path.display()),
1080 ))
1081 })?;
1082
1083 if issue.id.is_none() {
1089 issue.id = Some(crate::id::generate_issue_id());
1090 }
1091
1092 let rel = crate::layout::render(&self.config.layout, issue);
1096 let (dir, file) = rel.rsplit_once('/').unwrap_or(("", rel.as_str()));
1097 std::fs::create_dir_all(self.issues_dir.join(dir))?;
1098
1099 let stem = match filename_stem {
1100 Some(s) => s.to_string(),
1101 None => file.strip_suffix(".md").unwrap_or(file).to_string(),
1102 };
1103 let contents = issue.to_file_contents();
1106
1107 let written = self.write_new_issue_file(dir, &stem, contents.as_bytes());
1108 match written {
1113 Ok((filename, path)) => {
1114 claim.commit();
1115 issue.filename = filename;
1116 self.reconcile_mentions(issue, &issue.filename);
1117 Ok(path)
1118 }
1119 Err(e) => {
1120 claim.release();
1121 Err(e)
1122 }
1123 }
1124 }
1125
1126 pub fn mention_store(&self) -> Option<crate::mentions::store::MentionStore> {
1135 if !self.config.mentions.enabled {
1136 return None;
1137 }
1138 let dir = self.config.mentions.resolve_dir(&self.issues_dir);
1139 Some(crate::mentions::store::MentionStore::new(dir))
1140 }
1141
1142 fn reconcile_mentions(&self, issue: &Issue, file: &str) {
1150 let Some(store) = self.mention_store() else {
1151 return;
1152 };
1153 let id = issue.id.as_deref().unwrap_or(file);
1156 store.reconcile(crate::mentions::IssueContent {
1157 id,
1158 number: issue.number,
1159 file,
1160 title: &issue.title,
1161 body: &issue.body,
1162 });
1163 }
1164
1165 fn clear_mentions(&self, id: &str) {
1167 let Some(store) = self.mention_store() else {
1168 return;
1169 };
1170 if let Err(e) = store.clear_issue(id) {
1171 crate::storage::mention_disclosure(format!("mentions: {e} (left untouched)"));
1172 }
1173 }
1174
1175 fn write_new_issue_file(
1212 &self,
1213 segment: &str,
1214 stem: &str,
1215 contents: &[u8],
1216 ) -> Result<(String, PathBuf)> {
1217 const MAX_ATTEMPTS: usize = 1_000;
1218 let mut suffix = 1usize;
1219 for _ in 0..MAX_ATTEMPTS {
1220 let filename = if suffix == 1 {
1221 format!("{segment}/{stem}.md")
1222 } else {
1223 format!("{segment}/{stem}-{suffix}.md")
1224 };
1225 let path = self.issues_dir.join(&filename);
1226 match write_file_new(&self.issues_dir, &path, contents) {
1227 Ok(()) => return Ok((filename, path)),
1228 Err(TixError::Io(e)) if e.kind() == std::io::ErrorKind::AlreadyExists => {
1231 suffix += 1;
1232 }
1233 Err(e) => return Err(e),
1234 }
1235 }
1236 Err(TixError::Io(std::io::Error::new(
1237 std::io::ErrorKind::AlreadyExists,
1238 format!(
1239 "could not find a free filename for {segment}/{stem}.md in {MAX_ATTEMPTS} attempts"
1240 ),
1241 )))
1242 }
1243
1244 fn resolve_next_number(&self) -> Result<u32> {
1265 let issues = self.list_all()?;
1269 match issues.iter().map(|i| i.number).max() {
1270 None => Ok(1),
1271 Some(max) => max
1272 .checked_add(1)
1273 .ok_or_else(|| TixError::InvalidInput("issue-number overflow".to_string())),
1274 }
1275 }
1276
1277 pub fn config_path(&self) -> Result<std::path::PathBuf> {
1278 if let Some(root) = Config::project_root(&self.issues_dir) {
1279 Ok(root.join("tix.toml"))
1280 } else {
1281 Err(TixError::Config(
1282 "tix.toml not found in any parent directory of issues_dir".into(),
1283 ))
1284 }
1285 }
1286
1287 pub fn update(&self, filename: &str, issue: &Issue) -> Result<String> {
1296 validate_filename(filename)?;
1297 let path = self.issues_dir.join(filename);
1298 if !path.is_file() {
1299 return Err(TixError::NotFound(format!("file {filename}")));
1300 }
1301 if issue.has_retired_links_key {
1313 retired_key_disclosure(filename);
1329 }
1330 let contents = issue.to_file_contents();
1331 atomic_write(&self.issues_dir, &path, contents.as_bytes())?;
1332 let relocated = self.reconcile_location(filename, issue)?;
1336 self.reconcile_mentions(issue, &relocated);
1339 Ok(relocated)
1340 }
1341
1342 fn reconcile_location(&self, filename: &str, issue: &Issue) -> Result<String> {
1347 if !self.config.layout.move_on_change {
1348 return Ok(filename.to_string());
1349 }
1350 let target_dir = crate::layout::render_directory(&self.config.layout, issue);
1351 let Some((old_dir, base)) = split_relative(filename) else {
1352 return Ok(filename.to_string());
1353 };
1354 if old_dir == target_dir {
1355 return Ok(filename.to_string());
1356 }
1357 let stem = base.strip_suffix(".md").unwrap_or(&base);
1358 let moved = self.move_issue_file(filename, &target_dir, stem)?;
1359 Ok(moved.to_string_lossy().to_string())
1360 }
1361
1362 pub fn add_asset(&self, filename: &str, source_path: &Path) -> Result<AssetAdded> {
1376 validate_filename(filename)?;
1377 let mut issue = self.get(IssueSelector::Filename(filename.to_string()))?;
1378
1379 let (segment, basename) = split_relative(filename).ok_or_else(|| {
1380 TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
1381 })?;
1382 let stem = basename
1383 .strip_suffix(".md")
1384 .ok_or_else(|| {
1385 TixError::InvalidInput(format!(
1386 "asset target must be a `.md` issue file: {filename}"
1387 ))
1388 })?
1389 .to_string();
1390 let slug = slug_from_stem(&stem);
1391 let dir_name = format!("{slug}-assets");
1392 let asset_dir = self.issues_dir.join(&segment).join(&dir_name);
1393 std::fs::create_dir_all(&asset_dir)?;
1394
1395 let source_name = source_path
1396 .file_name()
1397 .and_then(|n| n.to_str())
1398 .ok_or_else(|| {
1399 TixError::InvalidInput("asset source must have a file name".to_string())
1400 })?
1401 .to_string();
1402 let written_name = resolve_asset_name(&asset_dir, &source_name);
1403 atomic_copy(&asset_dir, &asset_dir.join(&written_name), source_path)?;
1404
1405 let label = written_name
1406 .rsplit_once('.')
1407 .map(|(stem, _)| stem)
1408 .unwrap_or(&written_name);
1409 let link = if is_image_name(&written_name) {
1410 format!("")
1411 } else {
1412 format!("[{label}]({dir_name}/{written_name})")
1413 };
1414
1415 if !issue.body.is_empty() {
1419 if !issue.body.ends_with('\n') {
1420 issue.body.push('\n');
1421 }
1422 if !issue.body.ends_with("\n\n") {
1423 issue.body.push('\n');
1424 }
1425 }
1426 issue.body.push_str(&link);
1427 issue.body.push('\n');
1428 issue.bump_updated();
1429 self.update(filename, &issue)?;
1430
1431 Ok(AssetAdded { link, written_name })
1432 }
1433
1434 pub fn delete(&self, filename: &str) -> Result<()> {
1435 validate_filename(filename)?;
1436 let path = self.issues_dir.join(filename);
1437 if !path.is_file() {
1438 return Err(TixError::NotFound(format!("file {filename}")));
1439 }
1440 let freed = std::fs::read_to_string(&path)
1457 .ok()
1458 .and_then(|text| frontmatter_number(&text));
1459 let identity = self
1463 .get(IssueSelector::Filename(filename.to_string()))
1464 .ok()
1465 .and_then(|issue| issue.id);
1466 journal_record_write(&path);
1467 std::fs::remove_file(&path)?;
1468 if let Some(number) = freed {
1469 NumberClaim::release_number(&self.issues_dir, number);
1470 }
1471 if let Some(id) = identity {
1472 self.clear_mentions(&id);
1473 }
1474 if let Some((segment, basename)) = split_relative(filename) {
1478 let stem = basename.strip_suffix(".md").unwrap_or(&basename);
1479 let slug = slug_from_stem(stem);
1480 let asset_dir = self
1481 .issues_dir
1482 .join(&segment)
1483 .join(format!("{slug}-assets"));
1484 if asset_dir.exists()
1485 && let Err(e) = std::fs::remove_dir_all(&asset_dir)
1486 {
1487 eprintln!(
1488 "warning: failed to remove asset dir {}: {e}",
1489 asset_dir.display()
1490 );
1491 }
1492 }
1493 Ok(())
1494 }
1495
1496 pub fn set_status(&self, filename: &str, request: StatusChange<'_>) -> Result<StatusOutcome> {
1542 let mut issue = self.get(IssueSelector::Filename(filename.to_string()))?;
1543
1544 let (_, resolution) = crate::workflow::active_resolution(&self.config);
1547
1548 let outcome = crate::workflow::validate_transition(
1551 &issue,
1552 request.column,
1553 &resolution,
1554 &self.config,
1555 request.override_reason,
1556 );
1557 let accepted = match TransitionAccepted::try_from(outcome) {
1560 Ok(accepted) => accepted,
1561 Err(diagnostic) => return Err(TixError::Workflow(diagnostic)),
1562 };
1563
1564 if !accepted.changed {
1569 return Ok(StatusOutcome {
1570 issue,
1571 decision: None,
1572 written: false,
1573 });
1574 }
1575
1576 let was_complete = crate::workflow::is_completed(&issue, &resolution);
1597 let reaches_complete = resolution
1598 .resolve(&accepted.to)
1599 .is_some_and(crate::workflow::WorkflowCategory::is_complete);
1600 if reaches_complete {
1601 issue.commit = request.commit.map(str::to_string);
1602 } else if was_complete {
1603 issue.commit = None;
1604 }
1605
1606 issue.status = Status::new(accepted.to.clone());
1607 issue.bump_updated();
1608
1609 let decision = TransitionDecision::new(
1612 crate::model::utc_now_rfc3339(),
1613 actor_identity(),
1618 accepted.from.clone(),
1619 accepted.to.clone(),
1620 accepted.from_category.key(),
1621 accepted.to_category.key(),
1622 accepted.override_reason,
1623 );
1624 issue.history.append(decision.to_event());
1625
1626 issue.filename = self.update(filename, &issue)?;
1627 Ok(StatusOutcome {
1628 issue,
1629 decision: Some(decision),
1630 written: true,
1631 })
1632 }
1633
1634 pub fn relationship_done_column(&self) -> String {
1664 crate::workflow::completion_column_or_default(self.config.default_config(), &self.config)
1676 }
1677
1678 pub fn link(&self, a: &str, b: &str, kind: RelationKind) -> Result<RelationshipOutcome> {
1692 let issue_a = self.resolve_issue(a)?;
1693 let issue_b = self.resolve_issue(b)?;
1694 let issues = self.list_all()?;
1698 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1699 let done_column = self.relationship_done_column();
1700 let graph = RelationshipGraph::new(&views, &done_column);
1701
1702 let verdict = graph.validate_create(issue_a.number, issue_b.number, kind);
1703 if verdict.blocking() {
1704 return Err(relationship_error(&verdict.errors));
1705 }
1706
1707 if let Some(existing) = graph.find_canonical(issue_a.number, issue_b.number, kind) {
1710 return Ok(RelationshipOutcome {
1716 written: None,
1717 relationship: existing.clone(),
1718 changed: false,
1719 });
1720 }
1721
1722 let canonical = CanonicalEdge::new(issue_a.number, issue_b.number, kind);
1723 let mut owner = self.get(IssueSelector::Number(canonical.from))?;
1724 let target_title = self
1725 .get(IssueSelector::Number(canonical.to))
1726 .map(|i| i.title)
1727 .unwrap_or_default();
1728 let relationship =
1729 Relationship::new(canonical.from, canonical.to, canonical.kind, &target_title);
1730 let detail = format!("created {}: {}", relationship.id, relationship.display);
1731 owner.relationships.push(relationship.clone());
1732 owner.history.append_now(
1733 &actor_identity(),
1734 HistoryEventKind::RelationshipAdded,
1735 &detail,
1736 );
1737 owner.bump_updated();
1738 owner.filename = self.update(&owner.filename, &owner)?;
1739
1740 Ok(RelationshipOutcome {
1741 written: Some(owner),
1742 relationship,
1743 changed: true,
1744 })
1745 }
1746
1747 pub fn hierarchy_index(&self) -> Result<(HierarchyIndex, Vec<Issue>)> {
1772 let issues = self.list_all()?;
1773 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
1774 Ok((HierarchyIndex::build(&views), issues))
1775 }
1776
1777 fn child_in(snapshot: &[Issue], number: u32) -> Result<Issue> {
1782 snapshot
1783 .iter()
1784 .find(|i| i.number == number)
1785 .cloned()
1786 .ok_or_else(|| {
1787 TixError::NotFound(format!(
1788 "issue #{number} vanished between validating and writing it"
1789 ))
1790 })
1791 }
1792
1793 pub fn set_parent(&self, child: &str, parent: &str) -> Result<ParentOutcome> {
1799 let child_issue = self.resolve_issue(child)?;
1800 let parent_issue = self.resolve_issue(parent)?;
1801 let (index, issues) = self.hierarchy_index()?;
1802 let change = hierarchy::set_parent(&index, child_issue.number, parent_issue.number)?;
1803 self.commit_parent_change(Store::child_in(&issues, child_issue.number)?, change)
1804 }
1805
1806 pub fn change_parent(&self, child: &str, parent: &str) -> Result<ParentOutcome> {
1808 let child_issue = self.resolve_issue(child)?;
1809 let parent_issue = self.resolve_issue(parent)?;
1810 let (index, issues) = self.hierarchy_index()?;
1811 let change = hierarchy::change_parent(&index, child_issue.number, parent_issue.number)?;
1812 self.commit_parent_change(Store::child_in(&issues, child_issue.number)?, change)
1813 }
1814
1815 pub fn clear_parent(&self, child: &str) -> Result<ParentOutcome> {
1817 let child_issue = self.resolve_issue(child)?;
1818 let (index, issues) = self.hierarchy_index()?;
1819 let change = hierarchy::clear_parent(&index, child_issue.number)?;
1820 self.commit_parent_change(Store::child_in(&issues, child_issue.number)?, change)
1821 }
1822
1823 fn commit_parent_change(
1830 &self,
1831 mut issue: Issue,
1832 change: hierarchy::ParentChange,
1833 ) -> Result<ParentOutcome> {
1834 let Some(event) = change.event else {
1835 return Ok(ParentOutcome {
1840 written: None,
1841 parent: change.parent,
1842 event: None,
1843 changed: false,
1844 });
1845 };
1846 let previous = issue.parent;
1851 let kind = HistoryEventKind::from_parent(event.kind);
1852 let detail = hierarchy::change_detail(previous, change.parent);
1857 debug_assert_ne!(
1866 previous, change.parent,
1867 "an accepted change must alter the parent, or the event records nothing"
1868 );
1869 issue.parent = change.parent;
1872 issue.history.append_now(&actor_identity(), kind, &detail);
1873 issue.bump_updated();
1874 self.update(&issue.filename, &issue)?;
1875 Ok(ParentOutcome {
1876 written: Some(issue),
1877 parent: change.parent,
1878 event: Some(event),
1879 changed: true,
1880 })
1881 }
1882
1883 pub fn associate(
1899 &self,
1900 issue_selector: &str,
1901 project_reference: &str,
1902 ) -> Result<ProjectAssociationOutcome> {
1903 let issue = self.resolve_issue(issue_selector)?;
1904 let snapshot = self.list_all()?;
1905 let change =
1906 crate::projects::associate(&self.config, &snapshot, issue.number, project_reference);
1907 self.commit_project_change(&snapshot, change)
1908 }
1909
1910 pub fn dissociate(
1917 &self,
1918 issue_selector: &str,
1919 project_id: &str,
1920 ) -> Result<ProjectAssociationOutcome> {
1921 let issue = self.resolve_issue(issue_selector)?;
1922 let snapshot = self.list_all()?;
1923 let change = crate::projects::dissociate(&snapshot, issue.number, project_id);
1924 self.commit_project_change(&snapshot, change)
1925 }
1926
1927 pub fn bulk_associate(
1948 &self,
1949 project_reference: &str,
1950 selectors: &[String],
1951 ) -> Result<Vec<BulkAssociationOutcome>> {
1952 let mut working = self.list_all()?;
1953 let mut outcomes = Vec::with_capacity(selectors.len());
1954 for selector in selectors {
1955 let outcome = match self.resolve_issue(selector) {
1956 Err(error) => Err(error),
1957 Ok(issue) => {
1958 let change = crate::projects::associate(
1963 &self.config,
1964 &working,
1965 issue.number,
1966 project_reference,
1967 );
1968 match self.commit_project_change(&working, change) {
1969 Ok(applied) => {
1970 if let Some(written) = &applied.written {
1973 if let Some(slot) = working
1974 .iter_mut()
1975 .find(|candidate| candidate.number == issue.number)
1976 {
1977 *slot = written.clone();
1978 }
1979 }
1980 Ok(applied)
1981 }
1982 Err(error) => Err(error),
1983 }
1984 }
1985 };
1986 outcomes.push(BulkAssociationOutcome {
1987 selector: selector.clone(),
1988 outcome,
1989 });
1990 }
1991 Ok(outcomes)
1992 }
1993
1994 fn commit_project_change(
2002 &self,
2003 snapshot: &[Issue],
2004 change: std::result::Result<crate::projects::ProjectChange, crate::projects::ProjectError>,
2005 ) -> Result<ProjectAssociationOutcome> {
2006 let change = change.map_err(TixError::from)?;
2007 let Some(event) = change.event else {
2008 return Ok(ProjectAssociationOutcome {
2011 written: None,
2012 project_ids: change.project_ids,
2013 event: None,
2014 changed: false,
2015 });
2016 };
2017 let mut issue = snapshot
2018 .iter()
2019 .find(|issue| issue.number == change.issue.number())
2020 .cloned()
2021 .ok_or_else(|| {
2022 TixError::NotFound(format!(
2023 "issue #{} vanished between validating and writing it",
2024 change.issue.number()
2025 ))
2026 })?;
2027 issue.projects = Some(change.project_ids.clone());
2031 let kind = HistoryEventKind::from_project(event.kind);
2034 issue
2035 .history
2036 .append_now(&actor_identity(), kind, &event.detail);
2037 issue.bump_updated();
2038 issue.filename = self.update(&issue.filename, &issue)?;
2039 Ok(ProjectAssociationOutcome {
2040 written: Some(issue),
2041 project_ids: change.project_ids,
2042 event: Some(event),
2043 changed: true,
2044 })
2045 }
2046
2047 pub fn reference_epic(
2064 &self,
2065 issue_selector: &str,
2066 epic_reference: &str,
2067 ) -> Result<EpicReferenceOutcome> {
2068 let issue = self.resolve_issue(issue_selector)?;
2069 let snapshot = self.list_all()?;
2070 let change =
2071 crate::epics::reference_epic(&self.config, &snapshot, issue.number, epic_reference);
2072 self.commit_epic_change(&snapshot, change)
2073 }
2074
2075 pub fn dereference_epic(
2082 &self,
2083 issue_selector: &str,
2084 epic_id: &str,
2085 ) -> Result<EpicReferenceOutcome> {
2086 let issue = self.resolve_issue(issue_selector)?;
2087 let snapshot = self.list_all()?;
2088 let change = crate::epics::dereference_epic(&snapshot, issue.number, epic_id);
2089 self.commit_epic_change(&snapshot, change)
2090 }
2091
2092 pub fn bulk_reference(
2113 &self,
2114 epic_reference: &str,
2115 selectors: &[String],
2116 ) -> Result<Vec<BulkReferenceOutcome>> {
2117 let mut working = self.list_all()?;
2118 let mut outcomes = Vec::with_capacity(selectors.len());
2119 for selector in selectors {
2120 let outcome = match self.resolve_issue(selector) {
2121 Err(error) => Err(error),
2122 Ok(issue) => {
2123 let change = crate::epics::reference_epic(
2124 &self.config,
2125 &working,
2126 issue.number,
2127 epic_reference,
2128 );
2129 match self.commit_epic_change(&working, change) {
2130 Ok(applied) => {
2131 if let Some(written) = &applied.written {
2132 if let Some(slot) = working
2133 .iter_mut()
2134 .find(|candidate| candidate.number == issue.number)
2135 {
2136 *slot = written.clone();
2137 }
2138 }
2139 Ok(applied)
2140 }
2141 Err(error) => Err(error),
2142 }
2143 }
2144 };
2145 outcomes.push(BulkReferenceOutcome {
2146 selector: selector.clone(),
2147 outcome,
2148 });
2149 }
2150 Ok(outcomes)
2151 }
2152
2153 fn commit_epic_change(
2162 &self,
2163 snapshot: &[Issue],
2164 change: std::result::Result<crate::epics::EpicChange, crate::epics::EpicError>,
2165 ) -> Result<EpicReferenceOutcome> {
2166 let change = change.map_err(TixError::from)?;
2167 let Some(event) = change.event else {
2168 return Ok(EpicReferenceOutcome {
2171 written: None,
2172 epic_ids: change.epic_ids,
2173 event: None,
2174 changed: false,
2175 });
2176 };
2177 let mut issue = snapshot
2178 .iter()
2179 .find(|issue| issue.number == change.issue.number())
2180 .cloned()
2181 .ok_or_else(|| {
2182 TixError::NotFound(format!(
2183 "issue #{} vanished between validating and writing it",
2184 change.issue.number()
2185 ))
2186 })?;
2187 issue.epics = Some(change.epic_ids.clone());
2191 let kind = HistoryEventKind::from_epic(event.kind);
2194 issue
2195 .history
2196 .append_now(&actor_identity(), kind, &event.detail);
2197 issue.bump_updated();
2198 issue.filename = self.update(&issue.filename, &issue)?;
2199 Ok(EpicReferenceOutcome {
2200 written: Some(issue),
2201 epic_ids: change.epic_ids,
2202 event: Some(event),
2203 changed: true,
2204 })
2205 }
2206
2207 pub fn relationships_for(&self, number: u32) -> Result<Vec<Relationship>> {
2221 let issues = self.list_all()?;
2222 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
2223 let done_column = self.relationship_done_column();
2224 let graph = RelationshipGraph::new(&views, &done_column);
2225 Ok(graph
2226 .relationships_for(number)
2227 .into_iter()
2228 .cloned()
2229 .collect())
2230 }
2231
2232 pub fn unlink(&self, a: &str, b: &str, kind: RelationKind) -> Result<RelationshipOutcome> {
2237 let canonical = self.canonical_request(a, b, kind)?;
2238 let stored = self.find_stored_rows(&canonical)?;
2239
2240 if stored.is_empty() {
2241 return Ok(RelationshipOutcome {
2245 written: None,
2246 relationship: canonical_placeholder(&canonical),
2247 changed: false,
2248 });
2249 }
2250
2251 let mut by_file: HashMap<String, Vec<Relationship>> = HashMap::new();
2256 for (filename, rel) in stored {
2257 by_file.entry(filename).or_default().push(rel);
2258 }
2259
2260 let actor = actor_identity();
2261 let mut file_order: Vec<String> = by_file.keys().cloned().collect();
2263 file_order.sort_unstable();
2264
2265 let mut removed_row: Option<Relationship> = None;
2271
2272 let all_removed: Vec<Relationship> = by_file.values().flatten().cloned().collect();
2278 let summary = if all_removed.len() == 1 {
2279 let one = &all_removed[0];
2280 format!("removed {}: {}", one.id, one.display)
2281 } else {
2282 let kinds: Vec<String> = all_removed.iter().map(|r| r.kind.to_string()).collect();
2283 format!(
2284 "removed {} relationships ({})",
2285 all_removed.len(),
2286 kinds.join(", ")
2287 )
2288 };
2289
2290 let mut written: Option<Issue> = None;
2291 for filename in &file_order {
2292 let doomed = &by_file[filename];
2293 let mut owner = self.get(IssueSelector::Filename(filename.clone()))?;
2294 if removed_row.is_none() {
2295 removed_row = doomed.first().cloned();
2296 }
2297 owner.relationships.retain(|r| r.canonical() != canonical);
2298 owner
2299 .history
2300 .append_now(&actor, HistoryEventKind::RelationshipRemoved, &summary);
2301 owner.bump_updated();
2302 owner.filename = self.update(&owner.filename, &owner)?;
2303 written = Some(owner);
2304 }
2305
2306 let relationship = removed_row.unwrap_or_else(|| canonical_placeholder(&canonical));
2309
2310 Ok(RelationshipOutcome {
2311 written,
2312 relationship,
2313 changed: true,
2314 })
2315 }
2316
2317 pub fn edit_relation(
2332 &self,
2333 id: &str,
2334 new_to: Option<u32>,
2335 new_kind: Option<RelationKind>,
2336 ) -> Result<RelationshipOutcome> {
2337 let issues = self.list_all()?;
2338 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
2339 let done_column = self.relationship_done_column();
2340 let graph = RelationshipGraph::new(&views, &done_column);
2341
2342 let Some(existing) = graph.find_relationship(id) else {
2343 let mut verdict = crate::relationships::RelationshipValidationResult::clean();
2347 verdict.errors.push(Diagnostic::unknown_relation(id));
2348 return Err(relationship_error(&verdict.errors));
2349 };
2350
2351 let verdict = graph.validate_edit(id, new_to, new_kind);
2352 if verdict.blocking() {
2353 return Err(relationship_error(&verdict.errors));
2354 }
2355
2356 let to = new_to.unwrap_or(existing.to);
2357 let kind = new_kind.unwrap_or(existing.kind);
2358 let old_canonical = existing.canonical();
2359
2360 let requested = CanonicalEdge::new(existing.from, to, kind);
2372 let mut updated = existing.clone();
2373 updated.from = requested.from;
2374 updated.to = requested.to;
2375 updated.kind = requested.kind;
2376
2377 let target_title = self
2381 .get(IssueSelector::Number(updated.to))
2382 .map(|i| i.title)
2383 .unwrap_or_default();
2384 updated.refresh_display(&target_title);
2385 let new_canonical = updated.canonical();
2386
2387 if old_canonical == new_canonical && updated.display == existing.display {
2391 return Ok(RelationshipOutcome {
2392 written: None,
2393 relationship: existing.clone(),
2394 changed: false,
2395 });
2396 }
2397
2398 let actor = actor_identity();
2399 let detail = format!("{} → {}: {}", existing.display, updated.display, updated.id);
2400
2401 let mut removed_anywhere = false;
2417 for issue in self.list_all()? {
2418 if !issue
2419 .relationships
2420 .iter()
2421 .any(|r| r.id == id && r.canonical() == old_canonical)
2422 {
2423 continue;
2424 }
2425 let mut owner = self.get(IssueSelector::Filename(issue.filename.clone()))?;
2426 owner
2427 .relationships
2428 .retain(|r| !(r.id == id && r.canonical() == old_canonical));
2429 removed_anywhere = true;
2430 owner.bump_updated();
2431 owner.filename = self.update(&owner.filename, &owner)?;
2432 }
2433 debug_assert!(removed_anywhere, "graph found the row, the scan did not");
2434
2435 let mut new_owner = self.get(IssueSelector::Number(new_canonical.from))?;
2440 new_owner.relationships.push(updated.clone());
2441 new_owner
2442 .history
2443 .append_now(&actor, HistoryEventKind::RelationshipRetargeted, &detail);
2444 new_owner.bump_updated();
2445 new_owner.filename = self.update(&new_owner.filename, &new_owner)?;
2446
2447 Ok(RelationshipOutcome {
2448 written: Some(self.get(IssueSelector::Number(new_canonical.from))?),
2449 relationship: updated,
2450 changed: true,
2451 })
2452 }
2453
2454 pub fn remove_relation(&self, id: &str) -> Result<RelationshipOutcome> {
2460 let stored = self.find_stored_rows_by_id(id)?;
2461 if stored.is_empty() {
2462 return Ok(RelationshipOutcome {
2466 written: None,
2467 relationship: Relationship {
2468 id: id.to_string(),
2469 ..canonical_placeholder(&CanonicalEdge::new(0, 0, RelationKind::Related))
2470 },
2471 changed: false,
2472 });
2473 }
2474
2475 let canonical = stored[0].1.canonical();
2476 let from = canonical.from.to_string();
2480 let to = canonical.to.to_string();
2481 self.unlink(&from, &to, canonical.kind)
2482 }
2483
2484 fn find_stored_rows_by_id(&self, id: &str) -> Result<Vec<(String, Relationship)>> {
2490 let mut found = Vec::new();
2491 for issue in self.list_all()? {
2492 for rel in &issue.relationships {
2493 if rel.id == id {
2494 found.push((issue.filename.clone(), rel.clone()));
2495 }
2496 }
2497 }
2498 Ok(found)
2499 }
2500
2501 pub fn graph_source(&self) -> Result<(Vec<Issue>, String)> {
2520 Ok((self.list()?, self.relationship_done_column()))
2521 }
2522
2523 fn canonical_request(&self, a: &str, b: &str, kind: RelationKind) -> Result<CanonicalEdge> {
2532 let lenient = |sel: &str| -> Result<u32> {
2533 match self.resolve_issue(sel) {
2534 Ok(issue) => Ok(issue.number),
2535 Err(e) => sel.trim().parse::<u32>().map_err(|_| e),
2536 }
2537 };
2538 let from = lenient(a)?;
2539 let to = lenient(b)?;
2540 Ok(CanonicalEdge::new(from, to, kind))
2541 }
2542
2543 fn find_stored_rows(&self, canonical: &CanonicalEdge) -> Result<Vec<(String, Relationship)>> {
2559 let mut found = Vec::new();
2560 for issue in self.list_all()? {
2561 for rel in &issue.relationships {
2562 if rel.canonical() == *canonical {
2563 found.push((issue.filename.clone(), rel.clone()));
2564 }
2565 }
2566 }
2567 Ok(found)
2568 }
2569
2570 pub fn set_relationships(
2585 &self,
2586 filename: &str,
2587 targets: &[(u32, RelationKind)],
2588 ) -> Result<Issue> {
2589 let issue = self.get(IssueSelector::Filename(filename.to_string()))?;
2590 let own = issue.number;
2591 let actor = actor_identity();
2592
2593 let mut wanted: Vec<CanonicalEdge> = Vec::with_capacity(targets.len());
2595 for (target, kind) in targets {
2596 if *target == own {
2597 return Err(TixError::InvalidInput(format!(
2598 "issue #{own} cannot relate to itself"
2599 )));
2600 }
2601 self.get(IssueSelector::Number(*target))?;
2602 let canonical = CanonicalEdge::new(own, *target, *kind);
2603 if !wanted.contains(&canonical) {
2606 wanted.push(canonical);
2607 }
2608 }
2609
2610 {
2619 let issues = self.list_all()?;
2620 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
2621 let done_column = self.relationship_done_column();
2622 let graph = RelationshipGraph::new(&views, &done_column);
2623 for canonical in &wanted {
2624 let verdict = graph.validate_create(canonical.from, canonical.to, canonical.kind);
2625 if verdict.blocking() {
2626 return Err(relationship_error(&verdict.errors));
2627 }
2628 }
2629 }
2630
2631 let issues = self.list_all()?;
2649 let mut removals: HashMap<u32, Vec<Relationship>> = HashMap::new();
2650 for candidate in &issues {
2651 for rel in &candidate.relationships {
2652 let canonical = rel.canonical();
2653 let owned_by_us = canonical.from == own
2654 || (canonical.to == own && !canonical.kind.is_directional());
2655 if owned_by_us && !wanted.contains(&canonical) {
2656 removals
2657 .entry(canonical.from)
2658 .or_default()
2659 .push(rel.clone());
2660 }
2661 }
2662 }
2663
2664 let mut additions: HashMap<u32, Vec<(CanonicalEdge, String)>> = HashMap::new();
2667 for canonical in &wanted {
2668 let owner = match self.get(IssueSelector::Number(canonical.from)) {
2669 Ok(owner) => owner,
2670 Err(TixError::NotFound(_)) => continue,
2671 Err(e) => return Err(e),
2672 };
2673 if owner
2674 .relationships
2675 .iter()
2676 .any(|r| r.canonical() == *canonical)
2677 {
2678 continue;
2679 }
2680 let target_title = self
2681 .get(IssueSelector::Number(canonical.to))
2682 .map(|i| i.title)
2683 .unwrap_or_default();
2684 additions
2685 .entry(canonical.from)
2686 .or_default()
2687 .push((*canonical, target_title));
2688 }
2689
2690 if removals.is_empty() && additions.is_empty() {
2691 return Ok(issue);
2692 }
2693
2694 let mut removal_order: Vec<u32> = removals.keys().copied().collect();
2715 removal_order.sort_unstable();
2716 let mut addition_order: Vec<u32> = additions.keys().copied().collect();
2717 addition_order.sort_unstable();
2718
2719 fn apply(
2724 store: &Store,
2725 mut owner: Issue,
2726 undo: &mut Vec<(PathBuf, String)>,
2727 touched_named: &mut bool,
2728 named_path: &Path,
2729 ) -> Result<()> {
2730 let path = store.issues_dir.join(&owner.filename);
2731 if path == named_path {
2732 *touched_named = true;
2733 }
2734 if let Ok(previous) = std::fs::read_to_string(&path) {
2735 undo.push((path, previous));
2736 }
2737 owner.bump_updated();
2738 store.update(&owner.filename, &owner).map(|_| ())
2739 }
2740
2741 let named_path = self.issues_dir.join(&issue.filename);
2742 let mut undo: Vec<(PathBuf, String)> = Vec::new();
2743 let mut touched_named = false;
2744
2745 let applied = (|| -> Result<()> {
2746 for owner_number in &removal_order {
2747 let doomed = &removals[owner_number];
2748 let mut owner = self.get(IssueSelector::Number(*owner_number))?;
2749 let mut changed = false;
2750 for rel in doomed {
2751 let canonical = rel.canonical();
2752 let before = owner.relationships.len();
2753 owner.relationships.retain(|r| r.canonical() != canonical);
2754 if owner.relationships.len() != before {
2755 let detail = format!("removed {}: {}", rel.id, rel.display);
2756 owner.history.append_now(
2757 &actor,
2758 HistoryEventKind::RelationshipRemoved,
2759 &detail,
2760 );
2761 changed = true;
2762 }
2763 }
2764 if changed {
2765 apply(self, owner, &mut undo, &mut touched_named, &named_path)?;
2766 }
2767 }
2768
2769 for owner_number in &addition_order {
2770 let wanted_here = &additions[owner_number];
2771 let mut owner = self.get(IssueSelector::Number(*owner_number))?;
2772 for (canonical, target_title) in wanted_here {
2773 let rel = Relationship::new(
2774 canonical.from,
2775 canonical.to,
2776 canonical.kind,
2777 target_title,
2778 );
2779 let detail = format!("created {}: {}", rel.id, rel.display);
2780 owner
2781 .history
2782 .append_now(&actor, HistoryEventKind::RelationshipAdded, &detail);
2783 owner.relationships.push(rel);
2784 }
2785 apply(self, owner, &mut undo, &mut touched_named, &named_path)?;
2786 }
2787
2788 if !touched_named {
2802 let named = self.get(IssueSelector::Filename(filename.to_string()))?;
2803 apply(self, named, &mut undo, &mut touched_named, &named_path)?;
2804 }
2805 Ok(())
2806 })();
2807
2808 if let Err(e) = applied {
2809 for (path, previous) in undo.iter().rev() {
2810 let _ = atomic_write(&self.issues_dir, path, previous.as_bytes());
2811 }
2812 return Err(e);
2813 }
2814
2815 self.get(IssueSelector::Filename(filename.to_string()))
2816 }
2817
2818 pub fn resolve_issue(&self, raw: &str) -> Result<Issue> {
2828 let selector = crate::commands::parse_selector(self, raw)?;
2829 self.get(selector)
2830 }
2831
2832 pub fn reassign(&self, filename: &str, new_assignee: Option<&str>) -> Result<PathBuf> {
2855 let issue = self.get(IssueSelector::Filename(filename.to_string()))?;
2857 let mut updated = issue.clone();
2858 updated.assignee = new_assignee.map(str::to_string);
2859 let target_dir = crate::layout::render_directory(&self.config.layout, &updated);
2860 let (old_dir, base) = split_relative(filename).ok_or_else(|| {
2861 TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
2862 })?;
2863 if old_dir == target_dir {
2864 return Ok(PathBuf::from(filename));
2865 }
2866 let stem = base.strip_suffix(".md").unwrap_or(&base);
2867 self.move_issue_file(filename, &target_dir, stem)
2868 }
2869
2870 pub fn migrate_issue(&self, filename: &str, target: &str) -> Result<()> {
2875 if target == filename {
2876 return Ok(());
2877 }
2878 validate_filename(target)?;
2879 let from = self.issues_dir.join(filename);
2880 if !from.is_file() {
2881 return Err(TixError::NotFound(format!("file {filename}")));
2882 }
2883 let to = self.issues_dir.join(target);
2884 if to.exists() {
2885 return Err(TixError::InvalidInput(format!(
2886 "cannot migrate {filename}: {target} already exists"
2887 )));
2888 }
2889 if let Some(parent) = to.parent() {
2890 std::fs::create_dir_all(parent)?;
2891 }
2892 std::fs::rename(&from, &to)?;
2893 let (old_dir, base) = split_relative(filename).unwrap_or_default();
2894 let stem = base.strip_suffix(".md").unwrap_or(&base);
2895 let asset_name = format!("{}-assets", slug_from_stem(stem));
2896 let old_asset_dir = self.issues_dir.join(&old_dir).join(&asset_name);
2897 if old_asset_dir.exists()
2898 && let Some(new_dir) = to.parent()
2899 {
2900 let moved = resolve_asset_dir_name(new_dir, &asset_name);
2901 std::fs::rename(&old_asset_dir, new_dir.join(&moved))?;
2902 }
2903 Ok(())
2904 }
2905
2906 fn move_issue_file(&self, filename: &str, target_dir: &str, stem: &str) -> Result<PathBuf> {
2910 let target_dir_path = self.issues_dir.join(target_dir);
2911 std::fs::create_dir_all(&target_dir_path)?;
2912 let new_name = resolve_target_name(&target_dir_path, stem);
2913 let old_dir = split_relative(filename).map_or_else(String::new, |(d, _)| d);
2914 let from = self.issues_dir.join(filename);
2915 let to = target_dir_path.join(&new_name);
2916 journal_record_move(&from, &to);
2917 std::fs::rename(from, to)?;
2918 let slug = slug_from_stem(stem);
2921 let asset_name = format!("{slug}-assets");
2922 let old_asset_dir = self.issues_dir.join(&old_dir).join(&asset_name);
2923 if old_asset_dir.exists() {
2924 let moved = resolve_asset_dir_name(&target_dir_path, &asset_name);
2925 let asset_target = target_dir_path.join(&moved);
2926 journal_record_move(&old_asset_dir, &asset_target);
2927 std::fs::rename(&old_asset_dir, &asset_target)?;
2928 }
2929 Ok(PathBuf::from(format!("{target_dir}/{new_name}")))
2930 }
2931
2932 #[must_use]
2937 pub fn path_is_laid_out(&self, filename: &str, issue: &Issue) -> bool {
2938 split_relative(filename).is_some_and(|(dir, _)| {
2939 dir == crate::layout::render_directory(&self.config.layout, issue)
2940 })
2941 }
2942
2943 pub fn move_to_layout(&self, issue: &mut Issue) -> Result<bool> {
2947 let before = issue.filename.clone();
2948 let after = self.reconcile_location(&before, issue)?;
2949 issue.filename = after.clone();
2950 Ok(after != before)
2951 }
2952}
2953
2954pub(crate) fn is_issue_rel_path(rel: &Path) -> bool {
2962 let mut file_name: Option<&std::ffi::OsStr> = None;
2963 for component in rel.components() {
2964 match component {
2965 std::path::Component::Normal(segment) => {
2966 let Some(s) = segment.to_str() else {
2967 return false;
2968 };
2969 if s.starts_with(".tix-tmp-") || s.ends_with("-assets") {
2970 return false;
2971 }
2972 file_name = Some(segment);
2973 }
2974 std::path::Component::CurDir => {}
2975 _ => return false,
2977 }
2978 }
2979 file_name.is_some_and(|name| name.to_string_lossy().ends_with(".md"))
2980}
2981
2982fn validate_filename(filename: &str) -> Result<()> {
2991 if filename.is_empty() {
2992 return Err(TixError::InvalidInput(format!(
2993 "filename must not be empty: {filename:?}"
2994 )));
2995 }
2996 if filename.starts_with('/') {
2997 return Err(TixError::InvalidInput(format!(
2998 "filename must be relative (no leading `/`): {filename}"
2999 )));
3000 }
3001 if filename.contains('\\') {
3002 return Err(TixError::InvalidInput(format!(
3003 "filename must not contain backslashes: {filename}"
3004 )));
3005 }
3006 if filename.contains("..") {
3007 return Err(TixError::InvalidInput(format!(
3008 "filename must not contain `..`: {filename}"
3009 )));
3010 }
3011 if filename.contains('\0') {
3012 return Err(TixError::InvalidInput(format!(
3013 "filename must not contain NUL: {filename}"
3014 )));
3015 }
3016 if filename.split('/').any(|component| component.is_empty()) {
3017 return Err(TixError::InvalidInput(format!(
3018 "filename path components must not be empty: {filename}"
3019 )));
3020 }
3021 Ok(())
3022}
3023
3024fn frontmatter_number(text: &str) -> Option<u32> {
3040 let mut lines = text.lines();
3041 if lines.next()?.trim() != "---" {
3043 return None;
3044 }
3045 for line in lines {
3046 let line = line.trim_end();
3047 if line == "---" {
3050 return None;
3051 }
3052 if let Some(rest) = line.strip_prefix("number:") {
3053 return rest.trim().parse().ok();
3054 }
3055 }
3056 None
3057}
3058
3059fn claims_dir(issues_dir: &Path) -> PathBuf {
3071 issues_dir
3072 .parent()
3073 .unwrap_or(issues_dir)
3074 .join(NumberClaim::DIR)
3075}
3076
3077fn split_relative(filename: &str) -> Option<(String, String)> {
3078 let mut parts = filename.rsplitn(2, '/');
3079 let basename = parts.next()?;
3080 let segment = parts.next().unwrap_or("");
3081 Some((segment.to_string(), basename.to_string()))
3082}
3083
3084fn flip_archived(filename: &str, archive: bool) -> Option<String> {
3091 let (segment, basename) = split_relative(filename)?;
3092 let flipped = if archive {
3093 if basename.starts_with('.') {
3094 return None;
3095 }
3096 format!(".{basename}")
3097 } else {
3098 basename.strip_prefix('.')?.to_string()
3099 };
3100 Some(if segment.is_empty() {
3101 flipped
3102 } else {
3103 format!("{segment}/{flipped}")
3104 })
3105}
3106
3107fn resolve_target_name(dir: &Path, stem: &str) -> String {
3110 let mut candidate = format!("{stem}.md");
3111 let mut i = 1u32;
3112 while dir.join(&candidate).exists() {
3113 candidate = format!("{stem}-{i}.md");
3114 i += 1;
3115 }
3116 candidate
3117}
3118
3119fn slug_from_stem(stem: &str) -> String {
3128 let stem = strip_leading_id(stem);
3129 let stem = strip_date_prefix(stem);
3130 strip_leading_id(stem).to_string()
3131}
3132
3133fn strip_leading_id(stem: &str) -> &str {
3135 match stem.split_once('-') {
3136 Some((head, rest)) if crate::id::is_issue_id(head) => rest,
3137 _ => stem,
3138 }
3139}
3140
3141fn strip_date_prefix(stem: &str) -> &str {
3143 let bytes = stem.as_bytes();
3144 if stem.len() > 10 && bytes[4] == b'-' && bytes[7] == b'-' && bytes[10] == b'-' {
3145 &stem[11..]
3146 } else {
3147 stem
3148 }
3149}
3150
3151fn resolve_asset_name(dir: &Path, name: &str) -> String {
3155 if !dir.join(name).exists() {
3156 return name.to_string();
3157 }
3158 let (stem, ext) = match name.rsplit_once('.') {
3159 Some((s, e)) => (s, format!(".{e}")),
3160 None => (name, String::new()),
3161 };
3162 let mut i = 1u32;
3163 loop {
3164 let candidate = format!("{stem}-{i}{ext}");
3165 if !dir.join(&candidate).exists() {
3166 return candidate;
3167 }
3168 i += 1;
3169 }
3170}
3171
3172fn resolve_asset_dir_name(dir: &Path, name: &str) -> String {
3175 let mut candidate = name.to_string();
3176 let mut i = 1u32;
3177 while dir.join(&candidate).exists() {
3178 candidate = format!("{name}-{i}");
3179 i += 1;
3180 }
3181 candidate
3182}
3183
3184fn atomic_copy(dir: &Path, target: &Path, source: &Path) -> Result<()> {
3187 let pid = std::process::id();
3188 let nonce = nanos();
3189 let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
3190 let copy_result = (|| -> std::io::Result<()> {
3191 std::fs::copy(source, &tmp)?;
3192 let f = std::fs::File::open(&tmp)?;
3193 f.sync_all()?;
3194 Ok(())
3195 })();
3196 if let Err(e) = copy_result {
3197 let _ = std::fs::remove_file(&tmp);
3198 return Err(TixError::Io(e));
3199 }
3200 if let Err(e) = std::fs::rename(&tmp, target) {
3201 let _ = std::fs::remove_file(&tmp);
3202 return Err(TixError::Io(e));
3203 }
3204 Ok(())
3205}
3206
3207fn is_image_name(name: &str) -> bool {
3209 matches!(
3210 name.rsplit('.')
3211 .next()
3212 .unwrap_or("")
3213 .to_ascii_lowercase()
3214 .as_str(),
3215 "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp"
3216 )
3217}
3218
3219pub(crate) fn atomic_write(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
3222 write_file(dir, target, data, Link::Overwrite)
3223}
3224
3225fn write_file_new(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
3244 write_file(dir, target, data, Link::Exclusive)
3245}
3246
3247#[derive(Clone, Copy)]
3248enum Link {
3249 Overwrite,
3250 Exclusive,
3251}
3252
3253fn write_file(dir: &Path, target: &Path, data: &[u8], link: Link) -> Result<()> {
3254 journal_record_write(target);
3257 let pid = std::process::id();
3258 let nonce = nanos();
3259 let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
3260 let write_result = (|| -> std::io::Result<()> {
3261 let mut f = std::fs::File::create(&tmp)?;
3262 use std::io::Write;
3263 f.write_all(data)?;
3264 f.sync_all()?;
3265 Ok(())
3266 })();
3267 if let Err(e) = write_result {
3268 let _ = std::fs::remove_file(&tmp);
3269 return Err(TixError::Io(e));
3270 }
3271 let link_result = match link {
3272 Link::Overwrite => std::fs::rename(&tmp, target),
3273 Link::Exclusive => match std::fs::hard_link(&tmp, target) {
3274 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e),
3278 Err(_) => std::fs::rename(&tmp, target),
3281 Ok(()) => {
3282 let _ = std::fs::remove_file(&tmp);
3283 Ok(())
3284 }
3285 },
3286 };
3287 if let Err(e) = link_result {
3288 let _ = std::fs::remove_file(&tmp);
3289 return Err(TixError::Io(e));
3290 }
3291 let _ = std::fs::File::open(dir).and_then(|f| f.sync_all());
3292 Ok(())
3293}
3294
3295fn nanos() -> u64 {
3296 SystemTime::now()
3297 .duration_since(UNIX_EPOCH)
3298 .map(|d| d.as_nanos() as u64)
3299 .unwrap_or(0)
3300}
3301
3302#[cfg(test)]
3303mod tests {
3304 use super::*;
3305 use crate::config::{EpicConfiguration, ProjectConfiguration};
3307 use crate::model::{Priority, today};
3308 use crate::relationships::DiagnosticKind;
3309 use std::fs;
3310 use tempfile::TempDir;
3311
3312 #[test]
3325 fn write_file_new_refuses_an_existing_target() {
3326 let dir = TempDir::new().unwrap();
3327 let target = dir.path().join("issue.md");
3328 write_file_new(dir.path(), &target, b"first").unwrap();
3329 assert_eq!(fs::read_to_string(&target).unwrap(), "first");
3330
3331 let err = write_file_new(dir.path(), &target, b"second").unwrap_err();
3332 let TixError::Io(io_err) = err else {
3333 panic!("expected an I/O error, so the caller can retry on a name collision")
3334 };
3335 assert_eq!(
3336 io_err.kind(),
3337 std::io::ErrorKind::AlreadyExists,
3338 "the error must be AlreadyExists, so the caller can retry on a name \\
3339 collision rather than treating it as a hard failure"
3340 );
3341 assert_eq!(
3342 fs::read_to_string(&target).unwrap(),
3343 "first",
3344 "the existing file must be byte-identical — not truncated, not replaced"
3345 );
3346 }
3347
3348 #[test]
3350 fn a_clobbered_name_is_retried_rather_than_failing_or_overwriting() {
3351 let tmp = TempDir::new().unwrap();
3352 let dir = tmp.path();
3353 let store = Store::open(&crate::config::Config {
3354 issues_dir: dir.to_path_buf(),
3355 ..Default::default()
3356 })
3357 .unwrap();
3358
3359 let segment_dir = dir.join("seg");
3362 fs::create_dir_all(&segment_dir).unwrap();
3363
3364 for name in ["2026-01-01-race.md", "2026-01-01-race-2.md"] {
3367 fs::write(segment_dir.join(name), "planted").unwrap();
3368 }
3369
3370 let (filename, path) = store
3371 .write_new_issue_file("seg", "2026-01-01-race", b"mine")
3372 .unwrap();
3373 assert_eq!(filename, "seg/2026-01-01-race-3.md");
3374 assert_eq!(fs::read_to_string(&path).unwrap(), "mine");
3375 assert_eq!(
3376 fs::read_to_string(segment_dir.join("2026-01-01-race.md")).unwrap(),
3377 "planted"
3378 );
3379 assert_eq!(
3380 fs::read_to_string(segment_dir.join("2026-01-01-race-2.md")).unwrap(),
3381 "planted"
3382 );
3383 }
3384
3385 #[test]
3391 fn no_temp_files_are_left_behind_on_success_or_collision() {
3392 let dir = TempDir::new().unwrap();
3393 let target = dir.path().join("issue.md");
3394 write_file_new(dir.path(), &target, b"first").unwrap();
3395 let _ = write_file_new(dir.path(), &target, b"second");
3396 let leftovers: Vec<String> = fs::read_dir(dir.path())
3397 .unwrap()
3398 .filter_map(|e| e.ok())
3399 .map(|e| e.file_name().to_string_lossy().to_string())
3400 .filter(|n| n.starts_with(".tix-tmp-"))
3401 .collect();
3402 assert!(
3403 leftovers.is_empty(),
3404 "temp files must not survive, found {leftovers:?}"
3405 );
3406 }
3407
3408 fn make_store() -> (tempfile::TempDir, Store) {
3409 let tmp = tempfile::TempDir::new().unwrap();
3410 let dir = tmp.path().join("issues");
3411 fs::create_dir_all(&dir).unwrap();
3412 let config_path = tmp.path().join("tix.toml");
3413 let mut config = Config {
3414 issues_dir: dir.clone(),
3415 ..Config::default()
3416 };
3417 config.layout.directory = "{assignee}".to_string();
3422 config.layout.move_on_change = false;
3423 config.save(&config_path).unwrap();
3424 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
3425 let store = Store {
3426 issues_dir: dir,
3427 config,
3428 };
3429 (tmp, store)
3430 }
3431
3432 fn write_issue(store: &Store, name: &str, contents: &str) {
3433 fs::write(store.issues_dir.join(name), contents).unwrap();
3434 }
3435
3436 fn sample_frontmatter(number: u32, title: &str, status: &str) -> String {
3437 format!(
3438 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
3439 )
3440 }
3441
3442 #[test]
3451 fn list_never_opens_an_archived_file_but_list_all_does() {
3452 let (tmp, store) = make_store();
3453 let _ = tmp;
3454 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3455 write_issue(
3456 &store,
3457 "alice/2026-01-01-active.md",
3458 &sample_frontmatter(1, "Active", "todo"),
3459 );
3460 fs::write(
3461 store.issues_dir.join("alice/.2026-01-02-broken.md"),
3462 "this file has no frontmatter at all\n",
3463 )
3464 .unwrap();
3465
3466 let active = store
3467 .list()
3468 .expect("the default walk must not read the archived file");
3469 assert_eq!(active.len(), 1);
3470 assert_eq!(active[0].number, 1);
3471 assert!(!active[0].archived);
3472
3473 let all = store.list_all();
3474 assert!(
3475 matches!(all, Err(TixError::Parse(_))),
3476 "list_all() reads it, so the broken file is only invisible to list(): {all:?}"
3477 );
3478 }
3479
3480 #[test]
3481 fn archive_renames_in_place_and_restore_is_the_inverse() {
3482 let (tmp, store) = make_store();
3483 let _ = tmp;
3484 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3485 write_issue(
3486 &store,
3487 "alice/2026-01-01-x.md",
3488 &sample_frontmatter(5, "X", "todo"),
3489 );
3490
3491 let archived = store.archive("5").unwrap();
3492 assert_eq!(archived.filename, "alice/.2026-01-01-x.md");
3493 assert!(archived.archived);
3494 assert!(store.issues_dir.join("alice/.2026-01-01-x.md").is_file());
3495 assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
3496 assert!(
3497 store.list().unwrap().is_empty(),
3498 "hidden from the default view"
3499 );
3500
3501 let all = store.list_all().unwrap();
3502 assert_eq!(all.len(), 1);
3503 assert!(all[0].archived);
3504 assert_eq!(all[0].number, 5, "the number is untouched");
3505
3506 assert_eq!(
3508 store.get(IssueSelector::Number(5)).unwrap().filename,
3509 "alice/.2026-01-01-x.md"
3510 );
3511 assert!(store.get(IssueSelector::Slug(slugify("X"))).is_ok());
3512
3513 let restored = store.restore("5").unwrap();
3514 assert_eq!(restored.filename, "alice/2026-01-01-x.md");
3515 assert!(!restored.archived);
3516 assert_eq!(store.list().unwrap().len(), 1, "visible again");
3517 }
3518
3519 #[test]
3520 fn archive_appends_one_event_and_leaves_the_frontmatter_untouched() {
3521 let (tmp, store) = make_store();
3522 let _ = tmp;
3523 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3524 write_issue(
3525 &store,
3526 "alice/2026-01-01-x.md",
3527 &sample_frontmatter(5, "X", "todo"),
3528 );
3529 let before = store
3530 .get(IssueSelector::Filename("alice/2026-01-01-x.md".into()))
3531 .unwrap();
3532
3533 let archived = store.archive("5").unwrap();
3534 assert_eq!(archived.history.len(), 1, "exactly one History event");
3535 assert_eq!(
3536 archived.history.events()[0].kind,
3537 HistoryEventKind::Archived
3538 );
3539
3540 let after = store.get(IssueSelector::Number(5)).unwrap();
3541 assert_eq!(after.number, before.number);
3545 assert_eq!(after.title, before.title);
3546 assert_eq!(after.status, before.status);
3547 assert_eq!(after.labels, before.labels);
3548 assert_eq!(after.priority, before.priority);
3549 assert_eq!(after.assignee, before.assignee);
3550 assert_eq!(after.created, before.created);
3551 assert_eq!(
3552 after.updated, before.updated,
3553 "archiving must not bump `updated`: it is a frontmatter field"
3554 );
3555 let raw = read_issue_file(&store, "alice/.2026-01-01-x.md");
3557 let fm = raw.split("\n---").next().unwrap();
3558 assert!(
3559 !fm.contains("archived"),
3560 "the archived flag must never reach the frontmatter: {fm}"
3561 );
3562 }
3563
3564 #[test]
3565 fn restore_appends_one_restored_event() {
3566 let (tmp, store) = make_store();
3567 let _ = tmp;
3568 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3569 write_issue(
3570 &store,
3571 "alice/2026-01-01-x.md",
3572 &sample_frontmatter(5, "X", "todo"),
3573 );
3574 store.archive("5").unwrap();
3575 let restored = store.restore("5").unwrap();
3576 assert_eq!(restored.history.len(), 2, "archive + restore");
3577 assert_eq!(
3578 restored.history.events()[1].kind,
3579 HistoryEventKind::Restored
3580 );
3581 }
3582
3583 #[test]
3584 fn archiving_the_highest_number_does_not_free_it_for_reuse() {
3585 let (tmp, store) = make_store();
3586 let _ = tmp;
3587 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3588 write_issue(
3589 &store,
3590 "alice/2026-01-01-a.md",
3591 &sample_frontmatter(1, "A", "todo"),
3592 );
3593 write_issue(
3594 &store,
3595 "alice/2026-01-01-b.md",
3596 &sample_frontmatter(7, "B", "todo"),
3597 );
3598
3599 store.archive("7").unwrap();
3600
3601 let mut fresh = Issue::parse(&sample_frontmatter(0, "New", "todo")).unwrap();
3602 store.create(&mut fresh, "body", None).unwrap();
3603 assert_eq!(
3604 fresh.number, 8,
3605 "an archived issue keeps its number: `create` must not reuse #7"
3606 );
3607 }
3608
3609 #[test]
3610 fn redundant_archive_and_restore_are_refused_by_name() {
3611 let (tmp, store) = make_store();
3612 let _ = tmp;
3613 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3614 write_issue(
3615 &store,
3616 "alice/2026-01-01-x.md",
3617 &sample_frontmatter(5, "X", "todo"),
3618 );
3619
3620 let err = store.restore("5").unwrap_err();
3621 assert!(
3622 err.to_string().contains("not_archived"),
3623 "an active issue cannot be restored: {err}"
3624 );
3625 assert!(
3626 store.issues_dir.join("alice/2026-01-01-x.md").is_file(),
3627 "the refused restore writes nothing"
3628 );
3629
3630 store.archive("5").unwrap();
3631 let err = store.archive("5").unwrap_err();
3632 assert!(
3633 err.to_string().contains("already_archived"),
3634 "an archived issue cannot be re-archived: {err}"
3635 );
3636 assert!(
3637 store.issues_dir.join("alice/.2026-01-01-x.md").is_file(),
3638 "the refused archive writes nothing"
3639 );
3640 }
3641
3642 #[test]
3643 fn archive_never_clobbers_an_occupied_target_name() {
3644 let (tmp, store) = make_store();
3645 let _ = tmp;
3646 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3647 write_issue(
3648 &store,
3649 "alice/2026-01-01-x.md",
3650 &sample_frontmatter(5, "X", "todo"),
3651 );
3652 write_issue(
3654 &store,
3655 "alice/.2026-01-01-x.md",
3656 &sample_frontmatter(6, "Other", "todo"),
3657 );
3658 let err = store.archive("5").unwrap_err();
3659 assert!(err.to_string().contains("already exists"), "got: {err}");
3660 assert!(store.issues_dir.join("alice/2026-01-01-x.md").is_file());
3661 assert!(store.issues_dir.join("alice/.2026-01-01-x.md").is_file());
3662 }
3663
3664 #[test]
3665 fn archive_and_restore_a_legacy_flat_file_in_place() {
3666 let (tmp, store) = make_store();
3667 let _ = tmp;
3668 write_issue(
3669 &store,
3670 "2026-01-01-legacy.md",
3671 &sample_frontmatter(3, "Legacy", "todo"),
3672 );
3673
3674 let archived = store.archive("3").unwrap();
3675 assert_eq!(archived.filename, ".2026-01-01-legacy.md");
3676 assert!(store.issues_dir.join(".2026-01-01-legacy.md").is_file());
3677 assert_eq!(store.list().unwrap(), [] as [crate::model::Issue; 0]);
3678 assert_eq!(store.list_all().unwrap().len(), 1);
3679
3680 let restored = store.restore("3").unwrap();
3681 assert_eq!(restored.filename, "2026-01-01-legacy.md");
3682 assert!(store.issues_dir.join("2026-01-01-legacy.md").is_file());
3683 }
3684
3685 #[test]
3686 fn list_returns_sorted_by_filename() {
3687 let (tmp, store) = make_store();
3688 let _ = tmp;
3689 write_issue(
3690 &store,
3691 "2026-03-01-c.md",
3692 &sample_frontmatter(3, "C", "todo"),
3693 );
3694 write_issue(
3695 &store,
3696 "2026-01-01-a.md",
3697 &sample_frontmatter(1, "A", "todo"),
3698 );
3699 write_issue(
3700 &store,
3701 "2026-02-01-b.md",
3702 &sample_frontmatter(2, "B", "todo"),
3703 );
3704 let issues = store.list().unwrap();
3705 assert_eq!(issues.len(), 3);
3706 assert_eq!(issues[0].number, 1);
3707 assert_eq!(issues[1].number, 2);
3708 assert_eq!(issues[2].number, 3);
3709 assert_eq!(issues[0].title, "A");
3710 }
3711
3712 #[test]
3715 fn is_issue_rel_path_matches_what_scan_walks() {
3716 for path in [
3718 "a.md",
3719 "alice/b.md",
3720 ".hidden.md",
3721 "alice/.hidden.md",
3722 "deep/a/b.md",
3723 ] {
3724 assert!(
3725 is_issue_rel_path(Path::new(path)),
3726 "{path} is an issue file `scan` reads"
3727 );
3728 }
3729 for path in [
3731 "notes.txt",
3732 "alice/notes.txt",
3733 "alice",
3734 ".tix-tmp-x/a.md",
3735 "foo-assets/a.md",
3736 "alice/foo-assets/a.md",
3737 ] {
3738 assert!(
3739 !is_issue_rel_path(Path::new(path)),
3740 "{path} is not a path `scan` indexes"
3741 );
3742 }
3743 }
3744
3745 #[test]
3746 fn scan_above_the_parallel_threshold_is_sorted_and_complete() {
3747 let (tmp, store) = make_store();
3751 let _ = tmp;
3752 let n = Store::PARALLEL_SCAN_THRESHOLD + 44;
3753 for i in (1..=n).rev() {
3754 write_issue(
3755 &store,
3756 &format!("2026-01-01-{i:04}.md"),
3757 &sample_frontmatter(i as u32, &format!("T{i}"), "todo"),
3758 );
3759 }
3760 let issues = store.list().unwrap();
3761 assert_eq!(
3762 issues.len(),
3763 n,
3764 "every file is read, none dropped or doubled"
3765 );
3766 let names: Vec<&str> = issues.iter().map(|i| i.filename.as_str()).collect();
3767 let mut sorted = names.clone();
3768 sorted.sort_unstable();
3769 assert_eq!(names, sorted, "sorted by filename as `scan` promises");
3770 assert_eq!(issues[0].number, 1, "content came from the first file");
3771 assert_eq!(issues[n - 1].number, n as u32, "and from the last");
3772 }
3773
3774 #[test]
3775 fn read_issue_at_sets_filename_and_archived_like_scan() {
3776 let (tmp, store) = make_store();
3777 let _ = tmp;
3778 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3779 write_issue(
3780 &store,
3781 "alice/2026-01-01-a.md",
3782 &sample_frontmatter(1, "A", "todo"),
3783 );
3784 write_issue(
3785 &store,
3786 "alice/.2026-01-02-b.md",
3787 &sample_frontmatter(2, "B", "todo"),
3788 );
3789
3790 let active = store
3791 .read_issue_at(Path::new("alice/2026-01-01-a.md"))
3792 .unwrap();
3793 assert_eq!(active.number, 1);
3794 assert_eq!(active.filename, "alice/2026-01-01-a.md");
3795 assert!(!active.archived);
3796
3797 let archived = store
3798 .read_issue_at(Path::new("alice/.2026-01-02-b.md"))
3799 .unwrap();
3800 assert_eq!(archived.filename, "alice/.2026-01-02-b.md");
3801 assert!(
3802 archived.archived,
3803 "the leading dot marks it archived, as `scan` does"
3804 );
3805
3806 write_issue(&store, "flat.md", &sample_frontmatter(3, "F", "todo"));
3808 assert_eq!(
3809 store.read_issue_at(Path::new("flat.md")).unwrap().filename,
3810 "flat.md"
3811 );
3812 assert!(matches!(
3813 store.read_issue_at(Path::new("alice/gone.md")),
3814 Err(TixError::NotFound(_))
3815 ));
3816 }
3817
3818 #[test]
3819 fn list_ignores_non_md_files() {
3820 let (tmp, store) = make_store();
3821 let _ = tmp;
3822 write_issue(
3823 &store,
3824 "2026-01-01-a.md",
3825 &sample_frontmatter(1, "A", "todo"),
3826 );
3827 write_issue(&store, "readme.txt", "not an issue");
3828 write_issue(&store, "2026-01-01-sub", "no extension");
3829 let issues = store.list().unwrap();
3830 assert_eq!(issues.len(), 1);
3831 }
3832
3833 #[test]
3834 fn list_errors_on_malformed_frontmatter() {
3835 let (tmp, store) = make_store();
3836 let _ = tmp;
3837 write_issue(&store, "2026-01-01-bad.md", "no frontmatter here\n");
3838 let err = store.list().unwrap_err();
3839 assert!(matches!(err, TixError::Parse(_)), "got {err:?}");
3840 }
3841
3842 #[test]
3843 fn get_by_number() {
3844 let (tmp, store) = make_store();
3845 let _ = tmp;
3846 write_issue(
3847 &store,
3848 "2026-01-01-x.md",
3849 &sample_frontmatter(5, "X", "todo"),
3850 );
3851 let issue = store.get(IssueSelector::Number(5)).unwrap();
3852 assert_eq!(issue.title, "X");
3853 }
3854
3855 #[test]
3856 fn get_by_filename() {
3857 let (tmp, store) = make_store();
3858 let _ = tmp;
3859 write_issue(
3860 &store,
3861 "2026-01-01-y.md",
3862 &sample_frontmatter(2, "Y", "done"),
3863 );
3864 let issue = store
3865 .get(IssueSelector::Filename("2026-01-01-y.md".into()))
3866 .unwrap();
3867 assert_eq!(issue.status.display(), "done");
3868 }
3869
3870 #[test]
3871 fn get_by_slug() {
3872 let (tmp, store) = make_store();
3873 let _ = tmp;
3874 write_issue(
3875 &store,
3876 "2026-01-01-z.md",
3877 &sample_frontmatter(3, "Z", "todo"),
3878 );
3879 let issue = store.get(IssueSelector::Slug("z".into())).unwrap();
3880 assert_eq!(issue.number, 3);
3881 }
3882
3883 #[test]
3884 fn get_not_found() {
3885 let (tmp, store) = make_store();
3886 let _ = tmp;
3887 assert!(store.get(IssueSelector::Number(99)).is_err());
3888 }
3889
3890 #[test]
3891 fn create_assigns_number_from_live_issues_and_leaves_config_untouched() {
3892 let (tmp, store) = make_store();
3893 let _ = tmp;
3894 assert_eq!(store.config.state.next_number, 0);
3895 let mut issue = Issue {
3896 projects: None,
3897 id: None,
3898 number: 0,
3899 title: "First".into(),
3900 status: Status::default(),
3901 labels: vec![],
3902 priority: Priority::default(),
3903 assignee: None,
3904 estimate: None,
3905 commit: None,
3906 gitlab_id: None,
3907 linear_id: None,
3908 mission: None,
3909 task: None,
3910 relationships: vec![],
3911 related_issues: vec![],
3912 files: vec![],
3913 cycles: None,
3914 milestones: None,
3915 reviewer: None,
3916 start_date: None,
3917 due_date: None,
3918 created: "2026-01-01".into(),
3919 type_: None,
3920 updated: "2026-01-01".into(),
3921 filename: String::new(),
3922 body: String::new(),
3923 history: crate::model::History::new(),
3924 has_retired_links_key: false,
3925 time_entries: vec![],
3926 parent: None,
3927 series: None,
3928 health: None,
3929 epics: None,
3930 extra_frontmatter: std::collections::BTreeMap::new(),
3931 archived: false,
3932 entered_at: None,
3933 source: None,
3934 };
3935 let path = store.create(&mut issue, "Hello world", None).unwrap();
3936 assert!(path.exists());
3937 assert_eq!(
3938 issue.number, 1,
3939 "an empty store starts at 1, not 0 — #0 is indistinguishable from a \
3940 sentinel or a failed read (tix #1376)"
3941 );
3942 let config_path = tmp.path().join("tix.toml");
3944 let on_disk = std::fs::read_to_string(&config_path).unwrap();
3945 assert!(
3946 !on_disk.contains("next_number"),
3947 "create must not persist an issue-number counter:\n{on_disk}"
3948 );
3949 let reloaded = Config::load_with_warning(&config_path, |_| {}).unwrap();
3950 assert_eq!(reloaded.state.next_number, 0, "state is never written");
3951 }
3952
3953 #[test]
3954 fn create_resolves_max_live_number_plus_one() {
3955 let (tmp, store) = make_store();
3956 let _ = tmp;
3957 write_issue(
3958 &store,
3959 "2026-01-01-a.md",
3960 &sample_frontmatter(0, "A", "todo"),
3961 );
3962 write_issue(
3963 &store,
3964 "2026-01-01-b.md",
3965 &sample_frontmatter(1, "B", "todo"),
3966 );
3967 write_issue(
3968 &store,
3969 "2026-01-01-c.md",
3970 &sample_frontmatter(2, "C", "todo"),
3971 );
3972 let mut issue = Issue {
3973 projects: None,
3974 id: None,
3975 number: 0,
3976 title: "D".into(),
3977 status: Status::default(),
3978 labels: vec![],
3979 priority: Priority::default(),
3980 assignee: None,
3981 estimate: None,
3982 commit: None,
3983 gitlab_id: None,
3984 linear_id: None,
3985 mission: None,
3986 task: None,
3987 relationships: vec![],
3988 related_issues: vec![],
3989 files: vec![],
3990 cycles: None,
3991 milestones: None,
3992 reviewer: None,
3993 start_date: None,
3994 due_date: None,
3995 created: "2026-01-01".into(),
3996 type_: None,
3997 updated: "2026-01-01".into(),
3998 filename: String::new(),
3999 body: String::new(),
4000 history: crate::model::History::new(),
4001 has_retired_links_key: false,
4002 time_entries: vec![],
4003 parent: None,
4004 series: None,
4005 health: None,
4006 epics: None,
4007 extra_frontmatter: std::collections::BTreeMap::new(),
4008 archived: false,
4009 entered_at: None,
4010 source: None,
4011 };
4012 store.create(&mut issue, "", None).unwrap();
4013 assert_eq!(issue.number, 3);
4014 }
4015
4016 #[test]
4017 fn create_reuses_number_of_deleted_highest_issue() {
4018 let (_tmp, store) = make_store();
4022 let mut issue_a = Issue {
4023 projects: None,
4024 id: None,
4025 number: 0,
4026 title: "A".into(),
4027 status: Status::default(),
4028 labels: vec![],
4029 priority: Priority::default(),
4030 assignee: None,
4031 estimate: None,
4032 commit: None,
4033 gitlab_id: None,
4034 linear_id: None,
4035 mission: None,
4036 task: None,
4037 relationships: vec![],
4038 related_issues: vec![],
4039 files: vec![],
4040 cycles: None,
4041 milestones: None,
4042 reviewer: None,
4043 start_date: None,
4044 due_date: None,
4045 created: "2026-01-01".into(),
4046 type_: None,
4047 updated: "2026-01-01".into(),
4048 filename: String::new(),
4049 body: String::new(),
4050 history: crate::model::History::new(),
4051 has_retired_links_key: false,
4052 time_entries: vec![],
4053 parent: None,
4054 series: None,
4055 health: None,
4056 epics: None,
4057 extra_frontmatter: std::collections::BTreeMap::new(),
4058 archived: false,
4059 entered_at: None,
4060 source: None,
4061 };
4062 store.create(&mut issue_a, "", None).unwrap();
4063 let filename = issue_a.filename.clone();
4064 assert_eq!(issue_a.number, 1, "the first issue in a fresh store is #1");
4065 store.delete(&filename).unwrap();
4066 let mut issue_b = Issue {
4067 projects: None,
4068 id: None,
4069 number: 0,
4070 title: "B".into(),
4071 status: Status::default(),
4072 labels: vec![],
4073 priority: Priority::default(),
4074 assignee: None,
4075 estimate: None,
4076 commit: None,
4077 gitlab_id: None,
4078 linear_id: None,
4079 mission: None,
4080 task: None,
4081 relationships: vec![],
4082 related_issues: vec![],
4083 files: vec![],
4084 cycles: None,
4085 milestones: None,
4086 reviewer: None,
4087 start_date: None,
4088 due_date: None,
4089 created: "2026-01-02".into(),
4090 type_: None,
4091 updated: "2026-01-02".into(),
4092 filename: String::new(),
4093 body: String::new(),
4094 history: crate::model::History::new(),
4095 has_retired_links_key: false,
4096 time_entries: vec![],
4097 parent: None,
4098 series: None,
4099 health: None,
4100 epics: None,
4101 extra_frontmatter: std::collections::BTreeMap::new(),
4102 archived: false,
4103 entered_at: None,
4104 source: None,
4105 };
4106 store.create(&mut issue_b, "", None).unwrap();
4107 assert_eq!(
4108 issue_b.number, 1,
4109 "an emptied store resumes at the same floor as a fresh one: 1"
4110 );
4111 }
4112
4113 #[test]
4114 fn create_with_custom_filename_stem() {
4115 let (tmp, store) = make_store();
4116 let _ = tmp;
4117 let mut issue = Issue {
4118 projects: None,
4119 id: None,
4120 number: 0,
4121 title: "T".into(),
4122 status: Status::default(),
4123 labels: vec![],
4124 priority: Priority::default(),
4125 assignee: None,
4126 estimate: None,
4127 commit: None,
4128 gitlab_id: None,
4129 linear_id: None,
4130 mission: None,
4131 task: None,
4132 relationships: vec![],
4133 related_issues: vec![],
4134 files: vec![],
4135 cycles: None,
4136 milestones: None,
4137 reviewer: None,
4138 start_date: None,
4139 due_date: None,
4140 created: "2026-01-01".into(),
4141 type_: None,
4142 updated: "2026-01-01".into(),
4143 filename: String::new(),
4144 body: String::new(),
4145 history: crate::model::History::new(),
4146 has_retired_links_key: false,
4147 time_entries: vec![],
4148 parent: None,
4149 series: None,
4150 health: None,
4151 epics: None,
4152 extra_frontmatter: std::collections::BTreeMap::new(),
4153 archived: false,
4154 entered_at: None,
4155 source: None,
4156 };
4157 let path = store
4158 .create(&mut issue, "body", Some("custom-stem"))
4159 .unwrap();
4160 assert!(path.to_string_lossy().contains("custom-stem.md"));
4161 }
4162
4163 #[test]
4164 fn create_ensures_issues_dir_exists() {
4165 let tmp = tempfile::TempDir::new().unwrap();
4166 let dir = tmp.path().join("nested").join("issues");
4167 let config_path = tmp.path().join("tix.toml");
4168 let config = Config {
4169 issues_dir: dir.clone(),
4170 ..Config::default()
4171 };
4172 config.save(&config_path).unwrap();
4173 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
4174 let store = Store {
4175 issues_dir: dir,
4176 config,
4177 };
4178 let mut issue = Issue {
4179 projects: None,
4180 id: None,
4181 number: 0,
4182 title: "T".into(),
4183 status: Status::default(),
4184 labels: vec![],
4185 priority: Priority::default(),
4186 assignee: None,
4187 estimate: None,
4188 commit: None,
4189 gitlab_id: None,
4190 linear_id: None,
4191 mission: None,
4192 task: None,
4193 relationships: vec![],
4194 related_issues: vec![],
4195 files: vec![],
4196 cycles: None,
4197 milestones: None,
4198 reviewer: None,
4199 start_date: None,
4200 due_date: None,
4201 created: "2026-01-01".into(),
4202 type_: None,
4203 updated: "2026-01-01".into(),
4204 filename: String::new(),
4205 body: String::new(),
4206 history: crate::model::History::new(),
4207 has_retired_links_key: false,
4208 time_entries: vec![],
4209 parent: None,
4210 series: None,
4211 health: None,
4212 epics: None,
4213 extra_frontmatter: std::collections::BTreeMap::new(),
4214 archived: false,
4215 entered_at: None,
4216 source: None,
4217 };
4218 store.create(&mut issue, "", None).unwrap();
4219 assert_eq!(issue.number, 1, "the first issue in a fresh store is #1");
4220 }
4221
4222 #[test]
4223 fn update_replaces_content_atomically() {
4224 let (tmp, store) = make_store();
4225 let _ = tmp;
4226 write_issue(
4227 &store,
4228 "2026-01-01-u.md",
4229 &sample_frontmatter(1, "U", "todo"),
4230 );
4231 let mut issue = store
4232 .get(IssueSelector::Filename("2026-01-01-u.md".into()))
4233 .unwrap();
4234 issue.title = "Updated".into();
4235 issue.bump_updated();
4236 store.update("2026-01-01-u.md", &issue).unwrap();
4237 let reloaded = store
4238 .get(IssueSelector::Filename("2026-01-01-u.md".into()))
4239 .unwrap();
4240 assert_eq!(reloaded.title, "Updated");
4241 }
4242
4243 #[test]
4244 fn update_not_found() {
4245 let (tmp, store) = make_store();
4246 let _ = tmp;
4247 let issue = Issue {
4248 projects: None,
4249 id: None,
4250 number: 1,
4251 title: "T".into(),
4252 status: Status::default(),
4253 labels: vec![],
4254 priority: Priority::default(),
4255 assignee: None,
4256 estimate: None,
4257 commit: None,
4258 gitlab_id: None,
4259 linear_id: None,
4260 mission: None,
4261 task: None,
4262 relationships: vec![],
4263 related_issues: vec![],
4264 files: vec![],
4265 cycles: None,
4266 milestones: None,
4267 reviewer: None,
4268 start_date: None,
4269 due_date: None,
4270 created: "2026-01-01".into(),
4271 type_: None,
4272 updated: "2026-01-01".into(),
4273 filename: String::new(),
4274 body: String::new(),
4275 history: crate::model::History::new(),
4276 has_retired_links_key: false,
4277 time_entries: vec![],
4278 parent: None,
4279 series: None,
4280 health: None,
4281 epics: None,
4282 extra_frontmatter: std::collections::BTreeMap::new(),
4283 archived: false,
4284 entered_at: None,
4285 source: None,
4286 };
4287 assert!(store.update("nope.md", &issue).is_err());
4288 }
4289
4290 #[test]
4291 fn delete_removes_file() {
4292 let (tmp, store) = make_store();
4293 let _ = tmp;
4294 write_issue(
4295 &store,
4296 "2026-01-01-d.md",
4297 &sample_frontmatter(1, "D", "todo"),
4298 );
4299 assert!(store.issues_dir.join("2026-01-01-d.md").exists());
4300 store.delete("2026-01-01-d.md").unwrap();
4301 assert!(!store.issues_dir.join("2026-01-01-d.md").exists());
4302 }
4303
4304 #[test]
4305 fn delete_not_found() {
4306 let (tmp, store) = make_store();
4307 let _ = tmp;
4308 assert!(store.delete("nope.md").is_err());
4309 }
4310
4311 #[test]
4312 fn stray_tmp_file_not_treated_as_issue() {
4313 let (tmp, store) = make_store();
4314 let _ = tmp;
4315 write_issue(
4316 &store,
4317 "2026-01-01-a.md",
4318 &sample_frontmatter(1, "A", "todo"),
4319 );
4320 fs::write(store.issues_dir.join(".tix-tmp-9999-0"), "orphaned data").unwrap();
4321 let issues = store.list().unwrap();
4322 assert_eq!(issues.len(), 1);
4323 }
4324
4325 fn seam_store() -> (tempfile::TempDir, Store) {
4342 let tmp = tempfile::TempDir::new().unwrap();
4343 let dir = tmp.path().join("issues");
4344 fs::create_dir_all(&dir).unwrap();
4345 let config_path = tmp.path().join("tix.toml");
4346 let mut config = Config {
4347 issues_dir: dir.clone(),
4348 kanban: crate::config::KanbanSection {
4349 configurations: indexmap::IndexMap::from([(
4350 "default".to_string(),
4351 crate::config::BoardConfiguration {
4352 columns: vec![
4353 "triage".into(),
4354 "todo".into(),
4355 "doing".into(),
4356 "done".into(),
4357 ],
4358 done_column: Some("done".to_string()),
4359 category_map: indexmap::IndexMap::from([
4360 ("triage".to_string(), "backlog".to_string()),
4361 ("todo".to_string(), "unstarted".to_string()),
4362 ("doing".to_string(), "active".to_string()),
4363 ("done".to_string(), "completed".to_string()),
4364 ]),
4365 ..Default::default()
4366 },
4367 )]),
4368 },
4369 ..Config::default()
4370 };
4371 config.layout.move_on_change = false;
4372 config.save(&config_path).unwrap();
4373 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
4374 (
4375 tmp,
4376 Store {
4377 issues_dir: dir,
4378 config,
4379 },
4380 )
4381 }
4382
4383 fn seam_issue(status: &str) -> (tempfile::TempDir, Store, String) {
4391 let (tmp, store) = seam_store();
4392 write_issue(
4393 &store,
4394 "2026-01-01-seam.md",
4395 &format!(
4396 "---\nnumber: 1\ntitle: \"Seam\"\nstatus: {status}\nlabels: []\n\
4397 priority: \"medium\"\nfiles: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody."
4398 ),
4399 );
4400 let before = read_issue_file(&store, "2026-01-01-seam.md");
4401 (tmp, store, before)
4402 }
4403
4404 fn assert_untouched(store: &Store, before: &str, what: &str) {
4406 let after = read_issue_file(store, "2026-01-01-seam.md");
4407 assert_eq!(after, before, "{what}: the file must be byte-identical");
4408 }
4409
4410 #[test]
4411 fn t015_accepted_transition_writes_status_bumps_updated_and_appends_one_decision() {
4412 let (_tmp, store, _before) = seam_issue("todo");
4413 let outcome = store
4414 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
4415 .unwrap();
4416 assert!(outcome.written, "an accepted, changing move writes");
4417 let decision = outcome
4418 .decision
4419 .clone()
4420 .expect("an accepted transition appends exactly one decision");
4421 assert_eq!(decision.from, "todo");
4422 assert_eq!(decision.to, "doing");
4423 assert_eq!(decision.from_category, "unstarted");
4424 assert_eq!(decision.to_category, "active");
4425 assert!(
4426 decision.override_reason.is_none(),
4427 "no override was supplied"
4428 );
4429 assert_eq!(
4430 outcome.issue.status.display(),
4431 "doing",
4432 "Issue.status holds the COLUMN name, never a category (C-001)"
4433 );
4434 assert_eq!(outcome.issue.updated, today(), "`updated` is bumped");
4435
4436 let reloaded = store
4438 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4439 .unwrap();
4440 assert_eq!(reloaded.status.display(), "doing");
4441 let events = reloaded.history.events();
4442 assert_eq!(
4443 events.len(),
4444 1,
4445 "exactly one History entry, and it is the decision: {events:?}"
4446 );
4447 assert_eq!(events[0].kind, HistoryEventKind::TransitionAccepted);
4448 assert_eq!(
4449 crate::model::TransitionDecision::from_event(events[0])
4450 .unwrap()
4451 .to,
4452 "doing",
4453 "and the decision round-trips out of the file with its fields intact"
4454 );
4455 }
4456
4457 #[test]
4458 fn t015_unknown_column_is_refused_by_name_and_writes_nothing() {
4459 let (_tmp, store, before) = seam_issue("todo");
4460 let err = store
4461 .set_status("2026-01-01-seam.md", StatusChange::to("nope"))
4462 .unwrap_err();
4463 assert!(
4464 matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "unknown_column"),
4465 "the shared named diagnostic, contract §6: {err:?}"
4466 );
4467 assert!(
4468 err.to_string()
4469 .contains("valid columns: triage, todo, doing, done"),
4470 "in BOARD order, not hash order: {err}"
4471 );
4472 assert_untouched(&store, &before, "unknown column");
4473 }
4474
4475 #[test]
4476 fn t015_terminal_exit_is_refused_and_writes_nothing() {
4477 let (_tmp, store, before) = seam_issue("done");
4478 let err = store
4479 .set_status("2026-01-01-seam.md", StatusChange::to("todo"))
4480 .unwrap_err();
4481 assert!(
4482 matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "invalid_transition"),
4483 "{err:?}"
4484 );
4485 assert_untouched(&store, &before, "terminal exit without a reason");
4486 }
4487
4488 #[test]
4489 fn t015_adjacency_restricted_backwards_move_is_refused_and_writes_nothing() {
4490 let (_tmp, store, before) = seam_issue("doing");
4494 let err = store
4495 .set_status("2026-01-01-seam.md", StatusChange::to("triage"))
4496 .unwrap_err();
4497 assert!(
4498 matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "invalid_transition"),
4499 "{err:?}"
4500 );
4501 assert!(
4502 err.to_string().contains("category policy"),
4503 "the message names the rule that refused it: {err}"
4504 );
4505 assert_untouched(&store, &before, "adjacency refusal");
4506 }
4507
4508 #[test]
4509 fn t015_terminal_exit_with_a_reason_is_accepted_and_the_reason_is_recorded() {
4510 let (_tmp, store, before) = seam_issue("done");
4511 let outcome = store
4512 .set_status(
4513 "2026-01-01-seam.md",
4514 StatusChange::to("todo").with_override(Some("reopening: wrong epic")),
4515 )
4516 .unwrap();
4517 assert!(outcome.written);
4518 let decision = outcome.decision.expect("one decision");
4519 assert_eq!(
4520 decision.override_reason.as_deref(),
4521 Some("reopening: wrong epic"),
4522 "an override with no recorded reason is not an audit record"
4523 );
4524 assert_ne!(
4525 read_issue_file(&store, "2026-01-01-seam.md"),
4526 before,
4527 "sanity: an accepted move must have changed the file at all"
4528 );
4529 }
4530
4531 #[test]
4532 fn t015_a_blank_reason_is_not_an_override() {
4533 let (_tmp, store, before) = seam_issue("done");
4538 let err = store
4539 .set_status(
4540 "2026-01-01-seam.md",
4541 StatusChange::to("todo").with_override(Some(" ")),
4542 )
4543 .unwrap_err();
4544 assert!(
4545 matches!(&err, TixError::Workflow(_)),
4546 "a blank reason is not a reason: {err:?}"
4547 );
4548 assert_untouched(&store, &before, "blank override reason");
4549 }
4550
4551 #[test]
4552 fn t015_same_column_is_accepted_and_writes_nothing() {
4553 let (_tmp, store, before) = seam_issue("doing");
4554 let outcome = store
4555 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
4556 .unwrap();
4557 assert!(!outcome.written, "a same-column re-apply is a no-op");
4558 assert!(
4559 outcome.decision.is_none(),
4560 "and appends no decision, contract §3.2"
4561 );
4562 assert_untouched(&store, &before, "same column");
4563 let reloaded = store
4566 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4567 .unwrap();
4568 assert_eq!(
4569 reloaded.updated, "2026-01-01",
4570 "the fixture's original date"
4571 );
4572 }
4573
4574 #[test]
4575 fn t015_no_new_frontmatter_key_was_introduced() {
4576 let (_tmp, store, before) = seam_issue("todo");
4580 store
4581 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
4582 .unwrap();
4583 let after = read_issue_file(&store, "2026-01-01-seam.md");
4584 let keys = |text: &str| -> Vec<String> {
4585 text.split("---\n")
4586 .nth(1)
4587 .unwrap()
4588 .lines()
4589 .filter_map(|l| l.split_once(':').map(|(k, _)| k.trim().to_string()))
4590 .filter(|k| !k.is_empty())
4591 .collect()
4592 };
4593 assert_eq!(
4594 keys(&after),
4595 keys(&before),
4596 "the seam must not add or remove a top-level frontmatter key"
4597 );
4598 }
4599
4600 #[test]
4601 fn t015_a_hand_edited_inconsistent_file_is_read_and_not_repaired() {
4602 let (_tmp, store, before) = seam_issue("doing");
4607 let edited = before.replace("status: doing", "status: triage");
4610 write_issue(&store, "2026-01-01-seam.md", &edited);
4611 let after_edit = read_issue_file(&store, "2026-01-01-seam.md");
4612
4613 let issue = store
4614 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4615 .unwrap();
4616 assert_eq!(
4617 issue.status.display(),
4618 "triage",
4619 "a hand-edited status is read, not rejected at load"
4620 );
4621 assert_eq!(
4622 read_issue_file(&store, "2026-01-01-seam.md"),
4623 after_edit,
4624 "reading must not repair it"
4625 );
4626 store
4629 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
4630 .unwrap();
4631 let moved = store
4632 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4633 .unwrap();
4634 assert_eq!(moved.status.display(), "doing");
4635 assert_eq!(
4636 moved.history.events().len(),
4637 1,
4638 "only the new decision; nothing was written to explain the old one"
4639 );
4640 }
4641
4642 #[test]
4643 fn t015_every_rejection_kind_is_one_of_the_contract_vocabulary() {
4644 let allowed = [
4648 "missing_completion_category",
4649 "unknown_category",
4650 "mapping_for_unknown_column",
4651 "unmapped_column_default",
4652 "invalid_transition",
4653 "unknown_column",
4654 "direct_edit_policy_violation",
4655 ];
4656 let cases: Vec<(&str, &str)> = vec![
4657 ("todo", "nope"), ("done", "todo"), ("doing", "triage"), ];
4661 for (from, to) in cases {
4662 let (_tmp, store, _before) = seam_issue(from);
4663 let err = store
4664 .set_status("2026-01-01-seam.md", StatusChange::to(to))
4665 .unwrap_err();
4666 let TixError::Workflow(d) = &err else {
4667 panic!("{from} -> {to} must refuse through the workflow variant: {err:?}");
4668 };
4669 assert!(
4670 allowed.contains(&d.kind.as_str()),
4671 "`{}` is not in the contract's vocabulary",
4672 d.kind.as_str()
4673 );
4674 }
4675 }
4676
4677 #[test]
4678 fn t015_a_legacy_board_gains_no_policy_but_keeps_completion() {
4679 let (tmp, store) = make_store();
4684 let _ = tmp;
4685 write_issue(
4686 &store,
4687 "2026-01-01-legacy.md",
4688 &sample_frontmatter(1, "Legacy", "done"),
4689 );
4690 let moved = store
4691 .set_status("2026-01-01-legacy.md", StatusChange::to("todo"))
4692 .unwrap();
4693 assert!(moved.written, "a legacy board imposes no policy");
4694 assert_eq!(moved.issue.status.display(), "todo");
4695
4696 let resolution =
4699 crate::workflow::resolution(&store.config, store.config.default_config(), "default");
4700 assert!(
4701 !resolution.explicit_policy,
4702 "precondition: this board declared no category_map"
4703 );
4704 assert!(
4705 resolution.has_completion(),
4706 "but it still has a done column"
4707 );
4708 assert!(
4709 !crate::workflow::is_completed(&moved.issue, &resolution),
4710 "and `todo` is not complete"
4711 );
4712 }
4713
4714 #[test]
4715 fn t015_a_mapped_board_keeps_the_commit_coupling() {
4716 let (_tmp, store, _before) = seam_issue("doing");
4720 store
4721 .set_status(
4722 "2026-01-01-seam.md",
4723 StatusChange::to("done").with_commit(Some("abc123")),
4724 )
4725 .unwrap();
4726 let at_done = store
4727 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4728 .unwrap();
4729 assert_eq!(at_done.commit.as_deref(), Some("abc123"));
4730
4731 store
4732 .set_status(
4733 "2026-01-01-seam.md",
4734 StatusChange::to("todo").with_override(Some("not actually finished")),
4735 )
4736 .unwrap();
4737 let off_done = store
4738 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4739 .unwrap();
4740 assert_eq!(
4741 off_done.commit, None,
4742 "leaving completion clears the recorded commit"
4743 );
4744 }
4745
4746 #[test]
4747 fn t015_a_decision_round_trips_through_a_file_unchanged() {
4748 let (_tmp, store, _before) = seam_issue("todo");
4752 store
4753 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
4754 .unwrap();
4755 let first = store
4756 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4757 .unwrap();
4758 let text = read_issue_file(&store, "2026-01-01-seam.md");
4759 let parsed = Issue::parse(&text).unwrap();
4761 let events = parsed.history.events();
4762 let decision = crate::model::TransitionDecision::from_event(events[0]).unwrap();
4763 assert_eq!(decision.to, "doing");
4764 assert_eq!(decision.from_category, "unstarted");
4765 assert_eq!(decision.to_category, "active");
4766 assert_eq!(
4769 parsed.to_file_contents(),
4770 text,
4771 "a parsed decision must re-render to the same bytes"
4772 );
4773 assert_eq!(parsed.history.events().len(), first.history.events().len());
4774 }
4775
4776 #[test]
4777 fn t015_two_transitions_append_two_decisions_in_order() {
4778 let (_tmp, store, _before) = seam_issue("triage");
4779 for to in ["todo", "doing"] {
4780 store
4781 .set_status("2026-01-01-seam.md", StatusChange::to(to))
4782 .unwrap();
4783 }
4784 let after = store
4785 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4786 .unwrap();
4787 let events = after.history.events();
4788 assert_eq!(events.len(), 2, "one per accepted transition");
4789 let tos: Vec<String> = events
4790 .iter()
4791 .map(|e| crate::model::TransitionDecision::from_event(e).unwrap().to)
4792 .collect();
4793 assert_eq!(tos, vec!["todo", "doing"], "append order, not sorted");
4794 }
4795
4796 #[test]
4797 fn set_status_changes_status_and_bumps_updated() {
4798 let (tmp, store) = make_store();
4799 let _ = tmp;
4800 write_issue(
4801 &store,
4802 "2026-01-01-s.md",
4803 &sample_frontmatter(1, "S", "todo"),
4804 );
4805 let outcome = store
4806 .set_status("2026-01-01-s.md", StatusChange::to("in progress"))
4807 .unwrap();
4808 let issue = &outcome.issue;
4809 assert_eq!(issue.status.display(), "in progress");
4810 assert_eq!(issue.updated, today());
4811 assert_eq!(issue.title, "S");
4812 assert_eq!(issue.body, "Body text.");
4813 }
4814
4815 #[test]
4816 fn set_status_preserves_file_path() {
4817 let (tmp, store) = make_store();
4818 let _ = tmp;
4819 write_issue(
4820 &store,
4821 "2026-01-01-f.md",
4822 &sample_frontmatter(1, "F", "todo"),
4823 );
4824 let _ = store
4825 .set_status("2026-01-01-f.md", StatusChange::to("done"))
4826 .unwrap();
4827 assert!(store.issues_dir.join("2026-01-01-f.md").exists());
4828 }
4829
4830 fn read_issue_file(store: &Store, filename: &str) -> String {
4836 std::fs::read_to_string(store.issues_dir.join(filename)).unwrap()
4837 }
4838
4839 fn bare_frontmatter(number: u32, title: &str) -> String {
4842 format!(
4843 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
4844 )
4845 }
4846
4847 fn legacy_links_frontmatter(number: u32, title: &str, links: &str) -> String {
4849 format!(
4850 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\nlinks: {links}\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
4851 )
4852 }
4853
4854 fn related_pair() -> (TempDir, Store, Issue, Issue) {
4856 let (tmp, store) = make_store();
4857 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
4858 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
4859 let a = store.get(IssueSelector::Number(1)).unwrap();
4860 let b = store.get(IssueSelector::Number(2)).unwrap();
4861 (tmp, store, a, b)
4862 }
4863
4864 #[test]
4865 fn t009_link_records_a_single_directed_edge() {
4866 let (_tmp, store, a, b) = related_pair();
4867 let outcome = store
4868 .link(
4869 &a.number.to_string(),
4870 &b.number.to_string(),
4871 RelationKind::Blocks,
4872 )
4873 .unwrap();
4874 assert!(outcome.changed);
4875
4876 let a_after = store.get(IssueSelector::Number(1)).unwrap();
4879 let b_after = store.get(IssueSelector::Number(2)).unwrap();
4880 assert_eq!(a_after.relationships.len(), 1);
4881 assert!(
4882 b_after.relationships.is_empty(),
4883 "no back-reference is written for a directed edge"
4884 );
4885 assert_eq!(a_after.relationships[0].kind, RelationKind::Blocks);
4886 assert_eq!(
4887 (a_after.relationships[0].from, a_after.relationships[0].to),
4888 (1, 2)
4889 );
4890 }
4891
4892 #[test]
4893 fn t009_link_appends_exactly_one_history_event() {
4894 let (_tmp, store, a, b) = related_pair();
4895 store
4896 .link(
4897 &a.number.to_string(),
4898 &b.number.to_string(),
4899 RelationKind::Blocks,
4900 )
4901 .unwrap();
4902 let a_after = store.get(IssueSelector::Number(1)).unwrap();
4903 assert_eq!(a_after.history.len(), 1);
4904 assert_eq!(
4905 a_after.history.events()[0].kind,
4906 HistoryEventKind::RelationshipAdded
4907 );
4908 assert_ne!(a_after.history.events()[0].actor, "");
4910 }
4911
4912 #[test]
4913 fn t009_idempotent_reapply_writes_nothing_and_appends_no_second_event() {
4914 let (_tmp, store, a, b) = related_pair();
4915 let args = (a.number.to_string(), b.number.to_string());
4916 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4917 let first = store.get(IssueSelector::Number(1)).unwrap();
4918 let bytes_after_first = read_issue_file(&store, &first.filename);
4919
4920 let second = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4921 assert!(!second.changed, "re-applying an existing edge is a no-op");
4922 assert!(second.written.is_none(), "no file is written for a no-op");
4923
4924 let after = store.get(IssueSelector::Number(1)).unwrap();
4925 assert_eq!(after.relationships.len(), 1, "no duplicate edge");
4926 assert_eq!(after.history.len(), 1, "no second History event");
4927 assert_eq!(
4928 read_issue_file(&store, &after.filename),
4929 bytes_after_first,
4930 "the file is byte-identical after the no-op"
4931 );
4932 }
4933
4934 #[test]
4935 fn t009_inverse_spelling_resolves_to_the_same_stored_edge() {
4936 let (_tmp, store, a, b) = related_pair();
4937 let args = (a.number.to_string(), b.number.to_string());
4938 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4939 let again = store
4941 .link(&args.1, &args.0, RelationKind::BlockedBy)
4942 .unwrap();
4943 assert!(!again.changed, "the inverse spelling must not add an edge");
4944 let after = store.get(IssueSelector::Number(1)).unwrap();
4945 assert_eq!(after.relationships.len(), 1);
4946 assert_eq!(
4947 after.relationships[0].kind,
4948 RelationKind::Blocks,
4949 "the stored kind is always the canonical one"
4950 );
4951 }
4952
4953 #[test]
4954 fn t009_self_link_is_refused_and_writes_nothing() {
4955 let (_tmp, store, a, _b) = related_pair();
4956 let before = read_issue_file(&store, &a.filename);
4957 let err = store
4958 .link(
4959 &a.number.to_string(),
4960 &a.number.to_string(),
4961 RelationKind::Blocks,
4962 )
4963 .unwrap_err();
4964 assert!(matches!(err, TixError::Refused(_)));
4965 assert!(
4966 err.to_string().contains("self_link"),
4967 "the diagnostic names the rule: {err}"
4968 );
4969 assert_eq!(
4970 read_issue_file(&store, &a.filename),
4971 before,
4972 "SC-005: a rejected mutation leaves the file byte-identical"
4973 );
4974 }
4975
4976 #[test]
4977 fn t009_cycle_closing_edge_is_refused_and_writes_nothing() {
4978 let (tmp, store) = make_store();
4979 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
4980 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
4981 write_issue(&store, "2026-01-01-c.md", &bare_frontmatter(3, "C"));
4982 let _ = tmp;
4983 store.link("1", "2", RelationKind::Blocks).unwrap();
4984 store.link("2", "3", RelationKind::Blocks).unwrap();
4985 let c_before = read_issue_file(&store, "2026-01-01-c.md");
4986
4987 let err = store.link("3", "1", RelationKind::Blocks).unwrap_err();
4988 assert!(err.to_string().contains("cycle"), "{err}");
4989 assert_eq!(
4990 read_issue_file(&store, "2026-01-01-c.md"),
4991 c_before,
4992 "SC-005: both files of a rejected two-issue mutation are unchanged"
4993 );
4994 }
4995
4996 #[test]
4997 fn t009_unlink_removes_the_edge_and_is_idempotent() {
4998 let (_tmp, store, a, b) = related_pair();
4999 let args = (a.number.to_string(), b.number.to_string());
5000 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5001
5002 let removed = store
5003 .unlink(&args.0, &args.1, RelationKind::Blocks)
5004 .unwrap();
5005 assert!(removed.changed);
5006 let after = store.get(IssueSelector::Number(1)).unwrap();
5007 assert_eq!(
5008 after.relationships,
5009 [] as [crate::relationships::Relationship; 0]
5010 );
5011 assert_eq!(after.history.len(), 2, "added then removed");
5012
5013 let again = store
5015 .unlink(&args.0, &args.1, RelationKind::Blocks)
5016 .unwrap();
5017 assert!(!again.changed);
5018 let after2 = store.get(IssueSelector::Number(1)).unwrap();
5019 assert_eq!(after2.history.len(), 2, "no third History event");
5020 }
5021
5022 #[test]
5023 fn t009_unlink_of_a_never_linked_pair_is_a_noop() {
5024 let (_tmp, store, a, b) = related_pair();
5025 let outcome = store
5026 .unlink(
5027 &a.number.to_string(),
5028 &b.number.to_string(),
5029 RelationKind::Blocks,
5030 )
5031 .unwrap();
5032 assert!(!outcome.changed);
5033 assert!(outcome.written.is_none());
5034 let after = store.get(IssueSelector::Number(1)).unwrap();
5035 assert_eq!(
5036 after.relationships,
5037 [] as [crate::relationships::Relationship; 0]
5038 );
5039 assert!(after.history.is_empty(), "a no-op appends nothing");
5040 }
5041
5042 #[test]
5043 fn t009_set_relationships_replaces_the_set_and_keeps_surviving_ids() {
5044 let (_tmp, store, _a, _b) = related_pair();
5045 store.link("1", "2", RelationKind::Blocks).unwrap();
5046 let before = store.get(IssueSelector::Number(1)).unwrap();
5047 let survivor_id = before.relationships[0].id.clone();
5048
5049 let updated = store
5051 .set_relationships(&before.filename, &[(2, RelationKind::Blocks)])
5052 .unwrap();
5053 assert_eq!(updated.relationships.len(), 1);
5054 assert_eq!(
5055 updated.relationships[0].id, survivor_id,
5056 "a surviving relationship keeps its stable id"
5057 );
5058 }
5059
5060 #[test]
5061 fn t009_set_relationships_rejects_unknown_target_and_self() {
5062 let (_tmp, store, a, _b) = related_pair();
5063 assert!(
5064 store
5065 .set_relationships(&a.filename, &[(404, RelationKind::Blocks)])
5066 .is_err(),
5067 "an unknown target is refused"
5068 );
5069 assert!(
5070 store
5071 .set_relationships(&a.filename, &[(1, RelationKind::Blocks)])
5072 .is_err(),
5073 "a self-link is refused"
5074 );
5075 }
5076
5077 fn seeded(store: &Store, number: u32, title: &str, extra: &str) {
5083 let slug = title.to_lowercase();
5084 write_issue(
5085 store,
5086 &format!("2026-01-01-{slug}.md"),
5087 &format!(
5088 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\n{extra}files: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n"
5089 ),
5090 );
5091 }
5092
5093 #[test]
5094 fn f1_set_relationships_refuses_a_reciprocal_pair_the_service_rejects() {
5095 let (_tmp, store) = make_store();
5102 seeded(&store, 1, "A", "");
5103 seeded(&store, 2, "B", "");
5104 store.link("1", "2", RelationKind::Blocks).unwrap();
5105
5106 let err = store
5107 .set_relationships("2026-01-01-b.md", &[(1, RelationKind::Blocks)])
5108 .unwrap_err();
5109 assert!(
5110 err.to_string().contains("cycle"),
5111 "the service's verdict is what refuses it: {err}"
5112 );
5113 let two = store.get(IssueSelector::Number(2)).unwrap();
5114 assert!(
5115 two.relationships.is_empty(),
5116 "SC-005: a refused mutation writes nothing"
5117 );
5118 }
5119
5120 #[test]
5121 fn f4_a_hand_ordered_related_row_is_removable() {
5122 let (_tmp, store) = make_store();
5127 seeded(&store, 2, "B", "");
5128 seeded(
5129 &store,
5130 5,
5131 "E",
5132 "relationships:\n- id: rel_handauthored\n from: 5\n to: 2\n kind: related\n display: \"related #2 B\"\n",
5133 );
5134 let out = store.unlink("5", "2", RelationKind::Related).unwrap();
5135 assert!(out.changed, "the row is found and removed");
5136 assert_eq!(
5137 store.get(IssueSelector::Number(5)).unwrap().relationships,
5138 [] as [crate::relationships::Relationship; 0]
5139 );
5140 }
5141
5142 #[test]
5143 fn f5_a_persisted_blocked_by_row_is_removable() {
5144 let (_tmp, store) = make_store();
5151 seeded(&store, 1, "A", "");
5152 seeded(
5153 &store,
5154 2,
5155 "B",
5156 "relationships:\n- id: rel_bb\n from: 2\n to: 1\n kind: blocked_by\n display: \"blocks #1 A\"\n",
5157 );
5158 let out = store.unlink("2", "1", RelationKind::BlockedBy).unwrap();
5159 assert!(out.changed, "the row is found and removed");
5160 assert_eq!(
5161 store.get(IssueSelector::Number(2)).unwrap().relationships,
5162 [] as [crate::relationships::Relationship; 0]
5163 );
5164 }
5165
5166 #[test]
5167 fn f13_a_dangling_edge_is_removable() {
5168 let (_tmp, store) = make_store();
5173 seeded(&store, 1, "A", "");
5174 seeded(&store, 2, "B", "");
5175 store.link("1", "2", RelationKind::Blocks).unwrap();
5176 std::fs::remove_file(store.issues_dir.join("2026-01-01-b.md")).unwrap();
5177
5178 let out = store.unlink("1", "2", RelationKind::Blocks).unwrap();
5179 assert!(out.changed, "a dangling edge is still removable");
5180 let one = store.get(IssueSelector::Number(1)).unwrap();
5181 assert_eq!(
5182 one.relationships,
5183 [] as [crate::relationships::Relationship; 0]
5184 );
5185 assert_eq!(one.history.len(), 2, "and the removal is recorded");
5186 }
5187
5188 #[test]
5189 fn m10_one_unlink_of_a_duplicated_edge_appends_one_event() {
5190 let (_tmp, store) = make_store();
5197 seeded(
5202 &store,
5203 1,
5204 "A",
5205 "relationships:\n- id: rel_dup_12\n from: 1\n to: 2\n kind: related\n display: \"related #2 B\"\n",
5206 );
5207 seeded(
5208 &store,
5209 2,
5210 "B",
5211 "relationships:\n- id: rel_dup_21\n from: 2\n to: 1\n kind: related\n display: \"related #1 A\"\n",
5212 );
5213
5214 let outcome = store.unlink("1", "2", RelationKind::Related).unwrap();
5215 assert!(outcome.changed, "both redundant rows are removed");
5216 let mut summaries = Vec::new();
5217 for number in [1u32, 2] {
5218 let issue = store.get(IssueSelector::Number(number)).unwrap();
5219 assert!(
5220 issue.relationships.is_empty(),
5221 "issue #{number} keeps no edge"
5222 );
5223 assert_eq!(
5224 issue.history.len(),
5225 1,
5226 "issue #{number} records exactly one event, got {:?}",
5227 issue.history.events()
5228 );
5229 let event = &issue.history.events()[0];
5230 assert!(
5231 event.detail.contains("removed 2 relationships"),
5232 "and the event says both spellings went: {}",
5233 event.detail
5234 );
5235 summaries.push(event.detail.clone());
5236 }
5237 assert_eq!(
5241 summaries[0], summaries[1],
5242 "both files record the same single event, not two differing ones"
5243 );
5244 }
5245
5246 #[test]
5247 fn m11_unlink_reports_the_row_it_removed_not_an_empty_placeholder() {
5248 let (_tmp, store, a, b) = related_pair();
5254 let args = (a.number.to_string(), b.number.to_string());
5255 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5256 let before = store.get(IssueSelector::Number(1)).unwrap();
5257 let real_id = before.relationships[0].id.clone();
5258
5259 let outcome = store
5260 .unlink(&args.0, &args.1, RelationKind::Blocks)
5261 .unwrap();
5262 assert!(outcome.changed);
5263 assert_eq!(
5264 outcome.relationship.id, real_id,
5265 "the reported row is the one that was actually removed"
5266 );
5267 assert!(!outcome.relationship.id.is_empty(), "so it is addressable");
5268 assert_eq!(
5269 outcome.relationship.display,
5270 before.relationships[0].display
5271 );
5272 }
5273
5274 #[test]
5275 fn m15_the_history_detail_names_the_relationship_id() {
5276 let (_tmp, store, a, b) = related_pair();
5280 let args = (a.number.to_string(), b.number.to_string());
5281 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5282 let one = store.get(IssueSelector::Number(1)).unwrap();
5283 let id = one.relationships[0].id.clone();
5284
5285 let detail = &one.history.events()[0].detail;
5286 assert!(
5287 detail.starts_with(&format!("created {id}:")),
5288 "the detail names the stable id: {detail}"
5289 );
5290 assert!(
5291 !detail.contains(&format!("created {}:", RelationKind::Blocks)),
5292 "and does not stutter the kind, which `display` already carries: {detail}"
5293 );
5294 }
5295
5296 #[test]
5297 fn f17_all_diagnostics_are_reported_not_just_the_first() {
5298 let (_tmp, store) = make_store();
5310 seeded(
5311 &store,
5312 1,
5313 "A",
5314 "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",
5315 );
5316 seeded(
5317 &store,
5318 2,
5319 "B",
5320 "relationships:\n- id: rel_b21\n from: 2\n to: 1\n kind: blocks\n display: blocks\n",
5321 );
5322
5323 let issues = store.list().unwrap();
5324 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
5325 let verdict = RelationshipGraph::new(&views, "done").validate_snapshot();
5326 assert!(
5327 verdict.errors.len() >= 2,
5328 "the fixture must really produce several diagnostics, got {:?}",
5329 verdict.errors
5330 );
5331
5332 let rendered = relationship_error(&verdict.errors).to_string();
5333 for kind in ["conflicting_edge", "cycle"] {
5334 assert!(
5335 rendered.contains(kind),
5336 "every diagnostic must be reported; {kind} missing from: {rendered}"
5337 );
5338 }
5339 assert!(
5340 !rendered.contains("conflicting_edge: conflicting_edge")
5341 && !rendered.contains("cycle: cycle:"),
5342 "no doubled kind prefix: {rendered}"
5343 );
5344 let dup = vec![
5350 Diagnostic::new(
5351 DiagnosticKind::SelfLink,
5352 "to",
5353 "#1 cannot block itself: a self-link is not a valid relationship",
5354 ),
5355 Diagnostic::new(
5356 DiagnosticKind::SelfLink,
5357 "to",
5358 "#1 cannot block itself: a self-link is not a valid relationship",
5359 ),
5360 Diagnostic::new(DiagnosticKind::Cycle, "to", "cycle: 1 -> 1 -> #1"),
5361 ];
5362 let rendered = relationship_error(&dup).to_string();
5363 assert_eq!(
5364 rendered.matches("cannot block itself").count(),
5365 1,
5366 "an identical diagnostic renders once, not twice: {rendered}"
5367 );
5368 assert!(
5369 rendered.contains("cycle"),
5370 "a distinct diagnostic is still reported: {rendered}"
5371 );
5372 assert!(
5373 !rendered.contains("self_link: self_link") && !rendered.contains("cycle: cycle:"),
5374 "and no kind is prefixed twice: {rendered}"
5375 );
5376 }
5377
5378 #[test]
5379 fn t009_clearing_a_set_removes_a_related_edge_owned_by_another_file() {
5380 let (_tmp, store) = make_store();
5385 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(3, "A"));
5386 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(5, "B"));
5387
5388 store.link("5", "3", RelationKind::Related).unwrap();
5389 let three = store.get(IssueSelector::Number(3)).unwrap();
5390 let five = store.get(IssueSelector::Number(5)).unwrap();
5391 assert_eq!(
5392 three.relationships.len(),
5393 1,
5394 "the edge is stored in the lower-numbered issue's file"
5395 );
5396 assert!(
5397 five.relationships.is_empty(),
5398 "and #5's own file holds none"
5399 );
5400
5401 store.set_relationships(&five.filename, &[]).unwrap();
5402
5403 let three_after = store.get(IssueSelector::Number(3)).unwrap();
5404 let five_after = store.get(IssueSelector::Number(5)).unwrap();
5405 assert!(
5406 three_after.relationships.is_empty(),
5407 "the association is gone from the file that actually stored it"
5408 );
5409 assert_eq!(
5410 five_after.relationships,
5411 [] as [crate::relationships::Relationship; 0]
5412 );
5413 }
5414
5415 #[test]
5416 fn t009_clearing_a_set_does_not_remove_an_inbound_directional_edge() {
5417 let (_tmp, store) = make_store();
5422 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(3, "A"));
5423 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(5, "B"));
5424
5425 store.link("3", "5", RelationKind::Blocks).unwrap();
5426 let five = store.get(IssueSelector::Number(5)).unwrap();
5427
5428 store.set_relationships(&five.filename, &[]).unwrap();
5429
5430 let three_after = store.get(IssueSelector::Number(3)).unwrap();
5431 assert!(
5432 three_after
5433 .relationships
5434 .iter()
5435 .any(|r| r.to == 5 && r.kind == RelationKind::Blocks),
5436 "#3's declaration that it blocks #5 survives: a blocked issue \
5437 cannot unilaterally clear the blocker's graph"
5438 );
5439 }
5440
5441 fn relationship_fixture(name: &str) -> String {
5443 format!(
5444 "{}/tests/fixtures/relationships/{name}",
5445 env!("CARGO_MANIFEST_DIR")
5446 )
5447 }
5448
5449 #[test]
5450 fn t010_typed_fixture_round_trips_byte_stable_including_history() {
5451 let original =
5454 std::fs::read_to_string(relationship_fixture("typed-relationships.md")).unwrap();
5455 let issue = Issue::parse(&original).unwrap();
5456 assert_eq!(issue.relationships.len(), 2);
5457 assert_eq!(issue.history.len(), 2);
5458 let rendered = issue.to_file_contents();
5459 assert_eq!(
5460 rendered, original,
5461 "a typed fixture must not churn on a read/write round trip"
5462 );
5463 assert_eq!(
5464 Issue::parse(&rendered).unwrap().to_file_contents(),
5465 rendered
5466 );
5467 assert_eq!(issue.relationships[0].id, "rel_01JQ8Z4W2M6N7P9R3S5T7V9X1");
5469 assert!(
5470 rendered.find("## History").unwrap() < rendered.find("Prose that follows").unwrap(),
5471 "History sits between the frontmatter and the body"
5472 );
5473 }
5474
5475 #[test]
5476 fn t010_issue_with_no_relationship_data_is_untouched() {
5477 let original =
5478 std::fs::read_to_string(relationship_fixture("no-relationship-data.md")).unwrap();
5479 let issue = Issue::parse(&original).unwrap();
5480 assert_eq!(
5481 issue.relationships,
5482 [] as [crate::relationships::Relationship; 0]
5483 );
5484 assert!(issue.history.is_empty());
5485 assert!(!issue.has_retired_links_key);
5486 let rendered = issue.to_file_contents();
5487 assert_eq!(
5488 rendered, original,
5489 "an issue with no relationship data must not churn"
5490 );
5491 assert!(!rendered.contains("relationships:"));
5492 assert!(!rendered.contains("## History"));
5493 }
5494
5495 #[test]
5496 fn t010_an_empty_history_section_is_dropped_not_preserved() {
5497 let original = std::fs::read_to_string(relationship_fixture("history-empty.md")).unwrap();
5502 assert!(
5503 original.contains("## History"),
5504 "the fixture must actually carry the empty heading"
5505 );
5506 let issue = Issue::parse(&original).unwrap();
5507 assert!(
5508 issue.history.is_empty(),
5509 "a heading with no entries parses to an empty log"
5510 );
5511 assert!(
5512 issue
5513 .body
5514 .contains("An issue whose History section is present but empty."),
5515 "the prose after the heading is body, not a dropped event: {:?}",
5516 issue.body
5517 );
5518 let rendered = issue.to_file_contents();
5519 assert!(
5520 !rendered.contains("## History"),
5521 "the empty section is omitted rather than kept as a bare heading"
5522 );
5523 assert!(rendered.contains("An issue whose History section is present but empty."));
5524 }
5525
5526 #[test]
5527 fn t010_issue_with_no_history_section_is_untouched() {
5528 let original = std::fs::read_to_string(relationship_fixture("history-absent.md")).unwrap();
5529 let issue = Issue::parse(&original).unwrap();
5530 assert!(issue.history.is_empty());
5531 let rendered = issue.to_file_contents();
5532 assert_eq!(
5533 rendered, original,
5534 "a file written before History existed must not gain an empty section"
5535 );
5536 }
5537
5538 #[test]
5539 fn f2_one_bad_history_line_does_not_make_the_store_unreadable() {
5540 let (_tmp, store) = make_store();
5549 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
5550 write_issue(
5551 &store,
5552 "2026-01-01-c.md",
5553 "---\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",
5554 );
5555
5556 let all = store.list().expect("the store must remain listable");
5557 assert_eq!(all.len(), 2, "both issues load");
5558 assert_eq!(
5559 store
5560 .get(IssueSelector::Number(1))
5561 .expect("an unrelated, valid issue must stay readable")
5562 .title,
5563 "A"
5564 );
5565 let three = store.get(IssueSelector::Number(3)).unwrap();
5571 assert!(three.history.events().is_empty(), "no event is fabricated");
5572 assert_eq!(
5573 three.history.unparsed().len(),
5574 1,
5575 "the line is preserved for diagnosis: {:?}",
5576 three.history.unparsed()
5577 );
5578 assert!(
5579 !three.body.contains("ascii hyphens"),
5580 "and is not demoted into the body: {:?}",
5581 three.body
5582 );
5583 }
5584
5585 #[cfg(unix)]
5588 fn seeded_in_segment(store: &Store, number: u32, title: &str, segment: &str) {
5589 let dir = store.issues_dir.join(segment);
5590 std::fs::create_dir_all(&dir).unwrap();
5591 std::fs::write(
5592 dir.join(format!("2026-01-01-{}.md", title.to_lowercase())),
5593 format!(
5594 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\nfiles: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n"
5595 ),
5596 )
5597 .unwrap();
5598 }
5599
5600 #[cfg(unix)]
5601 #[test]
5602 fn f6_a_mid_sequence_write_failure_rolls_every_file_back() {
5603 use std::os::unix::fs::PermissionsExt;
5604 let (tmp, store) = make_store();
5613 let _ = tmp;
5614 seeded_in_segment(&store, 1, "A", "alice");
5615 seeded_in_segment(&store, 2, "B", "bob");
5616 seeded_in_segment(&store, 3, "C", "carol");
5617 seeded_in_segment(&store, 5, "E", "erin");
5618
5619 let paths: Vec<std::path::PathBuf> = [
5620 "alice/2026-01-01-a.md",
5621 "bob/2026-01-01-b.md",
5622 "carol/2026-01-01-c.md",
5623 ]
5624 .iter()
5625 .map(|p| store.issues_dir.join(p))
5626 .collect();
5627 let before: Vec<String> = paths
5628 .iter()
5629 .map(|p| std::fs::read_to_string(p).unwrap())
5630 .collect();
5631
5632 let carol = store.issues_dir.join("carol");
5635 let mut perms = std::fs::metadata(&carol).unwrap().permissions();
5636 perms.set_mode(0o555);
5637 std::fs::set_permissions(&carol, perms).unwrap();
5638
5639 let result = store.set_relationships(
5640 "carol/2026-01-01-c.md",
5641 &[
5642 (1, RelationKind::Related),
5643 (2, RelationKind::Related),
5644 (5, RelationKind::Related),
5645 ],
5646 );
5647
5648 let mut perms = std::fs::metadata(&carol).unwrap().permissions();
5649 perms.set_mode(0o755);
5650 std::fs::set_permissions(&carol, perms).unwrap();
5651
5652 assert!(result.is_err(), "the blocked write must fail");
5653 for (path, previous) in paths.iter().zip(before.iter()) {
5654 assert_eq!(
5655 std::fs::read_to_string(path).unwrap(),
5656 *previous,
5657 "{} was left mutated by a failed mutation",
5658 path.display()
5659 );
5660 }
5661 }
5662
5663 #[cfg(unix)]
5664 #[test]
5665 fn f6b_the_updated_bump_is_itself_rollback_covered() {
5666 use std::os::unix::fs::PermissionsExt;
5676
5677 let (tmp, store) = make_store();
5678 let _ = tmp;
5679 seeded_in_segment(&store, 1, "A", "alice");
5680 seeded_in_segment(&store, 3, "C", "carol");
5681
5682 let alice = store.issues_dir.join("alice/2026-01-01-a.md");
5683 let before = std::fs::read_to_string(&alice).unwrap();
5684
5685 let carol = store.issues_dir.join("carol");
5686 let mut perms = std::fs::metadata(&carol).unwrap().permissions();
5687 perms.set_mode(0o555);
5688 std::fs::set_permissions(&carol, perms).unwrap();
5689
5690 let result =
5691 store.set_relationships("carol/2026-01-01-c.md", &[(1, RelationKind::Related)]);
5692
5693 let mut perms = std::fs::metadata(&carol).unwrap().permissions();
5694 perms.set_mode(0o755);
5695 std::fs::set_permissions(&carol, perms).unwrap();
5696
5697 assert!(result.is_err(), "the blocked bump must fail the call");
5698 assert_eq!(
5699 std::fs::read_to_string(&alice).unwrap(),
5700 before,
5701 "the relationship write that succeeded earlier must be rolled back \
5702 when the subsequent `updated` bump fails"
5703 );
5704 }
5705
5706 #[test]
5707 fn f9_relationships_for_reports_both_endpoints_of_a_one_sided_edge() {
5708 let (_tmp, store) = make_store();
5714 seeded(&store, 1, "A", "");
5715 seeded(&store, 2, "B", "");
5716 store.link("2", "1", RelationKind::Related).unwrap();
5717
5718 let one = store.get(IssueSelector::Number(1)).unwrap();
5720 let two = store.get(IssueSelector::Number(2)).unwrap();
5721 assert_eq!(one.relationships.len(), 1, "precondition: #1 stores it");
5722 assert!(
5723 two.relationships.is_empty(),
5724 "precondition: #2's own field is the partial view that caused F9"
5725 );
5726
5727 for number in [1u32, 2] {
5730 let resolved = store.relationships_for(number).unwrap();
5731 assert_eq!(
5732 resolved.len(),
5733 1,
5734 "issue #{number} must see the relationship that touches it"
5735 );
5736 assert_eq!(resolved[0].kind, RelationKind::Related);
5737 }
5738 }
5739
5740 #[test]
5741 fn f7_writing_an_issue_with_the_retired_key_discloses_that_it_is_removed() {
5742 let (tmp, store) = make_store();
5747 let _ = tmp;
5748 let _ = take_disclosures();
5749 write_issue(
5754 &store,
5755 "2026-01-01-legacy.md",
5756 &legacy_links_frontmatter(1, "Legacy", "['7']"),
5757 );
5758 let issue = store.get(IssueSelector::Number(1)).unwrap();
5759 assert!(
5760 issue.has_retired_links_key,
5761 "precondition: the key is present"
5762 );
5763
5764 store.update(&issue.filename, &issue).unwrap();
5765
5766 let disclosures = take_disclosures();
5768 assert_eq!(
5769 disclosures.len(),
5770 1,
5771 "the write must disclose that it dropped the key: {disclosures:?}"
5772 );
5773 let line = &disclosures[0];
5774 assert!(
5775 line.contains("`links:`"),
5776 "it names what was removed: {line}"
5777 );
5778 assert!(
5782 line.starts_with("remove `links:` by hand"),
5783 "and leads with what to do about it: {line}"
5784 );
5785 assert!(
5786 line.contains(&issue.filename),
5787 "and which file it was removed from: {line}"
5788 );
5789 assert!(
5790 line.contains("C-001"),
5791 "and that no migration was applied, so the user is not left \
5792 wondering where their data went: {line}"
5793 );
5794
5795 let after = read_issue_file(&store, &issue.filename);
5797 assert!(
5798 !after.contains("links:"),
5799 "the key is not re-emitted (C-001: no migration)"
5800 );
5801 let reread = store.get(IssueSelector::Number(1)).unwrap();
5804 assert!(!reread.has_retired_links_key);
5805 assert!(
5806 take_disclosures().is_empty(),
5807 "and it is disclosed once, not on every read"
5808 );
5809 }
5810
5811 #[test]
5812 fn f7b_writing_an_issue_without_the_retired_key_is_silent() {
5813 let (tmp, store) = make_store();
5816 let _ = tmp;
5817 let _ = take_disclosures();
5818 write_issue(&store, "2026-01-01-plain.md", &bare_frontmatter(1, "Plain"));
5819 let issue = store.get(IssueSelector::Number(1)).unwrap();
5820 assert!(!issue.has_retired_links_key);
5821 store.update(&issue.filename, &issue).unwrap();
5822 assert!(
5823 take_disclosures().is_empty(),
5824 "an ordinary write discloses nothing"
5825 );
5826 }
5827
5828 #[test]
5829 fn t010_legacy_fixture_reports_the_retired_key_and_yields_no_edges() {
5830 let original =
5833 std::fs::read_to_string(relationship_fixture("legacy-links-key.md")).unwrap();
5834 let issue = Issue::parse(&original).unwrap();
5835 assert!(issue.has_retired_links_key, "the retired key is reported");
5836 assert!(
5837 issue.relationships.is_empty(),
5838 "C-001: legacy values are never reinterpreted as typed edges"
5839 );
5840 assert!(
5841 issue.history.is_empty(),
5842 "a legacy file gains no History section from parsing"
5843 );
5844 let (_tmp, store) = make_store();
5848 write_issue(&store, "2026-01-01-legacy.md", &original);
5849 let findings = crate::commands::lint::collect(&store.config, &store).unwrap();
5850 let retired: Vec<_> = findings
5851 .iter()
5852 .filter(|f| f.kind == "retired_links_key")
5853 .collect();
5854 assert_eq!(
5855 retired.len(),
5856 1,
5857 "one finding for the retired key: {findings:?}"
5858 );
5859 assert_eq!(retired[0].value, "links", "the finding names the field");
5860 assert!(
5861 retired[0].message.contains("retired"),
5862 "the message says why: {}",
5863 retired[0].message
5864 );
5865 assert!(
5866 retired[0].line > 0,
5867 "the finding points into the frontmatter"
5868 );
5869 let rewritten = issue.to_file_contents();
5871 assert!(!rewritten.contains("kind: related"));
5872 assert!(!rewritten.contains("kind: blocks"));
5873 assert!(!rewritten.contains("links:"));
5874 }
5875
5876 #[test]
5877 fn c001_a_legacy_links_key_is_reported_and_yields_no_typed_edges() {
5878 let (tmp, store) = make_store();
5879 write_issue(
5880 &store,
5881 "2026-01-01-legacy.md",
5882 &legacy_links_frontmatter(1, "Legacy", "[\"12\", \"47\"]"),
5883 );
5884 let _ = tmp;
5885 let issue = store.get(IssueSelector::Number(1)).unwrap();
5886 assert!(
5887 issue.has_retired_links_key,
5888 "the retired key is recorded as a validation result"
5889 );
5890 assert!(
5891 issue.relationships.is_empty(),
5892 "C-001: legacy values are never read as typed edges"
5893 );
5894 }
5895
5896 #[test]
5897 fn c001_there_is_no_migration_path_from_the_retired_key() {
5898 let (tmp, store) = make_store();
5907 let _ = tmp;
5908 write_issue(
5909 &store,
5910 "2026-01-01-legacy.md",
5911 &legacy_links_frontmatter(1, "Legacy", "[\"12\", \"47\"]"),
5912 );
5913 let issue = store.get(IssueSelector::Number(1)).unwrap();
5914 assert!(
5915 issue.has_retired_links_key,
5916 "precondition: the key is present"
5917 );
5918 assert!(
5919 issue.relationships.is_empty(),
5920 "C-001: legacy values are never read as typed edges"
5921 );
5922
5923 store.update(&issue.filename, &issue).unwrap();
5924 let written = read_issue_file(&store, &issue.filename);
5925
5926 let reparsed = store.get(IssueSelector::Number(1)).unwrap();
5935 assert!(
5936 reparsed.relationships.is_empty(),
5937 "the file on disk parses to zero typed edges, not just the in-memory copy"
5938 );
5939 for kind in ["blocks", "blocked_by", "duplicates", "related"] {
5940 assert!(
5941 !written.contains(&format!("kind: {kind}")),
5942 "a legacy link became a `{kind}` edge:\n{written}"
5943 );
5944 }
5945 assert!(
5946 !written.contains("kind:"),
5947 "no typed edge was written at all"
5948 );
5949 assert!(!written.contains("links:"), "the key is not re-emitted");
5952 let reread = store.get(IssueSelector::Number(1)).unwrap();
5953 assert!(
5954 reread.history.is_empty(),
5955 "a format change is not a relationship mutation, so it records no event"
5956 );
5957 }
5958
5959 #[test]
5960 fn f15_the_idempotent_no_op_reports_the_real_addressable_row() {
5961 let (_tmp, store, a, b) = related_pair();
5967 let args = (a.number.to_string(), b.number.to_string());
5968 let first = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5969 let second = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5970
5971 assert!(first.changed);
5972 assert!(!second.changed, "the second apply is a no-op");
5973 assert_eq!(
5974 second.relationship.id, first.relationship.id,
5975 "the no-op reports the real, addressable row"
5976 );
5977 assert!(
5978 !second.relationship.id.is_empty(),
5979 "an empty id is not addressable"
5980 );
5981 assert_eq!(second.relationship.display, first.relationship.display);
5982 }
5983
5984 #[test]
5985 fn f16_clearing_a_set_bumps_updated_on_the_named_issue() {
5986 let (_tmp, store, _a, _b) = related_pair();
5991 store.link("2", "1", RelationKind::Related).unwrap();
5992 let before = store.get(IssueSelector::Number(2)).unwrap();
5993 let updated_before = before.updated.clone();
5994
5995 store.set_relationships(&before.filename, &[]).unwrap();
5996
5997 let after = store.get(IssueSelector::Number(2)).unwrap();
5998 assert_ne!(
5999 after.updated, updated_before,
6000 "the named issue's relationship set changed, so `updated` must move"
6001 );
6002 }
6003
6004 #[test]
6005 fn c002_an_issue_with_no_relationship_data_round_trips_unchanged() {
6006 let (tmp, store) = make_store();
6007 let _ = tmp;
6008 write_issue(&store, "2026-01-01-plain.md", &bare_frontmatter(1, "Plain"));
6013 let issue = store.get(IssueSelector::Number(1)).unwrap();
6014 let first = issue.to_file_contents();
6015 assert!(
6016 !first.contains("relationships:"),
6017 "the empty typed field is omitted, not written as `[]`"
6018 );
6019 assert!(
6020 !first.contains("## History"),
6021 "an empty History section is omitted entirely"
6022 );
6023 let reparsed = Issue::parse(&first).unwrap();
6026 let second = reparsed.to_file_contents();
6027 assert_eq!(second, first);
6028 }
6029
6030 #[test]
6031 fn c002_history_section_round_trips_byte_stable() {
6032 let (tmp, store) = make_store();
6033 write_issue(&store, "2026-01-01-h.md", &bare_frontmatter(1, "H"));
6034 let _ = tmp;
6035 let mut issue = store.get(IssueSelector::Number(1)).unwrap();
6036 issue.history.append_now(
6037 "alice",
6038 HistoryEventKind::RelationshipAdded,
6039 "created blocks: blocks #2 B",
6040 );
6041 issue.history.append_now(
6042 "bob",
6043 HistoryEventKind::BlockedMoveAcknowledged,
6044 "acknowledged 1 open blocker",
6045 );
6046 let first = issue.to_file_contents();
6047 assert!(first.contains("## History"), "{first}");
6048
6049 let reparsed = Issue::parse(&first).unwrap();
6051 assert_eq!(reparsed.to_file_contents(), first);
6052 assert_eq!(reparsed.history.len(), 2);
6053 assert_eq!(reparsed.history.events()[0].actor, "alice");
6054 assert_eq!(
6055 reparsed.history.events()[1].kind,
6056 HistoryEventKind::BlockedMoveAcknowledged
6057 );
6058 assert!(
6059 !reparsed.body.contains("History"),
6060 "the section is stripped from the body: {:?}",
6061 reparsed.body
6062 );
6063 }
6064
6065 #[test]
6066 fn f1_a_hand_edit_last_in_the_section_stays_in_it() {
6067 let (tmp, store) = make_store();
6086 let _ = tmp;
6087 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";
6088 write_issue(&store, "2026-01-01-a.md", original);
6089
6090 let before = store.get(IssueSelector::Number(1)).unwrap();
6093 assert_eq!(
6094 before.history.events().len(),
6095 1,
6096 "precondition: the real event parsed"
6097 );
6098 assert_eq!(
6099 before.history.unparsed(),
6100 vec![" - my hand edit"],
6101 "precondition: the hand edit is inside the section"
6102 );
6103 assert_eq!(before.body, "", "precondition: body is empty");
6104
6105 let mut moved = before.clone();
6107 moved.status = crate::model::Status::new("in progress");
6108 store.update(&moved.filename, &moved).unwrap();
6109
6110 let after = store.get(IssueSelector::Number(1)).unwrap();
6111 assert_eq!(
6112 after.history.unparsed(),
6113 vec![" - my hand edit"],
6114 "the hand edit is still in the audit log, not demoted to the body"
6115 );
6116 assert_eq!(
6117 after.history.events().len(),
6118 1,
6119 "and the real event survived"
6120 );
6121 assert_eq!(after.body, "", "nothing leaked into the body");
6122
6123 store
6126 .link("1", "2", RelationKind::Related)
6127 .ok()
6128 .or_else(|| {
6129 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
6130 store.link("1", "2", RelationKind::Related).ok()
6131 });
6132 let linked = store.get(IssueSelector::Number(1)).unwrap();
6133 let rendered = linked.to_file_contents();
6134 assert!(
6135 rendered.contains("## History"),
6136 "the heading survived an unrelated write: {rendered}"
6137 );
6138 let hand = rendered
6139 .find("my hand edit")
6140 .expect("the hand edit is present");
6141 let newest = rendered
6142 .find("relationship_added")
6143 .expect("the new event is present");
6144 assert!(
6145 newest < hand,
6146 "the new event is appended AFTER the pre-existing content, so file \
6147 order is still append order: {rendered}"
6148 );
6149 }
6150
6151 #[test]
6152 fn f1_but_a_bare_paragraph_at_end_of_section_is_still_prose() {
6153 let (tmp, store) = make_store();
6158 let _ = tmp;
6159 write_issue(
6160 &store,
6161 "2026-01-01-a.md",
6162 "---\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",
6163 );
6164 let issue = store.get(IssueSelector::Number(1)).unwrap();
6165 assert_eq!(issue.history.events().len(), 1, "the real event parsed");
6166 assert!(
6167 issue.history.unparsed().is_empty(),
6168 "the paragraph is not in the audit log: {:?}",
6169 issue.history.unparsed()
6170 );
6171 assert!(
6172 issue.body.contains("just a closing paragraph"),
6173 "it is body prose: {:?}",
6174 issue.body
6175 );
6176 }
6177
6178 #[test]
6179 fn f1_a_hand_edit_survives_body_prose_and_a_later_body_replacement() {
6180 let (_tmp, store) = make_store();
6195 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
6196 let hand = "- 2026-02-02T00:00:00Z - bob - relationship_removed - by hand";
6197 write_issue(
6198 &store,
6199 "2026-01-01-a.md",
6200 &format!(
6201 "---\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"
6202 ),
6203 );
6204
6205 let before = store.get(IssueSelector::Number(1)).unwrap();
6206 assert_eq!(before.history.events().len(), 1, "the real event parsed");
6207 assert_eq!(
6208 before.history.unparsed(),
6209 vec![hand],
6210 "the hand-edited entry is retained even though body prose follows"
6211 );
6212 assert!(
6213 before.body.contains("Some body prose."),
6214 "and the prose is still body text: {:?}",
6215 before.body
6216 );
6217
6218 let mut moved = before.clone();
6220 moved.status = crate::model::Status::new("in progress");
6221 store.update(&moved.filename, &moved).unwrap();
6222 assert_eq!(
6223 store
6224 .get(IssueSelector::Number(1))
6225 .unwrap()
6226 .history
6227 .unparsed(),
6228 vec![hand],
6229 "a `tix move` keeps it in the audit log"
6230 );
6231
6232 let mut replaced = store.get(IssueSelector::Number(1)).unwrap();
6235 replaced.body = "Completely new body text.".to_string();
6236 store.update(&replaced.filename, &replaced).unwrap();
6237 let after = store.get(IssueSelector::Number(1)).unwrap();
6238 assert_eq!(
6239 after.history.unparsed(),
6240 vec![hand],
6241 "the hand-edited entry survives a wholesale body replacement"
6242 );
6243 assert_eq!(after.body, "Completely new body text.");
6244
6245 store.link("1", "2", RelationKind::Related).unwrap();
6247 let rendered = store
6248 .get(IssueSelector::Number(1))
6249 .unwrap()
6250 .to_file_contents();
6251 assert!(
6252 rendered.contains("## History"),
6253 "the heading survived: {rendered}"
6254 );
6255 let hand_at = rendered.find("by hand").expect("the hand edit is present");
6256 let new_at = rendered
6257 .find("created rel_")
6258 .expect("the new event is present");
6259 assert!(
6260 new_at > hand_at,
6261 "the new event is appended AFTER the pre-existing content, so file \
6262 order is still append order: {rendered}"
6263 );
6264 }
6265
6266 #[test]
6267 fn f1_a_well_formed_star_marked_entry_is_retained_with_body_prose_after_it() {
6268 let (_tmp, store) = make_store();
6278 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
6279 let hand =
6280 "* 2026-02-02T00:00:00Z \u{2014} bob \u{2014} relationship_removed \u{2014} by hand";
6281 write_issue(
6282 &store,
6283 "2026-01-01-a.md",
6284 &format!(
6285 "---\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"
6286 ),
6287 );
6288
6289 let before = store.get(IssueSelector::Number(1)).unwrap();
6290 assert_eq!(before.history.events().len(), 1, "the real event parsed");
6291 assert_eq!(
6292 before.history.unparsed(),
6293 vec![hand],
6294 "a well-formed `*` entry is retained even with body prose after it"
6295 );
6296 assert!(
6297 before.body.contains("Some body prose."),
6298 "and the prose is still body text: {:?}",
6299 before.body
6300 );
6301
6302 let mut replaced = before.clone();
6304 replaced.body = "Completely new body text.".to_string();
6305 store.update(&replaced.filename, &replaced).unwrap();
6306 assert_eq!(
6307 store
6308 .get(IssueSelector::Number(1))
6309 .unwrap()
6310 .history
6311 .unparsed(),
6312 vec![hand],
6313 "it survives a wholesale body replacement"
6314 );
6315
6316 store.link("1", "2", RelationKind::Related).unwrap();
6317 let rendered = store
6318 .get(IssueSelector::Number(1))
6319 .unwrap()
6320 .to_file_contents();
6321 assert_eq!(
6322 rendered.matches("## History").count(),
6323 1,
6324 "still exactly one section: {rendered}"
6325 );
6326 let hand_at = rendered.find("by hand").expect("the hand edit is present");
6327 let new_at = rendered
6328 .find("created rel_")
6329 .expect("the new event is present");
6330 assert!(
6331 new_at > hand_at,
6332 "appended after, so order is append order: {rendered}"
6333 );
6334 }
6335
6336 #[test]
6337 fn f1_a_three_field_ascii_hyphen_entry_is_retained_with_body_prose_after_it() {
6338 let (_tmp, store) = make_store();
6344 let hand = "- 2026-02-02T00:00:00Z - bob - relationship_removed";
6345 write_issue(
6346 &store,
6347 "2026-01-01-a.md",
6348 &format!(
6349 "---\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"
6350 ),
6351 );
6352
6353 let twin = Issue::parse(
6356 "---\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",
6357 )
6358 .unwrap();
6359 assert_eq!(
6360 twin.history.events().len(),
6361 1,
6362 "three fields with em-dashes is a valid event, detail optional"
6363 );
6364
6365 let before = store.get(IssueSelector::Number(1)).unwrap();
6366 assert_eq!(
6367 before.history.unparsed(),
6368 vec![hand],
6369 "so its ASCII-hyphen twin is retained, not demoted"
6370 );
6371 let mut replaced = before.clone();
6372 replaced.body = "Completely new body text.".to_string();
6373 store.update(&replaced.filename, &replaced).unwrap();
6374 assert_eq!(
6375 store
6376 .get(IssueSelector::Number(1))
6377 .unwrap()
6378 .history
6379 .unparsed(),
6380 vec![hand],
6381 "and survives a body replacement"
6382 );
6383 }
6384
6385 #[test]
6386 fn f1_a_heading_with_trailing_spaces_is_still_the_heading() {
6387 let (_tmp, store) = make_store();
6393 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
6394 for (label, heading) in [
6395 ("plain", "## History"),
6396 ("trailing spaces", "## History "),
6397 ] {
6398 write_issue(
6399 &store,
6400 "2026-01-01-a.md",
6401 &format!(
6402 "---\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"
6403 ),
6404 );
6405 let before = store.get(IssueSelector::Number(1)).unwrap();
6406 assert_eq!(
6407 before.history.events().len(),
6408 1,
6409 "{label}: the section is recognised"
6410 );
6411 store.link("1", "2", RelationKind::Related).unwrap();
6412 let rendered = store
6413 .get(IssueSelector::Number(1))
6414 .unwrap()
6415 .to_file_contents();
6416 assert_eq!(
6417 rendered.matches("## History").count(),
6418 1,
6419 "{label}: exactly one section after a write, got {rendered}"
6420 );
6421 assert!(
6422 !rendered.lines().any(|l| l != l.trim_end()),
6423 "{label}: no line keeps trailing whitespace, so the heading is \
6424 normalised: {rendered}"
6425 );
6426 }
6427 }
6428
6429 #[test]
6430 fn set_status_discloses_the_retired_key_rather_than_dropping_it_silently() {
6431 let (_tmp, store) = make_store();
6438 write_issue(
6439 &store,
6440 "2026-01-01-a.md",
6441 "---\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",
6442 );
6443 let before = store.get(IssueSelector::Number(1)).unwrap();
6444 assert!(
6445 before.has_retired_links_key,
6446 "precondition: the file carries the retired key"
6447 );
6448 let _ = crate::storage::take_disclosures();
6449
6450 store
6451 .set_status("2026-01-01-a.md", StatusChange::to("in progress"))
6452 .unwrap();
6453
6454 let disclosures = crate::storage::take_disclosures();
6455 assert_eq!(
6456 disclosures.len(),
6457 1,
6458 "set_status must disclose the removal, got {disclosures:?}"
6459 );
6460 assert!(
6461 disclosures[0].contains("links:") && disclosures[0].contains("a.md"),
6462 "naming the field and the file: {disclosures:?}"
6463 );
6464 assert!(
6465 !store
6466 .get(IssueSelector::Number(1))
6467 .unwrap()
6468 .has_retired_links_key,
6469 "and the key is gone, as C-001 intends"
6470 );
6471 }
6472
6473 #[test]
6474 fn f1_but_ordinary_prose_bullets_still_end_the_section() {
6475 let (_tmp, store) = make_store();
6480 write_issue(
6481 &store,
6482 "2026-01-01-a.md",
6483 concat!(
6484 "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\n",
6485 "created: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n",
6486 "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n\n",
6487 "- step one\n- step two\n",
6488 ),
6489 );
6490 let issue = store.get(IssueSelector::Number(1)).unwrap();
6491 assert_eq!(
6492 issue.history.events().len(),
6493 1,
6494 "the real event is an event"
6495 );
6496 assert!(
6497 issue.history.unparsed().is_empty(),
6498 "ordinary bullets are not retained: {:?}",
6499 issue.history.unparsed()
6500 );
6501 assert!(
6502 issue.body.contains("step one") && issue.body.contains("step two"),
6503 "they stay body text: {:?}",
6504 issue.body
6505 );
6506 }
6507
6508 #[test]
6509 fn a_year_is_not_an_ordered_list_marker() {
6510 for prose in [
6514 "2026. Q1 goals",
6515 "2026. roadmap review",
6516 "007. padded",
6517 "99999999999999999999. big",
6518 ] {
6519 assert!(
6520 !crate::model::looks_like_an_unrecognised_entry(prose)
6521 && Issue::parse(&format!(
6522 "---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{prose}\n"
6523 ))
6524 .unwrap()
6525 .history
6526 .is_empty(),
6527 "{prose:?} must be body prose, not a retained entry"
6528 );
6529 }
6530 }
6531
6532 #[test]
6533 fn f2_a_recognised_entry_with_a_bad_kind_keeps_its_trailing_whitespace() {
6534 let line = " - 2026-01-01T00:00:00Z \u{2014} alice \u{2014} bogus_kind \u{2014} d ";
6541 let raw = format!(
6542 "---\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"
6543 );
6544 let issue = Issue::parse(&raw).unwrap();
6545 assert!(issue.history.events().is_empty(), "no event was fabricated");
6546 assert_eq!(
6547 issue.history.unparsed(),
6548 vec![line],
6549 "kept byte-for-byte, trailing spaces included"
6550 );
6551
6552 let rendered = issue.to_file_contents();
6554 assert!(
6555 rendered.contains(&format!("{line}\n")),
6556 "re-emitted exactly, so an unrelated `tix edit` does not rewrite the \
6557 author's audit log: {rendered}"
6558 );
6559 assert_eq!(
6560 Issue::parse(&rendered).unwrap().to_file_contents(),
6561 rendered,
6562 "and the file is byte-stable from here"
6563 );
6564 }
6565
6566 #[test]
6567 fn c002_history_is_append_only() {
6568 let (tmp, store) = make_store();
6569 write_issue(&store, "2026-01-01-h.md", &bare_frontmatter(1, "H"));
6570 let _ = tmp;
6571 let mut issue = store.get(IssueSelector::Number(1)).unwrap();
6572 issue
6573 .history
6574 .append_now("alice", HistoryEventKind::RelationshipAdded, "first");
6575 let after_first = issue.history.clone();
6576 issue
6577 .history
6578 .append_now("alice", HistoryEventKind::RelationshipRemoved, "second");
6579
6580 assert_eq!(issue.history.len(), 2, "appending never replaces");
6581 assert_eq!(
6582 &issue.history.events()[..1],
6583 after_first.events(),
6584 "the earlier entry is untouched and still first"
6585 );
6586 }
6587
6588 #[test]
6589 fn c002_an_unknown_history_kind_is_kept_verbatim_not_coerced() {
6590 let line = "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} bogus_kind \u{2014} something";
6601 let raw = format!(
6602 "---\nnumber: 1\ntitle: \"H\"\nstatus: \"todo\"\n---\n\n## History\n\n{line}\n"
6603 );
6604 let issue = Issue::parse(&raw).expect("an unknown kind must not fail the parse");
6605
6606 assert!(
6607 issue.history.events().is_empty(),
6608 "no event was fabricated for an unknown kind"
6609 );
6610 assert_eq!(
6611 issue.history.unparsed(),
6612 vec![line],
6613 "the line is kept verbatim: not dropped, not repaired"
6614 );
6615 let rendered = issue.to_file_contents();
6619 assert!(rendered.contains(line), "the line survives the write");
6620 assert_eq!(
6621 Issue::parse(&rendered).unwrap().to_file_contents(),
6622 rendered,
6623 "and the file round-trips byte-identically"
6624 );
6625 }
6626
6627 #[test]
6628 fn list_mixes_grouped_and_flat_issues_sorted_by_relative_filename() {
6629 let (tmp, store) = make_store();
6630 let _ = tmp;
6631 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6632 fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
6633 write_issue(
6634 &store,
6635 "alice/2026-01-01-a.md",
6636 &sample_frontmatter(1, "A", "todo"),
6637 );
6638 write_issue(
6639 &store,
6640 "bob/2026-05-10-b.md",
6641 &sample_frontmatter(2, "B", "todo"),
6642 );
6643 write_issue(
6644 &store,
6645 "2026-02-01-c.md",
6646 &sample_frontmatter(3, "C", "todo"),
6647 );
6648 let issues = store.list().unwrap();
6649 let filenames: Vec<&str> = issues.iter().map(|i| i.filename.as_str()).collect();
6650 assert_eq!(issues.len(), 3);
6651 assert_eq!(
6652 filenames,
6653 vec![
6654 "2026-02-01-c.md",
6655 "alice/2026-01-01-a.md",
6656 "bob/2026-05-10-b.md"
6657 ],
6658 "deterministic sort by relative filename"
6659 );
6660 }
6661
6662 #[test]
6663 fn list_sorts_chronologically_within_a_group() {
6664 let (tmp, store) = make_store();
6665 let _ = tmp;
6666 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6667 write_issue(
6668 &store,
6669 "alice/2026-03-01-c.md",
6670 &sample_frontmatter(3, "C", "todo"),
6671 );
6672 write_issue(
6673 &store,
6674 "alice/2026-01-01-a.md",
6675 &sample_frontmatter(1, "A", "todo"),
6676 );
6677 write_issue(
6678 &store,
6679 "alice/2026-02-01-b.md",
6680 &sample_frontmatter(2, "B", "todo"),
6681 );
6682 let issues = store.list().unwrap();
6683 let numbers: Vec<u32> = issues.iter().map(|i| i.number).collect();
6684 assert_eq!(numbers, vec![1, 2, 3]);
6685 }
6686
6687 #[test]
6688 fn list_ignores_non_md_files_in_segments() {
6689 let (tmp, store) = make_store();
6690 let _ = tmp;
6691 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6692 write_issue(
6693 &store,
6694 "alice/2026-01-01-a.md",
6695 &sample_frontmatter(1, "A", "todo"),
6696 );
6697 fs::write(store.issues_dir.join("alice/notes.txt"), "not an issue").unwrap();
6698 fs::write(store.issues_dir.join("alice/.tix-tmp-123-456"), "orphan").unwrap();
6699 let issues = store.list().unwrap();
6700 assert_eq!(issues.len(), 1);
6701 assert_eq!(issues[0].filename, "alice/2026-01-01-a.md");
6702 }
6703
6704 #[test]
6705 fn list_skips_temp_marker_directories() {
6706 let (tmp, store) = make_store();
6707 let _ = tmp;
6708 fs::create_dir_all(store.issues_dir.join(".tix-tmp-999-0")).unwrap();
6709 write_issue(
6710 &store,
6711 ".tix-tmp-999-0/2026-01-01-junk.md",
6712 &sample_frontmatter(9, "J", "todo"),
6713 );
6714 write_issue(
6715 &store,
6716 "2026-01-01-a.md",
6717 &sample_frontmatter(1, "A", "todo"),
6718 );
6719 let issues = store.list().unwrap();
6720 assert_eq!(issues.len(), 1);
6721 assert_eq!(issues[0].filename, "2026-01-01-a.md");
6722 }
6723
6724 #[test]
6725 fn list_parse_error_carries_grouped_relative_path() {
6726 let (tmp, store) = make_store();
6727 let _ = tmp;
6728 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6729 fs::write(
6730 store.issues_dir.join("alice/2026-01-01-bad.md"),
6731 "no frontmatter here\n",
6732 )
6733 .unwrap();
6734 let err = store.list().unwrap_err();
6735 assert!(
6736 matches!(&err, TixError::Parse(m) if m.starts_with("alice/2026-01-01-bad.md:")),
6737 "parse error must carry the relative path, got {err:?}"
6738 );
6739 }
6740
6741 #[test]
6746 fn create_uses_the_default_layout_template() {
6747 let tmp = tempfile::TempDir::new().unwrap();
6752 let dir = tmp.path().join("issues");
6753 fs::create_dir_all(&dir).unwrap();
6754 let store = Store {
6755 issues_dir: dir,
6756 config: Config::default(),
6757 };
6758 let mut issue = Issue {
6759 projects: None,
6760 id: Some("A1B2C3D4".into()),
6761 number: 0,
6762 title: "Round trip".into(),
6763 status: Status::new("todo"),
6764 labels: vec![],
6765 priority: Priority::default(),
6766 assignee: Some("@alice".into()),
6767 estimate: None,
6768 commit: None,
6769 gitlab_id: None,
6770 linear_id: None,
6771 mission: None,
6772 task: None,
6773 relationships: vec![],
6774 related_issues: vec![],
6775 parent: None,
6776 series: None,
6777 health: None,
6778 files: vec![],
6779 cycles: None,
6780 milestones: None,
6781 reviewer: None,
6782 start_date: None,
6783 due_date: None,
6784 entered_at: None,
6785 source: None,
6786 created: "2026-01-01".into(),
6787 type_: None,
6788 updated: "2026-01-01".into(),
6789 filename: String::new(),
6790 body: String::new(),
6791 history: crate::model::History::new(),
6792 has_retired_links_key: false,
6793 time_entries: vec![],
6794 epics: None,
6795 extra_frontmatter: std::collections::BTreeMap::new(),
6796 archived: false,
6797 };
6798 store.create(&mut issue, "", None).unwrap();
6799 assert_eq!(
6800 issue.filename,
6801 "alice/default/todo/2026-01-01-A1B2C3D4-round-trip.md"
6802 );
6803 assert!(
6804 store
6805 .issues_dir
6806 .join("alice/default/todo/2026-01-01-A1B2C3D4-round-trip.md")
6807 .is_file()
6808 );
6809 }
6810
6811 #[test]
6812 fn create_assigns_a_valid_id_and_leads_the_filename_with_it() {
6813 let (tmp, store) = make_store();
6814 let _ = tmp;
6815 let mut a = crate::model::Issue::parse(
6819 "---\nnumber: 0\ntitle: Same title\nstatus: todo\nlabels: []\npriority: medium\n\
6820 created: 2026-01-01\nupdated: 2026-01-01\n---\n\n",
6821 )
6822 .unwrap();
6823 let mut b = a.clone();
6824 store.create(&mut a, "", None).unwrap();
6825 store.create(&mut b, "", None).unwrap();
6826
6827 let id_a = a.id.clone().expect("an id is assigned at create");
6828 let id_b = b.id.clone().expect("an id is assigned at create");
6829 assert!(crate::id::is_issue_id(&id_a), "shaped id: {id_a}");
6830 assert_ne!(id_a, id_b, "two creates must not share an id");
6831 assert!(
6832 a.filename.contains(&id_a),
6833 "filename must carry the id: {}",
6834 a.filename
6835 );
6836 assert!(
6837 a.to_file_contents().contains(&format!("id: {id_a}\n")),
6838 "the id must reach the frontmatter"
6839 );
6840 }
6841
6842 #[test]
6843 fn slug_from_stem_skips_the_id_and_date_prefixes() {
6844 assert_eq!(slug_from_stem("2026-01-01-fix-login"), "fix-login");
6846 assert_eq!(slug_from_stem("2026-01-01-A1B2C3D4-fix-login"), "fix-login");
6848 assert_eq!(slug_from_stem("A1B2C3D4-2026-01-01-fix-login"), "fix-login");
6850 assert_eq!(slug_from_stem("no-prefix"), "no-prefix");
6852 }
6853
6854 #[test]
6855 fn update_always_reconciles_the_file_location() {
6856 let tmp = tempfile::TempDir::new().unwrap();
6859 let dir = tmp.path().join("issues");
6860 fs::create_dir_all(&dir).unwrap();
6861 let store = Store {
6862 issues_dir: dir,
6863 config: Config::default(),
6864 };
6865 let mut issue = Issue {
6866 projects: None,
6867 id: Some("A1B2C3D4".into()),
6868 number: 0,
6869 type_: None,
6870 title: "Round trip".into(),
6871 status: Status::new("todo"),
6872 labels: vec![],
6873 priority: Priority::default(),
6874 assignee: Some("@alice".into()),
6875 estimate: None,
6876 commit: None,
6877 gitlab_id: None,
6878 linear_id: None,
6879 mission: None,
6880 task: None,
6881 relationships: vec![],
6882 related_issues: vec![],
6883 parent: None,
6884 series: None,
6885 health: None,
6886 files: vec![],
6887 cycles: None,
6888 milestones: None,
6889 reviewer: None,
6890 start_date: None,
6891 due_date: None,
6892 entered_at: None,
6893 source: None,
6894 created: "2026-01-01".into(),
6895 updated: "2026-01-01".into(),
6896 filename: String::new(),
6897 body: String::new(),
6898 history: crate::model::History::new(),
6899 has_retired_links_key: false,
6900 time_entries: vec![],
6901 epics: None,
6902 extra_frontmatter: std::collections::BTreeMap::new(),
6903 archived: false,
6904 };
6905 store.create(&mut issue, "", None).unwrap();
6906 let old = issue.filename.clone();
6907
6908 issue.status = Status::new("done");
6910 issue.filename = store.update(&old, &issue).unwrap();
6911 assert_eq!(
6912 issue.filename,
6913 "alice/default/done/2026-01-01-A1B2C3D4-round-trip.md"
6914 );
6915 assert!(!store.issues_dir.join(&old).exists());
6916
6917 let flat = "2026-01-01-legacy.md";
6919 fs::write(
6920 store.issues_dir.join(flat),
6921 "---\nnumber: 9\ntitle: L\nstatus: todo\nlabels: []\npriority: medium\n\
6922 files: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nbody\n",
6923 )
6924 .unwrap();
6925 let mut legacy = store
6926 .get(IssueSelector::Filename(flat.to_string()))
6927 .unwrap();
6928 legacy.status = Status::new("done");
6929 let after = store.update(flat, &legacy).unwrap();
6930 assert_eq!(after, "unassigned/default/done/2026-01-01-legacy.md");
6931 assert!(!store.issues_dir.join(flat).exists(), "migrated, not left");
6932 assert!(store.issues_dir.join(&after).is_file());
6933 }
6934
6935 #[test]
6936 fn create_writes_into_assignee_segment_and_records_relative_filename() {
6937 let (tmp, store) = make_store();
6938 let _ = tmp;
6939 let mut issue = Issue {
6940 projects: None,
6941 id: None,
6942 number: 0,
6943 title: "T".into(),
6944 status: Status::default(),
6945 labels: vec![],
6946 priority: Priority::default(),
6947 assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
6948 estimate: None,
6949 commit: None,
6950 gitlab_id: None,
6951 linear_id: None,
6952 mission: None,
6953 task: None,
6954 relationships: vec![],
6955 related_issues: vec![],
6956 files: vec![],
6957 cycles: None,
6958 milestones: None,
6959 reviewer: None,
6960 start_date: None,
6961 due_date: None,
6962 created: "2026-01-01".into(),
6963 type_: None,
6964 updated: "2026-01-01".into(),
6965 filename: String::new(),
6966 body: String::new(),
6967 history: crate::model::History::new(),
6968 has_retired_links_key: false,
6969 time_entries: vec![],
6970 parent: None,
6971 series: None,
6972 health: None,
6973 epics: None,
6974 extra_frontmatter: std::collections::BTreeMap::new(),
6975 archived: false,
6976 entered_at: None,
6977 source: None,
6978 };
6979 let path = store
6980 .create(&mut issue, "body", Some("custom-stem"))
6981 .unwrap();
6982 let rel = "herbert-maschke/custom-stem.md";
6983 assert_eq!(issue.filename, rel);
6984 assert_eq!(path, store.issues_dir.join(rel));
6985 assert!(store.issues_dir.join("herbert-maschke").is_dir());
6986 assert!(store.issues_dir.join(rel).is_file());
6987 }
6988
6989 #[test]
6990 fn create_without_assignee_writes_unassigned_segment() {
6991 let (tmp, store) = make_store();
6992 let _ = tmp;
6993 let mut issue = Issue {
6994 projects: None,
6995 id: None,
6996 number: 0,
6997 title: "T".into(),
6998 status: Status::default(),
6999 labels: vec![],
7000 priority: Priority::default(),
7001 assignee: None,
7002 estimate: None,
7003 commit: None,
7004 gitlab_id: None,
7005 linear_id: None,
7006 mission: None,
7007 task: None,
7008 relationships: vec![],
7009 related_issues: vec![],
7010 files: vec![],
7011 cycles: None,
7012 milestones: None,
7013 reviewer: None,
7014 start_date: None,
7015 due_date: None,
7016 created: "2026-01-01".into(),
7017 type_: None,
7018 updated: "2026-01-01".into(),
7019 filename: String::new(),
7020 body: String::new(),
7021 history: crate::model::History::new(),
7022 has_retired_links_key: false,
7023 time_entries: vec![],
7024 parent: None,
7025 series: None,
7026 health: None,
7027 epics: None,
7028 extra_frontmatter: std::collections::BTreeMap::new(),
7029 archived: false,
7030 entered_at: None,
7031 source: None,
7032 };
7033 let path = store.create(&mut issue, "", Some("stem")).unwrap();
7034 assert_eq!(issue.filename, "unassigned/stem.md");
7035 assert!(store.issues_dir.join("unassigned").is_dir());
7036 assert!(store.issues_dir.join("unassigned/stem.md").is_file());
7037 assert_eq!(path, store.issues_dir.join("unassigned/stem.md"));
7038 }
7039
7040 #[test]
7041 fn create_with_at_handle_derives_bare_segment() {
7042 let (tmp, store) = make_store();
7043 let _ = tmp;
7044 let mut issue = Issue {
7045 projects: None,
7046 id: None,
7047 number: 0,
7048 title: "T".into(),
7049 status: Status::default(),
7050 labels: vec![],
7051 priority: Priority::default(),
7052 assignee: Some("@alice".into()),
7053 estimate: None,
7054 commit: None,
7055 gitlab_id: None,
7056 linear_id: None,
7057 mission: None,
7058 task: None,
7059 relationships: vec![],
7060 related_issues: vec![],
7061 files: vec![],
7062 cycles: None,
7063 milestones: None,
7064 reviewer: None,
7065 start_date: None,
7066 due_date: None,
7067 created: "2026-01-01".into(),
7068 type_: None,
7069 updated: "2026-01-01".into(),
7070 filename: String::new(),
7071 body: String::new(),
7072 history: crate::model::History::new(),
7073 has_retired_links_key: false,
7074 time_entries: vec![],
7075 parent: None,
7076 series: None,
7077 health: None,
7078 epics: None,
7079 extra_frontmatter: std::collections::BTreeMap::new(),
7080 archived: false,
7081 entered_at: None,
7082 source: None,
7083 };
7084 store.create(&mut issue, "", Some("stem")).unwrap();
7085 assert_eq!(issue.filename, "alice/stem.md");
7086 assert!(store.issues_dir.join("alice/stem.md").is_file());
7087 }
7088
7089 #[test]
7094 fn reassign_moves_file_between_segments() {
7095 let (tmp, store) = make_store();
7096 let _ = tmp;
7097 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7098 write_issue(
7099 &store,
7100 "alice/2026-01-01-x.md",
7101 &sample_frontmatter(1, "X", "todo"),
7102 );
7103 let new_path = store
7104 .reassign("alice/2026-01-01-x.md", Some("@bob"))
7105 .unwrap();
7106 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x.md"));
7107 assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
7108 assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
7109 }
7110
7111 #[test]
7112 fn reassign_into_occupied_segment_picks_deterministic_suffix() {
7113 let (tmp, store) = make_store();
7114 let _ = tmp;
7115 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7116 fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
7117 write_issue(
7118 &store,
7119 "alice/2026-01-01-x.md",
7120 &sample_frontmatter(1, "X", "todo"),
7121 );
7122 write_issue(
7123 &store,
7124 "bob/2026-01-01-x.md",
7125 &sample_frontmatter(2, "X", "todo"),
7126 );
7127 let new_path = store
7128 .reassign("alice/2026-01-01-x.md", Some("@bob"))
7129 .unwrap();
7130 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x-1.md"));
7131 assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
7132 assert!(store.issues_dir.join("bob/2026-01-01-x-1.md").is_file());
7133 assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
7134 }
7135
7136 #[test]
7137 fn reassign_legacy_flat_file_into_segment() {
7138 let (tmp, store) = make_store();
7139 let _ = tmp;
7140 write_issue(
7141 &store,
7142 "2026-01-01-y.md",
7143 &sample_frontmatter(1, "Y", "todo"),
7144 );
7145 let new_path = store.reassign("2026-01-01-y.md", Some("alice")).unwrap();
7146 assert_eq!(new_path, PathBuf::from("alice/2026-01-01-y.md"));
7147 assert!(store.issues_dir.join("alice/2026-01-01-y.md").is_file());
7148 assert!(!store.issues_dir.join("2026-01-01-y.md").exists());
7149 }
7150
7151 #[test]
7152 fn reassign_to_none_uses_unassigned_fallback() {
7153 let (tmp, store) = make_store();
7154 let _ = tmp;
7155 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7156 write_issue(
7157 &store,
7158 "alice/2026-01-01-z.md",
7159 &sample_frontmatter(1, "Z", "todo"),
7160 );
7161 let new_path = store.reassign("alice/2026-01-01-z.md", None).unwrap();
7162 assert_eq!(new_path, PathBuf::from("unassigned/2026-01-01-z.md"));
7163 assert!(
7164 store
7165 .issues_dir
7166 .join("unassigned/2026-01-01-z.md")
7167 .is_file()
7168 );
7169 assert!(!store.issues_dir.join("alice/2026-01-01-z.md").exists());
7170 }
7171
7172 #[test]
7173 fn reassign_keeps_number_and_created_stable() {
7174 let (tmp, store) = make_store();
7175 let _ = tmp;
7176 let mut issue = Issue {
7177 projects: None,
7178 id: None,
7179 number: 0,
7180 title: "Stable".into(),
7181 status: Status::default(),
7182 labels: vec![],
7183 priority: Priority::default(),
7184 assignee: Some("alice".into()),
7185 estimate: None,
7186 commit: None,
7187 gitlab_id: None,
7188 linear_id: None,
7189 mission: None,
7190 task: None,
7191 relationships: vec![],
7192 related_issues: vec![],
7193 files: vec![],
7194 cycles: None,
7195 milestones: None,
7196 reviewer: None,
7197 start_date: None,
7198 due_date: None,
7199 created: "2026-01-01".into(),
7200 type_: None,
7201 updated: "2026-01-01".into(),
7202 filename: String::new(),
7203 body: String::new(),
7204 history: crate::model::History::new(),
7205 has_retired_links_key: false,
7206 time_entries: vec![],
7207 parent: None,
7208 series: None,
7209 health: None,
7210 epics: None,
7211 extra_frontmatter: std::collections::BTreeMap::new(),
7212 archived: false,
7213 entered_at: None,
7214 source: None,
7215 };
7216 store.create(&mut issue, "body", None).unwrap();
7217 let number = issue.number;
7218 let created = issue.created.clone();
7219 let before = issue.filename.clone();
7220 let new_path = store.reassign(&before, Some("@bob")).unwrap();
7221 let rescan = store.list().unwrap();
7222 let moved = rescan
7223 .iter()
7224 .find(|i| i.filename == new_path)
7225 .expect("moved file must be listed at its new relative path");
7226 assert_eq!(moved.number, number);
7227 assert_eq!(moved.created, created);
7228 assert_eq!(moved.title, "Stable");
7229 }
7230
7231 #[test]
7232 fn reassign_equal_segment_is_noop() {
7233 let (tmp, store) = make_store();
7234 let _ = tmp;
7235 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7236 write_issue(
7237 &store,
7238 "alice/2026-01-01-x.md",
7239 &sample_frontmatter(1, "X", "todo"),
7240 );
7241 let new_path = store
7242 .reassign("alice/2026-01-01-x.md", Some("alice"))
7243 .unwrap();
7244 assert_eq!(new_path, PathBuf::from("alice/2026-01-01-x.md"));
7245 assert!(store.issues_dir.join("alice/2026-01-01-x.md").is_file());
7246 }
7247
7248 #[test]
7249 fn reassign_missing_file_is_not_found() {
7250 let (tmp, store) = make_store();
7251 let _ = tmp;
7252 let err = store.reassign("nope.md", Some("alice")).unwrap_err();
7253 assert!(matches!(err, TixError::NotFound(_)));
7254 }
7255
7256 #[test]
7261 fn validate_filename_allows_flat_and_single_segment_prefix() {
7262 validate_filename("2026-01-01-x.md").unwrap();
7263 validate_filename("alice/2026-01-01-x.md").unwrap();
7264 }
7265
7266 #[test]
7267 fn validate_filename_allows_deeper_segments() {
7268 validate_filename("alice/bug/in-progress/2026-01-01-x.md").unwrap();
7270 }
7271
7272 #[test]
7273 fn validate_filename_refuses_leading_and_trailing_slash_and_absolute() {
7274 let err = validate_filename("/alice/2026-01-01-x.md").unwrap_err();
7275 assert!(matches!(err, TixError::InvalidInput(_)));
7276 let err = validate_filename("/etc/passwd").unwrap_err();
7277 assert!(matches!(err, TixError::InvalidInput(_)));
7278 let err = validate_filename("alice/").unwrap_err();
7279 assert!(matches!(err, TixError::InvalidInput(_)));
7280 }
7281
7282 #[test]
7283 fn path_traversal_with_segment_dotdot_refused() {
7284 let err = validate_filename("alice/../../x.md").unwrap_err();
7285 assert!(matches!(err, TixError::InvalidInput(_)));
7286 let err = validate_filename("alice/../x.md").unwrap_err();
7287 assert!(matches!(err, TixError::InvalidInput(_)));
7288 }
7289
7290 #[test]
7291 fn get_update_delete_set_status_work_on_grouped_relative_paths() {
7292 let (tmp, store) = make_store();
7293 let _ = tmp;
7294 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7295 write_issue(
7296 &store,
7297 "alice/2026-01-01-g.md",
7298 &sample_frontmatter(7, "G", "todo"),
7299 );
7300 let got = store
7301 .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
7302 .unwrap();
7303 assert_eq!(got.number, 7);
7304 let done = store
7305 .set_status("alice/2026-01-01-g.md", StatusChange::to("done"))
7306 .unwrap();
7307 assert_eq!(done.issue.status.display(), "done");
7308 assert!(store.issues_dir.join("alice/2026-01-01-g.md").is_file());
7309 let mut edited = store
7310 .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
7311 .unwrap();
7312 edited.title = "Updated".into();
7313 store.update("alice/2026-01-01-g.md", &edited).unwrap();
7314 let reloaded = store
7315 .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
7316 .unwrap();
7317 assert_eq!(reloaded.title, "Updated");
7318 store.delete("alice/2026-01-01-g.md").unwrap();
7319 assert!(!store.issues_dir.join("alice/2026-01-01-g.md").exists());
7320 }
7321
7322 #[test]
7327 fn grouped_round_trip_create_list_get_update_delete() {
7328 let (tmp, store) = make_store();
7329 let _ = tmp;
7330 let mut issue = Issue {
7331 projects: None,
7332 id: Some("A1B2C3D4".into()),
7333 number: 0,
7334 title: "Round trip".into(),
7335 status: Status::default(),
7336 labels: vec!["bug".into()],
7337 priority: Priority::default(),
7338 assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
7339 estimate: None,
7340 commit: None,
7341 gitlab_id: None,
7342 linear_id: None,
7343 mission: None,
7344 task: None,
7345 relationships: vec![],
7346 related_issues: vec![],
7347 files: vec![],
7348 cycles: None,
7349 milestones: None,
7350 reviewer: None,
7351 start_date: None,
7352 due_date: None,
7353 created: "2026-01-01".into(),
7354 type_: None,
7355 updated: "2026-01-01".into(),
7356 filename: String::new(),
7357 body: String::new(),
7358 history: crate::model::History::new(),
7359 has_retired_links_key: false,
7360 time_entries: vec![],
7361 parent: None,
7362 series: None,
7363 health: None,
7364 epics: None,
7365 extra_frontmatter: std::collections::BTreeMap::new(),
7366 archived: false,
7367 entered_at: None,
7368 source: None,
7369 };
7370 store.create(&mut issue, "initial body", None).unwrap();
7371 let rel = issue.filename.clone();
7372 assert_eq!(rel, "herbert-maschke/2026-01-01-A1B2C3D4-round-trip.md");
7373
7374 let listed = store.list().unwrap();
7375 assert_eq!(listed.len(), 1);
7376 assert_eq!(listed[0].filename, rel);
7377 assert_eq!(listed[0].number, issue.number);
7378
7379 let got = store.get(IssueSelector::Filename(rel.clone())).unwrap();
7380 let mut edited = got.clone();
7381 edited.title = "Renamed".into();
7382 store.update(&rel, &edited).unwrap();
7383 let reloaded = store.get(IssueSelector::Filename(rel.clone())).unwrap();
7384 assert_eq!(reloaded.title, "Renamed");
7385
7386 store.delete(&rel).unwrap();
7387 assert!(!store.issues_dir.join(&rel).exists());
7388 assert_eq!(store.list().unwrap(), [] as [crate::model::Issue; 0]);
7389 }
7390
7391 #[test]
7392 fn legacy_flat_file_edit_and_status_still_work_after_layout_flip() {
7393 let (tmp, store) = make_store();
7394 let _ = tmp;
7395 write_issue(
7396 &store,
7397 "2026-01-01-legacy.md",
7398 &sample_frontmatter(5, "Legacy", "todo"),
7399 );
7400 let listed = store.list().unwrap();
7401 assert_eq!(listed.len(), 1);
7402 assert_eq!(listed[0].filename, "2026-01-01-legacy.md");
7403 let mutated = store
7404 .set_status("2026-01-01-legacy.md", StatusChange::to("in progress"))
7405 .unwrap();
7406 assert_eq!(mutated.issue.status.display(), "in progress");
7407 assert!(store.issues_dir.join("2026-01-01-legacy.md").is_file());
7408 }
7409
7410 fn write_asset_source(tmp: &tempfile::TempDir, name: &str, contents: &[u8]) -> PathBuf {
7415 let path = tmp.path().join(name);
7416 fs::write(&path, contents).unwrap();
7417 path
7418 }
7419
7420 fn write_issue_with_basic_body(store: &Store, name: &str, number: u32) {
7421 write_issue(
7422 store,
7423 name,
7424 &sample_frontmatter(number, "Asset host", "todo"),
7425 );
7426 }
7427
7428 #[test]
7429 fn add_asset_image_inserts_image_link_and_writes_file() {
7430 let (tmp, store) = make_store();
7431 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7432 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7433 let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
7434 let added = store
7435 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7436 .unwrap();
7437 assert_eq!(added.written_name, "diagram.png");
7438 assert_eq!(added.link, "");
7439 assert!(
7440 store
7441 .issues_dir
7442 .join("alice/fix-login-bug-assets/diagram.png")
7443 .is_file()
7444 );
7445 }
7446
7447 #[test]
7448 fn add_asset_non_image_inserts_generic_markdown_link() {
7449 let (tmp, store) = make_store();
7450 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7451 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7452 let src = write_asset_source(&tmp, "report.pdf", b"%PDF");
7453 let added = store
7454 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7455 .unwrap();
7456 assert_eq!(added.link, "[report](fix-login-bug-assets/report.pdf)");
7457 assert!(
7458 store
7459 .issues_dir
7460 .join("alice/fix-login-bug-assets/report.pdf")
7461 .is_file()
7462 );
7463 }
7464
7465 #[test]
7466 fn add_asset_collision_resolves_with_numeric_suffix_and_link_points_at_it() {
7467 let (tmp, store) = make_store();
7468 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7469 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7470 let src = write_asset_source(&tmp, "x.png", b"first");
7471 let first = store
7472 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7473 .unwrap();
7474 assert_eq!(first.written_name, "x.png");
7475 let again = store
7476 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7477 .unwrap();
7478 assert_eq!(again.written_name, "x-1.png");
7479 assert_eq!(again.link, "");
7480 assert!(
7481 store
7482 .issues_dir
7483 .join("alice/fix-login-bug-assets/x.png")
7484 .is_file()
7485 );
7486 assert!(
7487 store
7488 .issues_dir
7489 .join("alice/fix-login-bug-assets/x-1.png")
7490 .is_file()
7491 );
7492 }
7493
7494 #[test]
7495 fn add_asset_appends_single_link_and_preserves_description() {
7496 let (tmp, store) = make_store();
7497 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7498 write_issue(
7499 &store,
7500 "alice/2026-01-01-fix-login-bug.md",
7501 "---\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",
7502 );
7503 let src = write_asset_source(&tmp, "diagram.svg", b"<svg/>");
7504 store
7505 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7506 .unwrap();
7507 let reloaded = store
7508 .get(crate::storage::IssueSelector::Filename(
7509 "alice/2026-01-01-fix-login-bug.md".into(),
7510 ))
7511 .unwrap();
7512 assert_eq!(reloaded.description(), "Title line.");
7514 let link_count = reloaded.body.matches("![diagram]").count();
7516 assert_eq!(link_count, 1);
7517 assert!(
7518 reloaded
7519 .body
7520 .ends_with("fix-login-bug-assets/diagram.svg)\n")
7521 );
7522 }
7523
7524 #[test]
7525 fn add_asset_missing_issue_is_not_found() {
7526 let (tmp, store) = make_store();
7527 let _ = tmp;
7528 let src = write_asset_source(&tmp, "x.png", b"x");
7529 let err = store.add_asset("missing.md", &src).unwrap_err();
7530 assert!(matches!(err, TixError::NotFound(_)));
7531 }
7532
7533 #[test]
7534 fn add_asset_legacy_flat_file_uses_stem_assets_under_issues_dir() {
7535 let (tmp, store) = make_store();
7536 write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
7537 let src = write_asset_source(&tmp, "x.png", b"x");
7538 let added = store.add_asset("2026-01-01-legacy.md", &src).unwrap();
7539 assert_eq!(added.link, "");
7540 assert!(store.issues_dir.join("legacy-assets/x.png").is_file());
7541 }
7542
7543 #[test]
7548 fn reassign_moves_asset_dir_with_the_file() {
7549 let (tmp, store) = make_store();
7550 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7551 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7552 let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
7553 store
7554 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7555 .unwrap();
7556 let new_path = store
7557 .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
7558 .unwrap();
7559 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
7560 assert!(
7561 store
7562 .issues_dir
7563 .join("bob/fix-login-bug-assets/diagram.png")
7564 .is_file()
7565 );
7566 assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
7567 }
7568
7569 #[test]
7570 fn reassign_without_asset_dir_behaves_as_before() {
7571 let (tmp, store) = make_store();
7572 let _ = tmp;
7573 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7574 write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
7575 let new_path = store
7576 .reassign("alice/2026-01-01-plain.md", Some("@bob"))
7577 .unwrap();
7578 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-plain.md"));
7579 assert!(store.issues_dir.join("bob/2026-01-01-plain.md").is_file());
7580 assert!(!store.issues_dir.join("bob/plain-assets").exists());
7581 }
7582
7583 #[test]
7584 fn reassign_into_segment_with_same_named_asset_dir_uses_suffix_no_loss() {
7585 let (tmp, store) = make_store();
7586 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7587 fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
7588 let existing = store.issues_dir.join("bob/fix-login-bug-assets");
7590 fs::create_dir_all(&existing).unwrap();
7591 fs::write(existing.join("keep.txt"), "precious").unwrap();
7592 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7593 let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
7594 store
7595 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7596 .unwrap();
7597 let new_path = store
7598 .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
7599 .unwrap();
7600 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
7601 assert!(existing.join("keep.txt").is_file());
7603 assert!(
7604 store
7605 .issues_dir
7606 .join("bob/fix-login-bug-assets-1/diagram.png")
7607 .is_file()
7608 );
7609 assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
7610 }
7611
7612 #[test]
7613 fn reassign_preserves_markdown_link_resolution_in_new_segment() {
7614 let (tmp, store) = make_store();
7615 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7616 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7617 let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
7618 store
7619 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7620 .unwrap();
7621 store
7622 .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
7623 .unwrap();
7624 assert!(
7627 store
7628 .issues_dir
7629 .join("bob/fix-login-bug-assets/diagram.png")
7630 .is_file()
7631 );
7632 }
7633
7634 #[test]
7639 fn delete_removes_asset_dir_no_orphans() {
7640 let (tmp, store) = make_store();
7641 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7642 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7643 let src = write_asset_source(&tmp, "x.png", b"x");
7644 store
7645 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7646 .unwrap();
7647 store.delete("alice/2026-01-01-fix-login-bug.md").unwrap();
7648 assert!(
7649 !store
7650 .issues_dir
7651 .join("alice/2026-01-01-fix-login-bug.md")
7652 .exists()
7653 );
7654 assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
7655 }
7656
7657 #[test]
7658 fn delete_without_asset_dir_still_deletes_file() {
7659 let (tmp, store) = make_store();
7660 let _ = tmp;
7661 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7662 write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
7663 store.delete("alice/2026-01-01-plain.md").unwrap();
7664 assert!(!store.issues_dir.join("alice/2026-01-01-plain.md").exists());
7665 }
7666
7667 #[test]
7668 fn list_ignores_asset_directories() {
7669 let (tmp, store) = make_store();
7670 write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
7671 let src = write_asset_source(&tmp, "x.png", b"x");
7672 store.add_asset("2026-01-01-legacy.md", &src).unwrap();
7673 let issues = store.list().unwrap();
7676 assert_eq!(issues.len(), 1);
7677 assert_eq!(issues[0].filename, "2026-01-01-legacy.md");
7678 }
7679
7680 #[test]
7681 fn list_ignores_nested_asset_directories_under_segment() {
7682 let (tmp, store) = make_store();
7683 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7684 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7685 let src = write_asset_source(&tmp, "x.png", b"x");
7686 store
7687 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7688 .unwrap();
7689 let issues = store.list().unwrap();
7690 assert_eq!(issues.len(), 1);
7691 assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
7692 }
7693
7694 #[test]
7695 fn asset_dir_with_formless_md_child_is_not_scanned_as_segment() {
7696 let (tmp, store) = make_store();
7697 let _ = tmp;
7698 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7699 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7700 let assets_dir = store.issues_dir.join("alice/fix-login-bug-assets");
7703 fs::create_dir_all(&assets_dir).unwrap();
7704 fs::write(assets_dir.join("notes.md"), "no frontmatter").unwrap();
7705 let issues = store.list().unwrap();
7706 assert_eq!(issues.len(), 1);
7707 assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
7708 }
7709
7710 fn hierarchy_trio() -> (TempDir, Store) {
7724 let (tmp, store) = make_store();
7725 for (n, title) in [(1, "A"), (2, "B"), (3, "C")] {
7726 write_issue(
7727 &store,
7728 &format!("2026-01-01-{}.md", title.to_lowercase()),
7729 &bare_frontmatter(n, title),
7730 );
7731 }
7732 (tmp, store)
7733 }
7734
7735 fn store_from_hierarchy_fixture(name: &str) -> (TempDir, Store) {
7742 let (tmp, store) = make_store();
7743 write_issue(
7744 &store,
7745 name,
7746 &std::fs::read_to_string(format!(
7747 "{}/tests/fixtures/hierarchy/{name}",
7748 env!("CARGO_MANIFEST_DIR")
7749 ))
7750 .unwrap(),
7751 );
7752 (tmp, store)
7753 }
7754
7755 fn bytes_of(store: &Store, number: u32) -> String {
7756 let issue = store.get(IssueSelector::Number(number)).unwrap();
7757 read_issue_file(store, &issue.filename)
7758 }
7759
7760 #[test]
7761 fn t008_set_writes_the_field_and_exactly_one_history_event() {
7762 let (_tmp, store) = hierarchy_trio();
7763 let outcome = store.set_parent("2", "1").unwrap();
7764 assert!(outcome.changed);
7765 assert_eq!(outcome.parent, Some(1));
7766
7767 let child = store.get(IssueSelector::Number(2)).unwrap();
7768 assert_eq!(child.parent, Some(1), "C-001: the number is stored");
7769 assert_eq!(child.history.len(), 1, "C-004: exactly one event");
7770 let event = &child.history.events()[0];
7771 assert_eq!(event.kind, HistoryEventKind::ParentSet);
7772 assert!(
7773 !event.actor.is_empty(),
7774 "an event without an actor is not an audit record"
7775 );
7776 assert_eq!(event.detail, "parent none -> #1", "got: {}", event.detail);
7778 }
7779
7780 #[test]
7781 fn t008_change_repoints_and_records_parent_changed() {
7782 let (_tmp, store) = hierarchy_trio();
7783 store.set_parent("3", "1").unwrap();
7784 let outcome = store.change_parent("3", "2").unwrap();
7785 assert!(outcome.changed);
7786 assert_eq!(outcome.parent, Some(2));
7787
7788 let child = store.get(IssueSelector::Number(3)).unwrap();
7789 assert_eq!(child.parent, Some(2));
7790 assert_eq!(child.history.len(), 2, "one event per accepted change");
7791 let events = child.history.events();
7792 let last = events.last().unwrap();
7793 assert_eq!(last.kind, HistoryEventKind::ParentChanged);
7794 assert_eq!(last.detail, "parent #1 -> #2", "got: {}", last.detail);
7797 }
7798
7799 #[test]
7800 fn t008_clear_removes_the_field_and_records_parent_cleared() {
7801 let (_tmp, store) = hierarchy_trio();
7802 store.set_parent("2", "1").unwrap();
7803 let outcome = store.clear_parent("2").unwrap();
7804 assert!(outcome.changed);
7805 assert_eq!(outcome.parent, None);
7806
7807 let child = store.get(IssueSelector::Number(2)).unwrap();
7808 assert_eq!(child.parent, None);
7809 assert_eq!(child.history.len(), 2);
7810 let events = child.history.events();
7811 let last = events.last().unwrap();
7812 assert_eq!(last.kind, HistoryEventKind::ParentCleared);
7813 assert_eq!(last.detail, "parent #1 -> none", "got: {}", last.detail);
7814 }
7815
7816 #[test]
7817 fn t008_reapplying_the_same_parent_is_a_no_op_with_no_second_event() {
7818 let (_tmp, store) = hierarchy_trio();
7819 store.set_parent("2", "1").unwrap();
7820 let after_first = bytes_of(&store, 2);
7821
7822 let second = store.set_parent("2", "1").unwrap();
7823 assert!(!second.changed, "an unchanged parent is not a change");
7824 assert!(second.event.is_none(), "a no-op records nothing (C-004)");
7825 assert_eq!(
7826 bytes_of(&store, 2),
7827 after_first,
7828 "a no-op must not rewrite the file at all"
7829 );
7830 }
7831
7832 #[test]
7833 fn t008_clearing_a_child_with_no_parent_is_a_no_op() {
7834 let (_tmp, store) = hierarchy_trio();
7835 let before = bytes_of(&store, 2);
7836 let outcome = store.clear_parent("2").unwrap();
7837 assert!(!outcome.changed);
7838 assert!(outcome.event.is_none());
7839 assert_eq!(bytes_of(&store, 2), before);
7840 }
7841
7842 #[test]
7843 fn t008_sc004_a_rejected_cycle_leaves_every_file_byte_identical() {
7844 let (_tmp, store) = hierarchy_trio();
7846 store.set_parent("2", "1").unwrap();
7847 store.set_parent("3", "2").unwrap();
7848 let before: Vec<String> = (1..=3).map(|n| bytes_of(&store, n)).collect();
7849
7850 let err = store
7851 .set_parent("1", "3")
7852 .expect_err("1 -> 2 -> 3 -> 1 is a cycle and must be refused");
7853 let msg = err.to_string();
7854 assert!(
7855 msg.contains("cycle"),
7856 "the diagnostic must name the rule: {msg}"
7857 );
7858
7859 for n in 1..=3 {
7861 assert_eq!(
7862 bytes_of(&store, n),
7863 before[(n - 1) as usize],
7864 "issue {n} must be byte-identical after a rejected cycle"
7865 );
7866 }
7867 }
7868
7869 #[test]
7870 fn t008_a_self_parent_is_rejected_and_writes_nothing() {
7871 let (_tmp, store) = hierarchy_trio();
7872 let before = bytes_of(&store, 2);
7873 let err = store
7874 .set_parent("2", "2")
7875 .expect_err("an issue cannot be its own parent");
7876 assert!(err.to_string().contains("self_parent"), "got: {err}");
7877 assert_eq!(bytes_of(&store, 2), before);
7878 }
7879
7880 #[test]
7881 fn t008_a_self_parent_already_on_disk_is_still_refused() {
7882 let (_tmp, store) = store_from_hierarchy_fixture("self-parent.md");
7890 let before = bytes_of(&store, 7);
7891 let err = store
7892 .set_parent("7", "7")
7893 .expect_err("a stored self-parent must still be refused on a re-set");
7894 assert!(
7895 err.to_string().contains("self_parent"),
7896 "refused for the wrong reason: {err}"
7897 );
7898 assert_eq!(
7899 bytes_of(&store, 7),
7900 before,
7901 "and the refused write must leave the file byte-identical"
7902 );
7903 assert!(store.change_parent("7", "7").is_err());
7905 assert_eq!(store.get(IssueSelector::Number(7)).unwrap().parent, Some(7));
7907 }
7908
7909 #[test]
7910 fn t008_c003_setting_a_child_writes_nothing_to_the_parents_file() {
7911 let (_tmp, store) = hierarchy_trio();
7914 let parent_before = bytes_of(&store, 1);
7915 store.set_parent("2", "1").unwrap();
7916 store.set_parent("3", "1").unwrap();
7917 assert_eq!(
7918 bytes_of(&store, 1),
7919 parent_before,
7920 "the parent's file must be untouched by its children appearing"
7921 );
7922 assert!(
7923 !parent_before.contains("parent:"),
7924 "the parent is a top of the hierarchy and has no parent itself"
7925 );
7926 }
7927
7928 #[test]
7929 fn t008_a_legacy_file_without_the_field_is_unchanged_by_a_no_op_write() {
7930 let (_tmp, store) = hierarchy_trio();
7932 let before = bytes_of(&store, 2);
7933 assert!(!before.contains("parent:"), "the fixture must be legacy");
7934
7935 store.clear_parent("2").unwrap();
7936 assert_eq!(bytes_of(&store, 2), before, "a no-op must not add the key");
7937 }
7938
7939 #[test]
7940 fn t008_an_unresolvable_selector_is_refused_before_any_write() {
7941 let (_tmp, store) = hierarchy_trio();
7942 let before = bytes_of(&store, 2);
7943 assert!(store.set_parent("2", "no such issue").is_err());
7944 assert!(store.change_parent("2", "999").is_err());
7945 assert_eq!(bytes_of(&store, 2), before);
7946 }
7947
7948 #[test]
7949 fn t008_t008_contains_no_hierarchy_rule_of_its_own() {
7950 let (_tmp, store) = hierarchy_trio();
7966 store.set_parent("2", "1").unwrap();
7967 store.set_parent("3", "2").unwrap();
7968
7969 let issues = store.list().unwrap();
7970 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
7971 let index = HierarchyIndex::build(&views);
7972 let service =
7973 hierarchy::set_parent(&index, 1, 3).expect_err("the service refuses this too");
7974 let storage = store.set_parent("1", "3").expect_err("and so does storage");
7975
7976 assert_eq!(
7977 storage.to_string(),
7978 TixError::from(service).to_string(),
7979 "storage must surface the service's diagnosis verbatim, not its own"
7980 );
7981 let text = storage.to_string();
7984 assert!(
7985 text.contains("cycle:") && !text.contains("cycle: cycle:"),
7986 "a lookalike renderer or a doubled prefix: {text}"
7987 );
7988 assert!(
7989 text.starts_with("error[E-tix-hierarchy]: "),
7990 "a hierarchy refusal carries the shared rustc-style header: {text}"
7991 );
7992 }
7993
7994 #[test]
7995 fn t008_a_no_op_reports_no_written_issue_but_still_reports_the_current_parent() {
7996 let (_tmp, store) = hierarchy_trio();
8000 store.set_parent("2", "1").unwrap();
8001 let again = store.set_parent("2", "1").unwrap();
8002 assert!(!again.changed);
8003 assert!(
8004 again.written.is_none(),
8005 "a no-op wrote nothing, so there is no written issue to hand back"
8006 );
8007 assert!(
8008 again.event.is_none(),
8009 "and no event, or a retry would pad the audit trail"
8010 );
8011 assert_eq!(
8012 again.parent,
8013 Some(1),
8014 "but the resulting state is still reported, so a caller need not re-read"
8015 );
8016 }
8017
8018 #[test]
8026 fn a_failed_transaction_restores_every_file_it_touched() {
8027 let (_tmp, store) = make_store();
8028 write_issue(
8029 &store,
8030 "2026-01-01-a.md",
8031 &sample_frontmatter(1, "A", "todo"),
8032 );
8033 write_issue(
8034 &store,
8035 "2026-01-01-b.md",
8036 &sample_frontmatter(2, "B", "todo"),
8037 );
8038 let before_a = read_issue_file(&store, "2026-01-01-a.md");
8039 let before_b = read_issue_file(&store, "2026-01-01-b.md");
8040
8041 let mut a = store.get(IssueSelector::Number(1)).unwrap();
8042 a.title = "Changed".into();
8043 let mut b = store.get(IssueSelector::Number(2)).unwrap();
8044 b.title = "Also changed".into();
8045
8046 let err = store
8047 .transaction(|| {
8048 store.update("2026-01-01-a.md", &a)?;
8049 store.update("2026-01-01-b.md", &b)?;
8050 Err::<(), _>(TixError::InvalidInput("boom".into()))
8051 })
8052 .unwrap_err();
8053
8054 assert!(err.to_string().contains("boom"), "got: {err}");
8055 assert_eq!(
8056 read_issue_file(&store, "2026-01-01-a.md"),
8057 before_a,
8058 "the first write must be undone too, not only the last"
8059 );
8060 assert_eq!(read_issue_file(&store, "2026-01-01-b.md"), before_b);
8061 assert_eq!(store.get(IssueSelector::Number(1)).unwrap().title, "A");
8062 assert_eq!(store.get(IssueSelector::Number(2)).unwrap().title, "B");
8063 }
8064
8065 #[test]
8068 fn a_committed_transaction_keeps_its_writes() {
8069 let (_tmp, store) = make_store();
8070 write_issue(
8071 &store,
8072 "2026-01-01-a.md",
8073 &sample_frontmatter(1, "A", "todo"),
8074 );
8075 let mut a = store.get(IssueSelector::Number(1)).unwrap();
8076 a.title = "Changed".into();
8077
8078 store
8079 .transaction(|| {
8080 store.update("2026-01-01-a.md", &a)?;
8081 Ok(())
8082 })
8083 .unwrap();
8084
8085 assert_eq!(
8086 store.get(IssueSelector::Number(1)).unwrap().title,
8087 "Changed"
8088 );
8089 }
8090
8091 #[test]
8097 fn a_failed_transaction_undoes_a_reassign_move_and_its_content_write() {
8098 let (_tmp, store) = make_store();
8099 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
8100 write_issue(
8101 &store,
8102 "alice/2026-01-01-x.md",
8103 &sample_frontmatter(5, "X", "todo"),
8104 );
8105 let before = read_issue_file(&store, "alice/2026-01-01-x.md");
8106
8107 let mut edited = store.get(IssueSelector::Number(5)).unwrap();
8108 edited.title = "Edited".into();
8109
8110 let err = store
8111 .transaction(|| {
8112 let moved = store.reassign("alice/2026-01-01-x.md", Some("@bob"))?;
8113 store.update(&moved.to_string_lossy(), &edited)?;
8114 Err::<(), _>(TixError::InvalidInput("boom".into()))
8115 })
8116 .unwrap_err();
8117
8118 assert!(err.to_string().contains("boom"));
8119 assert!(
8120 store.issues_dir.join("alice/2026-01-01-x.md").is_file(),
8121 "the moved file must be back under its original segment"
8122 );
8123 assert!(
8124 !store.issues_dir.join("bob/2026-01-01-x.md").exists(),
8125 "the moved copy must not survive the rollback"
8126 );
8127 assert_eq!(read_issue_file(&store, "alice/2026-01-01-x.md"), before);
8128 }
8129
8130 #[test]
8132 fn a_failed_transaction_restores_a_deleted_file() {
8133 let (_tmp, store) = make_store();
8134 write_issue(
8135 &store,
8136 "2026-01-01-a.md",
8137 &sample_frontmatter(1, "A", "todo"),
8138 );
8139 let before = read_issue_file(&store, "2026-01-01-a.md");
8140
8141 let _ = store
8142 .transaction(|| {
8143 store.delete("2026-01-01-a.md")?;
8144 Err::<(), _>(TixError::InvalidInput("boom".into()))
8145 })
8146 .unwrap_err();
8147
8148 assert!(store.issues_dir.join("2026-01-01-a.md").is_file());
8149 assert_eq!(read_issue_file(&store, "2026-01-01-a.md"), before);
8150 }
8151
8152 #[test]
8157 fn a_nested_transaction_joins_the_outer_one() {
8158 let (_tmp, store) = make_store();
8159 write_issue(
8160 &store,
8161 "2026-01-01-a.md",
8162 &sample_frontmatter(1, "A", "todo"),
8163 );
8164 let before = read_issue_file(&store, "2026-01-01-a.md");
8165 let mut a = store.get(IssueSelector::Number(1)).unwrap();
8166 a.title = "Outer".into();
8167 let mut a2 = a.clone();
8168 a2.title = "Inner".into();
8169
8170 let err = store
8171 .transaction(|| {
8172 store.update("2026-01-01-a.md", &a)?;
8173 store.transaction(|| {
8175 store.update("2026-01-01-a.md", &a2)?;
8176 Err::<(), _>(TixError::InvalidInput("inner boom".into()))
8177 })?;
8178 Ok(())
8179 })
8180 .unwrap_err();
8181
8182 assert!(err.to_string().contains("inner boom"));
8183 assert_eq!(
8184 read_issue_file(&store, "2026-01-01-a.md"),
8185 before,
8186 "the outer write must also be undone, so the whole unit is atomic"
8187 );
8188 }
8189
8190 #[test]
8193 fn a_write_outside_a_transaction_is_not_journaled() {
8194 let (_tmp, store) = make_store();
8195 write_issue(
8196 &store,
8197 "2026-01-01-a.md",
8198 &sample_frontmatter(1, "A", "todo"),
8199 );
8200 let mut a = store.get(IssueSelector::Number(1)).unwrap();
8201 a.title = "Plain".into();
8202 store.update("2026-01-01-a.md", &a).unwrap();
8203 assert_eq!(store.get(IssueSelector::Number(1)).unwrap().title, "Plain");
8204 }
8205
8206 fn project_store() -> (TempDir, Store) {
8215 let tmp = TempDir::new().unwrap();
8216 let dir = tmp.path().join("issues");
8217 fs::create_dir_all(&dir).unwrap();
8218 let config_path = tmp.path().join("tix.toml");
8219 let mut config = Config {
8220 issues_dir: dir.clone(),
8221 ..Config::default()
8222 };
8223 config.layout.directory = "{assignee}".to_string();
8224 config.layout.move_on_change = false;
8225 config.projects.insert(
8226 "p-1".to_string(),
8227 ProjectConfiguration {
8228 name: "One".into(),
8229 ..ProjectConfiguration::default()
8230 },
8231 );
8232 config.save(&config_path).unwrap();
8233 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
8234 let store = Store {
8235 issues_dir: dir,
8236 config,
8237 };
8238 write_issue(
8239 &store,
8240 "2026-01-01-a.md",
8241 &sample_frontmatter(1, "A", "todo"),
8242 );
8243 (tmp, store)
8244 }
8245
8246 fn reloaded(store: &Store, filename: &str) -> Issue {
8248 store
8249 .get(IssueSelector::Filename(filename.to_string()))
8250 .unwrap()
8251 }
8252
8253 fn project_fixture(name: &str) -> String {
8256 let path = format!(
8257 "{}/tests/fixtures/projects/{name}",
8258 env!("CARGO_MANIFEST_DIR")
8259 );
8260 fs::read_to_string(&path).unwrap_or_else(|e| panic!("reading fixture {name}: {e}"))
8261 }
8262
8263 fn all_project_fixtures() -> Vec<String> {
8265 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/projects");
8266 let mut names: Vec<String> = fs::read_dir(&dir)
8267 .unwrap()
8268 .filter_map(|entry| entry.ok())
8269 .map(|entry| entry.file_name().to_string_lossy().into_owned())
8270 .filter(|name| name.ends_with(".md"))
8271 .collect();
8272 names.sort();
8273 names
8274 }
8275
8276 #[test]
8277 fn t013_an_accepted_association_writes_the_list_and_exactly_one_history_entry() {
8278 let (_tmp, store) = project_store();
8279 let before = read_issue_file(&store, "2026-01-01-a.md");
8280 let outcome = store.associate("1", "p-1").unwrap();
8281
8282 assert!(outcome.changed, "an accepted, changing association writes");
8283 assert!(outcome.written.is_some());
8284 assert_eq!(outcome.project_ids, vec!["p-1".to_string()]);
8285 let event = outcome
8286 .event
8287 .clone()
8288 .expect("an accepted change is audited");
8289 assert_eq!(event.detail, "project `p-1` associated");
8290
8291 let after = reloaded(&store, "2026-01-01-a.md");
8293 assert_eq!(after.projects, Some(vec!["p-1".to_string()]));
8294 let events = after.history.events();
8295 assert_eq!(events.len(), 1, "exactly one History entry: {events:?}");
8296 assert_eq!(events[0].kind, HistoryEventKind::ProjectAssociated);
8297 assert_eq!(events[0].detail, "project `p-1` associated");
8298 assert_ne!(
8299 read_issue_file(&store, "2026-01-01-a.md"),
8300 before,
8301 "an accepted change is written"
8302 );
8303 }
8304
8305 #[test]
8306 fn t013_a_refused_association_writes_nothing_and_appends_nothing() {
8307 let (_tmp, store) = project_store();
8308 let before = read_issue_file(&store, "2026-01-01-a.md");
8309
8310 let err = store.associate("1", "p-unknown").unwrap_err();
8311 assert!(
8312 matches!(err, TixError::InvalidInput(_)),
8313 "an unknown project is a user input refusal, got {err:?}"
8314 );
8315 assert_eq!(
8316 read_issue_file(&store, "2026-01-01-a.md"),
8317 before,
8318 "a refusal must leave the file byte-identical"
8319 );
8320 assert!(
8321 reloaded(&store, "2026-01-01-a.md").history.is_empty(),
8322 "a refusal must append nothing"
8323 );
8324
8325 let err = store.associate("999", "p-1").unwrap_err();
8327 assert!(matches!(err, TixError::NotFound(_)), "got {err:?}");
8328 assert_eq!(read_issue_file(&store, "2026-01-01-a.md"), before);
8329 }
8330
8331 #[test]
8332 fn t013_a_refusal_reports_the_service_wording_not_a_storage_copy() {
8333 let (_tmp, store) = project_store();
8336 let err = store.associate("1", "p-unknown").unwrap_err();
8337 assert!(
8338 err.to_string()
8339 .contains(&crate::projects::unknown_project_reason("p-unknown")),
8340 "the refusal must come from the service, got: {err}"
8341 );
8342 }
8343
8344 #[test]
8345 fn t013_re_associating_is_idempotent_no_second_entry_and_no_second_event() {
8346 let (_tmp, store) = project_store();
8347 store.associate("1", "p-1").unwrap();
8348 let after_first = read_issue_file(&store, "2026-01-01-a.md");
8349
8350 let second = store.associate("1", "p-1").unwrap();
8351 assert!(!second.changed, "the idempotent re-apply changes nothing");
8352 assert!(second.written.is_none(), "and writes no file");
8353 assert!(second.event.is_none(), "and appends no event");
8354 assert_eq!(second.project_ids, vec!["p-1".to_string()]);
8355 assert_eq!(
8356 read_issue_file(&store, "2026-01-01-a.md"),
8357 after_first,
8358 "the file is byte-identical after a repeated association"
8359 );
8360
8361 let after = reloaded(&store, "2026-01-01-a.md");
8362 assert_eq!(after.history.events().len(), 1, "no second History event");
8363 assert_eq!(
8364 after.projects,
8365 Some(vec!["p-1".to_string()]),
8366 "and no duplicate entry: (issue, project_id) is unique"
8367 );
8368 }
8369
8370 #[test]
8371 fn t013_dissociate_drops_only_the_association_and_records_one_event() {
8372 let (_tmp, store) = project_store();
8373 store.associate("1", "p-1").unwrap();
8374
8375 let outcome = store.dissociate("1", "p-1").unwrap();
8376 assert!(outcome.changed);
8377 assert_eq!(outcome.project_ids, [] as [std::string::String; 0]);
8378 let event = outcome
8379 .event
8380 .clone()
8381 .expect("an accepted change is audited");
8382 assert_eq!(event.detail, "project `p-1` dissociated");
8383
8384 let after = reloaded(&store, "2026-01-01-a.md");
8385 assert_eq!(after.projects, None, "the association is dropped");
8386 assert!(
8387 !read_issue_file(&store, "2026-01-01-a.md").contains("projects:"),
8388 "an empty list is written as an absent key"
8389 );
8390 let events = after.history.events();
8391 assert_eq!(events.len(), 2, "associate + dissociate = two entries");
8392 assert_eq!(events[1].kind, HistoryEventKind::ProjectDissociated);
8393
8394 let again = store.dissociate("1", "p-1").unwrap();
8396 assert!(!again.changed);
8397 assert!(again.event.is_none());
8398 assert_eq!(
8399 reloaded(&store, "2026-01-01-a.md").history.events().len(),
8400 2,
8401 "no third History event"
8402 );
8403 }
8404
8405 #[test]
8406 fn t013_bulk_associate_writes_each_affected_file_independently() {
8407 let (_tmp, store) = project_store();
8408 write_issue(
8409 &store,
8410 "2026-01-01-b.md",
8411 &sample_frontmatter(2, "B", "todo"),
8412 );
8413
8414 let outcomes = store
8415 .bulk_associate(
8416 "p-1",
8417 &["1".to_string(), "999".to_string(), "2".to_string()],
8418 )
8419 .unwrap();
8420
8421 assert_eq!(outcomes.len(), 3, "one entry per selector, in input order");
8422 assert_eq!(outcomes[0].selector, "1");
8423 assert!(outcomes[0].outcome.as_ref().unwrap().changed);
8424 assert!(
8425 outcomes[1].outcome.is_err(),
8426 "a bad selector is its own error, not a failure of the run"
8427 );
8428 assert!(outcomes[2].outcome.as_ref().unwrap().changed);
8429
8430 for filename in ["2026-01-01-a.md", "2026-01-01-b.md"] {
8431 let issue = reloaded(&store, filename);
8432 assert_eq!(issue.projects, Some(vec!["p-1".to_string()]));
8433 assert_eq!(issue.history.events().len(), 1, "{filename}");
8434 }
8435
8436 write_issue(
8438 &store,
8439 "2026-01-01-c.md",
8440 &sample_frontmatter(3, "C", "todo"),
8441 );
8442 let before = read_issue_file(&store, "2026-01-01-c.md");
8443 let refused = store
8444 .bulk_associate("p-unknown", &["3".to_string()])
8445 .unwrap();
8446 assert!(refused[0].outcome.is_err());
8447 assert_eq!(read_issue_file(&store, "2026-01-01-c.md"), before);
8448 }
8449
8450 #[test]
8451 fn t013_bulk_associate_is_idempotent_across_two_selectors_for_one_issue() {
8452 let (_tmp, store) = project_store();
8457 let outcomes = store
8458 .bulk_associate("p-1", &["1".to_string(), "2026-01-01-a.md".to_string()])
8459 .unwrap();
8460 assert_eq!(outcomes.len(), 2, "one entry per selector, in input order");
8461
8462 let first = outcomes[0].outcome.as_ref().unwrap();
8463 assert!(first.changed, "the first selector writes");
8464 assert!(first.written.is_some());
8465 assert!(first.event.is_some(), "and appends its one event");
8466
8467 let second = outcomes[1].outcome.as_ref().unwrap();
8468 assert!(
8469 !second.changed,
8470 "the repeated selector is the idempotent no-op"
8471 );
8472 assert!(second.written.is_none(), "and writes nothing a second time");
8473 assert!(second.event.is_none(), "and appends no second event");
8474 assert_eq!(
8475 second.project_ids,
8476 vec!["p-1".to_string()],
8477 "and still reports the current list"
8478 );
8479
8480 let after = reloaded(&store, "2026-01-01-a.md");
8481 assert_eq!(after.projects, Some(vec!["p-1".to_string()]));
8482 assert_eq!(
8483 after.history.events().len(),
8484 1,
8485 "exactly one History entry across both selectors"
8486 );
8487 }
8488
8489 #[test]
8490 fn t014_nfr_002_every_project_fixture_round_trips_with_no_key_added_removed_or_reordered() {
8491 let names = all_project_fixtures();
8492 for required in [
8493 "no-associations.md",
8494 "one-association.md",
8495 "several-associations.md",
8496 "missing-reference.md",
8497 "legacy-no-key.md",
8498 ] {
8499 assert!(
8500 names.iter().any(|name| name == required),
8501 "the fixture set must cover every shape T014 names; {required} is missing from {names:?}"
8502 );
8503 }
8504 for name in names {
8505 let original = project_fixture(&name);
8506 let issue = Issue::parse(&original).expect("fixture parses");
8507 assert_eq!(
8508 issue.to_file_contents(),
8509 original,
8510 "{name} must round-trip byte-identically"
8511 );
8512 assert_eq!(
8513 Issue::parse(&issue.to_file_contents())
8514 .unwrap()
8515 .to_file_contents(),
8516 original,
8517 "{name} must be byte-stable once re-read"
8518 );
8519 }
8520 }
8521
8522 #[test]
8523 fn t014_nfr_002_a_legacy_fixture_gains_no_projects_key() {
8524 let original = project_fixture("legacy-no-key.md");
8525 assert!(!original.contains("projects:"));
8526 let issue = Issue::parse(&original).unwrap();
8527 assert_eq!(issue.projects, None);
8528 assert_eq!(
8529 issue.to_file_contents(),
8530 original,
8531 "a pre-project file must not gain a key"
8532 );
8533 }
8534
8535 #[test]
8536 fn t014_c_003_a_multi_membership_fixture_is_byte_stable_and_ordered() {
8537 let original = project_fixture("several-associations.md");
8538 let issue = Issue::parse(&original).unwrap();
8539 assert_eq!(
8540 issue.projects,
8541 Some(vec![
8542 "p-platform".to_string(),
8543 "p-docs".to_string(),
8544 "p-infra".to_string()
8545 ]),
8546 "declaration order is preserved (NFR-002)"
8547 );
8548 assert_eq!(issue.to_file_contents(), original);
8549 }
8550
8551 #[test]
8552 fn t014_c_005_a_missing_reference_round_trips_through_a_store_unchanged() {
8553 let (_tmp, store) = make_store();
8556 write_issue(
8557 &store,
8558 "missing-reference.md",
8559 &project_fixture("missing-reference.md"),
8560 );
8561 assert!(
8562 store.config.projects.is_empty(),
8563 "the reference is missing by construction"
8564 );
8565
8566 let issue = store.get(IssueSelector::Number(4)).unwrap();
8567 assert_eq!(issue.projects, Some(vec!["p-deleted".to_string()]));
8568
8569 store.update(&issue.filename, &issue).unwrap();
8571 assert_eq!(
8572 read_issue_file(&store, "missing-reference.md"),
8573 project_fixture("missing-reference.md"),
8574 "saving a missing reference must not drop or rewrite it"
8575 );
8576 let reloaded_issue = store.get(IssueSelector::Number(4)).unwrap();
8577 assert_eq!(reloaded_issue.projects, Some(vec!["p-deleted".to_string()]));
8578 }
8579
8580 #[test]
8581 fn t014_c_003_associating_an_issue_writes_no_member_list_to_the_registry() {
8582 let (tmp, store) = project_store();
8583 let config_path = tmp.path().join("tix.toml");
8584 let before = fs::read_to_string(&config_path).unwrap();
8585 assert!(
8586 before.contains("[projects.p-1]"),
8587 "the record is persisted before the association: {before}"
8588 );
8589
8590 store.associate("1", "p-1").unwrap();
8591
8592 let after = fs::read_to_string(&config_path).unwrap();
8593 assert_eq!(
8594 after, before,
8595 "the project's registry record must not grow a member list"
8596 );
8597 assert!(
8598 !after.contains("members"),
8599 "membership is derived, never stored on the project:\n{after}"
8600 );
8601 }
8602
8603 fn epic_store() -> (TempDir, Store) {
8614 let tmp = TempDir::new().unwrap();
8615 let dir = tmp.path().join("issues");
8616 fs::create_dir_all(&dir).unwrap();
8617 let config_path = tmp.path().join("tix.toml");
8618 let mut config = Config {
8619 issues_dir: dir.clone(),
8620 ..Config::default()
8621 };
8622 config.layout.directory = "{assignee}".to_string();
8623 config.layout.move_on_change = false;
8624 config.epics.insert(
8625 "e-7c4a91".to_string(),
8626 EpicConfiguration {
8627 name: "One".into(),
8628 ..EpicConfiguration::default()
8629 },
8630 );
8631 config.save(&config_path).unwrap();
8632 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
8633 let store = Store {
8634 issues_dir: dir,
8635 config,
8636 };
8637 write_issue(
8638 &store,
8639 "2026-01-01-a.md",
8640 &sample_frontmatter(1, "A", "todo"),
8641 );
8642 (tmp, store)
8643 }
8644
8645 fn epic_fixture(name: &str) -> String {
8648 let path = format!("{}/tests/fixtures/epics/{name}", env!("CARGO_MANIFEST_DIR"));
8649 fs::read_to_string(&path).unwrap_or_else(|e| panic!("reading fixture {name}: {e}"))
8650 }
8651
8652 fn all_epic_fixtures() -> Vec<String> {
8654 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/epics");
8655 let mut names: Vec<String> = fs::read_dir(&dir)
8656 .unwrap()
8657 .filter_map(|entry| entry.ok())
8658 .map(|entry| entry.file_name().to_string_lossy().into_owned())
8659 .filter(|name| name.ends_with(".md"))
8660 .collect();
8661 names.sort();
8662 names
8663 }
8664
8665 #[test]
8666 fn t015_an_accepted_reference_writes_the_list_and_exactly_one_history_entry() {
8667 let (_tmp, store) = epic_store();
8668 let before = read_issue_file(&store, "2026-01-01-a.md");
8669 let outcome = store.reference_epic("1", "e-7c4a91").unwrap();
8670
8671 assert!(outcome.changed, "an accepted, changing reference writes");
8672 assert!(outcome.written.is_some());
8673 assert_eq!(outcome.epic_ids, vec!["e-7c4a91".to_string()]);
8674 let event = outcome
8675 .event
8676 .clone()
8677 .expect("an accepted change is audited");
8678 assert_eq!(event.detail, "epic `e-7c4a91` referenced");
8679
8680 let after = reloaded(&store, "2026-01-01-a.md");
8682 assert_eq!(after.epics, Some(vec!["e-7c4a91".to_string()]));
8683 let events = after.history.events();
8684 assert_eq!(events.len(), 1, "exactly one History entry: {events:?}");
8685 assert_eq!(events[0].kind, HistoryEventKind::EpicReferenced);
8686 assert_eq!(events[0].detail, "epic `e-7c4a91` referenced");
8687 assert_ne!(
8688 read_issue_file(&store, "2026-01-01-a.md"),
8689 before,
8690 "an accepted change is written"
8691 );
8692 }
8693
8694 #[test]
8695 fn t015_a_refused_reference_writes_nothing_and_appends_nothing() {
8696 let (_tmp, store) = epic_store();
8697 let before = read_issue_file(&store, "2026-01-01-a.md");
8698
8699 let err = store.reference_epic("1", "e-unknown").unwrap_err();
8700 assert!(
8701 matches!(err, TixError::InvalidInput(_)),
8702 "an unknown epic is a user input refusal, got {err:?}"
8703 );
8704 assert_eq!(
8705 read_issue_file(&store, "2026-01-01-a.md"),
8706 before,
8707 "a refusal must leave the file byte-identical"
8708 );
8709 assert!(
8710 reloaded(&store, "2026-01-01-a.md").history.is_empty(),
8711 "a refusal must append nothing"
8712 );
8713
8714 let err = store.reference_epic("999", "e-7c4a91").unwrap_err();
8716 assert!(matches!(err, TixError::NotFound(_)), "got {err:?}");
8717 assert_eq!(read_issue_file(&store, "2026-01-01-a.md"), before);
8718 }
8719
8720 #[test]
8721 fn t015_a_refusal_reports_the_service_wording_not_a_storage_copy() {
8722 let (_tmp, store) = epic_store();
8725 let err = store.reference_epic("1", "e-unknown").unwrap_err();
8726 assert!(
8727 err.to_string()
8728 .contains(&crate::epics::unknown_epic_reason("e-unknown")),
8729 "the refusal must come from the service, got: {err}"
8730 );
8731 }
8732
8733 #[test]
8734 fn t015_re_referencing_is_idempotent_no_second_entry_and_no_second_event() {
8735 let (_tmp, store) = epic_store();
8736 store.reference_epic("1", "e-7c4a91").unwrap();
8737 let after_first = read_issue_file(&store, "2026-01-01-a.md");
8738
8739 let second = store.reference_epic("1", "e-7c4a91").unwrap();
8740 assert!(!second.changed, "the idempotent re-apply changes nothing");
8741 assert!(second.written.is_none(), "and writes no file");
8742 assert!(second.event.is_none(), "and appends no event");
8743 assert_eq!(second.epic_ids, vec!["e-7c4a91".to_string()]);
8744 assert_eq!(
8745 read_issue_file(&store, "2026-01-01-a.md"),
8746 after_first,
8747 "the file is byte-identical after a repeated reference"
8748 );
8749
8750 let after = reloaded(&store, "2026-01-01-a.md");
8751 assert_eq!(after.history.events().len(), 1, "no second History event");
8752 assert_eq!(
8753 after.epics,
8754 Some(vec!["e-7c4a91".to_string()]),
8755 "and no duplicate entry: (issue, epic_id) is unique"
8756 );
8757 }
8758
8759 #[test]
8760 fn t015_dereference_drops_only_the_reference_and_records_one_event() {
8761 let (_tmp, store) = epic_store();
8762 store.reference_epic("1", "e-7c4a91").unwrap();
8763
8764 let outcome = store.dereference_epic("1", "e-7c4a91").unwrap();
8765 assert!(outcome.changed);
8766 assert_eq!(outcome.epic_ids, [] as [std::string::String; 0]);
8767 let event = outcome
8768 .event
8769 .clone()
8770 .expect("an accepted change is audited");
8771 assert_eq!(event.detail, "epic `e-7c4a91` dereferenced");
8772
8773 let after = reloaded(&store, "2026-01-01-a.md");
8774 assert_eq!(after.epics, None, "the reference is dropped");
8775 assert!(
8776 !read_issue_file(&store, "2026-01-01-a.md").contains("epics:"),
8777 "an empty list is written as an absent key"
8778 );
8779 let events = after.history.events();
8780 assert_eq!(events.len(), 2, "reference + dereference = two entries");
8781 assert_eq!(events[1].kind, HistoryEventKind::EpicDereferenced);
8782
8783 let again = store.dereference_epic("1", "e-7c4a91").unwrap();
8785 assert!(!again.changed);
8786 assert!(again.event.is_none());
8787 assert_eq!(
8788 reloaded(&store, "2026-01-01-a.md").history.events().len(),
8789 2,
8790 "no third History event"
8791 );
8792 }
8793
8794 #[test]
8795 fn t015_dereference_retains_a_missing_reference_and_removes_it_verbatim() {
8796 let (_tmp, store) = epic_store();
8799 write_issue(
8800 &store,
8801 "2026-01-01-b.md",
8802 "---\nnumber: 2\ntitle: B\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\nepics:\n- e-deleted\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nBody.",
8803 );
8804 let outcome = store.dereference_epic("2", "e-deleted").unwrap();
8805 assert!(outcome.changed, "a stale reference is removable");
8806 assert_eq!(outcome.epic_ids, [] as [std::string::String; 0]);
8807 assert_eq!(reloaded(&store, "2026-01-01-b.md").epics, None);
8808 }
8809
8810 #[test]
8811 fn t015_bulk_reference_writes_each_affected_file_independently() {
8812 let (_tmp, store) = epic_store();
8813 write_issue(
8814 &store,
8815 "2026-01-01-b.md",
8816 &sample_frontmatter(2, "B", "todo"),
8817 );
8818
8819 let outcomes = store
8820 .bulk_reference(
8821 "e-7c4a91",
8822 &["1".to_string(), "999".to_string(), "2".to_string()],
8823 )
8824 .unwrap();
8825
8826 assert_eq!(outcomes.len(), 3, "one entry per selector, in input order");
8827 assert_eq!(outcomes[0].selector, "1");
8828 assert!(outcomes[0].outcome.as_ref().unwrap().changed);
8829 assert!(
8830 outcomes[1].outcome.is_err(),
8831 "a bad selector is its own error, not a failure of the run"
8832 );
8833 assert!(outcomes[2].outcome.as_ref().unwrap().changed);
8834
8835 for filename in ["2026-01-01-a.md", "2026-01-01-b.md"] {
8836 let issue = reloaded(&store, filename);
8837 assert_eq!(issue.epics, Some(vec!["e-7c4a91".to_string()]));
8838 assert_eq!(issue.history.events().len(), 1, "{filename}");
8839 }
8840
8841 write_issue(
8843 &store,
8844 "2026-01-01-c.md",
8845 &sample_frontmatter(3, "C", "todo"),
8846 );
8847 let before = read_issue_file(&store, "2026-01-01-c.md");
8848 let refused = store
8849 .bulk_reference("e-unknown", &["3".to_string()])
8850 .unwrap();
8851 assert!(refused[0].outcome.is_err());
8852 assert_eq!(read_issue_file(&store, "2026-01-01-c.md"), before);
8853 }
8854
8855 #[test]
8856 fn t015_bulk_reference_is_idempotent_across_two_selectors_for_one_issue() {
8857 let (_tmp, store) = epic_store();
8860 let outcomes = store
8861 .bulk_reference(
8862 "e-7c4a91",
8863 &["1".to_string(), "2026-01-01-a.md".to_string()],
8864 )
8865 .unwrap();
8866 assert_eq!(outcomes.len(), 2, "one entry per selector, in input order");
8867
8868 let first = outcomes[0].outcome.as_ref().unwrap();
8869 assert!(first.changed, "the first selector writes");
8870 assert!(first.written.is_some());
8871 assert!(first.event.is_some(), "and appends its one event");
8872
8873 let second = outcomes[1].outcome.as_ref().unwrap();
8874 assert!(
8875 !second.changed,
8876 "the repeated selector is the idempotent no-op"
8877 );
8878 assert!(second.written.is_none(), "and writes nothing a second time");
8879 assert!(second.event.is_none(), "and appends no second event");
8880 assert_eq!(
8881 second.epic_ids,
8882 vec!["e-7c4a91".to_string()],
8883 "and still reports the current list"
8884 );
8885
8886 let after = reloaded(&store, "2026-01-01-a.md");
8887 assert_eq!(after.epics, Some(vec!["e-7c4a91".to_string()]));
8888 assert_eq!(
8889 after.history.events().len(),
8890 1,
8891 "exactly one History entry across both selectors"
8892 );
8893 }
8894
8895 #[test]
8896 fn t016_nfr_002_every_epic_fixture_round_trips_with_no_key_added_removed_or_reordered() {
8897 let names = all_epic_fixtures();
8898 for required in [
8899 "no-references.md",
8900 "one-reference.md",
8901 "several-references.md",
8902 "missing-reference.md",
8903 "legacy-no-key.md",
8904 "task-labelled.md",
8905 ] {
8906 assert!(
8907 names.iter().any(|name| name == required),
8908 "the fixture set must cover every shape T016 names; {required} is missing from {names:?}"
8909 );
8910 }
8911 for name in names {
8912 let original = epic_fixture(&name);
8913 let issue = Issue::parse(&original).expect("fixture parses");
8914 assert_eq!(
8915 issue.to_file_contents(),
8916 original,
8917 "{name} must round-trip byte-identically"
8918 );
8919 assert_eq!(
8920 Issue::parse(&issue.to_file_contents())
8921 .unwrap()
8922 .to_file_contents(),
8923 original,
8924 "{name} must be byte-stable once re-read"
8925 );
8926 }
8927 }
8928
8929 #[test]
8930 fn t016_nfr_002_a_legacy_fixture_gains_no_epics_key() {
8931 let original = epic_fixture("legacy-no-key.md");
8932 assert!(!original.contains("epics:"));
8933 let issue = Issue::parse(&original).unwrap();
8934 assert_eq!(issue.epics, None);
8935 assert_eq!(
8936 issue.to_file_contents(),
8937 original,
8938 "a pre-epic file must not gain a key"
8939 );
8940 }
8941
8942 #[test]
8943 fn t016_c_003_a_multi_reference_fixture_is_byte_stable_and_ordered() {
8944 let original = epic_fixture("several-references.md");
8945 let issue = Issue::parse(&original).unwrap();
8946 assert_eq!(
8947 issue.epics,
8948 Some(vec![
8949 "e-platform".to_string(),
8950 "e-docs".to_string(),
8951 "e-infra".to_string()
8952 ]),
8953 "declaration order is preserved (NFR-002)"
8954 );
8955 assert_eq!(issue.to_file_contents(), original);
8956 }
8957
8958 #[test]
8959 fn t016_c_005_a_missing_reference_round_trips_through_a_store_unchanged() {
8960 let (_tmp, store) = make_store();
8963 write_issue(
8964 &store,
8965 "missing-reference.md",
8966 &epic_fixture("missing-reference.md"),
8967 );
8968 assert!(
8969 store.config.epics.is_empty(),
8970 "the reference is missing by construction"
8971 );
8972
8973 let issue = store.get(IssueSelector::Number(4)).unwrap();
8974 assert_eq!(issue.epics, Some(vec!["e-deleted".to_string()]));
8975
8976 store.update(&issue.filename, &issue).unwrap();
8978 assert_eq!(
8979 read_issue_file(&store, "missing-reference.md"),
8980 epic_fixture("missing-reference.md"),
8981 "saving a missing reference must not drop or rewrite it"
8982 );
8983 let reloaded_issue = store.get(IssueSelector::Number(4)).unwrap();
8984 assert_eq!(reloaded_issue.epics, Some(vec!["e-deleted".to_string()]));
8985 }
8986
8987 #[test]
8988 fn t016_c_001_a_task_labelled_fixture_round_trips_and_gains_no_task_key() {
8989 let original = epic_fixture("task-labelled.md");
8993 let issue = Issue::parse(&original).unwrap();
8994 assert_eq!(issue.epics, Some(vec!["e-7c4a91".to_string()]));
8995 assert_eq!(issue.task, Some("WP03".to_string()));
8996
8997 let rendered = issue.to_file_contents();
8998 assert_eq!(rendered, original, "the fixture round-trips unchanged");
8999 assert_eq!(
9000 rendered.matches("task:").count(),
9001 1,
9002 "exactly one `task:` key — the Spec Kitty pointer, never a second:\n{rendered}"
9003 );
9004 for forbidden in ["work_item:", "epic_task:"] {
9005 assert!(
9006 !rendered.contains(forbidden),
9007 "`{forbidden}` must not exist (C-001):\n{rendered}"
9008 );
9009 }
9010 }
9011
9012 #[test]
9013 fn t016_c_003_referencing_an_issue_writes_no_member_list_to_the_registry() {
9014 let (tmp, store) = epic_store();
9015 let config_path = tmp.path().join("tix.toml");
9016 let before = fs::read_to_string(&config_path).unwrap();
9017 assert!(
9018 before.contains("[epics") && before.contains("e-7c4a91"),
9019 "the record is persisted before the reference: {before}"
9020 );
9021
9022 store.reference_epic("1", "e-7c4a91").unwrap();
9023
9024 let after = fs::read_to_string(&config_path).unwrap();
9025 assert_eq!(
9026 after, before,
9027 "the epic's registry record must not grow a member list"
9028 );
9029 assert!(
9030 !after.contains("members"),
9031 "membership is derived, never stored on the epic:\n{after}"
9032 );
9033 }
9034}