use crate::commands::link;
use crate::commands::parse_selector;
use crate::config::ProjectId;
use crate::error::Result;
use crate::model::Issue;
use crate::projects::{ProjectHealthReport, ProjectIndex};
use crate::relationships::IssueView;
use crate::relationships::RelationshipGraph;
use crate::storage::Store;
use std::collections::{BTreeMap, HashSet};
impl crate::projects::HealthNode for Issue {
fn assignee(&self) -> Option<&str> {
self.assignee.as_deref()
}
}
pub fn run(store: &Store, raw: &str, show: Option<&str>) -> Result<()> {
let want_relationships = link::wants_relationships(show)?;
let issue = get(store, raw)?;
print!("{}", issue.to_file_contents());
{
let (index, issues, done_column) = crate::commands::parent::source(store)?;
let facts = crate::commands::parent::facts_for(&index, &issues, issue.number, &done_column);
print!("{}", crate::commands::parent::detail_block(&facts));
}
{
let (_, resolution) = crate::workflow::active_resolution(&store.config);
print!("{}", category_block(&issue, &resolution));
}
print!("{}", projects_block(store, &issue)?);
print!("{}", epics_block(store, &issue)?);
print!("{}", mentions_block(store, &issue)?);
if want_relationships {
let (issues, done_column) = store.graph_source()?;
let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
let graph = RelationshipGraph::new(&views, &done_column);
print!("{}", link::derived_block(&graph, issue.number));
}
Ok(())
}
pub fn mentions_block(store: &Store, issue: &Issue) -> Result<String> {
let Some(mentions) = store.mention_store() else {
return Ok(String::new());
};
let pending =
mentions.pending_for_issue(&crate::mentions::identity::local_candidates(), issue.number);
if pending.is_empty() {
return Ok(String::new());
}
let mut out = String::new();
let _ = std::fmt::Write::write_fmt(
&mut out,
format_args!("\nMentions for you ({}):\n\n", pending.len()),
);
for mention in &pending {
let _ = std::fmt::Write::write_fmt(
&mut out,
format_args!(" {} {}\n", mention.id, mention.context),
);
}
let _ = std::fmt::Write::write_fmt(
&mut out,
format_args!("\n act with: tix mentions read <id> | ignore <id> | jump <id>\n"),
);
Ok(out)
}
pub fn category_block(issue: &Issue, resolution: &crate::workflow::CategoryResolution) -> String {
let (key, label, meaning) = match crate::workflow::category_of(issue, resolution) {
Some(c) => {
let meaning = if c.is_complete() {
"complete"
} else if c.is_terminal() {
"terminal, not complete"
} else {
"in progress"
};
(c.key(), c.label(), meaning)
}
None => (
"(unmapped)",
"(unmapped)",
"unmapped column: this board configures no category for it",
),
};
format!("\n## Workflow\n\n- category: {key} ({label})\n- workflow: {meaning}\n")
}
pub(crate) fn project_reports(
store: &Store,
issues: &[Issue],
) -> Result<(ProjectIndex, BTreeMap<ProjectId, ProjectHealthReport>)> {
let index = ProjectIndex::build(&store.config, issues);
let assigned = crate::projects::assigned_keys(issues);
let planner =
crate::planning::Planner::from_config(store.config.clone(), std::path::PathBuf::new());
let overdue: HashSet<u32> = planner
.overdue_batch(store, &crate::model::today())
.iter()
.map(|issue| issue.number)
.collect();
let blocked = blocked_keys(store)?;
let mut reports = BTreeMap::new();
for project in index.projects() {
let report = index.health(
&store.config,
project.id.as_str(),
&assigned,
&overdue,
&blocked,
)?;
reports.insert(project.id.clone(), report);
}
Ok((index, reports))
}
fn blocked_keys(store: &Store) -> Result<HashSet<u32>> {
let (issues, done_column) = store.graph_source()?;
let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
let graph = RelationshipGraph::new(&views, &done_column);
Ok(issues
.iter()
.filter(|issue| graph.blocked_state(issue.number).is_blocked)
.map(|issue| issue.number)
.collect())
}
pub(crate) fn health_suffix(report: &ProjectHealthReport) -> String {
format!(
" done, overdue {}, blocked {}, unassigned {}",
report.health.overdue, report.health.blocked, report.health.unassigned
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpicBlockEntry {
pub heading: String,
pub detail: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum EpicRefItem {
Resolved(
Box<crate::epics::EpicRollup>,
Box<crate::epics::EpicHierarchy>,
),
Missing { id: String, reason: String },
}
pub(crate) fn progress_figure(progress: &crate::epics::EpicProgress) -> String {
format!("{}/{} done", progress.done, progress.total)
}
pub(crate) fn weighted_figure(weighted: &crate::epics::WeightedProgress) -> String {
match weighted.percent {
Some(percent) => format!(
"{}/{} ({percent}%)",
weighted.done_weight, weighted.total_weight
),
None => "none (no weighted children)".to_string(),
}
}
pub(crate) fn epic_ref_items(
config: &crate::config::Config,
index: &crate::epics::EpicIndex,
targets: &crate::epics::ReferenceTargets,
graph: &RelationshipGraph<'_>,
issue: &Issue,
) -> Result<Vec<EpicRefItem>> {
let Some(ids) = issue.epics.as_ref().filter(|ids| !ids.is_empty()) else {
return Ok(Vec::new());
};
let holder = crate::epics::ReferenceHolder::Issue(crate::epics::IssueKey::new(issue.number));
let mut out = Vec::new();
let mut seen: Vec<&str> = Vec::new();
for raw in ids {
if seen.contains(&raw.as_str()) {
continue;
}
seen.push(raw.as_str());
let resolved = crate::config::EpicId::new(raw)
.ok()
.filter(|id| index.epic(id).is_some());
match resolved {
Some(id) => {
let rollup = index
.rollup(config, id.as_str(), targets, graph)
.map_err(|err| crate::error::TixError::InvalidInput(err.message()))?;
let hierarchy = index
.hierarchy(id.as_str())
.map_err(|err| crate::error::TixError::InvalidInput(err.message()))?;
out.push(EpicRefItem::Resolved(Box::new(rollup), Box::new(hierarchy)));
}
None => {
let reason = index
.missing()
.iter()
.find(|missing| missing.holder == holder && missing.target == raw.as_str())
.map(|missing| missing.reason.clone())
.unwrap_or_else(|| {
if crate::config::EpicId::new(raw).is_ok() {
crate::epics::missing_epic_reason(&holder, raw)
} else {
crate::epics::malformed_epic_reason(issue.number, raw)
}
});
out.push(EpicRefItem::Missing {
id: raw.clone(),
reason,
});
}
}
}
Ok(out)
}
fn epic_block_entry(item: &EpicRefItem) -> EpicBlockEntry {
match item {
EpicRefItem::Missing { id, reason } => EpicBlockEntry {
heading: format!("{id} (missing reference)"),
detail: vec![reason.clone()],
},
EpicRefItem::Resolved(rollup, hierarchy) => resolved_entry(rollup, hierarchy),
}
}
fn resolved_entry(
rollup: &crate::epics::EpicRollup,
hierarchy: &crate::epics::EpicHierarchy,
) -> EpicBlockEntry {
let mut detail = vec![
format!("progress: {}", progress_figure(&rollup.progress)),
format!(
"weighted progress: {}",
weighted_figure(&rollup.weighted_progress)
),
format!(
"weight: {}",
rollup
.weighted_progress
.own_weight
.map_or_else(|| "unset".to_string(), |weight| weight.to_string())
),
format!("children: {}", rollup.child_count),
match (&hierarchy.parent, &hierarchy.unresolved) {
(Some(parent), _) => format!("parent: {}", parent.display),
(None, Some(unresolved)) => format!(
"parent: {} (missing reference: {})",
unresolved.recorded_parent, unresolved.reason
),
(None, None) => "parent: (none)".to_string(),
},
format!("depth: {}", hierarchy.depth),
format!("blocked: {}", rollup.blocked),
];
for member in &rollup.blocked_members {
detail.push(format!(" {}: {}", member.display, member.reason));
}
for missing in &rollup.missing_references {
detail.push(format!(
"missing {} reference: {}",
missing.target_kind.as_str(),
missing.reason
));
}
EpicBlockEntry {
heading: crate::epics::epic_display(&rollup.epic_id, &rollup.name),
detail,
}
}
pub(crate) fn epic_cell(item: &EpicRefItem) -> String {
match item {
EpicRefItem::Missing { id, .. } => format!("{id} (missing reference)"),
EpicRefItem::Resolved(rollup, _) => format!(
"{} {}, weighted {}, blocked {}",
crate::epics::epic_display(&rollup.epic_id, &rollup.name),
progress_figure(&rollup.progress),
weighted_figure(&rollup.weighted_progress),
rollup.blocked,
),
}
}
pub(crate) fn epic_entries_over(
config: &crate::config::Config,
snapshot: &[Issue],
done_column: &str,
issue: &Issue,
) -> Result<Vec<EpicBlockEntry>> {
let index = crate::epics::EpicIndex::build(config, snapshot);
let planner = crate::planning::Planner::from_config(config.clone(), std::path::PathBuf::new());
let targets = crate::epics::ReferenceTargets::from_planner(&planner)?;
let graph = crate::commands::graph_over(snapshot, done_column);
let items = epic_ref_items(config, &index, &targets, &graph, issue)?;
Ok(items.iter().map(epic_block_entry).collect())
}
pub fn epic_entries(store: &Store, issue: &Issue) -> Result<Vec<EpicBlockEntry>> {
let (issues, done_column) = store.graph_source()?;
epic_entries_over(&store.config, &issues, &done_column, issue)
}
pub(crate) fn epic_cells(store: &Store, rows: &[Issue]) -> Result<BTreeMap<u32, String>> {
let (snapshot, done_column) = store.graph_source()?;
let index = crate::epics::EpicIndex::build(&store.config, &snapshot);
let planner =
crate::planning::Planner::from_config(store.config.clone(), std::path::PathBuf::new());
let targets = crate::epics::ReferenceTargets::from_planner(&planner)?;
let graph = crate::commands::graph_over(&snapshot, &done_column);
let mut out = BTreeMap::new();
for issue in rows {
let items = epic_ref_items(&store.config, &index, &targets, &graph, issue)?;
if items.is_empty() {
continue;
}
out.insert(
issue.number,
items.iter().map(epic_cell).collect::<Vec<_>>().join("; "),
);
}
Ok(out)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpicMemberRef {
pub number: u32,
pub display: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EpicViewEntry {
pub epic: EpicBlockEntry,
pub members: Vec<EpicMemberRef>,
}
pub fn epic_view(store: &Store) -> Result<Vec<EpicViewEntry>> {
let (snapshot, done_column) = store.graph_source()?;
epic_view_over(&store.config, &snapshot, &done_column)
}
pub fn epic_view_over(
config: &crate::config::Config,
issues: &[Issue],
done_column: &str,
) -> Result<Vec<EpicViewEntry>> {
let index = crate::epics::EpicIndex::build(config, issues);
let planner = crate::planning::Planner::from_config(config.clone(), std::path::PathBuf::new());
let targets = crate::epics::ReferenceTargets::from_planner(&planner)?;
let graph = crate::commands::graph_over(issues, done_column);
let mut out = Vec::new();
for epic in index.epics() {
let id = epic.id.clone();
let rollup = index
.rollup(config, id.as_str(), &targets, &graph)
.map_err(|err| crate::error::TixError::InvalidInput(err.message()))?;
let hierarchy = index
.hierarchy(id.as_str())
.map_err(|err| crate::error::TixError::InvalidInput(err.message()))?;
let members = epic
.members
.iter()
.map(|member| EpicMemberRef {
number: member.issue.number(),
display: member.display.clone(),
})
.collect();
out.push(EpicViewEntry {
epic: resolved_entry(&rollup, &hierarchy),
members,
});
}
Ok(out)
}
pub fn epics_block(store: &Store, issue: &Issue) -> Result<String> {
let entries = epic_entries(store, issue)?;
if entries.is_empty() {
return Ok(String::new());
}
let mut out = String::from("\n## Epics\n\n");
for entry in entries {
out.push_str(&format!("- {}\n", entry.heading));
for line in entry.detail {
out.push_str(&format!(" - {line}\n"));
}
}
Ok(out)
}
pub fn projects_block(store: &Store, issue: &Issue) -> Result<String> {
let Some(ids) = issue.projects.as_ref().filter(|ids| !ids.is_empty()) else {
return Ok(String::new());
};
let issues = store.list()?;
let (_index, reports) = project_reports(store, &issues)?;
let mut out = String::from("\n## Projects\n\n");
let mut seen: Vec<&str> = Vec::new();
for raw in ids {
if seen.contains(&raw.as_str()) {
continue;
}
seen.push(raw.as_str());
match ProjectId::new(raw).ok().and_then(|id| reports.get(&id)) {
Some(report) => out.push_str(&format!(
"- {} {}/{}{}\n",
crate::projects::project_display(&report.project_id, &report.name),
report.progress.done,
report.progress.total,
health_suffix(report),
)),
None => out.push_str(&format!(
"- {raw} (missing reference): {}\n",
crate::projects::missing_reference_reason(issue.number, raw)
)),
}
}
Ok(out)
}
pub fn get(store: &Store, raw: &str) -> Result<Issue> {
let selector = parse_selector(store, raw)?;
store.get(selector)
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::model::{Issue, Priority, Status};
use std::fs;
use tempfile::TempDir;
fn make_store() -> (TempDir, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(dir.join("alice")).unwrap();
let config_path = tmp.path().join("tix.toml");
let config = crate::config::Config {
issues_dir: dir.clone(),
..crate::config::Config::default()
};
config.save(&config_path).unwrap();
let config = crate::config::Config::load_with_warning(&config_path, |_| {}).unwrap();
(
tmp,
Store {
issues_dir: dir,
config,
},
)
}
fn seed(store: &Store, number: u32, title: &str) -> Issue {
let mut issue = Issue {
projects: None,
id: None,
number,
title: title.to_string(),
status: Status::new("todo"),
labels: vec![],
priority: Priority::default(),
assignee: Some("alice".to_string()),
estimate: None,
commit: None,
gitlab_id: None,
linear_id: None,
mission: None,
task: None,
relationships: vec![],
related_issues: vec![],
files: vec![],
cycles: None,
milestones: None,
reviewer: None,
start_date: None,
due_date: None,
created: "2026-01-01".to_string(),
type_: None,
updated: "2026-01-01".to_string(),
filename: String::new(),
body: "the body".to_string(),
history: crate::model::History::new(),
has_retired_links_key: false,
time_entries: vec![],
parent: None,
series: None,
health: None,
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
archived: false,
entered_at: None,
source: None,
};
issue.filename = format!("alice/2026-01-01-{}.md", crate::slug::slugify(title));
fs::write(
store.issues_dir.join(&issue.filename),
issue.to_file_contents(),
)
.unwrap();
issue
}
#[test]
fn get_returns_the_full_record() {
let (tmp, store) = make_store();
let _ = tmp;
let seeded = seed(&store, 1, "Get me");
let issue = get(&store, &seeded.number.to_string()).unwrap();
assert_eq!(issue.title, "Get me");
assert_eq!(issue.body, "the body");
}
#[test]
fn get_selector_miss_is_not_found() {
let (tmp, store) = make_store();
let _ = tmp;
let err = get(&store, "999").unwrap_err();
assert!(
matches!(err, crate::error::TixError::NotFound(_)),
"missing issue must surface as NotFound, got {err}"
);
}
#[test]
fn get_ambiguous_selector_is_an_error() {
let (tmp, store) = make_store();
let _ = tmp;
seed(&store, 1, "Shared title");
seed(&store, 2, "Shared title other");
let err = get(&store, "Shared").unwrap_err();
assert!(
matches!(err, crate::error::TixError::InvalidInput(_)),
"ambiguous selector must be an InvalidInput error, got {err}"
);
}
#[test]
fn show_relationships_is_off_by_default_so_the_output_is_still_the_file() {
let (tmp, store) = make_store();
let _ = tmp;
let seeded = seed(&store, 1, "Plain");
let on_disk = fs::read_to_string(store.issues_dir.join(&seeded.filename)).unwrap();
assert_eq!(
get(&store, "1").unwrap().to_file_contents(),
on_disk,
"precondition: the record round-trips to exactly the file's bytes"
);
assert!(!link::wants_relationships(None).unwrap());
}
#[test]
fn the_derived_block_shows_an_edge_stored_on_the_other_endpoint() {
let (tmp, store) = make_store();
let _ = tmp;
let source = seed(&store, 1, "Source");
let target = seed(&store, 2, "Target");
store
.link(
&source.number.to_string(),
&target.number.to_string(),
crate::relationships::RelationKind::Blocks,
)
.unwrap();
let target_record = get(&store, "2").unwrap();
assert!(
target_record.relationships.is_empty(),
"precondition: the edge is stored once, on the source"
);
let (issues, done_column) = store.graph_source().unwrap();
let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
let graph = RelationshipGraph::new(&views, &done_column);
let block = link::derived_block(&graph, target_record.number);
assert!(
block.contains("Source"),
"the target must see the edge that names it:\n{block}"
);
assert!(
block.contains("blocked"),
"and the blocked reason follows from it:\n{block}"
);
}
fn mapped_store(columns: &[&str], done_column: &str, map: &[(&str, &str)]) -> (TempDir, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(dir.join("alice")).unwrap();
let config = crate::config::Config {
issues_dir: dir.clone(),
kanban: crate::config::KanbanSection {
configurations: indexmap::IndexMap::from([(
"default".to_string(),
crate::config::BoardConfiguration {
columns: columns.iter().map(|c| (*c).to_string()).collect(),
done_column: Some(done_column.to_string()),
category_map: map
.iter()
.map(|(c, k)| ((*c).to_string(), (*k).to_string()))
.collect(),
..Default::default()
},
)]),
},
..crate::config::Config::default()
};
config.save(&tmp.path().join("tix.toml")).unwrap();
let config =
crate::config::Config::load_with_warning(&tmp.path().join("tix.toml"), |_| {}).unwrap();
(
tmp,
Store {
issues_dir: dir,
config,
},
)
}
fn five_categories() -> (TempDir, Store) {
mapped_store(
&["triage", "wip", "review", "shipped", "abandoned"],
"shipped",
&[
("triage", "backlog"),
("wip", "active"),
("review", "active"),
("shipped", "completed"),
("abandoned", "canceled"),
],
)
}
fn write(store: &Store, number: u32, title: &str, column: &str) {
let slug = crate::slug::slugify(title);
fs::write(
store.issues_dir.join(format!("alice/2026-01-01-{slug}.md")),
format!(
"---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"{column}\"\n\
labels: []\npriority: \"medium\"\ncreated: \"2026-01-01\"\n\
updated: \"2026-01-01\"\n---\n\nbody\n"
),
)
.unwrap();
}
#[test]
fn t036_the_detail_block_carries_the_key_the_label_and_the_completion_context() {
let (_tmp, store) = five_categories();
write(&store, 1, "Shipped", "shipped");
write(&store, 2, "Abandoned", "abandoned");
write(&store, 3, "Wip", "wip");
let (_, resolution) = crate::workflow::active_resolution(&store.config);
for (selector, key, label, meaning) in [
("1", "completed", "Done", "complete"),
("2", "canceled", "Canceled", "terminal, not complete"),
("3", "active", "In progress", "in progress"),
] {
let issue = crate::commands::view::get(&store, selector).unwrap();
let block = crate::commands::view::category_block(&issue, &resolution);
assert!(
block.contains(&format!("- category: {key} ({label})")),
"{selector}: {block}"
);
assert!(
block.contains(&format!("- workflow: {meaning}")),
"{selector}: {block}"
);
}
}
#[test]
fn t036_a_canceled_issue_never_reads_as_complete() {
let (_tmp, store) = five_categories();
write(&store, 1, "Abandoned", "abandoned");
let (_, resolution) = crate::workflow::active_resolution(&store.config);
let issue = crate::commands::view::get(&store, "1").unwrap();
let block = crate::commands::view::category_block(&issue, &resolution);
assert!(block.contains("terminal, not complete"), "{block}");
assert!(
!block.contains("workflow: complete"),
"a canceled issue must never say complete: {block}"
);
}
#[test]
fn t036_an_unmapped_column_shows_a_label_not_an_error_and_not_a_blank() {
let (_tmp, store) = mapped_store(
&["triage", "wip", "shipped"],
"shipped",
&[("wip", "active"), ("shipped", "completed")],
);
write(&store, 1, "Defaulted", "triage");
write(&store, 2, "Orphan", "not-a-column");
let (_, resolution) = crate::workflow::active_resolution(&store.config);
let defaulted = crate::commands::view::get(&store, "1").unwrap();
let block = crate::commands::view::category_block(&defaulted, &resolution);
assert!(block.contains("unstarted (Not started)"), "{block}");
let orphan = crate::commands::view::get(&store, "2").unwrap();
let block = crate::commands::view::category_block(&orphan, &resolution);
assert!(
block.contains("category: (unmapped) ((unmapped))"),
"{block}"
);
assert!(
block.contains("this board configures no category for it"),
"an unmapped column is stated, not left blank: {block}"
);
}
fn project_store(projects: &[(&str, &str)]) -> (TempDir, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(dir.join("alice")).unwrap();
let mut config = crate::config::Config {
issues_dir: dir.clone(),
..crate::config::Config::default()
};
for (id, name) in projects {
config.projects.insert(
(*id).to_string(),
crate::config::ProjectConfiguration {
name: (*name).to_string(),
..crate::config::ProjectConfiguration::default()
},
);
}
(
tmp,
Store {
issues_dir: dir,
config,
},
)
}
fn rewrite(store: &Store, mut issue: Issue, projects: &[&str], assignee: Option<&str>) {
issue.projects = Some(projects.iter().map(|p| (*p).to_string()).collect());
issue.assignee = assignee.map(str::to_string);
fs::write(
store.issues_dir.join(&issue.filename),
issue.to_file_contents(),
)
.unwrap();
}
#[test]
fn t017_the_project_block_shows_both_projects_with_id_name_and_health() {
let (_tmp, store) = project_store(&[("p-a", "Platform"), ("p-b", "Docs")]);
rewrite(
&store,
seed(&store, 1, "Shared"),
&["p-a", "p-b"],
Some("alice"),
);
let loaded = get(&store, "1").unwrap();
let block = projects_block(&store, &loaded).unwrap();
assert!(block.contains("## Projects"), "{block}");
assert!(
block.contains("- Platform (p-a) 0/1 done, overdue 0, blocked 0, unassigned 0"),
"the id, the name, and every figure are rendered: {block}"
);
assert!(block.contains("- Docs (p-b) 0/1 done"), "{block}");
}
#[test]
fn t017_an_issue_in_no_project_renders_no_project_heading() {
let (_tmp, store) = project_store(&[("p-a", "Platform")]);
seed(&store, 1, "None");
let loaded = get(&store, "1").unwrap();
assert_eq!(
projects_block(&store, &loaded).unwrap(),
"",
"an empty association list is the empty state, not an empty heading"
);
}
#[test]
fn t017_a_missing_reference_is_rendered_with_its_id_and_reason() {
let (_tmp, store) = project_store(&[("p-a", "Platform")]);
rewrite(
&store,
seed(&store, 1, "Dangling"),
&["p-a", "p-gone"],
Some("alice"),
);
let loaded = get(&store, "1").unwrap();
let block = projects_block(&store, &loaded).unwrap();
assert!(
block.contains("- p-gone (missing reference):"),
"a dangling association is named, not dropped: {block}"
);
assert!(
block.contains("not in the registry"),
"the service's reason is rendered: {block}"
);
assert!(
block.contains("Platform (p-a)"),
"the resolvable association is still shown: {block}"
);
}
#[test]
fn t019_c006_show_and_list_report_the_same_figures_for_one_snapshot() {
let (_tmp, store) = project_store(&[("p-a", "Platform")]);
let mut done = seed(&store, 1, "Done");
done.status = crate::model::Status::new("done");
rewrite(&store, done, &["p-a"], Some("alice"));
rewrite(&store, seed(&store, 2, "Open"), &["p-a"], None);
let loaded = get(&store, "1").unwrap();
let block = projects_block(&store, &loaded).unwrap();
let issues = store.list().unwrap();
let cells = crate::commands::list::project_cells(&store, &issues).unwrap();
let figure = "1/2 done, overdue 0, blocked 0, unassigned 1";
assert!(block.contains(figure), "`tix show` must report it: {block}");
let cell = cells.get(&1).expect("issue #1 belongs to a project");
assert!(
cell.contains(figure),
"`tix list --show progress` must report the same value: {cell}"
);
}
fn epic_store(epics: &[(&str, &str, Option<i64>, Option<&str>)]) -> (TempDir, Store) {
let (tmp, mut store) = make_store();
for (id, name, weight, parent) in epics {
store.config.epics.insert(
(*id).to_string(),
crate::config::EpicConfiguration {
name: (*name).to_string(),
weight: *weight,
parent: parent.map(str::to_string),
..crate::config::EpicConfiguration::default()
},
);
}
(tmp, store)
}
fn with_epics(store: &Store, mut issue: Issue, epics: &[&str]) -> Issue {
issue.epics = Some(epics.iter().map(|id| (*id).to_string()).collect());
fs::write(
store.issues_dir.join(&issue.filename),
issue.to_file_contents(),
)
.unwrap();
issue
}
fn with_status(store: &Store, mut issue: Issue, status: &str) -> Issue {
issue.status = crate::model::Status::new(status);
fs::write(
store.issues_dir.join(&issue.filename),
issue.to_file_contents(),
)
.unwrap();
issue
}
#[test]
fn t017_the_epic_block_shows_both_epics_with_id_name_and_rollup() {
let (_tmp, store) = epic_store(&[
("e-aaa111", "Alpha", None, None),
("e-bbb222", "Beta", None, None),
]);
let issue = with_epics(&store, seed(&store, 1, "Shared"), &["e-aaa111", "e-bbb222"]);
let loaded = get(&store, &issue.number.to_string()).unwrap();
let block = epics_block(&store, &loaded).unwrap();
assert!(block.contains("## Epics"), "{block}");
assert!(
block.contains("- Alpha (e-aaa111)"),
"the id and the name render: {block}"
);
assert!(block.contains("- Beta (e-bbb222)"), "{block}");
assert!(
block.contains("progress: 0/1 done"),
"the rollup figures render: {block}"
);
assert!(block.contains("blocked: 0"), "{block}");
let alpha = block.find("Alpha").unwrap();
let beta = block.find("Beta").unwrap();
assert!(
alpha < beta,
"the issue's own reference order is kept: {block}"
);
}
#[test]
fn t017_an_issue_in_no_epic_renders_no_epic_heading() {
let (_tmp, store) = epic_store(&[("e-aaa111", "Alpha", None, None)]);
seed(&store, 1, "None");
let loaded = get(&store, "1").unwrap();
assert_eq!(
epics_block(&store, &loaded).unwrap(),
"",
"an empty reference list is the empty state, not an empty heading"
);
}
#[test]
fn t017_a_missing_epic_reference_is_rendered_with_its_id_and_reason() {
let (_tmp, store) = epic_store(&[("e-aaa111", "Alpha", None, None)]);
let issue = with_epics(
&store,
seed(&store, 1, "Dangling"),
&["e-aaa111", "e-gone1", "bad.epic"],
);
let loaded = get(&store, &issue.number.to_string()).unwrap();
let block = epics_block(&store, &loaded).unwrap();
assert!(
block.contains("- e-gone1 (missing reference)"),
"a dangling reference is named, not dropped: {block}"
);
assert!(
block.contains("not in the registry"),
"the service's reason is rendered: {block}"
);
assert!(
block.contains("- bad.epic (missing reference)"),
"an entry that is not a well-formed id is named too: {block}"
);
assert!(block.contains("not a well-formed epic id"), "{block}");
assert!(
block.contains("Alpha (e-aaa111)"),
"the resolvable reference is still shown: {block}"
);
}
#[test]
fn t017_a_task_labelled_issue_shows_its_epic_like_any_other() {
let (_tmp, store) = epic_store(&[("e-aaa111", "Alpha", None, None)]);
let mut issue = seed(&store, 1, "A task");
issue.labels = vec!["task".to_string()];
let issue = with_epics(&store, issue, &["e-aaa111"]);
let loaded = get(&store, &issue.number.to_string()).unwrap();
let block = epics_block(&store, &loaded).unwrap();
assert!(block.contains("Alpha (e-aaa111)"), "{block}");
assert!(
!block.contains("task"),
"no task heading or task section exists (C-001): {block}"
);
}
fn epic_parity_store() -> (TempDir, Store) {
let (_tmp, store) = epic_store(&[
("e-aaa111", "Alpha", Some(5), None),
("e-bbb222", "Beta", Some(2), Some("e-aaa111")),
]);
let one = with_epics(&store, seed(&store, 1, "Done"), &["e-aaa111", "e-bbb222"]);
with_status(&store, one, "done");
with_epics(&store, seed(&store, 2, "Open"), &["e-aaa111"]);
(_tmp, store)
}
#[test]
fn t021_c008_the_epic_figures_are_equal_across_show_list_and_the_tui_paths() {
let (_tmp, store) = epic_parity_store();
let loaded = get(&store, "2").unwrap();
let block = epics_block(&store, &loaded).unwrap();
let issues = store.list().unwrap();
let cells = epic_cells(&store, &issues).unwrap();
let cell = cells.get(&2).expect("#2 references Alpha");
let entries = epic_entries(&store, &loaded).unwrap();
let view = crate::commands::view::epic_view(&store).unwrap();
for text in [block.as_str(), cell.as_str()] {
assert!(text.contains("1/2 done"), "{text}");
assert!(text.contains("2/2 (100%)"), "{text}");
}
assert_eq!(entries.len(), 1, "issue #2 references exactly Alpha");
assert_eq!(entries[0].heading, "Alpha (e-aaa111)");
for line in [
"progress: 1/2 done",
"weighted progress: 2/2 (100%)",
"weight: 5",
"children: 1",
"blocked: 0",
] {
assert!(
entries[0].detail.iter().any(|detail| detail == line),
"the TUI detail path must carry `{line}`: {:?}",
entries[0].detail
);
}
assert_eq!(
view.first().map(|entry| entry.epic.heading.as_str()),
Some("Alpha (e-aaa111)"),
"epics render in declaration order"
);
let alpha = view
.iter()
.find(|entry| entry.epic.heading.starts_with("Alpha"))
.expect("Alpha is declared");
for line in ["progress: 1/2 done", "weighted progress: 2/2 (100%)"] {
assert!(
alpha.epic.detail.iter().any(|detail| detail == line),
"the epic view must carry `{line}`: {:?}",
alpha.epic.detail
);
}
let state = crate::ui::planning::EpicViewState::new();
let lines = crate::ui::planning::epic_lines(&view, &state, 40);
let text = lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n");
assert!(text.contains("progress: 1/2 done"), "{text}");
assert!(text.contains("weighted progress: 2/2 (100%)"), "{text}");
}
#[test]
fn t021_the_epic_figures_are_deterministic_and_stable_under_a_no_op() {
let (_tmp, store) = epic_parity_store();
let loaded = get(&store, "2").unwrap();
let first = epic_entries(&store, &loaded).unwrap();
let first_cells = epic_cells(&store, &store.list().unwrap()).unwrap();
let first_view = crate::commands::view::epic_view(&store).unwrap();
assert!(
first[0]
.detail
.iter()
.any(|line| line == "progress: 1/2 done")
);
assert!(
first[0]
.detail
.iter()
.any(|line| line == "weighted progress: 2/2 (100%)")
);
for _ in 0..3 {
assert_eq!(epic_entries(&store, &loaded).unwrap(), first);
assert_eq!(
epic_cells(&store, &store.list().unwrap()).unwrap(),
first_cells
);
assert_eq!(
crate::commands::view::epic_view(&store).unwrap(),
first_view
);
}
let issues = store.list().unwrap();
let change = crate::epics::dereference_epic(&issues, 2, "e-bbb222").unwrap();
assert!(change.event.is_none(), "an absent reference is a no-op");
assert_eq!(
epic_entries(&store, &loaded).unwrap(),
first,
"a no-op must not move a figure"
);
}
}