use clap::{Args, ValueEnum};
use rite_model::{Ceremony, MaterialKind, MaterialSource};
use rite_render::{Branding, Theme};
use rite_resolver::CeremonyInputs;
use std::collections::HashMap;
use std::io::BufRead;
use std::path::{Path, PathBuf};
#[derive(Args, Debug)]
pub struct InputArgs {
#[arg(long = "param", value_name = "NAME=VALUE")]
pub params: Vec<String>,
#[arg(long = "role", value_name = "ROLE_ID=PERSON")]
pub roles: Vec<String>,
#[arg(long = "material", value_name = "NAME=PATH_OR_ID")]
pub materials: Vec<String>,
}
#[derive(Copy, Clone, Debug, Default, ValueEnum)]
pub enum ThemeArg {
#[default]
Formal,
}
impl From<ThemeArg> for Theme {
fn from(arg: ThemeArg) -> Self {
match arg {
ThemeArg::Formal => Theme::Formal,
}
}
}
#[derive(Args, Debug)]
pub struct BrandingArgs {
#[arg(long, value_name = "NAME")]
pub brand_name: Option<String>,
#[arg(long, value_name = "PATH")]
pub logo: Option<PathBuf>,
#[arg(long, value_name = "COLOR")]
pub accent: Option<String>,
}
pub fn build_branding_or_exit(args: &BrandingArgs) -> Branding {
let logo = args.logo.as_ref().map(|path| {
let bytes = std::fs::read(path).unwrap_or_else(|e| {
eprintln!("Failed to read logo {}: {e}", path.display());
std::process::exit(1);
});
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("logo")
.to_string();
(bytes, name)
});
Branding::from_inputs(
args.brand_name.clone(),
logo.as_ref()
.map(|(bytes, name)| (bytes.as_slice(), name.as_str())),
args.accent.as_deref(),
)
.unwrap_or_else(|e| {
eprintln!("Invalid argument: {e}");
std::process::exit(1);
})
}
pub fn default_output_path(source: &Path, extension: &str) -> PathBuf {
let name = source
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("ceremony");
let name = name
.strip_suffix(".yaml")
.or_else(|| name.strip_suffix(".yml"))
.unwrap_or(name);
let stem = name.strip_suffix(".rite").unwrap_or(name);
source.with_file_name(format!("{stem}.{extension}"))
}
pub fn write_document(content: &str, output: Option<&Path>, default: &Path) {
let target = match output {
Some(p) if p.as_os_str() == "-" => {
print!("{content}");
return;
}
Some(p) => p,
None => default,
};
if let Err(e) = std::fs::write(target, content) {
eprintln!("Failed to write output to {}: {e}", target.display());
std::process::exit(1);
}
eprintln!("Wrote {}", target.display());
}
pub fn parse_key_value(s: &str) -> Result<(String, String), String> {
let (key, value) = s
.split_once('=')
.ok_or_else(|| format!("expected NAME=VALUE, got '{s}'"))?;
if key.is_empty() {
return Err(format!("empty name in '{s}'"));
}
Ok((key.to_string(), value.to_string()))
}
pub fn parse_material_value(value: &str) -> MaterialSource {
if let Some(path) = value.strip_prefix('@') {
MaterialSource::File {
file: std::env::current_dir().unwrap_or_default().join(path),
}
} else {
MaterialSource::Identifier {
identifier: value.to_string(),
}
}
}
fn collect_env_vars() -> (
HashMap<String, serde_json::Value>,
HashMap<String, String>,
HashMap<String, MaterialSource>,
) {
let mut params = HashMap::new();
let mut roles = HashMap::new();
let mut materials = HashMap::new();
for (key, value) in std::env::vars() {
if let Some(name) = key.strip_prefix("RITE_PARAM_") {
params.insert(name.to_lowercase(), serde_json::Value::String(value));
} else if let Some(name) = key.strip_prefix("RITE_ROLE_") {
roles.insert(name.to_lowercase(), value);
} else if let Some(name) = key.strip_prefix("RITE_MATERIAL_") {
materials.insert(name.to_lowercase(), parse_material_value(&value));
}
}
(params, roles, materials)
}
pub fn build_inputs(
params: &[String],
roles: &[String],
materials: &[String],
) -> Result<CeremonyInputs, String> {
let (mut input_params, mut input_roles, mut input_materials) = collect_env_vars();
for s in params {
let (key, value) = parse_key_value(s)?;
input_params.insert(key.to_lowercase(), serde_json::Value::String(value));
}
for s in roles {
let (key, value) = parse_key_value(s)?;
input_roles.insert(key.to_lowercase(), value);
}
for s in materials {
let (key, value) = parse_key_value(s)?;
input_materials.insert(key.to_lowercase(), parse_material_value(&value));
}
Ok(CeremonyInputs {
parameters: input_params,
roles: input_roles,
materials: input_materials,
})
}
pub fn build_inputs_or_exit(input_args: &InputArgs) -> CeremonyInputs {
match build_inputs(&input_args.params, &input_args.roles, &input_args.materials) {
Ok(i) => i,
Err(e) => {
eprintln!("Invalid argument: {e}");
std::process::exit(1);
}
}
}
pub fn resolve_or_exit(path: &Path, inputs: Option<&CeremonyInputs>) -> Ceremony {
let (resolved_opt, diags) = rite_resolver::analyze(path, inputs);
for d in &diags {
eprintln!("{d}");
}
let has_errors = diags
.iter()
.any(|d| d.severity == rite_resolver::Severity::Error);
if has_errors {
std::process::exit(1);
}
resolved_opt.unwrap_or_else(|| {
eprintln!("Internal error: ceremony resolved to None with no errors");
std::process::exit(1);
})
}
pub fn preflight_check_materials(resolved: &rite_model::Ceremony) -> Result<(), String> {
for (id, material) in resolved.materials.iter() {
if let MaterialKind::Digital {
source: Some(MaterialSource::File { file }),
} = &material.kind
&& !file.exists()
{
return Err(format!(
"Material '{}': file not found: {}",
id.as_str(),
file.display()
));
}
}
Ok(())
}
pub fn prompt_missing_params(inputs: &mut CeremonyInputs, ceremony: &rite_model::Ceremony) {
let stdin = std::io::stdin();
for (id, param) in ceremony.parameters.iter() {
let name = id.as_str();
if inputs.parameters.contains_key(name) || !param.value.is_null() {
continue;
}
eprintln!();
eprintln!("Parameter required: {name}");
let type_label = match param.declared_type {
rite_model::ParameterType::String => "string",
rite_model::ParameterType::Date => "date",
rite_model::ParameterType::Integer => "integer",
rite_model::ParameterType::Boolean => "boolean",
_ => "unknown",
};
eprintln!(" Type: {type_label}");
if let Some(desc) = ¶m.description {
eprintln!(" Description: {desc}");
}
eprint!("Enter value: ");
let mut line = String::new();
if stdin.lock().read_line(&mut line).is_ok() {
let value = line.trim_end().to_string();
if !value.is_empty() {
inputs
.parameters
.insert(name.to_string(), serde_json::Value::String(value));
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_key_value_splits_on_first_equals() {
assert_eq!(
parse_key_value("key=value").unwrap(),
("key".to_string(), "value".to_string())
);
}
#[test]
fn parse_key_value_value_may_contain_equals() {
assert_eq!(
parse_key_value("key=val=ue").unwrap(),
("key".to_string(), "val=ue".to_string())
);
}
#[test]
fn parse_key_value_empty_key_is_error() {
assert!(parse_key_value("=value").is_err());
}
#[test]
fn parse_key_value_no_equals_is_error() {
assert!(parse_key_value("noequals").is_err());
}
#[test]
fn parse_material_value_at_prefix_is_file() {
let source = parse_material_value("@/some/path.pem");
assert!(matches!(source, MaterialSource::File { file } if file.ends_with("some/path.pem")));
}
#[test]
fn parse_material_value_no_at_is_identifier() {
let source = parse_material_value("my-identifier");
assert!(
matches!(source, MaterialSource::Identifier { identifier } if identifier == "my-identifier")
);
}
#[test]
fn default_output_path_strips_compound_rite_yaml() {
assert_eq!(
default_output_path(Path::new("pki/root_ca.rite.yaml"), "html"),
PathBuf::from("pki/root_ca.html")
);
}
#[test]
fn default_output_path_handles_plain_yaml_and_yml() {
assert_eq!(
default_output_path(Path::new("foo.yaml"), "html"),
PathBuf::from("foo.html")
);
assert_eq!(
default_output_path(Path::new("foo.rite.yml"), "typ"),
PathBuf::from("foo.typ")
);
}
#[test]
fn default_output_path_keeps_intermediate_dots() {
assert_eq!(
default_output_path(Path::new("my.config.rite.yaml"), "html"),
PathBuf::from("my.config.html")
);
}
#[test]
fn build_inputs_normalizes_cli_keys_to_lowercase() {
let inputs = build_inputs(
&["Param_Name=value".to_string()],
&["Role_ID=Alice".to_string()],
&["Material_ID=@file.pem".to_string()],
)
.expect("inputs build");
assert!(inputs.parameters.contains_key("param_name"));
assert!(inputs.roles.contains_key("role_id"));
assert!(inputs.materials.contains_key("material_id"));
}
}