use std::{fs, process::Command};
fn cargo_bin() -> std::path::PathBuf {
let bin = env!("CARGO_BIN_EXE_reflectapi");
std::path::PathBuf::from(bin)
}
fn demo_schema() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.parent()
.unwrap()
.join("reflectapi-demo")
.join("reflectapi.json")
}
fn run(args: &[&str]) -> std::process::Output {
Command::new(cargo_bin())
.args(args)
.output()
.expect("spawn reflectapi")
}
#[cfg(unix)]
fn run_with_path(args: &[&str], path: &std::path::Path) -> std::process::Output {
Command::new(cargo_bin())
.args(args)
.env("PATH", path)
.output()
.expect("spawn reflectapi")
}
fn write_minimal_python_schema(path: &std::path::Path, type_name: &str) {
let schema = format!(
r#"{{
"name": "CLI stale cleanup test",
"description": "",
"functions": [
{{
"name": "items.get",
"path": "",
"output_kind": "complete",
"output_type": {{ "name": "{type_name}" }},
"serialization": ["json"],
"readonly": true
}}
],
"input_types": {{ "types": [] }},
"output_types": {{
"types": [
{{
"kind": "primitive",
"name": "std::string::String",
"description": "String"
}},
{{
"kind": "struct",
"name": "{type_name}",
"fields": {{
"named": [
{{
"name": "value",
"type": {{ "name": "std::string::String" }},
"required": true
}}
]
}}
}}
]
}}
}}"#
);
fs::write(path, schema).unwrap();
}
#[test]
fn ts_output_into_fresh_directory() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("brand-new-dir");
let schema = demo_schema();
let out = run(&[
"codegen",
"--language",
"typescript",
"--schema",
schema.to_str().unwrap(),
"--output",
target.to_str().unwrap(),
]);
assert!(
out.status.success(),
"exit={:?}\nstderr:\n{}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
assert!(target.is_dir(), "expected fresh dir to be created");
assert!(target.join("generated.ts").is_file());
assert!(target.join("generated.transport.ts").is_file());
}
#[test]
fn python_output_into_fresh_directory() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("python-client");
let schema = demo_schema();
let out = run(&[
"codegen",
"--language",
"python",
"--format=false",
"--schema",
schema.to_str().unwrap(),
"--output",
target.to_str().unwrap(),
]);
assert!(
out.status.success(),
"exit={:?}\nstderr:\n{}",
out.status.code(),
String::from_utf8_lossy(&out.stderr)
);
assert!(target.is_dir());
assert!(target.join("generated.py").is_file());
assert!(target.join("__init__.py").is_file());
}
#[test]
fn python_directory_output_removes_stale_generated_files() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("python-client");
fs::create_dir_all(target.join("legacy")).unwrap();
fs::write(
target.join("legacy/__init__.py"),
"\"\"\"\nDO NOT MODIFY THIS FILE MANUALLY\nThis file was generated by reflectapi-cli\n\"\"\"\n",
)
.unwrap();
fs::write(target.join("user_notes.py"), "# hand written\n").unwrap();
let schema_one = tmp.path().join("schema-one.json");
let schema_two = tmp.path().join("schema-two.json");
write_minimal_python_schema(&schema_one, "first::child::Thing");
write_minimal_python_schema(&schema_two, "second::Thing");
let out = run(&[
"codegen",
"--language",
"python",
"--format=false",
"--schema",
schema_one.to_str().unwrap(),
"--output",
target.to_str().unwrap(),
]);
assert!(
out.status.success(),
"stderr:\n{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!target.join("legacy/__init__.py").exists(),
"pre-manifest generated files should be removed by header scan"
);
assert!(target.join("first/child/__init__.py").is_file());
assert!(target.join("user_notes.py").is_file());
let out = run(&[
"codegen",
"--language",
"python",
"--format=false",
"--schema",
schema_two.to_str().unwrap(),
"--output",
target.to_str().unwrap(),
]);
assert!(
out.status.success(),
"stderr:\n{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!target.join("first/child/__init__.py").exists(),
"manifest-listed files absent from the new generation should be removed"
);
assert!(target.join("second/__init__.py").is_file());
assert!(target.join("user_notes.py").is_file());
let manifest = fs::read_to_string(target.join(".reflectapi-generated-files")).unwrap();
assert!(manifest.contains("second/__init__.py"));
assert!(!manifest.contains("first/child/__init__.py"));
}
#[cfg(unix)]
#[test]
fn python_legacy_cleanup_skips_symlinked_directories() {
use std::os::unix::fs::symlink;
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("python-client");
let external = tmp.path().join("external");
fs::create_dir_all(&target).unwrap();
fs::create_dir_all(&external).unwrap();
fs::write(
external.join("__init__.py"),
"\"\"\"\nDO NOT MODIFY THIS FILE MANUALLY\nThis file was generated by reflectapi-cli\n\"\"\"\n",
)
.unwrap();
symlink(&external, target.join("linked")).unwrap();
let schema = tmp.path().join("schema.json");
write_minimal_python_schema(&schema, "current::Thing");
let out = run(&[
"codegen",
"--language",
"python",
"--format=false",
"--schema",
schema.to_str().unwrap(),
"--output",
target.to_str().unwrap(),
]);
assert!(
out.status.success(),
"stderr:\n{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
external.join("__init__.py").is_file(),
"legacy cleanup should not follow directory symlinks out of the output tree"
);
assert!(target.join("linked").is_symlink());
assert!(target.join("current/__init__.py").is_file());
}
#[cfg(unix)]
#[test]
fn python_format_falls_back_when_ruff_is_missing() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("python-client");
let empty_path = tmp.path().join("bin");
fs::create_dir_all(&empty_path).unwrap();
let schema = tmp.path().join("schema.json");
write_minimal_python_schema(&schema, "current::Thing");
let out = run_with_path(
&[
"codegen",
"--language",
"python",
"--schema",
schema.to_str().unwrap(),
"--output",
target.to_str().unwrap(),
],
&empty_path,
);
assert!(
out.status.success(),
"stderr:\n{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(target.join("current/__init__.py").is_file());
}
#[cfg(unix)]
#[test]
fn python_format_false_does_not_require_ruff() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("python-client");
let empty_path = tmp.path().join("bin");
fs::create_dir_all(&empty_path).unwrap();
let schema = tmp.path().join("schema.json");
write_minimal_python_schema(&schema, "current::Thing");
let out = run_with_path(
&[
"codegen",
"--language",
"python",
"--format=false",
"--schema",
schema.to_str().unwrap(),
"--output",
target.to_str().unwrap(),
],
&empty_path,
);
assert!(
out.status.success(),
"stderr:\n{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(target.join("current/__init__.py").is_file());
}
#[cfg(unix)]
#[test]
fn python_format_reports_ruff_failures() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("python-client");
let bin = tmp.path().join("bin");
fs::create_dir_all(&bin).unwrap();
let ruff = bin.join("ruff");
fs::write(&ruff, "#!/bin/sh\necho 'ruff exploded' >&2\nexit 2\n").unwrap();
let mut permissions = fs::metadata(&ruff).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&ruff, permissions).unwrap();
let schema = tmp.path().join("schema.json");
write_minimal_python_schema(&schema, "current::Thing");
let out = run_with_path(
&[
"codegen",
"--language",
"python",
"--schema",
schema.to_str().unwrap(),
"--output",
target.to_str().unwrap(),
],
&bin,
);
assert!(
!out.status.success(),
"expected ruff failure to fail codegen"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("failed to format generated Python code with `ruff format`"));
assert!(stderr.contains("command failed with exit code"));
assert!(stderr.contains("Fix Ruff or pass `--format=false`"));
}
#[test]
fn ts_output_to_file_path_writes_siblings() {
let tmp = tempfile::tempdir().unwrap();
let target = tmp.path().join("generated.ts");
let schema = demo_schema();
let out = run(&[
"codegen",
"--language",
"typescript",
"--schema",
schema.to_str().unwrap(),
"--output",
target.to_str().unwrap(),
]);
assert!(
out.status.success(),
"stderr:\n{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(target.is_file(), "primary file at requested path");
assert!(tmp.path().join("generated.transport.ts").is_file());
}
#[test]
fn ts_stdout_emits_primary_file_not_transport() {
let schema = demo_schema();
let out = run(&[
"codegen",
"--language",
"typescript",
"--schema",
schema.to_str().unwrap(),
"--output",
"-",
]);
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("export function client(") || stdout.contains("export function client "),
"expected stdout to be generated.ts (with the `client` factory). got:\n{stdout}",
);
}
#[test]
fn python_stdout_emits_generated_not_init() {
let schema = demo_schema();
let out = run(&[
"codegen",
"--language",
"python",
"--format=false",
"--schema",
schema.to_str().unwrap(),
"--output",
"-",
]);
assert!(out.status.success());
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("from ._rebuild import rebuild_models")
&& stdout.contains("from .myapi.proto import"),
"expected stdout to be generated.py compatibility facade. got:\n{stdout}",
);
}