1use crate::commands::link;
2use crate::commands::parse_selector;
3use crate::config::ProjectId;
4use crate::error::Result;
5use crate::model::Issue;
6use crate::projects::{ProjectHealthReport, ProjectIndex};
7use crate::relationships::IssueView;
8use crate::relationships::RelationshipGraph;
9use crate::storage::Store;
10use std::collections::{BTreeMap, HashSet};
11
12impl crate::projects::HealthNode for Issue {
19 fn assignee(&self) -> Option<&str> {
20 self.assignee.as_deref()
21 }
22}
23
24pub fn run(store: &Store, raw: &str, show: Option<&str>) -> Result<()> {
35 let want_relationships = link::wants_relationships(show)?;
43 let issue = get(store, raw)?;
44 print!("{}", issue.to_file_contents());
45 {
54 let (index, issues, done_column) = crate::commands::parent::source(store)?;
55 let facts = crate::commands::parent::facts_for(&index, &issues, issue.number, &done_column);
56 print!("{}", crate::commands::parent::detail_block(&facts));
57 }
58 {
68 let (_, resolution) = crate::workflow::active_resolution(&store.config);
69 print!("{}", category_block(&issue, &resolution));
70 }
71 print!("{}", projects_block(store, &issue)?);
75 print!("{}", epics_block(store, &issue)?);
80 print!("{}", mentions_block(store, &issue)?);
87 if want_relationships {
88 let (issues, done_column) = store.graph_source()?;
91 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
92 let graph = RelationshipGraph::new(&views, &done_column);
93 print!("{}", link::derived_block(&graph, issue.number));
94 }
95 Ok(())
96}
97
98pub fn mentions_block(store: &Store, issue: &Issue) -> Result<String> {
106 let Some(mentions) = store.mention_store() else {
107 return Ok(String::new());
108 };
109 let pending =
110 mentions.pending_for_issue(&crate::mentions::identity::local_candidates(), issue.number);
111 if pending.is_empty() {
112 return Ok(String::new());
113 }
114 let mut out = String::new();
115 let _ = std::fmt::Write::write_fmt(
116 &mut out,
117 format_args!("\nMentions for you ({}):\n\n", pending.len()),
118 );
119 for mention in &pending {
120 let _ = std::fmt::Write::write_fmt(
121 &mut out,
122 format_args!(" {} {}\n", mention.id, mention.context),
123 );
124 }
125 let _ = std::fmt::Write::write_fmt(
126 &mut out,
127 format_args!("\n act with: tix mentions read <id> | ignore <id> | jump <id>\n"),
128 );
129 Ok(out)
130}
131
132pub fn category_block(issue: &Issue, resolution: &crate::workflow::CategoryResolution) -> String {
151 let (key, label, meaning) = match crate::workflow::category_of(issue, resolution) {
152 Some(c) => {
153 let meaning = if c.is_complete() {
154 "complete"
155 } else if c.is_terminal() {
156 "terminal, not complete"
157 } else {
158 "in progress"
159 };
160 (c.key(), c.label(), meaning)
161 }
162 None => (
163 "(unmapped)",
164 "(unmapped)",
165 "unmapped column: this board configures no category for it",
166 ),
167 };
168 format!("\n## Workflow\n\n- category: {key} ({label})\n- workflow: {meaning}\n")
169}
170
171pub(crate) fn project_reports(
183 store: &Store,
184 issues: &[Issue],
185) -> Result<(ProjectIndex, BTreeMap<ProjectId, ProjectHealthReport>)> {
186 let index = ProjectIndex::build(&store.config, issues);
187 let assigned = crate::projects::assigned_keys(issues);
188 let planner =
189 crate::planning::Planner::from_config(store.config.clone(), std::path::PathBuf::new());
190 let overdue: HashSet<u32> = planner
191 .overdue_batch(store, &crate::model::today())
192 .iter()
193 .map(|issue| issue.number)
194 .collect();
195 let blocked = blocked_keys(store)?;
196 let mut reports = BTreeMap::new();
197 for project in index.projects() {
198 let report = index.health(
199 &store.config,
200 project.id.as_str(),
201 &assigned,
202 &overdue,
203 &blocked,
204 )?;
205 reports.insert(project.id.clone(), report);
206 }
207 Ok((index, reports))
208}
209
210fn blocked_keys(store: &Store) -> Result<HashSet<u32>> {
215 let (issues, done_column) = store.graph_source()?;
216 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
217 let graph = RelationshipGraph::new(&views, &done_column);
218 Ok(issues
219 .iter()
220 .filter(|issue| graph.blocked_state(issue.number).is_blocked)
221 .map(|issue| issue.number)
222 .collect())
223}
224
225pub(crate) fn health_suffix(report: &ProjectHealthReport) -> String {
228 format!(
229 " done, overdue {}, blocked {}, unassigned {}",
230 report.health.overdue, report.health.blocked, report.health.unassigned
231 )
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
245pub struct EpicBlockEntry {
246 pub heading: String,
248 pub detail: Vec<String>,
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
260pub(crate) enum EpicRefItem {
261 Resolved(
263 Box<crate::epics::EpicRollup>,
264 Box<crate::epics::EpicHierarchy>,
265 ),
266 Missing { id: String, reason: String },
268}
269
270pub(crate) fn progress_figure(progress: &crate::epics::EpicProgress) -> String {
272 format!("{}/{} done", progress.done, progress.total)
273}
274
275pub(crate) fn weighted_figure(weighted: &crate::epics::WeightedProgress) -> String {
277 match weighted.percent {
278 Some(percent) => format!(
279 "{}/{} ({percent}%)",
280 weighted.done_weight, weighted.total_weight
281 ),
282 None => "none (no weighted children)".to_string(),
283 }
284}
285
286pub(crate) fn epic_ref_items(
299 config: &crate::config::Config,
300 index: &crate::epics::EpicIndex,
301 targets: &crate::epics::ReferenceTargets,
302 graph: &RelationshipGraph<'_>,
303 issue: &Issue,
304) -> Result<Vec<EpicRefItem>> {
305 let Some(ids) = issue.epics.as_ref().filter(|ids| !ids.is_empty()) else {
306 return Ok(Vec::new());
307 };
308 let holder = crate::epics::ReferenceHolder::Issue(crate::epics::IssueKey::new(issue.number));
309 let mut out = Vec::new();
310 let mut seen: Vec<&str> = Vec::new();
311 for raw in ids {
312 if seen.contains(&raw.as_str()) {
313 continue;
314 }
315 seen.push(raw.as_str());
316 let resolved = crate::config::EpicId::new(raw)
317 .ok()
318 .filter(|id| index.epic(id).is_some());
319 match resolved {
320 Some(id) => {
321 let rollup = index
322 .rollup(config, id.as_str(), targets, graph)
323 .map_err(|err| crate::error::TixError::InvalidInput(err.message()))?;
324 let hierarchy = index
325 .hierarchy(id.as_str())
326 .map_err(|err| crate::error::TixError::InvalidInput(err.message()))?;
327 out.push(EpicRefItem::Resolved(Box::new(rollup), Box::new(hierarchy)));
328 }
329 None => {
330 let reason = index
331 .missing()
332 .iter()
333 .find(|missing| missing.holder == holder && missing.target == raw.as_str())
334 .map(|missing| missing.reason.clone())
335 .unwrap_or_else(|| {
336 if crate::config::EpicId::new(raw).is_ok() {
337 crate::epics::missing_epic_reason(&holder, raw)
338 } else {
339 crate::epics::malformed_epic_reason(issue.number, raw)
340 }
341 });
342 out.push(EpicRefItem::Missing {
343 id: raw.clone(),
344 reason,
345 });
346 }
347 }
348 }
349 Ok(out)
350}
351
352fn epic_block_entry(item: &EpicRefItem) -> EpicBlockEntry {
354 match item {
355 EpicRefItem::Missing { id, reason } => EpicBlockEntry {
356 heading: format!("{id} (missing reference)"),
357 detail: vec![reason.clone()],
358 },
359 EpicRefItem::Resolved(rollup, hierarchy) => resolved_entry(rollup, hierarchy),
360 }
361}
362
363fn resolved_entry(
368 rollup: &crate::epics::EpicRollup,
369 hierarchy: &crate::epics::EpicHierarchy,
370) -> EpicBlockEntry {
371 let mut detail = vec![
372 format!("progress: {}", progress_figure(&rollup.progress)),
373 format!(
374 "weighted progress: {}",
375 weighted_figure(&rollup.weighted_progress)
376 ),
377 format!(
378 "weight: {}",
379 rollup
380 .weighted_progress
381 .own_weight
382 .map_or_else(|| "unset".to_string(), |weight| weight.to_string())
383 ),
384 format!("children: {}", rollup.child_count),
385 match (&hierarchy.parent, &hierarchy.unresolved) {
386 (Some(parent), _) => format!("parent: {}", parent.display),
387 (None, Some(unresolved)) => format!(
388 "parent: {} (missing reference: {})",
389 unresolved.recorded_parent, unresolved.reason
390 ),
391 (None, None) => "parent: (none)".to_string(),
392 },
393 format!("depth: {}", hierarchy.depth),
394 format!("blocked: {}", rollup.blocked),
395 ];
396 for member in &rollup.blocked_members {
397 detail.push(format!(" {}: {}", member.display, member.reason));
398 }
399 for missing in &rollup.missing_references {
400 detail.push(format!(
401 "missing {} reference: {}",
402 missing.target_kind.as_str(),
403 missing.reason
404 ));
405 }
406 EpicBlockEntry {
407 heading: crate::epics::epic_display(&rollup.epic_id, &rollup.name),
408 detail,
409 }
410}
411
412pub(crate) fn epic_cell(item: &EpicRefItem) -> String {
414 match item {
415 EpicRefItem::Missing { id, .. } => format!("{id} (missing reference)"),
416 EpicRefItem::Resolved(rollup, _) => format!(
417 "{} {}, weighted {}, blocked {}",
418 crate::epics::epic_display(&rollup.epic_id, &rollup.name),
419 progress_figure(&rollup.progress),
420 weighted_figure(&rollup.weighted_progress),
421 rollup.blocked,
422 ),
423 }
424}
425
426pub(crate) fn epic_entries_over(
434 config: &crate::config::Config,
435 snapshot: &[Issue],
436 done_column: &str,
437 issue: &Issue,
438) -> Result<Vec<EpicBlockEntry>> {
439 let index = crate::epics::EpicIndex::build(config, snapshot);
440 let planner = crate::planning::Planner::from_config(config.clone(), std::path::PathBuf::new());
441 let targets = crate::epics::ReferenceTargets::from_planner(&planner)?;
442 let graph = crate::commands::graph_over(snapshot, done_column);
443 let items = epic_ref_items(config, &index, &targets, &graph, issue)?;
444 Ok(items.iter().map(epic_block_entry).collect())
445}
446
447pub fn epic_entries(store: &Store, issue: &Issue) -> Result<Vec<EpicBlockEntry>> {
453 let (issues, done_column) = store.graph_source()?;
456 epic_entries_over(&store.config, &issues, &done_column, issue)
457}
458
459pub(crate) fn epic_cells(store: &Store, rows: &[Issue]) -> Result<BTreeMap<u32, String>> {
469 let (snapshot, done_column) = store.graph_source()?;
473 let index = crate::epics::EpicIndex::build(&store.config, &snapshot);
474 let planner =
475 crate::planning::Planner::from_config(store.config.clone(), std::path::PathBuf::new());
476 let targets = crate::epics::ReferenceTargets::from_planner(&planner)?;
477 let graph = crate::commands::graph_over(&snapshot, &done_column);
478 let mut out = BTreeMap::new();
479 for issue in rows {
480 let items = epic_ref_items(&store.config, &index, &targets, &graph, issue)?;
481 if items.is_empty() {
482 continue;
483 }
484 out.insert(
485 issue.number,
486 items.iter().map(epic_cell).collect::<Vec<_>>().join("; "),
487 );
488 }
489 Ok(out)
490}
491
492#[derive(Debug, Clone, PartialEq, Eq)]
494pub struct EpicMemberRef {
495 pub number: u32,
497 pub display: String,
499}
500
501#[derive(Debug, Clone, PartialEq, Eq)]
508pub struct EpicViewEntry {
509 pub epic: EpicBlockEntry,
511 pub members: Vec<EpicMemberRef>,
513}
514
515pub fn epic_view(store: &Store) -> Result<Vec<EpicViewEntry>> {
523 let (snapshot, done_column) = store.graph_source()?;
528 epic_view_over(&store.config, &snapshot, &done_column)
529}
530
531pub fn epic_view_over(
540 config: &crate::config::Config,
541 issues: &[Issue],
542 done_column: &str,
543) -> Result<Vec<EpicViewEntry>> {
544 let index = crate::epics::EpicIndex::build(config, issues);
545 let planner = crate::planning::Planner::from_config(config.clone(), std::path::PathBuf::new());
546 let targets = crate::epics::ReferenceTargets::from_planner(&planner)?;
547 let graph = crate::commands::graph_over(issues, done_column);
548 let mut out = Vec::new();
549 for epic in index.epics() {
550 let id = epic.id.clone();
551 let rollup = index
552 .rollup(config, id.as_str(), &targets, &graph)
553 .map_err(|err| crate::error::TixError::InvalidInput(err.message()))?;
554 let hierarchy = index
555 .hierarchy(id.as_str())
556 .map_err(|err| crate::error::TixError::InvalidInput(err.message()))?;
557 let members = epic
558 .members
559 .iter()
560 .map(|member| EpicMemberRef {
561 number: member.issue.number(),
562 display: member.display.clone(),
563 })
564 .collect();
565 out.push(EpicViewEntry {
566 epic: resolved_entry(&rollup, &hierarchy),
567 members,
568 });
569 }
570 Ok(out)
571}
572
573pub fn epics_block(store: &Store, issue: &Issue) -> Result<String> {
584 let entries = epic_entries(store, issue)?;
585 if entries.is_empty() {
586 return Ok(String::new());
587 }
588 let mut out = String::from("\n## Epics\n\n");
589 for entry in entries {
590 out.push_str(&format!("- {}\n", entry.heading));
591 for line in entry.detail {
592 out.push_str(&format!(" - {line}\n"));
593 }
594 }
595 Ok(out)
596}
597
598pub fn projects_block(store: &Store, issue: &Issue) -> Result<String> {
609 let Some(ids) = issue.projects.as_ref().filter(|ids| !ids.is_empty()) else {
610 return Ok(String::new());
611 };
612 let issues = store.list()?;
613 let (_index, reports) = project_reports(store, &issues)?;
614 let mut out = String::from("\n## Projects\n\n");
615 let mut seen: Vec<&str> = Vec::new();
616 for raw in ids {
617 if seen.contains(&raw.as_str()) {
618 continue;
619 }
620 seen.push(raw.as_str());
621 match ProjectId::new(raw).ok().and_then(|id| reports.get(&id)) {
622 Some(report) => out.push_str(&format!(
623 "- {} {}/{}{}\n",
624 crate::projects::project_display(&report.project_id, &report.name),
625 report.progress.done,
626 report.progress.total,
627 health_suffix(report),
628 )),
629 None => out.push_str(&format!(
630 "- {raw} (missing reference): {}\n",
631 crate::projects::missing_reference_reason(issue.number, raw)
632 )),
633 }
634 }
635 Ok(out)
636}
637
638pub fn get(store: &Store, raw: &str) -> Result<Issue> {
645 let selector = parse_selector(store, raw)?;
646 store.get(selector)
647}
648
649#[cfg(test)]
650mod tests {
651 #[allow(clippy::wildcard_imports)]
652 use super::*;
653 use crate::model::{Issue, Priority, Status};
654 use std::fs;
655 use tempfile::TempDir;
656
657 fn make_store() -> (TempDir, Store) {
658 let tmp = TempDir::new().unwrap();
659 let dir = tmp.path().join("issues");
660 fs::create_dir_all(dir.join("alice")).unwrap();
661 let config_path = tmp.path().join("tix.toml");
662 let config = crate::config::Config {
663 issues_dir: dir.clone(),
664 ..crate::config::Config::default()
665 };
666 config.save(&config_path).unwrap();
667 let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
668 (
669 tmp,
670 Store {
671 issues_dir: dir,
672 config,
673 },
674 )
675 }
676
677 fn seed(store: &Store, number: u32, title: &str) -> Issue {
678 let mut issue = Issue {
679 projects: None,
680 id: None,
681 number,
682 title: title.to_string(),
683 status: Status::new("todo"),
684 labels: vec![],
685 priority: Priority::default(),
686 assignee: Some("alice".to_string()),
687 estimate: None,
688 commit: None,
689 gitlab_id: None,
690 linear_id: None,
691 mission: None,
692 task: None,
693 relationships: vec![],
694 related_issues: vec![],
695 files: vec![],
696 cycles: None,
697 milestones: None,
698 reviewer: None,
699 start_date: None,
700 due_date: None,
701 created: "2026-01-01".to_string(),
702 type_: None,
703 updated: "2026-01-01".to_string(),
704 filename: String::new(),
705 body: "the body".to_string(),
706 history: crate::model::History::new(),
707 has_retired_links_key: false,
708 time_entries: vec![],
709 parent: None,
710 series: None,
711 health: None,
712 epics: None,
713 extra_frontmatter: std::collections::BTreeMap::new(),
714 archived: false,
715 entered_at: None,
716 source: None,
717 };
718 issue.filename = format!("alice/2026-01-01-{}.md", crate::slug::slugify(title));
719 fs::write(
720 store.issues_dir.join(&issue.filename),
721 issue.to_file_contents(),
722 )
723 .unwrap();
724 issue
725 }
726
727 #[test]
728 fn get_returns_the_full_record() {
729 let (tmp, store) = make_store();
730 let _ = tmp;
731 let seeded = seed(&store, 1, "Get me");
732 let issue = get(&store, &seeded.number.to_string()).unwrap();
733 assert_eq!(issue.title, "Get me");
734 assert_eq!(issue.body, "the body");
735 }
736
737 #[test]
738 fn get_selector_miss_is_not_found() {
739 let (tmp, store) = make_store();
740 let _ = tmp;
741 let err = get(&store, "999").unwrap_err();
742 assert!(
743 matches!(err, crate::error::TixError::NotFound(_)),
744 "missing issue must surface as NotFound, got {err}"
745 );
746 }
747
748 #[test]
749 fn get_ambiguous_selector_is_an_error() {
750 let (tmp, store) = make_store();
751 let _ = tmp;
752 seed(&store, 1, "Shared title");
753 seed(&store, 2, "Shared title other");
754 let err = get(&store, "Shared").unwrap_err();
757 assert!(
758 matches!(err, crate::error::TixError::InvalidInput(_)),
759 "ambiguous selector must be an InvalidInput error, got {err}"
760 );
761 }
762
763 #[test]
766 fn show_relationships_is_off_by_default_so_the_output_is_still_the_file() {
767 let (tmp, store) = make_store();
770 let _ = tmp;
771 let seeded = seed(&store, 1, "Plain");
772 let on_disk = fs::read_to_string(store.issues_dir.join(&seeded.filename)).unwrap();
773 assert_eq!(
774 get(&store, "1").unwrap().to_file_contents(),
775 on_disk,
776 "precondition: the record round-trips to exactly the file's bytes"
777 );
778 assert!(!link::wants_relationships(None).unwrap());
779 }
780
781 #[test]
782 fn the_derived_block_shows_an_edge_stored_on_the_other_endpoint() {
783 let (tmp, store) = make_store();
788 let _ = tmp;
789 let source = seed(&store, 1, "Source");
790 let target = seed(&store, 2, "Target");
791 store
792 .link(
793 &source.number.to_string(),
794 &target.number.to_string(),
795 crate::relationships::RelationKind::Blocks,
796 )
797 .unwrap();
798
799 let target_record = get(&store, "2").unwrap();
801 assert!(
802 target_record.relationships.is_empty(),
803 "precondition: the edge is stored once, on the source"
804 );
805
806 let (issues, done_column) = store.graph_source().unwrap();
807 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
808 let graph = RelationshipGraph::new(&views, &done_column);
809 let block = link::derived_block(&graph, target_record.number);
810
811 assert!(
812 block.contains("Source"),
813 "the target must see the edge that names it:\n{block}"
814 );
815 assert!(
816 block.contains("blocked"),
817 "and the blocked reason follows from it:\n{block}"
818 );
819 }
820 fn mapped_store(columns: &[&str], done_column: &str, map: &[(&str, &str)]) -> (TempDir, Store) {
826 let tmp = TempDir::new().unwrap();
827 let dir = tmp.path().join("issues");
828 fs::create_dir_all(dir.join("alice")).unwrap();
829 let config = crate::config::Config {
830 issues_dir: dir.clone(),
831 kanban: crate::config::KanbanSection {
832 configurations: indexmap::IndexMap::from([(
833 "default".to_string(),
834 crate::config::BoardConfiguration {
835 columns: columns.iter().map(|c| (*c).to_string()).collect(),
836 done_column: Some(done_column.to_string()),
837 category_map: map
838 .iter()
839 .map(|(c, k)| ((*c).to_string(), (*k).to_string()))
840 .collect(),
841 ..Default::default()
842 },
843 )]),
844 },
845 ..crate::config::Config::default()
846 };
847 config.save(&tmp.path().join("tix.toml")).unwrap();
848 let config =
849 crate::config::Config::load_with_warning(&tmp.path().join("tix.toml"), |_| {}).unwrap();
850 (
851 tmp,
852 Store {
853 issues_dir: dir,
854 config,
855 },
856 )
857 }
858
859 fn five_categories() -> (TempDir, Store) {
860 mapped_store(
861 &["triage", "wip", "review", "shipped", "abandoned"],
862 "shipped",
863 &[
864 ("triage", "backlog"),
865 ("wip", "active"),
866 ("review", "active"),
867 ("shipped", "completed"),
868 ("abandoned", "canceled"),
869 ],
870 )
871 }
872
873 fn write(store: &Store, number: u32, title: &str, column: &str) {
874 let slug = crate::slug::slugify(title);
875 fs::write(
876 store.issues_dir.join(format!("alice/2026-01-01-{slug}.md")),
877 format!(
878 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{column}\"\n\
879 labels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\n\
880 updated: \"2026-01-01\"\n---\n\nbody\n"
881 ),
882 )
883 .unwrap();
884 }
885
886 #[test]
887 fn t036_the_detail_block_carries_the_key_the_label_and_the_completion_context() {
888 let (_tmp, store) = five_categories();
889 write(&store, 1, "Shipped", "shipped");
890 write(&store, 2, "Abandoned", "abandoned");
891 write(&store, 3, "Wip", "wip");
892 let (_, resolution) = crate::workflow::active_resolution(&store.config);
893 for (selector, key, label, meaning) in [
894 ("1", "completed", "Done", "complete"),
895 ("2", "canceled", "Canceled", "terminal, not complete"),
896 ("3", "active", "In progress", "in progress"),
897 ] {
898 let issue = crate::commands::view::get(&store, selector).unwrap();
899 let block = crate::commands::view::category_block(&issue, &resolution);
900 assert!(
901 block.contains(&format!("- category: {key} ({label})")),
902 "{selector}: {block}"
903 );
904 assert!(
905 block.contains(&format!("- workflow: {meaning}")),
906 "{selector}: {block}"
907 );
908 }
909 }
910
911 #[test]
912 fn t036_a_canceled_issue_never_reads_as_complete() {
913 let (_tmp, store) = five_categories();
917 write(&store, 1, "Abandoned", "abandoned");
918 let (_, resolution) = crate::workflow::active_resolution(&store.config);
919 let issue = crate::commands::view::get(&store, "1").unwrap();
920 let block = crate::commands::view::category_block(&issue, &resolution);
921 assert!(block.contains("terminal, not complete"), "{block}");
922 assert!(
923 !block.contains("workflow: complete"),
924 "a canceled issue must never say complete: {block}"
925 );
926 }
927
928 #[test]
929 fn t036_an_unmapped_column_shows_a_label_not_an_error_and_not_a_blank() {
930 let (_tmp, store) = mapped_store(
935 &["triage", "wip", "shipped"],
936 "shipped",
937 &[("wip", "active"), ("shipped", "completed")],
938 );
939 write(&store, 1, "Defaulted", "triage");
940 write(&store, 2, "Orphan", "not-a-column");
941 let (_, resolution) = crate::workflow::active_resolution(&store.config);
942 let defaulted = crate::commands::view::get(&store, "1").unwrap();
943 let block = crate::commands::view::category_block(&defaulted, &resolution);
944 assert!(block.contains("unstarted (Not started)"), "{block}");
945 let orphan = crate::commands::view::get(&store, "2").unwrap();
946 let block = crate::commands::view::category_block(&orphan, &resolution);
947 assert!(
948 block.contains("category: (unmapped) ((unmapped))"),
949 "{block}"
950 );
951 assert!(
952 block.contains("this board configures no category for it"),
953 "an unmapped column is stated, not left blank: {block}"
954 );
955 }
956
957 fn project_store(projects: &[(&str, &str)]) -> (TempDir, Store) {
962 let tmp = TempDir::new().unwrap();
963 let dir = tmp.path().join("issues");
964 fs::create_dir_all(dir.join("alice")).unwrap();
965 let mut config = crate::config::Config {
966 issues_dir: dir.clone(),
967 ..crate::config::Config::default()
968 };
969 for (id, name) in projects {
970 config.projects.insert(
971 (*id).to_string(),
972 crate::config::ProjectConfiguration {
973 name: (*name).to_string(),
974 ..crate::config::ProjectConfiguration::default()
975 },
976 );
977 }
978 (
979 tmp,
980 Store {
981 issues_dir: dir,
982 config,
983 },
984 )
985 }
986
987 fn rewrite(store: &Store, mut issue: Issue, projects: &[&str], assignee: Option<&str>) {
989 issue.projects = Some(projects.iter().map(|p| (*p).to_string()).collect());
990 issue.assignee = assignee.map(str::to_string);
991 fs::write(
992 store.issues_dir.join(&issue.filename),
993 issue.to_file_contents(),
994 )
995 .unwrap();
996 }
997
998 #[test]
999 fn t017_the_project_block_shows_both_projects_with_id_name_and_health() {
1000 let (_tmp, store) = project_store(&[("p-a", "Platform"), ("p-b", "Docs")]);
1001 rewrite(
1002 &store,
1003 seed(&store, 1, "Shared"),
1004 &["p-a", "p-b"],
1005 Some("alice"),
1006 );
1007 let loaded = get(&store, "1").unwrap();
1008 let block = projects_block(&store, &loaded).unwrap();
1009 assert!(block.contains("## Projects"), "{block}");
1010 assert!(
1011 block.contains("- Platform (p-a) 0/1 done, overdue 0, blocked 0, unassigned 0"),
1012 "the id, the name, and every figure are rendered: {block}"
1013 );
1014 assert!(block.contains("- Docs (p-b) 0/1 done"), "{block}");
1015 }
1016
1017 #[test]
1018 fn t017_an_issue_in_no_project_renders_no_project_heading() {
1019 let (_tmp, store) = project_store(&[("p-a", "Platform")]);
1020 seed(&store, 1, "None");
1021 let loaded = get(&store, "1").unwrap();
1022 assert_eq!(
1023 projects_block(&store, &loaded).unwrap(),
1024 "",
1025 "an empty association list is the empty state, not an empty heading"
1026 );
1027 }
1028
1029 #[test]
1030 fn t017_a_missing_reference_is_rendered_with_its_id_and_reason() {
1031 let (_tmp, store) = project_store(&[("p-a", "Platform")]);
1032 rewrite(
1033 &store,
1034 seed(&store, 1, "Dangling"),
1035 &["p-a", "p-gone"],
1036 Some("alice"),
1037 );
1038 let loaded = get(&store, "1").unwrap();
1039 let block = projects_block(&store, &loaded).unwrap();
1040 assert!(
1041 block.contains("- p-gone (missing reference):"),
1042 "a dangling association is named, not dropped: {block}"
1043 );
1044 assert!(
1045 block.contains("not in the registry"),
1046 "the service's reason is rendered: {block}"
1047 );
1048 assert!(
1049 block.contains("Platform (p-a)"),
1050 "the resolvable association is still shown: {block}"
1051 );
1052 }
1053
1054 #[test]
1055 fn t019_c006_show_and_list_report_the_same_figures_for_one_snapshot() {
1056 let (_tmp, store) = project_store(&[("p-a", "Platform")]);
1061 let mut done = seed(&store, 1, "Done");
1062 done.status = crate::model::Status::new("done");
1063 rewrite(&store, done, &["p-a"], Some("alice"));
1064 rewrite(&store, seed(&store, 2, "Open"), &["p-a"], None);
1065
1066 let loaded = get(&store, "1").unwrap();
1067 let block = projects_block(&store, &loaded).unwrap();
1068 let issues = store.list().unwrap();
1069 let cells = crate::commands::list::project_cells(&store, &issues).unwrap();
1070 let figure = "1/2 done, overdue 0, blocked 0, unassigned 1";
1071 assert!(block.contains(figure), "`tix show` must report it: {block}");
1072 let cell = cells.get(&1).expect("issue #1 belongs to a project");
1073 assert!(
1074 cell.contains(figure),
1075 "`tix list --show progress` must report the same value: {cell}"
1076 );
1077 }
1078
1079 fn epic_store(epics: &[(&str, &str, Option<i64>, Option<&str>)]) -> (TempDir, Store) {
1083 let (tmp, mut store) = make_store();
1084 for (id, name, weight, parent) in epics {
1085 store.config.epics.insert(
1086 (*id).to_string(),
1087 crate::config::EpicConfiguration {
1088 name: (*name).to_string(),
1089 weight: *weight,
1090 parent: parent.map(str::to_string),
1091 ..crate::config::EpicConfiguration::default()
1092 },
1093 );
1094 }
1095 (tmp, store)
1096 }
1097
1098 fn with_epics(store: &Store, mut issue: Issue, epics: &[&str]) -> Issue {
1100 issue.epics = Some(epics.iter().map(|id| (*id).to_string()).collect());
1101 fs::write(
1102 store.issues_dir.join(&issue.filename),
1103 issue.to_file_contents(),
1104 )
1105 .unwrap();
1106 issue
1107 }
1108
1109 fn with_status(store: &Store, mut issue: Issue, status: &str) -> Issue {
1111 issue.status = crate::model::Status::new(status);
1112 fs::write(
1113 store.issues_dir.join(&issue.filename),
1114 issue.to_file_contents(),
1115 )
1116 .unwrap();
1117 issue
1118 }
1119
1120 #[test]
1121 fn t017_the_epic_block_shows_both_epics_with_id_name_and_rollup() {
1122 let (_tmp, store) = epic_store(&[
1123 ("e-aaa111", "Alpha", None, None),
1124 ("e-bbb222", "Beta", None, None),
1125 ]);
1126 let issue = with_epics(&store, seed(&store, 1, "Shared"), &["e-aaa111", "e-bbb222"]);
1127 let loaded = get(&store, &issue.number.to_string()).unwrap();
1128 let block = epics_block(&store, &loaded).unwrap();
1129 assert!(block.contains("## Epics"), "{block}");
1130 assert!(
1131 block.contains("- Alpha (e-aaa111)"),
1132 "the id and the name render: {block}"
1133 );
1134 assert!(block.contains("- Beta (e-bbb222)"), "{block}");
1135 assert!(
1136 block.contains("progress: 0/1 done"),
1137 "the rollup figures render: {block}"
1138 );
1139 assert!(block.contains("blocked: 0"), "{block}");
1140 let alpha = block.find("Alpha").unwrap();
1141 let beta = block.find("Beta").unwrap();
1142 assert!(
1143 alpha < beta,
1144 "the issue's own reference order is kept: {block}"
1145 );
1146 }
1147
1148 #[test]
1149 fn t017_an_issue_in_no_epic_renders_no_epic_heading() {
1150 let (_tmp, store) = epic_store(&[("e-aaa111", "Alpha", None, None)]);
1151 seed(&store, 1, "None");
1152 let loaded = get(&store, "1").unwrap();
1153 assert_eq!(
1154 epics_block(&store, &loaded).unwrap(),
1155 "",
1156 "an empty reference list is the empty state, not an empty heading"
1157 );
1158 }
1159
1160 #[test]
1161 fn t017_a_missing_epic_reference_is_rendered_with_its_id_and_reason() {
1162 let (_tmp, store) = epic_store(&[("e-aaa111", "Alpha", None, None)]);
1163 let issue = with_epics(
1164 &store,
1165 seed(&store, 1, "Dangling"),
1166 &["e-aaa111", "e-gone1", "bad.epic"],
1167 );
1168 let loaded = get(&store, &issue.number.to_string()).unwrap();
1169 let block = epics_block(&store, &loaded).unwrap();
1170 assert!(
1171 block.contains("- e-gone1 (missing reference)"),
1172 "a dangling reference is named, not dropped: {block}"
1173 );
1174 assert!(
1175 block.contains("not in the registry"),
1176 "the service's reason is rendered: {block}"
1177 );
1178 assert!(
1179 block.contains("- bad.epic (missing reference)"),
1180 "an entry that is not a well-formed id is named too: {block}"
1181 );
1182 assert!(block.contains("not a well-formed epic id"), "{block}");
1183 assert!(
1184 block.contains("Alpha (e-aaa111)"),
1185 "the resolvable reference is still shown: {block}"
1186 );
1187 }
1188
1189 #[test]
1190 fn t017_a_task_labelled_issue_shows_its_epic_like_any_other() {
1191 let (_tmp, store) = epic_store(&[("e-aaa111", "Alpha", None, None)]);
1194 let mut issue = seed(&store, 1, "A task");
1195 issue.labels = vec!["task".to_string()];
1196 let issue = with_epics(&store, issue, &["e-aaa111"]);
1197 let loaded = get(&store, &issue.number.to_string()).unwrap();
1198 let block = epics_block(&store, &loaded).unwrap();
1199 assert!(block.contains("Alpha (e-aaa111)"), "{block}");
1200 assert!(
1201 !block.contains("task"),
1202 "no task heading or task section exists (C-001): {block}"
1203 );
1204 }
1205
1206 fn epic_parity_store() -> (TempDir, Store) {
1210 let (_tmp, store) = epic_store(&[
1211 ("e-aaa111", "Alpha", Some(5), None),
1212 ("e-bbb222", "Beta", Some(2), Some("e-aaa111")),
1213 ]);
1214 let one = with_epics(&store, seed(&store, 1, "Done"), &["e-aaa111", "e-bbb222"]);
1215 with_status(&store, one, "done");
1216 with_epics(&store, seed(&store, 2, "Open"), &["e-aaa111"]);
1217 (_tmp, store)
1218 }
1219
1220 #[test]
1221 fn t021_c008_the_epic_figures_are_equal_across_show_list_and_the_tui_paths() {
1222 let (_tmp, store) = epic_parity_store();
1230 let loaded = get(&store, "2").unwrap();
1231 let block = epics_block(&store, &loaded).unwrap();
1232 let issues = store.list().unwrap();
1233 let cells = epic_cells(&store, &issues).unwrap();
1234 let cell = cells.get(&2).expect("#2 references Alpha");
1235 let entries = epic_entries(&store, &loaded).unwrap();
1239 let view = crate::commands::view::epic_view(&store).unwrap();
1240
1241 for text in [block.as_str(), cell.as_str()] {
1244 assert!(text.contains("1/2 done"), "{text}");
1245 assert!(text.contains("2/2 (100%)"), "{text}");
1246 }
1247 assert_eq!(entries.len(), 1, "issue #2 references exactly Alpha");
1248 assert_eq!(entries[0].heading, "Alpha (e-aaa111)");
1249 for line in [
1250 "progress: 1/2 done",
1251 "weighted progress: 2/2 (100%)",
1252 "weight: 5",
1253 "children: 1",
1254 "blocked: 0",
1255 ] {
1256 assert!(
1257 entries[0].detail.iter().any(|detail| detail == line),
1258 "the TUI detail path must carry `{line}`: {:?}",
1259 entries[0].detail
1260 );
1261 }
1262
1263 assert_eq!(
1265 view.first().map(|entry| entry.epic.heading.as_str()),
1266 Some("Alpha (e-aaa111)"),
1267 "epics render in declaration order"
1268 );
1269 let alpha = view
1270 .iter()
1271 .find(|entry| entry.epic.heading.starts_with("Alpha"))
1272 .expect("Alpha is declared");
1273 for line in ["progress: 1/2 done", "weighted progress: 2/2 (100%)"] {
1274 assert!(
1275 alpha.epic.detail.iter().any(|detail| detail == line),
1276 "the epic view must carry `{line}`: {:?}",
1277 alpha.epic.detail
1278 );
1279 }
1280 let state = crate::ui::planning::EpicViewState::new();
1283 let lines = crate::ui::planning::epic_lines(&view, &state, 40);
1284 let text = lines
1285 .iter()
1286 .map(|line| {
1287 line.spans
1288 .iter()
1289 .map(|span| span.content.as_ref())
1290 .collect::<String>()
1291 })
1292 .collect::<Vec<_>>()
1293 .join("\n");
1294 assert!(text.contains("progress: 1/2 done"), "{text}");
1295 assert!(text.contains("weighted progress: 2/2 (100%)"), "{text}");
1296 }
1297
1298 #[test]
1299 fn t021_the_epic_figures_are_deterministic_and_stable_under_a_no_op() {
1300 let (_tmp, store) = epic_parity_store();
1301 let loaded = get(&store, "2").unwrap();
1302 let first = epic_entries(&store, &loaded).unwrap();
1303 let first_cells = epic_cells(&store, &store.list().unwrap()).unwrap();
1304 let first_view = crate::commands::view::epic_view(&store).unwrap();
1305 assert!(
1308 first[0]
1309 .detail
1310 .iter()
1311 .any(|line| line == "progress: 1/2 done")
1312 );
1313 assert!(
1314 first[0]
1315 .detail
1316 .iter()
1317 .any(|line| line == "weighted progress: 2/2 (100%)")
1318 );
1319
1320 for _ in 0..3 {
1321 assert_eq!(epic_entries(&store, &loaded).unwrap(), first);
1322 assert_eq!(
1323 epic_cells(&store, &store.list().unwrap()).unwrap(),
1324 first_cells
1325 );
1326 assert_eq!(
1327 crate::commands::view::epic_view(&store).unwrap(),
1328 first_view
1329 );
1330 }
1331
1332 let issues = store.list().unwrap();
1335 let change = crate::epics::dereference_epic(&issues, 2, "e-bbb222").unwrap();
1336 assert!(change.event.is_none(), "an absent reference is a no-op");
1337 assert_eq!(
1338 epic_entries(&store, &loaded).unwrap(),
1339 first,
1340 "a no-op must not move a figure"
1341 );
1342 }
1343}