zacor 0.1.0

Package manager and dispatcher for zr — install, manage, and run modular CLI packages
Documentation
//! Capability provider — handles CAPABILITY_REQ from modules by executing
//! operations locally. Used by the protocol dispatcher (CLI transport) and
//! the HTTP server.

use serde_json::json;
use std::path::Path;
use zacor_package::io::fs::FileType;
use zacor_package::protocol::{self, CapabilityError, CapabilityReq, CapabilityRes, CapabilityResult};

/// Handle a CAPABILITY_REQ and return the corresponding CAPABILITY_RES.
pub fn handle(req: &CapabilityReq) -> CapabilityRes {
    let result = match req.domain.as_str() {
        "fs" => handle_fs(&req.op, &req.params),
        "clipboard" => handle_clipboard(&req.op, &req.params),
        "prompt" => handle_prompt(&req.op, &req.params),
        _ => Err(std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            format!("unknown capability domain: {}", req.domain),
        )),
    };

    CapabilityRes {
        id: req.id,
        result: match result {
            Ok(data) => CapabilityResult::Ok { data },
            Err(e) => CapabilityResult::Error {
                error: CapabilityError::from_io(&e),
            },
        },
    }
}

// ─── Filesystem ──────────────────────────────────────────────────────

fn resolve_path(path_str: &str) -> String {
    let cwd = std::env::current_dir()
        .map(|p| p.to_string_lossy().into_owned())
        .unwrap_or_default();
    let resolved = protocol::resolve_path(path_str, &cwd);
    resolved.replace('/', std::path::MAIN_SEPARATOR_STR)
}

fn handle_fs(op: &str, params: &serde_json::Value) -> std::io::Result<serde_json::Value> {
    let path_str = params
        .get("path")
        .and_then(|v| v.as_str())
        .unwrap_or("");
    let resolved = resolve_path(path_str);

    match op {
        "read_string" => {
            let content = std::fs::read_to_string(&resolved)?;
            Ok(json!({"content": content}))
        }
        "read" => {
            let content = std::fs::read(&resolved)?;
            let encoded = protocol::base64_encode(&content);
            Ok(json!({"content": encoded}))
        }
        "read_dir" => {
            let mut entries = Vec::new();
            for entry in std::fs::read_dir(&resolved)? {
                let entry = entry?;
                let meta = entry.metadata()?;
                let ft: FileType = meta.file_type().into();
                let mut e = json!({
                    "name": entry.file_name().to_string_lossy(),
                    "size": meta.len(),
                    "file_type": ft,
                });
                if let Ok(modified) = meta.modified() {
                    if let Ok(epoch) = modified.duration_since(std::time::UNIX_EPOCH) {
                        e["modified"] = json!(epoch.as_secs_f64());
                    }
                }
                entries.push(e);
            }
            Ok(json!({"entries": entries}))
        }
        "stat" => {
            let meta = std::fs::metadata(&resolved)?;
            let ft: FileType = meta.file_type().into();
            let mut result = json!({"size": meta.len(), "file_type": ft});
            if let Ok(modified) = meta.modified() {
                if let Ok(epoch) = modified.duration_since(std::time::UNIX_EPOCH) {
                    result["modified"] = json!(epoch.as_secs_f64());
                }
            }
            if let Ok(created) = meta.created() {
                if let Ok(epoch) = created.duration_since(std::time::UNIX_EPOCH) {
                    result["created"] = json!(epoch.as_secs_f64());
                }
            }
            Ok(result)
        }
        "exists" => Ok(json!({"exists": Path::new(&resolved).exists()})),
        "write" => {
            let content = params
                .get("content")
                .and_then(|v| v.as_str())
                .unwrap_or("");
            let decoded = protocol::base64_decode(content)?;
            std::fs::write(&resolved, decoded)?;
            Ok(json!({}))
        }
        _ => Err(std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            format!("unknown fs operation: {}", op),
        )),
    }
}

// ─── Clipboard ───────────────────────────────────────────────────────

fn handle_clipboard(
    op: &str,
    params: &serde_json::Value,
) -> std::io::Result<serde_json::Value> {
    match op {
        "read" => {
            let mut clipboard = arboard::Clipboard::new()
                .map_err(|e| std::io::Error::other(e.to_string()))?;
            let text = clipboard
                .get_text()
                .map_err(|e| std::io::Error::other(e.to_string()))?;
            Ok(json!({"text": text}))
        }
        "write" => {
            let text = params
                .get("text")
                .and_then(|v| v.as_str())
                .unwrap_or("");
            let mut clipboard = arboard::Clipboard::new()
                .map_err(|e| std::io::Error::other(e.to_string()))?;
            clipboard
                .set_text(text.to_string())
                .map_err(|e| std::io::Error::other(e.to_string()))?;
            Ok(json!({}))
        }
        _ => Err(std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            format!("unknown clipboard operation: {}", op),
        )),
    }
}

// ─── Prompt ──────────────────────────────────────────────────────────

fn handle_prompt(
    op: &str,
    params: &serde_json::Value,
) -> std::io::Result<serde_json::Value> {
    use std::io::{IsTerminal, Write};

    if !std::io::stdin().is_terminal() {
        return Err(std::io::Error::new(
            std::io::ErrorKind::Unsupported,
            "prompt not available in piped mode",
        ));
    }

    let message = params
        .get("message")
        .and_then(|v| v.as_str())
        .unwrap_or("");

    match op {
        "confirm" => {
            eprint!("{} [y/N] ", message);
            std::io::stderr().flush()?;
            let mut line = String::new();
            std::io::stdin().read_line(&mut line)?;
            let answer = matches!(line.trim().to_lowercase().as_str(), "y" | "yes");
            Ok(json!({"answer": answer}))
        }
        "choose" => {
            let options = params
                .get("options")
                .and_then(|v| v.as_array())
                .map(|arr| {
                    arr.iter()
                        .filter_map(|v| v.as_str().map(String::from))
                        .collect::<Vec<_>>()
                })
                .unwrap_or_default();

            eprintln!("{}", message);
            for (i, opt) in options.iter().enumerate() {
                eprintln!("  {}) {}", i + 1, opt);
            }
            eprint!("Choice: ");
            std::io::stderr().flush()?;

            let mut line = String::new();
            std::io::stdin().read_line(&mut line)?;
            let choice = line.trim();

            // Try numeric selection first
            if let Ok(n) = choice.parse::<usize>() {
                if n >= 1 && n <= options.len() {
                    return Ok(json!({"answer": options[n - 1]}));
                }
            }
            // Try exact text match
            if options.iter().any(|o| o == choice) {
                return Ok(json!({"answer": choice}));
            }

            Err(std::io::Error::new(
                std::io::ErrorKind::InvalidInput,
                format!("invalid choice: {}", choice),
            ))
        }
        "text" => {
            eprint!("{}: ", message);
            std::io::stderr().flush()?;
            let mut line = String::new();
            std::io::stdin().read_line(&mut line)?;
            Ok(json!({"answer": line.trim_end()}))
        }
        _ => Err(std::io::Error::new(
            std::io::ErrorKind::InvalidInput,
            format!("unknown prompt operation: {}", op),
        )),
    }
}

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

    fn make_req(domain: &str, op: &str, params: serde_json::Value) -> CapabilityReq {
        CapabilityReq {
            id: 1,
            domain: domain.into(),
            op: op.into(),
            params,
        }
    }

    #[test]
    fn fs_read_string() {
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("test.txt");
        std::fs::write(&file, "hello").unwrap();

        let req = make_req("fs", "read_string", json!({"path": file.to_str().unwrap()}));
        let res = handle(&req);
        match res.result {
            CapabilityResult::Ok { data } => {
                assert_eq!(data["content"], "hello");
            }
            CapabilityResult::Error { error } => panic!("unexpected error: {}", error.message),
        }
    }

    #[test]
    fn fs_exists() {
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("test.txt");

        let req = make_req("fs", "exists", json!({"path": file.to_str().unwrap()}));
        let res = handle(&req);
        match res.result {
            CapabilityResult::Ok { data } => assert_eq!(data["exists"], false),
            _ => panic!("expected ok"),
        }

        std::fs::write(&file, "data").unwrap();
        let res = handle(&req);
        match res.result {
            CapabilityResult::Ok { data } => assert_eq!(data["exists"], true),
            _ => panic!("expected ok"),
        }
    }

    #[test]
    fn fs_read_dir() {
        let dir = tempfile::tempdir().unwrap();
        std::fs::write(dir.path().join("a.txt"), "").unwrap();
        std::fs::write(dir.path().join("b.txt"), "").unwrap();

        let req = make_req(
            "fs",
            "read_dir",
            json!({"path": dir.path().to_str().unwrap()}),
        );
        let res = handle(&req);
        match res.result {
            CapabilityResult::Ok { data } => {
                let entries = data["entries"].as_array().unwrap();
                assert_eq!(entries.len(), 2);
            }
            _ => panic!("expected ok"),
        }
    }

    #[test]
    fn fs_not_found() {
        let req = make_req(
            "fs",
            "read_string",
            json!({"path": "/nonexistent/path/file.txt"}),
        );
        let res = handle(&req);
        match res.result {
            CapabilityResult::Error { error } => {
                assert_eq!(error.kind, "not_found");
            }
            _ => panic!("expected error"),
        }
    }

    #[test]
    fn fs_write_and_read() {
        let dir = tempfile::tempdir().unwrap();
        let file = dir.path().join("output.txt");

        let encoded = protocol::base64_encode(b"written data");
        let req = make_req(
            "fs",
            "write",
            json!({"path": file.to_str().unwrap(), "content": encoded}),
        );
        let res = handle(&req);
        assert!(matches!(res.result, CapabilityResult::Ok { .. }));

        let content = std::fs::read_to_string(&file).unwrap();
        assert_eq!(content, "written data");
    }

    #[test]
    fn unknown_domain_returns_error() {
        let req = make_req("unknown", "op", json!({}));
        let res = handle(&req);
        assert!(matches!(res.result, CapabilityResult::Error { .. }));
    }

    #[test]
    fn unknown_fs_op_returns_error() {
        let req = make_req("fs", "unknown_op", json!({}));
        let res = handle(&req);
        assert!(matches!(res.result, CapabilityResult::Error { .. }));
    }

    #[test]
    fn response_id_matches_request() {
        let req = CapabilityReq {
            id: 42,
            domain: "fs".into(),
            op: "exists".into(),
            params: json!({"path": "."}),
        };
        let res = handle(&req);
        assert_eq!(res.id, 42);
    }
}