use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use fraiseql_core::schema::CompiledSchema;
#[derive(Debug, Clone, Copy)]
pub enum ClientLanguage {
TypeScript,
Python,
Go,
Rust,
}
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 = CompiledSchema::from_json(&raw, false).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}"))?,
ClientLanguage::Python => fraiseql_codegen::client::python::generate(&schema)
.map_err(|e| anyhow::anyhow!("client generation failed: {e}"))?,
ClientLanguage::Go => fraiseql_codegen::client::go::generate(&schema)
.map_err(|e| anyhow::anyhow!("client generation failed: {e}"))?,
ClientLanguage::Rust => fraiseql_codegen::client::rust::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());
}
match language {
ClientLanguage::TypeScript => println!(
"\nGenerated {} files into {}. Type-check with `tsc --strict --noEmit {}/index.ts`.",
files.len(),
out_dir.display(),
out_dir.display(),
),
ClientLanguage::Python => println!(
"\nGenerated {} files into {}. Type-check with `ty check {}` (Python >= 3.12).",
files.len(),
out_dir.display(),
out_dir.display(),
),
ClientLanguage::Go => println!(
"\nGenerated {} files into {} (package `fraiseqlclient`, Go >= 1.21). Compile with `go build ./...`.",
files.len(),
out_dir.display(),
),
ClientLanguage::Rust => println!(
"\nGenerated {} files into {}. Declare it (`pub mod {};`) and compile with `cargo check`; \
the client needs `serde` (with `derive`) and `serde_json`.",
files.len(),
out_dir.display(),
out_dir
.file_name()
.map_or_else(|| "generated".into(), std::ffi::OsStr::to_string_lossy),
),
}
Ok(())
}
fn functions_type_specs(
raw: &str,
schema: &CompiledSchema,
) -> Vec<fraiseql_codegen::client::typescript::FunctionTypeSpec> {
use fraiseql_codegen::client::typescript::{
FunctionPayloadShape, FunctionTypeSpec, RequestQueryField,
};
use fraiseql_functions::{FunctionsConfig, triggers::registry::ParsedTrigger};
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::<FunctionsConfig>(value).ok())
else {
return Vec::new();
};
let is_type = |name: &str| schema.types.iter().any(|ty| ty.name == name);
let entity_shape = |entity_type: String| FunctionPayloadShape::Entity {
entity: Some(entity_type).filter(|name| is_type(name)),
};
functions
.definitions
.into_iter()
.filter_map(|def| {
let shape = match ParsedTrigger::parse(&def.trigger).ok()? {
ParsedTrigger::AfterMutation { entity_type, .. }
| ParsedTrigger::AfterCapture { entity_type, .. } => entity_shape(entity_type),
ParsedTrigger::Cron { .. } => FunctionPayloadShape::Cron,
ParsedTrigger::AfterIngest { .. } => FunctionPayloadShape::Ingest,
ParsedTrigger::RequestQuery => {
let fields: Vec<RequestQueryField> = schema
.queries
.iter()
.filter(|q| q.function.as_deref() == Some(def.name.as_str()))
.map(|q| RequestQueryField {
name: q.name.clone(),
arguments: q.arguments.clone(),
})
.collect();
if fields.is_empty() {
return None;
}
FunctionPayloadShape::Request { fields }
},
ParsedTrigger::Http { .. }
| ParsedTrigger::BeforeMutation { .. }
| ParsedTrigger::AfterStorage { .. } => return None,
};
Some(FunctionTypeSpec {
name: def.name,
shape,
})
})
.collect()
}
fn contains_generated_client(out_dir: &Path) -> bool {
["types.ts", "types.py", "types.go", "types.rs"].iter().any(|marker| {
std::fs::read_to_string(out_dir.join(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>"
)
}