use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use fraiseql_core::schema::CompiledSchema;
#[derive(Debug, Clone, Copy)]
pub enum ClientLanguage {
TypeScript,
}
pub fn run(
language: ClientLanguage,
schema_path: Option<&Path>,
out_dir: &Path,
force: bool,
) -> Result<()> {
let schema_path = match schema_path {
Some(p) => p.to_path_buf(),
None => auto_detect_compiled_schema()?,
};
let raw = std::fs::read_to_string(&schema_path)
.with_context(|| format!("Failed to read compiled schema {}", schema_path.display()))?;
let schema: CompiledSchema = serde_json::from_str(&raw).with_context(|| {
format!("Failed to parse {} as a compiled schema", schema_path.display())
})?;
let mut files = match language {
ClientLanguage::TypeScript => fraiseql_codegen::client::typescript::generate(&schema)
.map_err(|e| anyhow::anyhow!("client generation failed: {e}"))?,
};
if matches!(language, ClientLanguage::TypeScript) {
let specs = functions_type_specs(&raw, &schema);
if !specs.is_empty() {
let dts = fraiseql_codegen::client::typescript::generate_functions_dts(&schema, &specs)
.map_err(|e| anyhow::anyhow!("functions.d.ts generation failed: {e}"))?;
files.insert(std::path::PathBuf::from("functions.d.ts"), dts);
}
}
if out_dir.exists() && !force && contains_generated_client(out_dir) {
anyhow::bail!(
"Output directory {} already contains a generated client. Pass --force to overwrite.",
out_dir.display()
);
}
for (rel_path, content) in &files {
let full_path = out_dir.join(rel_path);
if let Some(parent) = full_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("Failed to create {}", parent.display()))?;
}
std::fs::write(&full_path, content)
.with_context(|| format!("Failed to write {}", full_path.display()))?;
println!(" wrote {}", full_path.display());
}
println!(
"\nGenerated {} files into {}. Type-check with `tsc --strict --noEmit {}/index.ts`.",
files.len(),
out_dir.display(),
out_dir.display(),
);
Ok(())
}
fn functions_type_specs(
raw: &str,
schema: &CompiledSchema,
) -> Vec<fraiseql_codegen::client::typescript::FunctionTypeSpec> {
use fraiseql_codegen::client::typescript::{FunctionPayloadShape, FunctionTypeSpec};
#[derive(serde::Deserialize)]
struct FunctionsLite {
#[serde(default)]
definitions: Vec<FunctionDefLite>,
}
#[derive(serde::Deserialize)]
struct FunctionDefLite {
name: String,
trigger: String,
}
let Some(functions) = serde_json::from_str::<serde_json::Value>(raw)
.ok()
.and_then(|value| value.get("functions").cloned())
.filter(|value| !value.is_null())
.and_then(|value| serde_json::from_value::<FunctionsLite>(value).ok())
else {
return Vec::new();
};
let is_type = |name: &str| schema.types.iter().any(|ty| ty.name == name);
let entity_shape = |parts: &[&str]| {
let entity = parts.get(2).filter(|name| is_type(name)).map(|name| (*name).to_string());
FunctionPayloadShape::Entity { entity }
};
functions
.definitions
.into_iter()
.filter_map(|def| {
let parts: Vec<&str> = def.trigger.split(':').collect();
let shape = match (parts.first().copied(), parts.get(1).copied()) {
(Some("after"), Some("mutation" | "capture")) => entity_shape(&parts),
(Some("cron"), _) => FunctionPayloadShape::Cron,
(Some("after"), Some("ingest")) => FunctionPayloadShape::Ingest,
_ => return None,
};
Some(FunctionTypeSpec {
name: def.name,
shape,
})
})
.collect()
}
fn contains_generated_client(out_dir: &Path) -> bool {
let marker = out_dir.join("types.ts");
std::fs::read_to_string(marker)
.is_ok_and(|content| content.contains("AUTO-GENERATED by fraiseql-codegen"))
}
fn auto_detect_compiled_schema() -> Result<PathBuf> {
let candidates = [
"schema.compiled.json",
"target/fraiseql/schema.compiled.json",
"build/schema.compiled.json",
];
for candidate in candidates {
let path = PathBuf::from(candidate);
if path.exists() {
return Ok(path);
}
}
anyhow::bail!(
"No compiled schema found. Compile first (`fraiseql compile`) or pass an explicit path: \
fraiseql generate-client typescript --schema <path> --out <dir>"
)
}