glacier-web-cli 0.4.1

Command-line tool for creating and running Glacier applications.
//! What tools ask about an app, as data: errors, models, routes. The MCP
//! server and `glacier check --json` read it; nothing here prints.
//!
//! Errors come in one shape whichever compiler found them:
//!
//! ```text
//! {"source": "glacier" | "rustc", "severity": "error", "file": "src/models/post.glacier",
//!  "line": 3, "column": 9, "message": "…", "expected": "def name(args) -> Type do", "fix": {…}}
//! ```
//!
//! Lines and columns count from 1.

use std::collections::HashMap;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::process::{Command, Stdio};
use std::rc::Rc;

use serde_json::{Value, json};

use crate::diagnostics::Source;

/// Errors in the app's `.glacier` files, without building: what the
/// `.glacier` compiler rejects, and what it warns about.
pub fn glacier_errors(app: &Path) -> Vec<Value> {
    let mut errors = Vec::new();
    for path in glacier_dialect::build::sources(app) {
        let Ok(text) = fs::read_to_string(&path) else {
            continue;
        };
        let name = path.file_name().unwrap_or_default().to_string_lossy();
        match glacier_dialect::transpile_as(&text, &name, glacier_dialect::Mode::for_path(&path)) {
            Ok(output) => {
                for warning in &output.warnings {
                    let mut value = glacier_error(&relative(app, &path), warning);
                    value["severity"] = "warning".into();
                    errors.push(value);
                }
            }
            Err(err) => errors.push(glacier_error(&relative(app, &path), &err)),
        }
    }
    errors
}

/// A `.glacier` compile error as data, with the syntax it expected when
/// the message names one (`expected \`def name(args) -> Type do\``).
pub fn glacier_error(file: &str, err: &glacier_dialect::Error) -> Value {
    let mut out = json!({
        "source": "glacier",
        "severity": "error",
        "file": file,
        "line": err.line + 1,
        "column": err.col + 1,
        "message": err.message,
    });
    if let Some(expected) = expected_syntax(&err.message) {
        out["expected"] = expected.into();
    }
    out
}

/// The first `` `…` `` after "expected" in a message.
fn expected_syntax(message: &str) -> Option<String> {
    let rest = &message[message.find("expected")?..];
    let start = rest.find('`')? + 1;
    let end = start + rest[start..].find('`')?;
    Some(rest[start..end].to_owned())
}

/// Every error building the app: the `.glacier` compiler's first (a
/// file that doesn't compile can't be built), then rustc's, located in
/// the `.glacier` source where the code came from one. Warnings too.
pub fn build_errors(app: &Path) -> Result<Vec<Value>, String> {
    let mut errors = glacier_errors(app);
    if errors.iter().any(|e| e["severity"] == "error") {
        return Ok(errors);
    }
    glacier_dialect::build::compile_all(app)?;
    let output = Command::new("cargo")
        .args(["check", "--all-targets", "--message-format", "json", "-q"])
        .current_dir(app)
        .stdout(Stdio::piped())
        .stderr(Stdio::null())
        .spawn()
        .map_err(|e| format!("running cargo: {e}"))?;
    let stdout = output.stdout.expect("piped stdout");
    let mut sources: HashMap<String, Option<Rc<Source>>> = HashMap::new();
    let mut lookup = |file: &str| {
        let path = if Path::new(file).is_absolute() {
            file.to_owned()
        } else {
            app.join(file).display().to_string()
        };
        sources
            .entry(path.clone())
            .or_insert_with(|| crate::dialect::glacier_source(&path))
            .clone()
    };
    let mut found = Vec::new();
    for line in BufReader::new(stdout).lines().map_while(Result::ok) {
        let Ok(message) = serde_json::from_str::<Value>(&line) else {
            continue;
        };
        if message["reason"] != "compiler-message" {
            continue;
        }
        let diagnostic = &message["message"];
        if !matches!(diagnostic["level"].as_str(), Some("error" | "warning")) {
            continue;
        }
        let structured = crate::diagnostics::structured(diagnostic, &mut lookup);
        // The library builds twice under --all-targets (once for its tests):
        // the same error comes back twice.
        if !found.contains(&structured) {
            found.push(structured);
        }
    }
    // The `.glacier` compiler's warnings first.
    errors.extend(found);
    Ok(errors)
}

/// The app's models: each one's fields (name, type, options) and
/// associations, read from `src/models/*.glacier`, with plain-Rust models
/// (`src/models/*.rs`) listed by name.
pub fn models(app: &Path) -> Vec<Value> {
    let dir = app.join("src/models");
    let mut paths: Vec<_> = fs::read_dir(&dir)
        .into_iter()
        .flatten()
        .flatten()
        .map(|e| e.path())
        .filter(|p| p.extension().is_some_and(|e| e == "glacier" || e == "rs"))
        .filter(|p| p.file_stem().is_some_and(|s| s != "mod"))
        .collect();
    paths.sort();
    let mut models = Vec::new();
    for path in paths {
        let Ok(text) = fs::read_to_string(&path) else {
            continue;
        };
        let file = relative(app, &path);
        if path.extension().is_some_and(|e| e == "rs") {
            for name in rust_models(&text) {
                models.push(json!({"name": name, "file": file, "language": "rust"}));
            }
            continue;
        }
        if let Some(model) = glacier_model(&text, &file) {
            models.push(model);
        }
    }
    models
}

/// `model Post do` with its `field`, `belongs_to` and `has_many` lines.
fn glacier_model(text: &str, file: &str) -> Option<Value> {
    let mut name = None;
    let mut table = None;
    let mut fields = Vec::new();
    let mut associations = Vec::new();
    for line in text.lines().map(str::trim) {
        if let Some(rest) = line.strip_prefix("model ") {
            let header = rest.strip_suffix(" do").unwrap_or(rest);
            let mut parts = header.split(',').map(str::trim);
            name = parts.next().map(str::to_owned);
            table = parts
                .find_map(|p| p.strip_prefix("table:"))
                .map(|t| t.trim().trim_matches('"').to_owned());
        } else if let Some(rest) = line.strip_prefix("field ") {
            let Some((field, rest)) = rest.split_once(':') else {
                continue;
            };
            let mut parts = split_top(rest);
            let ty = parts.first().cloned().unwrap_or_default();
            let options = parts.split_off(1.min(parts.len()));
            fields.push(json!({"name": field.trim(), "type": ty, "options": options}));
        } else if let Some((kind, rest)) = line
            .strip_prefix("belongs_to ")
            .or_else(|| line.strip_prefix("owned_by "))
            .or_else(|| line.strip_prefix("tenant "))
            .map(|r| ("belongs_to", r))
            .or_else(|| line.strip_prefix("has_many ").map(|r| ("has_many", r)))
        {
            let parts = split_top(rest);
            let target = parts.first().map(|p| p.trim_start_matches(':').to_owned());
            associations.push(json!({
                "kind": kind,
                "name": target,
                "options": parts.get(1..).map(<[String]>::to_vec).unwrap_or_default(),
            }));
        }
    }
    let name = name?;
    let table =
        table.unwrap_or_else(|| glacier_inflect::pluralize(&glacier_inflect::snake_case(&name)));
    Some(json!({
        "name": name,
        "table": table,
        "file": file,
        "language": "glacier",
        "fields": fields,
        "associations": associations,
    }))
}

/// `#[glacier::model]` / `derive(Model)` structs in a Rust file.
fn rust_models(text: &str) -> Vec<String> {
    let mut names = Vec::new();
    let mut marked = false;
    for line in text.lines().map(str::trim) {
        if line.contains("glacier::model") || line.contains("Model)") || line.contains("Model,") {
            marked = true;
        } else if marked && let Some(rest) = line.strip_prefix("pub struct ") {
            names.push(
                rest.split(|c: char| !c.is_alphanumeric() && c != '_')
                    .next()
                    .unwrap_or_default()
                    .to_owned(),
            );
            marked = false;
        }
    }
    names
}

/// Splits on commas outside brackets: `Option<i32>, default: :x` →
/// `["Option<i32>", "default: :x"]`.
fn split_top(text: &str) -> Vec<String> {
    let mut parts = Vec::new();
    let mut depth = 0i32;
    let mut current = String::new();
    for c in text.chars() {
        match c {
            '<' | '(' | '[' => depth += 1,
            '>' | ')' | ']' => depth -= 1,
            ',' if depth == 0 => {
                parts.push(current.trim().to_owned());
                current.clear();
                continue;
            }
            _ => {}
        }
        current.push(c);
    }
    if !current.trim().is_empty() {
        parts.push(current.trim().to_owned());
    }
    parts
}

/// The app's routes, as `glacier routes` lists them: method, path and
/// the action that answers. Builds and runs the app's routes task.
pub fn routes(app: &Path) -> Result<Vec<Value>, String> {
    glacier_dialect::build::compile_all(app)?;
    let output = Command::new("cargo")
        .args(["run", "-q"])
        .env("GLACIER_TASK", "routes")
        .current_dir(app)
        .stderr(Stdio::piped())
        .output()
        .map_err(|e| format!("running cargo: {e}"))?;
    if !output.status.success() {
        return Err(format!(
            "the app didn't build; run `check` for the errors\n{}",
            String::from_utf8_lossy(&output.stderr)
                .lines()
                .rev()
                .take(5)
                .collect::<Vec<_>>()
                .join("\n")
        ));
    }
    Ok(String::from_utf8_lossy(&output.stdout)
        .lines()
        .filter_map(|line| {
            let mut parts = line.split_whitespace();
            let (method, path) = (parts.next()?, parts.next()?);
            Some(json!({
                "method": method,
                "path": path,
                "action": parts.collect::<Vec<_>>().join(" "),
            }))
        })
        .collect())
}

/// Runs one of the app's JSON tasks (`db:schema`, `db:query`, `jobs`):
/// builds the app, runs it with `GLACIER_TASK`, and reads what it prints.
pub fn app_task(app: &Path, task: &str, envs: &[(&str, &str)]) -> Result<Value, String> {
    glacier_dialect::build::compile_all(app)?;
    let output = Command::new("cargo")
        .args(["run", "-q"])
        .env("GLACIER_TASK", task)
        .envs(envs.iter().copied())
        .current_dir(app)
        .stderr(Stdio::piped())
        .output()
        .map_err(|e| format!("running cargo: {e}"))?;
    let stderr = String::from_utf8_lossy(&output.stderr);
    if !output.status.success() {
        let last: Vec<&str> = stderr.lines().rev().take(8).collect();
        return Err(last.into_iter().rev().collect::<Vec<_>>().join("\n"));
    }
    let stdout = String::from_utf8_lossy(&output.stdout);
    // The whole output (pretty-printed), or its last line of JSON.
    if let Ok(value) = serde_json::from_str(stdout.trim()) {
        return Ok(value);
    }
    let json = stdout
        .lines()
        .rev()
        .find(|l| l.starts_with('{'))
        .unwrap_or_default();
    serde_json::from_str(json).map_err(|_| format!("the `{task}` task printed no JSON:\n{stdout}"))
}

fn relative(app: &Path, path: &Path) -> String {
    path.strip_prefix(app)
        .unwrap_or(path)
        .display()
        .to_string()
        .trim_start_matches("./")
        .to_owned()
}

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

    #[test]
    fn finds_the_expected_syntax() {
        assert_eq!(
            expected_syntax("expected `def name(args) -> Type do`").as_deref(),
            Some("def name(args) -> Type do")
        );
        assert_eq!(expected_syntax("atoms only work in `render`"), None);
    }

    #[test]
    fn reads_glacier_models() {
        let model = glacier_model(
            "model Post, table: \"blog_posts\" do\n  field title: String, required, length(max: 200)\n  field views: Option<i32>\n  belongs_to :author, User\n  has_many :comments, dependent: :destroy\nend\n",
            "src/models/post.glacier",
        )
        .unwrap();
        assert_eq!(model["name"], "Post");
        assert_eq!(model["table"], "blog_posts");
        assert_eq!(
            model["fields"][0],
            json!({"name": "title", "type": "String", "options": ["required", "length(max: 200)"]})
        );
        assert_eq!(model["fields"][1]["type"], "Option<i32>");
        assert_eq!(
            model["associations"],
            json!([
                {"kind": "belongs_to", "name": "author", "options": ["User"]},
                {"kind": "has_many", "name": "comments", "options": ["dependent: :destroy"]}
            ])
        );
        let plain = glacier_model("model Tag do\nend\n", "x").unwrap();
        assert_eq!(plain["table"], "tags");
    }
}