fn validate_dependency_integrity(
rhei: &Rhei,
index: &HashMap<TaskId, &Task>,
report: &mut ValidationReport,
) {
let rhei_ids = project_rhei_ids(rhei);
fn recurse(
task: &Task,
ancestors: &mut Vec<TaskId>,
index: &HashMap<TaskId, &Task>,
rhei_ids: &[String],
structure: &Structure,
report: &mut ValidationReport,
) {
let mut seen: HashSet<&TaskId> = HashSet::new();
for (position, dep) in task.prior.iter().enumerate() {
let kind = task.prior_kinds.get(position).and_then(|k| k.as_deref());
if !seen.insert(dep) {
report.errors.push(format!(
"Task {} lists Task {} more than once in **Prior:**; drop the duplicate",
task.id, dep
));
}
match (index.get(dep), kind) {
(Some(target), Some(kind)) if !target.kind.eq_ignore_ascii_case(kind) => {
let flavor = if structure.accepts_kind(kind) {
String::new()
} else {
report.help.push(declared_node_kinds_help(structure));
format!(" ('{kind}' is not a declared node kind)")
};
report.errors.push(format!(
"Task {} **Prior:** kind keyword '{kind}' does not match Task {}: \
that node is declared '{}'{flavor}. Use the node's kind or the bare id",
task.id,
dep,
title_case_kind(&target.kind),
));
}
(None, Some(kind)) if !structure.accepts_kind(kind) => {
report.help.push(declared_node_kinds_help(structure));
report.errors.push(format!(
"Task {} has an unresolvable **Prior:** reference: '{kind}' is not a \
declared node kind and no Task {} exists. If the reference is a task \
title, use the task's id instead (`**Prior:** 1`, `**Prior:** auth.2`)",
task.id, dep
));
}
(None, _) => {
let (tail, guidance) = missing_prior_hint(&task.id, dep, index, rhei_ids);
if let Some(guidance) = guidance {
report.help.push(guidance);
}
report
.errors
.push(format!("Task {} depends on missing Task {}{tail}", task.id, dep));
}
_ => {}
}
if ancestors.iter().any(|ancestor| ancestor == dep) {
report.errors.push(format!(
"Task {} cannot list ancestor Task {} as **Prior:**; parent/child structure already defines containment. Make the dependent work a top-level sibling if it must wait for Task {}.",
task.id, dep, dep
));
}
}
ancestors.push(task.id.clone());
for child in &task.children {
recurse(child, ancestors, index, rhei_ids, structure, report);
}
ancestors.pop();
}
let mut ancestors = Vec::new();
for task in &rhei.tasks {
recurse(task, &mut ancestors, index, &rhei_ids, &rhei.structure, report);
}
}
fn project_rhei_ids(rhei: &Rhei) -> Vec<String> {
let mut ids: Vec<String> = rhei
.tasks
.iter()
.filter_map(|task| match task.id.segments.first() {
Some(TaskIdSegment::Named(name)) => Some(name.clone()),
_ => None,
})
.collect();
ids.sort();
ids.dedup();
ids
}
fn missing_prior_hint(
task: &TaskId,
dep: &TaskId,
index: &HashMap<TaskId, &Task>,
rhei_ids: &[String],
) -> (String, Option<String>) {
let Some(TaskIdSegment::Named(candidate)) = dep.segments.first() else {
return (String::new(), None);
};
if rhei_ids.iter().any(|id| id == candidate) {
return (String::new(), None);
}
let citing_rhei = match task.segments.first() {
Some(TaskIdSegment::Named(name)) => name.as_str(),
_ => return (String::new(), None),
};
let tail = format!(
": no rhei named '{candidate}' in this project, \
and rhei '{citing_rhei}' has no ticket '{dep}'"
);
let mut guidance =
format!("Task {task} **Prior:** '{dep}': this project's rheis are {}.", rhei_ids.join(", "));
if let Some(corrected) = nearest_resolving_id(task, dep, candidate, index, rhei_ids) {
guidance.push_str(&format!(" Did you mean '{corrected}'?"));
}
(tail, Some(guidance))
}
fn declared_node_kinds_help(structure: &Structure) -> String {
format!("this plan structure declares nodeKinds {:?}.", structure.node_kinds)
}
fn nearest_resolving_id(
task: &TaskId,
dep: &TaskId,
candidate: &str,
index: &HashMap<TaskId, &Task>,
rhei_ids: &[String],
) -> Option<TaskId> {
let nearest = nearest_rhei_id(candidate, rhei_ids)?;
let mut segments = dep.segments.clone();
segments[0] = TaskIdSegment::Named(nearest.to_string());
let corrected = TaskId::from_segments(segments);
(corrected != *task && index.contains_key(&corrected)).then_some(corrected)
}
fn nearest_rhei_id<'a>(candidate: &str, rhei_ids: &'a [String]) -> Option<&'a str> {
let length = candidate.chars().count();
if length < 3 {
return None;
}
let budget = 2.min(length.div_ceil(3)).max(1);
rhei_ids
.iter()
.map(|id| (edit_distance(candidate, id), id.as_str()))
.filter(|(distance, _)| *distance <= budget)
.min_by_key(|(distance, _)| *distance)
.map(|(_, id)| id)
}
fn edit_distance(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
let mut previous: Vec<usize> = (0..=b.len()).collect();
let mut current = vec![0usize; b.len() + 1];
for (i, a_char) in a.iter().enumerate() {
current[0] = i + 1;
for (j, b_char) in b.iter().enumerate() {
let substitution = previous[j] + usize::from(a_char != b_char);
current[j + 1] = substitution.min(previous[j + 1] + 1).min(current[j] + 1);
}
std::mem::swap(&mut previous, &mut current);
}
previous[b.len()]
}
fn validate_prior_order_coherence(
rhei: &Rhei,
index: &HashMap<TaskId, &Task>,
machines: &MachineSet,
report: &mut ValidationReport,
) {
let satisfied = |id: &TaskId| -> bool {
index
.get(id)
.map(|dep| {
let machine = machines.for_task(id);
let state = parse_task_state(dep.state.as_str(), machine).state;
!is_cancelled_state_name(&state)
&& machine.states.get(&state).map(|def| def.terminal).unwrap_or(false)
})
.unwrap_or(false)
};
for_each_node(rhei, |task| {
let machine = machines.for_task(&task.id);
let state = parse_task_state(task.state.as_str(), machine).state;
if is_cancelled_state_name(&state)
|| !machine.states.get(&state).map(|def| def.terminal).unwrap_or(false)
{
return;
}
let unmet: Vec<String> = task
.prior
.iter()
.filter(|dep| index.contains_key(*dep) && !satisfied(dep))
.map(|dep| {
format!(
"Task {} ({})",
dep,
parse_task_state(index[dep].state.as_str(), machines.for_task(dep)).state
)
})
.collect();
if !unmet.is_empty() {
report.warnings.push(format!(
"{} {} is '{}' but its prerequisites are unsatisfied: {}. The plan contradicts its own **Prior:** dependencies.",
title_case_kind(&task.kind),
task.id,
state,
unmet.join(", ")
));
}
});
}