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 use super::*;
652 use crate::model::{Issue, Priority, Status};
653 use std::fs;
654 use tempfile::TempDir;
655
656 fn make_store() -> (TempDir, Store) {
657 let tmp = TempDir::new().unwrap();
658 let dir = tmp.path().join("issues");
659 fs::create_dir_all(dir.join("alice")).unwrap();
660 let config_path = tmp.path().join("tix.toml");
661 let config = crate::config::Config {
662 issues_dir: dir.clone(),
663 ..crate::config::Config::default()
664 };
665 config.save(&config_path).unwrap();
666 let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
667 (
668 tmp,
669 Store {
670 issues_dir: dir,
671 config,
672 },
673 )
674 }
675
676 fn seed(store: &Store, number: u32, title: &str) -> Issue {
677 let mut issue = Issue {
678 projects: None,
679 id: None,
680 number,
681 title: title.to_string(),
682 status: Status::new("todo"),
683 labels: vec![],
684 priority: Priority::default(),
685 assignee: Some("alice".to_string()),
686 estimate: None,
687 commit: None,
688 gitlab_id: None,
689 linear_id: None,
690 mission: None,
691 task: None,
692 relationships: vec![],
693 related_issues: vec![],
694 files: vec![],
695 cycles: None,
696 milestones: None,
697 reviewer: None,
698 start_date: None,
699 due_date: None,
700 created: "2026-01-01".to_string(),
701 type_: None,
702 updated: "2026-01-01".to_string(),
703 filename: String::new(),
704 body: "the body".to_string(),
705 history: crate::model::History::new(),
706 has_retired_links_key: false,
707 time_entries: vec![],
708 parent: None,
709 series: None,
710 health: None,
711 epics: None,
712 extra_frontmatter: std::collections::BTreeMap::new(),
713 archived: false,
714 entered_at: None,
715 source: None,
716 };
717 issue.filename = format!("alice/2026-01-01-{}.md", crate::slug::slugify(title));
718 fs::write(
719 store.issues_dir.join(&issue.filename),
720 issue.to_file_contents(),
721 )
722 .unwrap();
723 issue
724 }
725
726 #[test]
727 fn get_returns_the_full_record() {
728 let (tmp, store) = make_store();
729 let _ = tmp;
730 let seeded = seed(&store, 1, "Get me");
731 let issue = get(&store, &seeded.number.to_string()).unwrap();
732 assert_eq!(issue.title, "Get me");
733 assert_eq!(issue.body, "the body");
734 }
735
736 #[test]
737 fn get_selector_miss_is_not_found() {
738 let (tmp, store) = make_store();
739 let _ = tmp;
740 let err = get(&store, "999").unwrap_err();
741 assert!(
742 matches!(err, crate::error::TixError::NotFound(_)),
743 "missing issue must surface as NotFound, got {err}"
744 );
745 }
746
747 #[test]
748 fn get_ambiguous_selector_is_an_error() {
749 let (tmp, store) = make_store();
750 let _ = tmp;
751 seed(&store, 1, "Shared title");
752 seed(&store, 2, "Shared title other");
753 let err = get(&store, "Shared").unwrap_err();
756 assert!(
757 matches!(err, crate::error::TixError::InvalidInput(_)),
758 "ambiguous selector must be an InvalidInput error, got {err}"
759 );
760 }
761
762 #[test]
765 fn show_relationships_is_off_by_default_so_the_output_is_still_the_file() {
766 let (tmp, store) = make_store();
769 let _ = tmp;
770 let seeded = seed(&store, 1, "Plain");
771 let on_disk = fs::read_to_string(store.issues_dir.join(&seeded.filename)).unwrap();
772 assert_eq!(
773 get(&store, "1").unwrap().to_file_contents(),
774 on_disk,
775 "precondition: the record round-trips to exactly the file's bytes"
776 );
777 assert!(!link::wants_relationships(None).unwrap());
778 }
779
780 #[test]
781 fn the_derived_block_shows_an_edge_stored_on_the_other_endpoint() {
782 let (tmp, store) = make_store();
787 let _ = tmp;
788 let source = seed(&store, 1, "Source");
789 let target = seed(&store, 2, "Target");
790 store
791 .link(
792 &source.number.to_string(),
793 &target.number.to_string(),
794 crate::relationships::RelationKind::Blocks,
795 )
796 .unwrap();
797
798 let target_record = get(&store, "2").unwrap();
800 assert!(
801 target_record.relationships.is_empty(),
802 "precondition: the edge is stored once, on the source"
803 );
804
805 let (issues, done_column) = store.graph_source().unwrap();
806 let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
807 let graph = RelationshipGraph::new(&views, &done_column);
808 let block = link::derived_block(&graph, target_record.number);
809
810 assert!(
811 block.contains("Source"),
812 "the target must see the edge that names it:\n{block}"
813 );
814 assert!(
815 block.contains("blocked"),
816 "and the blocked reason follows from it:\n{block}"
817 );
818 }
819 fn mapped_store(columns: &[&str], done_column: &str, map: &[(&str, &str)]) -> (TempDir, Store) {
825 let tmp = TempDir::new().unwrap();
826 let dir = tmp.path().join("issues");
827 fs::create_dir_all(dir.join("alice")).unwrap();
828 let config = crate::config::Config {
829 issues_dir: dir.clone(),
830 kanban: crate::config::KanbanSection {
831 configurations: indexmap::IndexMap::from([(
832 "default".to_string(),
833 crate::config::BoardConfiguration {
834 columns: columns.iter().map(|c| (*c).to_string()).collect(),
835 done_column: Some(done_column.to_string()),
836 category_map: map
837 .iter()
838 .map(|(c, k)| ((*c).to_string(), (*k).to_string()))
839 .collect(),
840 ..Default::default()
841 },
842 )]),
843 },
844 ..crate::config::Config::default()
845 };
846 config.save(&tmp.path().join("tix.toml")).unwrap();
847 let config =
848 crate::config::Config::load_with_warning(&tmp.path().join("tix.toml"), |_| {}).unwrap();
849 (
850 tmp,
851 Store {
852 issues_dir: dir,
853 config,
854 },
855 )
856 }
857
858 fn five_categories() -> (TempDir, Store) {
859 mapped_store(
860 &["triage", "wip", "review", "shipped", "abandoned"],
861 "shipped",
862 &[
863 ("triage", "backlog"),
864 ("wip", "active"),
865 ("review", "active"),
866 ("shipped", "completed"),
867 ("abandoned", "canceled"),
868 ],
869 )
870 }
871
872 fn write(store: &Store, number: u32, title: &str, column: &str) {
873 let slug = crate::slug::slugify(title);
874 fs::write(
875 store.issues_dir.join(format!("alice/2026-01-01-{slug}.md")),
876 format!(
877 "---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{column}\"\n\
878 labels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\n\
879 updated: \"2026-01-01\"\n---\n\nbody\n"
880 ),
881 )
882 .unwrap();
883 }
884
885 #[test]
886 fn t036_the_detail_block_carries_the_key_the_label_and_the_completion_context() {
887 let (_tmp, store) = five_categories();
888 write(&store, 1, "Shipped", "shipped");
889 write(&store, 2, "Abandoned", "abandoned");
890 write(&store, 3, "Wip", "wip");
891 let (_, resolution) = crate::workflow::active_resolution(&store.config);
892 for (selector, key, label, meaning) in [
893 ("1", "completed", "Done", "complete"),
894 ("2", "canceled", "Canceled", "terminal, not complete"),
895 ("3", "active", "In progress", "in progress"),
896 ] {
897 let issue = crate::commands::view::get(&store, selector).unwrap();
898 let block = crate::commands::view::category_block(&issue, &resolution);
899 assert!(
900 block.contains(&format!("- category: {key} ({label})")),
901 "{selector}: {block}"
902 );
903 assert!(
904 block.contains(&format!("- workflow: {meaning}")),
905 "{selector}: {block}"
906 );
907 }
908 }
909
910 #[test]
911 fn t036_a_canceled_issue_never_reads_as_complete() {
912 let (_tmp, store) = five_categories();
916 write(&store, 1, "Abandoned", "abandoned");
917 let (_, resolution) = crate::workflow::active_resolution(&store.config);
918 let issue = crate::commands::view::get(&store, "1").unwrap();
919 let block = crate::commands::view::category_block(&issue, &resolution);
920 assert!(block.contains("terminal, not complete"), "{block}");
921 assert!(
922 !block.contains("workflow: complete"),
923 "a canceled issue must never say complete: {block}"
924 );
925 }
926
927 #[test]
928 fn t036_an_unmapped_column_shows_a_label_not_an_error_and_not_a_blank() {
929 let (_tmp, store) = mapped_store(
934 &["triage", "wip", "shipped"],
935 "shipped",
936 &[("wip", "active"), ("shipped", "completed")],
937 );
938 write(&store, 1, "Defaulted", "triage");
939 write(&store, 2, "Orphan", "not-a-column");
940 let (_, resolution) = crate::workflow::active_resolution(&store.config);
941 let defaulted = crate::commands::view::get(&store, "1").unwrap();
942 let block = crate::commands::view::category_block(&defaulted, &resolution);
943 assert!(block.contains("unstarted (Not started)"), "{block}");
944 let orphan = crate::commands::view::get(&store, "2").unwrap();
945 let block = crate::commands::view::category_block(&orphan, &resolution);
946 assert!(
947 block.contains("category: (unmapped) ((unmapped))"),
948 "{block}"
949 );
950 assert!(
951 block.contains("this board configures no category for it"),
952 "an unmapped column is stated, not left blank: {block}"
953 );
954 }
955
956 fn project_store(projects: &[(&str, &str)]) -> (TempDir, Store) {
961 let tmp = TempDir::new().unwrap();
962 let dir = tmp.path().join("issues");
963 fs::create_dir_all(dir.join("alice")).unwrap();
964 let mut config = crate::config::Config {
965 issues_dir: dir.clone(),
966 ..crate::config::Config::default()
967 };
968 for (id, name) in projects {
969 config.projects.insert(
970 (*id).to_string(),
971 crate::config::ProjectConfiguration {
972 name: (*name).to_string(),
973 ..crate::config::ProjectConfiguration::default()
974 },
975 );
976 }
977 (
978 tmp,
979 Store {
980 issues_dir: dir,
981 config,
982 },
983 )
984 }
985
986 fn rewrite(store: &Store, mut issue: Issue, projects: &[&str], assignee: Option<&str>) {
988 issue.projects = Some(projects.iter().map(|p| (*p).to_string()).collect());
989 issue.assignee = assignee.map(str::to_string);
990 fs::write(
991 store.issues_dir.join(&issue.filename),
992 issue.to_file_contents(),
993 )
994 .unwrap();
995 }
996
997 #[test]
998 fn t017_the_project_block_shows_both_projects_with_id_name_and_health() {
999 let (_tmp, store) = project_store(&[("p-a", "Platform"), ("p-b", "Docs")]);
1000 rewrite(
1001 &store,
1002 seed(&store, 1, "Shared"),
1003 &["p-a", "p-b"],
1004 Some("alice"),
1005 );
1006 let loaded = get(&store, "1").unwrap();
1007 let block = projects_block(&store, &loaded).unwrap();
1008 assert!(block.contains("## Projects"), "{block}");
1009 assert!(
1010 block.contains("- Platform (p-a) 0/1 done, overdue 0, blocked 0, unassigned 0"),
1011 "the id, the name, and every figure are rendered: {block}"
1012 );
1013 assert!(block.contains("- Docs (p-b) 0/1 done"), "{block}");
1014 }
1015
1016 #[test]
1017 fn t017_an_issue_in_no_project_renders_no_project_heading() {
1018 let (_tmp, store) = project_store(&[("p-a", "Platform")]);
1019 seed(&store, 1, "None");
1020 let loaded = get(&store, "1").unwrap();
1021 assert_eq!(
1022 projects_block(&store, &loaded).unwrap(),
1023 "",
1024 "an empty association list is the empty state, not an empty heading"
1025 );
1026 }
1027
1028 #[test]
1029 fn t017_a_missing_reference_is_rendered_with_its_id_and_reason() {
1030 let (_tmp, store) = project_store(&[("p-a", "Platform")]);
1031 rewrite(
1032 &store,
1033 seed(&store, 1, "Dangling"),
1034 &["p-a", "p-gone"],
1035 Some("alice"),
1036 );
1037 let loaded = get(&store, "1").unwrap();
1038 let block = projects_block(&store, &loaded).unwrap();
1039 assert!(
1040 block.contains("- p-gone (missing reference):"),
1041 "a dangling association is named, not dropped: {block}"
1042 );
1043 assert!(
1044 block.contains("not in the registry"),
1045 "the service's reason is rendered: {block}"
1046 );
1047 assert!(
1048 block.contains("Platform (p-a)"),
1049 "the resolvable association is still shown: {block}"
1050 );
1051 }
1052
1053 #[test]
1054 fn t019_c006_show_and_list_report_the_same_figures_for_one_snapshot() {
1055 let (_tmp, store) = project_store(&[("p-a", "Platform")]);
1060 let mut done = seed(&store, 1, "Done");
1061 done.status = crate::model::Status::new("done");
1062 rewrite(&store, done, &["p-a"], Some("alice"));
1063 rewrite(&store, seed(&store, 2, "Open"), &["p-a"], None);
1064
1065 let loaded = get(&store, "1").unwrap();
1066 let block = projects_block(&store, &loaded).unwrap();
1067 let issues = store.list().unwrap();
1068 let cells = crate::commands::list::project_cells(&store, &issues).unwrap();
1069 let figure = "1/2 done, overdue 0, blocked 0, unassigned 1";
1070 assert!(block.contains(figure), "`tix show` must report it: {block}");
1071 let cell = cells.get(&1).expect("issue #1 belongs to a project");
1072 assert!(
1073 cell.contains(figure),
1074 "`tix list --show progress` must report the same value: {cell}"
1075 );
1076 }
1077
1078 fn epic_store(epics: &[(&str, &str, Option<i64>, Option<&str>)]) -> (TempDir, Store) {
1082 let (tmp, mut store) = make_store();
1083 for (id, name, weight, parent) in epics {
1084 store.config.epics.insert(
1085 (*id).to_string(),
1086 crate::config::EpicConfiguration {
1087 name: (*name).to_string(),
1088 weight: *weight,
1089 parent: parent.map(str::to_string),
1090 ..crate::config::EpicConfiguration::default()
1091 },
1092 );
1093 }
1094 (tmp, store)
1095 }
1096
1097 fn with_epics(store: &Store, mut issue: Issue, epics: &[&str]) -> Issue {
1099 issue.epics = Some(epics.iter().map(|id| (*id).to_string()).collect());
1100 fs::write(
1101 store.issues_dir.join(&issue.filename),
1102 issue.to_file_contents(),
1103 )
1104 .unwrap();
1105 issue
1106 }
1107
1108 fn with_status(store: &Store, mut issue: Issue, status: &str) -> Issue {
1110 issue.status = crate::model::Status::new(status);
1111 fs::write(
1112 store.issues_dir.join(&issue.filename),
1113 issue.to_file_contents(),
1114 )
1115 .unwrap();
1116 issue
1117 }
1118
1119 #[test]
1120 fn t017_the_epic_block_shows_both_epics_with_id_name_and_rollup() {
1121 let (_tmp, store) = epic_store(&[
1122 ("e-aaa111", "Alpha", None, None),
1123 ("e-bbb222", "Beta", None, None),
1124 ]);
1125 let issue = with_epics(&store, seed(&store, 1, "Shared"), &["e-aaa111", "e-bbb222"]);
1126 let loaded = get(&store, &issue.number.to_string()).unwrap();
1127 let block = epics_block(&store, &loaded).unwrap();
1128 assert!(block.contains("## Epics"), "{block}");
1129 assert!(
1130 block.contains("- Alpha (e-aaa111)"),
1131 "the id and the name render: {block}"
1132 );
1133 assert!(block.contains("- Beta (e-bbb222)"), "{block}");
1134 assert!(
1135 block.contains("progress: 0/1 done"),
1136 "the rollup figures render: {block}"
1137 );
1138 assert!(block.contains("blocked: 0"), "{block}");
1139 let alpha = block.find("Alpha").unwrap();
1140 let beta = block.find("Beta").unwrap();
1141 assert!(
1142 alpha < beta,
1143 "the issue's own reference order is kept: {block}"
1144 );
1145 }
1146
1147 #[test]
1148 fn t017_an_issue_in_no_epic_renders_no_epic_heading() {
1149 let (_tmp, store) = epic_store(&[("e-aaa111", "Alpha", None, None)]);
1150 seed(&store, 1, "None");
1151 let loaded = get(&store, "1").unwrap();
1152 assert_eq!(
1153 epics_block(&store, &loaded).unwrap(),
1154 "",
1155 "an empty reference list is the empty state, not an empty heading"
1156 );
1157 }
1158
1159 #[test]
1160 fn t017_a_missing_epic_reference_is_rendered_with_its_id_and_reason() {
1161 let (_tmp, store) = epic_store(&[("e-aaa111", "Alpha", None, None)]);
1162 let issue = with_epics(
1163 &store,
1164 seed(&store, 1, "Dangling"),
1165 &["e-aaa111", "e-gone1", "bad.epic"],
1166 );
1167 let loaded = get(&store, &issue.number.to_string()).unwrap();
1168 let block = epics_block(&store, &loaded).unwrap();
1169 assert!(
1170 block.contains("- e-gone1 (missing reference)"),
1171 "a dangling reference is named, not dropped: {block}"
1172 );
1173 assert!(
1174 block.contains("not in the registry"),
1175 "the service's reason is rendered: {block}"
1176 );
1177 assert!(
1178 block.contains("- bad.epic (missing reference)"),
1179 "an entry that is not a well-formed id is named too: {block}"
1180 );
1181 assert!(block.contains("not a well-formed epic id"), "{block}");
1182 assert!(
1183 block.contains("Alpha (e-aaa111)"),
1184 "the resolvable reference is still shown: {block}"
1185 );
1186 }
1187
1188 #[test]
1189 fn t017_a_task_labelled_issue_shows_its_epic_like_any_other() {
1190 let (_tmp, store) = epic_store(&[("e-aaa111", "Alpha", None, None)]);
1193 let mut issue = seed(&store, 1, "A task");
1194 issue.labels = vec!["task".to_string()];
1195 let issue = with_epics(&store, issue, &["e-aaa111"]);
1196 let loaded = get(&store, &issue.number.to_string()).unwrap();
1197 let block = epics_block(&store, &loaded).unwrap();
1198 assert!(block.contains("Alpha (e-aaa111)"), "{block}");
1199 assert!(
1200 !block.contains("task"),
1201 "no task heading or task section exists (C-001): {block}"
1202 );
1203 }
1204
1205 fn epic_parity_store() -> (TempDir, Store) {
1209 let (_tmp, store) = epic_store(&[
1210 ("e-aaa111", "Alpha", Some(5), None),
1211 ("e-bbb222", "Beta", Some(2), Some("e-aaa111")),
1212 ]);
1213 let one = with_epics(&store, seed(&store, 1, "Done"), &["e-aaa111", "e-bbb222"]);
1214 with_status(&store, one, "done");
1215 with_epics(&store, seed(&store, 2, "Open"), &["e-aaa111"]);
1216 (_tmp, store)
1217 }
1218
1219 #[test]
1220 fn t021_c008_the_epic_figures_are_equal_across_show_list_and_the_tui_paths() {
1221 let (_tmp, store) = epic_parity_store();
1229 let loaded = get(&store, "2").unwrap();
1230 let block = epics_block(&store, &loaded).unwrap();
1231 let issues = store.list().unwrap();
1232 let cells = epic_cells(&store, &issues).unwrap();
1233 let cell = cells.get(&2).expect("#2 references Alpha");
1234 let entries = epic_entries(&store, &loaded).unwrap();
1238 let view = crate::commands::view::epic_view(&store).unwrap();
1239
1240 for text in [block.as_str(), cell.as_str()] {
1243 assert!(text.contains("1/2 done"), "{text}");
1244 assert!(text.contains("2/2 (100%)"), "{text}");
1245 }
1246 assert_eq!(entries.len(), 1, "issue #2 references exactly Alpha");
1247 assert_eq!(entries[0].heading, "Alpha (e-aaa111)");
1248 for line in [
1249 "progress: 1/2 done",
1250 "weighted progress: 2/2 (100%)",
1251 "weight: 5",
1252 "children: 1",
1253 "blocked: 0",
1254 ] {
1255 assert!(
1256 entries[0].detail.iter().any(|detail| detail == line),
1257 "the TUI detail path must carry `{line}`: {:?}",
1258 entries[0].detail
1259 );
1260 }
1261
1262 assert_eq!(
1264 view.first().map(|entry| entry.epic.heading.as_str()),
1265 Some("Alpha (e-aaa111)"),
1266 "epics render in declaration order"
1267 );
1268 let alpha = view
1269 .iter()
1270 .find(|entry| entry.epic.heading.starts_with("Alpha"))
1271 .expect("Alpha is declared");
1272 for line in ["progress: 1/2 done", "weighted progress: 2/2 (100%)"] {
1273 assert!(
1274 alpha.epic.detail.iter().any(|detail| detail == line),
1275 "the epic view must carry `{line}`: {:?}",
1276 alpha.epic.detail
1277 );
1278 }
1279 let state = crate::ui::planning::EpicViewState::new();
1282 let lines = crate::ui::planning::epic_lines(&view, &state, 40);
1283 let text = lines
1284 .iter()
1285 .map(|line| {
1286 line.spans
1287 .iter()
1288 .map(|span| span.content.as_ref())
1289 .collect::<String>()
1290 })
1291 .collect::<Vec<_>>()
1292 .join("\n");
1293 assert!(text.contains("progress: 1/2 done"), "{text}");
1294 assert!(text.contains("weighted progress: 2/2 (100%)"), "{text}");
1295 }
1296
1297 #[test]
1298 fn t021_the_epic_figures_are_deterministic_and_stable_under_a_no_op() {
1299 let (_tmp, store) = epic_parity_store();
1300 let loaded = get(&store, "2").unwrap();
1301 let first = epic_entries(&store, &loaded).unwrap();
1302 let first_cells = epic_cells(&store, &store.list().unwrap()).unwrap();
1303 let first_view = crate::commands::view::epic_view(&store).unwrap();
1304 assert!(
1307 first[0]
1308 .detail
1309 .iter()
1310 .any(|line| line == "progress: 1/2 done")
1311 );
1312 assert!(
1313 first[0]
1314 .detail
1315 .iter()
1316 .any(|line| line == "weighted progress: 2/2 (100%)")
1317 );
1318
1319 for _ in 0..3 {
1320 assert_eq!(epic_entries(&store, &loaded).unwrap(), first);
1321 assert_eq!(
1322 epic_cells(&store, &store.list().unwrap()).unwrap(),
1323 first_cells
1324 );
1325 assert_eq!(
1326 crate::commands::view::epic_view(&store).unwrap(),
1327 first_view
1328 );
1329 }
1330
1331 let issues = store.list().unwrap();
1334 let change = crate::epics::dereference_epic(&issues, 2, "e-bbb222").unwrap();
1335 assert!(change.event.is_none(), "an absent reference is a no-op");
1336 assert_eq!(
1337 epic_entries(&store, &loaded).unwrap(),
1338 first,
1339 "a no-op must not move a figure"
1340 );
1341 }
1342}