use crate::cli::ParentAction;
use crate::commands::parse_selector;
use crate::error::{Result, TixError};
use crate::hierarchy::{self, HierarchyIndex, ParentRollup};
use crate::model::Issue;
use crate::relationships::IssueView;
use crate::storage::Store;
pub fn run(store: &Store, action: &ParentAction) -> Result<()> {
match action {
ParentAction::Show { issue } => show(store, issue),
ParentAction::Children { issue } => children(store, issue),
ParentAction::List => list(store),
}
}
pub fn source(store: &Store) -> Result<(HierarchyIndex, Vec<Issue>, String)> {
let (issues, done_column) = store.graph_source()?;
let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
Ok((HierarchyIndex::build(&views), issues, done_column))
}
fn label(issues: &[Issue], number: u32) -> String {
match issues.iter().find(|i| i.number == number) {
Some(issue) => hierarchy::display_label(issue.number, &issue.title),
None => format!("#{number}"),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HierarchyFacts {
pub number: u32,
pub parent: Option<u32>,
pub parent_ref: Option<crate::id::IssueRef>,
pub unresolved_reason: Option<String>,
pub parent_label: String,
pub children: Vec<hierarchy::ChildRef>,
pub rollup: ParentRollup,
}
impl HierarchyFacts {
pub fn is_unresolved(&self) -> bool {
self.unresolved_reason.is_some()
}
pub fn has_children(&self) -> bool {
!self.children.is_empty()
}
pub fn child_count_line(&self) -> String {
match self.rollup.direct_children {
1 => "children: 1".to_string(),
n => format!("children: {n}"),
}
}
pub fn completion_line(&self) -> String {
format!(
"completed: {}/{} ({} only)",
self.rollup.completed_children,
self.rollup.progress.total,
self.rollup.completion_basis.as_str()
)
}
}
pub fn facts_over(issues: &[Issue], done_column: &str, number: u32) -> HierarchyFacts {
let index = index_over(issues);
facts_for(&index, issues, number, done_column)
}
pub fn index_over(issues: &[Issue]) -> HierarchyIndex {
let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
HierarchyIndex::build(&views)
}
pub fn facts_for(
index: &HierarchyIndex,
issues: &[Issue],
number: u32,
done_column: &str,
) -> HierarchyFacts {
let parent = index.parent_of(number);
let parent_ref = index.parent_ref_of(number).copied();
let unresolved_reason = parent_ref
.filter(|reference| !index.contains_ref(reference))
.map(|reference| hierarchy::unresolved_reason(number, reference));
let parent_label = match (parent_ref, parent) {
(Some(_reference), Some(p)) if index.contains(p) => label(issues, p),
(Some(reference), _) if index.contains_ref(&reference) => reference.to_string(),
(Some(reference), _) => format!("{reference} (missing)"),
(None, _) => "none".to_string(),
};
let children = index.children_of(number).children;
let rollup = hierarchy::rollup(index, number, done_column);
HierarchyFacts {
number,
parent,
parent_ref,
unresolved_reason,
parent_label,
children,
rollup,
}
}
fn show(store: &Store, raw: &str) -> Result<()> {
let (index, issues, done_column) = source(store)?;
let number = store.get(parse_selector(store, raw)?)?.number;
println!("{}", show_text(&index, &issues, number, &done_column)?);
Ok(())
}
pub fn show_text(
index: &HierarchyIndex,
issues: &[Issue],
number: u32,
done_column: &str,
) -> Result<String> {
if !index.contains(number) {
return Err(TixError::NotFound(format!(
"issue #{number} is not in the current snapshot"
)));
}
Ok(render_show(&facts_for(index, issues, number, done_column)))
}
fn render_show(facts: &HierarchyFacts) -> String {
let mut out = String::new();
out.push_str(&format!("Parent of #{}\n", facts.number));
out.push_str(&format!(" parent: {}\n", parent_line(facts)));
out.push_str(&format!(" {}\n", facts.child_count_line()));
out.push_str(&format!(" {}\n", facts.completion_line()));
out.push_str("Children\n");
if facts.children.is_empty() {
out.push_str(&format!(" {}\n", empty_children_text(facts.number)));
} else {
for child in &facts.children {
out.push_str(&format!(" {}\n", child.display));
}
}
out.trim_end_matches('\n').to_string()
}
pub fn parent_text(facts: &HierarchyFacts) -> String {
parent_line(facts)
}
fn parent_line(facts: &HierarchyFacts) -> String {
match &facts.unresolved_reason {
Some(reason) => format!("{} — {}", facts.parent_label, reason),
None if facts.parent_ref.is_some() => facts.parent_label.clone(),
None => "none".to_string(),
}
}
pub fn no_children_text(number: u32) -> String {
empty_children_text(number)
}
fn empty_children_text(number: u32) -> String {
format!("No children for #{number}")
}
fn children(store: &Store, raw: &str) -> Result<()> {
let (index, _, _) = source(store)?;
let number = store.get(parse_selector(store, raw)?)?.number;
println!("{}", children_text(&index, number)?);
Ok(())
}
pub fn children_text(index: &HierarchyIndex, number: u32) -> Result<String> {
if !index.contains(number) {
return Err(TixError::NotFound(format!(
"issue #{number} is not in the current snapshot"
)));
}
let membership = index.children_of(number);
Ok(render_children(number, &membership.children))
}
fn render_children(number: u32, children: &[hierarchy::ChildRef]) -> String {
if children.is_empty() {
return empty_children_text(number);
}
children
.iter()
.map(|c| c.display.clone())
.collect::<Vec<_>>()
.join("\n")
+ &format!("\n{}", count_suffix(children.len()))
}
fn count_suffix(n: usize) -> String {
match n {
1 => "1 child".to_string(),
k => format!("{k} children"),
}
}
pub fn detail_block(facts: &HierarchyFacts) -> String {
let mut out = String::new();
out.push_str("\n## Hierarchy\n\n");
out.push_str(&format!("- parent: {}\n", parent_line(facts)));
out.push_str(&format!("- {}\n", facts.child_count_line()));
out.push_str(&format!("- {}\n", facts.completion_line()));
out.push_str("children:\n");
if facts.children.is_empty() {
out.push_str(&format!(" - {}\n", empty_children_text(facts.number)));
} else {
for child in &facts.children {
out.push_str(&format!(" - {}\n", child.display));
}
}
out
}
fn list(store: &Store) -> Result<()> {
let (index, issues, done_column) = source(store)?;
println!("{}", list_text(&index, &issues, &done_column));
Ok(())
}
pub fn list_text(index: &HierarchyIndex, issues: &[Issue], done_column: &str) -> String {
let mut rows: Vec<(u32, String, String, String)> = Vec::new();
for issue in issues {
let membership = index.children_of(issue.number);
if membership.child_count == 0 {
continue;
}
let rollup = hierarchy::rollup(index, issue.number, done_column);
rows.push((
issue.number,
label(issues, issue.number),
count_suffix(membership.child_count),
format!("{}/{}", rollup.completed_children, rollup.progress.total),
));
}
if rows.is_empty() {
return "No issues have children".to_string();
}
rows.sort_by_key(|(number, ..)| *number);
let headers = ["ISSUE", "CHILDREN", "COMPLETED"];
let body: Vec<[String; 3]> = rows
.iter()
.map(|(_, label, children, completed)| [label.clone(), children.clone(), completed.clone()])
.collect();
aligned_table(&headers, &body)
}
fn aligned_table(headers: &[&str], rows: &[[String; 3]]) -> String {
let mut widths = [0usize; 3];
for (i, header) in headers.iter().enumerate() {
widths[i] = widths[i].max(header.chars().count());
}
for row in rows {
for (i, cell) in row.iter().enumerate() {
widths[i] = widths[i].max(cell.chars().count());
}
}
let render = |cells: [&str; 3]| {
let mut parts: Vec<String> = Vec::with_capacity(3);
for (i, cell) in cells.iter().enumerate() {
if i == 2 {
parts.push((*cell).to_string());
} else {
parts.push(format!("{cell:<width$}", width = widths[i]));
}
}
parts.join(" ")
};
let mut out = render([headers[0], headers[1], headers[2]]);
for row in rows {
out.push('\n');
out.push_str(&render([&row[0], &row[1], &row[2]]));
}
out
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::model::{Priority, Status};
fn issue(number: u32, title: &str, status: &str, parent: Option<u32>) -> Issue {
Issue {
number,
title: title.into(),
status: Status::new(status),
labels: vec![],
priority: Priority::Medium,
files: vec![],
parent: parent.map(crate::id::IssueRef::Number),
epics: None,
extra_frontmatter: std::collections::BTreeMap::new(),
..make_issue(title)
}
}
fn make_issue(title: &str) -> Issue {
Issue {
number: 0,
title: title.into(),
status: Status::new("todo"),
labels: vec![],
priority: Priority::Medium,
created: "2026-01-01".into(),
updated: "2026-01-01".into(),
..Issue::parse(
"---\nnumber: 0\ntitle: t\nstatus: todo\nlabels: []\npriority: medium\nfiles: []\ncreated: 2026-01-01\nupdated: 2026-01-01\n---\n\nB.",
)
.unwrap()
}
}
fn index(issues: &[Issue]) -> HierarchyIndex {
let views: Vec<IssueView<'_>> = issues.iter().map(Issue::issue_view).collect();
HierarchyIndex::build(&views)
}
#[test]
fn t013_show_reports_parent_children_count_and_rollup() {
let issues = vec![
issue(1, "Epic", "todo", None),
issue(2, "Task A", "done", Some(1)),
issue(3, "Task B", "todo", Some(1)),
];
let index = index(&issues);
let text = show_text(&index, &issues, 1, "done").unwrap();
assert_eq!(
text,
"Parent of #1\n parent: none\n children: 2\n completed: 1/2 (direct children only)\nChildren\n #2 Task A\n #3 Task B",
);
}
#[test]
fn t013_show_handles_an_issue_with_neither_parent_nor_children() {
let issues = vec![issue(1, "Alone", "todo", None)];
let index = index(&issues);
let text = show_text(&index, &issues, 1, "done").unwrap();
assert!(text.contains("parent: none"), "{text}");
assert!(text.contains("children: 0"), "{text}");
assert!(text.contains("completed: 0/0"), "{text}");
assert!(text.contains("No children for #1"), "{text}");
}
#[test]
fn t013_show_reports_a_leaf_that_has_a_parent() {
let issues = vec![
issue(1, "Epic", "todo", None),
issue(2, "Leaf", "todo", Some(1)),
];
let index = index(&issues);
let text = show_text(&index, &issues, 2, "done").unwrap();
assert!(text.contains("parent: #1 Epic"), "{text}");
assert!(text.contains("children: 0"), "{text}");
assert!(text.contains("No children for #2"), "{text}");
}
#[test]
fn t013_show_marks_a_dangling_parent_as_missing_rather_than_as_none() {
let issues = vec![issue(1, "Orphan", "todo", Some(999))];
let index = index(&issues);
let text = show_text(&index, &issues, 1, "done").unwrap();
let expected = crate::hierarchy::unresolved_reason(1, 999);
assert!(
text.contains(&expected),
"expected {expected:?} in:\n{text}"
);
assert!(
text.contains("#999"),
"the recorded reference must stay visible so it can be repaired: {text}"
);
assert!(
!text.contains("parent: none"),
"a dangling reference must not read as no parent: {text}"
);
}
#[test]
fn t013_children_lists_direct_children_ordered_by_issue_number() {
let issues = vec![
issue(3, "Third", "todo", Some(1)),
issue(1, "Epic", "todo", None),
issue(4, "Grandchild", "todo", Some(3)),
issue(2, "Second", "todo", Some(1)),
];
let index = index(&issues);
assert_eq!(
children_text(&index, 1).unwrap(),
"#2 Second\n#3 Third\n2 children",
"ordered by issue number, and the grandchild is not listed"
);
}
#[test]
fn t013_children_names_the_empty_case() {
let issues = vec![issue(1, "Alone", "todo", None)];
let index = index(&issues);
assert_eq!(children_text(&index, 1).unwrap(), "No children for #1");
}
#[test]
fn t013_children_singular_wording_for_exactly_one() {
let issues = vec![issue(1, "P", "todo", None), issue(2, "C", "todo", Some(1))];
let index = index(&issues);
let text = children_text(&index, 1).unwrap();
assert!(text.ends_with("1 child"), "{text}");
assert!(!text.contains("1 children"), "{text}");
}
#[test]
fn t013_list_reports_only_issues_that_have_children() {
let issues = vec![
issue(1, "Epic", "todo", None),
issue(2, "Child", "done", Some(1)),
issue(3, "Lonely", "todo", None),
];
let index = index(&issues);
let text = list_text(&index, &issues, "done");
assert!(text.starts_with("ISSUE"), "{text}");
assert!(text.contains("#1 Epic"), "{text}");
assert!(
!text.contains("Lonely"),
"an issue with no children is not a parent: {text}"
);
assert!(
text.contains("1/1"),
"the rollup comes from the service: {text}"
);
}
#[test]
fn t013_list_names_the_empty_board() {
let issues = vec![issue(1, "Alone", "todo", None)];
let index = index(&issues);
assert_eq!(
list_text(&index, &issues, "done"),
"No issues have children"
);
}
#[test]
fn t013_list_columns_are_aligned_and_never_trailing_space() {
let issues = vec![
issue(1, "A very long epic title indeed", "todo", None),
issue(2, "x", "todo", Some(1)),
issue(3, "A middling one", "done", Some(1)),
];
let index = index(&issues);
let text = list_text(&index, &issues, "done");
for line in text.lines() {
assert_eq!(line, line.trim_end(), "trailing whitespace: {line:?}");
}
let header = text.lines().next().unwrap();
let header_at = header.find("CHILDREN").unwrap();
let expected: [(&str, &str); 3] = [
("2 children", "#1 A very long epic title indeed"),
("1 child", "#2 x"),
("1 child", "#3 A middling one"),
];
for (line, (children, label)) in text.lines().skip(1).zip(expected) {
assert!(line.starts_with(label), "row lost its label: {line:?}");
let at = line
.find(children)
.unwrap_or_else(|| panic!("row {line:?} has no children cell {children:?}"));
assert_eq!(
at, header_at,
"the CHILDREN cell starts at {at} in {line:?} but at {header_at} in the header"
);
}
}
#[test]
fn t013_list_is_ordered_by_issue_number_even_when_titles_contain_a_hash() {
let issues = vec![
issue(3, "release #7 notes", "todo", Some(1)),
issue(1, "root", "todo", None),
issue(2, "#99 decoy", "todo", Some(1)),
];
let index = index(&issues);
let text = list_text(&index, &issues, "done");
let order: Vec<&str> = text.lines().skip(1).collect();
assert_eq!(order.len(), 1, "only the root is a parent: {text}");
assert!(order[0].starts_with("#1 root"), "{text}");
}
#[test]
fn t013_show_and_children_error_on_an_issue_absent_from_the_snapshot() {
let issues = vec![issue(1, "Present", "todo", None)];
let index = index(&issues);
assert!(show_text(&index, &issues, 404, "done").is_err());
assert!(children_text(&index, 404).is_err());
}
}