use std::path::Path;
use std::process::Command;
use anyhow::{Context, Result, bail};
use crate::config::{CommandConfig, SeamConfig};
use crate::shell::{run_command, which_exists};
use crate::ui::{self, DIM, GREEN, RESET, col};
use seam_codegen::{Manifest, ProcedureType};
pub(super) fn levenshtein(a: &str, b: &str) -> usize {
let n = b.len();
let mut prev: Vec<usize> = (0..=n).collect();
let mut curr = vec![0; n + 1];
for (i, ca) in a.chars().enumerate() {
curr[0] = i + 1;
for (j, cb) in b.chars().enumerate() {
let cost = if ca == cb { 0 } else { 1 };
curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[n]
}
pub(super) fn did_you_mean<'a>(name: &str, candidates: &[&'a str]) -> Option<&'a str> {
candidates
.iter()
.map(|c| (*c, levenshtein(name, c)))
.filter(|(_, d)| *d <= 3 && *d > 0)
.min_by_key(|(_, d)| *d)
.map(|(c, _)| c)
}
pub(super) fn extract_jtd_fields(schema: &serde_json::Value) -> std::collections::BTreeSet<&str> {
let mut fields = std::collections::BTreeSet::new();
if let Some(props) = schema.get("properties").and_then(serde_json::Value::as_object) {
fields.extend(props.keys().map(String::as_str));
}
if let Some(opt_props) = schema.get("optionalProperties").and_then(serde_json::Value::as_object) {
fields.extend(opt_props.keys().map(String::as_str));
}
fields
}
pub(crate) fn validate_invalidates(manifest: &Manifest) -> Result<()> {
let available: Vec<&str> = manifest.procedures.keys().map(String::as_str).collect();
let mut errors = Vec::new();
for (cmd_name, cmd) in &manifest.procedures {
let Some(targets) = &cmd.invalidates else { continue };
if cmd.proc_type != ProcedureType::Command {
ui::warn(&format!(
"Procedure \"{cmd_name}\" is a {} but declares invalidates. \
invalidates only takes effect on command procedures.",
cmd.proc_type
));
continue;
}
for target in targets {
let Some(target_proc) = manifest.procedures.get(&target.query) else {
let mut msg = format!(
" Command \"{cmd_name}\" invalidates \"{}\", but no procedure with that name exists.",
target.query
);
if let Some(suggestion) = did_you_mean(&target.query, &available) {
msg.push_str(&format!("\n\n Did you mean: {suggestion}?"));
}
errors.push(msg);
continue;
};
if target_proc.proc_type != ProcedureType::Query {
errors.push(format!(
" Command \"{cmd_name}\" invalidates \"{}\", but it is a {} (expected query).",
target.query, target_proc.proc_type
));
continue;
}
if let Some(mapping) = &target.mapping {
let target_fields = extract_jtd_fields(&target_proc.input);
let cmd_fields = extract_jtd_fields(&cmd.input);
for (key, val) in mapping {
if !target_fields.is_empty() && !target_fields.contains(key.as_str()) {
ui::warn(&format!(
"Command \"{cmd_name}\": invalidates mapping key \"{key}\" not found in \"{}\".input",
target.query
));
}
if !cmd_fields.is_empty() && !cmd_fields.contains(val.from.as_str()) {
ui::warn(&format!(
"Command \"{cmd_name}\": invalidates mapping from \"{}\".input field \"{}\" not found",
cmd_name, val.from
));
}
}
}
}
}
if errors.is_empty() {
Ok(())
} else {
bail!("invalid invalidates declaration\n\n{}", errors.join("\n\n"));
}
}
pub(crate) fn print_procedure_breakdown(manifest: &Manifest) {
let total = manifest.procedures.len();
let mut queries = 0u32;
let mut commands = 0u32;
let mut subscriptions = 0u32;
let mut streams = 0u32;
let mut uploads = 0u32;
for proc in manifest.procedures.values() {
match proc.proc_type {
ProcedureType::Query => queries += 1,
ProcedureType::Command => commands += 1,
ProcedureType::Subscription => subscriptions += 1,
ProcedureType::Stream => streams += 1,
ProcedureType::Upload => uploads += 1,
}
}
let mut parts = Vec::new();
if queries > 0 {
parts.push(format!("{queries} {}", if queries == 1 { "query" } else { "queries" }));
}
if commands > 0 {
parts.push(format!("{commands} {}", if commands == 1 { "command" } else { "commands" }));
}
if subscriptions > 0 {
parts.push(format!(
"{subscriptions} {}",
if subscriptions == 1 { "subscription" } else { "subscriptions" }
));
}
if streams > 0 {
parts.push(format!("{streams} {}", if streams == 1 { "stream" } else { "streams" }));
}
if uploads > 0 {
parts.push(format!("{uploads} {}", if uploads == 1 { "upload" } else { "uploads" }));
}
let breakdown =
if parts.is_empty() { String::new() } else { format!(" \u{2014} {}", parts.join(", ")) };
ui::detail_ok(&format!("{total} procedures{breakdown}"));
}
pub(crate) fn extract_manifest_command(
base_dir: &Path,
command_config: &CommandConfig,
out_dir: &Path,
) -> Result<Manifest> {
let command = command_config.command();
let spinner = ui::spinner(command);
let cwd = command_config.resolve_cwd(base_dir);
let output = Command::new("sh")
.args(["-c", command])
.current_dir(&cwd)
.output()
.with_context(|| format!("failed to run manifest command: {command}"))?;
if !output.status.success() {
spinner.finish_with("failed");
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("manifest command failed:\n{stderr}");
}
spinner.finish();
let stdout = String::from_utf8(output.stdout).context("invalid UTF-8 from manifest command")?;
let manifest: Manifest =
serde_json::from_str(&stdout).context("failed to parse manifest JSON from command output")?;
std::fs::create_dir_all(out_dir)
.with_context(|| format!("failed to create {}", out_dir.display()))?;
let manifest_path = out_dir.join("seam-manifest.json");
let json = serde_json::to_string_pretty(&manifest)?;
std::fs::write(&manifest_path, &json)
.with_context(|| format!("failed to write {}", manifest_path.display()))?;
ui::detail_ok(&format!("{}seam-manifest.json{}", col(DIM), col(RESET)));
Ok(manifest)
}
pub(crate) fn extract_manifest(
base_dir: &Path,
router_file: &str,
out_dir: &Path,
) -> Result<Manifest> {
let runtime = if which_exists("bun") { "bun" } else { "node" };
let script = format!(
"import('./{router_file}').then(m => {{ \
const r = m.router || m.default; \
console.log(JSON.stringify(r.manifest())); \
}})"
);
let spinner = ui::spinner(&format!("{runtime} -e \"import('{router_file}')...\""));
let output = Command::new(runtime)
.args(["-e", &script])
.current_dir(base_dir)
.output()
.with_context(|| format!("failed to run {runtime} for manifest extraction"))?;
if !output.status.success() {
spinner.finish_with("failed");
let stderr = String::from_utf8_lossy(&output.stderr);
bail!("manifest extraction failed:\n{stderr}");
}
spinner.finish();
let stdout = String::from_utf8(output.stdout).context("invalid UTF-8 from manifest output")?;
let manifest: Manifest =
serde_json::from_str(&stdout).context("failed to parse manifest JSON")?;
std::fs::create_dir_all(out_dir)
.with_context(|| format!("failed to create {}", out_dir.display()))?;
let manifest_path = out_dir.join("seam-manifest.json");
let json = serde_json::to_string_pretty(&manifest)?;
std::fs::write(&manifest_path, &json)
.with_context(|| format!("failed to write {}", manifest_path.display()))?;
ui::detail_ok(&format!("{}seam-manifest.json{}", col(DIM), col(RESET)));
Ok(manifest)
}
fn to_codegen_preference(
p: &crate::config::TransportPreference,
) -> seam_codegen::TransportPreference {
match p {
crate::config::TransportPreference::Http => seam_codegen::TransportPreference::Http,
crate::config::TransportPreference::Sse => seam_codegen::TransportPreference::Sse,
crate::config::TransportPreference::Ws => seam_codegen::TransportPreference::Ws,
crate::config::TransportPreference::Ipc => seam_codegen::TransportPreference::Ipc,
}
}
fn to_codegen_transport(tc: &crate::config::TransportConfig) -> seam_codegen::TransportConfig {
seam_codegen::TransportConfig {
prefer: to_codegen_preference(&tc.prefer),
fallback: tc.fallback.as_ref().map(|v| v.iter().map(to_codegen_preference).collect::<Vec<_>>()),
}
}
fn merge_transport_defaults(
defaults: &mut std::collections::BTreeMap<String, seam_codegen::TransportConfig>,
section: &crate::config::TransportSection,
) {
let pairs: &[(&str, &Option<crate::config::TransportConfig>)] = &[
("query", §ion.query),
("command", §ion.command),
("stream", §ion.stream),
("subscription", §ion.subscription),
("upload", §ion.upload),
("channel", §ion.channel),
];
for (kind, opt) in pairs {
if let Some(tc) = opt {
defaults.insert((*kind).to_string(), to_codegen_transport(tc));
}
}
}
fn fill_builtin_transport_defaults(
defaults: &mut std::collections::BTreeMap<String, seam_codegen::TransportConfig>,
) {
use seam_codegen::TransportPreference::{Http, Sse, Ws};
let builtins: &[(
&str,
seam_codegen::TransportPreference,
Option<Vec<seam_codegen::TransportPreference>>,
)] = &[
("query", Http, None),
("command", Http, None),
("stream", Sse, Some(vec![Http])),
("subscription", Sse, Some(vec![Http])),
("upload", Http, None),
("channel", Ws, Some(vec![Http])),
];
for (kind, prefer, fallback) in builtins {
defaults.entry((*kind).to_string()).or_insert_with(|| seam_codegen::TransportConfig {
prefer: *prefer,
fallback: fallback.clone(),
});
}
}
fn package_has_dep(pkg_path: &Path, dep_name: &str) -> bool {
let Ok(content) = std::fs::read_to_string(pkg_path) else { return false };
let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) else { return false };
pkg.get("dependencies").and_then(|d| d.get(dep_name)).is_some()
|| pkg.get("devDependencies").and_then(|d| d.get(dep_name)).is_some()
}
pub(crate) fn has_query_react_dep(base_dir: &Path, frontend_entry: Option<&str>) -> bool {
let dep_name = "@canmi/seam-query-react";
if package_has_dep(&base_dir.join("package.json"), dep_name) {
return true;
}
let Some(entry) = frontend_entry else { return false };
let mut dir = base_dir.join(entry).parent().map(Path::to_path_buf);
while let Some(current) = dir {
if package_has_dep(¤t.join("package.json"), dep_name) {
return true;
}
if current == base_dir {
break;
}
dir = current.parent().filter(|parent| parent.starts_with(base_dir)).map(Path::to_path_buf);
}
false
}
pub(crate) fn generate_types(
manifest: &Manifest,
config: &SeamConfig,
rpc_hashes: Option<&seam_codegen::RpcHashMap>,
base_dir: &Path,
) -> Result<()> {
let mut manifest = manifest.clone();
if let Some(ref section) = config.transport {
merge_transport_defaults(&mut manifest.transport_defaults, section);
}
fill_builtin_transport_defaults(&mut manifest.transport_defaults);
let code = seam_codegen::generate_typescript(&manifest, rpc_hashes, &config.frontend.data_id)?;
let line_count = code.lines().count();
let proc_count = manifest.procedures.len();
let emit_hooks = has_query_react_dep(base_dir, config.frontend.entry.as_deref());
let seam_dir = base_dir.join(".seam/generated");
std::fs::create_dir_all(&seam_dir)
.with_context(|| format!("failed to create {}", seam_dir.display()))?;
let primary_file = seam_dir.join("client.ts");
std::fs::write(&primary_file, &code)
.with_context(|| format!("failed to write {}", primary_file.display()))?;
std::fs::write(seam_dir.join("seam.d.ts"), seam_codegen::generate_type_declarations(emit_hooks))
.with_context(|| "failed to write .seam/generated/seam.d.ts")?;
if emit_hooks {
std::fs::write(seam_dir.join("hooks.ts"), seam_codegen::generate_hooks_module())
.with_context(|| "failed to write .seam/generated/hooks.ts")?;
}
let meta_code = format!(
"// Auto-generated by seam. Do not edit.\nexport const DATA_ID = \"{}\";\n",
config.frontend.data_id
);
std::fs::write(seam_dir.join("meta.ts"), &meta_code)
.with_context(|| "failed to write .seam/generated/meta.ts")?;
if config.build.pages_dir.is_none()
&& let Some(ref routes) = config.build.routes
{
let trimmed = routes.strip_prefix("./").unwrap_or(routes);
let import_path = std::path::Path::new(&format!("../../{trimmed}")).with_extension("js");
let routes_code = format!(
"// Auto-generated by seam. Do not edit.\nexport {{ default }} from '{}'\n",
import_path.display()
);
std::fs::write(seam_dir.join("routes.ts"), &routes_code)
.with_context(|| "failed to write .seam/generated/routes.ts")?;
}
if let Some(ref user_dir) = config.generate.out_dir {
let out = base_dir.join(user_dir);
std::fs::create_dir_all(&out).with_context(|| format!("failed to create {}", out.display()))?;
let file = out.join("client.ts");
std::fs::write(&file, &code).with_context(|| format!("failed to write {}", file.display()))?;
}
let display_path = match config.generate.out_dir.as_deref() {
Some(dir) => format!("{dir}/client.ts"),
None => ".seam/generated/client.ts".to_string(),
};
ui::detail_ok(&format!("{proc_count} procedures \u{2192} {display_path} ({line_count} lines)",));
Ok(())
}
pub(crate) fn run_typecheck(base_dir: &Path, command: &str) -> Result<()> {
run_command(base_dir, command, "type checker", &[])?;
ui::detail_ok(&format!("{}passed{}", col(GREEN), col(RESET)));
Ok(())
}
pub(crate) fn package_public_files(base_dir: &Path, out_dir: &Path) -> Result<usize> {
let src = base_dir.join("public");
if !src.is_dir() {
return Ok(0);
}
let dst = out_dir.join("public-root");
let mut count = 0usize;
copy_dir_recursive(&src, &src, &dst, &mut count)?;
Ok(count)
}
pub(crate) fn package_static_assets(
base_dir: &Path,
out_dir: &Path,
dist_dir: &str,
) -> Result<usize> {
let src_root = base_dir.join(dist_dir);
let public_dir = out_dir.join("public");
let mut count = 0usize;
copy_dir_recursive(&src_root, &src_root, &public_dir, &mut count)?;
Ok(count)
}
fn copy_dir_recursive(root: &Path, src: &Path, dst: &Path, count: &mut usize) -> Result<()> {
let entries = std::fs::read_dir(src);
let entries = match entries {
Ok(e) => e,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e).with_context(|| format!("failed to read {}", src.display())),
};
for entry in entries {
let entry = entry?;
let name = entry.file_name();
if name == ".vite" {
continue;
}
let src_path = entry.path();
let dst_path = dst.join(&name);
if entry.file_type()?.is_dir() {
copy_dir_recursive(root, &src_path, &dst_path, count)?;
} else {
std::fs::create_dir_all(dst)
.with_context(|| format!("failed to create {}", dst.display()))?;
std::fs::copy(&src_path, &dst_path).with_context(|| {
format!("failed to copy {} -> {}", src_path.display(), dst_path.display())
})?;
let rel = src_path.strip_prefix(root).unwrap_or(&src_path);
let size = std::fs::metadata(&dst_path).map(|m| m.len()).unwrap_or(0);
ui::detail_ok(&format!(
"{}public/{} ({}){}",
col(DIM),
rel.display(),
ui::format_size(size),
col(RESET)
));
*count += 1;
}
}
Ok(())
}