zynk 1.0.1

Portable protocol and helper CLI for multi-agent collaboration.
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>,
}

/// A rendered wire message plus the header fields the renderer *resolved*
/// (`--type`/`--mode` may come from a shorthand). `send herdr`'s audited send
/// derives the audit from these same resolved values (ADR 029 C4), so file and
/// audit never drift from what was actually sent.
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)
}