use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use crate::rhei_validator::StateMachine;
use crate::rhei_viz_model::VizModel;
use rhei_core::ast::Rhei;
use rhei_core::{parse, workspace};
use crate::rhei_viz::build_with_history;
pub type Bundle = BTreeMap<String, VizModel>;
pub fn collect_plans(
path: &Path,
key: &str,
machine_override: Option<&Path>,
) -> io::Result<Bundle> {
let mut plans = Bundle::new();
if path.is_file() {
let model = load_plan_file(path, machine_override)?;
plans.insert(key.to_string(), model);
return Ok(plans);
}
if !path.is_dir() {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("no such file or directory: {}", path.display()),
));
}
if workspace::is_panta_project(path) {
let loaded = workspace::load_panta_project(path).map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("failed to load Panta project {}: {}", path.display(), err.message),
)
})?;
let machines = resolve_project_machines(path, machine_override, &loaded)?;
let task_roots = loaded
.task_roots
.iter()
.map(|(id, root)| (id.clone(), root.clone()))
.collect::<std::collections::HashMap<_, _>>();
plans.insert(
key.to_string(),
crate::rhei_viz::build_set_with_history_roots(
&loaded.rhei,
&machines,
path,
&task_roots,
),
);
return Ok(plans);
}
if workspace::is_workspace(path) {
let loaded = workspace::load_implicit_panta(path).map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("failed to load workspace {}: {}", path.display(), err.message),
)
})?;
let machine = resolve_machine(path, machine_override, &loaded.rhei)?;
plans.insert(key.to_string(), build_with_history(&loaded.rhei, &machine, path));
}
for plan_path in standalone_plan_files(path)? {
if plan_path.file_name().and_then(|name| name.to_str()) == Some("index.rhei.md") {
continue;
}
let rel = plan_path.strip_prefix(path).unwrap_or(&plan_path).to_string_lossy().to_string();
let plan_key = format!("{key}::{rel}");
plans.insert(plan_key, load_plan_file(&plan_path, machine_override)?);
}
Ok(plans)
}
fn load_plan_file(path: &Path, machine_override: Option<&Path>) -> io::Result<VizModel> {
let text = fs::read_to_string(path)?;
let rhei = parse(&text).map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("failed to parse {}: {}", path.display(), err.message),
)
})?;
let rhei = workspace::implicit_panta_from_file_rhei(rhei, path)
.map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("failed to load {}: {}", path.display(), err.message),
)
})?
.rhei;
let machine = resolve_machine(path, machine_override, &rhei)?;
let workspace_root = path.parent().unwrap_or_else(|| Path::new("."));
Ok(build_with_history(&rhei, &machine, workspace_root))
}
fn resolve_project_machines(
path: &Path,
machine_override: Option<&Path>,
loaded: &workspace::PantaProject,
) -> io::Result<crate::rhei_validator::MachineSet> {
let default = resolve_machine(path, machine_override, &loaded.rhei)?;
let mut per_rhei = std::collections::BTreeMap::new();
let mut declared: Vec<(&String, &String)> = loaded.rhei_machines.iter().collect();
declared.sort();
for (rhei_id, machine_name) in declared {
if *machine_name == default.name {
continue;
}
if machine_override.is_some() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"--states declares '{}', but rhei '{rhei_id}' declares state machine '{machine_name}'; the override cannot reinterpret that rhei's states",
default.name
),
));
}
let mut candidates = Vec::new();
if let Some(root) = loaded.rhei_roots.get(rhei_id) {
candidates.push(root.join("states.yaml"));
}
candidates.push(path.join("states.yaml"));
let mut resolved = None;
for candidate in candidates {
if !candidate.is_file() {
continue;
}
let machine = load_machine(&candidate)?;
if machine.name == *machine_name {
resolved = Some(machine);
break;
}
}
let Some(machine) = resolved else {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"rhei '{rhei_id}' declares state machine '{machine_name}', but no states file declaring it was found in its root or the project root"
),
));
};
per_rhei.insert(rhei_id.clone(), machine);
}
Ok(crate::rhei_validator::MachineSet { default, per_rhei })
}
fn resolve_machine(
plan_or_dir: &Path,
machine_override: Option<&Path>,
rhei: &Rhei,
) -> io::Result<StateMachine> {
if let Some(machine_path) = machine_override {
return load_machine(machine_path);
}
let dir = if plan_or_dir.is_dir() {
plan_or_dir.to_path_buf()
} else {
plan_or_dir.parent().unwrap_or_else(|| Path::new(".")).to_path_buf()
};
let candidate = dir.join("states.yaml");
if candidate.is_file() {
let machine = load_machine(&candidate)?;
if machine.name == rhei.states {
return Ok(machine);
}
let builtin = StateMachine::builtin_default();
if rhei.states != builtin.name {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"plan declares state machine '{}', but auto-discovered states file '{}' declares '{}'",
rhei.states,
candidate.display(),
machine.name
),
));
}
return Ok(builtin);
}
if rhei.states == StateMachine::builtin_default().name {
return Ok(StateMachine::builtin_default());
}
load_machine(&candidate)
}
fn load_machine(machine_path: &Path) -> io::Result<StateMachine> {
StateMachine::from_yaml_file(machine_path).map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("failed to load state machine {}: {err}", machine_path.display()),
)
})
}
fn standalone_plan_files(dir: &Path) -> io::Result<Vec<PathBuf>> {
let mut files: Vec<PathBuf> = fs::read_dir(dir)?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.is_file())
.filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.map(|name| name.ends_with(".rhei.md"))
.unwrap_or(false)
})
.collect();
files.sort();
Ok(files)
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
struct TempDir {
path: PathBuf,
}
impl TempDir {
fn new(prefix: &str) -> Self {
let stamp = SystemTime::now().duration_since(UNIX_EPOCH).expect("clock").as_nanos();
let path = std::env::temp_dir().join(format!("rhei-viz-collect-{prefix}-{stamp}"));
fs::create_dir_all(&path).expect("create temp dir");
Self { path }
}
fn path(&self) -> &Path {
&self.path
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.path);
}
}
#[test]
fn merges_workspace_and_standalone_plans() {
let temp = TempDir::new("ws");
fs::write(
temp.path().join("index.rhei.md"),
"# Rhei: Workspace\n**States:** rhei\n\n## Overview\nDemo.\n",
)
.unwrap();
fs::create_dir_all(temp.path().join("tasks")).unwrap();
fs::write(
temp.path().join("tasks/alpha.rhei.md"),
"### Task 1: Alpha\n**State:** pending\n",
)
.unwrap();
fs::write(
temp.path().join("extra.rhei.md"),
"# Rhei: Extra\n**States:** rhei\n\n## Tasks\n\n### Task 1: Extra\n**State:** completed\n",
)
.unwrap();
let plans = collect_plans(temp.path(), "demo", None).expect("collect");
assert_eq!(plans.len(), 2);
assert_eq!(plans["demo"].plan_title.as_deref(), Some("Workspace"));
assert_eq!(plans["demo::extra.rhei.md"].plan_title.as_deref(), Some("Extra"));
}
#[test]
fn single_file_resolves_builtin_default() {
let temp = TempDir::new("file");
let plan = temp.path().join("plan.rhei.md");
fs::write(
&plan,
"# Rhei: Solo\n**States:** rhei\n\n## Tasks\n\n### Task 1: A\n**State:** in-progress\n",
)
.unwrap();
let plans = collect_plans(&plan, "solo", None).expect("collect");
assert_eq!(plans.len(), 1);
assert_eq!(plans["solo"].plan_state.as_deref(), Some("active"));
assert!(!plans["solo"].machine.states.is_empty());
}
#[test]
fn history_falls_back_to_legacy_rhei_local_ids() {
let temp = TempDir::new("legacy-history");
let plan = temp.path().join("plan.rhei.md");
fs::write(
&plan,
"# Rhei: Legacy\n**States:** rhei\n\n## Tasks\n\n### Task 1: A\n**State:** in-progress\n",
)
.unwrap();
let runtime = temp.path().join("runtime");
fs::create_dir_all(&runtime).unwrap();
fs::write(runtime.join("state-transitions.log"), "1 pending@in-progress\n").unwrap();
let plans = collect_plans(&plan, "legacy", None).expect("collect");
let task = &plans["legacy"].tasks[0];
assert_eq!(task.id, "plan.1");
assert_eq!(task.history.len(), 1, "legacy local-id history should attach");
assert_eq!(task.history[0].from, "pending");
assert_eq!(task.history[0].to, "in-progress");
fs::write(
runtime.join("state-transitions.log"),
"plan.1 pending@completed\n1 pending@in-progress\n",
)
.unwrap();
let plans = collect_plans(&plan, "legacy", None).expect("collect");
let task = &plans["legacy"].tasks[0];
assert_eq!(task.history.len(), 1);
assert_eq!(task.history[0].to, "completed");
}
#[test]
fn sibling_states_named_rhei_overrides_builtin_default() {
let temp = TempDir::new("local-rhei");
let plan = temp.path().join("plan.rhei.md");
fs::write(
&plan,
"# Rhei: Local\n**States:** rhei\n\n## Tasks\n\n### Task 1: A\n**State:** local-work\n",
)
.unwrap();
fs::write(
temp.path().join("states.yaml"),
r#"
name: rhei
version: 1.0
states:
local-work:
initial: true
instructions: "Use the local machine."
completed:
final: true
"#,
)
.unwrap();
let plans = collect_plans(&plan, "local", None).expect("collect");
let local = plans["local"]
.machine
.states
.iter()
.find(|state| state.name == "local-work")
.expect("local state is rendered");
assert_eq!(local.instructions.as_deref(), Some("Use the local machine."));
}
}