zynk 0.2.1

Portable protocol and helper CLI for multi-agent collaboration.
use crate::profile::load_profile;
use crate::{CliError, CliResult};
use clap::Args;
use fs2::FileExt;
use rand::{rngs::OsRng, Rng};
use sha2::{Digest, Sha256};
use std::collections::HashSet;
use std::fs::{self, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::PathBuf;

#[derive(Debug, Args)]
pub struct AuditArgs {
    #[arg(long)]
    pub profile: Option<PathBuf>,
    #[arg(
        long,
        default_value = "outputs",
        help = "runtime outputs root; pass <project>/outputs to write <project>/outputs/sessions/..."
    )]
    pub root: PathBuf,
    #[arg(long)]
    pub session_id: String,
    #[arg(long)]
    pub audit_id: Option<String>,
    #[arg(long)]
    pub previous_audit_id: Option<String>,
    #[arg(long)]
    pub timestamp: Option<String>,
    #[arg(long)]
    pub source_agent: String,
    #[arg(long)]
    pub source_address: String,
    #[arg(long)]
    pub target_agent: String,
    #[arg(long)]
    pub target_address: String,
    #[arg(long)]
    pub transport: String,
    #[arg(long)]
    pub workspace_id: String,
    #[arg(long)]
    pub transport_thread_id: Option<String>,
    #[arg(long)]
    pub mid: String,
    #[arg(long = "type")]
    pub record_type: String,
    #[arg(long)]
    pub mode: Option<String>,
    #[arg(long)]
    pub r#ref: Option<String>,
    #[arg(long)]
    pub re: Option<String>,
    #[arg(long, value_parser = ["agent", "operator", "helper-tool", "unknown"])]
    pub command_origin: String,
    #[arg(long)]
    pub payload: Option<String>,
    #[arg(long)]
    pub payload_file: Option<PathBuf>,
    #[arg(long, default_value = "hash-only")]
    pub payload_redaction_policy: String,
    #[arg(long)]
    pub payload_ref: Option<String>,
    #[arg(long)]
    pub sensitive_category: Option<String>,
    #[arg(long, default_value_t = 12)]
    pub excerpt_chars: usize,
    #[arg(
        long,
        help = "delivery proof state; use drafted for message-shaped text that was not sent"
    )]
    pub delivery_status: String,
    #[arg(long)]
    pub observed_by: String,
    #[arg(long, value_parser = ["transport", "agent", "operator", "helper-tool"])]
    pub verified_by: String,
}

pub fn run(args: AuditArgs) -> CliResult<()> {
    let profile = load_profile(args.profile.as_deref())?;
    if args.payload.is_some() == args.payload_file.is_some() {
        return Err(CliError::usage(
            "exactly one of --payload or --payload-file is required",
        ));
    }
    if args.excerpt_chars < 1 {
        return Err(CliError::usage("--excerpt-chars must be >= 1"));
    }
    if !profile
        .audit
        .delivery_status_enum
        .contains(&args.delivery_status)
    {
        return Err(CliError::usage(format!(
            "invalid delivery status: {}",
            args.delivery_status
        )));
    }
    if args.delivery_status == "sent" && args.verified_by == "agent" {
        return Err(CliError::usage(
            "delivery_status=sent requires transport, helper-tool, or operator verification",
        ));
    }
    if !profile
        .audit
        .redaction_policy_enum
        .contains(&args.payload_redaction_policy)
    {
        return Err(CliError::usage(format!(
            "invalid redaction policy: {}",
            args.payload_redaction_policy
        )));
    }

    let audit_path = args
        .root
        .join("sessions")
        .join(&args.session_id)
        .join("audit.md");
    if let Some(parent) = audit_path.parent() {
        fs::create_dir_all(parent).map_err(|error| {
            CliError::failure(format!("failed to create {}: {error}", parent.display()))
        })?;
    }

    let mut handle = OpenOptions::new()
        .read(true)
        .write(true)
        .create(true)
        .truncate(false)
        .open(&audit_path)
        .map_err(|error| {
            CliError::failure(format!("failed to open {}: {error}", audit_path.display()))
        })?;
    handle.lock_exclusive().map_err(|error| {
        CliError::failure(format!("failed to lock {}: {error}", audit_path.display()))
    })?;
    let result = write_locked_record(&args, &profile, &mut handle);
    let unlock_result = handle.unlock();
    if let Err(error) = unlock_result {
        return Err(CliError::failure(format!(
            "failed to unlock {}: {error}",
            audit_path.display()
        )));
    }
    result?;

    println!("{}", audit_path.display());
    Ok(())
}

fn write_locked_record(
    args: &AuditArgs,
    profile: &crate::profile::Profile,
    handle: &mut std::fs::File,
) -> CliResult<()> {
    handle
        .seek(SeekFrom::Start(0))
        .map_err(|error| CliError::failure(format!("failed to read audit file: {error}")))?;
    let mut existing_content = String::new();
    handle
        .read_to_string(&mut existing_content)
        .map_err(|error| CliError::failure(format!("failed to read audit file: {error}")))?;
    let existing_ids = audit_ids_from_text(&existing_content);
    let existing_id_set = existing_ids.iter().cloned().collect::<HashSet<_>>();
    let audit_id = if let Some(audit_id) = &args.audit_id {
        if existing_id_set.contains(audit_id) {
            return Err(CliError::usage(format!("duplicate audit_id: {audit_id}")));
        }
        audit_id.clone()
    } else {
        generate_audit_id(&existing_id_set)?
    };
    let previous_audit_id = args
        .previous_audit_id
        .clone()
        .unwrap_or_else(|| last_audit_id(&existing_ids));
    let record = render_record(args, profile, &previous_audit_id, &audit_id)?;

    handle
        .seek(SeekFrom::End(0))
        .map_err(|error| CliError::failure(format!("failed to append audit file: {error}")))?;
    if existing_content.is_empty() {
        write!(
            handle,
            "# Audit Trail: {}\n\nsession_id: {}\n\n## Records\n\n",
            args.session_id, args.session_id
        )
        .map_err(|error| CliError::failure(format!("failed to write audit header: {error}")))?;
    }
    writeln!(handle, "{record}")
        .map_err(|error| CliError::failure(format!("failed to write audit record: {error}")))?;
    Ok(())
}

fn render_record(
    args: &AuditArgs,
    profile: &crate::profile::Profile,
    previous_audit_id: &str,
    audit_id: &str,
) -> CliResult<String> {
    let mut policy = args.payload_redaction_policy.clone();
    if let Some(category) = &args.sensitive_category {
        if profile.audit.force_hash_only_categories.contains(category) {
            policy = "hash-only".to_string();
        }
    }
    let payload = payload_from_args(args)?;
    let payload_bytes = payload.as_bytes();
    let digest = Sha256::digest(payload_bytes);
    let timestamp = args
        .timestamp
        .clone()
        .unwrap_or_else(crate::timestamp::now_utc_seconds);
    let excerpt = payload_excerpt(&payload, args.excerpt_chars, &policy);

    let mut fields: Vec<(String, String)> = vec![
        ("audit_id".to_string(), audit_id.to_string()),
        (
            "previous_audit_id".to_string(),
            previous_audit_id.to_string(),
        ),
        ("timestamp".to_string(), timestamp),
        ("source_agent".to_string(), args.source_agent.clone()),
        ("source_address".to_string(), args.source_address.clone()),
        ("target_agent".to_string(), args.target_agent.clone()),
        ("target_address".to_string(), args.target_address.clone()),
        ("transport".to_string(), args.transport.clone()),
        ("workspace_id".to_string(), args.workspace_id.clone()),
        ("session_id".to_string(), args.session_id.clone()),
        ("mid".to_string(), args.mid.clone()),
        ("type".to_string(), args.record_type.clone()),
        ("command_origin".to_string(), args.command_origin.clone()),
        ("payload_hash".to_string(), format!("sha256:{digest:x}")),
        ("payload_redaction_policy".to_string(), policy),
        ("content_size".to_string(), payload_bytes.len().to_string()),
        ("delivery_status".to_string(), args.delivery_status.clone()),
        ("observed_by".to_string(), args.observed_by.clone()),
        ("verified_by".to_string(), args.verified_by.clone()),
    ];
    for (key, value) in [
        ("transport_thread_id", args.transport_thread_id.as_ref()),
        ("mode", args.mode.as_ref()),
        ("ref", args.r#ref.as_ref()),
        ("re", args.re.as_ref()),
        ("payload_ref", args.payload_ref.as_ref()),
        ("payload_excerpt", excerpt.as_ref()),
    ] {
        if let Some(value) = value {
            fields.push((key.to_string(), value.clone()));
        }
    }

    let record = fields
        .into_iter()
        .map(|(key, value)| format!("{key}={value}"))
        .collect::<Vec<_>>()
        .join("\n");
    Ok(format!("```text\n{record}\n```"))
}

fn payload_from_args(args: &AuditArgs) -> CliResult<String> {
    if let Some(path) = &args.payload_file {
        return fs::read_to_string(path).map_err(|error| {
            CliError::failure(format!(
                "failed to read payload file {}: {error}",
                path.display()
            ))
        });
    }
    Ok(args.payload.clone().unwrap_or_default())
}

fn payload_excerpt(payload: &str, chars: usize, policy: &str) -> Option<String> {
    match policy {
        "hash-only" => None,
        "full" => Some(payload.to_string()),
        _ => {
            let count = payload.chars().count();
            if count <= chars * 2 {
                Some(payload.to_string())
            } else {
                let start = payload.chars().take(chars).collect::<String>();
                let end = payload
                    .chars()
                    .rev()
                    .take(chars)
                    .collect::<String>()
                    .chars()
                    .rev()
                    .collect::<String>();
                Some(format!("{start}...{end}"))
            }
        }
    }
}

fn audit_ids_from_text(content: &str) -> Vec<String> {
    content
        .lines()
        .filter_map(|line| line.trim().strip_prefix("audit_id="))
        .filter(|id| {
            id.chars()
                .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit())
        })
        .map(str::to_string)
        .collect()
}

fn last_audit_id(existing_ids: &[String]) -> String {
    existing_ids
        .iter()
        .last()
        .cloned()
        .unwrap_or_else(|| "none".to_string())
}

fn generate_audit_id(existing_ids: &HashSet<String>) -> CliResult<String> {
    const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
    let mut rng = OsRng;
    for _ in 0..100 {
        let audit_id = (0..6)
            .map(|_| {
                let index = rng.gen_range(0..ALPHABET.len());
                ALPHABET[index] as char
            })
            .collect::<String>();
        if !existing_ids.contains(&audit_id) {
            return Ok(audit_id);
        }
    }
    Err(CliError::usage(
        "could not generate unique audit_id after 100 attempts",
    ))
}