use crate::profile::{load_profile, Profile};
use crate::{CliError, CliResult};
use clap::Args;
use serde_norway::Value;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Args, Clone)]
pub struct ComposeArgs {
#[arg(long)]
pub profile: Option<PathBuf>,
#[arg(long = "from")]
pub from: Option<String>,
#[arg(long)]
pub to: Option<String>,
#[arg(long)]
pub mid: Option<String>,
#[arg(long = "type")]
pub message_type: Option<String>,
#[arg(long)]
pub shorthand: Option<String>,
#[arg(long)]
pub r#ref: Option<String>,
#[arg(long)]
pub re: Option<String>,
#[arg(long)]
pub due: Option<String>,
#[arg(long)]
pub mode: Option<String>,
#[arg(long)]
pub transport: Option<String>,
#[arg(long)]
pub body: Option<String>,
#[arg(long)]
pub body_file: Option<PathBuf>,
#[arg(long)]
pub field: Vec<String>,
#[arg(long)]
pub var: Vec<String>,
}
pub struct ComposedMessage {
pub message: String,
pub message_type: String,
pub mode: Option<String>,
}
pub fn run(args: ComposeArgs) -> CliResult<()> {
let profile = load_profile(args.profile.as_deref())?;
let composed = build_message(&args, &profile)?;
println!("{}", composed.message);
Ok(())
}
pub fn build_message(args: &ComposeArgs, profile: &Profile) -> CliResult<ComposedMessage> {
let shorthand = match args.shorthand.as_deref() {
Some(name) => Some(
profile
.shorthand
.get(name)
.ok_or_else(|| CliError::usage(format!("unknown shorthand: {name}")))?,
),
None => None,
};
let message_type = args
.message_type
.clone()
.or_else(|| shorthand.and_then(|item| item.r#type.clone()))
.ok_or_else(|| CliError::usage("--type is required unless --shorthand provides one"))?;
let mode = args
.mode
.clone()
.or_else(|| shorthand.and_then(|item| item.mode.clone()));
let mut field_values = BTreeMap::from([
("from".to_string(), args.from.clone()),
("to".to_string(), args.to.clone()),
("mid".to_string(), args.mid.clone()),
("type".to_string(), Some(message_type.clone())),
("ref".to_string(), args.r#ref.clone()),
("re".to_string(), args.re.clone()),
("due".to_string(), args.due.clone()),
("mode".to_string(), mode.clone()),
]);
for (key, value) in parse_key_values(&args.field, "--field")? {
field_values.insert(key, Some(value));
}
for field in &profile.message.required_header_fields {
if value_missing(field_values.get(field)) {
let flag = if field == "from" {
"--from".to_string()
} else {
format!("--{field}")
};
return Err(CliError::usage(format!("{flag} is required")));
}
}
validate_required_fields(&message_type, &field_values, profile)?;
let body = if let Some(path) = &args.body_file {
fs::read_to_string(path).map_err(|error| {
CliError::failure(format!(
"failed to read body file {}: {error}",
path.display()
))
})?
} else if let Some(body) = &args.body {
body.clone()
} else if let Some(shorthand) = shorthand {
let mut template_values: HashMap<String, String> = field_values
.iter()
.filter_map(|(key, value)| value.as_ref().map(|value| (key.clone(), value.clone())))
.collect();
template_values.extend(parse_key_values(&args.var, "--var")?);
render_template(&shorthand.body_template, &template_values)?
} else {
return Err(CliError::usage(
"--body, --body-file, or --shorthand is required",
));
};
let body = body.lines().collect::<Vec<_>>().join(" ");
let from = field_values
.get("from")
.and_then(Option::as_ref)
.ok_or_else(|| CliError::usage("--from is required"))?;
let transport = args
.transport
.as_deref()
.unwrap_or(profile.transport.default.as_str());
let prefix = profile
.message
.human_prefix_template
.replace("{agent_id}", agent_id(from))
.replace("{transport}", transport);
let mut header_parts = Vec::new();
for field in ["from", "to", "mid", "type", "ref", "re", "due", "mode"] {
if let Some(Some(value)) = field_values.get(field) {
header_parts.push(format!("{field}={value}"));
}
}
let ordered: BTreeSet<&str> = ["from", "to", "mid", "type", "ref", "re", "due", "mode"]
.into_iter()
.collect();
for (field, value) in &field_values {
if !ordered.contains(field.as_str()) {
if let Some(value) = value {
header_parts.push(format!("{field}={value}"));
}
}
}
let message = format!(
"{prefix} [{} {}] BODY: {body}",
profile.message.structured_header_opener,
header_parts.join(" ")
);
Ok(ComposedMessage {
message,
message_type,
mode,
})
}
fn value_missing(value: Option<&Option<String>>) -> bool {
value.and_then(Option::as_deref).unwrap_or("").is_empty()
}
fn agent_id(address: &str) -> &str {
address
.split_once(':')
.map(|(agent, _)| agent)
.unwrap_or(address)
}
fn parse_key_values(items: &[String], flag: &str) -> CliResult<HashMap<String, String>> {
let mut values = HashMap::new();
for item in items {
let Some((key, value)) = item.split_once('=') else {
return Err(CliError::usage(format!(
"{flag} expects key=value, got {item:?}"
)));
};
if key.is_empty() {
return Err(CliError::usage(format!("{flag} key cannot be empty")));
}
values.insert(key.to_string(), value.to_string());
}
Ok(values)
}
fn validate_required_fields(
message_type: &str,
fields: &BTreeMap<String, Option<String>>,
profile: &Profile,
) -> CliResult<()> {
let Some(spec) = profile
.message_types
.per_type_required_fields
.get(message_type)
else {
return Ok(());
};
match spec {
Value::Sequence(fields_required) => {
for field in fields_required.iter().filter_map(Value::as_str) {
if value_missing(fields.get(field)) {
return Err(CliError::usage(format!("{message_type} requires {field}")));
}
}
}
Value::Mapping(mapping) => {
let any_of_key = Value::String("any_of".to_string());
if let Some(Value::Sequence(options)) = mapping.get(&any_of_key) {
let options: Vec<&str> = options.iter().filter_map(Value::as_str).collect();
if !options
.iter()
.any(|field| !value_missing(fields.get(*field)))
{
return Err(CliError::usage(format!(
"{message_type} requires one of: {}",
options.join(", ")
)));
}
}
}
_ => {}
}
Ok(())
}
fn render_template(template: &str, values: &HashMap<String, String>) -> CliResult<String> {
let mut output = String::new();
let mut chars = template.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '{' {
output.push(ch);
continue;
}
let mut name = String::new();
let mut closed = false;
for inner in chars.by_ref() {
if inner == '}' {
closed = true;
break;
}
name.push(inner);
}
if !closed {
return Err(CliError::usage("unclosed template variable"));
}
let Some(value) = values.get(&name) else {
return Err(CliError::usage(format!(
"missing template variables: {name}"
)));
};
output.push_str(value);
}
Ok(output)
}