nils-api-websocket 0.4.1

CLI crate for nils-api-websocket in the nils-cli workspace.
use std::io::Write;
use std::path::{Path, PathBuf};

use api_testing_core::cli_history::resolve_history_file;
use api_testing_core::config;

use crate::cli::{HistoryArgs, OutputFormat};
use api_testing_core::cli_util::trim_non_empty;

const HISTORY_SCHEMA_VERSION: &str = "cli.api-websocket.history.v1";

pub(crate) fn cmd_history(
    args: &HistoryArgs,
    invocation_dir: &Path,
    stdout: &mut dyn Write,
    stderr: &mut dyn Write,
) -> i32 {
    let config_dir = args
        .config_dir
        .as_deref()
        .and_then(trim_non_empty)
        .map(PathBuf::from);
    let history_file = match resolve_history_file(
        invocation_dir,
        config_dir.as_deref(),
        args.file.as_deref(),
        "WS_HISTORY_FILE",
        |cwd, config_dir| {
            config::resolve_websocket_setup_dir_for_history(cwd, invocation_dir, config_dir)
        },
        ".ws_history",
    ) {
        Ok(v) => v,
        Err(err) => {
            return fail_history(
                args,
                stdout,
                stderr,
                1,
                "history_resolve_error",
                &format!("{err}"),
                None,
            );
        }
    };

    if !history_file.is_file() {
        return fail_history(
            args,
            stdout,
            stderr,
            1,
            "history_not_found",
            &format!("History file not found: {}", history_file.display()),
            Some(serde_json::json!({
                "history_file": history_file.to_string_lossy(),
            })),
        );
    }

    let records = match api_testing_core::history::read_records(&history_file) {
        Ok(v) => v,
        Err(err) => {
            return fail_history(
                args,
                stdout,
                stderr,
                1,
                "history_read_error",
                &format!("{err}"),
                None,
            );
        }
    };

    if records.is_empty() {
        return fail_history(
            args,
            stdout,
            stderr,
            3,
            "history_empty",
            "History file is empty",
            Some(serde_json::json!({
                "history_file": history_file.to_string_lossy(),
            })),
        );
    }

    let n = if args.last {
        1
    } else {
        args.tail.unwrap_or(1).max(1) as usize
    };
    let start = records.len().saturating_sub(n);
    let selected: Vec<String> = records[start..]
        .iter()
        .map(|record| render_record(record, args.command_only))
        .collect();

    if matches!(args.format, OutputFormat::Json) {
        let payload = serde_json::json!({
            "schema_version": HISTORY_SCHEMA_VERSION,
            "command": "api-websocket history",
            "ok": true,
            "result": {
                "history_file": history_file.to_string_lossy(),
                "count": selected.len(),
                "records": selected,
            }
        });
        let _ = serde_json::to_writer_pretty(&mut *stdout, &payload);
        let _ = stdout.write_all(b"\n");
        return 0;
    }

    for record in selected {
        let _ = stdout.write_all(record.as_bytes());
    }

    0
}

fn render_record(record: &str, command_only: bool) -> String {
    if !command_only || !record.starts_with('#') {
        return record.to_string();
    }

    let trimmed = record
        .split_once('\n')
        .map(|(_first, rest)| rest)
        .unwrap_or_default();

    if trimmed.is_empty() {
        "\n\n".to_string()
    } else {
        trimmed.to_string()
    }
}

fn fail_history(
    args: &HistoryArgs,
    stdout: &mut dyn Write,
    stderr: &mut dyn Write,
    exit_code: i32,
    error_code: &str,
    message: &str,
    details: Option<serde_json::Value>,
) -> i32 {
    if matches!(args.format, OutputFormat::Json) {
        let mut error = serde_json::json!({
            "code": error_code,
            "message": message,
        });
        if let Some(details) = details {
            error["details"] = details;
        }
        let payload = serde_json::json!({
            "schema_version": HISTORY_SCHEMA_VERSION,
            "command": "api-websocket history",
            "ok": false,
            "error": error,
        });
        let _ = serde_json::to_writer_pretty(&mut *stdout, &payload);
        let _ = stdout.write_all(b"\n");
    } else {
        let _ = writeln!(stderr, "{message}");
    }
    exit_code
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn render_record_returns_original_when_not_command_only() {
        let input = "# meta\napi-websocket call \\\n  req.ws.json\n\n";
        assert_eq!(render_record(input, false), input);
    }

    #[test]
    fn render_record_strips_metadata_line_in_command_only_mode() {
        let input = "# meta\napi-websocket call \\\n  req.ws.json\n\n";
        let rendered = render_record(input, true);
        assert!(rendered.starts_with("api-websocket call"));
        assert!(!rendered.starts_with("#"));
    }
}