railwayapp 5.45.1

Interact with Railway via CLI
use std::{
    fs,
    path::{Path, PathBuf},
    process::Command,
};

use anyhow::{Context, Result, bail};
use serde_json::{Value, json};

use super::compiler::project_definition_to_graph;
use super::graph::RailwayGraph;
use super::partial::parse_partial_name;

pub struct EvaluatedFile {
    pub file: PathBuf,
    pub graph: RailwayGraph,
    pub partial: Option<String>,
}

pub fn evaluate_file(file: &Path) -> Result<EvaluatedFile> {
    let file = file.canonicalize().unwrap_or_else(|_| file.to_path_buf());
    let ext = file
        .extension()
        .and_then(|ext| ext.to_str())
        .unwrap_or("")
        .to_ascii_lowercase();
    let payload = match ext.as_str() {
        "py" => evaluate_python(&file)?,
        "go" => evaluate_go(&file)?,
        "ts" | "mts" | "cts" | "js" | "mjs" | "cjs" => evaluate_javascript(&file)?,
        other => bail!("Unsupported IaC file extension: .{other}"),
    };
    let partial = parse_partial_name(payload.get("partial").and_then(Value::as_str))
        .map_err(|err| anyhow::anyhow!(err))?;
    let project = payload
        .get("project")
        .cloned()
        .or_else(|| payload.get("graph").cloned())
        .unwrap_or(payload.clone());
    let graph = project_definition_to_graph(&normalize_project(project));
    Ok(EvaluatedFile {
        file,
        graph,
        partial,
    })
}

fn normalize_project(mut project: Value) -> Value {
    if project.get("resources").is_none() {
        if let Some(resources) = project.get("Resources").cloned() {
            project["resources"] = resources;
        }
    }
    if let Some(resources) = project.get_mut("resources").and_then(Value::as_array_mut) {
        for resource in resources {
            if resource.get("address").is_none() {
                let kind = resource
                    .get("type")
                    .and_then(Value::as_str)
                    .unwrap_or("service");
                let name = resource
                    .get("name")
                    .and_then(Value::as_str)
                    .unwrap_or("web");
                resource["address"] = json!(format!("{kind}.{name}"));
            }
            if resource.get("kind").is_none()
                && resource.get("type").and_then(Value::as_str) == Some("service")
            {
                resource["kind"] = json!("empty");
            }
            if let Some(start) = resource.get("start").cloned() {
                if resource.get("deploy").is_none() {
                    resource["deploy"] = json!({});
                }
                if resource["deploy"].get("startCommand").is_none() {
                    resource["deploy"]["startCommand"] = start;
                }
            }
            if let Some(build) = resource.get("build").cloned() {
                if build.is_string() {
                    resource["build"] = json!({ "buildCommand": build });
                }
            }
        }
    }
    if project.get("name").is_none() {
        project["name"] = json!("app");
    }
    project
}

fn evaluate_python(file: &Path) -> Result<Value> {
    let script = r#"
import importlib.util, json, sys
path = sys.argv[1]
spec = importlib.util.spec_from_file_location("railway_sdk_user", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
partial = getattr(mod, "PARTIAL", None) or getattr(mod, "Partial", None) or getattr(mod, "partial", None)
candidate = getattr(mod, "main", None) or getattr(mod, "Railway", None) or getattr(mod, "default", None)
project = candidate() if callable(candidate) else candidate
if hasattr(project, "to_graph"):
    project = project.to_graph()
print(json.dumps({"partial": partial, "project": project}, default=str))
"#;
    let output = Command::new("python3")
        .args(["-c", script, &file.to_string_lossy()])
        .output()
        .context("Failed to run python3 to evaluate .railway/railway.py")?;
    decode_eval_output("python3", &output)
}

fn evaluate_go(file: &Path) -> Result<Value> {
    let dir = file
        .parent()
        .context("Go IaC file has no parent directory")?;
    let wrapper = dir.join("railway_iac_eval_main.go");
    let has_partial = fs::read_to_string(file)
        .unwrap_or_default()
        .contains("Partial");
    let source = if has_partial {
        r#"package main
import ("encoding/json"; "os")
func main() {
  out, err := json.Marshal(map[string]any{"partial": Partial, "project": Railway().Graph()})
  if err != nil { panic(err) }
  os.Stdout.Write(out)
}
"#
    } else {
        r#"package main
import ("encoding/json"; "os")
func main() {
  out, err := json.Marshal(map[string]any{"project": Railway().Graph()})
  if err != nil { panic(err) }
  os.Stdout.Write(out)
}
"#
    };
    fs::write(&wrapper, source)?;
    let output = Command::new("go")
        .args([
            "run",
            file.file_name()
                .and_then(|n| n.to_str())
                .unwrap_or("railway.go"),
            wrapper
                .file_name()
                .and_then(|n| n.to_str())
                .unwrap_or("railway_iac_eval_main.go"),
        ])
        .current_dir(dir)
        .output();
    let _ = fs::remove_file(&wrapper);
    decode_eval_output(
        "go run",
        &output.context("Failed to run go to evaluate .railway/railway.go")?,
    )
}

fn evaluate_javascript(file: &Path) -> Result<Value> {
    let script = r#"
import { pathToFileURL } from "node:url";
const file = process.argv[1];
const mod = await import(`${pathToFileURL(file).href}?t=${Date.now()}`);
const partial = mod.partial ?? mod.PARTIAL ?? mod.Partial ?? undefined;
let exported = mod.default ?? mod.main ?? mod.Railway ?? mod;
while (exported && typeof exported === "object" && "default" in exported && exported.name == null && exported.resources == null) {
  exported = exported.default;
}
const project = typeof exported === "function" ? await exported({}) : exported;
const graph = project && typeof project === "object" && "to_graph" in project ? project.to_graph() : project;
process.stdout.write(JSON.stringify({ partial, project: graph }));
"#;
    let mut command = Command::new("node");
    command.args([
        "--experimental-strip-types",
        "--disable-warning=ExperimentalWarning",
        "--input-type=module",
        "-e",
        script,
        "--",
        &file.to_string_lossy(),
    ]);
    if let Some(modules) = nearest_node_modules(file) {
        command.env("NODE_PATH", modules);
    }
    let output = command
        .output()
        .context("Failed to run node to evaluate the IaC file")?;
    decode_eval_output("node", &output)
}

fn nearest_node_modules(start: &Path) -> Option<PathBuf> {
    for dir in start.ancestors() {
        let candidate = dir.join("node_modules");
        if candidate.is_dir() {
            return Some(candidate);
        }
    }
    None
}

fn decode_eval_output(runtime: &str, output: &std::process::Output) -> Result<Value> {
    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
    if !output.status.success() {
        bail!("{runtime} failed to evaluate IaC file.\n{stderr}\n{stdout}");
    }
    serde_json::from_str(&stdout).with_context(|| {
        format!("{runtime} returned non-JSON output.\nstdout:\n{stdout}\nstderr:\n{stderr}")
    })
}