#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedTaskState {
pub state: String,
pub visit: Option<u32>,
}
pub fn parse_task_state(raw: &str, machine: &StateMachine) -> ParsedTaskState {
if machine.is_valid_state(raw) {
return ParsedTaskState { state: raw.to_string(), visit: None };
}
if let Some((base, visit_text)) = raw.rsplit_once('-') {
if let Ok(visit) = visit_text.parse::<u32>() {
if machine.is_valid_state(base) {
return ParsedTaskState { state: base.to_string(), visit: Some(visit) };
}
}
}
ParsedTaskState { state: raw.to_string(), visit: None }
}
#[derive(Debug, Clone)]
pub struct MachineSet {
pub default: StateMachine,
pub per_rhei: BTreeMap<String, StateMachine>,
}
impl MachineSet {
pub fn single(machine: StateMachine) -> Self {
Self { default: machine, per_rhei: BTreeMap::new() }
}
pub fn for_task(&self, id: &TaskId) -> &StateMachine {
if let Some(TaskIdSegment::Named(rhei)) = id.segments.first() {
if let Some(machine) = self.per_rhei.get(rhei) {
return machine;
}
}
&self.default
}
pub fn for_task_str(&self, task_id: &str) -> &StateMachine {
let rhei_id = task_id.split('.').next().unwrap_or(task_id);
self.per_rhei.get(rhei_id).unwrap_or(&self.default)
}
pub fn distinct(&self) -> Vec<&StateMachine> {
let mut seen: HashSet<String> = HashSet::new();
let mut out = vec![&self.default];
seen.insert(self.default.fingerprint());
for machine in self.per_rhei.values() {
if seen.insert(machine.fingerprint()) {
out.push(machine);
}
}
out
}
pub fn is_single(&self) -> bool {
self.distinct().len() == 1
}
}
pub struct Validator {
machines: MachineSet,
}
impl Validator {
pub fn new(machine: StateMachine) -> Self {
Self { machines: MachineSet::single(machine) }
}
pub fn with_machines(machines: MachineSet) -> Self {
Self { machines }
}
pub fn validate(&self, rhei: &Rhei) -> ValidationReport {
self.validate_with_base(rhei, None)
}
pub fn validate_with_base(&self, rhei: &Rhei, base_path: Option<&Path>) -> ValidationReport {
let mut report = ValidationReport::ok();
let index = build_task_index(rhei);
for machine in self.machines.distinct() {
validate_node_policy_against_structure(machine, &rhei.structure, &mut report);
validate_state_machine_warnings(machine, &mut report);
}
validate_sibling_uniqueness(rhei, &mut report);
validate_dependency_integrity(rhei, &index, &mut report);
validate_prior_order_coherence(rhei, &index, &self.machines, &mut report);
validate_state_consistency(rhei, &self.machines, &mut report);
validate_task_execution_overrides(rhei, &self.machines, &mut report);
validate_terminal_tree_coherence(rhei, &self.machines, &mut report);
validate_circular_dependencies(rhei, &index, &mut report);
validate_assignee_nonempty(rhei, &mut report);
validate_result_blocks(rhei, &self.machines, &mut report);
if let Some(base) = base_path {
validate_markdown_links(rhei, base, &mut report);
}
report
}
}
pub fn validate_with_machine(rhei: &Rhei, machine: &StateMachine) -> ValidationReport {
Validator::new(machine.clone()).validate(rhei)
}
pub fn validate_with_machine_set(rhei: &Rhei, machines: &MachineSet) -> ValidationReport {
Validator::with_machines(machines.clone()).validate(rhei)
}
pub fn validate_with_machine_set_and_link_bases(
rhei: &Rhei,
machines: &MachineSet,
default_base: &Path,
task_bases: &HashMap<String, PathBuf>,
section_bases: &[PathBuf],
) -> ValidationReport {
let mut report = Validator::with_machines(machines.clone()).validate_with_base(rhei, None);
validate_markdown_links_with_task_bases(rhei, default_base, task_bases, section_bases, &mut report);
report
}
pub fn validate_with_machine_and_base(
rhei: &Rhei,
machine: &StateMachine,
base_path: &Path,
) -> ValidationReport {
Validator::new(machine.clone()).validate_with_base(rhei, Some(base_path))
}
pub fn validate_with_machine_and_link_bases(
rhei: &Rhei,
machine: &StateMachine,
default_base: &Path,
task_bases: &HashMap<String, PathBuf>,
section_bases: &[PathBuf],
) -> ValidationReport {
let mut report = Validator::new(machine.clone()).validate_with_base(rhei, None);
validate_markdown_links_with_task_bases(rhei, default_base, task_bases, section_bases, &mut report);
report
}
pub fn validate_from_machine_file<P: AsRef<Path>>(
rhei: &Rhei,
machine_path: P,
) -> Result<ValidationReport, StateMachineLoadError> {
let machine = StateMachine::from_yaml_file(machine_path)?;
Ok(Validator::new(machine).validate(rhei))
}
fn validate_state_machine_warnings(machine: &StateMachine, report: &mut ValidationReport) {
for (state_name, state) in &machine.states {
if state.gating && state.agent.is_some() {
report.warnings.push(format!(
"state '{state_name}' declares 'agent' on a gating state; gating states are human-only, so rhei run will not invoke this agent"
));
}
}
}
fn build_task_index(rhei: &Rhei) -> HashMap<TaskId, &Task> {
fn visit<'a>(task: &'a Task, map: &mut HashMap<TaskId, &'a Task>) {
map.insert(task.id.clone(), task);
for child in &task.children {
visit(child, map);
}
}
let mut map = HashMap::new();
for t in &rhei.tasks {
visit(t, &mut map);
}
map
}
fn validate_node_policy_against_structure(
machine: &StateMachine,
structure: &Structure,
report: &mut ValidationReport,
) {
let Some(policy) = machine.node_policy.as_ref() else {
return;
};
for kind in policy.by_type.keys() {
if !structure.accepts_kind(kind) {
report.errors.push(format!(
"node_policy.by_type references node kind '{}' but the plan structure declares nodeKinds {:?}",
kind, structure.node_kinds
));
}
}
for (idx, ov) in policy.overrides.iter().enumerate() {
if let Some(node_type) = ov.match_.node_type.as_deref() {
if !structure.accepts_kind(node_type) {
report.errors.push(format!(
"node_policy.overrides[{idx}].match.type references node kind '{}' but the plan structure declares nodeKinds {:?}",
node_type, structure.node_kinds
));
}
}
if let Some(level) = ov.match_.level {
if level == 0 || level > structure.max_levels {
report.errors.push(format!(
"node_policy.overrides[{idx}].match.level is {}, but levels must be in 1..={} for this plan structure",
level, structure.max_levels
));
}
}
}
}
fn for_each_node<'a>(rhei: &'a Rhei, mut f: impl FnMut(&'a Task)) {
fn recurse<'a>(task: &'a Task, f: &mut impl FnMut(&'a Task)) {
f(task);
for child in &task.children {
recurse(child, f);
}
}
for t in &rhei.tasks {
recurse(t, &mut f);
}
}
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 {
format!(
" ('{kind}' is not a declared node kind; declared: {:?})",
structure.node_kinds
)
};
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.errors.push(format!(
"Task {} has an unresolvable **Prior:** reference: '{kind}' is not a \
declared node kind (declared: {:?}) and no Task {} exists. If the \
reference is a task title, use the task's id instead (`**Prior:** 1`, \
`**Prior:** auth.2`)",
task.id, structure.node_kinds, dep
));
}
(None, _) => {
report.errors.push(format!(
"Task {} depends on missing Task {}{}",
task.id,
dep,
missing_prior_hint(&task.id, dep, index, rhei_ids)
));
}
_ => {}
}
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 {
let Some(TaskIdSegment::Named(candidate)) = dep.segments.first() else {
return String::new();
};
if rhei_ids.iter().any(|id| id == candidate) {
return String::new();
}
let citing_rhei = match task.segments.first() {
Some(TaskIdSegment::Named(name)) => name.as_str(),
_ => return String::new(),
};
let mut hint = format!(
": no rhei named '{candidate}' in this project (rheis: {}), \
and rhei '{citing_rhei}' has no ticket '{dep}'",
rhei_ids.join(", ")
);
if let Some(corrected) = nearest_resolving_id(task, dep, candidate, index, rhei_ids) {
hint.push_str(&format!(". Did you mean '{corrected}'?"));
}
hint
}
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;
state != "cancelled"
&& 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 state == "cancelled"
|| !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(", ")
));
}
});
}
fn validate_state_consistency(rhei: &Rhei, machines: &MachineSet, report: &mut ValidationReport) {
for_each_node(rhei, |task| {
let machine = machines.for_task(&task.id);
let kind_label = title_case_kind(&task.kind);
let subject = format!("{} {}", kind_label, task.id);
validate_task_state_instance(&subject, &task.state, machine, report);
validate_task_state_against_profile(
&subject,
&task.state,
task.kind.as_str(),
task.profile_level(),
machine,
report,
);
});
}
fn validate_task_execution_overrides(
rhei: &Rhei,
machines: &MachineSet,
report: &mut ValidationReport,
) {
for_each_node(rhei, |task| {
let machine = machines.for_task(&task.id);
let declared_models: HashSet<&str> = machine.models.iter().map(String::as_str).collect();
let subject = format!("{} {}", title_case_kind(&task.kind), task.id);
let has_model = task.model.is_some();
let has_target = task.target.is_some();
if has_model && has_target {
report.errors.push(format!(
"{} declares both **Model:** and **Target:**; task execution overrides are mutually exclusive",
subject
));
}
if let Some(model) = task.model.as_deref() {
let trimmed = model.trim();
if trimmed.is_empty() {
report.errors.push(format!("{} declares an empty **Model:** override", subject));
} else if !declared_models.contains(trimmed) {
report.errors.push(format!(
"{} declares **Model:** '{}' but the active state machine does not declare that model",
subject, trimmed
));
}
}
if let Some(target) = task.target.as_deref() {
let trimmed = target.trim();
if trimmed.is_empty() {
report.errors.push(format!("{} declares an empty **Target:** override", subject));
} else if let Err(err) = parse_execution_target(trimmed) {
report.errors.push(format!(
"{} declares invalid **Target:** '{}': {}",
subject, trimmed, err
));
}
}
if !has_model && !has_target {
return;
}
let parsed = parse_task_state(&task.state, machine);
let Some(state_def) = machine.states.get(&parsed.state) else {
return;
};
if !state_def.all_targets.is_empty() || !state_def.all_models.is_empty() {
report.errors.push(format!(
"{} declares a task execution override but state '{}' is a fanout state",
subject, parsed.state
));
}
if state_def.target_locked {
report.errors.push(format!(
"{} declares a task execution override but state '{}' has target_locked: true",
subject, parsed.state
));
}
});
}
fn title_case_kind(kind: &str) -> String {
let mut out = String::with_capacity(kind.len());
let mut chars = kind.chars();
if let Some(first) = chars.next() {
for c in first.to_uppercase() {
out.push(c);
}
}
for c in chars {
out.push(c);
}
out
}
fn validate_task_state_against_profile(
subject: &str,
raw_state: &str,
kind: &str,
level: u8,
machine: &StateMachine,
report: &mut ValidationReport,
) {
let Some(profile) = machine.profile_for_node(kind, level) else {
return;
};
let parsed = parse_task_state(raw_state, machine);
if !machine.is_valid_state(&parsed.state) {
return;
}
if !profile.allowed.iter().any(|s| s == &parsed.state) {
let allowed = profile.allowed.join(", ");
report.errors.push(format!(
"{} has state '{}' which is not allowed by its resolved profile. Profile allows: [{}]",
subject, parsed.state, allowed
));
}
}
fn validate_task_state_instance(
subject: &str,
raw_state: &str,
machine: &StateMachine,
report: &mut ValidationReport,
) {
let parsed = parse_task_state(raw_state, machine);
if !machine.is_valid_state(&parsed.state) {
let allowed = machine.allowed_states().collect::<Vec<_>>().join(", ");
report
.errors
.push(format!("{} has invalid state '{}'. Allowed: [{}]", subject, raw_state, allowed));
return;
}
let Some(visit) = parsed.visit else {
return;
};
if visit <= 1 {
report.errors.push(format!(
"{} has invalid counted state '{}'. Visit suffix '-1' is not allowed; omit the suffix for the first visit.",
subject, raw_state
));
return;
}
let state_def = &machine.states[&parsed.state];
let Some(limit) = state_def.visits else {
report.errors.push(format!(
"{} has invalid counted state '{}'. State '{}' does not declare 'visits'.",
subject, raw_state, parsed.state
));
return;
};
if visit > limit {
report.errors.push(format!(
"{} has invalid counted state '{}'. Visit {} exceeds the declared limit {} for state '{}'.",
subject, raw_state, visit, limit, parsed.state
));
}
}
fn validate_assignee_nonempty(rhei: &Rhei, report: &mut ValidationReport) {
for_each_node(rhei, |task| {
if let Some(assignee) = &task.assignee {
if assignee.trim().is_empty() {
report.warnings.push(format!("Task {} has an empty **Assignee:** value", task.id));
}
}
});
}
fn validate_sibling_uniqueness(rhei: &Rhei, report: &mut ValidationReport) {
fn recurse(parent: Option<&Task>, siblings: &[Task], report: &mut ValidationReport) {
let mut seen: HashSet<TaskId> = HashSet::new();
for task in siblings {
if let Some(p) = parent {
if !task.id.extends(&p.id) {
report.errors.push(format!(
"Task {} must extend parent Task {} by exactly one segment",
task.id, p.id
));
}
}
if !seen.insert(task.id.clone()) {
report.errors.push(format!(
"Duplicate sibling task id: Task {}{}",
task.id,
parent.map(|p| format!(" under Task {}", p.id)).unwrap_or_default()
));
}
recurse(Some(task), &task.children, report);
}
}
recurse(None, &rhei.tasks, report);
}
fn validate_terminal_tree_coherence(
rhei: &Rhei,
machines: &MachineSet,
report: &mut ValidationReport,
) {
fn is_terminal(state_raw: &str, machine: &StateMachine) -> bool {
let parsed = parse_task_state(state_raw, machine);
machine.states.get(&parsed.state).map(|d| d.terminal).unwrap_or(false)
}
fn check_descendants(
ancestor: &Task,
node: &Task,
machine: &StateMachine,
report: &mut ValidationReport,
) {
for child in &node.children {
if !is_terminal(&child.state, machine) {
report.errors.push(format!(
"Task {} is in terminal state '{}' but descendant Task {} ('{}') is in non-terminal state '{}'",
ancestor.id, ancestor.state, child.id, child.title, child.state
));
}
check_descendants(ancestor, child, machine, report);
}
}
for_each_node(rhei, |task| {
let machine = machines.for_task(&task.id);
if is_terminal(&task.state, machine) {
check_descendants(task, task, machine, report);
}
});
}