fn append_result_entry(
workspace_root: &Path,
task_id: &str,
from: &str,
to: &str,
message: Option<&str>,
) -> MietteResult<()> {
append_state_transition_log_entry(workspace_root, task_id, from, to)?;
let Some(msg) = message else {
return Ok(());
};
let results_dir = workspace_root.join("runtime").join("results");
fs::create_dir_all(&results_dir)
.map_err(|err| miette!(
help = runtime_dir_help(),
"failed to create runtime/results directory: {err}"
))?;
let result_file = results_dir.join(format!("{}.md", task_id));
use std::fs::OpenOptions;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&result_file)
.map_err(|err| miette!(
help = runtime_results_help(),
"failed to open result file: {err}"
))?;
writeln!(file, "## Result")
.map_err(|err| miette!(
help = runtime_results_help(),
"failed to write result entry: {err}"
))?;
writeln!(file).map_err(|err| miette!(
help = runtime_results_help(),
"failed to write result entry: {err}"
))?;
writeln!(file, "{}", msg).map_err(|err| miette!(
help = runtime_results_help(),
"failed to write result entry: {err}"
))?;
writeln!(file).map_err(|err| miette!(
help = runtime_results_help(),
"failed to write result entry: {err}"
))?;
Ok(())
}
fn append_state_transition_log_entry(
workspace_root: &Path,
task_id: &str,
from: &str,
to: &str,
) -> MietteResult<()> {
let runtime_dir = workspace_root.join("runtime");
fs::create_dir_all(&runtime_dir)
.map_err(|err| miette!(
help = runtime_dir_help(),
"failed to create runtime directory: {err}"
))?;
let transitions_file = runtime_dir.join("state-transitions.log");
use std::fs::OpenOptions;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&transitions_file)
.map_err(|err| miette!(
help = transition_log_help(),
"failed to open state transition log: {err}"
))?;
writeln!(file, "{} {}@{}", task_id, from, to)
.map_err(|err| miette!(
help = transition_log_help(),
"failed to write state transition log entry: {err}"
))?;
Ok(())
}
#[allow(clippy::too_many_arguments)]
fn record_transition_result(
artifact_root: &Path,
task_file: &Path,
local_id: &str,
machine: &rhei_validator::StateMachine,
task_id: &str,
from: &str,
to: &str,
message: Option<&str>,
) -> MietteResult<()> {
append_result_entry(artifact_root, task_id, from, to, message)?;
if is_terminal_state(to, machine) {
ensure_result_file(artifact_root, task_id)?;
let result_link = format!("runtime/results/{}.md", task_id);
rewrite_task_completion(task_file, local_id, task_id, &result_link, true)?;
}
Ok(())
}
fn ensure_result_file(workspace_root: &Path, task_id: &str) -> MietteResult<()> {
let results_dir = workspace_root.join("runtime").join("results");
fs::create_dir_all(&results_dir)
.map_err(|err| {
miette!(help = runtime_results_help(), "failed to create runtime/results directory: {err}")
})?;
let result_file = results_dir.join(format!("{}.md", task_id));
if result_file.exists() {
return Ok(());
}
fs::write(&result_file, "")
.map_err(|err| file_io_report(&result_file, "failed to create result file", err))
}
struct TaskAssigneeClaimContext<'a> {
workspace_root: &'a Path,
metadata: Option<&'a Metadata>,
state_def: &'a rhei_validator::StateDef,
settings: &'a RheiSettings,
}
fn write_task_assignee(
task_file: &Path,
task_id: &str,
qualified_id: &str,
expected_state: &str,
machine: &rhei_validator::StateMachine,
claim: TaskAssigneeClaimContext<'_>,
assignee: &str,
) -> MietteResult<()> {
let handle = fs::File::open(task_file)
.map_err(|err| file_io_report(task_file, "failed to open plan file", err))?;
handle
.lock_exclusive()
.map_err(|err| file_io_report(task_file, "failed to acquire file lock", err))?;
let raw = fs::read_to_string(task_file)
.map_err(|err| file_io_report(task_file, "failed to read plan file", err))?;
let target = parse_task_id(task_id);
let task = parse_claim_task_from_raw(&raw, task_file, &target, task_id)?;
let current_state = normalized_state_name(task.state.as_str(), machine);
if current_state != expected_state {
let _ = fs2::FileExt::unlock(&handle);
return Err(miette!(
help = task_moved_help(),
"conflict: Task {} is in state '{}', expected '{}'",
qualified_id,
task.state,
expected_state
));
}
if let Some(existing) = task.assignee.as_deref() {
let _ = fs2::FileExt::unlock(&handle);
return Err(miette!(
help = format!(
"release it with: rhei release {qualified_id} — or work on a different task."
),
"Task {} is already assigned to {}", qualified_id, existing
));
}
ensure_state_inputs_exist_for_transition(
claim.workspace_root,
Some(&task),
qualified_id,
¤t_state,
claim.state_def,
Some(render_visit_count(
claim.metadata,
&parse_task_id(qualified_id),
¤t_state,
task.state.as_str(),
machine,
)),
machine,
claim.settings,
&format!("Task {} cannot be claimed in state {}.", qualified_id, current_state),
)?;
let rewritten = insert_task_assignee(&raw, task_id, assignee)?;
let parent = task_file.parent().unwrap_or(Path::new("."));
let mut tmp = tempfile::NamedTempFile::new_in(parent)
.map_err(|err| miette!(
help = temp_write_help(),
"failed to create temp file: {err}"
))?;
tmp.write_all(rewritten.as_bytes())
.map_err(|err| miette!(
help = temp_write_help(),
"failed to write temp file: {err}"
))?;
tmp.persist(task_file).map_err(|err| miette!(
help = temp_write_help(),
"failed to persist temp file: {err}"
))?;
let _ = fs2::FileExt::unlock(&handle);
Ok(())
}
fn parse_claim_task_from_raw(
raw: &str,
task_file: &Path,
target: &TaskId,
task_id: &str,
) -> MietteResult<rhei_core::ast::Task> {
if let Ok(rhei) = rhei_core::parse(raw) {
if let Some(task) = find_task_by_id(&rhei.tasks, target) {
return Ok(task.clone());
}
}
if let Ok(tasks) = rhei_core::parser::parse_workspace_tasks(raw) {
if let Some(task) = find_task_by_id(&tasks, target) {
return Ok(task.clone());
}
}
Err(miette!(
help = task_id_help(),
"task '{}' not found in {}", task_id, task_file.display()
))
}
fn trim_trailing_blank_lines(lines: &mut Vec<String>) {
while lines.last().is_some_and(|line| line.trim().is_empty()) {
lines.pop();
}
}
fn rewrite_task_completion(
task_file: &Path,
task_id: &str,
link_text: &str,
link_path: &str,
insert_link: bool,
) -> MietteResult<()> {
let raw = fs::read_to_string(task_file)
.map_err(|err| file_io_report(task_file, "failed to read plan file", err))?;
let lines: Vec<&str> = raw.lines().collect();
let mut result_lines: Vec<String> = Vec::with_capacity(lines.len() + 2);
let mut in_target_task = false;
let mut target_found = false;
let mut link_inserted = !insert_link; let result_line = format!("> **Result:** [{}]({})", link_text, link_path);
let mut in_code_block = false;
for line in &lines {
let heading = node_heading_outside_code(line, &mut in_code_block);
if in_target_task && !link_inserted && heading.is_some() {
trim_trailing_blank_lines(&mut result_lines);
result_lines.push(String::new());
result_lines.push(result_line.clone());
result_lines.push(String::new());
link_inserted = true;
}
if let Some((_, id)) = heading {
in_target_task = id == task_id;
target_found |= in_target_task;
}
if !in_code_block && in_target_task && line.starts_with("**Assignee:**") {
continue;
}
if !in_code_block && in_target_task && line.starts_with("> **Result:**") {
if !link_inserted {
result_lines.push(result_line.clone());
link_inserted = true;
continue;
}
link_inserted = true;
}
result_lines.push(line.to_string());
}
if in_target_task && !link_inserted {
trim_trailing_blank_lines(&mut result_lines);
result_lines.push(String::new());
result_lines.push(result_line);
}
if !target_found {
return Err(miette!(
help = task_id_help(),
"task '{}' not found in {}", task_id, task_file.display()
));
}
let mut output = result_lines.join("\n");
if raw.ends_with('\n') {
output.push('\n');
}
let parent = task_file.parent().unwrap_or(Path::new("."));
let mut tmp = tempfile::NamedTempFile::new_in(parent)
.map_err(|err| miette!(
help = temp_write_help(),
"failed to create temp file: {err}"
))?;
tmp.write_all(output.as_bytes()).map_err(|err| miette!(
help = temp_write_help(),
"failed to write temp file: {err}"
))?;
tmp.persist(task_file).map_err(|err| miette!(
help = temp_write_help(),
"failed to persist temp file: {err}"
))?;
Ok(())
}
fn state_instructions(machine: &rhei_validator::StateMachine, state: &str) -> String {
machine
.states
.get(state)
.and_then(|def| machine.effective_instructions(def))
.unwrap_or_default()
}
fn state_personality(machine: &rhei_validator::StateMachine, state: &str) -> Option<String> {
machine.effective_personality(machine.states.get(state)?)
}