glacier-web-cli 0.5.0

Command-line tool for creating and running Glacier applications.
//! `glacier mcp` as an agent's client sees it: JSON-RPC over stdio, in an
//! app's directory.

use std::io::{BufRead, BufReader, Write};
use std::path::Path;
use std::process::{Command, Stdio};

use serde_json::{Value, json};

/// Sends `requests` (one per line) to `glacier mcp` in `dir` and returns
/// the replies, in order.
fn session(dir: &Path, requests: &[Value]) -> Vec<Value> {
    let mut child = Command::new(env!("CARGO_BIN_EXE_glacier"))
        .arg("mcp")
        .current_dir(dir)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .spawn()
        .expect("start glacier mcp");
    let mut stdin = child.stdin.take().unwrap();
    for request in requests {
        writeln!(stdin, "{request}").unwrap();
    }
    drop(stdin);
    let replies = BufReader::new(child.stdout.take().unwrap())
        .lines()
        .map(|line| serde_json::from_str(&line.unwrap()).unwrap())
        .collect();
    assert!(child.wait().unwrap().success());
    replies
}

fn call(id: u64, tool: &str, arguments: Value) -> Value {
    json!({"jsonrpc": "2.0", "id": id, "method": "tools/call",
           "params": {"name": tool, "arguments": arguments}})
}

#[test]
fn an_agent_learns_the_language_and_checks_its_code() {
    let dir = std::env::temp_dir().join(format!("glacier-mcp-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(dir.join("src/models")).unwrap();
    std::fs::create_dir_all(dir.join("src/controllers")).unwrap();
    std::fs::write(
        dir.join("src/models/post.glacier"),
        "model Post do\n  field title: String, required\n  has_many :comments\nend\n",
    )
    .unwrap();
    std::fs::write(
        dir.join("src/controllers/posts_controller.glacier"),
        "controller Posts do\n  def show(ctx do\n  end\nend\n",
    )
    .unwrap();

    let replies = session(
        &dir,
        &[
            json!({"jsonrpc": "2.0", "id": 1, "method": "initialize",
                   "params": {"protocolVersion": "2025-06-18", "capabilities": {},
                              "clientInfo": {"name": "test", "version": "0"}}}),
            json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
            json!({"jsonrpc": "2.0", "id": 2, "method": "tools/list"}),
            call(3, "get_syntax_reference", json!({})),
            call(
                4,
                "search_docs",
                json!({"query": "has_many through", "limit": 2}),
            ),
            call(5, "list_models", json!({})),
            call(6, "check", json!({"build": false})),
            call(
                7,
                "show_generated_rust",
                json!({"path": "src/models/post.glacier"}),
            ),
        ],
    );
    assert_eq!(
        replies.len(),
        7,
        "every request answered, the notification not"
    );
    assert_eq!(replies[0]["result"]["serverInfo"]["name"], "glacier");
    assert!(
        replies[1]["result"]["tools"].as_array().unwrap().len() >= 10,
        "{}",
        replies[1]
    );
    assert!(
        replies[2]["result"]["content"][0]["text"]
            .as_str()
            .unwrap()
            .contains("controller")
    );
    assert_eq!(
        replies[3]["result"]["structuredContent"]["results"]
            .as_array()
            .unwrap()
            .len(),
        2
    );

    let models = &replies[4]["result"]["structuredContent"]["models"];
    assert_eq!(models[0]["name"], "Post");
    assert_eq!(models[0]["table"], "posts");
    assert_eq!(models[0]["associations"][0]["name"], "comments");

    let check = &replies[5]["result"]["structuredContent"];
    assert_eq!(check["ok"], false);
    let error = &check["diagnostics"][0];
    assert_eq!(error["source"], "glacier");
    assert_eq!(error["file"], "src/controllers/posts_controller.glacier");
    assert_eq!(
        (error["line"].clone(), error["column"].clone()),
        (json!(2), json!(7))
    );

    let rust = replies[6]["result"]["content"][0]["text"].as_str().unwrap();
    assert!(rust.contains("pub struct Post"), "{rust}");

    std::fs::remove_dir_all(&dir).ok();
}

#[test]
fn an_agent_navigates_the_app() {
    let dir = std::env::temp_dir().join(format!("glacier-mcp-nav-{}", std::process::id()));
    let _ = std::fs::remove_dir_all(&dir);
    std::fs::create_dir_all(dir.join("src/models")).unwrap();
    std::fs::create_dir_all(dir.join("src/controllers")).unwrap();
    std::fs::write(
        dir.join("src/models/post.glacier"),
        "model Post do\n  field title: String, required\n  belongs_to :author, User, touch\n\n  before_save do\n    self.title = self.title.trim().to_owned()\n  end\nend\n",
    )
    .unwrap();
    std::fs::write(
        dir.join("src/controllers/posts_controller.glacier"),
        "controller Posts do\n  def show(ctx) do\n    post = Post.find(ctx.id()?)!\n    json post\n  end\nend\n",
    )
    .unwrap();

    let replies = session(
        &dir,
        &[
            call(1, "list_symbols", json!({"kind": "action"})),
            call(2, "describe_symbol", json!({"name": "Post"})),
            call(3, "describe_callbacks", json!({"model": "Post"})),
            call(4, "find_references", json!({"name": "Post"})),
            call(5, "rename_symbol", json!({"from": "Post", "to": "Article"})),
            call(6, "find_definition", json!({"name": "Posts#show"})),
        ],
    );
    let result = |i: usize| &replies[i]["result"]["structuredContent"];
    assert_eq!(result(0)["symbols"][0]["name"], "show");
    assert_eq!(result(1)["definitions"][0]["kind"], "model");
    assert!(
        result(1)["definitions"][0]["code"]
            .as_str()
            .unwrap()
            .contains("before_save")
    );
    let steps: Vec<String> = result(2)["save"]
        .as_array()
        .unwrap()
        .iter()
        .map(|s| {
            s["callback"]
                .as_str()
                .or(s["step"].as_str())
                .unwrap()
                .to_owned()
        })
        .collect();
    assert_eq!(steps, ["validations", "before_save", "write", "touch"]);
    assert_eq!(result(3)["references"].as_array().unwrap().len(), 2);
    assert_eq!(result(4)["applied"], false);
    assert_eq!(result(4)["changes"].as_array().unwrap().len(), 2);
    let untouched = std::fs::read_to_string(dir.join("src/models/post.glacier")).unwrap();
    assert!(
        untouched.starts_with("model Post do"),
        "a dry run changes nothing"
    );
    assert_eq!(result(5)["definitions"][0]["line"], 2);

    std::fs::remove_dir_all(&dir).ok();
}