use std::io::IsTerminal;
use crate::cli::{EpicCommand, ExitCode};
use crate::config::{Config, EpicId};
use crate::epics::{
self, Epic, EpicEdit, EpicHierarchy, EpicRollup, NewEpic, Patch, WeightedProgress,
};
use crate::error::{Result, TixError};
use crate::model::Issue;
use crate::relationships::{IssueView, RelationshipGraph};
use crate::storage::{BulkReferenceOutcome, Store};
pub fn run(config: &mut Config, store: &Store, command: &EpicCommand) -> Result<ExitCode> {
match command {
EpicCommand::Create {
name,
description,
status,
owner,
start_date,
due_date,
weight,
projects,
milestone,
cycle,
} => create(
config,
store,
name,
description.as_deref(),
status.as_deref(),
owner.as_deref(),
start_date.as_deref(),
due_date.as_deref(),
weight.as_deref(),
projects.as_deref(),
milestone.as_deref(),
cycle.as_deref(),
),
EpicCommand::Edit {
reference,
name,
description,
status,
owner,
start_date,
due_date,
weight,
parent,
projects,
milestone,
cycle,
} => edit(
config,
store,
reference,
name.as_deref(),
description.as_deref(),
status.as_deref(),
owner.as_deref(),
start_date.as_deref(),
due_date.as_deref(),
weight.as_deref(),
parent.as_deref(),
projects.as_deref(),
milestone.as_deref(),
cycle.as_deref(),
),
EpicCommand::List => list(config, store),
EpicCommand::Show { reference } => show(config, store, reference),
EpicCommand::Navigate { reference } => navigate(config, store, reference),
EpicCommand::Delete { reference, yes } => delete(config, store, reference, *yes),
EpicCommand::Add { reference, issues } | EpicCommand::BulkAdd { reference, issues } => {
reference_from_epic(config, store, reference, issues)
}
EpicCommand::Remove { reference, issues } => {
dereference_from_epic(config, store, reference, issues)
}
}
}
fn save(config: &mut Config, store: &Store) -> Result<()> {
config.validate()?;
let path = crate::commands::views::config_path(store);
config
.save(&path)
.map_err(|error| TixError::Config(format!("could not save `{}`: {error}", path.display())))
}
#[allow(clippy::too_many_arguments)]
fn create(
config: &mut Config,
store: &Store,
name: &str,
description: Option<&str>,
status: Option<&str>,
owner: Option<&str>,
start_date: Option<&str>,
due_date: Option<&str>,
weight: Option<&str>,
projects: Option<&str>,
milestone: Option<&str>,
cycle: Option<&str>,
) -> Result<ExitCode> {
let new = NewEpic {
name: name.to_string(),
description: description.map(String::from),
status: status.map(String::from),
owner: owner.map(String::from),
start_date: trim_optional(start_date),
due_date: trim_optional(due_date),
weight: create_weight(weight)?,
projects: projects
.map(crate::commands::parse_csv_list)
.unwrap_or_default(),
milestone: trim_optional(milestone),
cycle: trim_optional(cycle),
};
let epic = epics::create_epic(config, &new)?;
save(config, store)?;
println!("Created {}", epics::epic_display(&epic.id, &epic.name));
Ok(ExitCode::Success)
}
#[allow(clippy::too_many_arguments)]
fn edit(
config: &mut Config,
store: &Store,
reference: &str,
name: Option<&str>,
description: Option<&str>,
status: Option<&str>,
owner: Option<&str>,
start_date: Option<&str>,
due_date: Option<&str>,
weight: Option<&str>,
parent: Option<&str>,
projects: Option<&str>,
milestone: Option<&str>,
cycle: Option<&str>,
) -> Result<ExitCode> {
let edit = EpicEdit {
name: name.map_or(Patch::Unchanged, |value| Patch::Set(value.to_string())),
description: text_patch(description),
status: text_patch(status),
owner: text_patch(owner),
start_date: date_patch(start_date),
due_date: date_patch(due_date),
weight: weight_patch(weight)?,
parent: text_patch(parent),
projects: match projects {
None => Patch::Unchanged,
Some(value) if value.trim().is_empty() => Patch::Clear,
Some(value) => Patch::Set(crate::commands::parse_csv_list(value)),
},
milestone: text_patch(milestone),
cycle: text_patch(cycle),
};
if edit.name.is_unchanged()
&& edit.description.is_unchanged()
&& edit.status.is_unchanged()
&& edit.owner.is_unchanged()
&& edit.start_date.is_unchanged()
&& edit.due_date.is_unchanged()
&& edit.weight.is_unchanged()
&& edit.parent.is_unchanged()
&& edit.projects.is_unchanged()
&& edit.milestone.is_unchanged()
&& edit.cycle.is_unchanged()
{
return Err(epic_edit_requires_a_field());
}
let epic = epics::edit_epic(config, reference, &edit)?;
save(config, store)?;
println!("Updated {}", epics::epic_display(&epic.id, &epic.name));
Ok(ExitCode::Success)
}
fn list(config: &Config, store: &Store) -> Result<ExitCode> {
for line in listing_lines(config, &store.list_all()?) {
println!("{line}");
}
Ok(ExitCode::Success)
}
pub fn listing_lines(config: &Config, issues: &[Issue]) -> Vec<String> {
epics::list_epics(config, issues)
.iter()
.map(|epic| {
format!(
"{}\t{}\t{}\t{}",
epic.id, epic.name, epic.member_count, epic.child_count
)
})
.collect()
}
fn show(config: &Config, store: &Store, reference: &str) -> Result<ExitCode> {
let issues = store.list_all()?;
let index = epics::EpicIndex::build(config, &issues);
let epic = index.resolve(reference)?.clone();
let targets = reference_targets(config, store)?;
let (graph_issues, done_column) = store.graph_source()?;
let views: Vec<IssueView<'_>> = graph_issues.iter().map(Issue::issue_view).collect();
let graph = RelationshipGraph::new(&views, &done_column);
let rollup = index.rollup(config, reference, &targets, &graph)?;
for line in record_lines(&epic, Some(&rollup)) {
println!("{line}");
}
Ok(ExitCode::Success)
}
pub fn record_lines(epic: &Epic, rollup: Option<&EpicRollup>) -> Vec<String> {
let mut lines = vec![
format!("id: {}", epic.id),
format!("name: {}", epic.name),
format!(
"description: {}",
epic.description.as_deref().unwrap_or("-")
),
format!("status: {}", epic.status.as_deref().unwrap_or("-")),
format!("owner: {}", epic.owner.as_deref().unwrap_or("-")),
format!("start-date: {}", epic.start_date.as_deref().unwrap_or("-")),
format!("due-date: {}", epic.due_date.as_deref().unwrap_or("-")),
format!(
"weight: {}",
epic.weight
.map_or_else(|| "-".to_string(), |w| w.to_string())
),
format!("parent: {}", epic.parent.as_deref().unwrap_or("-")),
format!("projects: {}", join_or_dash(&epic.projects)),
format!("milestone: {}", epic.milestone.as_deref().unwrap_or("-")),
format!("cycle: {}", epic.cycle.as_deref().unwrap_or("-")),
format!("children: {}", epic.child_count),
format!("members: {}", epic.member_count),
];
if let Some(rollup) = rollup {
lines.push(format!(
"progress: {}/{}",
rollup.progress.done, rollup.progress.total
));
lines.push(format!(
"weighted: {}",
weighted_summary(&rollup.weighted_progress)
));
lines.push(format!("blocked: {}", rollup.blocked));
lines.push(format!(
"missing-references: {}",
rollup.missing_references.len()
));
}
lines.extend(
epic.children
.iter()
.map(|child| format!(" child: {}", child.display)),
);
lines.extend(
epic.members
.iter()
.map(|member| format!(" {}", member.display)),
);
lines
}
fn weighted_summary(weighted: &WeightedProgress) -> String {
let percent = weighted
.percent
.map_or_else(|| "-".to_string(), |p| format!("{p}%"));
format!(
"{}/{} ({percent}; {} weighted, {} unweighted child epic(s))",
weighted.done_weight,
weighted.total_weight,
weighted.weighted_children,
weighted.unweighted_children
)
}
fn navigate(config: &Config, store: &Store, reference: &str) -> Result<ExitCode> {
let issues = store.list_all()?;
let index = epics::EpicIndex::build(config, &issues);
let hierarchy = index.hierarchy(reference)?;
let epic = index.resolve(reference)?.clone();
for line in navigation_lines(&hierarchy, &epic) {
println!("{line}");
}
Ok(ExitCode::Success)
}
pub fn navigation_lines(hierarchy: &EpicHierarchy, epic: &Epic) -> Vec<String> {
let mut lines = vec!["ancestors:".to_string()];
if hierarchy.ancestors.is_empty() {
lines.push(" -".to_string());
}
for ancestor in &hierarchy.ancestors {
lines.push(format!(" {}", ancestor.display));
}
match &hierarchy.parent {
Some(parent) => lines.push(format!("parent: {}", parent.display)),
None => lines.push("parent: -".to_string()),
}
if let Some(unresolved) = &hierarchy.unresolved {
lines.push(format!("unresolved-parent: {}", unresolved.reason));
}
lines.push(format!(
"epic: {}",
epics::epic_display(&epic.id, &epic.name)
));
lines.push(format!("depth: {}", hierarchy.depth));
lines.push(format!("children: {}", hierarchy.child_count));
for child in &hierarchy.children {
lines.push(format!(" {}", child.display));
}
lines.push(format!("issues: {}", epic.member_count));
for member in &epic.members {
lines.push(format!(" {}", member.display));
}
lines.push(
"note: members are ordinary issues; navigate one directly with `tix show <number>`"
.to_string(),
);
lines
}
fn delete(config: &mut Config, store: &Store, reference: &str, yes: bool) -> Result<ExitCode> {
let issues = store.list_all()?;
let epic = epics::resolve(config, &issues, reference)?;
let mut confirmed = yes || !std::io::stdin().is_terminal();
if !confirmed {
eprint!(
"Delete epic '{}'? [y/N] ",
epics::epic_display(&epic.id, &epic.name)
);
let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.map_err(TixError::Io)?;
confirmed = line.trim().eq_ignore_ascii_case("y");
if !confirmed {
eprintln!("aborted");
}
}
if !confirmed {
return Ok(ExitCode::Success);
}
eprint!("{}", impact_notice(&epic));
let deletion = epics::delete_epic(config, reference, &issues)?;
save(config, store)?;
println!(
"Deleted epic {}",
epics::epic_display(&deletion.id, &deletion.name)
);
Ok(ExitCode::Success)
}
pub fn impact_notice(epic: &Epic) -> String {
let mut out = String::new();
if !epic.members.is_empty() {
let count = epic.members.len();
if count == 1 {
out.push_str(
"Note: 1 issue still references this epic and keeps the reference as a missing \
epic reference.\nIt is not deleted or modified; `tix lint` reports the dangling \
reference until it is repaired.\n",
);
} else {
out.push_str(&format!(
"Note: {count} issues still reference this epic and keep the reference as a \
missing epic reference.\nThey are not deleted or modified; `tix lint` reports \
the dangling reference until it is repaired.\n"
));
}
for member in &epic.members {
out.push_str(&format!(" {}\n", member.display));
}
}
if !epic.children.is_empty() {
let count = epic.children.len();
if count == 1 {
out.push_str(
"Note: 1 child epic records this epic as its parent and keeps the reference as \
an unresolved parent.\nIt is not deleted or modified.\n",
);
} else {
out.push_str(&format!(
"Note: {count} child epics record this epic as their parent and keep the \
reference as an unresolved parent.\nThey are not deleted or modified.\n"
));
}
for child in &epic.children {
out.push_str(&format!(" {}\n", child.display));
}
}
out
}
fn reference_from_epic(
config: &Config,
store: &Store,
reference: &str,
selectors: &[String],
) -> Result<ExitCode> {
let id = epics::resolve_epic_id(&config.epics, reference)?;
let display = display_for(config, &id);
let outcomes = store.bulk_reference(id.as_str(), selectors)?;
report(&display, "referenced", outcomes)
}
fn dereference_from_epic(
config: &Config,
store: &Store,
reference: &str,
selectors: &[String],
) -> Result<ExitCode> {
let needle = epics::dereference_needle(&config.epics, reference);
let display = match epics::resolve_epic_id(&config.epics, reference) {
Ok(id) => display_for(config, &id),
Err(_) => format!("`{reference}`"),
};
let mut code = ExitCode::Success;
let mut changed = 0usize;
let mut unchanged = 0usize;
let mut failed = 0usize;
for selector in selectors {
match store.dereference_epic(selector, &needle) {
Ok(applied) if applied.changed => {
changed += 1;
println!("dereferenced {selector}");
}
Ok(_) => {
unchanged += 1;
println!("dereferenced {selector} (was not referenced)");
}
Err(error) => {
failed += 1;
eprintln!("{}", crate::error::render_error(&error));
if code == ExitCode::Success {
code = crate::cli::exit_code_for(&error);
}
}
}
}
println!(
"dereferenced {changed} of {} issue(s) from {display}{}{}",
selectors.len(),
unchanged_note(unchanged),
failure_note(failed)
);
Ok(code)
}
fn report(display: &str, verb: &str, outcomes: Vec<BulkReferenceOutcome>) -> Result<ExitCode> {
let total = outcomes.len();
let mut code = ExitCode::Success;
let mut changed = 0usize;
let mut unchanged = 0usize;
let mut failed = 0usize;
for outcome in outcomes {
match outcome.outcome {
Ok(applied) if applied.changed => {
changed += 1;
println!("{verb} {}", outcome.selector);
}
Ok(_) => {
unchanged += 1;
println!("{verb} {} (already {verb})", outcome.selector);
}
Err(error) => {
failed += 1;
eprintln!("{}", crate::error::render_error(&error));
if code == ExitCode::Success {
code = crate::cli::exit_code_for(&error);
}
}
}
}
println!(
"{verb} {changed} of {total} issue(s) with {display}{}{}",
unchanged_note(unchanged),
failure_note(failed)
);
Ok(code)
}
fn unchanged_note(unchanged: usize) -> String {
if unchanged == 0 {
String::new()
} else {
format!(" ({unchanged} unchanged)")
}
}
fn failure_note(failed: usize) -> String {
if failed == 0 {
String::new()
} else {
format!("; {failed} failed")
}
}
fn display_for(config: &Config, id: &EpicId) -> String {
match config.epics.get(id.as_str()) {
Some(record) => epics::epic_display(id, &record.name),
None => format!("`{id}`"),
}
}
pub fn resolve_epics(config: &Config, raw: &str) -> Result<Vec<String>> {
let mut out: Vec<String> = Vec::new();
for reference in crate::commands::parse_csv_list(raw) {
let id = epics::resolve_epic_id(&config.epics, &reference)?.to_string();
if !out.contains(&id) {
out.push(id);
}
}
Ok(out)
}
pub fn reference_created(store: &Store, issue: &Issue, ids: &[String]) -> Result<()> {
for id in ids {
store.reference_epic(&issue.number.to_string(), id)?;
}
Ok(())
}
#[derive(Debug)]
pub struct EpicEditPlan {
add_ids: Vec<String>,
remove_ids: Vec<String>,
clear: bool,
}
pub fn plan_edit_epics(
config: &Config,
store: &Store,
selector: &str,
add: &[String],
remove: &[String],
clear: bool,
) -> Result<EpicEditPlan> {
let mut add_ids: Vec<String> = Vec::new();
for reference in add {
let id = epics::resolve_epic_id(&config.epics, reference)?.to_string();
if !add_ids.contains(&id) {
add_ids.push(id);
}
}
let remove_ids: Vec<String> = remove
.iter()
.map(|reference| epics::dereference_needle(&config.epics, reference))
.collect();
let stored = store.resolve_issue(selector)?.epics.unwrap_or_default();
for reference in &remove_ids {
let known = epics::resolve_epic_id(&config.epics, reference).is_ok();
if !known && !stored.iter().any(|id| id == reference) {
return Err(epics::EpicError::UnknownEpic {
reference: reference.clone(),
}
.into());
}
}
Ok(EpicEditPlan {
add_ids,
remove_ids,
clear,
})
}
pub fn apply_edit_epics(store: &Store, selector: &str, plan: &EpicEditPlan) -> Result<bool> {
let mut changed = false;
if plan.clear {
let stored = store.resolve_issue(selector)?.epics.unwrap_or_default();
for id in &stored {
changed |= store.dereference_epic(selector, id)?.changed;
}
}
for id in &plan.remove_ids {
changed |= store.dereference_epic(selector, id)?.changed;
}
for id in &plan.add_ids {
changed |= store.reference_epic(selector, id)?.changed;
}
Ok(changed)
}
fn epic_edit_requires_a_field() -> TixError {
TixError::InvalidInput(
"no fields to change; pass at least one of --name, --description, --status, \
--owner, --start-date, --due-date, --weight, --parent, --projects, \
--milestone or --cycle"
.to_string(),
)
}
fn text_patch(value: Option<&str>) -> Patch<String> {
match value {
None => Patch::Unchanged,
Some(text) if text.trim().is_empty() => Patch::Clear,
Some(text) => Patch::Set(text.to_string()),
}
}
fn date_patch(value: Option<&str>) -> Patch<String> {
match value {
None => Patch::Unchanged,
Some(text) if text.trim().is_empty() => Patch::Clear,
Some(text) => Patch::Set(text.trim().to_string()),
}
}
fn weight_patch(value: Option<&str>) -> Result<Patch<i64>> {
match value {
None => Ok(Patch::Unchanged),
Some(text) if text.trim().is_empty() => Ok(Patch::Clear),
Some(text) => Ok(Patch::Set(parse_weight(text)?)),
}
}
fn create_weight(value: Option<&str>) -> Result<Option<i64>> {
match value {
None => Ok(None),
Some(text) if text.trim().is_empty() => Ok(None),
Some(text) => Ok(Some(parse_weight(text)?)),
}
}
fn parse_weight(text: &str) -> Result<i64> {
text.trim().parse::<i64>().map_err(|_| {
TixError::InvalidInput(format!(
"{}: epic weight must be an integer; got `{text}`",
epics::EpicDiagnosticKind::InvalidWeight
))
})
}
fn trim_optional(value: Option<&str>) -> Option<String> {
value
.map(str::trim)
.filter(|text| !text.is_empty())
.map(String::from)
}
fn join_or_dash(values: &[String]) -> String {
if values.is_empty() {
"-".to_string()
} else {
values.join(", ")
}
}
fn reference_targets(config: &Config, store: &Store) -> Result<epics::ReferenceTargets> {
let root = Config::project_root(&store.issues_dir).unwrap_or_else(|| store.issues_dir.clone());
let planner = crate::planning::Planner::from_config(config.clone(), root);
epics::ReferenceTargets::from_planner(&planner)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::EpicConfiguration;
use std::fs;
use tempfile::TempDir;
fn store_with(epics: &[(&str, &str)], issues: usize) -> (TempDir, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let mut config = Config {
issues_dir: dir.clone(),
..Config::default()
};
config.layout.directory = "{assignee}".to_string();
config.layout.move_on_change = false;
for (id, name) in epics {
config.epics.insert(
(*id).to_string(),
EpicConfiguration {
name: (*name).to_string(),
..EpicConfiguration::default()
},
);
}
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let store = Store {
issues_dir: dir.clone(),
config,
};
for number in 1..=issues {
write_issue(
&store,
u32::try_from(number).unwrap(),
&format!("Issue {number}"),
&[],
);
}
(tmp, store)
}
fn store_with_unresolved_parent() -> (TempDir, Store) {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("issues");
fs::create_dir_all(&dir).unwrap();
let config_path = tmp.path().join("tix.toml");
let mut config = Config {
issues_dir: dir.clone(),
..Config::default()
};
config.layout.directory = "{assignee}".to_string();
config.layout.move_on_change = false;
config.epics.insert(
"e-1".to_string(),
EpicConfiguration {
name: "Orphan".to_string(),
parent: Some("e-gone".to_string()),
..EpicConfiguration::default()
},
);
config.save(&config_path).unwrap();
let config = Config::load_with_warning(&config_path, |_| {}).unwrap();
let store = Store {
issues_dir: dir,
config,
};
(tmp, store)
}
fn write_issue(store: &Store, number: u32, title: &str, epics: &[&str]) {
let mut frontmatter =
format!("---\nnumber: {number}\ntitle: \"{title}\"\nstatus: \"todo\"\nlabels: []\n");
if !epics.is_empty() {
frontmatter.push_str("epics:\n");
for id in epics {
frontmatter.push_str(&format!("- {id}\n"));
}
}
frontmatter.push_str("---\n");
fs::write(
store
.issues_dir
.join(format!("2026-01-{number:02}-issue-{number}.md")),
frontmatter,
)
.unwrap();
}
fn registry(store: &Store) -> String {
fs::read_to_string(registry_path(store)).unwrap()
}
fn registry_path(store: &Store) -> std::path::PathBuf {
Config::project_root(&store.issues_dir)
.unwrap()
.join("tix.toml")
}
fn issue_bytes(store: &Store, number: u32) -> String {
fs::read_to_string(
store
.issues_dir
.join(format!("2026-01-{number:02}-issue-{number}.md")),
)
.unwrap()
}
#[test]
fn the_listing_shows_each_epic_id_and_name_alongside_its_counts() {
let (_tmp, store) = store_with(&[("e-1", "Harden"), ("e-2", "Docs")], 0);
let mut config = store.config.clone();
config.epics.get_mut("e-2").unwrap().parent = Some("e-1".to_string());
let lines = listing_lines(&config, &store.list_all().unwrap());
assert_eq!(lines[0], "e-1\tHarden\t0\t1");
assert_eq!(lines[1], "e-2\tDocs\t0\t0");
assert!(lines[0].contains("e-1"));
assert!(lines[0].contains("Harden"));
}
#[test]
fn create_allocates_an_id_and_refuses_a_duplicate_name() {
let (_tmp, store) = store_with(&[], 0);
let mut config = store.config.clone();
create(
&mut config,
&store,
"Harden",
None,
None,
Some("alice"),
None,
None,
Some("3"),
Some("p-1"),
None,
None,
)
.unwrap();
assert_eq!(config.epics.len(), 1);
let (id, record) = config.epics.iter().next().unwrap();
assert_eq!(record.name, "Harden");
assert_eq!(record.owner.as_deref(), Some("alice"));
assert_eq!(record.weight, Some(3));
assert_eq!(record.projects, vec!["p-1".to_string()]);
assert!(id.starts_with("e-"), "an allocated id: {id}");
let before = registry(&store);
let error = create(
&mut config,
&store,
"harden",
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.expect_err("a duplicate name is refused");
assert!(
error.to_string().contains("duplicate_name"),
"the named diagnostic: {error}"
);
assert_eq!(config.epics.len(), 1);
assert_eq!(registry(&store), before, "nothing written");
}
#[test]
fn edit_renames_the_label_and_keeps_the_id() {
let (_tmp, store) = store_with(&[("e-1", "Harden")], 1);
write_issue(&store, 1, "Issue 1", &["e-1"]);
let mut config = store.config.clone();
edit(
&mut config,
&store,
"e-1",
Some("Harden the sync path"),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.unwrap();
assert!(config.epics.contains_key("e-1"));
assert_eq!(config.epics["e-1"].name, "Harden the sync path");
let epic = epics::resolve(&config, &store.list_all().unwrap(), "e-1").unwrap();
assert_eq!(epic.name, "Harden the sync path");
assert_eq!(epic.member_count, 1);
}
#[test]
fn edit_refuses_a_self_parent_and_a_cycle_without_writing() {
let (_tmp, store) = store_with(&[("e-1", "A"), ("e-2", "B")], 0);
let mut config = store.config.clone();
edit(
&mut config,
&store,
"e-2",
None,
None,
None,
None,
None,
None,
None,
Some("e-1"),
None,
None,
None,
)
.unwrap();
assert_eq!(config.epics["e-2"].parent.as_deref(), Some("e-1"));
let before = registry(&store);
let error = edit(
&mut config,
&store,
"e-1",
None,
None,
None,
None,
None,
None,
None,
Some("e-1"),
None,
None,
None,
)
.expect_err("self-parent");
assert!(error.to_string().contains("self_parent"), "{error}");
let error = edit(
&mut config,
&store,
"e-1",
None,
None,
None,
None,
None,
None,
None,
Some("e-2"),
None,
None,
None,
)
.expect_err("cycle");
assert!(error.to_string().contains("cycle"), "{error}");
assert_eq!(registry(&store), before, "nothing written on a refusal");
}
#[test]
fn edit_with_no_fields_is_a_usage_error() {
let (_tmp, store) = store_with(&[("e-1", "A")], 0);
let mut config = store.config.clone();
let error = edit(
&mut config,
&store,
"e-1",
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.expect_err("a fieldless edit is refused");
assert!(error.to_string().contains("no fields to change"), "{error}");
}
#[test]
fn delete_removes_only_the_registry_entry_and_reports_members_and_children() {
let (_tmp, store) = store_with(&[("e-1", "Parent"), ("e-2", "Child")], 1);
write_issue(&store, 1, "Issue 1", &["e-1"]);
let mut config = store.config.clone();
config.epics.get_mut("e-2").unwrap().parent = Some("e-1".to_string());
let epic = epics::resolve(&config, &store.list_all().unwrap(), "e-1").unwrap();
let notice = impact_notice(&epic);
assert!(
notice.contains("Issue 1"),
"the notice names the issue: {notice}"
);
assert!(
notice.contains("Child"),
"the notice names the child: {notice}"
);
assert!(notice.contains("not deleted"));
let issue_before = issue_bytes(&store, 1);
delete(&mut config, &store, "e-1", true).unwrap();
assert!(!config.epics.contains_key("e-1"));
assert!(config.epics.contains_key("e-2"));
assert!(!registry(&store).contains("[epics.e-1]"));
assert_eq!(issue_bytes(&store, 1), issue_before);
assert!(issue_bytes(&store, 1).contains("e-1"));
assert_eq!(config.epics["e-2"].parent.as_deref(), Some("e-1"));
}
#[test]
fn delete_refuses_an_unknown_id_and_writes_nothing() {
let (_tmp, store) = store_with(&[("e-1", "A")], 0);
let mut config = store.config.clone();
let before = registry(&store);
let error = delete(&mut config, &store, "bogus", true).expect_err("unknown id");
assert!(error.to_string().contains("unknown_epic"), "{error}");
assert!(config.epics.contains_key("e-1"));
assert_eq!(registry(&store), before);
}
#[test]
fn resolve_epics_collapses_duplicates_and_refuses_an_unknown_id() {
let (_tmp, store) = store_with(&[("e-1", "Harden")], 0);
let ids = resolve_epics(&store.config, "Harden,e-1").unwrap();
assert_eq!(
ids,
vec!["e-1".to_string()],
"a name and an id are one epic"
);
let error = resolve_epics(&store.config, "e-1,bogus").expect_err("unknown id");
assert!(error.to_string().contains("unknown_epic"), "{error}");
}
#[test]
fn reference_created_writes_one_entry_per_epic() {
let (_tmp, store) = store_with(&[("e-1", "A"), ("e-2", "B")], 1);
let issue = store.resolve_issue("1").unwrap();
reference_created(&store, &issue, &["e-1".to_string(), "e-2".to_string()]).unwrap();
let after = store.resolve_issue("1").unwrap();
assert_eq!(
after.epics,
Some(vec!["e-1".to_string(), "e-2".to_string()])
);
assert_eq!(after.history.events().len(), 2, "one entry per reference");
}
#[test]
fn a_plan_refuses_an_unknown_remove_reference_that_is_not_stored() {
let (_tmp, store) = store_with(&[("e-1", "A")], 1);
let error = plan_edit_epics(
&store.config,
&store,
"1",
&[],
&["bogus".to_string()],
false,
)
.expect_err("an unresolvable, unstored reference is a typo");
assert!(error.to_string().contains("unknown_epic"), "{error}");
}
#[test]
fn a_plan_accepts_removing_a_stale_stored_id() {
let (_tmp, store) = store_with(&[], 1);
write_issue(&store, 1, "Issue 1", &["e-gone"]);
let plan = plan_edit_epics(
&store.config,
&store,
"1",
&[],
&["e-gone".to_string()],
false,
)
.unwrap();
assert!(
apply_edit_epics(&store, "1", &plan).unwrap(),
"removing a stored id changed the issue"
);
assert_eq!(store.resolve_issue("1").unwrap().epics, None);
}
#[test]
fn clearing_removes_every_reference_and_keeps_the_issue() {
let (_tmp, store) = store_with(&[("e-1", "A"), ("e-2", "B")], 1);
write_issue(&store, 1, "Issue 1", &["e-1", "e-2"]);
let plan = plan_edit_epics(&store.config, &store, "1", &[], &[], true).unwrap();
assert!(apply_edit_epics(&store, "1", &plan).unwrap());
let issue = store.resolve_issue("1").unwrap();
assert_eq!(issue.epics, None);
assert_eq!(issue.history.events().len(), 2, "one entry per removal");
assert!(
store.issues_dir.join(&issue.filename).is_file(),
"the issue itself is kept"
);
}
#[test]
fn a_noop_epic_plan_reports_no_change() {
let (_tmp, store) = store_with(&[("e-1", "A"), ("e-2", "B")], 1);
write_issue(&store, 1, "Issue 1", &["e-1"]);
let plan =
plan_edit_epics(&store.config, &store, "1", &["e-1".to_string()], &[], false).unwrap();
assert!(
!apply_edit_epics(&store, "1", &plan).unwrap(),
"adding an existing reference is a no-op"
);
let plan =
plan_edit_epics(&store.config, &store, "1", &[], &["e-2".to_string()], false).unwrap();
assert!(
!apply_edit_epics(&store, "1", &plan).unwrap(),
"removing a reference that is not there is a no-op"
);
}
#[test]
fn bulk_reference_reports_each_selector_independently() {
let (_tmp, store) = store_with(&[("e-1", "A")], 2);
let code = reference_from_epic(
&store.config,
&store,
"e-1",
&["1".to_string(), "99".to_string()],
)
.unwrap();
assert_ne!(code, ExitCode::Success, "a partial batch exits non-zero");
assert!(issue_bytes(&store, 1).contains("e-1"));
assert_eq!(
store.resolve_issue("1").unwrap().history.events().len(),
1,
"exactly one History entry for the accepted change"
);
}
#[test]
fn associate_refuses_an_unknown_epic_before_writing_anything() {
let (_tmp, store) = store_with(&[("e-1", "A")], 1);
let before = issue_bytes(&store, 1);
let error = reference_from_epic(&store.config, &store, "bogus", &["1".to_string()])
.expect_err("an unknown epic refuses up front");
assert!(error.to_string().contains("unknown_epic"), "{error}");
assert_eq!(issue_bytes(&store, 1), before, "nothing written");
}
#[test]
fn navigation_lines_are_parent_first_with_ancestors_and_depth() {
let (_tmp, store) = store_with(&[("e-1", "Root"), ("e-2", "Mid"), ("e-3", "Leaf")], 0);
let mut config = store.config.clone();
config.epics.get_mut("e-2").unwrap().parent = Some("e-1".to_string());
config.epics.get_mut("e-3").unwrap().parent = Some("e-2".to_string());
let issues = store.list_all().unwrap();
let index = epics::EpicIndex::build(&config, &issues);
let hierarchy = index.hierarchy("e-3").unwrap();
let epic = index.resolve("e-3").unwrap().clone();
let lines = navigation_lines(&hierarchy, &epic);
let pos = |needle: &str| lines.iter().position(|l| l.contains(needle));
assert!(
pos("Root") < pos("Mid") && pos("Mid") < pos("Leaf"),
"parent-first order: {lines:?}"
);
assert!(lines.contains(&"depth: 2".to_string()), "{lines:?}");
assert!(
lines.iter().any(|l| l.contains("navigate one directly")),
"direct issue navigation is advertised: {lines:?}"
);
}
#[test]
fn navigation_reports_an_unresolved_parent() {
let (_tmp, store) = store_with_unresolved_parent();
let issues = store.list_all().unwrap();
let index = epics::EpicIndex::build(&store.config, &issues);
let hierarchy = index.hierarchy("e-1").unwrap();
let epic = index.resolve("e-1").unwrap().clone();
let lines = navigation_lines(&hierarchy, &epic);
assert!(
lines.iter().any(|l| l.starts_with("unresolved-parent:")),
"the unresolved parent is reported: {lines:?}"
);
assert!(lines.contains(&"parent: -".to_string()), "{lines:?}");
}
#[test]
fn impact_notice_agrees_with_its_subject() {
let (_tmp, store) = store_with(&[("e-1", "Parent"), ("e-2", "Child")], 1);
write_issue(&store, 1, "Issue 1", &["e-1"]);
let mut config = store.config.clone();
config.epics.get_mut("e-2").unwrap().parent = Some("e-1".to_string());
let epic = epics::resolve(&config, &store.list_all().unwrap(), "e-1").unwrap();
let notice = impact_notice(&epic);
assert!(
notice.contains("1 issue still references this epic"),
"{notice}"
);
assert!(
notice.contains("1 child epic records this epic as its parent"),
"{notice}"
);
let (_tmp2, store2) = store_with(
&[("e-1", "Parent"), ("e-2", "Child A"), ("e-3", "Child B")],
2,
);
write_issue(&store2, 1, "Issue 1", &["e-1"]);
write_issue(&store2, 2, "Issue 2", &["e-1"]);
let mut config2 = store2.config.clone();
config2.epics.get_mut("e-2").unwrap().parent = Some("e-1".to_string());
config2.epics.get_mut("e-3").unwrap().parent = Some("e-1".to_string());
let epic2 = epics::resolve(&config2, &store2.list_all().unwrap(), "e-1").unwrap();
let notice2 = impact_notice(&epic2);
assert!(
notice2.contains("2 issues still reference this epic"),
"{notice2}"
);
assert!(
notice2.contains("2 child epics record this epic as their parent"),
"{notice2}"
);
}
}