use std::path::{Path, PathBuf};
use std::process::Command;
use assert_cmd::Command as AssertCommand;
use serde_json::json;
use tempfile::TempDir;
fn snapshot_json() -> Vec<u8> {
let input = r#"{"type":"object","properties":{"query":{"type":"string"},"max_results":{"type":"integer"},"sort-order":{"type":"string"}},"required":["query"]}"#;
let output = r##"{"type":"object","properties":{"results":{"type":"array","items":{"$ref":"#/$defs/Result"}}},"$defs":{"Result":{"type":"object","properties":{"url":{"type":"string"},"title":{"type":"string"}},"required":["url","title"]}}}"##;
let snapshot = json!({
"format_version": 1,
"captured_at": "2026-06-04T10:00:00Z",
"source_query": { "tags": [], "tools": [] },
"descriptors": [{
"tool_id": "acme/web_search", "name": "Web Search", "version": "1.2.0",
"description": "Search the web for query terms",
"input_schema": input, "output_schema": output,
"requires": [], "estimated_time_ms": 800, "stateless": true,
"streaming": false, "tags": ["search"], "node_count": 1
}]
});
serde_json::to_vec_pretty(&snapshot).expect("serialize snapshot")
}
fn python() -> Option<String> {
for cand in ["python", "python3", "py"] {
let ok = Command::new(cand)
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if ok {
return Some(cand.to_string());
}
}
None
}
fn tool_available(py: &str, args: &[&str]) -> bool {
Command::new(py)
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn python_files(root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if matches!(
path.extension().and_then(|e| e.to_str()),
Some("py") | Some("pyi")
) {
out.push(path);
}
}
}
out
}
#[test]
fn generated_python_compiles_and_typechecks() {
let Some(py) = python() else {
eprintln!("skipping E-3: no python interpreter found");
return;
};
let home = TempDir::new().expect("home");
let work = TempDir::new().expect("work");
let snap = work.path().join("tools.snapshot");
let out = work.path().join("generated");
std::fs::write(&snap, snapshot_json()).expect("write snapshot");
let mut gen = AssertCommand::cargo_bin("net-mesh").expect("cargo_bin");
gen.env("HOME", home.path())
.env("XDG_CONFIG_HOME", home.path())
.env("USERPROFILE", home.path());
let status = gen
.args([
"typegen",
"generate",
"--language",
"python",
"--from-snapshot",
snap.to_str().expect("snap"),
"--out",
out.to_str().expect("out"),
])
.output()
.expect("invoke net-mesh");
assert!(
status.status.success(),
"generate failed: {}",
String::from_utf8_lossy(&status.stderr)
);
std::fs::write(
work.path().join("consumer.py"),
"from generated.acme_web_search import (\n\
\x20 AcmeWebSearchRequest,\n\
\x20 AcmeWebSearchResponse,\n\
\x20 call_acme_web_search,\n\
)\n\
\n\
\n\
def build() -> AcmeWebSearchRequest:\n\
\x20 # Omits the optional max_results / sort-order: optional fields\n\
\x20 # must be omittable under mypy --strict (the .pyi default fix).\n\
\x20 return AcmeWebSearchRequest(query=\"net mesh\")\n\
\n\
\n\
def count(res: AcmeWebSearchResponse) -> int:\n\
\x20 return len(res.results or [])\n\
\n\
\n\
__all__ = [\"build\", \"count\", \"call_acme_web_search\"]\n",
)
.expect("write consumer");
let files = python_files(&out);
assert!(!files.is_empty(), "no generated python files found");
let mut args = vec![
"-c".to_string(),
"import ast, sys\nfor f in sys.argv[1:]:\n ast.parse(open(f, encoding='utf-8').read(), f)\n".to_string(),
];
args.extend(files.iter().map(|f| f.display().to_string()));
let parse = Command::new(&py)
.args(&args)
.output()
.expect("run python ast parse");
assert!(
parse.status.success(),
"generated python failed to parse:\n{}",
String::from_utf8_lossy(&parse.stderr)
);
let have_mypy = tool_available(&py, &["-m", "mypy", "--version"]);
let have_pydantic = tool_available(&py, &["-c", "import pydantic"]);
if have_pydantic {
let probe = Command::new(&py)
.args([
"-c",
"from generated.acme_web_search import AcmeWebSearchRequest\n\
r = AcmeWebSearchRequest(query='q', sort_order='asc')\n\
d = r.model_dump(exclude_none=True, by_alias=True)\n\
assert d['sort-order'] == 'asc', d\n\
assert d['query'] == 'q', d\n",
])
.current_dir(work.path())
.output()
.expect("run pydantic runtime probe");
assert!(
probe.status.success(),
"pydantic rejected construction by attr name (populate_by_name missing?):\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&probe.stdout),
String::from_utf8_lossy(&probe.stderr)
);
}
if !have_mypy || !have_pydantic {
eprintln!(
"skipping E-3 mypy portion (mypy={have_mypy}, pydantic={have_pydantic}); syntax gate passed"
);
return;
}
std::fs::write(
work.path().join("mypy.ini"),
"[mypy]\nstrict = True\nplugins = pydantic.mypy\n",
)
.expect("write mypy.ini");
let mypy = Command::new(&py)
.args(["-m", "mypy", "--config-file", "mypy.ini", "consumer.py"])
.current_dir(work.path())
.output()
.expect("run mypy");
assert!(
mypy.status.success(),
"mypy --strict rejected the generated python:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&mypy.stdout),
String::from_utf8_lossy(&mypy.stderr)
);
}