use std::str::FromStr;
use crate::cli::LinkAction;
use crate::error::{Result, TixError};
use crate::model::Issue;
use crate::relationships::{
BlockedState, DependencyChain, Diagnostic, IssueView, RelationKind, Relationship,
RelationshipGraph,
};
use crate::storage::{RelationshipOutcome, Store};
const KIND_SPELLINGS: &str = "blocks|blocked_by|duplicates|related";
fn chain_bound_note() -> String {
format!(
"note: chain display truncated at {} nodes; the chain may continue",
crate::relationships::MAX_CHAIN_NODES
)
}
pub fn parse_kind(raw: &str) -> Result<RelationKind> {
RelationKind::from_str(raw).map_err(|d: Diagnostic| TixError::InvalidInput(d.to_string()))
}
pub fn require_kind(raw: Option<&str>) -> Result<RelationKind> {
require_kind_named(raw, &format!("pass --type {KIND_SPELLINGS}"))
}
pub fn require_kind_named(raw: Option<&str>, advice: &str) -> Result<RelationKind> {
match raw.map(str::trim) {
Some(value) if !value.is_empty() => parse_kind(value),
_ => Err(TixError::InvalidInput(
Diagnostic::unknown_kind("kind", format!("relation type required: {advice}"))
.to_string(),
)),
}
}
fn require_endpoint(raw: Option<&str>, name: &str) -> Result<String> {
match raw.map(str::trim) {
Some(value) if !value.is_empty() => Ok(value.to_string()),
_ => Err(TixError::InvalidInput(format!(
"{name} is required: usage is `tix link <from> <to> --type <kind>`"
))),
}
}
pub fn link_message(outcome: &RelationshipOutcome) -> String {
let rel = &outcome.relationship;
if outcome.changed {
format!(
"Linked {} {} {} ({})",
rel.from.human(),
rel.kind,
rel.to.human(),
rel.id
)
} else {
format!(
"Already linked {} {} {} (no change, {})",
rel.from.human(),
rel.kind,
rel.to.human(),
rel.id
)
}
}
pub fn unlink_message(outcome: &RelationshipOutcome) -> String {
let rel = &outcome.relationship;
if outcome.changed {
format!(
"Unlinked {} {} {} ({})",
rel.from.human(),
rel.kind,
rel.to.human(),
rel.id
)
} else {
format!(
"Not linked {} {} {} (no change)",
rel.from.human(),
rel.kind,
rel.to.human()
)
}
}
pub fn edit_message(outcome: &RelationshipOutcome) -> String {
let rel = &outcome.relationship;
if outcome.changed {
format!(
"Relinked {}: {} {} {}",
rel.id,
rel.from.human(),
rel.kind,
rel.to.human()
)
} else {
format!("{} unchanged (no change)", rel.id)
}
}
pub fn remove_message(outcome: &RelationshipOutcome) -> String {
let rel = &outcome.relationship;
if outcome.changed {
format!(
"Removed {}: {} {} {}",
rel.id,
rel.from.human(),
rel.kind,
rel.to.human()
)
} else {
format!("No relationship {} (no change)", rel.id)
}
}
pub fn run(
store: &Store,
action: Option<&LinkAction>,
from: Option<&str>,
to: Option<&str>,
kind: Option<&str>,
) -> Result<()> {
store.transaction(|| match action {
Some(LinkAction::Edit { id, to, r#type }) => {
edit(store, id, to.as_deref(), r#type.as_deref())
}
Some(LinkAction::Remove { id }) => remove(store, id),
Some(LinkAction::List { issue, kind }) => list(store, issue.as_deref(), kind.as_deref()),
None => {
let from = require_endpoint(from, "from")?;
let to = require_endpoint(to, "to")?;
let kind = require_kind(kind)?;
let outcome = store.link(&from, &to, kind)?;
println!("{}", link_message(&outcome));
Ok(())
}
})
}
fn edit(store: &Store, id: &str, to: Option<&str>, kind: Option<&str>) -> Result<()> {
let new_kind = match kind {
Some(raw) => Some(parse_kind(raw)?),
None => None,
};
let new_to = match to {
Some(raw) => Some(
store
.get(crate::commands::parse_selector(store, raw)?)
.map(|i| i.number)?,
),
None => None,
};
if new_to.is_none() && new_kind.is_none() {
return Err(TixError::InvalidInput(format!(
"nothing to change: pass --to and/or --type to `tix link edit {id}`"
)));
}
let outcome = store.edit_relation(id, new_to, new_kind)?;
println!("{}", edit_message(&outcome));
Ok(())
}
fn remove(store: &Store, id: &str) -> Result<()> {
let outcome = store.remove_relation(id)?;
println!("{}", remove_message(&outcome));
Ok(())
}
fn list(store: &Store, issue: Option<&str>, kind: Option<&str>) -> Result<()> {
let wanted = match kind {
Some(raw) => Some(canonical_filter(parse_kind(raw)?)),
None => None,
};
let subject = match issue {
Some(raw) => Some(
store
.get(crate::commands::parse_selector(store, raw)?)
.map(|i| i.number)?,
),
None => None,
};
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);
let edges: Vec<&Relationship> = match subject {
Some(number) => graph.relationships_for(number),
None => graph.all_relationships().iter().collect(),
};
let shown: Vec<&Relationship> = edges
.into_iter()
.filter(|rel| wanted.is_none_or(|w| rel.kind == w))
.collect();
if shown.is_empty() {
println!("{}", empty_listing_text(subject, wanted));
return Ok(());
}
for rel in shown {
println!("{}", relationship_line(rel));
}
Ok(())
}
pub fn listing_text(
shown: &[&Relationship],
subject: Option<u32>,
wanted: Option<RelationKind>,
) -> String {
if shown.is_empty() {
return empty_listing_text(subject, wanted);
}
shown
.iter()
.map(|rel| relationship_line(rel))
.collect::<Vec<_>>()
.join("\n")
}
fn empty_listing_text(subject: Option<u32>, wanted: Option<RelationKind>) -> String {
match (subject, wanted) {
(Some(number), Some(kind)) => format!("No {} relationships for #{number}", kind),
(Some(number), None) => format!("No relationships for #{number}"),
(None, Some(kind)) => format!("No {kind} relationships"),
(None, None) => "No relationships".to_string(),
}
}
pub fn canonical_filter(kind: RelationKind) -> RelationKind {
match kind {
RelationKind::BlockedBy => RelationKind::Blocks,
other => other,
}
}
pub fn unlink_cli(store: &Store, from: &str, to: &str, kind: Option<&str>) -> Result<()> {
let kind = require_kind(kind)?;
let outcome = store.transaction(|| store.unlink(from, to, kind))?;
println!("{}", unlink_message(&outcome));
Ok(())
}
pub fn chain(store: &Store, raw: &str) -> Result<()> {
let issue = store.get(crate::commands::parse_selector(store, raw)?)?;
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!("{}", chain_output(&graph.dependency_chain(issue.number)));
Ok(())
}
pub fn relationship_line(rel: &Relationship) -> String {
format!(
"{} --{}--> {} {} [{}]",
rel.from.human(),
rel.kind,
rel.to.human(),
display_target(rel),
rel.id
)
}
fn display_target(rel: &Relationship) -> String {
let target = rel.to.human();
let prefix = format!("{} {target}", rel.kind.as_str());
match rel.display.strip_prefix(&prefix) {
Some(title) => {
let title = title.trim();
if title.is_empty() {
target
} else {
title.to_string()
}
}
None => rel.display.clone(),
}
}
pub fn chain_output(chain: &DependencyChain) -> String {
let mut out = String::new();
if chain.paths.is_empty() {
out.push_str(&format!("No dependency chain for #{}\n", chain.root));
} else {
for path in &chain.paths {
for (depth, node) in path.iter().enumerate() {
out.push_str(if depth == 0 { "" } else { " -> " });
out.push_str(&node_line(node));
out.push('\n');
}
}
}
if chain.truncated {
out.push_str(&chain_bound_note());
out.push('\n');
}
out
}
fn node_line(node: &crate::relationships::ChainNode) -> String {
let title = node.title.trim();
if title.is_empty() {
format!("#{}", node.number)
} else {
format!("#{} {}", node.number, title)
}
}
pub const SHOW_RELATIONSHIPS: &str = "relationships";
pub fn wants_relationships(show: Option<&str>) -> Result<bool> {
let Some(raw) = show else {
return Ok(false);
};
let mut requested = false;
for token in raw.split(',').map(str::trim).filter(|t| !t.is_empty()) {
if token != SHOW_RELATIONSHIPS {
return Err(TixError::InvalidInput(format!(
"unknown --show value `{token}`; this surface understands `{SHOW_RELATIONSHIPS}`"
)));
}
requested = true;
}
Ok(requested)
}
pub fn relationship_block(graph: &RelationshipGraph, number: u32) -> Vec<String> {
graph
.relationships_for(number)
.into_iter()
.map(relationship_line)
.collect()
}
pub fn blocked_marker(state: &BlockedState) -> Option<String> {
if !state.is_blocked {
return None;
}
Some(format!("blocked: {}", state.reason))
}
pub fn derived_block(graph: &RelationshipGraph, number: u32) -> String {
let mut out = String::new();
let relationships = relationship_block(graph, number);
out.push_str("\n## Relationships\n\n");
if relationships.is_empty() {
out.push_str("- none\n");
} else {
for line in relationships {
out.push_str(&format!("- {line}\n"));
}
}
out.push_str("\n## Dependency chain\n\n");
out.push_str(&chain_output(&graph.dependency_chain(number)));
let state = graph.blocked_state(number);
out.push_str("\n## Blocked\n\n");
match blocked_marker(&state) {
Some(marker) => out.push_str(&format!("- {marker}\n")),
None => out.push_str("- not blocked\n"),
}
out
}
#[cfg(test)]
mod tests {
#[allow(clippy::wildcard_imports)]
use super::*;
use crate::commands::create::create;
use crate::error::TixError;
use crate::storage::IssueSelector;
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).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, title: &str) -> Issue {
create(
&store.config,
store,
title,
Some("body"),
None,
None,
Some(""),
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
None,
)
.unwrap()
}
fn sel(issue: &Issue) -> String {
issue.number.to_string()
}
#[test]
fn t011_a_missing_type_is_rejected_and_the_message_names_the_flag() {
let err = require_kind(None).unwrap_err();
let text = err.to_string();
assert!(
text.contains("--type"),
"the rejection must name the missing flag, got: {text}"
);
for spelling in ["blocks", "blocked_by", "duplicates", "related"] {
assert!(
text.contains(spelling),
"the rejection must list the `{spelling}` spelling, got: {text}"
);
}
assert!(
text.contains("unknown_kind (kind)"),
"the diagnostic kind and its field are authoritative and must survive: {text}"
);
}
#[test]
fn t011_an_empty_type_is_rejected_rather_than_defaulted() {
assert!(require_kind(Some(" ")).is_err());
assert!(require_kind(Some("")).is_err());
}
#[test]
fn t011_an_unknown_kind_is_rejected_with_the_service_text_verbatim() {
let err = parse_kind("nonsense").unwrap_err();
let text = err.to_string();
assert!(
text.contains("unknown_kind (kind)"),
"the service's kind and field must reach the user: {text}"
);
assert!(text.contains("`nonsense`"), "got: {text}");
assert!(parse_kind("blocks-2").is_err());
}
#[test]
fn t011_every_documented_spelling_parses() {
for (raw, expected) in [
("blocks", RelationKind::Blocks),
("blocked_by", RelationKind::BlockedBy),
("duplicates", RelationKind::Duplicates),
("related", RelationKind::Related),
] {
assert_eq!(parse_kind(raw).unwrap(), expected, "parsing {raw}");
}
}
#[test]
fn t011_a_link_without_type_creates_nothing() {
let (tmp, store) = make_store();
let _ = tmp;
let a = seed(&store, "A");
let b = seed(&store, "B");
let before = snapshot(&store);
let err = run(&store, None, Some(&sel(&a)), Some(&sel(&b)), None).unwrap_err();
assert!(matches!(err, TixError::InvalidInput(_)), "got {err:?}");
assert_eq!(snapshot(&store), before, "no file may change");
}
#[test]
fn t011_a_typed_link_records_the_canonical_edge() {
let (tmp, store) = make_store();
let _ = tmp;
let a = seed(&store, "A");
let b = seed(&store, "B");
run(&store, None, Some(&sel(&a)), Some(&sel(&b)), Some("blocks")).unwrap();
let owner = store.get(IssueSelector::Number(a.number)).unwrap();
assert_eq!(owner.relationships.len(), 1, "one edge, stored once");
let edge = &owner.relationships[0];
assert_eq!(edge.kind, RelationKind::Blocks);
assert_eq!(edge.from, crate::id::canonical_ref(&a));
assert_eq!(edge.to, crate::id::canonical_ref(&b));
assert!(!edge.id.is_empty(), "a stored relationship is addressable");
}
#[test]
fn t011_blocked_by_is_an_input_spelling_that_stores_the_blocks_edge() {
let (tmp, store) = make_store();
let _ = tmp;
let a = seed(&store, "A");
let b = seed(&store, "B");
run(
&store,
None,
Some(&sel(&a)),
Some(&sel(&b)),
Some("blocked_by"),
)
.unwrap();
let owner = store.get(IssueSelector::Number(b.number)).unwrap();
assert_eq!(owner.relationships.len(), 1, "stored once, on the blocker");
let edge = &owner.relationships[0];
assert_eq!(
edge.kind,
RelationKind::Blocks,
"`blocked_by` is never persisted; it normalizes into `blocks`"
);
assert_eq!(edge.from, crate::id::canonical_ref(&b));
assert_eq!(edge.to, crate::id::canonical_ref(&a));
}
#[test]
fn t011_a_self_link_is_rejected_and_writes_nothing() {
let (tmp, store) = make_store();
let _ = tmp;
let a = seed(&store, "A");
let before = snapshot(&store);
let err = run(&store, None, Some(&sel(&a)), Some(&sel(&a)), Some("blocks")).unwrap_err();
assert!(err.to_string().contains("self"), "got: {err}");
assert_eq!(snapshot(&store), before, "no file may change");
}
#[test]
fn t011_an_incomplete_invocation_names_the_missing_endpoint() {
let (tmp, store) = make_store();
let _ = tmp;
let a = seed(&store, "A");
let err = run(&store, None, Some(&sel(&a)), None, Some("blocks")).unwrap_err();
assert!(err.to_string().contains("to is required"), "got: {err}");
let err = run(&store, None, None, Some(&sel(&a)), Some("blocks")).unwrap_err();
assert!(err.to_string().contains("from is required"), "got: {err}");
}
#[test]
fn t012_an_edit_preserves_the_stable_id() {
let (tmp, store) = make_store();
let _ = tmp;
let a = seed(&store, "A");
let b = seed(&store, "B");
let c = seed(&store, "C");
let created = store
.link(&sel(&a), &sel(&b), RelationKind::Blocks)
.unwrap();
let id = created.relationship.id.clone();
edit(&store, &id, Some(&sel(&c)), None).unwrap();
let owner = store.get(IssueSelector::Number(a.number)).unwrap();
assert_eq!(owner.relationships.len(), 1);
let edge = &owner.relationships[0];
assert_eq!(edge.id, id, "identity survives the retarget");
assert_eq!(
edge.to,
crate::id::canonical_ref(&c),
"the retarget took effect"
);
assert_eq!(
edge.kind,
RelationKind::Blocks,
"the kind was not disturbed"
);
}
#[test]
fn t012_an_edit_records_the_retargeted_history_event() {
let (tmp, store) = make_store();
let _ = tmp;
let a = seed(&store, "A");
let b = seed(&store, "B");
let c = seed(&store, "C");
let created = store
.link(&sel(&a), &sel(&b), RelationKind::Blocks)
.unwrap();
edit(&store, &created.relationship.id, Some(&sel(&c)), None).unwrap();
let owner = store.get(IssueSelector::Number(a.number)).unwrap();
let kinds: Vec<String> = owner
.history
.events()
.iter()
.map(|e| e.kind.as_str().to_string())
.collect();
assert!(
kinds.contains(&"relationship_retargeted".to_string()),
"an edit must leave a `relationship_retargeted` event, got {kinds:?}"
);
assert!(
!kinds.contains(&"relationship_removed".to_string()),
"one edit is one mutation, not a remove plus a create: {kinds:?}"
);
}
#[test]
fn t012_a_rejected_edit_writes_nothing() {
let (tmp, store) = make_store();
let _ = tmp;
let a = seed(&store, "A");
let b = seed(&store, "B");
let created = store
.link(&sel(&a), &sel(&b), RelationKind::Blocks)
.unwrap();
let before = snapshot(&store);
let err = edit(&store, &created.relationship.id, Some(&sel(&a)), None).unwrap_err();
assert!(err.to_string().contains("self"), "got: {err}");
assert_eq!(snapshot(&store), before, "no file may change (SC-005)");
}
#[test]
fn t012_an_edit_with_nothing_to_change_is_refused() {
let (tmp, store) = make_store();
let _ = tmp;
let a = seed(&store, "A");
let b = seed(&store, "B");
let created = store
.link(&sel(&a), &sel(&b), RelationKind::Blocks)
.unwrap();
let err = edit(&store, &created.relationship.id, None, None).unwrap_err();
assert!(err.to_string().contains("nothing to change"), "got: {err}");
}
#[test]
fn t012_removing_an_absent_relationship_is_a_no_op_with_no_history_entry() {
let (tmp, store) = make_store();
let _ = tmp;
seed(&store, "A");
let before = snapshot(&store);
assert!(!before.is_empty(), "precondition: the board has an issue");
remove(&store, "rel_does_not_exist").unwrap();
assert_eq!(
snapshot(&store),
before,
"an absent id must not write a file, and must not append History"
);
}
#[test]
fn t012_repeated_remove_is_idempotent() {
let (tmp, store) = make_store();
let _ = tmp;
let a = seed(&store, "A");
let b = seed(&store, "B");
let created = store
.link(&sel(&a), &sel(&b), RelationKind::Blocks)
.unwrap();
let id = created.relationship.id.clone();
let first = store.remove_relation(&id).unwrap();
assert!(first.changed, "the first remove is a real mutation");
let after_first = snapshot(&store);
let second = store.remove_relation(&id).unwrap();
assert!(!second.changed, "the second remove is a no-op");
assert_eq!(snapshot(&store), after_first, "no second write");
}
#[test]
fn t012_an_unknown_id_is_a_named_diagnostic() {
let (tmp, store) = make_store();
let _ = tmp;
let err = store.remove_relation("rel_nope").map(|_| ());
assert!(err.is_ok());
let err = store
.edit_relation("rel_nope", None, Some(RelationKind::Related))
.unwrap_err();
assert!(err.to_string().contains("unknown_relation"), "got: {err}");
assert!(err.to_string().contains("rel_nope"), "got: {err}");
}
#[test]
fn t012_listing_is_ordered_by_kind_then_target_and_carries_the_id() {
let (tmp, store) = make_store();
let _ = tmp;
let a = seed(&store, "A");
let b = seed(&store, "B");
let c = seed(&store, "C");
let d = seed(&store, "D");
store
.link(&sel(&a), &sel(&c), RelationKind::Related)
.unwrap();
store
.link(&sel(&a), &sel(&b), RelationKind::Blocks)
.unwrap();
store
.link(&sel(&a), &sel(&d), RelationKind::Duplicates)
.unwrap();
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 lines: Vec<String> = graph
.relationships_for(a.number)
.into_iter()
.map(relationship_line)
.collect();
assert_eq!(lines.len(), 3);
let kinds: Vec<&str> = lines
.iter()
.map(|l| l.split("--").nth(1).unwrap())
.collect();
assert_eq!(kinds, vec!["blocks", "duplicates", "related"], "{lines:?}");
assert!(
lines.iter().all(|l| l.contains("[rel_")),
"every line carries the id `tix link edit`/`remove` accept: {lines:?}"
);
}
#[test]
fn t012_kind_filtering_is_exact_and_blocked_by_filters_the_canonical_edge() {
let (tmp, store) = make_store();
let _ = tmp;
let a = seed(&store, "A");
let b = seed(&store, "B");
store
.link(&sel(&a), &sel(&b), RelationKind::BlockedBy)
.unwrap();
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 all = graph.relationships_for(a.number);
assert_eq!(all.len(), 1);
assert_eq!(
all[0].kind,
RelationKind::Blocks,
"precondition: nothing persists as `blocked_by`"
);
for spelling in ["blocks", "blocked_by"] {
let wanted = canonical_filter(parse_kind(spelling).unwrap());
let matched: Vec<_> = all.iter().copied().filter(|r| r.kind == wanted).collect();
assert_eq!(
matched.len(),
1,
"`--kind {spelling}` must find the canonical blocking edge, not nothing"
);
}
for spelling in ["duplicates", "related"] {
let wanted = canonical_filter(parse_kind(spelling).unwrap());
let matched: Vec<_> = all.iter().copied().filter(|r| r.kind == wanted).collect();
assert!(
matched.is_empty(),
"`--kind {spelling}` must not match a block"
);
}
}
#[test]
fn t012_unlink_without_type_is_rejected_and_names_the_flag() {
let (tmp, store) = make_store();
let _ = tmp;
let a = seed(&store, "A");
let b = seed(&store, "B");
let before = snapshot(&store);
let err = unlink_cli(&store, &sel(&a), &sel(&b), None).unwrap_err();
assert!(err.to_string().contains("--type"), "got: {err}");
assert_eq!(snapshot(&store), before, "a rejected remove writes nothing");
}
#[test]
fn t012_unlink_with_type_removes_the_edge() {
let (tmp, store) = make_store();
let _ = tmp;
let a = seed(&store, "A");
let b = seed(&store, "B");
store
.link(&sel(&a), &sel(&b), RelationKind::Blocks)
.unwrap();
unlink_cli(&store, &sel(&a), &sel(&b), Some("blocks")).unwrap();
let owner = store.get(IssueSelector::Number(a.number)).unwrap();
assert!(
owner.relationships.is_empty(),
"the fully-qualified spelling removes the same edge"
);
}
#[test]
fn t013_a_three_node_chain_prints_in_service_order() {
let (tmp, store) = make_store();
let _ = tmp;
let a = seed(&store, "A");
let b = seed(&store, "B");
let c = seed(&store, "C");
store
.link(&sel(&a), &sel(&b), RelationKind::Blocks)
.unwrap();
store
.link(&sel(&b), &sel(&c), RelationKind::Blocks)
.unwrap();
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 out = chain_output(&graph.dependency_chain(a.number));
let root = out.find("#").unwrap();
let b_at = out.find(&format!("#{} ", b.number)).unwrap();
let c_at = out.find(&format!("#{} ", c.number)).unwrap();
assert!(root < b_at && b_at < c_at, "order must be A, B, C:\n{out}");
assert!(out.contains("B"), "display names appear:\n{out}");
assert!(out.contains("C"), "display names appear:\n{out}");
assert!(
!out.contains("truncated"),
"a three-node chain is not truncated:\n{out}"
);
}
#[test]
fn t013_an_issue_with_no_outbound_blocks_says_so() {
let (tmp, store) = make_store();
let _ = tmp;
let a = seed(&store, "A");
let b = seed(&store, "B");
store
.link(&sel(&b), &sel(&a), RelationKind::Blocks)
.unwrap();
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 out = chain_output(&graph.dependency_chain(a.number));
assert!(
out.contains("No dependency chain"),
"the empty case is named, not printed as an empty blob:\n{out}"
);
}
#[test]
fn t013_the_truncated_flag_produces_an_explicit_note() {
let chain = DependencyChain {
root: 1,
paths: vec![],
truncated: true,
};
let out = chain_output(&chain);
assert!(out.contains("truncated"), "{out}");
assert!(
out.contains("may continue"),
"the note must say the chain continues, not that it ends: {out}"
);
}
#[test]
fn t013_a_cyclic_hand_edited_file_terminates_and_reports_the_cycle() {
let (tmp, store) = make_store();
let _ = tmp;
let a = seed(&store, "A");
let b = seed(&store, "B");
let mut owner = store.get(IssueSelector::Number(a.number)).unwrap();
let root = a.number;
owner
.relationships
.push(crate::relationships::Relationship::new(
a.number,
b.number,
RelationKind::Blocks,
"B",
));
store.update(&owner.filename, &owner).unwrap();
let mut owner = store.get(IssueSelector::Number(b.number)).unwrap();
owner
.relationships
.push(crate::relationships::Relationship::new(
b.number,
a.number,
RelationKind::Blocks,
"A",
));
store.update(&owner.filename, &owner).unwrap();
let err = store
.link(&sel(&a), &sel(&b), RelationKind::Blocks)
.unwrap_err();
assert!(err.to_string().contains("cycle"), "got: {err}");
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 out = chain_output(&graph.dependency_chain(root));
assert_ne!(out, "");
}
#[test]
fn t013_an_unresolvable_selector_is_a_not_found() {
let (tmp, store) = make_store();
let _ = tmp;
let err = chain(&store, "999").unwrap_err();
assert!(matches!(err, TixError::NotFound(_)), "got {err:?}");
}
#[test]
fn show_absent_or_empty_requests_nothing() {
assert!(!wants_relationships(None).unwrap());
assert!(!wants_relationships(Some("")).unwrap());
assert!(!wants_relationships(Some(" ")).unwrap());
assert!(!wants_relationships(Some(",,")).unwrap());
}
#[test]
fn show_accepts_the_documented_token_in_any_spacing() {
assert!(wants_relationships(Some("relationships")).unwrap());
assert!(wants_relationships(Some(" relationships ")).unwrap());
assert!(wants_relationships(Some("relationships,relationships")).unwrap());
}
#[test]
fn an_unknown_show_value_is_rejected_rather_than_ignored() {
let err = wants_relationships(Some("relatinships")).unwrap_err();
let text = err.to_string();
assert!(
text.contains("relatinships"),
"names what was typed: {text}"
);
assert!(
text.contains("relationships"),
"names what is valid: {text}"
);
assert!(wants_relationships(Some("relationships,blocks")).is_err());
}
#[test]
fn t014_an_inbound_open_blocks_edge_marks_the_issue_blocked_with_a_reason() {
let (tmp, store) = make_store();
let _ = tmp;
let blocker = seed(&store, "Blocker");
let subject = seed(&store, "Subject");
store
.link(&sel(&blocker), &sel(&subject), RelationKind::Blocks)
.unwrap();
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 marker = blocked_marker(&graph.blocked_state(subject.number)).expect("blocked");
assert!(marker.contains("blocked:"), "{marker}");
assert!(
marker.contains(&blocker.number.to_string()),
"the reason names the blocker: {marker}"
);
}
#[test]
fn t014_related_only_is_not_blocked() {
let (tmp, store) = make_store();
let _ = tmp;
let other = seed(&store, "Other");
let subject = seed(&store, "Subject");
store
.link(&sel(&other), &sel(&subject), RelationKind::Related)
.unwrap();
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);
assert_eq!(
graph.relationships_for(subject.number).len(),
1,
"precondition: the edge exists and is visible"
);
assert!(
blocked_marker(&graph.blocked_state(subject.number)).is_none(),
"an issue that is only `related` must not render as blocked"
);
}
#[test]
fn t014_a_blocker_in_the_configured_done_column_is_not_blocked() {
let (tmp, store) = make_store();
let _ = tmp;
let blocker = seed(&store, "Blocker");
let subject = seed(&store, "Subject");
store
.link(&sel(&blocker), &sel(&subject), RelationKind::Blocks)
.unwrap();
store
.set_status(
&store
.get(IssueSelector::Number(blocker.number))
.unwrap()
.filename,
crate::storage::StatusChange::to("done"),
)
.unwrap();
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);
assert!(
blocked_marker(&graph.blocked_state(subject.number)).is_none(),
"a finished blocker does not block"
);
}
#[test]
fn t014_the_derived_block_names_every_section() {
let (tmp, store) = make_store();
let _ = tmp;
let blocker = seed(&store, "Blocker");
let subject = seed(&store, "Subject");
store
.link(&sel(&blocker), &sel(&subject), RelationKind::Blocks)
.unwrap();
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 out = derived_block(&graph, subject.number);
assert!(out.contains("## Relationships"), "{out}");
assert!(out.contains("## Dependency chain"), "{out}");
assert!(out.contains("## Blocked"), "{out}");
assert!(
out.contains("not blocked") || out.contains("blocked:"),
"{out}"
);
}
fn snapshot(store: &Store) -> std::collections::BTreeMap<String, String> {
let mut out = std::collections::BTreeMap::new();
for issue in store.list().unwrap() {
out.insert(
issue.filename.clone(),
fs::read_to_string(store.issues_dir.join(&issue.filename)).unwrap(),
);
}
out
}
}