cratestack-cli 0.2.0

Rust-native schema-first framework for typed HTTP APIs, generated clients, and backend services.
use std::collections::BTreeSet;
use std::path::PathBuf;

use anyhow::{Result, anyhow, bail};

pub(crate) fn render_schema_error(
    schema: &PathBuf,
    error: &cratestack_parser::SchemaError,
) -> String {
    error.render(
        &schema.display().to_string(),
        &std::fs::read_to_string(schema).unwrap_or_default(),
    )
}

pub(crate) fn json_check_success(schema: &PathBuf) -> serde_json::Value {
    serde_json::json!({
        "ok": true,
        "schema": schema.display().to_string(),
        "diagnostics": [],
    })
}

pub(crate) fn json_check_failure(
    schema: &PathBuf,
    error: &cratestack_parser::SchemaError,
) -> serde_json::Value {
    let span = error.span();
    serde_json::json!({
        "ok": false,
        "schema": schema.display().to_string(),
        "diagnostics": [
            {
                "message": error.message(),
                "line": error.line(),
                "start": span.start,
                "end": span.end,
            }
        ],
    })
}

pub(crate) fn parse_schema_or_render(schema: &PathBuf) -> Result<cratestack_core::Schema> {
    cratestack_parser::parse_schema_file(schema)
        .map_err(|error| anyhow!(render_schema_error(schema, &error)))
}

pub(crate) fn validate_mount_path(mount_path: &str) -> Result<()> {
    if !mount_path.starts_with('/') {
        bail!("mount path '{mount_path}' must begin with '/'");
    }
    if mount_path.trim() == "/" {
        bail!("mount path '/' is not supported; use a non-root path such as '/studio'");
    }
    Ok(())
}

pub(crate) fn validate_service_url(service_url: &str) -> Result<()> {
    let parsed = url::Url::parse(service_url)
        .map_err(|error| anyhow!("service url '{service_url}' must be absolute: {error}"))?;
    if !parsed.has_host() {
        bail!("service url '{service_url}' must be absolute");
    }
    Ok(())
}

pub(crate) fn validate_studio_context_inputs(
    schema: &[PathBuf],
    service_url: &[String],
    context: &[String],
) -> Result<()> {
    if schema.is_empty() {
        bail!("at least one --schema must be provided");
    }
    if schema.len() != service_url.len() {
        bail!("generate-studio requires the same number of --schema and --service-url values");
    }
    if !context.is_empty() && context.len() != schema.len() {
        bail!("generate-studio requires either zero --context values or one per --schema");
    }
    Ok(())
}

pub(crate) fn validate_context_key(key: &str) -> Result<()> {
    if key.is_empty() {
        bail!("studio context key must not be empty");
    }
    if key.chars().any(|character| {
        !(character.is_ascii_alphanumeric() || character == '-' || character == '_')
    }) {
        bail!("studio context key '{key}' is not URL-safe");
    }
    Ok(())
}

pub(crate) fn validate_studio_name(name: &str) -> Result<()> {
    if name.is_empty() {
        bail!("studio name must not be empty");
    }
    if name.chars().any(|character| {
        !(character.is_ascii_alphanumeric() || character == '-' || character == '_')
    }) {
        bail!("studio name '{name}' is not cargo-safe or filesystem-safe");
    }
    Ok(())
}

pub(crate) fn ensure_output_dir_is_empty(out: &PathBuf) -> Result<()> {
    if !out.exists() {
        return Ok(());
    }
    let mut entries = std::fs::read_dir(out)?;
    if entries.next().is_some() {
        bail!(
            "output directory '{}' already exists and is not empty",
            out.display()
        );
    }
    Ok(())
}

pub(crate) fn derive_service_name(schema: &PathBuf, name: &str) -> String {
    schema
        .parent()
        .and_then(|path| path.parent())
        .and_then(|path| path.file_name())
        .and_then(|value| value.to_str())
        .filter(|value| value.ends_with("-service") || value.ends_with("-gateway"))
        .map(str::to_owned)
        .or_else(|| {
            schema
                .file_stem()
                .and_then(|value| value.to_str())
                .map(str::to_owned)
        })
        .unwrap_or_else(|| name.to_owned())
}

pub(crate) fn resolve_context_keys(
    schema: &[PathBuf],
    explicit_contexts: &[String],
) -> Result<Vec<String>> {
    let context_keys = if explicit_contexts.is_empty() {
        schema
            .iter()
            .enumerate()
            .map(|(index, schema_path)| derive_context_key(schema_path, index + 1))
            .collect::<Vec<_>>()
    } else {
        explicit_contexts.to_vec()
    };

    let mut seen = BTreeSet::new();
    for key in &context_keys {
        validate_context_key(key)?;
        if !seen.insert(key.clone()) {
            bail!("studio context key '{key}' is duplicated");
        }
    }

    Ok(context_keys)
}

fn derive_context_key(schema: &PathBuf, ordinal: usize) -> String {
    let value = schema
        .file_stem()
        .and_then(|value| value.to_str())
        .map(slugify_path_token)
        .filter(|value| !value.is_empty())
        .unwrap_or_else(|| format!("context-{ordinal}"));

    if value
        .chars()
        .next()
        .is_some_and(|character| character.is_ascii_digit())
    {
        format!("context-{value}")
    } else {
        value
    }
}

pub(crate) fn slugify_path_token(value: &str) -> String {
    value
        .chars()
        .map(|character| {
            if character.is_ascii_alphanumeric() {
                character.to_ascii_lowercase()
            } else if character == '_' {
                '_'
            } else {
                '-'
            }
        })
        .collect::<String>()
        .split('-')
        .filter(|segment| !segment.is_empty())
        .collect::<Vec<_>>()
        .join("-")
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct GeneratedFile {
    pub(crate) file_name: String,
    pub(crate) contents: String,
}

pub(crate) trait GeneratedFileLike {
    fn into_generated_file(self) -> GeneratedFile;
}

impl GeneratedFileLike for cratestack_client_dart::GeneratedDartFile {
    fn into_generated_file(self) -> GeneratedFile {
        GeneratedFile {
            file_name: self.file_name,
            contents: self.contents,
        }
    }
}

impl GeneratedFileLike for cratestack_client_typescript::GeneratedTypeScriptFile {
    fn into_generated_file(self) -> GeneratedFile {
        GeneratedFile {
            file_name: self.file_name,
            contents: self.contents,
        }
    }
}

impl GeneratedFileLike for cratestack_studio_generator::GeneratedStudioFile {
    fn into_generated_file(self) -> GeneratedFile {
        GeneratedFile {
            file_name: self.file_name,
            contents: self.contents,
        }
    }
}

pub(crate) fn into_generated_files<T: GeneratedFileLike>(files: Vec<T>) -> Vec<GeneratedFile> {
    files
        .into_iter()
        .map(GeneratedFileLike::into_generated_file)
        .collect()
}

pub(crate) fn write_generated_files(out: &PathBuf, files: Vec<GeneratedFile>) -> Result<()> {
    std::fs::create_dir_all(out)?;
    for file in files {
        let destination = out.join(file.file_name);
        if let Some(parent) = destination.parent() {
            std::fs::create_dir_all(parent)?;
        }
        std::fs::write(destination, file.contents)?;
    }
    Ok(())
}