#![forbid(unsafe_code)]
use clap::{Parser, Subcommand};
use jerrycan::platform::design::Design;
use jerrycan::platform::{
EXIT_OK, EXIT_USAGE, Failure, checkpipe, genroute, mounting, package, questions, scaffold,
};
use std::path::{Path, PathBuf};
#[derive(Parser)]
#[command(
name = "jerrycan",
version,
about = "The AI-native Rust backend platform"
)]
struct Cli {
#[arg(long, global = true)]
json: bool,
#[command(subcommand)]
command: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
New {
name: String,
#[arg(long)]
design: String,
},
#[command(alias = "g")]
Generate {
#[command(subcommand)]
what: GenerateCmd,
},
List {
#[command(subcommand)]
what: ListCmd,
},
Dev {
#[arg(long)]
addr: Option<String>,
},
Check {
#[arg(long)]
module: Option<String>,
},
Test {
#[arg(long)]
module: Option<String>,
},
GenTests {
#[arg(long)]
module: String,
},
Docs {
topic: Option<String>,
#[arg(long)]
search: Option<String>,
},
Explain { code: String },
Add { extension: String },
Db {
#[command(subcommand)]
what: DbCmd,
},
Package {
#[arg(long)]
docker: bool,
#[arg(long)]
binary: bool,
#[arg(long)]
k8s: bool,
#[arg(long)]
systemd: bool,
},
Mcp,
}
#[derive(Subcommand)]
enum DbCmd {
Migrate {
#[arg(long)]
url: Option<String>,
},
}
#[derive(Subcommand)]
enum GenerateCmd {
Route { path: String },
Dep {
name: String,
#[arg(long)]
module: String,
},
}
#[derive(Subcommand)]
enum ListCmd {
Routes,
}
fn main() {
let cli = match Cli::try_parse() {
Ok(c) => c,
Err(e) => {
if e.use_stderr() {
eprint!("{e}");
std::process::exit(EXIT_USAGE);
}
print!("{e}");
std::process::exit(EXIT_OK);
}
};
let result: Result<(), Failure> = run(cli);
match result {
Ok(()) => std::process::exit(EXIT_OK),
Err(f) => {
eprintln!("error: {}", f.message);
std::process::exit(f.exit);
}
}
}
fn run(cli: Cli) -> Result<(), Failure> {
match cli.command {
Cmd::New { name, design } => cmd_new(&name, &design, cli.json),
Cmd::Generate { what } => match what {
GenerateCmd::Route { path } => cmd_generate_route(&path, cli.json),
GenerateCmd::Dep { name, module } => cmd_generate_dep(&name, &module, cli.json),
},
Cmd::List {
what: ListCmd::Routes,
} => cmd_list_routes(cli.json),
Cmd::Dev { addr } => cmd_dev(addr.as_deref()),
Cmd::Check { module } => cmd_check(module.as_deref(), cli.json),
Cmd::Test { module } => cmd_test(module.as_deref()),
Cmd::GenTests { module } => cmd_gen_tests(&module, cli.json),
Cmd::Docs { topic, search } => cmd_docs(topic.as_deref(), search.as_deref(), cli.json),
Cmd::Explain { code } => cmd_explain(&code, cli.json),
Cmd::Add { extension } => cmd_add(&extension, cli.json),
Cmd::Db {
what: DbCmd::Migrate { url },
} => cmd_db_migrate(url.as_deref(), cli.json),
Cmd::Package {
docker,
binary,
k8s,
systemd,
} => cmd_package(docker, binary, k8s, systemd, cli.json),
Cmd::Mcp => jerrycan::platform::mcp::serve_stdio().map_err(Failure::environment),
}
}
fn cmd_docs(topic: Option<&str>, query: Option<&str>, json_mode: bool) -> Result<(), Failure> {
use jerrycan::platform::docsidx;
if let Some(q) = query {
let results = docsidx::search(q, 5);
let payload = serde_json::json!({ "results": results });
if json_mode {
println!("{payload}");
} else {
for r in payload["results"].as_array().unwrap() {
println!(
"{} ({}#{})",
r["snippet"].as_str().unwrap(),
r["page"].as_str().unwrap(),
r["anchor"].as_str().unwrap_or("")
);
}
}
return Ok(());
}
let Some(topic) = topic else {
return Err(Failure::usage(
"provide a topic (`jerrycan docs dependencies`) or --search <query>",
));
};
let (page, anchor) = match topic.split_once('#') {
Some((p, a)) => (p, Some(a)),
None => (topic, None),
};
let md = docsidx::get(page, anchor).ok_or_else(|| {
let names: Vec<&str> = docsidx::PAGES.iter().map(|(n, _)| *n).collect();
Failure::usage(format!(
"unknown docs page `{page}` — available: {}",
names.join(", ")
))
})?;
if json_mode {
println!("{}", serde_json::json!({ "markdown": md }));
} else {
println!("{md}");
}
Ok(())
}
fn cmd_explain(code: &str, json_mode: bool) -> Result<(), Failure> {
let info = jerrycan::platform::codes::lookup(code).ok_or_else(|| {
Failure::usage(format!(
"unknown code `{code}` — see `jerrycan explain JC0404` for the format"
))
})?;
if json_mode {
println!(
"{}",
serde_json::json!({
"code": info.code, "title": info.title, "cause": info.cause, "fix": info.fix, "doc": info.doc,
})
);
} else {
println!("{} — {}", info.code, info.title);
println!("\ncause: {}", info.cause);
println!("fix: {}", info.fix);
println!("docs: {}", info.doc);
}
Ok(())
}
fn emit(json_mode: bool, payload: &serde_json::Value, human: &str) {
if json_mode {
println!("{payload}");
}
eprintln!("{human}");
}
fn load_design(path: &Path) -> Result<Design, Failure> {
Design::from_path(path).map_err(Failure::usage)
}
fn require_complete(design: &Design, json_mode: bool) -> Result<(), Failure> {
let qs = questions::validate(design);
if qs.is_empty() {
return Ok(());
}
let payload = serde_json::json!({
"status": "questions",
"questions": qs,
"next_step": "answer the questions, fix design.json, and re-run",
});
if json_mode {
println!("{payload}");
}
let mut human = String::from("design is incomplete:\n");
for q in &qs {
human.push_str(&format!(" {} — {}\n", q.id, q.question));
}
Err(Failure::gate(human))
}
fn cmd_new(target: &str, design_path: &str, json_mode: bool) -> Result<(), Failure> {
let design = load_design(Path::new(design_path))?;
require_complete(&design, json_mode)?;
let created = scaffold::scaffold(Path::new(target), &design).map_err(Failure::gate)?;
let payload = serde_json::json!({
"created": created,
"next_step": format!("cd {target} && jerrycan check — then implement the handler stubs"),
});
emit(
json_mode,
&payload,
&format!(
"scaffolded {} files into {target}",
payload["created"].as_array().map(Vec::len).unwrap_or(0)
),
);
Ok(())
}
fn app_root() -> Result<PathBuf, Failure> {
let cwd = std::env::current_dir().map_err(|e| Failure::environment(e.to_string()))?;
if cwd.join("design.json").exists() {
Ok(cwd)
} else {
Err(Failure::usage(
"no design.json here — run inside a jerrycan app (or scaffold one with `jerrycan new`) — if you're in a subdirectory, cd to the app root.",
))
}
}
fn cmd_generate_route(module_path: &str, json_mode: bool) -> Result<(), Failure> {
let root = app_root()?;
let design = load_design(&root.join("design.json"))?;
require_complete(&design, json_mode)?;
let top = module_path
.split('/')
.next()
.expect("split yields at least one");
if genroute::module_by_path(&design, module_path).is_none() {
return Err(Failure::usage(format!(
"module `{module_path}` is not in design.json — add it there first (the design is the source of truth)"
)));
}
let top_module = design
.modules
.iter()
.find(|m| m.name == top)
.expect("checked above");
let mode = genroute::GenMode {
db: design.wants_db(),
auth: design.wants_auth(),
};
let created = genroute::write_module(&root.join("crates/routes"), top_module, mode)
.map_err(Failure::gate)?;
let modified = mounting::regenerate(&root, &design).map_err(Failure::gate)?;
let payload = serde_json::json!({
"created": created,
"modified": modified,
"next_step": format!("implement crates/routes/{top}/src/handlers.rs, then jerrycan check --module {top} — note: regeneration mirrors design.json exactly; routes removed there are removed here (stale agent files are not deleted)"),
});
emit(
json_mode,
&payload,
&format!("generated `{module_path}` and rewired mounting"),
);
Ok(())
}
fn cmd_generate_dep(name: &str, module: &str, json_mode: bool) -> Result<(), Failure> {
let root = app_root()?;
let mut design = load_design(&root.join("design.json"))?;
genroute::add_dependency(&mut design, module, name).map_err(Failure::usage)?;
std::fs::write(
root.join("design.json"),
scaffold::canonical_design_json(&design),
)
.map_err(|e| Failure::gate(e.to_string()))?;
let deps_rel = {
let mut parts = module.split('/');
let top = parts.next().unwrap_or(module);
let mut p = format!("crates/routes/{top}/src");
for sub in parts {
p.push_str(&format!("/subroutes/{}", sub.replace('-', "_")));
}
format!("{p}/deps.rs")
};
let payload = serde_json::json!({
"created": [],
"modified": ["design.json"],
"next_step": format!("define `{name}` in {deps_rel} (configure hook)"),
});
emit(
json_mode,
&payload,
&format!("recorded dependency `{name}` on module `{module}`"),
);
Ok(())
}
fn cmd_add(extension: &str, json_mode: bool) -> Result<(), Failure> {
if !matches!(extension, "db" | "validate") {
return Err(Failure::usage(format!(
"unknown extension `{extension}` — available: db, validate"
)));
}
let root = app_root()?;
let design_path = root.join("design.json");
let mut design = load_design(&design_path)?;
if !design.dependencies.iter().any(|d| d == extension) {
design.dependencies.push(extension.to_string());
}
std::fs::write(&design_path, scaffold::canonical_design_json(&design))
.map_err(|e| Failure::gate(e.to_string()))?;
let mode = genroute::GenMode {
db: design.wants_db(),
auth: design.wants_auth(),
};
for m in &design.modules {
genroute::write_module(&root.join("crates/routes"), m, mode).map_err(Failure::gate)?;
}
let mut modified = mounting::regenerate(&root, &design).map_err(Failure::gate)?;
modified.extend(scaffold::write_policy_files(&root, &design).map_err(Failure::gate)?);
modified.push("design.json".to_string());
modified.sort();
modified.dedup();
let next_step = if extension == "db" {
"`db` wired — policy files updated. NOTE: existing modules keep their in-memory repo.rs (agent-owned); rewrite each crates/routes/<m>/src/repo.rs to the SQL form (or delete it and re-run `jerrycan generate route <m>`) before the build will pass. Then jerrycan check.".to_string()
} else {
format!("`{extension}` wired — review the regenerated mounting, then jerrycan check")
};
let payload = serde_json::json!({
"created": [],
"modified": modified,
"next_step": next_step,
});
emit(json_mode, &payload, &format!("added `{extension}`"));
Ok(())
}
fn cmd_db_migrate(url: Option<&str>, json_mode: bool) -> Result<(), Failure> {
let root = app_root()?;
let design = load_design(&root.join("design.json"))?;
if !design.wants_db() {
return Err(Failure::usage(
"this app has no `db` dependency — run `jerrycan add db` first",
));
}
let pairs = mounting::collect_migrations(&root).map_err(Failure::gate)?;
let url = url
.map(str::to_string)
.or_else(|| std::env::var("JERRYCAN_DATABASE_URL").ok())
.ok_or_else(|| Failure::usage("provide --url or set JERRYCAN_DATABASE_URL"))?;
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| Failure::environment(e.to_string()))?;
let applied = runtime
.block_on(async {
let db = jerrycan::db::Db::connect(&url).await?;
db.migrate_owned(&pairs).await
})
.map_err(|e| Failure::gate(e.message().to_string()))?;
let payload = serde_json::json!({
"applied": applied,
"next_step": if applied.is_empty() { "database already up to date" } else { "migrations applied — jerrycan check" },
});
emit(
json_mode,
&payload,
&format!(
"applied {} migration(s)",
payload["applied"].as_array().map(Vec::len).unwrap_or(0)
),
);
Ok(())
}
fn cmd_package(
docker: bool,
binary: bool,
k8s: bool,
systemd: bool,
json_mode: bool,
) -> Result<(), Failure> {
let root = app_root()?;
let design = load_design(&root.join("design.json"))?;
let (artifacts, sbom) = package::run_package(&root, &design, docker, k8s, systemd, binary)
.map_err(Failure::gate)?;
let payload = serde_json::json!({
"artifacts": artifacts,
"sbom": sbom,
"next_step": "deploy with your own tooling (kubectl apply -f deploy/k8s.yaml, docker build, scp the binary + systemd unit)",
});
emit(
json_mode,
&payload,
&format!("packaged {} artifact(s)", artifacts.len()),
);
Ok(())
}
fn cmd_list_routes(json_mode: bool) -> Result<(), Failure> {
let root = app_root()?;
let design = load_design(&root.join("design.json"))?;
let routes = genroute::route_map(&design);
let payload = serde_json::json!({ "routes": routes });
let mut human = String::new();
for r in &routes {
human.push_str(&format!(
"{:6} {} → {}::{}\n",
r.method, r.path, r.module, r.handler
));
}
emit(json_mode, &payload, human.trim_end());
Ok(())
}
fn cmd_check(module: Option<&str>, json_mode: bool) -> Result<(), Failure> {
let root = app_root()?;
let design = load_design(&root.join("design.json"))?;
if module.is_some() {
eprintln!(
"note: audit/deny skipped in module scope — run a full `jerrycan check` before packaging"
);
}
let report = checkpipe::run_all(&root, &design, module).map_err(Failure::environment)?;
if json_mode {
println!(
"{}",
serde_json::to_string(&report).expect("report serializes")
);
}
for d in &report.diagnostics {
eprintln!("error[{}]: {}", d.code, d.message);
if let (Some(f), Some(l)) = (&d.file, d.line) {
eprintln!(" --> {f}:{l}");
} else if let Some(f) = &d.file {
eprintln!(" --> {f}");
}
if let Some(s) = &d.suggestion {
eprintln!(" = help: {s}");
}
if let Some(u) = &d.doc_url {
eprintln!(" = docs: {u}");
}
}
if report.ok {
eprintln!("check: all green");
Ok(())
} else {
Err(Failure::gate(report.next_step))
}
}
fn cmd_test(module: Option<&str>) -> Result<(), Failure> {
let root = app_root()?;
let mut c = std::process::Command::new("cargo");
c.current_dir(&root).arg("test");
match module {
Some(m) => c.args(["-p", &format!("route-{m}")]),
None => c.arg("--workspace"),
};
let status = c
.status()
.map_err(|e| Failure::environment(e.to_string()))?;
if status.success() {
Ok(())
} else {
Err(Failure::gate("test suite failed"))
}
}
fn cmd_gen_tests(module: &str, json_mode: bool) -> Result<(), Failure> {
let root = app_root()?;
let design = load_design(&root.join("design.json"))?;
let (rel, count) = jerrycan::platform::testgen::write_acceptance(&root, &design, module)
.map_err(Failure::usage)?;
let payload = serde_json::json!({
"tests_created": [rel],
"expected_failing": count,
"next_step": format!("cargo test -p route-{module} (expect {count} failures), implement handlers, iterate"),
});
emit(
json_mode,
&payload,
&format!("{count} acceptance tests written to {rel}"),
);
Ok(())
}
fn cmd_dev(addr: Option<&str>) -> Result<(), Failure> {
use jerrycan::platform::newest_mtime;
let root = app_root()?;
eprintln!("jerrycan dev: watching {} (Ctrl-C to stop)", root.display());
loop {
let stamp = newest_mtime(&root);
let mut child = {
let mut c = std::process::Command::new("cargo");
c.current_dir(&root).args(["run", "-p", "app"]);
if let Some(a) = addr {
c.env("JERRYCAN_ADDR", a);
}
c.spawn()
.map_err(|e| Failure::environment(format!("cargo run failed to start: {e}")))?
};
loop {
std::thread::sleep(std::time::Duration::from_millis(500));
if let Ok(Some(status)) = child.try_wait() {
eprintln!("app exited ({status}); waiting for changes…");
while newest_mtime(&root) <= stamp {
std::thread::sleep(std::time::Duration::from_millis(500));
}
break;
}
if newest_mtime(&root) > stamp {
eprintln!("change detected — restarting");
let _ = child.kill();
let _ = child.wait();
break;
}
}
}
}