fn parse_template_source_filter(value: &str) -> MietteResult<TemplateSourceFilter> {
match value.trim().to_ascii_lowercase().as_str() {
"project" => Ok(TemplateSourceFilter::Project),
"user" => Ok(TemplateSourceFilter::User),
"builtin" | "built-in" => Ok(TemplateSourceFilter::Builtin),
"all" => Ok(TemplateSourceFilter::All),
other => Err(miette!(
help = "pass --source project, --source user, --source builtin, or \
--source all.",
"invalid template source '{}'. Expected one of: project, user, builtin, all",
other
)),
}
}
fn discover_templates(filter: TemplateSourceFilter) -> MietteResult<Vec<DiscoveredTemplate>> {
let mut templates = Vec::new();
let mut seen = HashSet::new();
for (source, root) in template_search_roots(filter)? {
if source == TemplateSource::Builtin {
for name in builtin_template_names() {
if seen.contains(&name) {
continue;
}
let Ok(extracted) = materialize_builtin_template(&name) else {
continue;
};
let Ok(manifest) = load_template_manifest(extracted.path()) else {
continue;
};
seen.insert(name.clone());
templates.push(DiscoveredTemplate {
manifest,
path: PathBuf::from(&name),
source,
});
}
continue;
}
if !root.is_dir() {
continue;
}
let mut entries = fs::read_dir(&root)
.map_err(|err| file_io_report(&root, "failed to read template directory", err))?
.collect::<Result<Vec<_>, _>>()
.map_err(|err| {
miette!(
help = "check that the templates directory is readable, then re-run: rhei templates",
"failed to read dir entry in '{}': {err}", root.display()
)
})?;
entries.sort_by_key(|entry| entry.file_name());
for entry in entries {
let path = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') || !path.is_dir() || seen.contains(&name) {
continue;
}
let Ok(manifest) = load_template_manifest(&path) else {
continue;
};
seen.insert(name);
templates.push(DiscoveredTemplate { manifest, path, source });
}
}
Ok(templates)
}
fn template_search_roots(
filter: TemplateSourceFilter,
) -> MietteResult<Vec<(TemplateSource, PathBuf)>> {
let mut roots = Vec::new();
if filter.includes(TemplateSource::Project) {
roots.push((
TemplateSource::Project,
project_template_root()?,
));
}
if filter.includes(TemplateSource::User) {
roots.push((
TemplateSource::User,
home_dir()?.join(".agents").join("rhei").join("templates"),
));
}
if filter.includes(TemplateSource::Builtin) {
roots.push((TemplateSource::Builtin, PathBuf::from("<compiled into the rhei binary>")));
}
Ok(roots)
}
fn project_template_root() -> MietteResult<PathBuf> {
if let Some(root) = nearest_project_template_root()? {
return Ok(root);
}
Ok(find_project_root()?.join(".agents").join("rhei").join("templates"))
}
fn nearest_project_template_root() -> MietteResult<Option<PathBuf>> {
let cwd = std::env::current_dir()
.map_err(|e| miette!(
help = cwd_help(),
"failed to determine working directory: {e}"
))?;
let mut dir = Some(cwd.as_path());
while let Some(current) = dir {
let candidate = current.join(".agents").join("rhei").join("templates");
if candidate.is_dir() {
return Ok(Some(candidate));
}
dir = current.parent();
}
Ok(None)
}
struct ResolvedTemplate {
path: PathBuf,
_extracted: Option<ExtractedTemplate>,
}
impl ResolvedTemplate {
fn path(&self) -> &Path {
&self.path
}
}
fn resolve_template_reference(reference: &str) -> MietteResult<ResolvedTemplate> {
if template_reference_is_path(reference) {
let path = PathBuf::from(reference);
if !path.is_dir() {
return Err(miette!(
help = "a template reference containing '/' is treated as a path. Point it at \
the directory holding template.yaml, or drop the '/' to use a \
template by name: rhei templates",
"template directory '{}' does not exist",
path.display()
));
}
return Ok(ResolvedTemplate { path, _extracted: None });
}
for (source, root) in template_search_roots(TemplateSourceFilter::All)? {
if source == TemplateSource::Builtin {
if builtin_template_exists(reference) {
let extracted = materialize_builtin_template(reference)?;
return Ok(ResolvedTemplate {
path: extracted.path().to_path_buf(),
_extracted: Some(extracted),
});
}
continue;
}
let candidate = root.join(reference);
if candidate.is_dir() {
return Ok(ResolvedTemplate { path: candidate, _extracted: None });
}
}
let names = discover_templates(TemplateSourceFilter::All)?
.into_iter()
.map(|template| template.manifest.name)
.collect::<Vec<_>>();
let hint = nearest_match(reference, &names)
.map(|name| format!("Did you mean '{name}'? "))
.unwrap_or_default();
Err(miette!(
help = format!("{hint}List the templates you can instantiate with: rhei templates"),
"template '{}' not found among project, user, or built-in templates",
reference
))
}
fn template_reference_is_path(reference: &str) -> bool {
let path = Path::new(reference);
path.is_absolute() || reference.contains('/') || reference.starts_with('.')
}
fn load_template_manifest(template_dir: &Path) -> MietteResult<TemplateManifest> {
let manifest_path = template_dir.join("template.yaml");
let raw = fs::read_to_string(&manifest_path).map_err(|err| {
file_io_report(&manifest_path, "failed to read template manifest", err)
})?;
let manifest: TemplateManifest = serde_yaml::from_str(&raw)
.map_err(|err| miette!(
help = template_manifest_help(),
"failed to parse '{}': {err}", manifest_path.display()
))?;
validate_template_manifest(&manifest, template_dir)?;
Ok(manifest)
}
fn validate_template_manifest(
manifest: &TemplateManifest,
template_dir: &Path,
) -> MietteResult<()> {
let dir_name =
template_dir.file_name().and_then(|name| name.to_str()).ok_or_else(|| {
miette!(
help = template_manifest_help(),
"template path '{}' has no directory name", template_dir.display()
)
})?;
let ident = Regex::new(r"^[A-Za-z][A-Za-z0-9_-]*$")
.expect("template identifier regex should be valid");
if manifest.name != dir_name {
return Err(miette!(
help = template_manifest_help(),
"template manifest name '{}' does not match directory '{}'",
manifest.name,
dir_name
));
}
if !ident.is_match(&manifest.name) {
return Err(miette!(
help = template_manifest_help(),
"template name '{}' is not a valid identifier", manifest.name
));
}
if manifest.description.trim().is_empty() {
return Err(miette!(
help = template_manifest_help(),
"template '{}' must include a non-empty description",
manifest.name
));
}
let cwd = std::env::current_dir()
.map_err(|err| miette!(
help = cwd_help(),
"failed to determine working directory: {err}"
))?;
let mut seen = HashSet::new();
let mut positional_indexes = Vec::new();
for input in &manifest.inputs {
if !ident.is_match(&input.name) {
return Err(miette!(
help = template_manifest_help(),
"template '{}' input '{}' is not a valid identifier",
manifest.name,
input.name
));
}
if !seen.insert(input.name.as_str()) {
return Err(miette!(
help = template_manifest_help(),
"template '{}' declares duplicate input '{}'",
manifest.name,
input.name
));
}
if input.description.trim().is_empty() {
return Err(miette!(
help = template_manifest_help(),
"template '{}' input '{}' must include a description",
manifest.name,
input.name
));
}
if let Some(index) = input.positional {
if index == 0 {
return Err(miette!(
help = template_manifest_help(),
"template '{}' input '{}' positional index must be >= 1",
manifest.name,
input.name
));
}
positional_indexes.push((index, input.name.as_str()));
}
if input.schema.required == Some(true) && input.schema.default.is_some() {
return Err(miette!(
help = template_manifest_help(),
"template '{}' input '{}' cannot set both required: true and default",
manifest.name,
input.name
));
}
validate_template_value_schema(&manifest.name, &input.name, &input.schema)?;
if let Some(default) = input.schema.default.as_ref() {
let _ = coerce_template_input_value(input, default, &cwd, true)?;
}
}
positional_indexes.sort_by_key(|(index, _)| *index);
for (expected, (actual, name)) in positional_indexes.iter().enumerate() {
let expected = expected + 1;
if *actual != expected {
return Err(miette!(
help = template_manifest_help(),
"template '{}' input '{}' declares positional {}, but positional indexes must be unique and contiguous starting at 1",
manifest.name,
name,
actual
));
}
}
let _ = detect_template_layout(template_dir)?;
Ok(())
}
fn validate_template_value_schema(
template_name: &str,
label: &str,
schema: &TemplateValueSchema,
) -> MietteResult<()> {
if let Some(pattern) = schema.validate.as_deref() {
if matches!(schema.value_type, TemplateInputType::Array | TemplateInputType::Object) {
return Err(miette!(
help = template_manifest_help(),
"template '{}' input '{}' cannot set validate on {} values",
template_name,
label,
schema.value_type.as_str()
));
}
let _ = compile_full_match_regex(pattern).map_err(|err| {
miette!(
help = template_manifest_help(),
"template '{}' input '{}' has invalid validate regex: {err}",
template_name,
label
)
})?;
}
if let Some(format) = schema.format {
if matches!(schema.value_type, TemplateInputType::Array | TemplateInputType::Object) {
return Err(miette!(
help = format!(
"move `format: {}` onto the scalar it applies to — the array's `items` \
entry or the object property — instead of the {} itself.",
format.as_str(),
schema.value_type.as_str()
),
"template '{}' input '{}' cannot set format on {} values",
template_name,
label,
schema.value_type.as_str()
));
}
}
match schema.value_type {
TemplateInputType::Array => {
let Some(items) = schema.items.as_deref() else {
return Err(miette!(
help = template_manifest_help(),
"template '{}' input '{}' with type array must declare items",
template_name,
label
));
};
if !schema.properties.is_empty() {
return Err(miette!(
help = template_manifest_help(),
"template '{}' input '{}' with type array cannot declare properties",
template_name,
label
));
}
validate_template_value_schema(template_name, label, items)?;
}
TemplateInputType::Object => {
if schema.items.is_some() {
return Err(miette!(
help = template_manifest_help(),
"template '{}' input '{}' with type object cannot declare items",
template_name,
label
));
}
for (property, property_schema) in &schema.properties {
validate_template_value_schema(
template_name,
&format!("{label}.{property}"),
property_schema,
)?;
}
}
_ => {
if schema.items.is_some() {
return Err(miette!(
help = template_manifest_help(),
"template '{}' input '{}' with type {} cannot declare items",
template_name,
label,
schema.value_type.as_str()
));
}
if !schema.properties.is_empty() {
return Err(miette!(
help = template_manifest_help(),
"template '{}' input '{}' with type {} cannot declare properties",
template_name,
label,
schema.value_type.as_str()
));
}
}
}
Ok(())
}
fn detect_template_layout(template_dir: &Path) -> MietteResult<TemplateLayout> {
let plan_path = template_dir.join("plan.rhei.md");
let index_path = template_dir.join("index.rhei.md");
let has_plan = plan_path.is_file();
let has_index = index_path.is_file();
match (has_plan, has_index) {
(true, false) => Ok(TemplateLayout::SingleFile),
(false, true) => {
let tasks_dir = template_dir.join("tasks");
if !tasks_dir.is_dir() {
return Err(miette!(
help = template_manifest_help(),
"template '{}' is a workspace template but is missing tasks/",
template_dir.display()
));
}
Ok(TemplateLayout::Workspace)
}
(true, true) => Err(miette!(
help = template_manifest_help(),
"template '{}' contains both plan.rhei.md and index.rhei.md",
template_dir.display()
)),
(false, false) => Err(miette!(
help = template_manifest_help(),
"template '{}' must contain either plan.rhei.md or index.rhei.md",
template_dir.display()
)),
}
}
fn collect_template_inputs(
manifest: &TemplateManifest,
template_ref: &str,
values_files: &[PathBuf],
input_args: &[String],
set_values: &[String],
set_files: &[String],
) -> MietteResult<BTreeMap<String, serde_json::Value>> {
let cwd = std::env::current_dir()
.map_err(|err| miette!(
help = cwd_help(),
"failed to determine working directory: {err}"
))?;
let mut raw_values: BTreeMap<String, YamlValue> = BTreeMap::new();
for values_file in values_files {
let loaded = load_template_values_file(values_file)?;
for (key, value) in loaded {
raw_values.insert(key, value);
}
}
let parsed_input_args = parse_template_input_args(manifest, input_args)?;
for (key, value) in parsed_input_args.positional_values {
raw_values.insert(key, YamlValue::String(value));
}
for (key, value) in parsed_input_args.assignments {
raw_values.insert(key, YamlValue::String(value));
}
for assignment in set_values {
let (key, value) = parse_assignment(assignment, "--set")?;
raw_values.insert(key, YamlValue::String(value));
}
for assignment in set_files {
let (key, value_path) = parse_assignment(assignment, "--set-file")?;
let path = PathBuf::from(value_path);
let contents = fs::read_to_string(&path)
.map_err(|err| file_io_report(&path, "failed to read --set-file input", err))?;
raw_values.insert(key, YamlValue::String(contents));
}
let declared_inputs =
manifest.inputs.iter().map(|input| input.name.clone()).collect::<Vec<_>>();
for key in raw_values.keys() {
if !declared_inputs.iter().any(|name| name == key) {
return Err(miette!(
help = format!(
"{}List every input with: {}",
did_you_mean(key, &declared_inputs)
.map(|hint| format!("{hint} "))
.unwrap_or_default(),
list_inputs_command(template_ref)
),
"template '{}' has no input named '{}'",
manifest.name,
key
));
}
}
let missing = manifest
.inputs
.iter()
.filter(|input| {
input.is_required()
&& input.schema.default.is_none()
&& !raw_values.contains_key(&input.name)
})
.collect::<Vec<_>>();
if !missing.is_empty() {
return Err(missing_template_inputs_report(
manifest,
template_ref,
&missing,
values_files,
input_args,
set_values,
set_files,
));
}
let mut resolved = BTreeMap::new();
let mut rejected = Vec::new();
for input in &manifest.inputs {
let value = if let Some(raw) = raw_values.get(&input.name) {
coerce_template_input_value(input, raw, &cwd, false)
} else if let Some(default) = input.schema.default.as_ref() {
coerce_template_input_value(input, default, &cwd, true)
} else {
Ok(empty_template_value(&input.schema))
};
let value = match value {
Ok(value) => value,
Err(report) => {
rejected.push((input.name.clone(), report));
continue;
}
};
if let Err(report) = validate_resolved_value(&input.name, &input.schema, &value) {
rejected.push((input.name.clone(), report));
continue;
}
resolved.insert(input.name.clone(), value);
}
if !rejected.is_empty() {
return Err(rejected_template_inputs_report(rejected, template_ref));
}
Ok(resolved)
}
fn rejected_template_inputs_report(
rejected: Vec<(String, Report)>,
template_ref: &str,
) -> Report {
if rejected.len() == 1 {
let (_, report) = rejected.into_iter().next().expect("exactly one rejection");
return with_list_inputs_pointer(&report, template_ref);
}
let listed = rejected
.iter()
.map(|(_, report)| format!(" {report}"))
.collect::<Vec<_>>()
.join("\n");
let remedies = rejected
.iter()
.filter_map(|(name, report)| {
report.help().map(|help| {
let text = help.to_string();
let joined =
text.lines().map(str::trim).collect::<Vec<_>>().join(" ");
format!("{name}: {joined}")
})
})
.collect::<Vec<_>>()
.join("\n");
miette!(
help = format!(
"{remedies}\nSee what every input accepts: {}",
list_inputs_command(template_ref)
),
"{} inputs were rejected:\n{listed}",
rejected.len()
)
}
fn with_list_inputs_pointer(report: &Report, template_ref: &str) -> Report {
let pointer =
format!("See what every input accepts: {}", list_inputs_command(template_ref));
let help = match report.help() {
Some(help) => format!("{help}\n{pointer}"),
None => pointer,
};
miette!(help = help, "{report}")
}
fn validate_template_input_format(
label: &str,
format: TemplateInputFormat,
rendered: &str,
) -> MietteResult<()> {
match format {
TemplateInputFormat::ExecutionTarget => {
rhei_validator::parse_execution_target(rendered).map_err(|err| {
miette!(
help = format!(
"{err}.\n{}",
execution_target_repair_example(label, rendered)
),
"input '{}' is not a valid execution target: '{}'",
label,
rendered
)
})?;
Ok(())
}
}
}
fn execution_target_repair_example(label: &str, rendered: &str) -> String {
let example = rhei_validator::execution_target_example(rendered);
let nested = label.contains('[') || label.contains('.');
if !nested {
return format!("A corrected value for this input: {}", shell_assignment(label, &example));
}
let root = label.split(['[', '.']).next().unwrap_or(label);
format!(
"A corrected value for '{label}': {}. Supply it inside the whole '{root}' value, \
which is written as one YAML or JSON literal.",
shell_quote(&example)
)
}
fn list_inputs_command(template_ref: &str) -> String {
shell_command(["rhei", "instantiate", template_ref, "--list-inputs"])
}
#[allow(clippy::too_many_arguments)]
fn missing_template_inputs_report(
manifest: &TemplateManifest,
template_ref: &str,
missing: &[&TemplateInputDef],
values_files: &[PathBuf],
input_args: &[String],
set_values: &[String],
set_files: &[String],
) -> Report {
let listed = missing
.iter()
.map(|input| {
format!(" {} ({}) — {}", input.name, input.value_type().as_str(), input.description)
})
.collect::<Vec<_>>()
.join("\n");
let placeholders = missing
.iter()
.map(|input| format!("{}={}", input.name, template_input_placeholder(input)))
.collect::<Vec<_>>();
let command = format_template_instantiation_command(
template_ref,
input_args,
set_values,
set_files,
values_files,
None,
&placeholders,
);
let long_value_hint = missing
.iter()
.filter(|input| matches!(input.value_type(), TemplateInputType::String))
.max_by_key(|input| input.description.chars().count())
.map(|input| {
format!(
"\nFor a long value, read it from a file: --set-file {}=<path>",
input.name
)
})
.unwrap_or_default();
let noun = if missing.len() == 1 { "input" } else { "inputs" };
miette!(
help = format!(
"replace each <…> and run:\n {command}{long_value_hint}\nList every input \
with: {}",
list_inputs_command(template_ref)
),
"template '{}' is missing {} required {noun}:\n{listed}",
manifest.name,
missing.len()
)
}
fn template_input_placeholder(input: &TemplateInputDef) -> String {
match input.value_type() {
TemplateInputType::Number => "<number>".to_string(),
TemplateInputType::Boolean => "<true|false>".to_string(),
TemplateInputType::Path => "<path>".to_string(),
TemplateInputType::Array => "<[item, item]>".to_string(),
TemplateInputType::Object => "<{key: value}>".to_string(),
TemplateInputType::String => "<value>".to_string(),
}
}
fn validate_resolved_value(
label: &str,
schema: &TemplateValueSchema,
value: &serde_json::Value,
) -> MietteResult<()> {
if let Some(pattern) = schema.validate.as_deref() {
let regex = compile_full_match_regex(pattern).map_err(|err| {
miette!(
help = internal_error_help(),
"input '{}' has invalid validate regex: {err}",
label
)
})?;
let rendered = scalar_template_value_as_string(value).ok_or_else(|| {
miette!(
help = internal_error_help(),
"input '{}' uses validate but did not resolve to a scalar string value",
label
)
})?;
if !regex.is_match(&rendered) {
return Err(miette!(
help = format!("'{rendered}' is rejected by the input's own pattern."),
"input '{}' does not match validation pattern '{}'",
label,
pattern
));
}
}
if let Some(format) = schema.format {
let rendered = scalar_template_value_as_string(value).ok_or_else(|| {
miette!(
help = internal_error_help(),
"input '{}' uses format but did not resolve to a scalar value",
label
)
})?;
validate_template_input_format(label, format, &rendered)?;
}
match schema.value_type {
TemplateInputType::Array => {
if let (Some(items), serde_json::Value::Array(elements)) =
(schema.items.as_deref(), value)
{
for (idx, element) in elements.iter().enumerate() {
validate_resolved_value(
&format!("{label}[{idx}]"),
items,
element,
)?;
}
}
}
TemplateInputType::Object => {
if let serde_json::Value::Object(map) = value {
for (property, property_schema) in &schema.properties {
if let Some(element) = map.get(property) {
validate_resolved_value(
&format!("{label}.{property}"),
property_schema,
element,
)?;
}
}
}
}
_ => {}
}
Ok(())
}