fn relative_path(from_dir: &Path, to_path: &Path) -> PathBuf {
let make_absolute = |p: &Path| -> PathBuf {
if p.is_absolute() {
p.to_path_buf()
} else if let Ok(cwd) = std::env::current_dir() {
cwd.join(p)
} else {
p.to_path_buf()
}
};
let from = make_absolute(from_dir);
let to = make_absolute(to_path);
let from_components: Vec<_> = from.components().collect();
let to_components: Vec<_> = to.components().collect();
let common =
from_components.iter().zip(to_components.iter()).take_while(|(a, b)| a == b).count();
let mut result = PathBuf::new();
for _ in common..from_components.len() {
result.push("..");
}
for component in &to_components[common..] {
result.push(component);
}
result
}
fn filesystem_skill_source(skill_name: &str) -> Option<PathBuf> {
let usable = |dir: PathBuf| -> Option<PathBuf> {
if dir.join("SKILL.md").is_file() {
dir.canonicalize().ok()
} else {
None
}
};
let exe = std::env::current_exe().ok();
if let Some(bin_dir) = exe.as_deref().and_then(Path::parent) {
if let Some(found) = usable(bin_dir.join("../share/rhei/skills").join(skill_name)) {
return Some(found);
}
}
let starts = [exe.as_deref().and_then(Path::parent).map(Path::to_path_buf), std::env::current_dir().ok()];
for start in starts.into_iter().flatten() {
let mut dir = Some(start);
while let Some(d) = dir {
if let Some(found) = usable(d.join("crates/rhei-cli/skills").join(skill_name)) {
return Some(found);
}
dir = d.parent().map(Path::to_path_buf);
}
}
None
}
struct ResolvedSkills {
sources: Vec<(String, PathBuf)>,
_extraction: Option<tempfile::TempDir>,
}
fn resolve_skill_sources(skills: &[String], link: bool) -> MietteResult<ResolvedSkills> {
let mut sources = Vec::new();
let mut extraction: Option<tempfile::TempDir> = None;
for skill in skills {
if let Some(found) = filesystem_skill_source(skill) {
sources.push((skill.clone(), found));
continue;
}
if !builtin_skill_exists(skill) {
return Err(unknown_skill_error(skill));
}
if link {
return Err(miette!(
help = "run from a rhei checkout, or drop --link to copy the embedded skill.",
"--link needs skill files on disk, but '{skill}' is only available as the copy \
embedded in this binary. Searched '<binary>/../share/rhei/skills/{skill}/' and \
'crates/rhei-cli/skills/{skill}/' up from both the binary and the current \
directory."
));
}
let temp = match extraction {
Some(ref t) => t,
None => extraction.insert(
tempfile::Builder::new()
.prefix("rhei-builtin-skills-")
.tempdir()
.map_err(|err| miette!(
help = "embedded skills are unpacked into a temp directory. Check that \
$TMPDIR exists and is writable.",
"failed to create a temporary directory: {err}"
))?,
),
};
sources.push((skill.clone(), materialize_builtin_skill(skill, temp.path())?));
}
Ok(ResolvedSkills { sources, _extraction: extraction })
}
fn find_project_root() -> MietteResult<PathBuf> {
let cwd = std::env::current_dir()
.map_err(|e| miette!(
help = cwd_help(),
"failed to determine working directory: {e}"
))?;
let markers = [".git", "Cargo.toml", "package.json", "pyproject.toml", "go.mod"];
let mut dir = Some(cwd.as_path());
while let Some(d) = dir {
for marker in &markers {
if d.join(marker).exists() {
return Ok(d.to_path_buf());
}
}
dir = d.parent();
}
Ok(cwd)
}
fn inject_marked_section(file: &Path, content: &str, dry_run: bool) -> MietteResult<()> {
let start_marker = "<!-- rhei:start -->";
let end_marker = "<!-- rhei:end -->";
let existing = if file.exists() {
fs::read_to_string(file).map_err(|e| file_io_report(file, "failed to read", e))?
} else {
String::new()
};
let block = format!("{start_marker}\n{content}\n{end_marker}");
let new_content = if let (Some(start), Some(end)) =
(existing.find(start_marker), existing.find(end_marker))
{
let before = &existing[..start];
let after = &existing[end + end_marker.len()..];
format!("{before}{block}{after}")
} else {
if existing.is_empty() {
block
} else if existing.ends_with('\n') {
format!("{existing}\n{block}\n")
} else {
format!("{existing}\n\n{block}\n")
}
};
if dry_run {
println!(" [dry-run] would write {} ({} bytes)", file.display(), new_content.len());
return Ok(());
}
if let Some(parent) = file.parent() {
fs::create_dir_all(parent)
.map_err(|e| file_io_report(parent, "failed to create directory", e))?;
}
fs::write(file, &new_content)
.map_err(|e| file_io_report(file, "failed to write", e))?;
Ok(())
}
fn is_rhei_heading(line: &str) -> bool {
(line.starts_with("# rhei") || line.starts_with("## rhei")) && !line.starts_with("###")
}
fn claude_md_block_end(lines: &[&str], heading: usize) -> usize {
let heading_level = lines[heading].chars().take_while(|&c| c == '#').count();
lines
.iter()
.enumerate()
.skip(heading + 1)
.find(|(_, line)| {
let level = line.chars().take_while(|&c| c == '#').count();
line.trim().is_empty() || (level > 0 && level <= heading_level)
})
.map_or(lines.len(), |(index, _)| index)
}
fn remove_marked_section(file: &Path, dry_run: bool) -> MietteResult<()> {
if !file.exists() {
return Ok(());
}
let content = fs::read_to_string(file)
.map_err(|e| file_io_report(file, "failed to read", e))?;
let start_marker = "<!-- rhei:start -->";
let end_marker = "<!-- rhei:end -->";
let mut result = content.clone();
if let (Some(start), Some(end)) = (result.find(start_marker), result.find(end_marker)) {
let block_end = end + end_marker.len();
let block_end =
if result[block_end..].starts_with('\n') { block_end + 1 } else { block_end };
let block_start = if start > 0 && result[..start].ends_with('\n') {
if start >= 2 && result[..start].ends_with("\n\n") {
start - 1
} else {
start
}
} else {
start
};
result = format!("{}{}", &result[..block_start], &result[block_end..]);
}
let lines: Vec<&str> = result.lines().collect();
let mut new_lines: Vec<&str> = Vec::new();
let mut index = 0;
while index < lines.len() {
if is_rhei_heading(lines[index]) {
let end = claude_md_block_end(&lines, index);
index = if lines.get(end).is_some_and(|line| line.trim().is_empty()) {
end + 1
} else {
end
};
continue;
}
new_lines.push(lines[index]);
index += 1;
}
let final_content = if new_lines.is_empty() {
String::new()
} else {
let mut s = new_lines.join("\n");
if content.ends_with('\n') {
s.push('\n');
}
s
};
if final_content == content {
return Ok(());
}
if dry_run {
println!(" [dry-run] would update {}", file.display());
return Ok(());
}
fs::write(file, &final_content)
.map_err(|e| file_io_report(file, "failed to write", e))?;
Ok(())
}
fn display_path(path: &Path) -> String {
let absolute = path.display().to_string();
let Ok(cwd) = std::env::current_dir() else {
return absolute;
};
let relative = relative_path(&cwd, path).display().to_string();
if relative.is_empty() {
return ".".to_string();
}
if relative.len() < absolute.len() {
relative
} else {
absolute
}
}
fn parse_report(path: &Path, input: &str, err: &rhei_core::parser::ParseError) -> Report {
miette!(
help = plan_authoring_help(),
"{}", render_parse_diagnostic(path, input, err)
)
}
fn parse_errors_report(
path: &Path,
input: &str,
errors: &[rhei_core::parser::ParseError],
) -> Report {
miette!(
help = plan_authoring_help(),
"{}", render_multi_parse_diagnostic(path, input, errors)
)
}
struct ParseErrorGroup {
path: PathBuf,
input: String,
errors: Vec<rhei_core::parser::ParseError>,
}
fn workspace_parse_errors_report(groups: &[ParseErrorGroup]) -> Report {
miette!(
help = plan_authoring_help(),
"{}", render_workspace_parse_diagnostic(groups)
)
}
fn render_workspace_parse_diagnostic(groups: &[ParseErrorGroup]) -> String {
let error_count: usize = groups.iter().map(|group| group.errors.len()).sum();
let file_count = groups.len();
let problem_word = if error_count == 1 { "problem" } else { "problems" };
let file_word = if file_count == 1 { "file" } else { "files" };
let mut lines = vec![
"-- PARSE ERROR ----------------------------".to_string(),
format!("in Directory Workspace task files ({error_count} {problem_word}, {file_count} {file_word})"),
];
lines.push(String::new());
lines.push("I got stuck while reading this workspace's markdown task files.".to_string());
let mut index = 1usize;
for group in groups {
lines.push(String::new());
lines.push(format!("{}:", display_path(&group.path)));
for err in &group.errors {
match err.line {
Some(line_number) => {
lines.push(format!("{index}. line {line_number}: {}", err.message));
if let Some(source_line) = line_text(&group.input, line_number) {
lines.push(format!(" {line_number}| {source_line}"));
}
}
None => {
lines.push(format!("{index}. {}", err.message));
}
}
index += 1;
}
}
lines.push(String::new());
lines.push(
"Hint: fix the problems above — each one refers to a distinct file, line, or task."
.to_string(),
);
lines.join("\n")
}
fn render_multi_parse_diagnostic(
path: &Path,
input: &str,
errors: &[rhei_core::parser::ParseError],
) -> String {
if errors.len() == 1 {
return render_parse_diagnostic(path, input, &errors[0]);
}
let mut lines = vec![
"-- PARSE ERROR ----------------------------".to_string(),
format!("in {}", display_path(path)),
];
lines.push(String::new());
lines
.push(format!("I got stuck while reading this markdown plan ({} problems).", errors.len()));
for (i, err) in errors.iter().enumerate() {
lines.push(String::new());
let prefix = format!("{}.", i + 1);
match err.line {
Some(line_number) => {
lines.push(format!("{prefix} line {line_number}: {}", err.message));
if let Some(source_line) = line_text(input, line_number) {
lines.push(format!(" {line_number}| {source_line}"));
}
}
None => {
lines.push(format!("{prefix} {}", err.message));
}
}
}
lines.push(String::new());
lines.push(
"Hint: fix the problems above — each one refers to a distinct line or task.".to_string(),
);
lines.join("\n")
}
fn nested_parse_report(err: &rhei_core::parser::ParseError) -> Report {
let Some(path) = err.file.as_deref() else {
return miette!(help = plan_authoring_help(), "{}", err.message);
};
let Ok(source) = std::fs::read_to_string(path) else {
return miette!(help = plan_authoring_help(), "{}: {}", path.display(), err.message);
};
let collected = collect_parse_errors(path, &source);
if collected.len() > 1 && collected.iter().any(|other| other.message == err.message) {
return parse_errors_report(path, &source, &collected);
}
parse_report(path, &source, err)
}
fn collect_parse_errors(path: &Path, source: &str) -> Vec<rhei_core::parser::ParseError> {
let name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default();
if name.ends_with(".rhei.md") && name != "index.rhei.md" {
return rhei_core::parser::parse_collect(source).1;
}
let Some(structure) = owning_structure(path) else {
return Vec::new();
};
rhei_core::parser::parse_workspace_tasks_collect_with_structure(source, &structure).1
}
fn owning_structure(path: &Path) -> Option<rhei_core::ast::Structure> {
let parent = path.parent()?;
let mut dir = parent;
loop {
let index = dir.join("index.rhei.md");
if index.is_file() {
let raw = std::fs::read_to_string(&index).ok()?;
return Some(rhei_core::parser::parse_workspace_index(&raw).ok()?.structure);
}
let manifest = dir.join(rhei_core::workspace::PANTA_INDEX_FILE);
if manifest.is_file() {
let raw = std::fs::read_to_string(&manifest).ok()?;
return Some(rhei_core::parser::parse_panta_manifest(&raw).ok()?.structure);
}
dir = dir.parent()?;
}
}
trait FileIoCause: std::fmt::Display {
fn io_kind(&self) -> Option<std::io::ErrorKind> {
None
}
}
impl FileIoCause for std::io::Error {
fn io_kind(&self) -> Option<std::io::ErrorKind> {
Some(self.kind())
}
}
impl FileIoCause for &std::io::Error {
fn io_kind(&self) -> Option<std::io::ErrorKind> {
Some((*self).kind())
}
}
impl FileIoCause for String {}
impl FileIoCause for &str {}
fn file_io_report(path: &Path, action: &str, err: impl FileIoCause) -> Report {
let help = io_error_help(path, err.io_kind().unwrap_or(std::io::ErrorKind::Other));
miette!(help = help, "{action} '{}': {err}", path.display())
}
fn validation_report(input: &Path, state_machine: Option<&Path>, errors: &[String]) -> Report {
miette!(
help = "fix the errors above, then re-check with: rhei validate <plan>",
"{}", render_validation_diagnostic(input, state_machine, errors)
)
}
fn render_parse_diagnostic(
path: &Path,
input: &str,
err: &rhei_core::parser::ParseError,
) -> String {
let mut lines = vec![
"-- PARSE ERROR ----------------------------".to_string(),
format!("in {}", display_path(path)),
];
lines.push(String::new());
lines.push("I got stuck while reading this markdown plan.".to_string());
if let Some(line_number) = err.line {
lines.push(String::new());
lines.push(format!("I was partway through line {line_number} when the problem showed up."));
if let Some(source_line) = line_text(input, line_number) {
lines.push(String::new());
lines.push(format!("{line_number}| {source_line}"));
lines.push(format!("{}{}", " ".repeat(line_number.to_string().len() + 2), "^"));
}
}
lines.push(String::new());
lines.push(err.message.replace(" before task content", "\nbefore task content"));
lines.push(String::new());
lines.push(
"Hint: check the markdown structure around the highlighted line and try again.".to_string(),
);
lines.join("\n")
}
fn render_validation_diagnostic(
input: &Path,
state_machine: Option<&Path>,
errors: &[String],
) -> String {
let mut lines = vec![
"-- VALIDATION ERROR ----------------------".to_string(),
format!("in {}", display_path(input)),
];
lines.push(String::new());
lines.push(format!(
"I validated this plan using {}, but found a problem.",
state_machine_label(state_machine),
));
lines.push(String::new());
lines.push(format_validation_errors(errors));
lines.push(String::new());
lines.push("I recommend fixing the problems above and running the command again.".to_string());
lines.join("\n")
}
fn format_validation_errors(errors: &[String]) -> String {
if errors.len() == 1 {
format!("The problem is:\n\n {}", errors[0])
} else {
let mut lines = vec![format!("I found {} problems:", errors.len()), String::new()];
lines.extend(
errors.iter().enumerate().map(|(index, error)| format!("{}. {}", index + 1, error)),
);
lines.join("\n")
}
}
fn line_text(input: &str, line_number: usize) -> Option<&str> {
input.lines().nth(line_number.saturating_sub(1))
}
fn add_a_rhei_hint() -> &'static str {
"add one with `rhei instantiate <template>` (`rhei templates` lists them), or by hand as a \
`<id>.rhei.md` file next to index.panta.md"
}
fn add_a_rhei_help() -> String {
[
"Add a rhei either way:",
" from a template `rhei templates` lists them; `rhei instantiate <name>` writes one",
" into this project, keeping the template's own state machine",
" by hand create `<id>.rhei.md` next to index.panta.md:",
"",
" # Rhei: <title>",
"",
" ## Tasks",
"",
" ### Task 1: <first ticket>",
" **State:** pending",
]
.join("\n")
}