genify 0.0.5

Turn one file into a complete project
Documentation
#![cfg(feature = "cli")]

use std::{
    fs,
    io::{BufRead, BufReader, Write},
    path::{Path, PathBuf},
    process::{Child, ChildStdin, ChildStdout, Command, Stdio},
    time::{SystemTime, UNIX_EPOCH},
};

use serde_json::{Value as JsonValue, json};

#[test]
fn mcp_can_plan_diff_and_apply_simple_config() {
    let root = temp_root("stdio");
    let config = json!({
        "props": {
            "name": "demo"
        },
        "rules": [
            {
                "type": "write",
                "path": "out.txt",
                "content": "Hello {{ name }}",
                "if_exists": "error"
            }
        ]
    });

    let mut client = McpClient::start(&root);
    client.request(
        1,
        "initialize",
        json!({
            "protocolVersion": "2025-11-25",
            "clientInfo": {
                "name": "genify-test",
                "version": "0.0.0"
            },
            "capabilities": {}
        }),
    );
    let init = client.response();
    assert_eq!(init["result"]["serverInfo"]["name"], "genify");
    client.notification("notifications/initialized", json!({}));

    client.request(2, "tools/list", json!({}));
    let tools = client.response();
    let tool_names = tools["result"]["tools"]
        .as_array()
        .expect("tools should be an array")
        .iter()
        .map(|tool| tool["name"].as_str().expect("tool should have a name"))
        .collect::<Vec<_>>();
    assert!(tool_names.contains(&"genify_plan"));
    assert!(tool_names.contains(&"genify_apply"));

    client.request(
        3,
        "tools/call",
        json!({
            "name": "genify_plan",
            "arguments": {
                "config": config.clone()
            }
        }),
    );
    let plan = client.response();
    assert_eq!(
        plan["result"]["structuredContent"]["affected_paths"],
        json!(["out.txt"])
    );
    assert!(!root.join("out.txt").exists());

    client.request(
        4,
        "tools/call",
        json!({
            "name": "genify_diff",
            "arguments": {
                "config": config.clone()
            }
        }),
    );
    let diff = client.response();
    let diff_text = diff["result"]["structuredContent"]["diff"]
        .as_str()
        .expect("diff should be a string");
    assert!(diff_text.contains("+Hello demo"));
    assert!(!root.join("out.txt").exists());

    client.request(
        5,
        "tools/call",
        json!({
            "name": "genify_apply",
            "arguments": {
                "config": config,
                "explicit_approval": true
            }
        }),
    );
    let apply = client.response();
    assert_eq!(
        apply["result"]["structuredContent"]["changed_files"],
        json!(["out.txt"])
    );
    assert_eq!(
        fs::read_to_string(root.join("out.txt")).expect("output should exist"),
        "Hello demo\n"
    );

    client.shutdown();
    let _ = fs::remove_dir_all(root);
}

#[test]
fn mcp_validate_config_returns_json_hint() {
    let root = temp_root("validate-hint");
    let mut client = McpClient::start(&root);
    client.request(
        1,
        "initialize",
        json!({
            "protocolVersion": "2025-11-25",
            "clientInfo": {
                "name": "genify-test",
                "version": "0.0.0"
            },
            "capabilities": {}
        }),
    );
    let _ = client.response();
    client.notification("notifications/initialized", json!({}));

    client.request(
        2,
        "tools/call",
        json!({
            "name": "genify_validate_config",
            "arguments": {
                "config": {}
            }
        }),
    );
    let response = client.response();
    let content = &response["result"]["structuredContent"];
    assert_eq!(content["valid"], false);
    assert_eq!(content["diagnostics"][0]["code"], "missing_rules");
    assert_eq!(
        content["hint"]["minimal_config"]["rules"][0]["type"],
        "replace"
    );

    client.shutdown();
    let _ = fs::remove_dir_all(root);
}

struct McpClient {
    child: Child,
    stdin: ChildStdin,
    stdout: BufReader<ChildStdout>,
}

impl McpClient {
    fn start(root: &Path) -> Self {
        let mut child = Command::new(env!("CARGO_BIN_EXE_genify"))
            .args(["mcp", "--root"])
            .arg(root)
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .expect("MCP server should start");
        let stdin = child.stdin.take().expect("stdin should be piped");
        let stdout = BufReader::new(child.stdout.take().expect("stdout should be piped"));
        Self {
            child,
            stdin,
            stdout,
        }
    }

    fn request(&mut self, id: u64, method: &str, params: JsonValue) {
        let request = json!({
            "jsonrpc": "2.0",
            "id": id,
            "method": method,
            "params": params
        });
        self.write_message(request);
    }

    fn notification(&mut self, method: &str, params: JsonValue) {
        let notification = json!({
            "jsonrpc": "2.0",
            "method": method,
            "params": params
        });
        self.write_message(notification);
    }

    fn response(&mut self) -> JsonValue {
        let mut line = String::new();
        self.stdout
            .read_line(&mut line)
            .expect("response should be readable");
        assert!(!line.is_empty(), "server closed stdout");
        serde_json::from_str(&line).expect("response should be JSON")
    }

    fn write_message(&mut self, message: JsonValue) {
        writeln!(
            self.stdin,
            "{}",
            serde_json::to_string(&message).expect("message should serialize")
        )
        .expect("request should be written");
        self.stdin.flush().expect("request should be flushed");
    }

    fn shutdown(mut self) {
        drop(self.stdin);
        let _ = self.child.wait();
    }
}

fn temp_root(name: &str) -> PathBuf {
    let suffix = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("system clock should be after unix epoch")
        .as_nanos();
    let path = std::env::temp_dir().join(format!("genify-mcp-{name}-{suffix}"));
    fs::create_dir_all(&path).expect("temp root should be created");
    path
}