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 all_relationships(&self) -> Result<Vec<Relationship>> {
2246 let issues = self.list_all()?;
2247 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
2248 let done_column = self.relationship_done_column();
2249 let graph = RelationshipGraph::new(&views, &done_column);
2250 Ok(graph.all_relationships().to_vec())
2251 }
2252
2253 pub fn unlink(&self, a: &str, b: &str, kind: RelationKind) -> Result<RelationshipOutcome> {
2258 let canonical = self.canonical_request(a, b, kind)?;
2259 let stored = self.find_stored_rows(&canonical)?;
2260
2261 if stored.is_empty() {
2262 return Ok(RelationshipOutcome {
2266 written: None,
2267 relationship: canonical_placeholder(&canonical),
2268 changed: false,
2269 });
2270 }
2271
2272 let mut by_file: HashMap<String, Vec<Relationship>> = HashMap::new();
2277 for (filename, rel) in stored {
2278 by_file.entry(filename).or_default().push(rel);
2279 }
2280
2281 let actor = actor_identity();
2282 let mut file_order: Vec<String> = by_file.keys().cloned().collect();
2284 file_order.sort_unstable();
2285
2286 let mut removed_row: Option<Relationship> = None;
2292
2293 let all_removed: Vec<Relationship> = by_file.values().flatten().cloned().collect();
2299 let summary = if all_removed.len() == 1 {
2300 let one = &all_removed[0];
2301 format!("removed {}: {}", one.id, one.display)
2302 } else {
2303 let kinds: Vec<String> = all_removed.iter().map(|r| r.kind.to_string()).collect();
2304 format!(
2305 "removed {} relationships ({})",
2306 all_removed.len(),
2307 kinds.join(", ")
2308 )
2309 };
2310
2311 let mut written: Option<Issue> = None;
2312 for filename in &file_order {
2313 let doomed = &by_file[filename];
2314 let mut owner = self.get(IssueSelector::Filename(filename.clone()))?;
2315 if removed_row.is_none() {
2316 removed_row = doomed.first().cloned();
2317 }
2318 owner.relationships.retain(|r| r.canonical() != canonical);
2319 owner
2320 .history
2321 .append_now(&actor, HistoryEventKind::RelationshipRemoved, &summary);
2322 owner.bump_updated();
2323 owner.filename = self.update(&owner.filename, &owner)?;
2324 written = Some(owner);
2325 }
2326
2327 let relationship = removed_row.unwrap_or_else(|| canonical_placeholder(&canonical));
2330
2331 Ok(RelationshipOutcome {
2332 written,
2333 relationship,
2334 changed: true,
2335 })
2336 }
2337
2338 pub fn edit_relation(
2353 &self,
2354 id: &str,
2355 new_to: Option<u32>,
2356 new_kind: Option<RelationKind>,
2357 ) -> Result<RelationshipOutcome> {
2358 let issues = self.list_all()?;
2359 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
2360 let done_column = self.relationship_done_column();
2361 let graph = RelationshipGraph::new(&views, &done_column);
2362
2363 let Some(existing) = graph.find_relationship(id) else {
2364 let mut verdict = crate::relationships::RelationshipValidationResult::clean();
2368 verdict.errors.push(Diagnostic::unknown_relation(id));
2369 return Err(relationship_error(&verdict.errors));
2370 };
2371
2372 let verdict = graph.validate_edit(id, new_to, new_kind);
2373 if verdict.blocking() {
2374 return Err(relationship_error(&verdict.errors));
2375 }
2376
2377 let to = new_to.unwrap_or(existing.to);
2378 let kind = new_kind.unwrap_or(existing.kind);
2379 let old_canonical = existing.canonical();
2380
2381 let requested = CanonicalEdge::new(existing.from, to, kind);
2393 let mut updated = existing.clone();
2394 updated.from = requested.from;
2395 updated.to = requested.to;
2396 updated.kind = requested.kind;
2397
2398 let target_title = self
2402 .get(IssueSelector::Number(updated.to))
2403 .map(|i| i.title)
2404 .unwrap_or_default();
2405 updated.refresh_display(&target_title);
2406 let new_canonical = updated.canonical();
2407
2408 if old_canonical == new_canonical && updated.display == existing.display {
2412 return Ok(RelationshipOutcome {
2413 written: None,
2414 relationship: existing.clone(),
2415 changed: false,
2416 });
2417 }
2418
2419 let actor = actor_identity();
2420 let detail = format!("{} → {}: {}", existing.display, updated.display, updated.id);
2421
2422 let mut removed_anywhere = false;
2438 for issue in self.list_all()? {
2439 if !issue
2440 .relationships
2441 .iter()
2442 .any(|r| r.id == id && r.canonical() == old_canonical)
2443 {
2444 continue;
2445 }
2446 let mut owner = self.get(IssueSelector::Filename(issue.filename.clone()))?;
2447 owner
2448 .relationships
2449 .retain(|r| !(r.id == id && r.canonical() == old_canonical));
2450 removed_anywhere = true;
2451 owner.bump_updated();
2452 owner.filename = self.update(&owner.filename, &owner)?;
2453 }
2454 debug_assert!(removed_anywhere, "graph found the row, the scan did not");
2455
2456 let mut new_owner = self.get(IssueSelector::Number(new_canonical.from))?;
2461 new_owner.relationships.push(updated.clone());
2462 new_owner
2463 .history
2464 .append_now(&actor, HistoryEventKind::RelationshipRetargeted, &detail);
2465 new_owner.bump_updated();
2466 new_owner.filename = self.update(&new_owner.filename, &new_owner)?;
2467
2468 Ok(RelationshipOutcome {
2469 written: Some(self.get(IssueSelector::Number(new_canonical.from))?),
2470 relationship: updated,
2471 changed: true,
2472 })
2473 }
2474
2475 pub fn remove_relation(&self, id: &str) -> Result<RelationshipOutcome> {
2481 let stored = self.find_stored_rows_by_id(id)?;
2482 if stored.is_empty() {
2483 return Ok(RelationshipOutcome {
2487 written: None,
2488 relationship: Relationship {
2489 id: id.to_string(),
2490 ..canonical_placeholder(&CanonicalEdge::new(0, 0, RelationKind::Related))
2491 },
2492 changed: false,
2493 });
2494 }
2495
2496 let canonical = stored[0].1.canonical();
2497 let from = canonical.from.to_string();
2501 let to = canonical.to.to_string();
2502 self.unlink(&from, &to, canonical.kind)
2503 }
2504
2505 fn find_stored_rows_by_id(&self, id: &str) -> Result<Vec<(String, Relationship)>> {
2511 let mut found = Vec::new();
2512 for issue in self.list_all()? {
2513 for rel in &issue.relationships {
2514 if rel.id == id {
2515 found.push((issue.filename.clone(), rel.clone()));
2516 }
2517 }
2518 }
2519 Ok(found)
2520 }
2521
2522 pub fn graph_source(&self) -> Result<(Vec<Issue>, String)> {
2541 Ok((self.list()?, self.relationship_done_column()))
2542 }
2543
2544 fn canonical_request(&self, a: &str, b: &str, kind: RelationKind) -> Result<CanonicalEdge> {
2553 let lenient = |sel: &str| -> Result<u32> {
2554 match self.resolve_issue(sel) {
2555 Ok(issue) => Ok(issue.number),
2556 Err(e) => sel.trim().parse::<u32>().map_err(|_| e),
2557 }
2558 };
2559 let from = lenient(a)?;
2560 let to = lenient(b)?;
2561 Ok(CanonicalEdge::new(from, to, kind))
2562 }
2563
2564 fn find_stored_rows(&self, canonical: &CanonicalEdge) -> Result<Vec<(String, Relationship)>> {
2580 let mut found = Vec::new();
2581 for issue in self.list_all()? {
2582 for rel in &issue.relationships {
2583 if rel.canonical() == *canonical {
2584 found.push((issue.filename.clone(), rel.clone()));
2585 }
2586 }
2587 }
2588 Ok(found)
2589 }
2590
2591 pub fn set_relationships(
2606 &self,
2607 filename: &str,
2608 targets: &[(u32, RelationKind)],
2609 ) -> Result<Issue> {
2610 let issue = self.get(IssueSelector::Filename(filename.to_string()))?;
2611 let own = issue.number;
2612 let actor = actor_identity();
2613
2614 let mut wanted: Vec<CanonicalEdge> = Vec::with_capacity(targets.len());
2616 for (target, kind) in targets {
2617 if *target == own {
2618 return Err(TixError::InvalidInput(format!(
2619 "issue #{own} cannot relate to itself"
2620 )));
2621 }
2622 self.get(IssueSelector::Number(*target))?;
2623 let canonical = CanonicalEdge::new(own, *target, *kind);
2624 if !wanted.contains(&canonical) {
2627 wanted.push(canonical);
2628 }
2629 }
2630
2631 {
2640 let issues = self.list_all()?;
2641 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
2642 let done_column = self.relationship_done_column();
2643 let graph = RelationshipGraph::new(&views, &done_column);
2644 for canonical in &wanted {
2645 let verdict = graph.validate_create(canonical.from, canonical.to, canonical.kind);
2646 if verdict.blocking() {
2647 return Err(relationship_error(&verdict.errors));
2648 }
2649 }
2650 }
2651
2652 let issues = self.list_all()?;
2670 let mut removals: HashMap<u32, Vec<Relationship>> = HashMap::new();
2671 for candidate in &issues {
2672 for rel in &candidate.relationships {
2673 let canonical = rel.canonical();
2674 let owned_by_us = canonical.from == own
2675 || (canonical.to == own && !canonical.kind.is_directional());
2676 if owned_by_us && !wanted.contains(&canonical) {
2677 removals
2678 .entry(canonical.from)
2679 .or_default()
2680 .push(rel.clone());
2681 }
2682 }
2683 }
2684
2685 let mut additions: HashMap<u32, Vec<(CanonicalEdge, String)>> = HashMap::new();
2688 for canonical in &wanted {
2689 let owner = match self.get(IssueSelector::Number(canonical.from)) {
2690 Ok(owner) => owner,
2691 Err(TixError::NotFound(_)) => continue,
2692 Err(e) => return Err(e),
2693 };
2694 if owner
2695 .relationships
2696 .iter()
2697 .any(|r| r.canonical() == *canonical)
2698 {
2699 continue;
2700 }
2701 let target_title = self
2702 .get(IssueSelector::Number(canonical.to))
2703 .map(|i| i.title)
2704 .unwrap_or_default();
2705 additions
2706 .entry(canonical.from)
2707 .or_default()
2708 .push((*canonical, target_title));
2709 }
2710
2711 if removals.is_empty() && additions.is_empty() {
2712 return Ok(issue);
2713 }
2714
2715 let mut removal_order: Vec<u32> = removals.keys().copied().collect();
2736 removal_order.sort_unstable();
2737 let mut addition_order: Vec<u32> = additions.keys().copied().collect();
2738 addition_order.sort_unstable();
2739
2740 fn apply(
2745 store: &Store,
2746 mut owner: Issue,
2747 undo: &mut Vec<(PathBuf, String)>,
2748 touched_named: &mut bool,
2749 named_path: &Path,
2750 ) -> Result<()> {
2751 let path = store.issues_dir.join(&owner.filename);
2752 if path == named_path {
2753 *touched_named = true;
2754 }
2755 if let Ok(previous) = std::fs::read_to_string(&path) {
2756 undo.push((path, previous));
2757 }
2758 owner.bump_updated();
2759 store.update(&owner.filename, &owner).map(|_| ())
2760 }
2761
2762 let named_path = self.issues_dir.join(&issue.filename);
2763 let mut undo: Vec<(PathBuf, String)> = Vec::new();
2764 let mut touched_named = false;
2765
2766 let applied = (|| -> Result<()> {
2767 for owner_number in &removal_order {
2768 let doomed = &removals[owner_number];
2769 let mut owner = self.get(IssueSelector::Number(*owner_number))?;
2770 let mut changed = false;
2771 for rel in doomed {
2772 let canonical = rel.canonical();
2773 let before = owner.relationships.len();
2774 owner.relationships.retain(|r| r.canonical() != canonical);
2775 if owner.relationships.len() != before {
2776 let detail = format!("removed {}: {}", rel.id, rel.display);
2777 owner.history.append_now(
2778 &actor,
2779 HistoryEventKind::RelationshipRemoved,
2780 &detail,
2781 );
2782 changed = true;
2783 }
2784 }
2785 if changed {
2786 apply(self, owner, &mut undo, &mut touched_named, &named_path)?;
2787 }
2788 }
2789
2790 for owner_number in &addition_order {
2791 let wanted_here = &additions[owner_number];
2792 let mut owner = self.get(IssueSelector::Number(*owner_number))?;
2793 for (canonical, target_title) in wanted_here {
2794 let rel = Relationship::new(
2795 canonical.from,
2796 canonical.to,
2797 canonical.kind,
2798 target_title,
2799 );
2800 let detail = format!("created {}: {}", rel.id, rel.display);
2801 owner
2802 .history
2803 .append_now(&actor, HistoryEventKind::RelationshipAdded, &detail);
2804 owner.relationships.push(rel);
2805 }
2806 apply(self, owner, &mut undo, &mut touched_named, &named_path)?;
2807 }
2808
2809 if !touched_named {
2823 let named = self.get(IssueSelector::Filename(filename.to_string()))?;
2824 apply(self, named, &mut undo, &mut touched_named, &named_path)?;
2825 }
2826 Ok(())
2827 })();
2828
2829 if let Err(e) = applied {
2830 for (path, previous) in undo.iter().rev() {
2831 let _ = atomic_write(&self.issues_dir, path, previous.as_bytes());
2832 }
2833 return Err(e);
2834 }
2835
2836 self.get(IssueSelector::Filename(filename.to_string()))
2837 }
2838
2839 pub fn resolve_issue(&self, raw: &str) -> Result<Issue> {
2849 let selector = crate::commands::parse_selector(self, raw)?;
2850 self.get(selector)
2851 }
2852
2853 pub fn reassign(&self, filename: &str, new_assignee: Option<&str>) -> Result<PathBuf> {
2876 let issue = self.get(IssueSelector::Filename(filename.to_string()))?;
2878 let mut updated = issue.clone();
2879 updated.assignee = new_assignee.map(str::to_string);
2880 let target_dir = crate::layout::render_directory(&self.config.layout, &updated);
2881 let (old_dir, base) = split_relative(filename).ok_or_else(|| {
2882 TixError::InvalidInput(format!("invalid relative issue filename: {filename}"))
2883 })?;
2884 if old_dir == target_dir {
2885 return Ok(PathBuf::from(filename));
2886 }
2887 let stem = base.strip_suffix(".md").unwrap_or(&base);
2888 self.move_issue_file(filename, &target_dir, stem)
2889 }
2890
2891 pub fn migrate_issue(&self, filename: &str, target: &str) -> Result<()> {
2896 if target == filename {
2897 return Ok(());
2898 }
2899 validate_filename(target)?;
2900 let from = self.issues_dir.join(filename);
2901 if !from.is_file() {
2902 return Err(TixError::NotFound(format!("file {filename}")));
2903 }
2904 let to = self.issues_dir.join(target);
2905 if to.exists() {
2906 return Err(TixError::InvalidInput(format!(
2907 "cannot migrate {filename}: {target} already exists"
2908 )));
2909 }
2910 if let Some(parent) = to.parent() {
2911 std::fs::create_dir_all(parent)?;
2912 }
2913 std::fs::rename(&from, &to)?;
2914 let (old_dir, base) = split_relative(filename).unwrap_or_default();
2915 let stem = base.strip_suffix(".md").unwrap_or(&base);
2916 let asset_name = format!("{}-assets", slug_from_stem(stem));
2917 let old_asset_dir = self.issues_dir.join(&old_dir).join(&asset_name);
2918 if old_asset_dir.exists()
2919 && let Some(new_dir) = to.parent()
2920 {
2921 let moved = resolve_asset_dir_name(new_dir, &asset_name);
2922 std::fs::rename(&old_asset_dir, new_dir.join(&moved))?;
2923 }
2924 Ok(())
2925 }
2926
2927 fn move_issue_file(&self, filename: &str, target_dir: &str, stem: &str) -> Result<PathBuf> {
2931 let target_dir_path = self.issues_dir.join(target_dir);
2932 std::fs::create_dir_all(&target_dir_path)?;
2933 let new_name = resolve_target_name(&target_dir_path, stem);
2934 let old_dir = split_relative(filename).map_or_else(String::new, |(d, _)| d);
2935 let from = self.issues_dir.join(filename);
2936 let to = target_dir_path.join(&new_name);
2937 journal_record_move(&from, &to);
2938 std::fs::rename(from, to)?;
2939 let slug = slug_from_stem(stem);
2942 let asset_name = format!("{slug}-assets");
2943 let old_asset_dir = self.issues_dir.join(&old_dir).join(&asset_name);
2944 if old_asset_dir.exists() {
2945 let moved = resolve_asset_dir_name(&target_dir_path, &asset_name);
2946 let asset_target = target_dir_path.join(&moved);
2947 journal_record_move(&old_asset_dir, &asset_target);
2948 std::fs::rename(&old_asset_dir, &asset_target)?;
2949 }
2950 Ok(PathBuf::from(format!("{target_dir}/{new_name}")))
2951 }
2952
2953 #[must_use]
2958 pub fn path_is_laid_out(&self, filename: &str, issue: &Issue) -> bool {
2959 split_relative(filename).is_some_and(|(dir, _)| {
2960 dir == crate::layout::render_directory(&self.config.layout, issue)
2961 })
2962 }
2963
2964 pub fn move_to_layout(&self, issue: &mut Issue) -> Result<bool> {
2968 let before = issue.filename.clone();
2969 let after = self.reconcile_location(&before, issue)?;
2970 issue.filename = after.clone();
2971 Ok(after != before)
2972 }
2973}
2974
2975pub(crate) fn is_issue_rel_path(rel: &Path) -> bool {
2983 let mut file_name: Option<&std::ffi::OsStr> = None;
2984 for component in rel.components() {
2985 match component {
2986 std::path::Component::Normal(segment) => {
2987 let Some(s) = segment.to_str() else {
2988 return false;
2989 };
2990 if s.starts_with(".tix-tmp-") || s.ends_with("-assets") {
2991 return false;
2992 }
2993 file_name = Some(segment);
2994 }
2995 std::path::Component::CurDir => {}
2996 _ => return false,
2998 }
2999 }
3000 file_name.is_some_and(|name| name.to_string_lossy().ends_with(".md"))
3001}
3002
3003fn validate_filename(filename: &str) -> Result<()> {
3012 if filename.is_empty() {
3013 return Err(TixError::InvalidInput(format!(
3014 "filename must not be empty: {filename:?}"
3015 )));
3016 }
3017 if filename.starts_with('/') {
3018 return Err(TixError::InvalidInput(format!(
3019 "filename must be relative (no leading `/`): {filename}"
3020 )));
3021 }
3022 if filename.contains('\\') {
3023 return Err(TixError::InvalidInput(format!(
3024 "filename must not contain backslashes: {filename}"
3025 )));
3026 }
3027 if filename.contains("..") {
3028 return Err(TixError::InvalidInput(format!(
3029 "filename must not contain `..`: {filename}"
3030 )));
3031 }
3032 if filename.contains('\0') {
3033 return Err(TixError::InvalidInput(format!(
3034 "filename must not contain NUL: {filename}"
3035 )));
3036 }
3037 if filename.split('/').any(|component| component.is_empty()) {
3038 return Err(TixError::InvalidInput(format!(
3039 "filename path components must not be empty: {filename}"
3040 )));
3041 }
3042 Ok(())
3043}
3044
3045fn frontmatter_number(text: &str) -> Option<u32> {
3061 let mut lines = text.lines();
3062 if lines.next()?.trim() != "---" {
3064 return None;
3065 }
3066 for line in lines {
3067 let line = line.trim_end();
3068 if line == "---" {
3071 return None;
3072 }
3073 if let Some(rest) = line.strip_prefix("number:") {
3074 return rest.trim().parse().ok();
3075 }
3076 }
3077 None
3078}
3079
3080fn claims_dir(issues_dir: &Path) -> PathBuf {
3092 issues_dir
3093 .parent()
3094 .unwrap_or(issues_dir)
3095 .join(NumberClaim::DIR)
3096}
3097
3098fn split_relative(filename: &str) -> Option<(String, String)> {
3099 let mut parts = filename.rsplitn(2, '/');
3100 let basename = parts.next()?;
3101 let segment = parts.next().unwrap_or("");
3102 Some((segment.to_string(), basename.to_string()))
3103}
3104
3105fn flip_archived(filename: &str, archive: bool) -> Option<String> {
3112 let (segment, basename) = split_relative(filename)?;
3113 let flipped = if archive {
3114 if basename.starts_with('.') {
3115 return None;
3116 }
3117 format!(".{basename}")
3118 } else {
3119 basename.strip_prefix('.')?.to_string()
3120 };
3121 Some(if segment.is_empty() {
3122 flipped
3123 } else {
3124 format!("{segment}/{flipped}")
3125 })
3126}
3127
3128fn resolve_target_name(dir: &Path, stem: &str) -> String {
3131 let mut candidate = format!("{stem}.md");
3132 let mut i = 1u32;
3133 while dir.join(&candidate).exists() {
3134 candidate = format!("{stem}-{i}.md");
3135 i += 1;
3136 }
3137 candidate
3138}
3139
3140fn slug_from_stem(stem: &str) -> String {
3149 let stem = strip_leading_id(stem);
3150 let stem = strip_date_prefix(stem);
3151 strip_leading_id(stem).to_string()
3152}
3153
3154fn strip_leading_id(stem: &str) -> &str {
3156 match stem.split_once('-') {
3157 Some((head, rest)) if crate::id::is_issue_id(head) => rest,
3158 _ => stem,
3159 }
3160}
3161
3162fn strip_date_prefix(stem: &str) -> &str {
3164 let bytes = stem.as_bytes();
3165 if stem.len() > 10 && bytes[4] == b'-' && bytes[7] == b'-' && bytes[10] == b'-' {
3166 &stem[11..]
3167 } else {
3168 stem
3169 }
3170}
3171
3172fn resolve_asset_name(dir: &Path, name: &str) -> String {
3176 if !dir.join(name).exists() {
3177 return name.to_string();
3178 }
3179 let (stem, ext) = match name.rsplit_once('.') {
3180 Some((s, e)) => (s, format!(".{e}")),
3181 None => (name, String::new()),
3182 };
3183 let mut i = 1u32;
3184 loop {
3185 let candidate = format!("{stem}-{i}{ext}");
3186 if !dir.join(&candidate).exists() {
3187 return candidate;
3188 }
3189 i += 1;
3190 }
3191}
3192
3193fn resolve_asset_dir_name(dir: &Path, name: &str) -> String {
3196 let mut candidate = name.to_string();
3197 let mut i = 1u32;
3198 while dir.join(&candidate).exists() {
3199 candidate = format!("{name}-{i}");
3200 i += 1;
3201 }
3202 candidate
3203}
3204
3205fn atomic_copy(dir: &Path, target: &Path, source: &Path) -> Result<()> {
3208 let pid = std::process::id();
3209 let nonce = nanos();
3210 let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
3211 let copy_result = (|| -> std::io::Result<()> {
3212 std::fs::copy(source, &tmp)?;
3213 let f = std::fs::File::open(&tmp)?;
3214 f.sync_all()?;
3215 Ok(())
3216 })();
3217 if let Err(e) = copy_result {
3218 let _ = std::fs::remove_file(&tmp);
3219 return Err(TixError::Io(e));
3220 }
3221 if let Err(e) = std::fs::rename(&tmp, target) {
3222 let _ = std::fs::remove_file(&tmp);
3223 return Err(TixError::Io(e));
3224 }
3225 Ok(())
3226}
3227
3228fn is_image_name(name: &str) -> bool {
3230 matches!(
3231 name.rsplit('.')
3232 .next()
3233 .unwrap_or("")
3234 .to_ascii_lowercase()
3235 .as_str(),
3236 "png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp"
3237 )
3238}
3239
3240pub(crate) fn atomic_write(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
3243 write_file(dir, target, data, Link::Overwrite)
3244}
3245
3246fn write_file_new(dir: &Path, target: &Path, data: &[u8]) -> Result<()> {
3265 write_file(dir, target, data, Link::Exclusive)
3266}
3267
3268#[derive(Clone, Copy)]
3269enum Link {
3270 Overwrite,
3271 Exclusive,
3272}
3273
3274fn write_file(dir: &Path, target: &Path, data: &[u8], link: Link) -> Result<()> {
3275 journal_record_write(target);
3278 let pid = std::process::id();
3279 let nonce = nanos();
3280 let tmp = dir.join(format!(".tix-tmp-{pid}-{nonce}"));
3281 let write_result = (|| -> std::io::Result<()> {
3282 let mut f = std::fs::File::create(&tmp)?;
3283 use std::io::Write;
3284 f.write_all(data)?;
3285 f.sync_all()?;
3286 Ok(())
3287 })();
3288 if let Err(e) = write_result {
3289 let _ = std::fs::remove_file(&tmp);
3290 return Err(TixError::Io(e));
3291 }
3292 let link_result = match link {
3293 Link::Overwrite => std::fs::rename(&tmp, target),
3294 Link::Exclusive => match std::fs::hard_link(&tmp, target) {
3295 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Err(e),
3299 Err(_) => std::fs::rename(&tmp, target),
3302 Ok(()) => {
3303 let _ = std::fs::remove_file(&tmp);
3304 Ok(())
3305 }
3306 },
3307 };
3308 if let Err(e) = link_result {
3309 let _ = std::fs::remove_file(&tmp);
3310 return Err(TixError::Io(e));
3311 }
3312 let _ = std::fs::File::open(dir).and_then(|f| f.sync_all());
3313 Ok(())
3314}
3315
3316fn nanos() -> u64 {
3317 SystemTime::now()
3318 .duration_since(UNIX_EPOCH)
3319 .map(|d| d.as_nanos() as u64)
3320 .unwrap_or(0)
3321}
3322
3323#[cfg(test)]
3324mod tests {
3325 use super::*;
3326 use crate::config::{EpicConfiguration, ProjectConfiguration};
3328 use crate::model::{Priority, today};
3329 use crate::relationships::DiagnosticKind;
3330 use std::fs;
3331 use tempfile::TempDir;
3332
3333 #[test]
3346 fn write_file_new_refuses_an_existing_target() {
3347 let dir = TempDir::new().unwrap();
3348 let target = dir.path().join("issue.md");
3349 write_file_new(dir.path(), &target, b"first").unwrap();
3350 assert_eq!(fs::read_to_string(&target).unwrap(), "first");
3351
3352 let err = write_file_new(dir.path(), &target, b"second").unwrap_err();
3353 let TixError::Io(io_err) = err else {
3354 panic!("expected an I/O error, so the caller can retry on a name collision")
3355 };
3356 assert_eq!(
3357 io_err.kind(),
3358 std::io::ErrorKind::AlreadyExists,
3359 "the error must be AlreadyExists, so the caller can retry on a name \\
3360 collision rather than treating it as a hard failure"
3361 );
3362 assert_eq!(
3363 fs::read_to_string(&target).unwrap(),
3364 "first",
3365 "the existing file must be byte-identical — not truncated, not replaced"
3366 );
3367 }
3368
3369 #[test]
3371 fn a_clobbered_name_is_retried_rather_than_failing_or_overwriting() {
3372 let tmp = TempDir::new().unwrap();
3373 let dir = tmp.path();
3374 let store = Store::open(&crate::config::Config {
3375 issues_dir: dir.to_path_buf(),
3376 ..Default::default()
3377 })
3378 .unwrap();
3379
3380 let segment_dir = dir.join("seg");
3383 fs::create_dir_all(&segment_dir).unwrap();
3384
3385 for name in ["2026-01-01-race.md", "2026-01-01-race-2.md"] {
3388 fs::write(segment_dir.join(name), "planted").unwrap();
3389 }
3390
3391 let (filename, path) = store
3392 .write_new_issue_file("seg", "2026-01-01-race", b"mine")
3393 .unwrap();
3394 assert_eq!(filename, "seg/2026-01-01-race-3.md");
3395 assert_eq!(fs::read_to_string(&path).unwrap(), "mine");
3396 assert_eq!(
3397 fs::read_to_string(segment_dir.join("2026-01-01-race.md")).unwrap(),
3398 "planted"
3399 );
3400 assert_eq!(
3401 fs::read_to_string(segment_dir.join("2026-01-01-race-2.md")).unwrap(),
3402 "planted"
3403 );
3404 }
3405
3406 #[test]
3412 fn no_temp_files_are_left_behind_on_success_or_collision() {
3413 let dir = TempDir::new().unwrap();
3414 let target = dir.path().join("issue.md");
3415 write_file_new(dir.path(), &target, b"first").unwrap();
3416 let _ = write_file_new(dir.path(), &target, b"second");
3417 let leftovers: Vec<String> = fs::read_dir(dir.path())
3418 .unwrap()
3419 .filter_map(|e| e.ok())
3420 .map(|e| e.file_name().to_string_lossy().to_string())
3421 .filter(|n| n.starts_with(".tix-tmp-"))
3422 .collect();
3423 assert!(
3424 leftovers.is_empty(),
3425 "temp files must not survive, found {leftovers:?}"
3426 );
3427 }
3428
3429 fn make_store() -> (tempfile::TempDir, Store) {
3430 let tmp = tempfile::TempDir::new().unwrap();
3431 let dir = tmp.path().join("issues");
3432 fs::create_dir_all(&dir).unwrap();
3433 let config_path = tmp.path().join("tix.toml");
3434 let mut config = Config {
3435 issues_dir: dir.clone(),
3436 ..Config::default()
3437 };
3438 config.layout.directory = "{assignee}".to_string();
3443 config.layout.move_on_change = false;
3444 config.save(&config_path).unwrap();
3445 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
3446 let store = Store {
3447 issues_dir: dir,
3448 config,
3449 };
3450 (tmp, store)
3451 }
3452
3453 fn write_issue(store: &Store, name: &str, contents: &str) {
3454 fs::write(store.issues_dir.join(name), contents).unwrap();
3455 }
3456
3457 fn sample_frontmatter(number: u32, title: &str, status: &str) -> String {
3458 format!(
3459 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{status}\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
3460 )
3461 }
3462
3463 #[test]
3472 fn list_never_opens_an_archived_file_but_list_all_does() {
3473 let (tmp, store) = make_store();
3474 let _ = tmp;
3475 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3476 write_issue(
3477 &store,
3478 "alice/2026-01-01-active.md",
3479 &sample_frontmatter(1, "Active", "todo"),
3480 );
3481 fs::write(
3482 store.issues_dir.join("alice/.2026-01-02-broken.md"),
3483 "this file has no frontmatter at all\n",
3484 )
3485 .unwrap();
3486
3487 let active = store
3488 .list()
3489 .expect("the default walk must not read the archived file");
3490 assert_eq!(active.len(), 1);
3491 assert_eq!(active[0].number, 1);
3492 assert!(!active[0].archived);
3493
3494 let all = store.list_all();
3495 assert!(
3496 matches!(all, Err(TixError::Parse(_))),
3497 "list_all() reads it, so the broken file is only invisible to list(): {all:?}"
3498 );
3499 }
3500
3501 #[test]
3502 fn archive_renames_in_place_and_restore_is_the_inverse() {
3503 let (tmp, store) = make_store();
3504 let _ = tmp;
3505 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3506 write_issue(
3507 &store,
3508 "alice/2026-01-01-x.md",
3509 &sample_frontmatter(5, "X", "todo"),
3510 );
3511
3512 let archived = store.archive("5").unwrap();
3513 assert_eq!(archived.filename, "alice/.2026-01-01-x.md");
3514 assert!(archived.archived);
3515 assert!(store.issues_dir.join("alice/.2026-01-01-x.md").is_file());
3516 assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
3517 assert!(
3518 store.list().unwrap().is_empty(),
3519 "hidden from the default view"
3520 );
3521
3522 let all = store.list_all().unwrap();
3523 assert_eq!(all.len(), 1);
3524 assert!(all[0].archived);
3525 assert_eq!(all[0].number, 5, "the number is untouched");
3526
3527 assert_eq!(
3529 store.get(IssueSelector::Number(5)).unwrap().filename,
3530 "alice/.2026-01-01-x.md"
3531 );
3532 assert!(store.get(IssueSelector::Slug(slugify("X"))).is_ok());
3533
3534 let restored = store.restore("5").unwrap();
3535 assert_eq!(restored.filename, "alice/2026-01-01-x.md");
3536 assert!(!restored.archived);
3537 assert_eq!(store.list().unwrap().len(), 1, "visible again");
3538 }
3539
3540 #[test]
3541 fn archive_appends_one_event_and_leaves_the_frontmatter_untouched() {
3542 let (tmp, store) = make_store();
3543 let _ = tmp;
3544 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3545 write_issue(
3546 &store,
3547 "alice/2026-01-01-x.md",
3548 &sample_frontmatter(5, "X", "todo"),
3549 );
3550 let before = store
3551 .get(IssueSelector::Filename("alice/2026-01-01-x.md".into()))
3552 .unwrap();
3553
3554 let archived = store.archive("5").unwrap();
3555 assert_eq!(archived.history.len(), 1, "exactly one History event");
3556 assert_eq!(
3557 archived.history.events()[0].kind,
3558 HistoryEventKind::Archived
3559 );
3560
3561 let after = store.get(IssueSelector::Number(5)).unwrap();
3562 assert_eq!(after.number, before.number);
3566 assert_eq!(after.title, before.title);
3567 assert_eq!(after.status, before.status);
3568 assert_eq!(after.labels, before.labels);
3569 assert_eq!(after.priority, before.priority);
3570 assert_eq!(after.assignee, before.assignee);
3571 assert_eq!(after.created, before.created);
3572 assert_eq!(
3573 after.updated, before.updated,
3574 "archiving must not bump `updated`: it is a frontmatter field"
3575 );
3576 let raw = read_issue_file(&store, "alice/.2026-01-01-x.md");
3578 let fm = raw.split("\n---").next().unwrap();
3579 assert!(
3580 !fm.contains("archived"),
3581 "the archived flag must never reach the frontmatter: {fm}"
3582 );
3583 }
3584
3585 #[test]
3586 fn restore_appends_one_restored_event() {
3587 let (tmp, store) = make_store();
3588 let _ = tmp;
3589 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3590 write_issue(
3591 &store,
3592 "alice/2026-01-01-x.md",
3593 &sample_frontmatter(5, "X", "todo"),
3594 );
3595 store.archive("5").unwrap();
3596 let restored = store.restore("5").unwrap();
3597 assert_eq!(restored.history.len(), 2, "archive + restore");
3598 assert_eq!(
3599 restored.history.events()[1].kind,
3600 HistoryEventKind::Restored
3601 );
3602 }
3603
3604 #[test]
3605 fn archiving_the_highest_number_does_not_free_it_for_reuse() {
3606 let (tmp, store) = make_store();
3607 let _ = tmp;
3608 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3609 write_issue(
3610 &store,
3611 "alice/2026-01-01-a.md",
3612 &sample_frontmatter(1, "A", "todo"),
3613 );
3614 write_issue(
3615 &store,
3616 "alice/2026-01-01-b.md",
3617 &sample_frontmatter(7, "B", "todo"),
3618 );
3619
3620 store.archive("7").unwrap();
3621
3622 let mut fresh = Issue::parse(&sample_frontmatter(0, "New", "todo")).unwrap();
3623 store.create(&mut fresh, "body", None).unwrap();
3624 assert_eq!(
3625 fresh.number, 8,
3626 "an archived issue keeps its number: `create` must not reuse #7"
3627 );
3628 }
3629
3630 #[test]
3631 fn redundant_archive_and_restore_are_refused_by_name() {
3632 let (tmp, store) = make_store();
3633 let _ = tmp;
3634 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3635 write_issue(
3636 &store,
3637 "alice/2026-01-01-x.md",
3638 &sample_frontmatter(5, "X", "todo"),
3639 );
3640
3641 let err = store.restore("5").unwrap_err();
3642 assert!(
3643 err.to_string().contains("not_archived"),
3644 "an active issue cannot be restored: {err}"
3645 );
3646 assert!(
3647 store.issues_dir.join("alice/2026-01-01-x.md").is_file(),
3648 "the refused restore writes nothing"
3649 );
3650
3651 store.archive("5").unwrap();
3652 let err = store.archive("5").unwrap_err();
3653 assert!(
3654 err.to_string().contains("already_archived"),
3655 "an archived issue cannot be re-archived: {err}"
3656 );
3657 assert!(
3658 store.issues_dir.join("alice/.2026-01-01-x.md").is_file(),
3659 "the refused archive writes nothing"
3660 );
3661 }
3662
3663 #[test]
3664 fn archive_never_clobbers_an_occupied_target_name() {
3665 let (tmp, store) = make_store();
3666 let _ = tmp;
3667 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3668 write_issue(
3669 &store,
3670 "alice/2026-01-01-x.md",
3671 &sample_frontmatter(5, "X", "todo"),
3672 );
3673 write_issue(
3675 &store,
3676 "alice/.2026-01-01-x.md",
3677 &sample_frontmatter(6, "Other", "todo"),
3678 );
3679 let err = store.archive("5").unwrap_err();
3680 assert!(err.to_string().contains("already exists"), "got: {err}");
3681 assert!(store.issues_dir.join("alice/2026-01-01-x.md").is_file());
3682 assert!(store.issues_dir.join("alice/.2026-01-01-x.md").is_file());
3683 }
3684
3685 #[test]
3686 fn archive_and_restore_a_legacy_flat_file_in_place() {
3687 let (tmp, store) = make_store();
3688 let _ = tmp;
3689 write_issue(
3690 &store,
3691 "2026-01-01-legacy.md",
3692 &sample_frontmatter(3, "Legacy", "todo"),
3693 );
3694
3695 let archived = store.archive("3").unwrap();
3696 assert_eq!(archived.filename, ".2026-01-01-legacy.md");
3697 assert!(store.issues_dir.join(".2026-01-01-legacy.md").is_file());
3698 assert_eq!(store.list().unwrap(), [] as [crate::model::Issue; 0]);
3699 assert_eq!(store.list_all().unwrap().len(), 1);
3700
3701 let restored = store.restore("3").unwrap();
3702 assert_eq!(restored.filename, "2026-01-01-legacy.md");
3703 assert!(store.issues_dir.join("2026-01-01-legacy.md").is_file());
3704 }
3705
3706 #[test]
3707 fn list_returns_sorted_by_filename() {
3708 let (tmp, store) = make_store();
3709 let _ = tmp;
3710 write_issue(
3711 &store,
3712 "2026-03-01-c.md",
3713 &sample_frontmatter(3, "C", "todo"),
3714 );
3715 write_issue(
3716 &store,
3717 "2026-01-01-a.md",
3718 &sample_frontmatter(1, "A", "todo"),
3719 );
3720 write_issue(
3721 &store,
3722 "2026-02-01-b.md",
3723 &sample_frontmatter(2, "B", "todo"),
3724 );
3725 let issues = store.list().unwrap();
3726 assert_eq!(issues.len(), 3);
3727 assert_eq!(issues[0].number, 1);
3728 assert_eq!(issues[1].number, 2);
3729 assert_eq!(issues[2].number, 3);
3730 assert_eq!(issues[0].title, "A");
3731 }
3732
3733 #[test]
3736 fn is_issue_rel_path_matches_what_scan_walks() {
3737 for path in [
3739 "a.md",
3740 "alice/b.md",
3741 ".hidden.md",
3742 "alice/.hidden.md",
3743 "deep/a/b.md",
3744 ] {
3745 assert!(
3746 is_issue_rel_path(Path::new(path)),
3747 "{path} is an issue file `scan` reads"
3748 );
3749 }
3750 for path in [
3752 "notes.txt",
3753 "alice/notes.txt",
3754 "alice",
3755 ".tix-tmp-x/a.md",
3756 "foo-assets/a.md",
3757 "alice/foo-assets/a.md",
3758 ] {
3759 assert!(
3760 !is_issue_rel_path(Path::new(path)),
3761 "{path} is not a path `scan` indexes"
3762 );
3763 }
3764 }
3765
3766 #[test]
3767 fn scan_above_the_parallel_threshold_is_sorted_and_complete() {
3768 let (tmp, store) = make_store();
3772 let _ = tmp;
3773 let n = Store::PARALLEL_SCAN_THRESHOLD + 44;
3774 for i in (1..=n).rev() {
3775 write_issue(
3776 &store,
3777 &format!("2026-01-01-{i:04}.md"),
3778 &sample_frontmatter(i as u32, &format!("T{i}"), "todo"),
3779 );
3780 }
3781 let issues = store.list().unwrap();
3782 assert_eq!(
3783 issues.len(),
3784 n,
3785 "every file is read, none dropped or doubled"
3786 );
3787 let names: Vec<&str> = issues.iter().map(|i| i.filename.as_str()).collect();
3788 let mut sorted = names.clone();
3789 sorted.sort_unstable();
3790 assert_eq!(names, sorted, "sorted by filename as `scan` promises");
3791 assert_eq!(issues[0].number, 1, "content came from the first file");
3792 assert_eq!(issues[n - 1].number, n as u32, "and from the last");
3793 }
3794
3795 #[test]
3796 fn read_issue_at_sets_filename_and_archived_like_scan() {
3797 let (tmp, store) = make_store();
3798 let _ = tmp;
3799 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
3800 write_issue(
3801 &store,
3802 "alice/2026-01-01-a.md",
3803 &sample_frontmatter(1, "A", "todo"),
3804 );
3805 write_issue(
3806 &store,
3807 "alice/.2026-01-02-b.md",
3808 &sample_frontmatter(2, "B", "todo"),
3809 );
3810
3811 let active = store
3812 .read_issue_at(Path::new("alice/2026-01-01-a.md"))
3813 .unwrap();
3814 assert_eq!(active.number, 1);
3815 assert_eq!(active.filename, "alice/2026-01-01-a.md");
3816 assert!(!active.archived);
3817
3818 let archived = store
3819 .read_issue_at(Path::new("alice/.2026-01-02-b.md"))
3820 .unwrap();
3821 assert_eq!(archived.filename, "alice/.2026-01-02-b.md");
3822 assert!(
3823 archived.archived,
3824 "the leading dot marks it archived, as `scan` does"
3825 );
3826
3827 write_issue(&store, "flat.md", &sample_frontmatter(3, "F", "todo"));
3829 assert_eq!(
3830 store.read_issue_at(Path::new("flat.md")).unwrap().filename,
3831 "flat.md"
3832 );
3833 assert!(matches!(
3834 store.read_issue_at(Path::new("alice/gone.md")),
3835 Err(TixError::NotFound(_))
3836 ));
3837 }
3838
3839 #[test]
3840 fn list_ignores_non_md_files() {
3841 let (tmp, store) = make_store();
3842 let _ = tmp;
3843 write_issue(
3844 &store,
3845 "2026-01-01-a.md",
3846 &sample_frontmatter(1, "A", "todo"),
3847 );
3848 write_issue(&store, "readme.txt", "not an issue");
3849 write_issue(&store, "2026-01-01-sub", "no extension");
3850 let issues = store.list().unwrap();
3851 assert_eq!(issues.len(), 1);
3852 }
3853
3854 #[test]
3855 fn list_errors_on_malformed_frontmatter() {
3856 let (tmp, store) = make_store();
3857 let _ = tmp;
3858 write_issue(&store, "2026-01-01-bad.md", "no frontmatter here\n");
3859 let err = store.list().unwrap_err();
3860 assert!(matches!(err, TixError::Parse(_)), "got {err:?}");
3861 }
3862
3863 #[test]
3864 fn get_by_number() {
3865 let (tmp, store) = make_store();
3866 let _ = tmp;
3867 write_issue(
3868 &store,
3869 "2026-01-01-x.md",
3870 &sample_frontmatter(5, "X", "todo"),
3871 );
3872 let issue = store.get(IssueSelector::Number(5)).unwrap();
3873 assert_eq!(issue.title, "X");
3874 }
3875
3876 #[test]
3877 fn get_by_filename() {
3878 let (tmp, store) = make_store();
3879 let _ = tmp;
3880 write_issue(
3881 &store,
3882 "2026-01-01-y.md",
3883 &sample_frontmatter(2, "Y", "done"),
3884 );
3885 let issue = store
3886 .get(IssueSelector::Filename("2026-01-01-y.md".into()))
3887 .unwrap();
3888 assert_eq!(issue.status.display(), "done");
3889 }
3890
3891 #[test]
3892 fn get_by_slug() {
3893 let (tmp, store) = make_store();
3894 let _ = tmp;
3895 write_issue(
3896 &store,
3897 "2026-01-01-z.md",
3898 &sample_frontmatter(3, "Z", "todo"),
3899 );
3900 let issue = store.get(IssueSelector::Slug("z".into())).unwrap();
3901 assert_eq!(issue.number, 3);
3902 }
3903
3904 #[test]
3905 fn get_not_found() {
3906 let (tmp, store) = make_store();
3907 let _ = tmp;
3908 assert!(store.get(IssueSelector::Number(99)).is_err());
3909 }
3910
3911 #[test]
3912 fn create_assigns_number_from_live_issues_and_leaves_config_untouched() {
3913 let (tmp, store) = make_store();
3914 let _ = tmp;
3915 assert_eq!(store.config.state.next_number, 0);
3916 let mut issue = Issue {
3917 projects: None,
3918 id: None,
3919 number: 0,
3920 title: "First".into(),
3921 status: Status::default(),
3922 labels: vec![],
3923 priority: Priority::default(),
3924 assignee: None,
3925 estimate: None,
3926 commit: None,
3927 gitlab_id: None,
3928 linear_id: None,
3929 mission: None,
3930 task: None,
3931 relationships: vec![],
3932 related_issues: vec![],
3933 files: vec![],
3934 cycles: None,
3935 milestones: None,
3936 reviewer: None,
3937 start_date: None,
3938 due_date: None,
3939 created: "2026-01-01".into(),
3940 type_: None,
3941 updated: "2026-01-01".into(),
3942 filename: String::new(),
3943 body: String::new(),
3944 history: crate::model::History::new(),
3945 has_retired_links_key: false,
3946 time_entries: vec![],
3947 parent: None,
3948 series: None,
3949 health: None,
3950 epics: None,
3951 extra_frontmatter: std::collections::BTreeMap::new(),
3952 archived: false,
3953 entered_at: None,
3954 source: None,
3955 };
3956 let path = store.create(&mut issue, "Hello world", None).unwrap();
3957 assert!(path.exists());
3958 assert_eq!(
3959 issue.number, 1,
3960 "an empty store starts at 1, not 0 — #0 is indistinguishable from a \
3961 sentinel or a failed read (tix #1376)"
3962 );
3963 let config_path = tmp.path().join("tix.toml");
3965 let on_disk = std::fs::read_to_string(&config_path).unwrap();
3966 assert!(
3967 !on_disk.contains("next_number"),
3968 "create must not persist an issue-number counter:\n{on_disk}"
3969 );
3970 let reloaded = Config::load_with_warning(&config_path, |_| {}).unwrap();
3971 assert_eq!(reloaded.state.next_number, 0, "state is never written");
3972 }
3973
3974 #[test]
3975 fn create_resolves_max_live_number_plus_one() {
3976 let (tmp, store) = make_store();
3977 let _ = tmp;
3978 write_issue(
3979 &store,
3980 "2026-01-01-a.md",
3981 &sample_frontmatter(0, "A", "todo"),
3982 );
3983 write_issue(
3984 &store,
3985 "2026-01-01-b.md",
3986 &sample_frontmatter(1, "B", "todo"),
3987 );
3988 write_issue(
3989 &store,
3990 "2026-01-01-c.md",
3991 &sample_frontmatter(2, "C", "todo"),
3992 );
3993 let mut issue = Issue {
3994 projects: None,
3995 id: None,
3996 number: 0,
3997 title: "D".into(),
3998 status: Status::default(),
3999 labels: vec![],
4000 priority: Priority::default(),
4001 assignee: None,
4002 estimate: None,
4003 commit: None,
4004 gitlab_id: None,
4005 linear_id: None,
4006 mission: None,
4007 task: None,
4008 relationships: vec![],
4009 related_issues: vec![],
4010 files: vec![],
4011 cycles: None,
4012 milestones: None,
4013 reviewer: None,
4014 start_date: None,
4015 due_date: None,
4016 created: "2026-01-01".into(),
4017 type_: None,
4018 updated: "2026-01-01".into(),
4019 filename: String::new(),
4020 body: String::new(),
4021 history: crate::model::History::new(),
4022 has_retired_links_key: false,
4023 time_entries: vec![],
4024 parent: None,
4025 series: None,
4026 health: None,
4027 epics: None,
4028 extra_frontmatter: std::collections::BTreeMap::new(),
4029 archived: false,
4030 entered_at: None,
4031 source: None,
4032 };
4033 store.create(&mut issue, "", None).unwrap();
4034 assert_eq!(issue.number, 3);
4035 }
4036
4037 #[test]
4038 fn create_reuses_number_of_deleted_highest_issue() {
4039 let (_tmp, store) = make_store();
4043 let mut issue_a = Issue {
4044 projects: None,
4045 id: None,
4046 number: 0,
4047 title: "A".into(),
4048 status: Status::default(),
4049 labels: vec![],
4050 priority: Priority::default(),
4051 assignee: None,
4052 estimate: None,
4053 commit: None,
4054 gitlab_id: None,
4055 linear_id: None,
4056 mission: None,
4057 task: None,
4058 relationships: vec![],
4059 related_issues: vec![],
4060 files: vec![],
4061 cycles: None,
4062 milestones: None,
4063 reviewer: None,
4064 start_date: None,
4065 due_date: None,
4066 created: "2026-01-01".into(),
4067 type_: None,
4068 updated: "2026-01-01".into(),
4069 filename: String::new(),
4070 body: String::new(),
4071 history: crate::model::History::new(),
4072 has_retired_links_key: false,
4073 time_entries: vec![],
4074 parent: None,
4075 series: None,
4076 health: None,
4077 epics: None,
4078 extra_frontmatter: std::collections::BTreeMap::new(),
4079 archived: false,
4080 entered_at: None,
4081 source: None,
4082 };
4083 store.create(&mut issue_a, "", None).unwrap();
4084 let filename = issue_a.filename.clone();
4085 assert_eq!(issue_a.number, 1, "the first issue in a fresh store is #1");
4086 store.delete(&filename).unwrap();
4087 let mut issue_b = Issue {
4088 projects: None,
4089 id: None,
4090 number: 0,
4091 title: "B".into(),
4092 status: Status::default(),
4093 labels: vec![],
4094 priority: Priority::default(),
4095 assignee: None,
4096 estimate: None,
4097 commit: None,
4098 gitlab_id: None,
4099 linear_id: None,
4100 mission: None,
4101 task: None,
4102 relationships: vec![],
4103 related_issues: vec![],
4104 files: vec![],
4105 cycles: None,
4106 milestones: None,
4107 reviewer: None,
4108 start_date: None,
4109 due_date: None,
4110 created: "2026-01-02".into(),
4111 type_: None,
4112 updated: "2026-01-02".into(),
4113 filename: String::new(),
4114 body: String::new(),
4115 history: crate::model::History::new(),
4116 has_retired_links_key: false,
4117 time_entries: vec![],
4118 parent: None,
4119 series: None,
4120 health: None,
4121 epics: None,
4122 extra_frontmatter: std::collections::BTreeMap::new(),
4123 archived: false,
4124 entered_at: None,
4125 source: None,
4126 };
4127 store.create(&mut issue_b, "", None).unwrap();
4128 assert_eq!(
4129 issue_b.number, 1,
4130 "an emptied store resumes at the same floor as a fresh one: 1"
4131 );
4132 }
4133
4134 #[test]
4135 fn create_with_custom_filename_stem() {
4136 let (tmp, store) = make_store();
4137 let _ = tmp;
4138 let mut issue = Issue {
4139 projects: None,
4140 id: None,
4141 number: 0,
4142 title: "T".into(),
4143 status: Status::default(),
4144 labels: vec![],
4145 priority: Priority::default(),
4146 assignee: None,
4147 estimate: None,
4148 commit: None,
4149 gitlab_id: None,
4150 linear_id: None,
4151 mission: None,
4152 task: None,
4153 relationships: vec![],
4154 related_issues: vec![],
4155 files: vec![],
4156 cycles: None,
4157 milestones: None,
4158 reviewer: None,
4159 start_date: None,
4160 due_date: None,
4161 created: "2026-01-01".into(),
4162 type_: None,
4163 updated: "2026-01-01".into(),
4164 filename: String::new(),
4165 body: String::new(),
4166 history: crate::model::History::new(),
4167 has_retired_links_key: false,
4168 time_entries: vec![],
4169 parent: None,
4170 series: None,
4171 health: None,
4172 epics: None,
4173 extra_frontmatter: std::collections::BTreeMap::new(),
4174 archived: false,
4175 entered_at: None,
4176 source: None,
4177 };
4178 let path = store
4179 .create(&mut issue, "body", Some("custom-stem"))
4180 .unwrap();
4181 assert!(path.to_string_lossy().contains("custom-stem.md"));
4182 }
4183
4184 #[test]
4185 fn create_ensures_issues_dir_exists() {
4186 let tmp = tempfile::TempDir::new().unwrap();
4187 let dir = tmp.path().join("nested").join("issues");
4188 let config_path = tmp.path().join("tix.toml");
4189 let config = Config {
4190 issues_dir: dir.clone(),
4191 ..Config::default()
4192 };
4193 config.save(&config_path).unwrap();
4194 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
4195 let store = Store {
4196 issues_dir: dir,
4197 config,
4198 };
4199 let mut issue = Issue {
4200 projects: None,
4201 id: None,
4202 number: 0,
4203 title: "T".into(),
4204 status: Status::default(),
4205 labels: vec![],
4206 priority: Priority::default(),
4207 assignee: None,
4208 estimate: None,
4209 commit: None,
4210 gitlab_id: None,
4211 linear_id: None,
4212 mission: None,
4213 task: None,
4214 relationships: vec![],
4215 related_issues: vec![],
4216 files: vec![],
4217 cycles: None,
4218 milestones: None,
4219 reviewer: None,
4220 start_date: None,
4221 due_date: None,
4222 created: "2026-01-01".into(),
4223 type_: None,
4224 updated: "2026-01-01".into(),
4225 filename: String::new(),
4226 body: String::new(),
4227 history: crate::model::History::new(),
4228 has_retired_links_key: false,
4229 time_entries: vec![],
4230 parent: None,
4231 series: None,
4232 health: None,
4233 epics: None,
4234 extra_frontmatter: std::collections::BTreeMap::new(),
4235 archived: false,
4236 entered_at: None,
4237 source: None,
4238 };
4239 store.create(&mut issue, "", None).unwrap();
4240 assert_eq!(issue.number, 1, "the first issue in a fresh store is #1");
4241 }
4242
4243 #[test]
4244 fn update_replaces_content_atomically() {
4245 let (tmp, store) = make_store();
4246 let _ = tmp;
4247 write_issue(
4248 &store,
4249 "2026-01-01-u.md",
4250 &sample_frontmatter(1, "U", "todo"),
4251 );
4252 let mut issue = store
4253 .get(IssueSelector::Filename("2026-01-01-u.md".into()))
4254 .unwrap();
4255 issue.title = "Updated".into();
4256 issue.bump_updated();
4257 store.update("2026-01-01-u.md", &issue).unwrap();
4258 let reloaded = store
4259 .get(IssueSelector::Filename("2026-01-01-u.md".into()))
4260 .unwrap();
4261 assert_eq!(reloaded.title, "Updated");
4262 }
4263
4264 #[test]
4265 fn update_not_found() {
4266 let (tmp, store) = make_store();
4267 let _ = tmp;
4268 let issue = Issue {
4269 projects: None,
4270 id: None,
4271 number: 1,
4272 title: "T".into(),
4273 status: Status::default(),
4274 labels: vec![],
4275 priority: Priority::default(),
4276 assignee: None,
4277 estimate: None,
4278 commit: None,
4279 gitlab_id: None,
4280 linear_id: None,
4281 mission: None,
4282 task: None,
4283 relationships: vec![],
4284 related_issues: vec![],
4285 files: vec![],
4286 cycles: None,
4287 milestones: None,
4288 reviewer: None,
4289 start_date: None,
4290 due_date: None,
4291 created: "2026-01-01".into(),
4292 type_: None,
4293 updated: "2026-01-01".into(),
4294 filename: String::new(),
4295 body: String::new(),
4296 history: crate::model::History::new(),
4297 has_retired_links_key: false,
4298 time_entries: vec![],
4299 parent: None,
4300 series: None,
4301 health: None,
4302 epics: None,
4303 extra_frontmatter: std::collections::BTreeMap::new(),
4304 archived: false,
4305 entered_at: None,
4306 source: None,
4307 };
4308 assert!(store.update("nope.md", &issue).is_err());
4309 }
4310
4311 #[test]
4312 fn delete_removes_file() {
4313 let (tmp, store) = make_store();
4314 let _ = tmp;
4315 write_issue(
4316 &store,
4317 "2026-01-01-d.md",
4318 &sample_frontmatter(1, "D", "todo"),
4319 );
4320 assert!(store.issues_dir.join("2026-01-01-d.md").exists());
4321 store.delete("2026-01-01-d.md").unwrap();
4322 assert!(!store.issues_dir.join("2026-01-01-d.md").exists());
4323 }
4324
4325 #[test]
4326 fn delete_not_found() {
4327 let (tmp, store) = make_store();
4328 let _ = tmp;
4329 assert!(store.delete("nope.md").is_err());
4330 }
4331
4332 #[test]
4333 fn stray_tmp_file_not_treated_as_issue() {
4334 let (tmp, store) = make_store();
4335 let _ = tmp;
4336 write_issue(
4337 &store,
4338 "2026-01-01-a.md",
4339 &sample_frontmatter(1, "A", "todo"),
4340 );
4341 fs::write(store.issues_dir.join(".tix-tmp-9999-0"), "orphaned data").unwrap();
4342 let issues = store.list().unwrap();
4343 assert_eq!(issues.len(), 1);
4344 }
4345
4346 fn seam_store() -> (tempfile::TempDir, Store) {
4363 let tmp = tempfile::TempDir::new().unwrap();
4364 let dir = tmp.path().join("issues");
4365 fs::create_dir_all(&dir).unwrap();
4366 let config_path = tmp.path().join("tix.toml");
4367 let mut config = Config {
4368 issues_dir: dir.clone(),
4369 kanban: crate::config::KanbanSection {
4370 configurations: indexmap::IndexMap::from([(
4371 "default".to_string(),
4372 crate::config::BoardConfiguration {
4373 columns: vec![
4374 "triage".into(),
4375 "todo".into(),
4376 "doing".into(),
4377 "done".into(),
4378 ],
4379 done_column: Some("done".to_string()),
4380 category_map: indexmap::IndexMap::from([
4381 ("triage".to_string(), "backlog".to_string()),
4382 ("todo".to_string(), "unstarted".to_string()),
4383 ("doing".to_string(), "active".to_string()),
4384 ("done".to_string(), "completed".to_string()),
4385 ]),
4386 ..Default::default()
4387 },
4388 )]),
4389 },
4390 ..Config::default()
4391 };
4392 config.layout.move_on_change = false;
4393 config.save(&config_path).unwrap();
4394 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
4395 (
4396 tmp,
4397 Store {
4398 issues_dir: dir,
4399 config,
4400 },
4401 )
4402 }
4403
4404 fn seam_issue(status: &str) -> (tempfile::TempDir, Store, String) {
4412 let (tmp, store) = seam_store();
4413 write_issue(
4414 &store,
4415 "2026-01-01-seam.md",
4416 &format!(
4417 "---\nnumber: 1\ntitle: \"Seam\"\nstatus: {status}\nlabels: []\n\
4418 priority: \"medium\"\nfiles: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody."
4419 ),
4420 );
4421 let before = read_issue_file(&store, "2026-01-01-seam.md");
4422 (tmp, store, before)
4423 }
4424
4425 fn assert_untouched(store: &Store, before: &str, what: &str) {
4427 let after = read_issue_file(store, "2026-01-01-seam.md");
4428 assert_eq!(after, before, "{what}: the file must be byte-identical");
4429 }
4430
4431 #[test]
4432 fn t015_accepted_transition_writes_status_bumps_updated_and_appends_one_decision() {
4433 let (_tmp, store, _before) = seam_issue("todo");
4434 let outcome = store
4435 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
4436 .unwrap();
4437 assert!(outcome.written, "an accepted, changing move writes");
4438 let decision = outcome
4439 .decision
4440 .clone()
4441 .expect("an accepted transition appends exactly one decision");
4442 assert_eq!(decision.from, "todo");
4443 assert_eq!(decision.to, "doing");
4444 assert_eq!(decision.from_category, "unstarted");
4445 assert_eq!(decision.to_category, "active");
4446 assert!(
4447 decision.override_reason.is_none(),
4448 "no override was supplied"
4449 );
4450 assert_eq!(
4451 outcome.issue.status.display(),
4452 "doing",
4453 "Issue.status holds the COLUMN name, never a category (C-001)"
4454 );
4455 assert_eq!(outcome.issue.updated, today(), "`updated` is bumped");
4456
4457 let reloaded = store
4459 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4460 .unwrap();
4461 assert_eq!(reloaded.status.display(), "doing");
4462 let events = reloaded.history.events();
4463 assert_eq!(
4464 events.len(),
4465 1,
4466 "exactly one History entry, and it is the decision: {events:?}"
4467 );
4468 assert_eq!(events[0].kind, HistoryEventKind::TransitionAccepted);
4469 assert_eq!(
4470 crate::model::TransitionDecision::from_event(events[0])
4471 .unwrap()
4472 .to,
4473 "doing",
4474 "and the decision round-trips out of the file with its fields intact"
4475 );
4476 }
4477
4478 #[test]
4479 fn t015_unknown_column_is_refused_by_name_and_writes_nothing() {
4480 let (_tmp, store, before) = seam_issue("todo");
4481 let err = store
4482 .set_status("2026-01-01-seam.md", StatusChange::to("nope"))
4483 .unwrap_err();
4484 assert!(
4485 matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "unknown_column"),
4486 "the shared named diagnostic, contract §6: {err:?}"
4487 );
4488 assert!(
4489 err.to_string()
4490 .contains("valid columns: triage, todo, doing, done"),
4491 "in BOARD order, not hash order: {err}"
4492 );
4493 assert_untouched(&store, &before, "unknown column");
4494 }
4495
4496 #[test]
4497 fn t015_terminal_exit_is_refused_and_writes_nothing() {
4498 let (_tmp, store, before) = seam_issue("done");
4499 let err = store
4500 .set_status("2026-01-01-seam.md", StatusChange::to("todo"))
4501 .unwrap_err();
4502 assert!(
4503 matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "invalid_transition"),
4504 "{err:?}"
4505 );
4506 assert_untouched(&store, &before, "terminal exit without a reason");
4507 }
4508
4509 #[test]
4510 fn t015_adjacency_restricted_backwards_move_is_refused_and_writes_nothing() {
4511 let (_tmp, store, before) = seam_issue("doing");
4515 let err = store
4516 .set_status("2026-01-01-seam.md", StatusChange::to("triage"))
4517 .unwrap_err();
4518 assert!(
4519 matches!(&err, TixError::Workflow(d) if d.kind.as_str() == "invalid_transition"),
4520 "{err:?}"
4521 );
4522 assert!(
4523 err.to_string().contains("category policy"),
4524 "the message names the rule that refused it: {err}"
4525 );
4526 assert_untouched(&store, &before, "adjacency refusal");
4527 }
4528
4529 #[test]
4530 fn t015_terminal_exit_with_a_reason_is_accepted_and_the_reason_is_recorded() {
4531 let (_tmp, store, before) = seam_issue("done");
4532 let outcome = store
4533 .set_status(
4534 "2026-01-01-seam.md",
4535 StatusChange::to("todo").with_override(Some("reopening: wrong epic")),
4536 )
4537 .unwrap();
4538 assert!(outcome.written);
4539 let decision = outcome.decision.expect("one decision");
4540 assert_eq!(
4541 decision.override_reason.as_deref(),
4542 Some("reopening: wrong epic"),
4543 "an override with no recorded reason is not an audit record"
4544 );
4545 assert_ne!(
4546 read_issue_file(&store, "2026-01-01-seam.md"),
4547 before,
4548 "sanity: an accepted move must have changed the file at all"
4549 );
4550 }
4551
4552 #[test]
4553 fn t015_a_blank_reason_is_not_an_override() {
4554 let (_tmp, store, before) = seam_issue("done");
4559 let err = store
4560 .set_status(
4561 "2026-01-01-seam.md",
4562 StatusChange::to("todo").with_override(Some(" ")),
4563 )
4564 .unwrap_err();
4565 assert!(
4566 matches!(&err, TixError::Workflow(_)),
4567 "a blank reason is not a reason: {err:?}"
4568 );
4569 assert_untouched(&store, &before, "blank override reason");
4570 }
4571
4572 #[test]
4573 fn t015_same_column_is_accepted_and_writes_nothing() {
4574 let (_tmp, store, before) = seam_issue("doing");
4575 let outcome = store
4576 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
4577 .unwrap();
4578 assert!(!outcome.written, "a same-column re-apply is a no-op");
4579 assert!(
4580 outcome.decision.is_none(),
4581 "and appends no decision, contract §3.2"
4582 );
4583 assert_untouched(&store, &before, "same column");
4584 let reloaded = store
4587 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4588 .unwrap();
4589 assert_eq!(
4590 reloaded.updated, "2026-01-01",
4591 "the fixture's original date"
4592 );
4593 }
4594
4595 #[test]
4596 fn t015_no_new_frontmatter_key_was_introduced() {
4597 let (_tmp, store, before) = seam_issue("todo");
4601 store
4602 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
4603 .unwrap();
4604 let after = read_issue_file(&store, "2026-01-01-seam.md");
4605 let keys = |text: &str| -> Vec<String> {
4606 text.split("---\n")
4607 .nth(1)
4608 .unwrap()
4609 .lines()
4610 .filter_map(|l| l.split_once(':').map(|(k, _)| k.trim().to_string()))
4611 .filter(|k| !k.is_empty())
4612 .collect()
4613 };
4614 assert_eq!(
4615 keys(&after),
4616 keys(&before),
4617 "the seam must not add or remove a top-level frontmatter key"
4618 );
4619 }
4620
4621 #[test]
4622 fn t015_a_hand_edited_inconsistent_file_is_read_and_not_repaired() {
4623 let (_tmp, store, before) = seam_issue("doing");
4628 let edited = before.replace("status: doing", "status: triage");
4631 write_issue(&store, "2026-01-01-seam.md", &edited);
4632 let after_edit = read_issue_file(&store, "2026-01-01-seam.md");
4633
4634 let issue = store
4635 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4636 .unwrap();
4637 assert_eq!(
4638 issue.status.display(),
4639 "triage",
4640 "a hand-edited status is read, not rejected at load"
4641 );
4642 assert_eq!(
4643 read_issue_file(&store, "2026-01-01-seam.md"),
4644 after_edit,
4645 "reading must not repair it"
4646 );
4647 store
4650 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
4651 .unwrap();
4652 let moved = store
4653 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4654 .unwrap();
4655 assert_eq!(moved.status.display(), "doing");
4656 assert_eq!(
4657 moved.history.events().len(),
4658 1,
4659 "only the new decision; nothing was written to explain the old one"
4660 );
4661 }
4662
4663 #[test]
4664 fn t015_every_rejection_kind_is_one_of_the_contract_vocabulary() {
4665 let allowed = [
4669 "missing_completion_category",
4670 "unknown_category",
4671 "mapping_for_unknown_column",
4672 "unmapped_column_default",
4673 "invalid_transition",
4674 "unknown_column",
4675 "direct_edit_policy_violation",
4676 ];
4677 let cases: Vec<(&str, &str)> = vec![
4678 ("todo", "nope"), ("done", "todo"), ("doing", "triage"), ];
4682 for (from, to) in cases {
4683 let (_tmp, store, _before) = seam_issue(from);
4684 let err = store
4685 .set_status("2026-01-01-seam.md", StatusChange::to(to))
4686 .unwrap_err();
4687 let TixError::Workflow(d) = &err else {
4688 panic!("{from} -> {to} must refuse through the workflow variant: {err:?}");
4689 };
4690 assert!(
4691 allowed.contains(&d.kind.as_str()),
4692 "`{}` is not in the contract's vocabulary",
4693 d.kind.as_str()
4694 );
4695 }
4696 }
4697
4698 #[test]
4699 fn t015_a_legacy_board_gains_no_policy_but_keeps_completion() {
4700 let (tmp, store) = make_store();
4705 let _ = tmp;
4706 write_issue(
4707 &store,
4708 "2026-01-01-legacy.md",
4709 &sample_frontmatter(1, "Legacy", "done"),
4710 );
4711 let moved = store
4712 .set_status("2026-01-01-legacy.md", StatusChange::to("todo"))
4713 .unwrap();
4714 assert!(moved.written, "a legacy board imposes no policy");
4715 assert_eq!(moved.issue.status.display(), "todo");
4716
4717 let resolution =
4720 crate::workflow::resolution(&store.config, store.config.default_config(), "default");
4721 assert!(
4722 !resolution.explicit_policy,
4723 "precondition: this board declared no category_map"
4724 );
4725 assert!(
4726 resolution.has_completion(),
4727 "but it still has a done column"
4728 );
4729 assert!(
4730 !crate::workflow::is_completed(&moved.issue, &resolution),
4731 "and `todo` is not complete"
4732 );
4733 }
4734
4735 #[test]
4736 fn t015_a_mapped_board_keeps_the_commit_coupling() {
4737 let (_tmp, store, _before) = seam_issue("doing");
4741 store
4742 .set_status(
4743 "2026-01-01-seam.md",
4744 StatusChange::to("done").with_commit(Some("abc123")),
4745 )
4746 .unwrap();
4747 let at_done = store
4748 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4749 .unwrap();
4750 assert_eq!(at_done.commit.as_deref(), Some("abc123"));
4751
4752 store
4753 .set_status(
4754 "2026-01-01-seam.md",
4755 StatusChange::to("todo").with_override(Some("not actually finished")),
4756 )
4757 .unwrap();
4758 let off_done = store
4759 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4760 .unwrap();
4761 assert_eq!(
4762 off_done.commit, None,
4763 "leaving completion clears the recorded commit"
4764 );
4765 }
4766
4767 #[test]
4768 fn t015_a_decision_round_trips_through_a_file_unchanged() {
4769 let (_tmp, store, _before) = seam_issue("todo");
4773 store
4774 .set_status("2026-01-01-seam.md", StatusChange::to("doing"))
4775 .unwrap();
4776 let first = store
4777 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4778 .unwrap();
4779 let text = read_issue_file(&store, "2026-01-01-seam.md");
4780 let parsed = Issue::parse(&text).unwrap();
4782 let events = parsed.history.events();
4783 let decision = crate::model::TransitionDecision::from_event(events[0]).unwrap();
4784 assert_eq!(decision.to, "doing");
4785 assert_eq!(decision.from_category, "unstarted");
4786 assert_eq!(decision.to_category, "active");
4787 assert_eq!(
4790 parsed.to_file_contents(),
4791 text,
4792 "a parsed decision must re-render to the same bytes"
4793 );
4794 assert_eq!(parsed.history.events().len(), first.history.events().len());
4795 }
4796
4797 #[test]
4798 fn t015_two_transitions_append_two_decisions_in_order() {
4799 let (_tmp, store, _before) = seam_issue("triage");
4800 for to in ["todo", "doing"] {
4801 store
4802 .set_status("2026-01-01-seam.md", StatusChange::to(to))
4803 .unwrap();
4804 }
4805 let after = store
4806 .get(IssueSelector::Filename("2026-01-01-seam.md".into()))
4807 .unwrap();
4808 let events = after.history.events();
4809 assert_eq!(events.len(), 2, "one per accepted transition");
4810 let tos: Vec<String> = events
4811 .iter()
4812 .map(|e| crate::model::TransitionDecision::from_event(e).unwrap().to)
4813 .collect();
4814 assert_eq!(tos, vec!["todo", "doing"], "append order, not sorted");
4815 }
4816
4817 #[test]
4818 fn set_status_changes_status_and_bumps_updated() {
4819 let (tmp, store) = make_store();
4820 let _ = tmp;
4821 write_issue(
4822 &store,
4823 "2026-01-01-s.md",
4824 &sample_frontmatter(1, "S", "todo"),
4825 );
4826 let outcome = store
4827 .set_status("2026-01-01-s.md", StatusChange::to("in progress"))
4828 .unwrap();
4829 let issue = &outcome.issue;
4830 assert_eq!(issue.status.display(), "in progress");
4831 assert_eq!(issue.updated, today());
4832 assert_eq!(issue.title, "S");
4833 assert_eq!(issue.body, "Body text.");
4834 }
4835
4836 #[test]
4837 fn set_status_preserves_file_path() {
4838 let (tmp, store) = make_store();
4839 let _ = tmp;
4840 write_issue(
4841 &store,
4842 "2026-01-01-f.md",
4843 &sample_frontmatter(1, "F", "todo"),
4844 );
4845 let _ = store
4846 .set_status("2026-01-01-f.md", StatusChange::to("done"))
4847 .unwrap();
4848 assert!(store.issues_dir.join("2026-01-01-f.md").exists());
4849 }
4850
4851 fn read_issue_file(store: &Store, filename: &str) -> String {
4857 std::fs::read_to_string(store.issues_dir.join(filename)).unwrap()
4858 }
4859
4860 fn bare_frontmatter(number: u32, title: &str) -> String {
4863 format!(
4864 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
4865 )
4866 }
4867
4868 fn legacy_links_frontmatter(number: u32, title: &str, links: &str) -> String {
4870 format!(
4871 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\nlinks: {links}\npriority: \"medium\"\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody text."
4872 )
4873 }
4874
4875 fn related_pair() -> (TempDir, Store, Issue, Issue) {
4877 let (tmp, store) = make_store();
4878 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
4879 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
4880 let a = store.get(IssueSelector::Number(1)).unwrap();
4881 let b = store.get(IssueSelector::Number(2)).unwrap();
4882 (tmp, store, a, b)
4883 }
4884
4885 #[test]
4886 fn t009_link_records_a_single_directed_edge() {
4887 let (_tmp, store, a, b) = related_pair();
4888 let outcome = store
4889 .link(
4890 &a.number.to_string(),
4891 &b.number.to_string(),
4892 RelationKind::Blocks,
4893 )
4894 .unwrap();
4895 assert!(outcome.changed);
4896
4897 let a_after = store.get(IssueSelector::Number(1)).unwrap();
4900 let b_after = store.get(IssueSelector::Number(2)).unwrap();
4901 assert_eq!(a_after.relationships.len(), 1);
4902 assert!(
4903 b_after.relationships.is_empty(),
4904 "no back-reference is written for a directed edge"
4905 );
4906 assert_eq!(a_after.relationships[0].kind, RelationKind::Blocks);
4907 assert_eq!(
4908 (a_after.relationships[0].from, a_after.relationships[0].to),
4909 (1, 2)
4910 );
4911 }
4912
4913 #[test]
4914 fn t009_link_appends_exactly_one_history_event() {
4915 let (_tmp, store, a, b) = related_pair();
4916 store
4917 .link(
4918 &a.number.to_string(),
4919 &b.number.to_string(),
4920 RelationKind::Blocks,
4921 )
4922 .unwrap();
4923 let a_after = store.get(IssueSelector::Number(1)).unwrap();
4924 assert_eq!(a_after.history.len(), 1);
4925 assert_eq!(
4926 a_after.history.events()[0].kind,
4927 HistoryEventKind::RelationshipAdded
4928 );
4929 assert_ne!(a_after.history.events()[0].actor, "");
4931 }
4932
4933 #[test]
4934 fn t009_idempotent_reapply_writes_nothing_and_appends_no_second_event() {
4935 let (_tmp, store, a, b) = related_pair();
4936 let args = (a.number.to_string(), b.number.to_string());
4937 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4938 let first = store.get(IssueSelector::Number(1)).unwrap();
4939 let bytes_after_first = read_issue_file(&store, &first.filename);
4940
4941 let second = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4942 assert!(!second.changed, "re-applying an existing edge is a no-op");
4943 assert!(second.written.is_none(), "no file is written for a no-op");
4944
4945 let after = store.get(IssueSelector::Number(1)).unwrap();
4946 assert_eq!(after.relationships.len(), 1, "no duplicate edge");
4947 assert_eq!(after.history.len(), 1, "no second History event");
4948 assert_eq!(
4949 read_issue_file(&store, &after.filename),
4950 bytes_after_first,
4951 "the file is byte-identical after the no-op"
4952 );
4953 }
4954
4955 #[test]
4956 fn t009_inverse_spelling_resolves_to_the_same_stored_edge() {
4957 let (_tmp, store, a, b) = related_pair();
4958 let args = (a.number.to_string(), b.number.to_string());
4959 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
4960 let again = store
4962 .link(&args.1, &args.0, RelationKind::BlockedBy)
4963 .unwrap();
4964 assert!(!again.changed, "the inverse spelling must not add an edge");
4965 let after = store.get(IssueSelector::Number(1)).unwrap();
4966 assert_eq!(after.relationships.len(), 1);
4967 assert_eq!(
4968 after.relationships[0].kind,
4969 RelationKind::Blocks,
4970 "the stored kind is always the canonical one"
4971 );
4972 }
4973
4974 #[test]
4975 fn t009_self_link_is_refused_and_writes_nothing() {
4976 let (_tmp, store, a, _b) = related_pair();
4977 let before = read_issue_file(&store, &a.filename);
4978 let err = store
4979 .link(
4980 &a.number.to_string(),
4981 &a.number.to_string(),
4982 RelationKind::Blocks,
4983 )
4984 .unwrap_err();
4985 assert!(matches!(err, TixError::Refused(_)));
4986 assert!(
4987 err.to_string().contains("self_link"),
4988 "the diagnostic names the rule: {err}"
4989 );
4990 assert_eq!(
4991 read_issue_file(&store, &a.filename),
4992 before,
4993 "SC-005: a rejected mutation leaves the file byte-identical"
4994 );
4995 }
4996
4997 #[test]
4998 fn t009_cycle_closing_edge_is_refused_and_writes_nothing() {
4999 let (tmp, store) = make_store();
5000 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
5001 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
5002 write_issue(&store, "2026-01-01-c.md", &bare_frontmatter(3, "C"));
5003 let _ = tmp;
5004 store.link("1", "2", RelationKind::Blocks).unwrap();
5005 store.link("2", "3", RelationKind::Blocks).unwrap();
5006 let c_before = read_issue_file(&store, "2026-01-01-c.md");
5007
5008 let err = store.link("3", "1", RelationKind::Blocks).unwrap_err();
5009 assert!(err.to_string().contains("cycle"), "{err}");
5010 assert_eq!(
5011 read_issue_file(&store, "2026-01-01-c.md"),
5012 c_before,
5013 "SC-005: both files of a rejected two-issue mutation are unchanged"
5014 );
5015 }
5016
5017 #[test]
5018 fn t009_unlink_removes_the_edge_and_is_idempotent() {
5019 let (_tmp, store, a, b) = related_pair();
5020 let args = (a.number.to_string(), b.number.to_string());
5021 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5022
5023 let removed = store
5024 .unlink(&args.0, &args.1, RelationKind::Blocks)
5025 .unwrap();
5026 assert!(removed.changed);
5027 let after = store.get(IssueSelector::Number(1)).unwrap();
5028 assert_eq!(
5029 after.relationships,
5030 [] as [crate::relationships::Relationship; 0]
5031 );
5032 assert_eq!(after.history.len(), 2, "added then removed");
5033
5034 let again = store
5036 .unlink(&args.0, &args.1, RelationKind::Blocks)
5037 .unwrap();
5038 assert!(!again.changed);
5039 let after2 = store.get(IssueSelector::Number(1)).unwrap();
5040 assert_eq!(after2.history.len(), 2, "no third History event");
5041 }
5042
5043 #[test]
5044 fn t009_unlink_of_a_never_linked_pair_is_a_noop() {
5045 let (_tmp, store, a, b) = related_pair();
5046 let outcome = store
5047 .unlink(
5048 &a.number.to_string(),
5049 &b.number.to_string(),
5050 RelationKind::Blocks,
5051 )
5052 .unwrap();
5053 assert!(!outcome.changed);
5054 assert!(outcome.written.is_none());
5055 let after = store.get(IssueSelector::Number(1)).unwrap();
5056 assert_eq!(
5057 after.relationships,
5058 [] as [crate::relationships::Relationship; 0]
5059 );
5060 assert!(after.history.is_empty(), "a no-op appends nothing");
5061 }
5062
5063 #[test]
5064 fn t009_set_relationships_replaces_the_set_and_keeps_surviving_ids() {
5065 let (_tmp, store, _a, _b) = related_pair();
5066 store.link("1", "2", RelationKind::Blocks).unwrap();
5067 let before = store.get(IssueSelector::Number(1)).unwrap();
5068 let survivor_id = before.relationships[0].id.clone();
5069
5070 let updated = store
5072 .set_relationships(&before.filename, &[(2, RelationKind::Blocks)])
5073 .unwrap();
5074 assert_eq!(updated.relationships.len(), 1);
5075 assert_eq!(
5076 updated.relationships[0].id, survivor_id,
5077 "a surviving relationship keeps its stable id"
5078 );
5079 }
5080
5081 #[test]
5082 fn t009_set_relationships_rejects_unknown_target_and_self() {
5083 let (_tmp, store, a, _b) = related_pair();
5084 assert!(
5085 store
5086 .set_relationships(&a.filename, &[(404, RelationKind::Blocks)])
5087 .is_err(),
5088 "an unknown target is refused"
5089 );
5090 assert!(
5091 store
5092 .set_relationships(&a.filename, &[(1, RelationKind::Blocks)])
5093 .is_err(),
5094 "a self-link is refused"
5095 );
5096 }
5097
5098 fn seeded(store: &Store, number: u32, title: &str, extra: &str) {
5104 let slug = title.to_lowercase();
5105 write_issue(
5106 store,
5107 &format!("2026-01-01-{slug}.md"),
5108 &format!(
5109 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\n{extra}files: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n"
5110 ),
5111 );
5112 }
5113
5114 #[test]
5115 fn f1_set_relationships_refuses_a_reciprocal_pair_the_service_rejects() {
5116 let (_tmp, store) = make_store();
5123 seeded(&store, 1, "A", "");
5124 seeded(&store, 2, "B", "");
5125 store.link("1", "2", RelationKind::Blocks).unwrap();
5126
5127 let err = store
5128 .set_relationships("2026-01-01-b.md", &[(1, RelationKind::Blocks)])
5129 .unwrap_err();
5130 assert!(
5131 err.to_string().contains("cycle"),
5132 "the service's verdict is what refuses it: {err}"
5133 );
5134 let two = store.get(IssueSelector::Number(2)).unwrap();
5135 assert!(
5136 two.relationships.is_empty(),
5137 "SC-005: a refused mutation writes nothing"
5138 );
5139 }
5140
5141 #[test]
5142 fn f4_a_hand_ordered_related_row_is_removable() {
5143 let (_tmp, store) = make_store();
5148 seeded(&store, 2, "B", "");
5149 seeded(
5150 &store,
5151 5,
5152 "E",
5153 "relationships:\n- id: rel_handauthored\n from: 5\n to: 2\n kind: related\n display: \"related #2 B\"\n",
5154 );
5155 let out = store.unlink("5", "2", RelationKind::Related).unwrap();
5156 assert!(out.changed, "the row is found and removed");
5157 assert_eq!(
5158 store.get(IssueSelector::Number(5)).unwrap().relationships,
5159 [] as [crate::relationships::Relationship; 0]
5160 );
5161 }
5162
5163 #[test]
5164 fn f5_a_persisted_blocked_by_row_is_removable() {
5165 let (_tmp, store) = make_store();
5172 seeded(&store, 1, "A", "");
5173 seeded(
5174 &store,
5175 2,
5176 "B",
5177 "relationships:\n- id: rel_bb\n from: 2\n to: 1\n kind: blocked_by\n display: \"blocks #1 A\"\n",
5178 );
5179 let out = store.unlink("2", "1", RelationKind::BlockedBy).unwrap();
5180 assert!(out.changed, "the row is found and removed");
5181 assert_eq!(
5182 store.get(IssueSelector::Number(2)).unwrap().relationships,
5183 [] as [crate::relationships::Relationship; 0]
5184 );
5185 }
5186
5187 #[test]
5188 fn f13_a_dangling_edge_is_removable() {
5189 let (_tmp, store) = make_store();
5194 seeded(&store, 1, "A", "");
5195 seeded(&store, 2, "B", "");
5196 store.link("1", "2", RelationKind::Blocks).unwrap();
5197 std::fs::remove_file(store.issues_dir.join("2026-01-01-b.md")).unwrap();
5198
5199 let out = store.unlink("1", "2", RelationKind::Blocks).unwrap();
5200 assert!(out.changed, "a dangling edge is still removable");
5201 let one = store.get(IssueSelector::Number(1)).unwrap();
5202 assert_eq!(
5203 one.relationships,
5204 [] as [crate::relationships::Relationship; 0]
5205 );
5206 assert_eq!(one.history.len(), 2, "and the removal is recorded");
5207 }
5208
5209 #[test]
5210 fn m10_one_unlink_of_a_duplicated_edge_appends_one_event() {
5211 let (_tmp, store) = make_store();
5218 seeded(
5223 &store,
5224 1,
5225 "A",
5226 "relationships:\n- id: rel_dup_12\n from: 1\n to: 2\n kind: related\n display: \"related #2 B\"\n",
5227 );
5228 seeded(
5229 &store,
5230 2,
5231 "B",
5232 "relationships:\n- id: rel_dup_21\n from: 2\n to: 1\n kind: related\n display: \"related #1 A\"\n",
5233 );
5234
5235 let outcome = store.unlink("1", "2", RelationKind::Related).unwrap();
5236 assert!(outcome.changed, "both redundant rows are removed");
5237 let mut summaries = Vec::new();
5238 for number in [1u32, 2] {
5239 let issue = store.get(IssueSelector::Number(number)).unwrap();
5240 assert!(
5241 issue.relationships.is_empty(),
5242 "issue #{number} keeps no edge"
5243 );
5244 assert_eq!(
5245 issue.history.len(),
5246 1,
5247 "issue #{number} records exactly one event, got {:?}",
5248 issue.history.events()
5249 );
5250 let event = &issue.history.events()[0];
5251 assert!(
5252 event.detail.contains("removed 2 relationships"),
5253 "and the event says both spellings went: {}",
5254 event.detail
5255 );
5256 summaries.push(event.detail.clone());
5257 }
5258 assert_eq!(
5262 summaries[0], summaries[1],
5263 "both files record the same single event, not two differing ones"
5264 );
5265 }
5266
5267 #[test]
5268 fn m11_unlink_reports_the_row_it_removed_not_an_empty_placeholder() {
5269 let (_tmp, store, a, b) = related_pair();
5275 let args = (a.number.to_string(), b.number.to_string());
5276 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5277 let before = store.get(IssueSelector::Number(1)).unwrap();
5278 let real_id = before.relationships[0].id.clone();
5279
5280 let outcome = store
5281 .unlink(&args.0, &args.1, RelationKind::Blocks)
5282 .unwrap();
5283 assert!(outcome.changed);
5284 assert_eq!(
5285 outcome.relationship.id, real_id,
5286 "the reported row is the one that was actually removed"
5287 );
5288 assert!(!outcome.relationship.id.is_empty(), "so it is addressable");
5289 assert_eq!(
5290 outcome.relationship.display,
5291 before.relationships[0].display
5292 );
5293 }
5294
5295 #[test]
5296 fn m15_the_history_detail_names_the_relationship_id() {
5297 let (_tmp, store, a, b) = related_pair();
5301 let args = (a.number.to_string(), b.number.to_string());
5302 store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5303 let one = store.get(IssueSelector::Number(1)).unwrap();
5304 let id = one.relationships[0].id.clone();
5305
5306 let detail = &one.history.events()[0].detail;
5307 assert!(
5308 detail.starts_with(&format!("created {id}:")),
5309 "the detail names the stable id: {detail}"
5310 );
5311 assert!(
5312 !detail.contains(&format!("created {}:", RelationKind::Blocks)),
5313 "and does not stutter the kind, which `display` already carries: {detail}"
5314 );
5315 }
5316
5317 #[test]
5318 fn f17_all_diagnostics_are_reported_not_just_the_first() {
5319 let (_tmp, store) = make_store();
5331 seeded(
5332 &store,
5333 1,
5334 "A",
5335 "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",
5336 );
5337 seeded(
5338 &store,
5339 2,
5340 "B",
5341 "relationships:\n- id: rel_b21\n from: 2\n to: 1\n kind: blocks\n display: blocks\n",
5342 );
5343
5344 let issues = store.list().unwrap();
5345 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
5346 let verdict = RelationshipGraph::new(&views, "done").validate_snapshot();
5347 assert!(
5348 verdict.errors.len() >= 2,
5349 "the fixture must really produce several diagnostics, got {:?}",
5350 verdict.errors
5351 );
5352
5353 let rendered = relationship_error(&verdict.errors).to_string();
5354 for kind in ["conflicting_edge", "cycle"] {
5355 assert!(
5356 rendered.contains(kind),
5357 "every diagnostic must be reported; {kind} missing from: {rendered}"
5358 );
5359 }
5360 assert!(
5361 !rendered.contains("conflicting_edge: conflicting_edge")
5362 && !rendered.contains("cycle: cycle:"),
5363 "no doubled kind prefix: {rendered}"
5364 );
5365 let dup = vec![
5371 Diagnostic::new(
5372 DiagnosticKind::SelfLink,
5373 "to",
5374 "#1 cannot block itself: a self-link is not a valid relationship",
5375 ),
5376 Diagnostic::new(
5377 DiagnosticKind::SelfLink,
5378 "to",
5379 "#1 cannot block itself: a self-link is not a valid relationship",
5380 ),
5381 Diagnostic::new(DiagnosticKind::Cycle, "to", "cycle: 1 -> 1 -> #1"),
5382 ];
5383 let rendered = relationship_error(&dup).to_string();
5384 assert_eq!(
5385 rendered.matches("cannot block itself").count(),
5386 1,
5387 "an identical diagnostic renders once, not twice: {rendered}"
5388 );
5389 assert!(
5390 rendered.contains("cycle"),
5391 "a distinct diagnostic is still reported: {rendered}"
5392 );
5393 assert!(
5394 !rendered.contains("self_link: self_link") && !rendered.contains("cycle: cycle:"),
5395 "and no kind is prefixed twice: {rendered}"
5396 );
5397 }
5398
5399 #[test]
5400 fn t009_clearing_a_set_removes_a_related_edge_owned_by_another_file() {
5401 let (_tmp, store) = make_store();
5406 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(3, "A"));
5407 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(5, "B"));
5408
5409 store.link("5", "3", RelationKind::Related).unwrap();
5410 let three = store.get(IssueSelector::Number(3)).unwrap();
5411 let five = store.get(IssueSelector::Number(5)).unwrap();
5412 assert_eq!(
5413 three.relationships.len(),
5414 1,
5415 "the edge is stored in the lower-numbered issue's file"
5416 );
5417 assert!(
5418 five.relationships.is_empty(),
5419 "and #5's own file holds none"
5420 );
5421
5422 store.set_relationships(&five.filename, &[]).unwrap();
5423
5424 let three_after = store.get(IssueSelector::Number(3)).unwrap();
5425 let five_after = store.get(IssueSelector::Number(5)).unwrap();
5426 assert!(
5427 three_after.relationships.is_empty(),
5428 "the association is gone from the file that actually stored it"
5429 );
5430 assert_eq!(
5431 five_after.relationships,
5432 [] as [crate::relationships::Relationship; 0]
5433 );
5434 }
5435
5436 #[test]
5437 fn t009_clearing_a_set_does_not_remove_an_inbound_directional_edge() {
5438 let (_tmp, store) = make_store();
5443 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(3, "A"));
5444 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(5, "B"));
5445
5446 store.link("3", "5", RelationKind::Blocks).unwrap();
5447 let five = store.get(IssueSelector::Number(5)).unwrap();
5448
5449 store.set_relationships(&five.filename, &[]).unwrap();
5450
5451 let three_after = store.get(IssueSelector::Number(3)).unwrap();
5452 assert!(
5453 three_after
5454 .relationships
5455 .iter()
5456 .any(|r| r.to == 5 && r.kind == RelationKind::Blocks),
5457 "#3's declaration that it blocks #5 survives: a blocked issue \
5458 cannot unilaterally clear the blocker's graph"
5459 );
5460 }
5461
5462 fn relationship_fixture(name: &str) -> String {
5464 format!(
5465 "{}/tests/fixtures/relationships/{name}",
5466 env!("CARGO_MANIFEST_DIR")
5467 )
5468 }
5469
5470 #[test]
5471 fn t010_typed_fixture_round_trips_byte_stable_including_history() {
5472 let original =
5475 std::fs::read_to_string(relationship_fixture("typed-relationships.md")).unwrap();
5476 let issue = Issue::parse(&original).unwrap();
5477 assert_eq!(issue.relationships.len(), 2);
5478 assert_eq!(issue.history.len(), 2);
5479 let rendered = issue.to_file_contents();
5480 assert_eq!(
5481 rendered, original,
5482 "a typed fixture must not churn on a read/write round trip"
5483 );
5484 assert_eq!(
5485 Issue::parse(&rendered).unwrap().to_file_contents(),
5486 rendered
5487 );
5488 assert_eq!(issue.relationships[0].id, "rel_01JQ8Z4W2M6N7P9R3S5T7V9X1");
5490 assert!(
5491 rendered.find("## History").unwrap() < rendered.find("Prose that follows").unwrap(),
5492 "History sits between the frontmatter and the body"
5493 );
5494 }
5495
5496 #[test]
5497 fn t010_issue_with_no_relationship_data_is_untouched() {
5498 let original =
5499 std::fs::read_to_string(relationship_fixture("no-relationship-data.md")).unwrap();
5500 let issue = Issue::parse(&original).unwrap();
5501 assert_eq!(
5502 issue.relationships,
5503 [] as [crate::relationships::Relationship; 0]
5504 );
5505 assert!(issue.history.is_empty());
5506 assert!(!issue.has_retired_links_key);
5507 let rendered = issue.to_file_contents();
5508 assert_eq!(
5509 rendered, original,
5510 "an issue with no relationship data must not churn"
5511 );
5512 assert!(!rendered.contains("relationships:"));
5513 assert!(!rendered.contains("## History"));
5514 }
5515
5516 #[test]
5517 fn t010_an_empty_history_section_is_dropped_not_preserved() {
5518 let original = std::fs::read_to_string(relationship_fixture("history-empty.md")).unwrap();
5523 assert!(
5524 original.contains("## History"),
5525 "the fixture must actually carry the empty heading"
5526 );
5527 let issue = Issue::parse(&original).unwrap();
5528 assert!(
5529 issue.history.is_empty(),
5530 "a heading with no entries parses to an empty log"
5531 );
5532 assert!(
5533 issue
5534 .body
5535 .contains("An issue whose History section is present but empty."),
5536 "the prose after the heading is body, not a dropped event: {:?}",
5537 issue.body
5538 );
5539 let rendered = issue.to_file_contents();
5540 assert!(
5541 !rendered.contains("## History"),
5542 "the empty section is omitted rather than kept as a bare heading"
5543 );
5544 assert!(rendered.contains("An issue whose History section is present but empty."));
5545 }
5546
5547 #[test]
5548 fn t010_issue_with_no_history_section_is_untouched() {
5549 let original = std::fs::read_to_string(relationship_fixture("history-absent.md")).unwrap();
5550 let issue = Issue::parse(&original).unwrap();
5551 assert!(issue.history.is_empty());
5552 let rendered = issue.to_file_contents();
5553 assert_eq!(
5554 rendered, original,
5555 "a file written before History existed must not gain an empty section"
5556 );
5557 }
5558
5559 #[test]
5560 fn f2_one_bad_history_line_does_not_make_the_store_unreadable() {
5561 let (_tmp, store) = make_store();
5570 write_issue(&store, "2026-01-01-a.md", &bare_frontmatter(1, "A"));
5571 write_issue(
5572 &store,
5573 "2026-01-01-c.md",
5574 "---\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",
5575 );
5576
5577 let all = store.list().expect("the store must remain listable");
5578 assert_eq!(all.len(), 2, "both issues load");
5579 assert_eq!(
5580 store
5581 .get(IssueSelector::Number(1))
5582 .expect("an unrelated, valid issue must stay readable")
5583 .title,
5584 "A"
5585 );
5586 let three = store.get(IssueSelector::Number(3)).unwrap();
5592 assert!(three.history.events().is_empty(), "no event is fabricated");
5593 assert_eq!(
5594 three.history.unparsed().len(),
5595 1,
5596 "the line is preserved for diagnosis: {:?}",
5597 three.history.unparsed()
5598 );
5599 assert!(
5600 !three.body.contains("ascii hyphens"),
5601 "and is not demoted into the body: {:?}",
5602 three.body
5603 );
5604 }
5605
5606 #[cfg(unix)]
5609 fn seeded_in_segment(store: &Store, number: u32, title: &str, segment: &str) {
5610 let dir = store.issues_dir.join(segment);
5611 std::fs::create_dir_all(&dir).unwrap();
5612 std::fs::write(
5613 dir.join(format!("2026-01-01-{}.md", title.to_lowercase())),
5614 format!(
5615 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\npriority: \"medium\"\nfiles: []\ncreated: \"2026-01-01\"\nupdated: \"2026-01-01\"\n---\n\nBody.\n"
5616 ),
5617 )
5618 .unwrap();
5619 }
5620
5621 #[cfg(unix)]
5622 #[test]
5623 fn f6_a_mid_sequence_write_failure_rolls_every_file_back() {
5624 use std::os::unix::fs::PermissionsExt;
5625 let (tmp, store) = make_store();
5634 let _ = tmp;
5635 seeded_in_segment(&store, 1, "A", "alice");
5636 seeded_in_segment(&store, 2, "B", "bob");
5637 seeded_in_segment(&store, 3, "C", "carol");
5638 seeded_in_segment(&store, 5, "E", "erin");
5639
5640 let paths: Vec<std::path::PathBuf> = [
5641 "alice/2026-01-01-a.md",
5642 "bob/2026-01-01-b.md",
5643 "carol/2026-01-01-c.md",
5644 ]
5645 .iter()
5646 .map(|p| store.issues_dir.join(p))
5647 .collect();
5648 let before: Vec<String> = paths
5649 .iter()
5650 .map(|p| std::fs::read_to_string(p).unwrap())
5651 .collect();
5652
5653 let carol = store.issues_dir.join("carol");
5656 let mut perms = std::fs::metadata(&carol).unwrap().permissions();
5657 perms.set_mode(0o555);
5658 std::fs::set_permissions(&carol, perms).unwrap();
5659
5660 let result = store.set_relationships(
5661 "carol/2026-01-01-c.md",
5662 &[
5663 (1, RelationKind::Related),
5664 (2, RelationKind::Related),
5665 (5, RelationKind::Related),
5666 ],
5667 );
5668
5669 let mut perms = std::fs::metadata(&carol).unwrap().permissions();
5670 perms.set_mode(0o755);
5671 std::fs::set_permissions(&carol, perms).unwrap();
5672
5673 assert!(result.is_err(), "the blocked write must fail");
5674 for (path, previous) in paths.iter().zip(before.iter()) {
5675 assert_eq!(
5676 std::fs::read_to_string(path).unwrap(),
5677 *previous,
5678 "{} was left mutated by a failed mutation",
5679 path.display()
5680 );
5681 }
5682 }
5683
5684 #[cfg(unix)]
5685 #[test]
5686 fn f6b_the_updated_bump_is_itself_rollback_covered() {
5687 use std::os::unix::fs::PermissionsExt;
5697
5698 let (tmp, store) = make_store();
5699 let _ = tmp;
5700 seeded_in_segment(&store, 1, "A", "alice");
5701 seeded_in_segment(&store, 3, "C", "carol");
5702
5703 let alice = store.issues_dir.join("alice/2026-01-01-a.md");
5704 let before = std::fs::read_to_string(&alice).unwrap();
5705
5706 let carol = store.issues_dir.join("carol");
5707 let mut perms = std::fs::metadata(&carol).unwrap().permissions();
5708 perms.set_mode(0o555);
5709 std::fs::set_permissions(&carol, perms).unwrap();
5710
5711 let result =
5712 store.set_relationships("carol/2026-01-01-c.md", &[(1, RelationKind::Related)]);
5713
5714 let mut perms = std::fs::metadata(&carol).unwrap().permissions();
5715 perms.set_mode(0o755);
5716 std::fs::set_permissions(&carol, perms).unwrap();
5717
5718 assert!(result.is_err(), "the blocked bump must fail the call");
5719 assert_eq!(
5720 std::fs::read_to_string(&alice).unwrap(),
5721 before,
5722 "the relationship write that succeeded earlier must be rolled back \
5723 when the subsequent `updated` bump fails"
5724 );
5725 }
5726
5727 #[test]
5728 fn f9_relationships_for_reports_both_endpoints_of_a_one_sided_edge() {
5729 let (_tmp, store) = make_store();
5735 seeded(&store, 1, "A", "");
5736 seeded(&store, 2, "B", "");
5737 store.link("2", "1", RelationKind::Related).unwrap();
5738
5739 let one = store.get(IssueSelector::Number(1)).unwrap();
5741 let two = store.get(IssueSelector::Number(2)).unwrap();
5742 assert_eq!(one.relationships.len(), 1, "precondition: #1 stores it");
5743 assert!(
5744 two.relationships.is_empty(),
5745 "precondition: #2's own field is the partial view that caused F9"
5746 );
5747
5748 for number in [1u32, 2] {
5751 let resolved = store.relationships_for(number).unwrap();
5752 assert_eq!(
5753 resolved.len(),
5754 1,
5755 "issue #{number} must see the relationship that touches it"
5756 );
5757 assert_eq!(resolved[0].kind, RelationKind::Related);
5758 }
5759 }
5760
5761 #[test]
5762 fn f7_writing_an_issue_with_the_retired_key_discloses_that_it_is_removed() {
5763 let (tmp, store) = make_store();
5768 let _ = tmp;
5769 let _ = take_disclosures();
5770 write_issue(
5775 &store,
5776 "2026-01-01-legacy.md",
5777 &legacy_links_frontmatter(1, "Legacy", "['7']"),
5778 );
5779 let issue = store.get(IssueSelector::Number(1)).unwrap();
5780 assert!(
5781 issue.has_retired_links_key,
5782 "precondition: the key is present"
5783 );
5784
5785 store.update(&issue.filename, &issue).unwrap();
5786
5787 let disclosures = take_disclosures();
5789 assert_eq!(
5790 disclosures.len(),
5791 1,
5792 "the write must disclose that it dropped the key: {disclosures:?}"
5793 );
5794 let line = &disclosures[0];
5795 assert!(
5796 line.contains("`links:`"),
5797 "it names what was removed: {line}"
5798 );
5799 assert!(
5803 line.starts_with("remove `links:` by hand"),
5804 "and leads with what to do about it: {line}"
5805 );
5806 assert!(
5807 line.contains(&issue.filename),
5808 "and which file it was removed from: {line}"
5809 );
5810 assert!(
5811 line.contains("C-001"),
5812 "and that no migration was applied, so the user is not left \
5813 wondering where their data went: {line}"
5814 );
5815
5816 let after = read_issue_file(&store, &issue.filename);
5818 assert!(
5819 !after.contains("links:"),
5820 "the key is not re-emitted (C-001: no migration)"
5821 );
5822 let reread = store.get(IssueSelector::Number(1)).unwrap();
5825 assert!(!reread.has_retired_links_key);
5826 assert!(
5827 take_disclosures().is_empty(),
5828 "and it is disclosed once, not on every read"
5829 );
5830 }
5831
5832 #[test]
5833 fn f7b_writing_an_issue_without_the_retired_key_is_silent() {
5834 let (tmp, store) = make_store();
5837 let _ = tmp;
5838 let _ = take_disclosures();
5839 write_issue(&store, "2026-01-01-plain.md", &bare_frontmatter(1, "Plain"));
5840 let issue = store.get(IssueSelector::Number(1)).unwrap();
5841 assert!(!issue.has_retired_links_key);
5842 store.update(&issue.filename, &issue).unwrap();
5843 assert!(
5844 take_disclosures().is_empty(),
5845 "an ordinary write discloses nothing"
5846 );
5847 }
5848
5849 #[test]
5850 fn t010_legacy_fixture_reports_the_retired_key_and_yields_no_edges() {
5851 let original =
5854 std::fs::read_to_string(relationship_fixture("legacy-links-key.md")).unwrap();
5855 let issue = Issue::parse(&original).unwrap();
5856 assert!(issue.has_retired_links_key, "the retired key is reported");
5857 assert!(
5858 issue.relationships.is_empty(),
5859 "C-001: legacy values are never reinterpreted as typed edges"
5860 );
5861 assert!(
5862 issue.history.is_empty(),
5863 "a legacy file gains no History section from parsing"
5864 );
5865 let (_tmp, store) = make_store();
5869 write_issue(&store, "2026-01-01-legacy.md", &original);
5870 let findings = crate::commands::lint::collect(&store.config, &store).unwrap();
5871 let retired: Vec<_> = findings
5872 .iter()
5873 .filter(|f| f.kind == "retired_links_key")
5874 .collect();
5875 assert_eq!(
5876 retired.len(),
5877 1,
5878 "one finding for the retired key: {findings:?}"
5879 );
5880 assert_eq!(retired[0].value, "links", "the finding names the field");
5881 assert!(
5882 retired[0].message.contains("retired"),
5883 "the message says why: {}",
5884 retired[0].message
5885 );
5886 assert!(
5887 retired[0].line > 0,
5888 "the finding points into the frontmatter"
5889 );
5890 let rewritten = issue.to_file_contents();
5892 assert!(!rewritten.contains("kind: related"));
5893 assert!(!rewritten.contains("kind: blocks"));
5894 assert!(!rewritten.contains("links:"));
5895 }
5896
5897 #[test]
5898 fn c001_a_legacy_links_key_is_reported_and_yields_no_typed_edges() {
5899 let (tmp, store) = make_store();
5900 write_issue(
5901 &store,
5902 "2026-01-01-legacy.md",
5903 &legacy_links_frontmatter(1, "Legacy", "[\"12\", \"47\"]"),
5904 );
5905 let _ = tmp;
5906 let issue = store.get(IssueSelector::Number(1)).unwrap();
5907 assert!(
5908 issue.has_retired_links_key,
5909 "the retired key is recorded as a validation result"
5910 );
5911 assert!(
5912 issue.relationships.is_empty(),
5913 "C-001: legacy values are never read as typed edges"
5914 );
5915 }
5916
5917 #[test]
5918 fn c001_there_is_no_migration_path_from_the_retired_key() {
5919 let (tmp, store) = make_store();
5928 let _ = tmp;
5929 write_issue(
5930 &store,
5931 "2026-01-01-legacy.md",
5932 &legacy_links_frontmatter(1, "Legacy", "[\"12\", \"47\"]"),
5933 );
5934 let issue = store.get(IssueSelector::Number(1)).unwrap();
5935 assert!(
5936 issue.has_retired_links_key,
5937 "precondition: the key is present"
5938 );
5939 assert!(
5940 issue.relationships.is_empty(),
5941 "C-001: legacy values are never read as typed edges"
5942 );
5943
5944 store.update(&issue.filename, &issue).unwrap();
5945 let written = read_issue_file(&store, &issue.filename);
5946
5947 let reparsed = store.get(IssueSelector::Number(1)).unwrap();
5956 assert!(
5957 reparsed.relationships.is_empty(),
5958 "the file on disk parses to zero typed edges, not just the in-memory copy"
5959 );
5960 for kind in ["blocks", "blocked_by", "duplicates", "related"] {
5961 assert!(
5962 !written.contains(&format!("kind: {kind}")),
5963 "a legacy link became a `{kind}` edge:\n{written}"
5964 );
5965 }
5966 assert!(
5967 !written.contains("kind:"),
5968 "no typed edge was written at all"
5969 );
5970 assert!(!written.contains("links:"), "the key is not re-emitted");
5973 let reread = store.get(IssueSelector::Number(1)).unwrap();
5974 assert!(
5975 reread.history.is_empty(),
5976 "a format change is not a relationship mutation, so it records no event"
5977 );
5978 }
5979
5980 #[test]
5981 fn f15_the_idempotent_no_op_reports_the_real_addressable_row() {
5982 let (_tmp, store, a, b) = related_pair();
5988 let args = (a.number.to_string(), b.number.to_string());
5989 let first = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5990 let second = store.link(&args.0, &args.1, RelationKind::Blocks).unwrap();
5991
5992 assert!(first.changed);
5993 assert!(!second.changed, "the second apply is a no-op");
5994 assert_eq!(
5995 second.relationship.id, first.relationship.id,
5996 "the no-op reports the real, addressable row"
5997 );
5998 assert!(
5999 !second.relationship.id.is_empty(),
6000 "an empty id is not addressable"
6001 );
6002 assert_eq!(second.relationship.display, first.relationship.display);
6003 }
6004
6005 #[test]
6006 fn f16_clearing_a_set_bumps_updated_on_the_named_issue() {
6007 let (_tmp, store, _a, _b) = related_pair();
6012 store.link("2", "1", RelationKind::Related).unwrap();
6013 let before = store.get(IssueSelector::Number(2)).unwrap();
6014 let updated_before = before.updated.clone();
6015
6016 store.set_relationships(&before.filename, &[]).unwrap();
6017
6018 let after = store.get(IssueSelector::Number(2)).unwrap();
6019 assert_ne!(
6020 after.updated, updated_before,
6021 "the named issue's relationship set changed, so `updated` must move"
6022 );
6023 }
6024
6025 #[test]
6026 fn c002_an_issue_with_no_relationship_data_round_trips_unchanged() {
6027 let (tmp, store) = make_store();
6028 let _ = tmp;
6029 write_issue(&store, "2026-01-01-plain.md", &bare_frontmatter(1, "Plain"));
6034 let issue = store.get(IssueSelector::Number(1)).unwrap();
6035 let first = issue.to_file_contents();
6036 assert!(
6037 !first.contains("relationships:"),
6038 "the empty typed field is omitted, not written as `[]`"
6039 );
6040 assert!(
6041 !first.contains("## History"),
6042 "an empty History section is omitted entirely"
6043 );
6044 let reparsed = Issue::parse(&first).unwrap();
6047 let second = reparsed.to_file_contents();
6048 assert_eq!(second, first);
6049 }
6050
6051 #[test]
6052 fn c002_history_section_round_trips_byte_stable() {
6053 let (tmp, store) = make_store();
6054 write_issue(&store, "2026-01-01-h.md", &bare_frontmatter(1, "H"));
6055 let _ = tmp;
6056 let mut issue = store.get(IssueSelector::Number(1)).unwrap();
6057 issue.history.append_now(
6058 "alice",
6059 HistoryEventKind::RelationshipAdded,
6060 "created blocks: blocks #2 B",
6061 );
6062 issue.history.append_now(
6063 "bob",
6064 HistoryEventKind::BlockedMoveAcknowledged,
6065 "acknowledged 1 open blocker",
6066 );
6067 let first = issue.to_file_contents();
6068 assert!(first.contains("## History"), "{first}");
6069
6070 let reparsed = Issue::parse(&first).unwrap();
6072 assert_eq!(reparsed.to_file_contents(), first);
6073 assert_eq!(reparsed.history.len(), 2);
6074 assert_eq!(reparsed.history.events()[0].actor, "alice");
6075 assert_eq!(
6076 reparsed.history.events()[1].kind,
6077 HistoryEventKind::BlockedMoveAcknowledged
6078 );
6079 assert!(
6080 !reparsed.body.contains("History"),
6081 "the section is stripped from the body: {:?}",
6082 reparsed.body
6083 );
6084 }
6085
6086 #[test]
6087 fn f1_a_hand_edit_last_in_the_section_stays_in_it() {
6088 let (tmp, store) = make_store();
6107 let _ = tmp;
6108 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";
6109 write_issue(&store, "2026-01-01-a.md", original);
6110
6111 let before = store.get(IssueSelector::Number(1)).unwrap();
6114 assert_eq!(
6115 before.history.events().len(),
6116 1,
6117 "precondition: the real event parsed"
6118 );
6119 assert_eq!(
6120 before.history.unparsed(),
6121 vec![" - my hand edit"],
6122 "precondition: the hand edit is inside the section"
6123 );
6124 assert_eq!(before.body, "", "precondition: body is empty");
6125
6126 let mut moved = before.clone();
6128 moved.status = crate::model::Status::new("in progress");
6129 store.update(&moved.filename, &moved).unwrap();
6130
6131 let after = store.get(IssueSelector::Number(1)).unwrap();
6132 assert_eq!(
6133 after.history.unparsed(),
6134 vec![" - my hand edit"],
6135 "the hand edit is still in the audit log, not demoted to the body"
6136 );
6137 assert_eq!(
6138 after.history.events().len(),
6139 1,
6140 "and the real event survived"
6141 );
6142 assert_eq!(after.body, "", "nothing leaked into the body");
6143
6144 store
6147 .link("1", "2", RelationKind::Related)
6148 .ok()
6149 .or_else(|| {
6150 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
6151 store.link("1", "2", RelationKind::Related).ok()
6152 });
6153 let linked = store.get(IssueSelector::Number(1)).unwrap();
6154 let rendered = linked.to_file_contents();
6155 assert!(
6156 rendered.contains("## History"),
6157 "the heading survived an unrelated write: {rendered}"
6158 );
6159 let hand = rendered
6160 .find("my hand edit")
6161 .expect("the hand edit is present");
6162 let newest = rendered
6163 .find("relationship_added")
6164 .expect("the new event is present");
6165 assert!(
6166 newest < hand,
6167 "the new event is appended AFTER the pre-existing content, so file \
6168 order is still append order: {rendered}"
6169 );
6170 }
6171
6172 #[test]
6173 fn f1_but_a_bare_paragraph_at_end_of_section_is_still_prose() {
6174 let (tmp, store) = make_store();
6179 let _ = tmp;
6180 write_issue(
6181 &store,
6182 "2026-01-01-a.md",
6183 "---\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",
6184 );
6185 let issue = store.get(IssueSelector::Number(1)).unwrap();
6186 assert_eq!(issue.history.events().len(), 1, "the real event parsed");
6187 assert!(
6188 issue.history.unparsed().is_empty(),
6189 "the paragraph is not in the audit log: {:?}",
6190 issue.history.unparsed()
6191 );
6192 assert!(
6193 issue.body.contains("just a closing paragraph"),
6194 "it is body prose: {:?}",
6195 issue.body
6196 );
6197 }
6198
6199 #[test]
6200 fn f1_a_hand_edit_survives_body_prose_and_a_later_body_replacement() {
6201 let (_tmp, store) = make_store();
6216 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
6217 let hand = "- 2026-02-02T00:00:00Z - bob - relationship_removed - by hand";
6218 write_issue(
6219 &store,
6220 "2026-01-01-a.md",
6221 &format!(
6222 "---\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"
6223 ),
6224 );
6225
6226 let before = store.get(IssueSelector::Number(1)).unwrap();
6227 assert_eq!(before.history.events().len(), 1, "the real event parsed");
6228 assert_eq!(
6229 before.history.unparsed(),
6230 vec![hand],
6231 "the hand-edited entry is retained even though body prose follows"
6232 );
6233 assert!(
6234 before.body.contains("Some body prose."),
6235 "and the prose is still body text: {:?}",
6236 before.body
6237 );
6238
6239 let mut moved = before.clone();
6241 moved.status = crate::model::Status::new("in progress");
6242 store.update(&moved.filename, &moved).unwrap();
6243 assert_eq!(
6244 store
6245 .get(IssueSelector::Number(1))
6246 .unwrap()
6247 .history
6248 .unparsed(),
6249 vec![hand],
6250 "a `tix move` keeps it in the audit log"
6251 );
6252
6253 let mut replaced = store.get(IssueSelector::Number(1)).unwrap();
6256 replaced.body = "Completely new body text.".to_string();
6257 store.update(&replaced.filename, &replaced).unwrap();
6258 let after = store.get(IssueSelector::Number(1)).unwrap();
6259 assert_eq!(
6260 after.history.unparsed(),
6261 vec![hand],
6262 "the hand-edited entry survives a wholesale body replacement"
6263 );
6264 assert_eq!(after.body, "Completely new body text.");
6265
6266 store.link("1", "2", RelationKind::Related).unwrap();
6268 let rendered = store
6269 .get(IssueSelector::Number(1))
6270 .unwrap()
6271 .to_file_contents();
6272 assert!(
6273 rendered.contains("## History"),
6274 "the heading survived: {rendered}"
6275 );
6276 let hand_at = rendered.find("by hand").expect("the hand edit is present");
6277 let new_at = rendered
6278 .find("created rel_")
6279 .expect("the new event is present");
6280 assert!(
6281 new_at > hand_at,
6282 "the new event is appended AFTER the pre-existing content, so file \
6283 order is still append order: {rendered}"
6284 );
6285 }
6286
6287 #[test]
6288 fn f1_a_well_formed_star_marked_entry_is_retained_with_body_prose_after_it() {
6289 let (_tmp, store) = make_store();
6299 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
6300 let hand =
6301 "* 2026-02-02T00:00:00Z \u{2014} bob \u{2014} relationship_removed \u{2014} by hand";
6302 write_issue(
6303 &store,
6304 "2026-01-01-a.md",
6305 &format!(
6306 "---\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"
6307 ),
6308 );
6309
6310 let before = store.get(IssueSelector::Number(1)).unwrap();
6311 assert_eq!(before.history.events().len(), 1, "the real event parsed");
6312 assert_eq!(
6313 before.history.unparsed(),
6314 vec![hand],
6315 "a well-formed `*` entry is retained even with body prose after it"
6316 );
6317 assert!(
6318 before.body.contains("Some body prose."),
6319 "and the prose is still body text: {:?}",
6320 before.body
6321 );
6322
6323 let mut replaced = before.clone();
6325 replaced.body = "Completely new body text.".to_string();
6326 store.update(&replaced.filename, &replaced).unwrap();
6327 assert_eq!(
6328 store
6329 .get(IssueSelector::Number(1))
6330 .unwrap()
6331 .history
6332 .unparsed(),
6333 vec![hand],
6334 "it survives a wholesale body replacement"
6335 );
6336
6337 store.link("1", "2", RelationKind::Related).unwrap();
6338 let rendered = store
6339 .get(IssueSelector::Number(1))
6340 .unwrap()
6341 .to_file_contents();
6342 assert_eq!(
6343 rendered.matches("## History").count(),
6344 1,
6345 "still exactly one section: {rendered}"
6346 );
6347 let hand_at = rendered.find("by hand").expect("the hand edit is present");
6348 let new_at = rendered
6349 .find("created rel_")
6350 .expect("the new event is present");
6351 assert!(
6352 new_at > hand_at,
6353 "appended after, so order is append order: {rendered}"
6354 );
6355 }
6356
6357 #[test]
6358 fn f1_a_three_field_ascii_hyphen_entry_is_retained_with_body_prose_after_it() {
6359 let (_tmp, store) = make_store();
6365 let hand = "- 2026-02-02T00:00:00Z - bob - relationship_removed";
6366 write_issue(
6367 &store,
6368 "2026-01-01-a.md",
6369 &format!(
6370 "---\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"
6371 ),
6372 );
6373
6374 let twin = Issue::parse(
6377 "---\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",
6378 )
6379 .unwrap();
6380 assert_eq!(
6381 twin.history.events().len(),
6382 1,
6383 "three fields with em-dashes is a valid event, detail optional"
6384 );
6385
6386 let before = store.get(IssueSelector::Number(1)).unwrap();
6387 assert_eq!(
6388 before.history.unparsed(),
6389 vec![hand],
6390 "so its ASCII-hyphen twin is retained, not demoted"
6391 );
6392 let mut replaced = before.clone();
6393 replaced.body = "Completely new body text.".to_string();
6394 store.update(&replaced.filename, &replaced).unwrap();
6395 assert_eq!(
6396 store
6397 .get(IssueSelector::Number(1))
6398 .unwrap()
6399 .history
6400 .unparsed(),
6401 vec![hand],
6402 "and survives a body replacement"
6403 );
6404 }
6405
6406 #[test]
6407 fn f1_a_heading_with_trailing_spaces_is_still_the_heading() {
6408 let (_tmp, store) = make_store();
6414 write_issue(&store, "2026-01-01-b.md", &bare_frontmatter(2, "B"));
6415 for (label, heading) in [
6416 ("plain", "## History"),
6417 ("trailing spaces", "## History "),
6418 ] {
6419 write_issue(
6420 &store,
6421 "2026-01-01-a.md",
6422 &format!(
6423 "---\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"
6424 ),
6425 );
6426 let before = store.get(IssueSelector::Number(1)).unwrap();
6427 assert_eq!(
6428 before.history.events().len(),
6429 1,
6430 "{label}: the section is recognised"
6431 );
6432 store.link("1", "2", RelationKind::Related).unwrap();
6433 let rendered = store
6434 .get(IssueSelector::Number(1))
6435 .unwrap()
6436 .to_file_contents();
6437 assert_eq!(
6438 rendered.matches("## History").count(),
6439 1,
6440 "{label}: exactly one section after a write, got {rendered}"
6441 );
6442 assert!(
6443 !rendered.lines().any(|l| l != l.trim_end()),
6444 "{label}: no line keeps trailing whitespace, so the heading is \
6445 normalised: {rendered}"
6446 );
6447 }
6448 }
6449
6450 #[test]
6451 fn set_status_discloses_the_retired_key_rather_than_dropping_it_silently() {
6452 let (_tmp, store) = make_store();
6459 write_issue(
6460 &store,
6461 "2026-01-01-a.md",
6462 "---\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",
6463 );
6464 let before = store.get(IssueSelector::Number(1)).unwrap();
6465 assert!(
6466 before.has_retired_links_key,
6467 "precondition: the file carries the retired key"
6468 );
6469 let _ = crate::storage::take_disclosures();
6470
6471 store
6472 .set_status("2026-01-01-a.md", StatusChange::to("in progress"))
6473 .unwrap();
6474
6475 let disclosures = crate::storage::take_disclosures();
6476 assert_eq!(
6477 disclosures.len(),
6478 1,
6479 "set_status must disclose the removal, got {disclosures:?}"
6480 );
6481 assert!(
6482 disclosures[0].contains("links:") && disclosures[0].contains("a.md"),
6483 "naming the field and the file: {disclosures:?}"
6484 );
6485 assert!(
6486 !store
6487 .get(IssueSelector::Number(1))
6488 .unwrap()
6489 .has_retired_links_key,
6490 "and the key is gone, as C-001 intends"
6491 );
6492 }
6493
6494 #[test]
6495 fn f1_but_ordinary_prose_bullets_still_end_the_section() {
6496 let (_tmp, store) = make_store();
6501 write_issue(
6502 &store,
6503 "2026-01-01-a.md",
6504 concat!(
6505 "---\nnumber: 1\ntitle: \"A\"\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\n",
6506 "created: 2026-01-01\nupdated: 2026-01-01\n---\n\n## History\n\n",
6507 "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} relationship_added \u{2014} prior\n\n",
6508 "- step one\n- step two\n",
6509 ),
6510 );
6511 let issue = store.get(IssueSelector::Number(1)).unwrap();
6512 assert_eq!(
6513 issue.history.events().len(),
6514 1,
6515 "the real event is an event"
6516 );
6517 assert!(
6518 issue.history.unparsed().is_empty(),
6519 "ordinary bullets are not retained: {:?}",
6520 issue.history.unparsed()
6521 );
6522 assert!(
6523 issue.body.contains("step one") && issue.body.contains("step two"),
6524 "they stay body text: {:?}",
6525 issue.body
6526 );
6527 }
6528
6529 #[test]
6530 fn a_year_is_not_an_ordered_list_marker() {
6531 for prose in [
6535 "2026. Q1 goals",
6536 "2026. roadmap review",
6537 "007. padded",
6538 "99999999999999999999. big",
6539 ] {
6540 assert!(
6541 !crate::model::looks_like_an_unrecognised_entry(prose)
6542 && Issue::parse(&format!(
6543 "---\nnumber: 1\ntitle: T\nstatus: todo\n---\n\n## History\n\n{prose}\n"
6544 ))
6545 .unwrap()
6546 .history
6547 .is_empty(),
6548 "{prose:?} must be body prose, not a retained entry"
6549 );
6550 }
6551 }
6552
6553 #[test]
6554 fn f2_a_recognised_entry_with_a_bad_kind_keeps_its_trailing_whitespace() {
6555 let line = " - 2026-01-01T00:00:00Z \u{2014} alice \u{2014} bogus_kind \u{2014} d ";
6562 let raw = format!(
6563 "---\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"
6564 );
6565 let issue = Issue::parse(&raw).unwrap();
6566 assert!(issue.history.events().is_empty(), "no event was fabricated");
6567 assert_eq!(
6568 issue.history.unparsed(),
6569 vec![line],
6570 "kept byte-for-byte, trailing spaces included"
6571 );
6572
6573 let rendered = issue.to_file_contents();
6575 assert!(
6576 rendered.contains(&format!("{line}\n")),
6577 "re-emitted exactly, so an unrelated `tix edit` does not rewrite the \
6578 author's audit log: {rendered}"
6579 );
6580 assert_eq!(
6581 Issue::parse(&rendered).unwrap().to_file_contents(),
6582 rendered,
6583 "and the file is byte-stable from here"
6584 );
6585 }
6586
6587 #[test]
6588 fn c002_history_is_append_only() {
6589 let (tmp, store) = make_store();
6590 write_issue(&store, "2026-01-01-h.md", &bare_frontmatter(1, "H"));
6591 let _ = tmp;
6592 let mut issue = store.get(IssueSelector::Number(1)).unwrap();
6593 issue
6594 .history
6595 .append_now("alice", HistoryEventKind::RelationshipAdded, "first");
6596 let after_first = issue.history.clone();
6597 issue
6598 .history
6599 .append_now("alice", HistoryEventKind::RelationshipRemoved, "second");
6600
6601 assert_eq!(issue.history.len(), 2, "appending never replaces");
6602 assert_eq!(
6603 &issue.history.events()[..1],
6604 after_first.events(),
6605 "the earlier entry is untouched and still first"
6606 );
6607 }
6608
6609 #[test]
6610 fn c002_an_unknown_history_kind_is_kept_verbatim_not_coerced() {
6611 let line = "- 2026-01-01T00:00:00Z \u{2014} alice \u{2014} bogus_kind \u{2014} something";
6622 let raw = format!(
6623 "---\nnumber: 1\ntitle: \"H\"\nstatus: \"todo\"\n---\n\n## History\n\n{line}\n"
6624 );
6625 let issue = Issue::parse(&raw).expect("an unknown kind must not fail the parse");
6626
6627 assert!(
6628 issue.history.events().is_empty(),
6629 "no event was fabricated for an unknown kind"
6630 );
6631 assert_eq!(
6632 issue.history.unparsed(),
6633 vec![line],
6634 "the line is kept verbatim: not dropped, not repaired"
6635 );
6636 let rendered = issue.to_file_contents();
6640 assert!(rendered.contains(line), "the line survives the write");
6641 assert_eq!(
6642 Issue::parse(&rendered).unwrap().to_file_contents(),
6643 rendered,
6644 "and the file round-trips byte-identically"
6645 );
6646 }
6647
6648 #[test]
6649 fn list_mixes_grouped_and_flat_issues_sorted_by_relative_filename() {
6650 let (tmp, store) = make_store();
6651 let _ = tmp;
6652 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6653 fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
6654 write_issue(
6655 &store,
6656 "alice/2026-01-01-a.md",
6657 &sample_frontmatter(1, "A", "todo"),
6658 );
6659 write_issue(
6660 &store,
6661 "bob/2026-05-10-b.md",
6662 &sample_frontmatter(2, "B", "todo"),
6663 );
6664 write_issue(
6665 &store,
6666 "2026-02-01-c.md",
6667 &sample_frontmatter(3, "C", "todo"),
6668 );
6669 let issues = store.list().unwrap();
6670 let filenames: Vec<&str> = issues.iter().map(|i| i.filename.as_str()).collect();
6671 assert_eq!(issues.len(), 3);
6672 assert_eq!(
6673 filenames,
6674 vec![
6675 "2026-02-01-c.md",
6676 "alice/2026-01-01-a.md",
6677 "bob/2026-05-10-b.md"
6678 ],
6679 "deterministic sort by relative filename"
6680 );
6681 }
6682
6683 #[test]
6684 fn list_sorts_chronologically_within_a_group() {
6685 let (tmp, store) = make_store();
6686 let _ = tmp;
6687 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6688 write_issue(
6689 &store,
6690 "alice/2026-03-01-c.md",
6691 &sample_frontmatter(3, "C", "todo"),
6692 );
6693 write_issue(
6694 &store,
6695 "alice/2026-01-01-a.md",
6696 &sample_frontmatter(1, "A", "todo"),
6697 );
6698 write_issue(
6699 &store,
6700 "alice/2026-02-01-b.md",
6701 &sample_frontmatter(2, "B", "todo"),
6702 );
6703 let issues = store.list().unwrap();
6704 let numbers: Vec<u32> = issues.iter().map(|i| i.number).collect();
6705 assert_eq!(numbers, vec![1, 2, 3]);
6706 }
6707
6708 #[test]
6709 fn list_ignores_non_md_files_in_segments() {
6710 let (tmp, store) = make_store();
6711 let _ = tmp;
6712 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6713 write_issue(
6714 &store,
6715 "alice/2026-01-01-a.md",
6716 &sample_frontmatter(1, "A", "todo"),
6717 );
6718 fs::write(store.issues_dir.join("alice/notes.txt"), "not an issue").unwrap();
6719 fs::write(store.issues_dir.join("alice/.tix-tmp-123-456"), "orphan").unwrap();
6720 let issues = store.list().unwrap();
6721 assert_eq!(issues.len(), 1);
6722 assert_eq!(issues[0].filename, "alice/2026-01-01-a.md");
6723 }
6724
6725 #[test]
6726 fn list_skips_temp_marker_directories() {
6727 let (tmp, store) = make_store();
6728 let _ = tmp;
6729 fs::create_dir_all(store.issues_dir.join(".tix-tmp-999-0")).unwrap();
6730 write_issue(
6731 &store,
6732 ".tix-tmp-999-0/2026-01-01-junk.md",
6733 &sample_frontmatter(9, "J", "todo"),
6734 );
6735 write_issue(
6736 &store,
6737 "2026-01-01-a.md",
6738 &sample_frontmatter(1, "A", "todo"),
6739 );
6740 let issues = store.list().unwrap();
6741 assert_eq!(issues.len(), 1);
6742 assert_eq!(issues[0].filename, "2026-01-01-a.md");
6743 }
6744
6745 #[test]
6746 fn list_parse_error_carries_grouped_relative_path() {
6747 let (tmp, store) = make_store();
6748 let _ = tmp;
6749 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
6750 fs::write(
6751 store.issues_dir.join("alice/2026-01-01-bad.md"),
6752 "no frontmatter here\n",
6753 )
6754 .unwrap();
6755 let err = store.list().unwrap_err();
6756 assert!(
6757 matches!(&err, TixError::Parse(m) if m.starts_with("alice/2026-01-01-bad.md:")),
6758 "parse error must carry the relative path, got {err:?}"
6759 );
6760 }
6761
6762 #[test]
6767 fn create_uses_the_default_layout_template() {
6768 let tmp = tempfile::TempDir::new().unwrap();
6773 let dir = tmp.path().join("issues");
6774 fs::create_dir_all(&dir).unwrap();
6775 let store = Store {
6776 issues_dir: dir,
6777 config: Config::default(),
6778 };
6779 let mut issue = Issue {
6780 projects: None,
6781 id: Some("A1B2C3D4".into()),
6782 number: 0,
6783 title: "Round trip".into(),
6784 status: Status::new("todo"),
6785 labels: vec![],
6786 priority: Priority::default(),
6787 assignee: Some("@alice".into()),
6788 estimate: None,
6789 commit: None,
6790 gitlab_id: None,
6791 linear_id: None,
6792 mission: None,
6793 task: None,
6794 relationships: vec![],
6795 related_issues: vec![],
6796 parent: None,
6797 series: None,
6798 health: None,
6799 files: vec![],
6800 cycles: None,
6801 milestones: None,
6802 reviewer: None,
6803 start_date: None,
6804 due_date: None,
6805 entered_at: None,
6806 source: None,
6807 created: "2026-01-01".into(),
6808 type_: None,
6809 updated: "2026-01-01".into(),
6810 filename: String::new(),
6811 body: String::new(),
6812 history: crate::model::History::new(),
6813 has_retired_links_key: false,
6814 time_entries: vec![],
6815 epics: None,
6816 extra_frontmatter: std::collections::BTreeMap::new(),
6817 archived: false,
6818 };
6819 store.create(&mut issue, "", None).unwrap();
6820 assert_eq!(
6821 issue.filename,
6822 "alice/default/todo/2026-01-01-A1B2C3D4-round-trip.md"
6823 );
6824 assert!(
6825 store
6826 .issues_dir
6827 .join("alice/default/todo/2026-01-01-A1B2C3D4-round-trip.md")
6828 .is_file()
6829 );
6830 }
6831
6832 #[test]
6833 fn create_assigns_a_valid_id_and_leads_the_filename_with_it() {
6834 let (tmp, store) = make_store();
6835 let _ = tmp;
6836 let mut a = crate::model::Issue::parse(
6840 "---\nnumber: 0\ntitle: Same title\nstatus: todo\nlabels: []\npriority: medium\n\
6841 created: 2026-01-01\nupdated: 2026-01-01\n---\n\n",
6842 )
6843 .unwrap();
6844 let mut b = a.clone();
6845 store.create(&mut a, "", None).unwrap();
6846 store.create(&mut b, "", None).unwrap();
6847
6848 let id_a = a.id.clone().expect("an id is assigned at create");
6849 let id_b = b.id.clone().expect("an id is assigned at create");
6850 assert!(crate::id::is_issue_id(&id_a), "shaped id: {id_a}");
6851 assert_ne!(id_a, id_b, "two creates must not share an id");
6852 assert!(
6853 a.filename.contains(&id_a),
6854 "filename must carry the id: {}",
6855 a.filename
6856 );
6857 assert!(
6858 a.to_file_contents().contains(&format!("id: {id_a}\n")),
6859 "the id must reach the frontmatter"
6860 );
6861 }
6862
6863 #[test]
6864 fn slug_from_stem_skips_the_id_and_date_prefixes() {
6865 assert_eq!(slug_from_stem("2026-01-01-fix-login"), "fix-login");
6867 assert_eq!(slug_from_stem("2026-01-01-A1B2C3D4-fix-login"), "fix-login");
6869 assert_eq!(slug_from_stem("A1B2C3D4-2026-01-01-fix-login"), "fix-login");
6871 assert_eq!(slug_from_stem("no-prefix"), "no-prefix");
6873 }
6874
6875 #[test]
6876 fn update_always_reconciles_the_file_location() {
6877 let tmp = tempfile::TempDir::new().unwrap();
6880 let dir = tmp.path().join("issues");
6881 fs::create_dir_all(&dir).unwrap();
6882 let store = Store {
6883 issues_dir: dir,
6884 config: Config::default(),
6885 };
6886 let mut issue = Issue {
6887 projects: None,
6888 id: Some("A1B2C3D4".into()),
6889 number: 0,
6890 type_: None,
6891 title: "Round trip".into(),
6892 status: Status::new("todo"),
6893 labels: vec![],
6894 priority: Priority::default(),
6895 assignee: Some("@alice".into()),
6896 estimate: None,
6897 commit: None,
6898 gitlab_id: None,
6899 linear_id: None,
6900 mission: None,
6901 task: None,
6902 relationships: vec![],
6903 related_issues: vec![],
6904 parent: None,
6905 series: None,
6906 health: None,
6907 files: vec![],
6908 cycles: None,
6909 milestones: None,
6910 reviewer: None,
6911 start_date: None,
6912 due_date: None,
6913 entered_at: None,
6914 source: None,
6915 created: "2026-01-01".into(),
6916 updated: "2026-01-01".into(),
6917 filename: String::new(),
6918 body: String::new(),
6919 history: crate::model::History::new(),
6920 has_retired_links_key: false,
6921 time_entries: vec![],
6922 epics: None,
6923 extra_frontmatter: std::collections::BTreeMap::new(),
6924 archived: false,
6925 };
6926 store.create(&mut issue, "", None).unwrap();
6927 let old = issue.filename.clone();
6928
6929 issue.status = Status::new("done");
6931 issue.filename = store.update(&old, &issue).unwrap();
6932 assert_eq!(
6933 issue.filename,
6934 "alice/default/done/2026-01-01-A1B2C3D4-round-trip.md"
6935 );
6936 assert!(!store.issues_dir.join(&old).exists());
6937
6938 let flat = "2026-01-01-legacy.md";
6940 fs::write(
6941 store.issues_dir.join(flat),
6942 "---\nnumber: 9\ntitle: L\nstatus: todo\nlabels: []\npriority: medium\n\
6943 files: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nbody\n",
6944 )
6945 .unwrap();
6946 let mut legacy = store
6947 .get(IssueSelector::Filename(flat.to_string()))
6948 .unwrap();
6949 legacy.status = Status::new("done");
6950 let after = store.update(flat, &legacy).unwrap();
6951 assert_eq!(after, "unassigned/default/done/2026-01-01-legacy.md");
6952 assert!(!store.issues_dir.join(flat).exists(), "migrated, not left");
6953 assert!(store.issues_dir.join(&after).is_file());
6954 }
6955
6956 #[test]
6957 fn create_writes_into_assignee_segment_and_records_relative_filename() {
6958 let (tmp, store) = make_store();
6959 let _ = tmp;
6960 let mut issue = Issue {
6961 projects: None,
6962 id: None,
6963 number: 0,
6964 title: "T".into(),
6965 status: Status::default(),
6966 labels: vec![],
6967 priority: Priority::default(),
6968 assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
6969 estimate: None,
6970 commit: None,
6971 gitlab_id: None,
6972 linear_id: None,
6973 mission: None,
6974 task: None,
6975 relationships: vec![],
6976 related_issues: vec![],
6977 files: vec![],
6978 cycles: None,
6979 milestones: None,
6980 reviewer: None,
6981 start_date: None,
6982 due_date: None,
6983 created: "2026-01-01".into(),
6984 type_: None,
6985 updated: "2026-01-01".into(),
6986 filename: String::new(),
6987 body: String::new(),
6988 history: crate::model::History::new(),
6989 has_retired_links_key: false,
6990 time_entries: vec![],
6991 parent: None,
6992 series: None,
6993 health: None,
6994 epics: None,
6995 extra_frontmatter: std::collections::BTreeMap::new(),
6996 archived: false,
6997 entered_at: None,
6998 source: None,
6999 };
7000 let path = store
7001 .create(&mut issue, "body", Some("custom-stem"))
7002 .unwrap();
7003 let rel = "herbert-maschke/custom-stem.md";
7004 assert_eq!(issue.filename, rel);
7005 assert_eq!(path, store.issues_dir.join(rel));
7006 assert!(store.issues_dir.join("herbert-maschke").is_dir());
7007 assert!(store.issues_dir.join(rel).is_file());
7008 }
7009
7010 #[test]
7011 fn create_without_assignee_writes_unassigned_segment() {
7012 let (tmp, store) = make_store();
7013 let _ = tmp;
7014 let mut issue = Issue {
7015 projects: None,
7016 id: None,
7017 number: 0,
7018 title: "T".into(),
7019 status: Status::default(),
7020 labels: vec![],
7021 priority: Priority::default(),
7022 assignee: None,
7023 estimate: None,
7024 commit: None,
7025 gitlab_id: None,
7026 linear_id: None,
7027 mission: None,
7028 task: None,
7029 relationships: vec![],
7030 related_issues: vec![],
7031 files: vec![],
7032 cycles: None,
7033 milestones: None,
7034 reviewer: None,
7035 start_date: None,
7036 due_date: None,
7037 created: "2026-01-01".into(),
7038 type_: None,
7039 updated: "2026-01-01".into(),
7040 filename: String::new(),
7041 body: String::new(),
7042 history: crate::model::History::new(),
7043 has_retired_links_key: false,
7044 time_entries: vec![],
7045 parent: None,
7046 series: None,
7047 health: None,
7048 epics: None,
7049 extra_frontmatter: std::collections::BTreeMap::new(),
7050 archived: false,
7051 entered_at: None,
7052 source: None,
7053 };
7054 let path = store.create(&mut issue, "", Some("stem")).unwrap();
7055 assert_eq!(issue.filename, "unassigned/stem.md");
7056 assert!(store.issues_dir.join("unassigned").is_dir());
7057 assert!(store.issues_dir.join("unassigned/stem.md").is_file());
7058 assert_eq!(path, store.issues_dir.join("unassigned/stem.md"));
7059 }
7060
7061 #[test]
7062 fn create_with_at_handle_derives_bare_segment() {
7063 let (tmp, store) = make_store();
7064 let _ = tmp;
7065 let mut issue = Issue {
7066 projects: None,
7067 id: None,
7068 number: 0,
7069 title: "T".into(),
7070 status: Status::default(),
7071 labels: vec![],
7072 priority: Priority::default(),
7073 assignee: Some("@alice".into()),
7074 estimate: None,
7075 commit: None,
7076 gitlab_id: None,
7077 linear_id: None,
7078 mission: None,
7079 task: None,
7080 relationships: vec![],
7081 related_issues: vec![],
7082 files: vec![],
7083 cycles: None,
7084 milestones: None,
7085 reviewer: None,
7086 start_date: None,
7087 due_date: None,
7088 created: "2026-01-01".into(),
7089 type_: None,
7090 updated: "2026-01-01".into(),
7091 filename: String::new(),
7092 body: String::new(),
7093 history: crate::model::History::new(),
7094 has_retired_links_key: false,
7095 time_entries: vec![],
7096 parent: None,
7097 series: None,
7098 health: None,
7099 epics: None,
7100 extra_frontmatter: std::collections::BTreeMap::new(),
7101 archived: false,
7102 entered_at: None,
7103 source: None,
7104 };
7105 store.create(&mut issue, "", Some("stem")).unwrap();
7106 assert_eq!(issue.filename, "alice/stem.md");
7107 assert!(store.issues_dir.join("alice/stem.md").is_file());
7108 }
7109
7110 #[test]
7115 fn reassign_moves_file_between_segments() {
7116 let (tmp, store) = make_store();
7117 let _ = tmp;
7118 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7119 write_issue(
7120 &store,
7121 "alice/2026-01-01-x.md",
7122 &sample_frontmatter(1, "X", "todo"),
7123 );
7124 let new_path = store
7125 .reassign("alice/2026-01-01-x.md", Some("@bob"))
7126 .unwrap();
7127 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x.md"));
7128 assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
7129 assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
7130 }
7131
7132 #[test]
7133 fn reassign_into_occupied_segment_picks_deterministic_suffix() {
7134 let (tmp, store) = make_store();
7135 let _ = tmp;
7136 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7137 fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
7138 write_issue(
7139 &store,
7140 "alice/2026-01-01-x.md",
7141 &sample_frontmatter(1, "X", "todo"),
7142 );
7143 write_issue(
7144 &store,
7145 "bob/2026-01-01-x.md",
7146 &sample_frontmatter(2, "X", "todo"),
7147 );
7148 let new_path = store
7149 .reassign("alice/2026-01-01-x.md", Some("@bob"))
7150 .unwrap();
7151 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-x-1.md"));
7152 assert!(store.issues_dir.join("bob/2026-01-01-x.md").is_file());
7153 assert!(store.issues_dir.join("bob/2026-01-01-x-1.md").is_file());
7154 assert!(!store.issues_dir.join("alice/2026-01-01-x.md").exists());
7155 }
7156
7157 #[test]
7158 fn reassign_legacy_flat_file_into_segment() {
7159 let (tmp, store) = make_store();
7160 let _ = tmp;
7161 write_issue(
7162 &store,
7163 "2026-01-01-y.md",
7164 &sample_frontmatter(1, "Y", "todo"),
7165 );
7166 let new_path = store.reassign("2026-01-01-y.md", Some("alice")).unwrap();
7167 assert_eq!(new_path, PathBuf::from("alice/2026-01-01-y.md"));
7168 assert!(store.issues_dir.join("alice/2026-01-01-y.md").is_file());
7169 assert!(!store.issues_dir.join("2026-01-01-y.md").exists());
7170 }
7171
7172 #[test]
7173 fn reassign_to_none_uses_unassigned_fallback() {
7174 let (tmp, store) = make_store();
7175 let _ = tmp;
7176 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7177 write_issue(
7178 &store,
7179 "alice/2026-01-01-z.md",
7180 &sample_frontmatter(1, "Z", "todo"),
7181 );
7182 let new_path = store.reassign("alice/2026-01-01-z.md", None).unwrap();
7183 assert_eq!(new_path, PathBuf::from("unassigned/2026-01-01-z.md"));
7184 assert!(
7185 store
7186 .issues_dir
7187 .join("unassigned/2026-01-01-z.md")
7188 .is_file()
7189 );
7190 assert!(!store.issues_dir.join("alice/2026-01-01-z.md").exists());
7191 }
7192
7193 #[test]
7194 fn reassign_keeps_number_and_created_stable() {
7195 let (tmp, store) = make_store();
7196 let _ = tmp;
7197 let mut issue = Issue {
7198 projects: None,
7199 id: None,
7200 number: 0,
7201 title: "Stable".into(),
7202 status: Status::default(),
7203 labels: vec![],
7204 priority: Priority::default(),
7205 assignee: Some("alice".into()),
7206 estimate: None,
7207 commit: None,
7208 gitlab_id: None,
7209 linear_id: None,
7210 mission: None,
7211 task: None,
7212 relationships: vec![],
7213 related_issues: vec![],
7214 files: vec![],
7215 cycles: None,
7216 milestones: None,
7217 reviewer: None,
7218 start_date: None,
7219 due_date: None,
7220 created: "2026-01-01".into(),
7221 type_: None,
7222 updated: "2026-01-01".into(),
7223 filename: String::new(),
7224 body: String::new(),
7225 history: crate::model::History::new(),
7226 has_retired_links_key: false,
7227 time_entries: vec![],
7228 parent: None,
7229 series: None,
7230 health: None,
7231 epics: None,
7232 extra_frontmatter: std::collections::BTreeMap::new(),
7233 archived: false,
7234 entered_at: None,
7235 source: None,
7236 };
7237 store.create(&mut issue, "body", None).unwrap();
7238 let number = issue.number;
7239 let created = issue.created.clone();
7240 let before = issue.filename.clone();
7241 let new_path = store.reassign(&before, Some("@bob")).unwrap();
7242 let rescan = store.list().unwrap();
7243 let moved = rescan
7244 .iter()
7245 .find(|i| i.filename == new_path)
7246 .expect("moved file must be listed at its new relative path");
7247 assert_eq!(moved.number, number);
7248 assert_eq!(moved.created, created);
7249 assert_eq!(moved.title, "Stable");
7250 }
7251
7252 #[test]
7253 fn reassign_equal_segment_is_noop() {
7254 let (tmp, store) = make_store();
7255 let _ = tmp;
7256 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7257 write_issue(
7258 &store,
7259 "alice/2026-01-01-x.md",
7260 &sample_frontmatter(1, "X", "todo"),
7261 );
7262 let new_path = store
7263 .reassign("alice/2026-01-01-x.md", Some("alice"))
7264 .unwrap();
7265 assert_eq!(new_path, PathBuf::from("alice/2026-01-01-x.md"));
7266 assert!(store.issues_dir.join("alice/2026-01-01-x.md").is_file());
7267 }
7268
7269 #[test]
7270 fn reassign_missing_file_is_not_found() {
7271 let (tmp, store) = make_store();
7272 let _ = tmp;
7273 let err = store.reassign("nope.md", Some("alice")).unwrap_err();
7274 assert!(matches!(err, TixError::NotFound(_)));
7275 }
7276
7277 #[test]
7282 fn validate_filename_allows_flat_and_single_segment_prefix() {
7283 validate_filename("2026-01-01-x.md").unwrap();
7284 validate_filename("alice/2026-01-01-x.md").unwrap();
7285 }
7286
7287 #[test]
7288 fn validate_filename_allows_deeper_segments() {
7289 validate_filename("alice/bug/in-progress/2026-01-01-x.md").unwrap();
7291 }
7292
7293 #[test]
7294 fn validate_filename_refuses_leading_and_trailing_slash_and_absolute() {
7295 let err = validate_filename("/alice/2026-01-01-x.md").unwrap_err();
7296 assert!(matches!(err, TixError::InvalidInput(_)));
7297 let err = validate_filename("/etc/passwd").unwrap_err();
7298 assert!(matches!(err, TixError::InvalidInput(_)));
7299 let err = validate_filename("alice/").unwrap_err();
7300 assert!(matches!(err, TixError::InvalidInput(_)));
7301 }
7302
7303 #[test]
7304 fn path_traversal_with_segment_dotdot_refused() {
7305 let err = validate_filename("alice/../../x.md").unwrap_err();
7306 assert!(matches!(err, TixError::InvalidInput(_)));
7307 let err = validate_filename("alice/../x.md").unwrap_err();
7308 assert!(matches!(err, TixError::InvalidInput(_)));
7309 }
7310
7311 #[test]
7312 fn get_update_delete_set_status_work_on_grouped_relative_paths() {
7313 let (tmp, store) = make_store();
7314 let _ = tmp;
7315 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7316 write_issue(
7317 &store,
7318 "alice/2026-01-01-g.md",
7319 &sample_frontmatter(7, "G", "todo"),
7320 );
7321 let got = store
7322 .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
7323 .unwrap();
7324 assert_eq!(got.number, 7);
7325 let done = store
7326 .set_status("alice/2026-01-01-g.md", StatusChange::to("done"))
7327 .unwrap();
7328 assert_eq!(done.issue.status.display(), "done");
7329 assert!(store.issues_dir.join("alice/2026-01-01-g.md").is_file());
7330 let mut edited = store
7331 .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
7332 .unwrap();
7333 edited.title = "Updated".into();
7334 store.update("alice/2026-01-01-g.md", &edited).unwrap();
7335 let reloaded = store
7336 .get(IssueSelector::Filename("alice/2026-01-01-g.md".into()))
7337 .unwrap();
7338 assert_eq!(reloaded.title, "Updated");
7339 store.delete("alice/2026-01-01-g.md").unwrap();
7340 assert!(!store.issues_dir.join("alice/2026-01-01-g.md").exists());
7341 }
7342
7343 #[test]
7348 fn grouped_round_trip_create_list_get_update_delete() {
7349 let (tmp, store) = make_store();
7350 let _ = tmp;
7351 let mut issue = Issue {
7352 projects: None,
7353 id: Some("A1B2C3D4".into()),
7354 number: 0,
7355 title: "Round trip".into(),
7356 status: Status::default(),
7357 labels: vec!["bug".into()],
7358 priority: Priority::default(),
7359 assignee: Some("Herbert Maschke <thyseus@pm.me>".into()),
7360 estimate: None,
7361 commit: None,
7362 gitlab_id: None,
7363 linear_id: None,
7364 mission: None,
7365 task: None,
7366 relationships: vec![],
7367 related_issues: vec![],
7368 files: vec![],
7369 cycles: None,
7370 milestones: None,
7371 reviewer: None,
7372 start_date: None,
7373 due_date: None,
7374 created: "2026-01-01".into(),
7375 type_: None,
7376 updated: "2026-01-01".into(),
7377 filename: String::new(),
7378 body: String::new(),
7379 history: crate::model::History::new(),
7380 has_retired_links_key: false,
7381 time_entries: vec![],
7382 parent: None,
7383 series: None,
7384 health: None,
7385 epics: None,
7386 extra_frontmatter: std::collections::BTreeMap::new(),
7387 archived: false,
7388 entered_at: None,
7389 source: None,
7390 };
7391 store.create(&mut issue, "initial body", None).unwrap();
7392 let rel = issue.filename.clone();
7393 assert_eq!(rel, "herbert-maschke/2026-01-01-A1B2C3D4-round-trip.md");
7394
7395 let listed = store.list().unwrap();
7396 assert_eq!(listed.len(), 1);
7397 assert_eq!(listed[0].filename, rel);
7398 assert_eq!(listed[0].number, issue.number);
7399
7400 let got = store.get(IssueSelector::Filename(rel.clone())).unwrap();
7401 let mut edited = got.clone();
7402 edited.title = "Renamed".into();
7403 store.update(&rel, &edited).unwrap();
7404 let reloaded = store.get(IssueSelector::Filename(rel.clone())).unwrap();
7405 assert_eq!(reloaded.title, "Renamed");
7406
7407 store.delete(&rel).unwrap();
7408 assert!(!store.issues_dir.join(&rel).exists());
7409 assert_eq!(store.list().unwrap(), [] as [crate::model::Issue; 0]);
7410 }
7411
7412 #[test]
7413 fn legacy_flat_file_edit_and_status_still_work_after_layout_flip() {
7414 let (tmp, store) = make_store();
7415 let _ = tmp;
7416 write_issue(
7417 &store,
7418 "2026-01-01-legacy.md",
7419 &sample_frontmatter(5, "Legacy", "todo"),
7420 );
7421 let listed = store.list().unwrap();
7422 assert_eq!(listed.len(), 1);
7423 assert_eq!(listed[0].filename, "2026-01-01-legacy.md");
7424 let mutated = store
7425 .set_status("2026-01-01-legacy.md", StatusChange::to("in progress"))
7426 .unwrap();
7427 assert_eq!(mutated.issue.status.display(), "in progress");
7428 assert!(store.issues_dir.join("2026-01-01-legacy.md").is_file());
7429 }
7430
7431 fn write_asset_source(tmp: &tempfile::TempDir, name: &str, contents: &[u8]) -> PathBuf {
7436 let path = tmp.path().join(name);
7437 fs::write(&path, contents).unwrap();
7438 path
7439 }
7440
7441 fn write_issue_with_basic_body(store: &Store, name: &str, number: u32) {
7442 write_issue(
7443 store,
7444 name,
7445 &sample_frontmatter(number, "Asset host", "todo"),
7446 );
7447 }
7448
7449 #[test]
7450 fn add_asset_image_inserts_image_link_and_writes_file() {
7451 let (tmp, store) = make_store();
7452 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7453 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7454 let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
7455 let added = store
7456 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7457 .unwrap();
7458 assert_eq!(added.written_name, "diagram.png");
7459 assert_eq!(added.link, "");
7460 assert!(
7461 store
7462 .issues_dir
7463 .join("alice/fix-login-bug-assets/diagram.png")
7464 .is_file()
7465 );
7466 }
7467
7468 #[test]
7469 fn add_asset_non_image_inserts_generic_markdown_link() {
7470 let (tmp, store) = make_store();
7471 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7472 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7473 let src = write_asset_source(&tmp, "report.pdf", b"%PDF");
7474 let added = store
7475 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7476 .unwrap();
7477 assert_eq!(added.link, "[report](fix-login-bug-assets/report.pdf)");
7478 assert!(
7479 store
7480 .issues_dir
7481 .join("alice/fix-login-bug-assets/report.pdf")
7482 .is_file()
7483 );
7484 }
7485
7486 #[test]
7487 fn add_asset_collision_resolves_with_numeric_suffix_and_link_points_at_it() {
7488 let (tmp, store) = make_store();
7489 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7490 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7491 let src = write_asset_source(&tmp, "x.png", b"first");
7492 let first = store
7493 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7494 .unwrap();
7495 assert_eq!(first.written_name, "x.png");
7496 let again = store
7497 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7498 .unwrap();
7499 assert_eq!(again.written_name, "x-1.png");
7500 assert_eq!(again.link, "");
7501 assert!(
7502 store
7503 .issues_dir
7504 .join("alice/fix-login-bug-assets/x.png")
7505 .is_file()
7506 );
7507 assert!(
7508 store
7509 .issues_dir
7510 .join("alice/fix-login-bug-assets/x-1.png")
7511 .is_file()
7512 );
7513 }
7514
7515 #[test]
7516 fn add_asset_appends_single_link_and_preserves_description() {
7517 let (tmp, store) = make_store();
7518 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7519 write_issue(
7520 &store,
7521 "alice/2026-01-01-fix-login-bug.md",
7522 "---\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",
7523 );
7524 let src = write_asset_source(&tmp, "diagram.svg", b"<svg/>");
7525 store
7526 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7527 .unwrap();
7528 let reloaded = store
7529 .get(crate::storage::IssueSelector::Filename(
7530 "alice/2026-01-01-fix-login-bug.md".into(),
7531 ))
7532 .unwrap();
7533 assert_eq!(reloaded.description(), "Title line.");
7535 let link_count = reloaded.body.matches("![diagram]").count();
7537 assert_eq!(link_count, 1);
7538 assert!(
7539 reloaded
7540 .body
7541 .ends_with("fix-login-bug-assets/diagram.svg)\n")
7542 );
7543 }
7544
7545 #[test]
7546 fn add_asset_missing_issue_is_not_found() {
7547 let (tmp, store) = make_store();
7548 let _ = tmp;
7549 let src = write_asset_source(&tmp, "x.png", b"x");
7550 let err = store.add_asset("missing.md", &src).unwrap_err();
7551 assert!(matches!(err, TixError::NotFound(_)));
7552 }
7553
7554 #[test]
7555 fn add_asset_legacy_flat_file_uses_stem_assets_under_issues_dir() {
7556 let (tmp, store) = make_store();
7557 write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
7558 let src = write_asset_source(&tmp, "x.png", b"x");
7559 let added = store.add_asset("2026-01-01-legacy.md", &src).unwrap();
7560 assert_eq!(added.link, "");
7561 assert!(store.issues_dir.join("legacy-assets/x.png").is_file());
7562 }
7563
7564 #[test]
7569 fn reassign_moves_asset_dir_with_the_file() {
7570 let (tmp, store) = make_store();
7571 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7572 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7573 let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
7574 store
7575 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7576 .unwrap();
7577 let new_path = store
7578 .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
7579 .unwrap();
7580 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
7581 assert!(
7582 store
7583 .issues_dir
7584 .join("bob/fix-login-bug-assets/diagram.png")
7585 .is_file()
7586 );
7587 assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
7588 }
7589
7590 #[test]
7591 fn reassign_without_asset_dir_behaves_as_before() {
7592 let (tmp, store) = make_store();
7593 let _ = tmp;
7594 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7595 write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
7596 let new_path = store
7597 .reassign("alice/2026-01-01-plain.md", Some("@bob"))
7598 .unwrap();
7599 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-plain.md"));
7600 assert!(store.issues_dir.join("bob/2026-01-01-plain.md").is_file());
7601 assert!(!store.issues_dir.join("bob/plain-assets").exists());
7602 }
7603
7604 #[test]
7605 fn reassign_into_segment_with_same_named_asset_dir_uses_suffix_no_loss() {
7606 let (tmp, store) = make_store();
7607 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7608 fs::create_dir_all(store.issues_dir.join("bob")).unwrap();
7609 let existing = store.issues_dir.join("bob/fix-login-bug-assets");
7611 fs::create_dir_all(&existing).unwrap();
7612 fs::write(existing.join("keep.txt"), "precious").unwrap();
7613 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7614 let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
7615 store
7616 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7617 .unwrap();
7618 let new_path = store
7619 .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
7620 .unwrap();
7621 assert_eq!(new_path, PathBuf::from("bob/2026-01-01-fix-login-bug.md"));
7622 assert!(existing.join("keep.txt").is_file());
7624 assert!(
7625 store
7626 .issues_dir
7627 .join("bob/fix-login-bug-assets-1/diagram.png")
7628 .is_file()
7629 );
7630 assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
7631 }
7632
7633 #[test]
7634 fn reassign_preserves_markdown_link_resolution_in_new_segment() {
7635 let (tmp, store) = make_store();
7636 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7637 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7638 let src = write_asset_source(&tmp, "diagram.png", b"PNGDATA");
7639 store
7640 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7641 .unwrap();
7642 store
7643 .reassign("alice/2026-01-01-fix-login-bug.md", Some("@bob"))
7644 .unwrap();
7645 assert!(
7648 store
7649 .issues_dir
7650 .join("bob/fix-login-bug-assets/diagram.png")
7651 .is_file()
7652 );
7653 }
7654
7655 #[test]
7660 fn delete_removes_asset_dir_no_orphans() {
7661 let (tmp, store) = make_store();
7662 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7663 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7664 let src = write_asset_source(&tmp, "x.png", b"x");
7665 store
7666 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7667 .unwrap();
7668 store.delete("alice/2026-01-01-fix-login-bug.md").unwrap();
7669 assert!(
7670 !store
7671 .issues_dir
7672 .join("alice/2026-01-01-fix-login-bug.md")
7673 .exists()
7674 );
7675 assert!(!store.issues_dir.join("alice/fix-login-bug-assets").exists());
7676 }
7677
7678 #[test]
7679 fn delete_without_asset_dir_still_deletes_file() {
7680 let (tmp, store) = make_store();
7681 let _ = tmp;
7682 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7683 write_issue_with_basic_body(&store, "alice/2026-01-01-plain.md", 1);
7684 store.delete("alice/2026-01-01-plain.md").unwrap();
7685 assert!(!store.issues_dir.join("alice/2026-01-01-plain.md").exists());
7686 }
7687
7688 #[test]
7689 fn list_ignores_asset_directories() {
7690 let (tmp, store) = make_store();
7691 write_issue_with_basic_body(&store, "2026-01-01-legacy.md", 1);
7692 let src = write_asset_source(&tmp, "x.png", b"x");
7693 store.add_asset("2026-01-01-legacy.md", &src).unwrap();
7694 let issues = store.list().unwrap();
7697 assert_eq!(issues.len(), 1);
7698 assert_eq!(issues[0].filename, "2026-01-01-legacy.md");
7699 }
7700
7701 #[test]
7702 fn list_ignores_nested_asset_directories_under_segment() {
7703 let (tmp, store) = make_store();
7704 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7705 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7706 let src = write_asset_source(&tmp, "x.png", b"x");
7707 store
7708 .add_asset("alice/2026-01-01-fix-login-bug.md", &src)
7709 .unwrap();
7710 let issues = store.list().unwrap();
7711 assert_eq!(issues.len(), 1);
7712 assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
7713 }
7714
7715 #[test]
7716 fn asset_dir_with_formless_md_child_is_not_scanned_as_segment() {
7717 let (tmp, store) = make_store();
7718 let _ = tmp;
7719 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
7720 write_issue_with_basic_body(&store, "alice/2026-01-01-fix-login-bug.md", 1);
7721 let assets_dir = store.issues_dir.join("alice/fix-login-bug-assets");
7724 fs::create_dir_all(&assets_dir).unwrap();
7725 fs::write(assets_dir.join("notes.md"), "no frontmatter").unwrap();
7726 let issues = store.list().unwrap();
7727 assert_eq!(issues.len(), 1);
7728 assert_eq!(issues[0].filename, "alice/2026-01-01-fix-login-bug.md");
7729 }
7730
7731 fn hierarchy_trio() -> (TempDir, Store) {
7745 let (tmp, store) = make_store();
7746 for (n, title) in [(1, "A"), (2, "B"), (3, "C")] {
7747 write_issue(
7748 &store,
7749 &format!("2026-01-01-{}.md", title.to_lowercase()),
7750 &bare_frontmatter(n, title),
7751 );
7752 }
7753 (tmp, store)
7754 }
7755
7756 fn store_from_hierarchy_fixture(name: &str) -> (TempDir, Store) {
7763 let (tmp, store) = make_store();
7764 write_issue(
7765 &store,
7766 name,
7767 &std::fs::read_to_string(format!(
7768 "{}/tests/fixtures/hierarchy/{name}",
7769 env!("CARGO_MANIFEST_DIR")
7770 ))
7771 .unwrap(),
7772 );
7773 (tmp, store)
7774 }
7775
7776 fn bytes_of(store: &Store, number: u32) -> String {
7777 let issue = store.get(IssueSelector::Number(number)).unwrap();
7778 read_issue_file(store, &issue.filename)
7779 }
7780
7781 #[test]
7782 fn t008_set_writes_the_field_and_exactly_one_history_event() {
7783 let (_tmp, store) = hierarchy_trio();
7784 let outcome = store.set_parent("2", "1").unwrap();
7785 assert!(outcome.changed);
7786 assert_eq!(outcome.parent, Some(1));
7787
7788 let child = store.get(IssueSelector::Number(2)).unwrap();
7789 assert_eq!(child.parent, Some(1), "C-001: the number is stored");
7790 assert_eq!(child.history.len(), 1, "C-004: exactly one event");
7791 let event = &child.history.events()[0];
7792 assert_eq!(event.kind, HistoryEventKind::ParentSet);
7793 assert!(
7794 !event.actor.is_empty(),
7795 "an event without an actor is not an audit record"
7796 );
7797 assert_eq!(event.detail, "parent none -> #1", "got: {}", event.detail);
7799 }
7800
7801 #[test]
7802 fn t008_change_repoints_and_records_parent_changed() {
7803 let (_tmp, store) = hierarchy_trio();
7804 store.set_parent("3", "1").unwrap();
7805 let outcome = store.change_parent("3", "2").unwrap();
7806 assert!(outcome.changed);
7807 assert_eq!(outcome.parent, Some(2));
7808
7809 let child = store.get(IssueSelector::Number(3)).unwrap();
7810 assert_eq!(child.parent, Some(2));
7811 assert_eq!(child.history.len(), 2, "one event per accepted change");
7812 let events = child.history.events();
7813 let last = events.last().unwrap();
7814 assert_eq!(last.kind, HistoryEventKind::ParentChanged);
7815 assert_eq!(last.detail, "parent #1 -> #2", "got: {}", last.detail);
7818 }
7819
7820 #[test]
7821 fn t008_clear_removes_the_field_and_records_parent_cleared() {
7822 let (_tmp, store) = hierarchy_trio();
7823 store.set_parent("2", "1").unwrap();
7824 let outcome = store.clear_parent("2").unwrap();
7825 assert!(outcome.changed);
7826 assert_eq!(outcome.parent, None);
7827
7828 let child = store.get(IssueSelector::Number(2)).unwrap();
7829 assert_eq!(child.parent, None);
7830 assert_eq!(child.history.len(), 2);
7831 let events = child.history.events();
7832 let last = events.last().unwrap();
7833 assert_eq!(last.kind, HistoryEventKind::ParentCleared);
7834 assert_eq!(last.detail, "parent #1 -> none", "got: {}", last.detail);
7835 }
7836
7837 #[test]
7838 fn t008_reapplying_the_same_parent_is_a_no_op_with_no_second_event() {
7839 let (_tmp, store) = hierarchy_trio();
7840 store.set_parent("2", "1").unwrap();
7841 let after_first = bytes_of(&store, 2);
7842
7843 let second = store.set_parent("2", "1").unwrap();
7844 assert!(!second.changed, "an unchanged parent is not a change");
7845 assert!(second.event.is_none(), "a no-op records nothing (C-004)");
7846 assert_eq!(
7847 bytes_of(&store, 2),
7848 after_first,
7849 "a no-op must not rewrite the file at all"
7850 );
7851 }
7852
7853 #[test]
7854 fn t008_clearing_a_child_with_no_parent_is_a_no_op() {
7855 let (_tmp, store) = hierarchy_trio();
7856 let before = bytes_of(&store, 2);
7857 let outcome = store.clear_parent("2").unwrap();
7858 assert!(!outcome.changed);
7859 assert!(outcome.event.is_none());
7860 assert_eq!(bytes_of(&store, 2), before);
7861 }
7862
7863 #[test]
7864 fn t008_sc004_a_rejected_cycle_leaves_every_file_byte_identical() {
7865 let (_tmp, store) = hierarchy_trio();
7867 store.set_parent("2", "1").unwrap();
7868 store.set_parent("3", "2").unwrap();
7869 let before: Vec<String> = (1..=3).map(|n| bytes_of(&store, n)).collect();
7870
7871 let err = store
7872 .set_parent("1", "3")
7873 .expect_err("1 -> 2 -> 3 -> 1 is a cycle and must be refused");
7874 let msg = err.to_string();
7875 assert!(
7876 msg.contains("cycle"),
7877 "the diagnostic must name the rule: {msg}"
7878 );
7879
7880 for n in 1..=3 {
7882 assert_eq!(
7883 bytes_of(&store, n),
7884 before[(n - 1) as usize],
7885 "issue {n} must be byte-identical after a rejected cycle"
7886 );
7887 }
7888 }
7889
7890 #[test]
7891 fn t008_a_self_parent_is_rejected_and_writes_nothing() {
7892 let (_tmp, store) = hierarchy_trio();
7893 let before = bytes_of(&store, 2);
7894 let err = store
7895 .set_parent("2", "2")
7896 .expect_err("an issue cannot be its own parent");
7897 assert!(err.to_string().contains("self_parent"), "got: {err}");
7898 assert_eq!(bytes_of(&store, 2), before);
7899 }
7900
7901 #[test]
7902 fn t008_a_self_parent_already_on_disk_is_still_refused() {
7903 let (_tmp, store) = store_from_hierarchy_fixture("self-parent.md");
7911 let before = bytes_of(&store, 7);
7912 let err = store
7913 .set_parent("7", "7")
7914 .expect_err("a stored self-parent must still be refused on a re-set");
7915 assert!(
7916 err.to_string().contains("self_parent"),
7917 "refused for the wrong reason: {err}"
7918 );
7919 assert_eq!(
7920 bytes_of(&store, 7),
7921 before,
7922 "and the refused write must leave the file byte-identical"
7923 );
7924 assert!(store.change_parent("7", "7").is_err());
7926 assert_eq!(store.get(IssueSelector::Number(7)).unwrap().parent, Some(7));
7928 }
7929
7930 #[test]
7931 fn t008_c003_setting_a_child_writes_nothing_to_the_parents_file() {
7932 let (_tmp, store) = hierarchy_trio();
7935 let parent_before = bytes_of(&store, 1);
7936 store.set_parent("2", "1").unwrap();
7937 store.set_parent("3", "1").unwrap();
7938 assert_eq!(
7939 bytes_of(&store, 1),
7940 parent_before,
7941 "the parent's file must be untouched by its children appearing"
7942 );
7943 assert!(
7944 !parent_before.contains("parent:"),
7945 "the parent is a top of the hierarchy and has no parent itself"
7946 );
7947 }
7948
7949 #[test]
7950 fn t008_a_legacy_file_without_the_field_is_unchanged_by_a_no_op_write() {
7951 let (_tmp, store) = hierarchy_trio();
7953 let before = bytes_of(&store, 2);
7954 assert!(!before.contains("parent:"), "the fixture must be legacy");
7955
7956 store.clear_parent("2").unwrap();
7957 assert_eq!(bytes_of(&store, 2), before, "a no-op must not add the key");
7958 }
7959
7960 #[test]
7961 fn t008_an_unresolvable_selector_is_refused_before_any_write() {
7962 let (_tmp, store) = hierarchy_trio();
7963 let before = bytes_of(&store, 2);
7964 assert!(store.set_parent("2", "no such issue").is_err());
7965 assert!(store.change_parent("2", "999").is_err());
7966 assert_eq!(bytes_of(&store, 2), before);
7967 }
7968
7969 #[test]
7970 fn t008_t008_contains_no_hierarchy_rule_of_its_own() {
7971 let (_tmp, store) = hierarchy_trio();
7987 store.set_parent("2", "1").unwrap();
7988 store.set_parent("3", "2").unwrap();
7989
7990 let issues = store.list().unwrap();
7991 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
7992 let index = HierarchyIndex::build(&views);
7993 let service =
7994 hierarchy::set_parent(&index, 1, 3).expect_err("the service refuses this too");
7995 let storage = store.set_parent("1", "3").expect_err("and so does storage");
7996
7997 assert_eq!(
7998 storage.to_string(),
7999 TixError::from(service).to_string(),
8000 "storage must surface the service's diagnosis verbatim, not its own"
8001 );
8002 let text = storage.to_string();
8005 assert!(
8006 text.contains("cycle:") && !text.contains("cycle: cycle:"),
8007 "a lookalike renderer or a doubled prefix: {text}"
8008 );
8009 assert!(
8010 text.starts_with("error[E-tix-hierarchy]: "),
8011 "a hierarchy refusal carries the shared rustc-style header: {text}"
8012 );
8013 }
8014
8015 #[test]
8016 fn t008_a_no_op_reports_no_written_issue_but_still_reports_the_current_parent() {
8017 let (_tmp, store) = hierarchy_trio();
8021 store.set_parent("2", "1").unwrap();
8022 let again = store.set_parent("2", "1").unwrap();
8023 assert!(!again.changed);
8024 assert!(
8025 again.written.is_none(),
8026 "a no-op wrote nothing, so there is no written issue to hand back"
8027 );
8028 assert!(
8029 again.event.is_none(),
8030 "and no event, or a retry would pad the audit trail"
8031 );
8032 assert_eq!(
8033 again.parent,
8034 Some(1),
8035 "but the resulting state is still reported, so a caller need not re-read"
8036 );
8037 }
8038
8039 #[test]
8047 fn a_failed_transaction_restores_every_file_it_touched() {
8048 let (_tmp, store) = make_store();
8049 write_issue(
8050 &store,
8051 "2026-01-01-a.md",
8052 &sample_frontmatter(1, "A", "todo"),
8053 );
8054 write_issue(
8055 &store,
8056 "2026-01-01-b.md",
8057 &sample_frontmatter(2, "B", "todo"),
8058 );
8059 let before_a = read_issue_file(&store, "2026-01-01-a.md");
8060 let before_b = read_issue_file(&store, "2026-01-01-b.md");
8061
8062 let mut a = store.get(IssueSelector::Number(1)).unwrap();
8063 a.title = "Changed".into();
8064 let mut b = store.get(IssueSelector::Number(2)).unwrap();
8065 b.title = "Also changed".into();
8066
8067 let err = store
8068 .transaction(|| {
8069 store.update("2026-01-01-a.md", &a)?;
8070 store.update("2026-01-01-b.md", &b)?;
8071 Err::<(), _>(TixError::InvalidInput("boom".into()))
8072 })
8073 .unwrap_err();
8074
8075 assert!(err.to_string().contains("boom"), "got: {err}");
8076 assert_eq!(
8077 read_issue_file(&store, "2026-01-01-a.md"),
8078 before_a,
8079 "the first write must be undone too, not only the last"
8080 );
8081 assert_eq!(read_issue_file(&store, "2026-01-01-b.md"), before_b);
8082 assert_eq!(store.get(IssueSelector::Number(1)).unwrap().title, "A");
8083 assert_eq!(store.get(IssueSelector::Number(2)).unwrap().title, "B");
8084 }
8085
8086 #[test]
8089 fn a_committed_transaction_keeps_its_writes() {
8090 let (_tmp, store) = make_store();
8091 write_issue(
8092 &store,
8093 "2026-01-01-a.md",
8094 &sample_frontmatter(1, "A", "todo"),
8095 );
8096 let mut a = store.get(IssueSelector::Number(1)).unwrap();
8097 a.title = "Changed".into();
8098
8099 store
8100 .transaction(|| {
8101 store.update("2026-01-01-a.md", &a)?;
8102 Ok(())
8103 })
8104 .unwrap();
8105
8106 assert_eq!(
8107 store.get(IssueSelector::Number(1)).unwrap().title,
8108 "Changed"
8109 );
8110 }
8111
8112 #[test]
8118 fn a_failed_transaction_undoes_a_reassign_move_and_its_content_write() {
8119 let (_tmp, store) = make_store();
8120 fs::create_dir_all(store.issues_dir.join("alice")).unwrap();
8121 write_issue(
8122 &store,
8123 "alice/2026-01-01-x.md",
8124 &sample_frontmatter(5, "X", "todo"),
8125 );
8126 let before = read_issue_file(&store, "alice/2026-01-01-x.md");
8127
8128 let mut edited = store.get(IssueSelector::Number(5)).unwrap();
8129 edited.title = "Edited".into();
8130
8131 let err = store
8132 .transaction(|| {
8133 let moved = store.reassign("alice/2026-01-01-x.md", Some("@bob"))?;
8134 store.update(&moved.to_string_lossy(), &edited)?;
8135 Err::<(), _>(TixError::InvalidInput("boom".into()))
8136 })
8137 .unwrap_err();
8138
8139 assert!(err.to_string().contains("boom"));
8140 assert!(
8141 store.issues_dir.join("alice/2026-01-01-x.md").is_file(),
8142 "the moved file must be back under its original segment"
8143 );
8144 assert!(
8145 !store.issues_dir.join("bob/2026-01-01-x.md").exists(),
8146 "the moved copy must not survive the rollback"
8147 );
8148 assert_eq!(read_issue_file(&store, "alice/2026-01-01-x.md"), before);
8149 }
8150
8151 #[test]
8153 fn a_failed_transaction_restores_a_deleted_file() {
8154 let (_tmp, store) = make_store();
8155 write_issue(
8156 &store,
8157 "2026-01-01-a.md",
8158 &sample_frontmatter(1, "A", "todo"),
8159 );
8160 let before = read_issue_file(&store, "2026-01-01-a.md");
8161
8162 let _ = store
8163 .transaction(|| {
8164 store.delete("2026-01-01-a.md")?;
8165 Err::<(), _>(TixError::InvalidInput("boom".into()))
8166 })
8167 .unwrap_err();
8168
8169 assert!(store.issues_dir.join("2026-01-01-a.md").is_file());
8170 assert_eq!(read_issue_file(&store, "2026-01-01-a.md"), before);
8171 }
8172
8173 #[test]
8178 fn a_nested_transaction_joins_the_outer_one() {
8179 let (_tmp, store) = make_store();
8180 write_issue(
8181 &store,
8182 "2026-01-01-a.md",
8183 &sample_frontmatter(1, "A", "todo"),
8184 );
8185 let before = read_issue_file(&store, "2026-01-01-a.md");
8186 let mut a = store.get(IssueSelector::Number(1)).unwrap();
8187 a.title = "Outer".into();
8188 let mut a2 = a.clone();
8189 a2.title = "Inner".into();
8190
8191 let err = store
8192 .transaction(|| {
8193 store.update("2026-01-01-a.md", &a)?;
8194 store.transaction(|| {
8196 store.update("2026-01-01-a.md", &a2)?;
8197 Err::<(), _>(TixError::InvalidInput("inner boom".into()))
8198 })?;
8199 Ok(())
8200 })
8201 .unwrap_err();
8202
8203 assert!(err.to_string().contains("inner boom"));
8204 assert_eq!(
8205 read_issue_file(&store, "2026-01-01-a.md"),
8206 before,
8207 "the outer write must also be undone, so the whole unit is atomic"
8208 );
8209 }
8210
8211 #[test]
8214 fn a_write_outside_a_transaction_is_not_journaled() {
8215 let (_tmp, store) = make_store();
8216 write_issue(
8217 &store,
8218 "2026-01-01-a.md",
8219 &sample_frontmatter(1, "A", "todo"),
8220 );
8221 let mut a = store.get(IssueSelector::Number(1)).unwrap();
8222 a.title = "Plain".into();
8223 store.update("2026-01-01-a.md", &a).unwrap();
8224 assert_eq!(store.get(IssueSelector::Number(1)).unwrap().title, "Plain");
8225 }
8226
8227 fn project_store() -> (TempDir, Store) {
8236 let tmp = TempDir::new().unwrap();
8237 let dir = tmp.path().join("issues");
8238 fs::create_dir_all(&dir).unwrap();
8239 let config_path = tmp.path().join("tix.toml");
8240 let mut config = Config {
8241 issues_dir: dir.clone(),
8242 ..Config::default()
8243 };
8244 config.layout.directory = "{assignee}".to_string();
8245 config.layout.move_on_change = false;
8246 config.projects.insert(
8247 "p-1".to_string(),
8248 ProjectConfiguration {
8249 name: "One".into(),
8250 ..ProjectConfiguration::default()
8251 },
8252 );
8253 config.save(&config_path).unwrap();
8254 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
8255 let store = Store {
8256 issues_dir: dir,
8257 config,
8258 };
8259 write_issue(
8260 &store,
8261 "2026-01-01-a.md",
8262 &sample_frontmatter(1, "A", "todo"),
8263 );
8264 (tmp, store)
8265 }
8266
8267 fn reloaded(store: &Store, filename: &str) -> Issue {
8269 store
8270 .get(IssueSelector::Filename(filename.to_string()))
8271 .unwrap()
8272 }
8273
8274 fn project_fixture(name: &str) -> String {
8277 let path = format!(
8278 "{}/tests/fixtures/projects/{name}",
8279 env!("CARGO_MANIFEST_DIR")
8280 );
8281 fs::read_to_string(&path).unwrap_or_else(|e| panic!("reading fixture {name}: {e}"))
8282 }
8283
8284 fn all_project_fixtures() -> Vec<String> {
8286 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/projects");
8287 let mut names: Vec<String> = fs::read_dir(&dir)
8288 .unwrap()
8289 .filter_map(|entry| entry.ok())
8290 .map(|entry| entry.file_name().to_string_lossy().into_owned())
8291 .filter(|name| name.ends_with(".md"))
8292 .collect();
8293 names.sort();
8294 names
8295 }
8296
8297 #[test]
8298 fn t013_an_accepted_association_writes_the_list_and_exactly_one_history_entry() {
8299 let (_tmp, store) = project_store();
8300 let before = read_issue_file(&store, "2026-01-01-a.md");
8301 let outcome = store.associate("1", "p-1").unwrap();
8302
8303 assert!(outcome.changed, "an accepted, changing association writes");
8304 assert!(outcome.written.is_some());
8305 assert_eq!(outcome.project_ids, vec!["p-1".to_string()]);
8306 let event = outcome
8307 .event
8308 .clone()
8309 .expect("an accepted change is audited");
8310 assert_eq!(event.detail, "project `p-1` associated");
8311
8312 let after = reloaded(&store, "2026-01-01-a.md");
8314 assert_eq!(after.projects, Some(vec!["p-1".to_string()]));
8315 let events = after.history.events();
8316 assert_eq!(events.len(), 1, "exactly one History entry: {events:?}");
8317 assert_eq!(events[0].kind, HistoryEventKind::ProjectAssociated);
8318 assert_eq!(events[0].detail, "project `p-1` associated");
8319 assert_ne!(
8320 read_issue_file(&store, "2026-01-01-a.md"),
8321 before,
8322 "an accepted change is written"
8323 );
8324 }
8325
8326 #[test]
8327 fn t013_a_refused_association_writes_nothing_and_appends_nothing() {
8328 let (_tmp, store) = project_store();
8329 let before = read_issue_file(&store, "2026-01-01-a.md");
8330
8331 let err = store.associate("1", "p-unknown").unwrap_err();
8332 assert!(
8333 matches!(err, TixError::InvalidInput(_)),
8334 "an unknown project is a user input refusal, got {err:?}"
8335 );
8336 assert_eq!(
8337 read_issue_file(&store, "2026-01-01-a.md"),
8338 before,
8339 "a refusal must leave the file byte-identical"
8340 );
8341 assert!(
8342 reloaded(&store, "2026-01-01-a.md").history.is_empty(),
8343 "a refusal must append nothing"
8344 );
8345
8346 let err = store.associate("999", "p-1").unwrap_err();
8348 assert!(matches!(err, TixError::NotFound(_)), "got {err:?}");
8349 assert_eq!(read_issue_file(&store, "2026-01-01-a.md"), before);
8350 }
8351
8352 #[test]
8353 fn t013_a_refusal_reports_the_service_wording_not_a_storage_copy() {
8354 let (_tmp, store) = project_store();
8357 let err = store.associate("1", "p-unknown").unwrap_err();
8358 assert!(
8359 err.to_string()
8360 .contains(&crate::projects::unknown_project_reason("p-unknown")),
8361 "the refusal must come from the service, got: {err}"
8362 );
8363 }
8364
8365 #[test]
8366 fn t013_re_associating_is_idempotent_no_second_entry_and_no_second_event() {
8367 let (_tmp, store) = project_store();
8368 store.associate("1", "p-1").unwrap();
8369 let after_first = read_issue_file(&store, "2026-01-01-a.md");
8370
8371 let second = store.associate("1", "p-1").unwrap();
8372 assert!(!second.changed, "the idempotent re-apply changes nothing");
8373 assert!(second.written.is_none(), "and writes no file");
8374 assert!(second.event.is_none(), "and appends no event");
8375 assert_eq!(second.project_ids, vec!["p-1".to_string()]);
8376 assert_eq!(
8377 read_issue_file(&store, "2026-01-01-a.md"),
8378 after_first,
8379 "the file is byte-identical after a repeated association"
8380 );
8381
8382 let after = reloaded(&store, "2026-01-01-a.md");
8383 assert_eq!(after.history.events().len(), 1, "no second History event");
8384 assert_eq!(
8385 after.projects,
8386 Some(vec!["p-1".to_string()]),
8387 "and no duplicate entry: (issue, project_id) is unique"
8388 );
8389 }
8390
8391 #[test]
8392 fn t013_dissociate_drops_only_the_association_and_records_one_event() {
8393 let (_tmp, store) = project_store();
8394 store.associate("1", "p-1").unwrap();
8395
8396 let outcome = store.dissociate("1", "p-1").unwrap();
8397 assert!(outcome.changed);
8398 assert_eq!(outcome.project_ids, [] as [std::string::String; 0]);
8399 let event = outcome
8400 .event
8401 .clone()
8402 .expect("an accepted change is audited");
8403 assert_eq!(event.detail, "project `p-1` dissociated");
8404
8405 let after = reloaded(&store, "2026-01-01-a.md");
8406 assert_eq!(after.projects, None, "the association is dropped");
8407 assert!(
8408 !read_issue_file(&store, "2026-01-01-a.md").contains("projects:"),
8409 "an empty list is written as an absent key"
8410 );
8411 let events = after.history.events();
8412 assert_eq!(events.len(), 2, "associate + dissociate = two entries");
8413 assert_eq!(events[1].kind, HistoryEventKind::ProjectDissociated);
8414
8415 let again = store.dissociate("1", "p-1").unwrap();
8417 assert!(!again.changed);
8418 assert!(again.event.is_none());
8419 assert_eq!(
8420 reloaded(&store, "2026-01-01-a.md").history.events().len(),
8421 2,
8422 "no third History event"
8423 );
8424 }
8425
8426 #[test]
8427 fn t013_bulk_associate_writes_each_affected_file_independently() {
8428 let (_tmp, store) = project_store();
8429 write_issue(
8430 &store,
8431 "2026-01-01-b.md",
8432 &sample_frontmatter(2, "B", "todo"),
8433 );
8434
8435 let outcomes = store
8436 .bulk_associate(
8437 "p-1",
8438 &["1".to_string(), "999".to_string(), "2".to_string()],
8439 )
8440 .unwrap();
8441
8442 assert_eq!(outcomes.len(), 3, "one entry per selector, in input order");
8443 assert_eq!(outcomes[0].selector, "1");
8444 assert!(outcomes[0].outcome.as_ref().unwrap().changed);
8445 assert!(
8446 outcomes[1].outcome.is_err(),
8447 "a bad selector is its own error, not a failure of the run"
8448 );
8449 assert!(outcomes[2].outcome.as_ref().unwrap().changed);
8450
8451 for filename in ["2026-01-01-a.md", "2026-01-01-b.md"] {
8452 let issue = reloaded(&store, filename);
8453 assert_eq!(issue.projects, Some(vec!["p-1".to_string()]));
8454 assert_eq!(issue.history.events().len(), 1, "{filename}");
8455 }
8456
8457 write_issue(
8459 &store,
8460 "2026-01-01-c.md",
8461 &sample_frontmatter(3, "C", "todo"),
8462 );
8463 let before = read_issue_file(&store, "2026-01-01-c.md");
8464 let refused = store
8465 .bulk_associate("p-unknown", &["3".to_string()])
8466 .unwrap();
8467 assert!(refused[0].outcome.is_err());
8468 assert_eq!(read_issue_file(&store, "2026-01-01-c.md"), before);
8469 }
8470
8471 #[test]
8472 fn t013_bulk_associate_is_idempotent_across_two_selectors_for_one_issue() {
8473 let (_tmp, store) = project_store();
8478 let outcomes = store
8479 .bulk_associate("p-1", &["1".to_string(), "2026-01-01-a.md".to_string()])
8480 .unwrap();
8481 assert_eq!(outcomes.len(), 2, "one entry per selector, in input order");
8482
8483 let first = outcomes[0].outcome.as_ref().unwrap();
8484 assert!(first.changed, "the first selector writes");
8485 assert!(first.written.is_some());
8486 assert!(first.event.is_some(), "and appends its one event");
8487
8488 let second = outcomes[1].outcome.as_ref().unwrap();
8489 assert!(
8490 !second.changed,
8491 "the repeated selector is the idempotent no-op"
8492 );
8493 assert!(second.written.is_none(), "and writes nothing a second time");
8494 assert!(second.event.is_none(), "and appends no second event");
8495 assert_eq!(
8496 second.project_ids,
8497 vec!["p-1".to_string()],
8498 "and still reports the current list"
8499 );
8500
8501 let after = reloaded(&store, "2026-01-01-a.md");
8502 assert_eq!(after.projects, Some(vec!["p-1".to_string()]));
8503 assert_eq!(
8504 after.history.events().len(),
8505 1,
8506 "exactly one History entry across both selectors"
8507 );
8508 }
8509
8510 #[test]
8511 fn t014_nfr_002_every_project_fixture_round_trips_with_no_key_added_removed_or_reordered() {
8512 let names = all_project_fixtures();
8513 for required in [
8514 "no-associations.md",
8515 "one-association.md",
8516 "several-associations.md",
8517 "missing-reference.md",
8518 "legacy-no-key.md",
8519 ] {
8520 assert!(
8521 names.iter().any(|name| name == required),
8522 "the fixture set must cover every shape T014 names; {required} is missing from {names:?}"
8523 );
8524 }
8525 for name in names {
8526 let original = project_fixture(&name);
8527 let issue = Issue::parse(&original).expect("fixture parses");
8528 assert_eq!(
8529 issue.to_file_contents(),
8530 original,
8531 "{name} must round-trip byte-identically"
8532 );
8533 assert_eq!(
8534 Issue::parse(&issue.to_file_contents())
8535 .unwrap()
8536 .to_file_contents(),
8537 original,
8538 "{name} must be byte-stable once re-read"
8539 );
8540 }
8541 }
8542
8543 #[test]
8544 fn t014_nfr_002_a_legacy_fixture_gains_no_projects_key() {
8545 let original = project_fixture("legacy-no-key.md");
8546 assert!(!original.contains("projects:"));
8547 let issue = Issue::parse(&original).unwrap();
8548 assert_eq!(issue.projects, None);
8549 assert_eq!(
8550 issue.to_file_contents(),
8551 original,
8552 "a pre-project file must not gain a key"
8553 );
8554 }
8555
8556 #[test]
8557 fn t014_c_003_a_multi_membership_fixture_is_byte_stable_and_ordered() {
8558 let original = project_fixture("several-associations.md");
8559 let issue = Issue::parse(&original).unwrap();
8560 assert_eq!(
8561 issue.projects,
8562 Some(vec![
8563 "p-platform".to_string(),
8564 "p-docs".to_string(),
8565 "p-infra".to_string()
8566 ]),
8567 "declaration order is preserved (NFR-002)"
8568 );
8569 assert_eq!(issue.to_file_contents(), original);
8570 }
8571
8572 #[test]
8573 fn t014_c_005_a_missing_reference_round_trips_through_a_store_unchanged() {
8574 let (_tmp, store) = make_store();
8577 write_issue(
8578 &store,
8579 "missing-reference.md",
8580 &project_fixture("missing-reference.md"),
8581 );
8582 assert!(
8583 store.config.projects.is_empty(),
8584 "the reference is missing by construction"
8585 );
8586
8587 let issue = store.get(IssueSelector::Number(4)).unwrap();
8588 assert_eq!(issue.projects, Some(vec!["p-deleted".to_string()]));
8589
8590 store.update(&issue.filename, &issue).unwrap();
8592 assert_eq!(
8593 read_issue_file(&store, "missing-reference.md"),
8594 project_fixture("missing-reference.md"),
8595 "saving a missing reference must not drop or rewrite it"
8596 );
8597 let reloaded_issue = store.get(IssueSelector::Number(4)).unwrap();
8598 assert_eq!(reloaded_issue.projects, Some(vec!["p-deleted".to_string()]));
8599 }
8600
8601 #[test]
8602 fn t014_c_003_associating_an_issue_writes_no_member_list_to_the_registry() {
8603 let (tmp, store) = project_store();
8604 let config_path = tmp.path().join("tix.toml");
8605 let before = fs::read_to_string(&config_path).unwrap();
8606 assert!(
8607 before.contains("[projects.p-1]"),
8608 "the record is persisted before the association: {before}"
8609 );
8610
8611 store.associate("1", "p-1").unwrap();
8612
8613 let after = fs::read_to_string(&config_path).unwrap();
8614 assert_eq!(
8615 after, before,
8616 "the project's registry record must not grow a member list"
8617 );
8618 assert!(
8619 !after.contains("members"),
8620 "membership is derived, never stored on the project:\n{after}"
8621 );
8622 }
8623
8624 fn epic_store() -> (TempDir, Store) {
8635 let tmp = TempDir::new().unwrap();
8636 let dir = tmp.path().join("issues");
8637 fs::create_dir_all(&dir).unwrap();
8638 let config_path = tmp.path().join("tix.toml");
8639 let mut config = Config {
8640 issues_dir: dir.clone(),
8641 ..Config::default()
8642 };
8643 config.layout.directory = "{assignee}".to_string();
8644 config.layout.move_on_change = false;
8645 config.epics.insert(
8646 "e-7c4a91".to_string(),
8647 EpicConfiguration {
8648 name: "One".into(),
8649 ..EpicConfiguration::default()
8650 },
8651 );
8652 config.save(&config_path).unwrap();
8653 let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
8654 let store = Store {
8655 issues_dir: dir,
8656 config,
8657 };
8658 write_issue(
8659 &store,
8660 "2026-01-01-a.md",
8661 &sample_frontmatter(1, "A", "todo"),
8662 );
8663 (tmp, store)
8664 }
8665
8666 fn epic_fixture(name: &str) -> String {
8669 let path = format!("{}/tests/fixtures/epics/{name}", env!("CARGO_MANIFEST_DIR"));
8670 fs::read_to_string(&path).unwrap_or_else(|e| panic!("reading fixture {name}: {e}"))
8671 }
8672
8673 fn all_epic_fixtures() -> Vec<String> {
8675 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/epics");
8676 let mut names: Vec<String> = fs::read_dir(&dir)
8677 .unwrap()
8678 .filter_map(|entry| entry.ok())
8679 .map(|entry| entry.file_name().to_string_lossy().into_owned())
8680 .filter(|name| name.ends_with(".md"))
8681 .collect();
8682 names.sort();
8683 names
8684 }
8685
8686 #[test]
8687 fn t015_an_accepted_reference_writes_the_list_and_exactly_one_history_entry() {
8688 let (_tmp, store) = epic_store();
8689 let before = read_issue_file(&store, "2026-01-01-a.md");
8690 let outcome = store.reference_epic("1", "e-7c4a91").unwrap();
8691
8692 assert!(outcome.changed, "an accepted, changing reference writes");
8693 assert!(outcome.written.is_some());
8694 assert_eq!(outcome.epic_ids, vec!["e-7c4a91".to_string()]);
8695 let event = outcome
8696 .event
8697 .clone()
8698 .expect("an accepted change is audited");
8699 assert_eq!(event.detail, "epic `e-7c4a91` referenced");
8700
8701 let after = reloaded(&store, "2026-01-01-a.md");
8703 assert_eq!(after.epics, Some(vec!["e-7c4a91".to_string()]));
8704 let events = after.history.events();
8705 assert_eq!(events.len(), 1, "exactly one History entry: {events:?}");
8706 assert_eq!(events[0].kind, HistoryEventKind::EpicReferenced);
8707 assert_eq!(events[0].detail, "epic `e-7c4a91` referenced");
8708 assert_ne!(
8709 read_issue_file(&store, "2026-01-01-a.md"),
8710 before,
8711 "an accepted change is written"
8712 );
8713 }
8714
8715 #[test]
8716 fn t015_a_refused_reference_writes_nothing_and_appends_nothing() {
8717 let (_tmp, store) = epic_store();
8718 let before = read_issue_file(&store, "2026-01-01-a.md");
8719
8720 let err = store.reference_epic("1", "e-unknown").unwrap_err();
8721 assert!(
8722 matches!(err, TixError::InvalidInput(_)),
8723 "an unknown epic is a user input refusal, got {err:?}"
8724 );
8725 assert_eq!(
8726 read_issue_file(&store, "2026-01-01-a.md"),
8727 before,
8728 "a refusal must leave the file byte-identical"
8729 );
8730 assert!(
8731 reloaded(&store, "2026-01-01-a.md").history.is_empty(),
8732 "a refusal must append nothing"
8733 );
8734
8735 let err = store.reference_epic("999", "e-7c4a91").unwrap_err();
8737 assert!(matches!(err, TixError::NotFound(_)), "got {err:?}");
8738 assert_eq!(read_issue_file(&store, "2026-01-01-a.md"), before);
8739 }
8740
8741 #[test]
8742 fn t015_a_refusal_reports_the_service_wording_not_a_storage_copy() {
8743 let (_tmp, store) = epic_store();
8746 let err = store.reference_epic("1", "e-unknown").unwrap_err();
8747 assert!(
8748 err.to_string()
8749 .contains(&crate::epics::unknown_epic_reason("e-unknown")),
8750 "the refusal must come from the service, got: {err}"
8751 );
8752 }
8753
8754 #[test]
8755 fn t015_re_referencing_is_idempotent_no_second_entry_and_no_second_event() {
8756 let (_tmp, store) = epic_store();
8757 store.reference_epic("1", "e-7c4a91").unwrap();
8758 let after_first = read_issue_file(&store, "2026-01-01-a.md");
8759
8760 let second = store.reference_epic("1", "e-7c4a91").unwrap();
8761 assert!(!second.changed, "the idempotent re-apply changes nothing");
8762 assert!(second.written.is_none(), "and writes no file");
8763 assert!(second.event.is_none(), "and appends no event");
8764 assert_eq!(second.epic_ids, vec!["e-7c4a91".to_string()]);
8765 assert_eq!(
8766 read_issue_file(&store, "2026-01-01-a.md"),
8767 after_first,
8768 "the file is byte-identical after a repeated reference"
8769 );
8770
8771 let after = reloaded(&store, "2026-01-01-a.md");
8772 assert_eq!(after.history.events().len(), 1, "no second History event");
8773 assert_eq!(
8774 after.epics,
8775 Some(vec!["e-7c4a91".to_string()]),
8776 "and no duplicate entry: (issue, epic_id) is unique"
8777 );
8778 }
8779
8780 #[test]
8781 fn t015_dereference_drops_only_the_reference_and_records_one_event() {
8782 let (_tmp, store) = epic_store();
8783 store.reference_epic("1", "e-7c4a91").unwrap();
8784
8785 let outcome = store.dereference_epic("1", "e-7c4a91").unwrap();
8786 assert!(outcome.changed);
8787 assert_eq!(outcome.epic_ids, [] as [std::string::String; 0]);
8788 let event = outcome
8789 .event
8790 .clone()
8791 .expect("an accepted change is audited");
8792 assert_eq!(event.detail, "epic `e-7c4a91` dereferenced");
8793
8794 let after = reloaded(&store, "2026-01-01-a.md");
8795 assert_eq!(after.epics, None, "the reference is dropped");
8796 assert!(
8797 !read_issue_file(&store, "2026-01-01-a.md").contains("epics:"),
8798 "an empty list is written as an absent key"
8799 );
8800 let events = after.history.events();
8801 assert_eq!(events.len(), 2, "reference + dereference = two entries");
8802 assert_eq!(events[1].kind, HistoryEventKind::EpicDereferenced);
8803
8804 let again = store.dereference_epic("1", "e-7c4a91").unwrap();
8806 assert!(!again.changed);
8807 assert!(again.event.is_none());
8808 assert_eq!(
8809 reloaded(&store, "2026-01-01-a.md").history.events().len(),
8810 2,
8811 "no third History event"
8812 );
8813 }
8814
8815 #[test]
8816 fn t015_dereference_retains_a_missing_reference_and_removes_it_verbatim() {
8817 let (_tmp, store) = epic_store();
8820 write_issue(
8821 &store,
8822 "2026-01-01-b.md",
8823 "---\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.",
8824 );
8825 let outcome = store.dereference_epic("2", "e-deleted").unwrap();
8826 assert!(outcome.changed, "a stale reference is removable");
8827 assert_eq!(outcome.epic_ids, [] as [std::string::String; 0]);
8828 assert_eq!(reloaded(&store, "2026-01-01-b.md").epics, None);
8829 }
8830
8831 #[test]
8832 fn t015_bulk_reference_writes_each_affected_file_independently() {
8833 let (_tmp, store) = epic_store();
8834 write_issue(
8835 &store,
8836 "2026-01-01-b.md",
8837 &sample_frontmatter(2, "B", "todo"),
8838 );
8839
8840 let outcomes = store
8841 .bulk_reference(
8842 "e-7c4a91",
8843 &["1".to_string(), "999".to_string(), "2".to_string()],
8844 )
8845 .unwrap();
8846
8847 assert_eq!(outcomes.len(), 3, "one entry per selector, in input order");
8848 assert_eq!(outcomes[0].selector, "1");
8849 assert!(outcomes[0].outcome.as_ref().unwrap().changed);
8850 assert!(
8851 outcomes[1].outcome.is_err(),
8852 "a bad selector is its own error, not a failure of the run"
8853 );
8854 assert!(outcomes[2].outcome.as_ref().unwrap().changed);
8855
8856 for filename in ["2026-01-01-a.md", "2026-01-01-b.md"] {
8857 let issue = reloaded(&store, filename);
8858 assert_eq!(issue.epics, Some(vec!["e-7c4a91".to_string()]));
8859 assert_eq!(issue.history.events().len(), 1, "{filename}");
8860 }
8861
8862 write_issue(
8864 &store,
8865 "2026-01-01-c.md",
8866 &sample_frontmatter(3, "C", "todo"),
8867 );
8868 let before = read_issue_file(&store, "2026-01-01-c.md");
8869 let refused = store
8870 .bulk_reference("e-unknown", &["3".to_string()])
8871 .unwrap();
8872 assert!(refused[0].outcome.is_err());
8873 assert_eq!(read_issue_file(&store, "2026-01-01-c.md"), before);
8874 }
8875
8876 #[test]
8877 fn t015_bulk_reference_is_idempotent_across_two_selectors_for_one_issue() {
8878 let (_tmp, store) = epic_store();
8881 let outcomes = store
8882 .bulk_reference(
8883 "e-7c4a91",
8884 &["1".to_string(), "2026-01-01-a.md".to_string()],
8885 )
8886 .unwrap();
8887 assert_eq!(outcomes.len(), 2, "one entry per selector, in input order");
8888
8889 let first = outcomes[0].outcome.as_ref().unwrap();
8890 assert!(first.changed, "the first selector writes");
8891 assert!(first.written.is_some());
8892 assert!(first.event.is_some(), "and appends its one event");
8893
8894 let second = outcomes[1].outcome.as_ref().unwrap();
8895 assert!(
8896 !second.changed,
8897 "the repeated selector is the idempotent no-op"
8898 );
8899 assert!(second.written.is_none(), "and writes nothing a second time");
8900 assert!(second.event.is_none(), "and appends no second event");
8901 assert_eq!(
8902 second.epic_ids,
8903 vec!["e-7c4a91".to_string()],
8904 "and still reports the current list"
8905 );
8906
8907 let after = reloaded(&store, "2026-01-01-a.md");
8908 assert_eq!(after.epics, Some(vec!["e-7c4a91".to_string()]));
8909 assert_eq!(
8910 after.history.events().len(),
8911 1,
8912 "exactly one History entry across both selectors"
8913 );
8914 }
8915
8916 #[test]
8917 fn t016_nfr_002_every_epic_fixture_round_trips_with_no_key_added_removed_or_reordered() {
8918 let names = all_epic_fixtures();
8919 for required in [
8920 "no-references.md",
8921 "one-reference.md",
8922 "several-references.md",
8923 "missing-reference.md",
8924 "legacy-no-key.md",
8925 "task-labelled.md",
8926 ] {
8927 assert!(
8928 names.iter().any(|name| name == required),
8929 "the fixture set must cover every shape T016 names; {required} is missing from {names:?}"
8930 );
8931 }
8932 for name in names {
8933 let original = epic_fixture(&name);
8934 let issue = Issue::parse(&original).expect("fixture parses");
8935 assert_eq!(
8936 issue.to_file_contents(),
8937 original,
8938 "{name} must round-trip byte-identically"
8939 );
8940 assert_eq!(
8941 Issue::parse(&issue.to_file_contents())
8942 .unwrap()
8943 .to_file_contents(),
8944 original,
8945 "{name} must be byte-stable once re-read"
8946 );
8947 }
8948 }
8949
8950 #[test]
8951 fn t016_nfr_002_a_legacy_fixture_gains_no_epics_key() {
8952 let original = epic_fixture("legacy-no-key.md");
8953 assert!(!original.contains("epics:"));
8954 let issue = Issue::parse(&original).unwrap();
8955 assert_eq!(issue.epics, None);
8956 assert_eq!(
8957 issue.to_file_contents(),
8958 original,
8959 "a pre-epic file must not gain a key"
8960 );
8961 }
8962
8963 #[test]
8964 fn t016_c_003_a_multi_reference_fixture_is_byte_stable_and_ordered() {
8965 let original = epic_fixture("several-references.md");
8966 let issue = Issue::parse(&original).unwrap();
8967 assert_eq!(
8968 issue.epics,
8969 Some(vec![
8970 "e-platform".to_string(),
8971 "e-docs".to_string(),
8972 "e-infra".to_string()
8973 ]),
8974 "declaration order is preserved (NFR-002)"
8975 );
8976 assert_eq!(issue.to_file_contents(), original);
8977 }
8978
8979 #[test]
8980 fn t016_c_005_a_missing_reference_round_trips_through_a_store_unchanged() {
8981 let (_tmp, store) = make_store();
8984 write_issue(
8985 &store,
8986 "missing-reference.md",
8987 &epic_fixture("missing-reference.md"),
8988 );
8989 assert!(
8990 store.config.epics.is_empty(),
8991 "the reference is missing by construction"
8992 );
8993
8994 let issue = store.get(IssueSelector::Number(4)).unwrap();
8995 assert_eq!(issue.epics, Some(vec!["e-deleted".to_string()]));
8996
8997 store.update(&issue.filename, &issue).unwrap();
8999 assert_eq!(
9000 read_issue_file(&store, "missing-reference.md"),
9001 epic_fixture("missing-reference.md"),
9002 "saving a missing reference must not drop or rewrite it"
9003 );
9004 let reloaded_issue = store.get(IssueSelector::Number(4)).unwrap();
9005 assert_eq!(reloaded_issue.epics, Some(vec!["e-deleted".to_string()]));
9006 }
9007
9008 #[test]
9009 fn t016_c_001_a_task_labelled_fixture_round_trips_and_gains_no_task_key() {
9010 let original = epic_fixture("task-labelled.md");
9014 let issue = Issue::parse(&original).unwrap();
9015 assert_eq!(issue.epics, Some(vec!["e-7c4a91".to_string()]));
9016 assert_eq!(issue.task, Some("WP03".to_string()));
9017
9018 let rendered = issue.to_file_contents();
9019 assert_eq!(rendered, original, "the fixture round-trips unchanged");
9020 assert_eq!(
9021 rendered.matches("task:").count(),
9022 1,
9023 "exactly one `task:` key — the Spec Kitty pointer, never a second:\n{rendered}"
9024 );
9025 for forbidden in ["work_item:", "epic_task:"] {
9026 assert!(
9027 !rendered.contains(forbidden),
9028 "`{forbidden}` must not exist (C-001):\n{rendered}"
9029 );
9030 }
9031 }
9032
9033 #[test]
9034 fn t016_c_003_referencing_an_issue_writes_no_member_list_to_the_registry() {
9035 let (tmp, store) = epic_store();
9036 let config_path = tmp.path().join("tix.toml");
9037 let before = fs::read_to_string(&config_path).unwrap();
9038 assert!(
9039 before.contains("[epics") && before.contains("e-7c4a91"),
9040 "the record is persisted before the reference: {before}"
9041 );
9042
9043 store.reference_epic("1", "e-7c4a91").unwrap();
9044
9045 let after = fs::read_to_string(&config_path).unwrap();
9046 assert_eq!(
9047 after, before,
9048 "the epic's registry record must not grow a member list"
9049 );
9050 assert!(
9051 !after.contains("members"),
9052 "membership is derived, never stored on the epic:\n{after}"
9053 );
9054 }
9055}