use std::process::Command;
fn binary() -> &'static str {
env!("CARGO_BIN_EXE_codenexus")
}
fn run(args: &[&str]) -> (i32, String, String) {
let output = Command::new(binary())
.args(args)
.env("CODENEXUS_LANG", "en")
.output()
.unwrap_or_else(|e| panic!("failed to spawn codenexus: {e}"));
(
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stdout).to_string(),
String::from_utf8_lossy(&output.stderr).to_string(),
)
}
#[test]
fn help_lists_all_commands() {
let (code, stdout, _) = run(&["--help"]);
assert_eq!(code, 0, " --help should exit 0");
assert!(stdout.contains("Usage:"), "should show Usage line");
for cmd in [
"index",
"query",
"search",
"trace",
"impact",
"context",
"dead_code",
"architecture",
"complexity",
"community",
"cross_service",
"route_map",
"tool_map",
"shape_check",
"api_impact",
"diagram",
"arch_diff",
"detect_changes",
"rename",
"lsp_goto_def",
"lsp_hover",
"export",
"import",
"setup",
"hook",
"skill",
"ask",
"ci",
"lint",
"taint",
"supply",
"evolve",
"hub",
"list",
"status",
"clean",
"daemon",
] {
assert!(
stdout.contains(cmd),
"`--help` should list the `{cmd}` command, got:\n{stdout}"
);
}
}
#[test]
#[cfg(feature = "mcp")]
fn help_lists_mcp_serve_mode() {
let (code, stdout, _) = run(&["--help"]);
assert_eq!(code, 0, "--help should exit 0");
assert!(
stdout.contains("mcp"),
"`--help` should list the `mcp` serve mode, got:\n{stdout}"
);
}
#[test]
fn version_flag_prints_version() {
let (code, stdout, _) = run(&["--version"]);
assert_eq!(code, 0, "--version should exit 0");
assert!(
stdout.contains("codenexus") || stdout.contains("CodeNexus"),
"version output should contain crate name, got: {stdout}"
);
}
#[test]
fn no_subcommand_exits_gracefully() {
let (code, stdout, _) = run(&[]);
assert_eq!(code, 0, "no subcommand should exit 0");
assert!(
stdout.contains("--help"),
"should suggest --help, got: {stdout}"
);
}
#[test]
fn list_command_works_with_empty_db() {
let tmp = tempfile::NamedTempFile::new().expect("create temp db file");
let db_path = tmp.path().to_str().expect("db path to str");
let (code, _stdout, stderr) = run(&["--db", db_path, "list"]);
assert_eq!(code, 0, "list should exit 0 on empty db, stderr: {stderr}");
}
#[test]
fn status_command_works_with_empty_db() {
let tmp = tempfile::NamedTempFile::new().expect("create temp db file");
let db_path = tmp.path().to_str().expect("db path to str");
let (code, _, stderr) = run(&["--db", db_path, "status"]);
assert_eq!(
code, 0,
"status should exit 0 on empty db, stderr: {stderr}"
);
}
#[test]
fn query_command_returns_result() {
let tmp = tempfile::NamedTempFile::new().expect("create temp db file");
let db_path = tmp.path().to_str().expect("db path to str");
let (code, stdout, stderr) = run(&["--db", db_path, "query", "--cypher", "RETURN 1 AS one"]);
assert_eq!(code, 0, "query should exit 0, stderr: {stderr}");
assert!(
stdout.contains("\"one\"") || stdout.contains("one"),
"query output should contain column 'one', got: {stdout}"
);
}
#[test]
fn unknown_subcommand_exits_with_error() {
let (code, _, stderr) = run(&["nonexistent_command"]);
assert_ne!(
code, 0,
"unknown command should exit non-zero, stderr: {stderr}"
);
}
#[test]
fn corrupt_db_exits_4_with_clear_message() {
let tmp = tempfile::NamedTempFile::new().expect("create temp db file");
std::fs::write(tmp.path(), b"this is not a valid ladybugdb file").expect("write corrupt bytes");
let db_path = tmp.path().to_str().expect("db path to str");
let mut cases: Vec<Vec<String>> = vec![
vec!["--db".into(), db_path.into(), "list".into()],
vec!["--db".into(), db_path.into(), "status".into()],
vec![
"--db".into(),
db_path.into(),
"query".into(),
"--cypher".into(),
"RETURN 1 AS one".into(),
],
];
let repo = tempfile::TempDir::new().expect("create repo dir");
cases.push(vec![
"--db".into(),
db_path.into(),
"index".into(),
"--path".into(),
repo.path().to_str().expect("repo path to str").into(),
"--name".into(),
"corrupt_demo".into(),
]);
for args in &cases {
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
let (code, _stdout, stderr) = run(&arg_refs);
assert_eq!(
code, 4,
"{args:?} on a corrupt DB should exit 4, stderr: {stderr}"
);
assert!(
stderr.contains("database corrupt"),
"{args:?} stderr should name the corruption, got: {stderr}"
);
}
}
#[test]
fn format_text_keeps_human_banner_and_exit_zero() {
let (code, stdout, _) = run(&["--format", "text"]);
assert_eq!(code, 0, "explicit text mode must keep the no-op exit code");
assert!(
stdout.contains("Quick start:"),
"text mode must keep the banner, got: {stdout}"
);
}
#[test]
fn format_json_no_subcommand_emits_unified_error_json_on_stdout() {
let (code, stdout, stderr) = run(&["--format", "json"]);
assert_eq!(
code, 2,
"json usage error must exit with the payload's class code, stderr: {stderr}"
);
let payload: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| {
panic!("stdout must carry exactly the JSON payload, got {stdout:?}: {e}")
});
assert_eq!(payload["code"], "BAD_REQUEST");
assert!(
payload["message"]
.as_str()
.unwrap_or_default()
.contains("no subcommand"),
"payload must explain the usage error, got: {payload}"
);
assert!(
!stdout.contains("Quick start:"),
"the human banner must not leak into json mode, got: {stdout}"
);
}
#[test]
fn format_rejects_unknown_values() {
let (code, _, stderr) = run(&["--format", "yaml", "list"]);
assert_ne!(code, 0, "unknown --format value must be rejected");
assert!(
stderr.contains("yaml"),
"clap must name the rejected value, stderr: {stderr}"
);
}
#[test]
fn format_json_handler_error_is_unified_error_on_stdout() {
let tmp = tempfile::NamedTempFile::new().expect("create temp db file");
let db_path = tmp.path().to_str().expect("db path to str");
let (code, stdout, stderr) = run(&[
"--db",
db_path,
"--format",
"json",
"query",
"--cypher",
"DEFINITELY NOT CYPHER",
]);
assert_ne!(code, 0, "invalid cypher must fail, stderr: {stderr}");
let payload: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap_or_else(|e| {
panic!("stdout must carry exactly the JSON payload, got {stdout:?}: {e}")
});
assert_eq!(
payload["code"], "BAD_REQUEST",
"invalid cypher is an input error, got: {payload}"
);
assert_eq!(
code, 2,
"exit code must match the payload class (BAD_REQUEST → 2)"
);
assert!(
payload["message"].as_str().is_some_and(|m| !m.is_empty()),
"payload must carry a message, got: {payload}"
);
assert!(
!stderr.contains("Error:"),
"json mode must not use the text error line, stderr: {stderr}"
);
}
#[test]
fn ci_gate_e2e_fails_on_high_risk_and_passes_with_fail_on_none() {
let tmp = tempfile::TempDir::new().expect("temp dir");
let root = tmp.path();
let git = |args: &[&str]| {
Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.status()
.map(|s| s.success())
.unwrap_or(false)
};
if !git(&["init"]) {
eprintln!("skipping test: git unavailable");
return;
}
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(
root.join("src/hub.rs"),
"pub fn hub_fn() -> i32 {\n 42\n}\n",
)
.unwrap();
let mut callers = String::new();
for i in 0..5 {
callers.push_str(&format!(
"pub fn caller_{i}() -> i32 {{\n hub_fn()\n}}\n"
));
}
std::fs::write(root.join("src/callers.rs"), callers).unwrap();
assert!(git(&["add", "."]));
assert!(git(&[
"-c",
"user.email=t@t.com",
"-c",
"user.name=T",
"commit",
"-m",
"init"
]));
let (code, _stdout, stderr) = run_in(root, &["index", "--path", ".", "--name", "gate_e2e"]);
assert_eq!(code, 0, "index should exit 0, stderr: {stderr}");
std::fs::write(
root.join("src/hub.rs"),
"pub fn hub_fn() -> i32 {\n // changed\n 43\n}\n",
)
.unwrap();
let (code, stdout, stderr) = run_in(
root,
&[
"--db",
".codenexus/gate_e2e.lbug",
"ci",
"--path",
".",
"--base_mode",
"unstaged",
"--fail_on",
"high",
],
);
let json_line = stdout
.lines()
.find(|l| l.starts_with('{'))
.expect("ci should print a JSON receipt");
let receipt: serde_json::Value = serde_json::from_str(json_line).expect("valid gate JSON");
assert_eq!(receipt["verdict"], "fail", "gate must fail: {stderr}");
assert_eq!(receipt["base_mode"], "unstaged");
assert!(
receipt["risk_counts"]["high"].as_u64().unwrap() >= 1,
"hub_fn should be high risk: {receipt}"
);
assert!(
receipt["markdown"]
.as_str()
.unwrap()
.contains("CodeNexus Architecture Gate"),
"markdown report must be present"
);
assert_eq!(code, 2, "failed gate must exit 2");
let (code, stdout, _) = run_in(
root,
&[
"--db",
".codenexus/gate_e2e.lbug",
"ci",
"--path",
".",
"--base_mode",
"unstaged",
"--fail_on",
"none",
],
);
assert_eq!(code, 0, "fail_on=none must pass");
let json_line = stdout
.lines()
.find(|l| l.starts_with('{'))
.expect("gate JSON present");
let receipt: serde_json::Value = serde_json::from_str(json_line).unwrap();
assert_eq!(receipt["verdict"], "pass");
assert_eq!(receipt["fail_on"], "none");
}
fn run_in(root: &std::path::Path, args: &[&str]) -> (i32, String, String) {
let output = Command::new(binary())
.args(args)
.current_dir(root)
.output()
.unwrap_or_else(|e| panic!("failed to spawn codenexus: {e}"));
(
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stdout).to_string(),
String::from_utf8_lossy(&output.stderr).to_string(),
)
}
#[test]
fn cn_alias_binary_shares_the_cli_surface() {
let cn = env!("CARGO_BIN_EXE_cn");
let out = Command::new(cn)
.arg("--version")
.output()
.unwrap_or_else(|e| panic!("failed to spawn cn: {e}"));
assert!(out.status.success(), "cn --version should exit 0");
let version = String::from_utf8_lossy(&out.stdout);
assert!(
version.contains(env!("CARGO_PKG_VERSION")),
"cn --version should carry the crate version, got: {version}"
);
let help = Command::new(cn)
.arg("--help")
.output()
.unwrap_or_else(|e| panic!("failed to spawn cn: {e}"));
assert!(help.status.success(), "cn --help should exit 0");
let help_text = String::from_utf8_lossy(&help.stdout);
assert!(
help_text.contains("Usage:"),
"cn --help must render the CLI"
);
if cfg!(feature = "webui") {
let webui_help = Command::new(cn)
.args(["webui", "--help"])
.output()
.unwrap_or_else(|e| panic!("failed to spawn cn webui: {e}"));
assert!(webui_help.status.success(), "cn webui --help should exit 0");
let text = String::from_utf8_lossy(&webui_help.stdout);
assert!(
text.contains("--port"),
"cn webui --help 应列出 --port:{text}"
);
}
}