roswire 0.1.0

JSON-first RouterOS CLI bridge for AI agents and automation.
use crate::args::{Cli, ParsedInvocation};
use crate::error::{RosWireError, RosWireResult};
use serde::Serialize;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

const SCRIPT_PUT_PLAN_SCHEMA_VERSION: &str = "roswire.workflow.script.put.plan.v1";
const MAX_SCRIPT_SOURCE_BYTES: u64 = 256 * 1024;

#[derive(Debug, Default)]
pub struct WorkflowModule;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkflowResult {
    Payload(String),
    Invocation(ParsedInvocation),
}

#[derive(Debug, Serialize)]
struct ScriptPutPlan {
    schema_version: &'static str,
    operation: &'static str,
    dry_run: bool,
    script_name: String,
    source_path: String,
    source_bytes: u64,
    routeros_command: &'static str,
    routeros_rest_path: &'static str,
    side_effects: Vec<&'static str>,
    routeros_file_created: bool,
    content_redacted: bool,
}

pub fn handle(tokens: &[String], cli: &Cli) -> Option<RosWireResult<WorkflowResult>> {
    match tokens {
        [script, put, name] if script == "script" && put == "put" => {
            Some(handle_script_put(name, cli))
        }
        [script, put, ..] if script == "script" && put == "put" => Some(Err(Box::new(
            RosWireError::usage(
                "script put requires exactly one script name: roswire script put <name> --source @<local.rsc>",
            ),
        ))),
        _ => None,
    }
}

fn handle_script_put(name: &str, cli: &Cli) -> RosWireResult<WorkflowResult> {
    if name.trim().is_empty() {
        return Err(Box::new(RosWireError::usage(
            "script put requires a non-empty script name",
        )));
    }

    let source_path = source_path_from_cli(cli)?;
    if cli.dry_run {
        return script_put_plan(name, &source_path).map(WorkflowResult::Payload);
    }

    let source = read_script_source(&source_path)?;
    Ok(WorkflowResult::Invocation(ParsedInvocation {
        path: vec!["system".to_owned(), "script".to_owned()],
        action: "add".to_owned(),
        resolved_args: BTreeMap::from([
            ("name".to_owned(), name.to_owned()),
            ("source".to_owned(), source),
        ]),
    }))
}

fn script_put_plan(name: &str, source_path: &Path) -> RosWireResult<String> {
    let source_bytes = validated_script_file_len(source_path)?;
    render_json(&ScriptPutPlan {
        schema_version: SCRIPT_PUT_PLAN_SCHEMA_VERSION,
        operation: "script.put",
        dry_run: true,
        script_name: name.to_owned(),
        source_path: redact_local_path(source_path),
        source_bytes,
        routeros_command: "/system/script/add",
        routeros_rest_path: "/rest/system/script",
        side_effects: vec!["creates-routeros-script"],
        routeros_file_created: false,
        content_redacted: true,
    })
}

fn source_path_from_cli(cli: &Cli) -> RosWireResult<PathBuf> {
    let source = cli.source.as_deref().ok_or_else(|| {
        Box::new(RosWireError::usage(
            "missing script source: use --source @<local.rsc>",
        ))
    })?;

    let path = source.strip_prefix('@').ok_or_else(|| {
        Box::new(RosWireError::usage(
            "script source must reference a local file with @<local.rsc>",
        ))
    })?;

    if path.trim().is_empty() {
        return Err(Box::new(RosWireError::usage(
            "script source path cannot be empty",
        )));
    }

    Ok(PathBuf::from(path))
}

fn read_script_source(path: &Path) -> RosWireResult<String> {
    validated_script_file_len(path)?;
    let bytes = fs::read(path).map_err(|error| {
        Box::new(RosWireError::usage(format!(
            "failed to read script source {}: {error}",
            redact_local_path(path),
        )))
    })?;

    if bytes.len() as u64 > MAX_SCRIPT_SOURCE_BYTES {
        return Err(Box::new(RosWireError::file_too_large(format!(
            "script source {} exceeds {} bytes",
            redact_local_path(path),
            MAX_SCRIPT_SOURCE_BYTES,
        ))));
    }

    String::from_utf8(bytes).map_err(|error| {
        Box::new(
            RosWireError::usage(format!(
                "script source {} must be UTF-8 text: {error}",
                redact_local_path(path),
            ))
            .with_hint("save the .rsc file as UTF-8 text"),
        )
    })
}

fn validated_script_file_len(path: &Path) -> RosWireResult<u64> {
    let metadata = fs::metadata(path).map_err(|error| {
        Box::new(RosWireError::usage(format!(
            "failed to inspect script source {}: {error}",
            redact_local_path(path),
        )))
    })?;

    if !metadata.is_file() {
        return Err(Box::new(RosWireError::usage(format!(
            "script source must be a file: {}",
            redact_local_path(path),
        ))));
    }

    let len = metadata.len();
    if len > MAX_SCRIPT_SOURCE_BYTES {
        return Err(Box::new(RosWireError::file_too_large(format!(
            "script source {} exceeds {} bytes",
            redact_local_path(path),
            MAX_SCRIPT_SOURCE_BYTES,
        ))));
    }

    Ok(len)
}

fn redact_local_path(path: &Path) -> String {
    if path.is_absolute() {
        let file_name = path
            .file_name()
            .and_then(|value| value.to_str())
            .unwrap_or("source.rsc");
        format!("***REDACTED***/{file_name}")
    } else {
        path.display().to_string()
    }
}

fn render_json<T: Serialize>(value: &T) -> RosWireResult<String> {
    serde_json::to_string_pretty(value).map_err(|error| {
        Box::new(RosWireError::internal(format!(
            "failed to serialize workflow payload: {error}",
        )))
    })
}

#[cfg(test)]
mod tests {
    use super::{handle, WorkflowResult, MAX_SCRIPT_SOURCE_BYTES};
    use crate::args::Cli;
    use crate::error::ErrorCode;
    use clap::Parser;
    use std::fs;

    #[test]
    fn script_put_dry_run_plan_redacts_path_and_content() {
        let temp = tempfile::tempdir().expect("temp dir should be created");
        let source = temp.path().join("bootstrap.rsc");
        let script = ":put \"VERY_SECRET_SCRIPT\"";
        fs::write(&source, script).expect("source should be written");
        let source_arg = format!("@{}", source.display());
        let cli = Cli::try_parse_from([
            "roswire",
            "script",
            "put",
            "bootstrap",
            "--source",
            &source_arg,
            "--dry-run",
            "--json",
        ])
        .expect("cli should parse");

        let result = handle(&cli.tokens, &cli)
            .expect("workflow should match")
            .expect("dry-run should succeed");

        let WorkflowResult::Payload(payload) = result else {
            panic!("dry-run should return payload");
        };
        assert!(payload.contains("roswire.workflow.script.put.plan.v1"));
        assert!(payload.contains("\"script_name\": \"bootstrap\""));
        assert!(payload.contains("***REDACTED***/bootstrap.rsc"));
        assert!(payload.contains("\"routeros_file_created\": false"));
        assert!(!payload.contains(temp.path().to_string_lossy().as_ref()));
        assert!(!payload.contains(script));
    }

    #[test]
    fn script_put_actual_invocation_reads_utf8_source() {
        let temp = tempfile::tempdir().expect("temp dir should be created");
        let source = temp.path().join("bootstrap.rsc");
        let script = ":put \"hello\"\n";
        fs::write(&source, script).expect("source should be written");
        let source_arg = format!("@{}", source.display());
        let cli = Cli::try_parse_from([
            "roswire",
            "script",
            "put",
            "bootstrap",
            "--source",
            &source_arg,
            "--json",
        ])
        .expect("cli should parse");

        let result = handle(&cli.tokens, &cli)
            .expect("workflow should match")
            .expect("script put should succeed");

        let WorkflowResult::Invocation(invocation) = result else {
            panic!("actual script put should return invocation");
        };
        assert_eq!(invocation.path, vec!["system", "script"]);
        assert_eq!(invocation.action, "add");
        assert_eq!(
            invocation.resolved_args.get("name").map(String::as_str),
            Some("bootstrap"),
        );
        assert_eq!(
            invocation.resolved_args.get("source").map(String::as_str),
            Some(script),
        );
    }

    #[test]
    fn script_put_requires_at_source_path() {
        let cli = Cli::try_parse_from([
            "roswire",
            "script",
            "put",
            "bootstrap",
            "--source",
            "setup.rsc",
            "--json",
        ])
        .expect("cli should parse");

        let error = handle(&cli.tokens, &cli)
            .expect("workflow should match")
            .expect_err("source should require @ prefix");

        assert_eq!(error.error_code, ErrorCode::UsageError);
        assert!(error.message.contains("@<local.rsc>"));
    }

    #[test]
    fn script_put_reports_too_large_without_reading_content() {
        let temp = tempfile::tempdir().expect("temp dir should be created");
        let source = temp.path().join("large.rsc");
        fs::write(&source, vec![b'a'; MAX_SCRIPT_SOURCE_BYTES as usize + 1])
            .expect("large source should be written");
        let source_arg = format!("@{}", source.display());
        let cli = Cli::try_parse_from([
            "roswire",
            "script",
            "put",
            "bootstrap",
            "--source",
            &source_arg,
            "--json",
        ])
        .expect("cli should parse");

        let error = handle(&cli.tokens, &cli)
            .expect("workflow should match")
            .expect_err("large source should fail");

        assert_eq!(error.error_code, ErrorCode::FileTooLarge);
        assert!(!error
            .message
            .contains(temp.path().to_string_lossy().as_ref()));
    }

    #[test]
    fn script_put_rejects_non_utf8_source() {
        let temp = tempfile::tempdir().expect("temp dir should be created");
        let source = temp.path().join("binary.rsc");
        fs::write(&source, [0xff, 0xfe]).expect("binary source should be written");
        let source_arg = format!("@{}", source.display());
        let cli = Cli::try_parse_from([
            "roswire",
            "script",
            "put",
            "bootstrap",
            "--source",
            &source_arg,
            "--json",
        ])
        .expect("cli should parse");

        let error = handle(&cli.tokens, &cli)
            .expect("workflow should match")
            .expect_err("non-utf8 source should fail");

        assert_eq!(error.error_code, ErrorCode::UsageError);
        assert!(error.message.contains("UTF-8"));
        assert!(!error
            .message
            .contains(temp.path().to_string_lossy().as_ref()));
    }
}