#![cfg(feature = "cli")]
#![allow(clippy::expect_used)]
use std::process::{Command, Output};
use serde_json::{Value, json};
fn run(args: &[&str]) -> Output {
Command::new(env!("CARGO_BIN_EXE_afslug"))
.args(args)
.output()
.expect("afslug should run")
}
fn stdout_json(output: &Output) -> Value {
serde_json::from_slice(&output.stdout).expect("stdout should contain one JSON event")
}
fn stderr_json(output: &Output) -> Value {
serde_json::from_slice(&output.stderr).expect("stderr should contain one JSON event")
}
fn help_of(output: &Output) -> Value {
stdout_json(output)["result"]["help"].clone()
}
#[test]
fn slugifies_with_a_strict_afdata_result() {
let output = run(&["slugify", "Hello, 世界!"]);
assert!(output.status.success());
assert!(output.stderr.is_empty());
assert_eq!(
stdout_json(&output),
json!({
"kind": "result",
"result": {
"code": "slugify",
"slug": "hello-世界",
"changed_from_input": true
},
"trace": {}
})
);
}
#[test]
fn supports_plain_afdata_output() {
let output = run(&["slugify", "Already-Slug", "--output", "plain"]);
assert!(output.status.success());
assert!(output.stderr.is_empty());
let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8");
assert_eq!(
stdout,
"kind=result result.changed_from_input=true result.code=slugify \
result.slug=already-slug trace={}\n"
);
}
#[test]
fn supports_yaml_afdata_output() {
let output = run(&["slugify", "Hello, World!", "--output", "yaml"]);
assert!(output.status.success());
assert!(output.stderr.is_empty());
let stdout = String::from_utf8(output.stdout).expect("stdout should be UTF-8");
assert_eq!(
stdout,
concat!(
"---\n",
"kind: \"result\"\n",
"result:\n",
" changed_from_input: true\n",
" code: \"slugify\"\n",
" slug: \"hello-world\"\n",
"trace: {}\n",
)
);
}
#[test]
fn slugify_honors_config_flags() {
let output = run(&[
"slugify",
"Rust 版 CLI Tool",
"--charset",
"ascii-alphanumeric",
"--max-chars",
"8",
]);
assert!(output.status.success());
assert_eq!(stdout_json(&output)["result"]["slug"], "rust-cli");
}
#[test]
fn slugify_keeps_case_when_lowercasing_is_disabled() {
let output = run(&["slugify", "Hello World", "--no-lowercase"]);
assert!(output.status.success());
assert_eq!(stdout_json(&output)["result"]["slug"], "Hello-World");
}
#[test]
fn slugify_substitutes_fallback_for_empty_output() {
let output = run(&["slugify", "!!!", "--fallback", "item"]);
assert!(output.status.success());
assert_eq!(stdout_json(&output)["result"]["slug"], "item");
}
#[test]
fn slugify_validation_failure_is_a_structured_error() {
let output = run(&["slugify", "!!!", "--validation", "url-path"]);
assert_eq!(output.status.code(), Some(1));
let event = stderr_json(&output);
assert_eq!(event["kind"], "error");
assert_eq!(event["error"]["code"], "slug_error");
}
#[test]
fn validate_accepts_a_valid_segment() {
let output = run(&["validate", "my-slug", "--policy", "url-path"]);
assert!(output.status.success());
assert_eq!(
stdout_json(&output),
json!({
"kind": "result",
"result": {
"code": "validate",
"value": "my-slug",
"valid": true
},
"trace": {}
})
);
}
#[test]
fn validate_rejects_an_invalid_segment_as_a_structured_error() {
let output = run(&["validate", "bad/slug", "--policy", "local-path"]);
assert_eq!(output.status.code(), Some(1));
let event = stderr_json(&output);
assert_eq!(event["kind"], "error");
assert_eq!(event["error"]["code"], "slug_error");
assert_eq!(event["error"]["retryable"], false);
}
#[test]
fn reports_argument_errors_as_afdata_json() {
let output = run(&[]);
assert_eq!(output.status.code(), Some(2));
assert!(output.stdout.is_empty());
let event = stderr_json(&output);
assert_eq!(event["kind"], "error");
assert_eq!(event["error"]["code"], "cli_unregistered_combination");
assert_eq!(event["error"]["retryable"], false);
assert_eq!(event["trace"], json!({}));
}
#[test]
fn explicit_json_version_is_structured() {
let output = run(&["--version", "--output", "json"]);
assert!(output.status.success());
let value = stdout_json(&output);
assert_eq!(value["kind"], "result");
assert_eq!(value["result"]["code"], "version");
assert_eq!(value["result"]["name"], "afslug");
assert_eq!(value["result"]["display_name"], "Agent-First Slug");
assert_eq!(value["result"]["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(value["trace"], json!({}));
}
#[test]
fn bare_version_is_structured() {
let output = run(&["--version"]);
assert!(output.status.success());
assert!(output.stderr.is_empty());
let value = stdout_json(&output);
assert_eq!(value["kind"], "result");
assert_eq!(value["result"]["code"], "version");
assert_eq!(value["result"]["name"], "afslug");
assert_eq!(value["result"]["version"], env!("CARGO_PKG_VERSION"));
}
#[test]
fn short_flags_do_not_exist() {
for short in ["-V", "-h"] {
let output = run(&[short]);
assert_eq!(output.status.code(), Some(2), "{short} must be rejected");
assert!(output.stdout.is_empty());
let value = stderr_json(&output);
assert_eq!(value["kind"], "error");
assert_eq!(value["error"]["code"], "cli_unknown_argument");
assert_eq!(
value["error"]["message"],
format!("unknown argument `{short}`")
);
}
}
#[test]
fn root_help_routes_to_commands_without_listing_their_arguments() {
let root = run(&["--help"]);
assert!(root.status.success());
assert!(root.stderr.is_empty());
assert_eq!(stdout_json(&root)["result"]["code"], "help");
let help = help_of(&root);
assert_eq!(help["schema"], "cli-help-v2");
assert_eq!(help["command_path"], "afslug");
assert!(help.get("shapes").is_none(), "{help}");
assert_eq!(
help["subcommands"],
json!([
"afslug skill --help",
"afslug slugify --help",
"afslug validate --help"
])
);
assert!(!help.to_string().contains("--docs"), "{help}");
}
#[test]
fn command_help_answers_in_one_round_trip() {
let scoped = run(&["slugify", "--help"]);
assert!(scoped.status.success());
let help = help_of(&scoped);
assert_eq!(help["command_path"], "afslug slugify");
let shapes = help["shapes"].as_array().expect("slugify has one shape");
assert_eq!(shapes.len(), 1);
let usage = shapes[0]["usage"].as_str().expect("usage is a string");
for optional in [
"[--delimiter <CHAR>]",
"[--no-lowercase]",
"[--max-chars <N>]",
"[--fallback <SLUG>]",
] {
assert!(usage.contains(optional), "{optional} missing from {usage}");
}
assert!(
usage.contains("[--dots <replace|preserve|preserve-between-digits>]"),
"{usage}"
);
assert_eq!(help["defaults"]["--charset"], "unicode-alphanumeric");
}
#[test]
fn sibling_shapes_each_say_how_they_differ() {
let help = help_of(&run(&["skill", "install", "--help"]));
let shapes = help["shapes"].as_array().expect("two shapes");
assert_eq!(shapes.len(), 2);
let by_id = |id: &str| {
shapes
.iter()
.find(|shape| shape["id"] == id)
.unwrap_or_else(|| panic!("missing shape {id}: {help}"))
.clone()
};
let every = by_id("skill-install-every-agent");
let one = by_id("skill-install-one-agent");
assert_ne!(every["about"], one["about"]);
assert!(
!every["usage"]
.as_str()
.unwrap_or_default()
.contains("--skills-dir"),
"{every}"
);
assert!(
one["usage"]
.as_str()
.unwrap_or_default()
.contains("[--skills-dir <DIR>]"),
"{one}"
);
}
#[test]
fn plain_help_is_not_weaker_than_the_structured_form() {
let plain = run(&["slugify", "--help", "--output", "plain"]);
assert!(plain.status.success());
let text = String::from_utf8(plain.stdout).expect("plain help is UTF-8");
assert!(text.contains("afslug slugify <TEXT>"), "{text}");
assert!(text.contains("Text to slugify"), "{text}");
assert!(text.contains("--charset=unicode-alphanumeric"), "{text}");
}
#[test]
fn an_unknown_command_names_itself() {
let pseudo = run(&["help"]);
assert_eq!(pseudo.status.code(), Some(2));
assert!(pseudo.stdout.is_empty());
let event = stderr_json(&pseudo);
assert_eq!(event["error"]["code"], "cli_unknown_command");
assert_eq!(event["error"]["message"], "unknown command `help`");
assert_eq!(
event["error"]["hint"],
"run `afslug --help` and choose one registered combination"
);
assert!(
pseudo.stderr.len() < 256,
"an unknown command must not embed eager help"
);
}
#[test]
fn output_to_is_honored_once_an_invocation_resolves() {
let resolved = run(&["validate", "bad/slug", "--output-to", "stdout"]);
assert_eq!(resolved.status.code(), Some(1));
assert!(resolved.stderr.is_empty());
assert_eq!(stdout_json(&resolved)["error"]["code"], "slug_error");
}
#[test]
fn a_rejected_invocation_reports_on_the_diagnostic_stream() {
let output = run(&["--output-to", "stdout"]);
assert_eq!(output.status.code(), Some(2));
assert!(output.stdout.is_empty());
assert_eq!(stderr_json(&output)["kind"], "error");
}
#[test]
fn docs_render_the_whole_registry_as_markdown() {
let output = run(&["--docs"]);
assert!(output.status.success());
assert!(output.stderr.is_empty());
let text = String::from_utf8(output.stdout).expect("docs are UTF-8");
assert!(text.starts_with("# afslug CLI reference"), "{text:.80}");
for command in ["afslug slugify", "afslug validate", "afslug skill install"] {
assert!(
text.contains(command),
"{command} missing from the reference"
);
}
}
#[test]
fn skill_install_bundles_skill_and_agent_asset() {
let dir = std::env::temp_dir().join(format!("afslug_skill_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let dir_str = dir.to_str().expect("temp path is utf-8");
let target = [
"--agent",
"claude-code",
"--scope",
"personal",
"--skills-dir",
dir_str,
];
let mut install = vec!["skill", "install"];
install.extend_from_slice(&target);
install.push("--force");
let installed = run(&install);
assert!(
installed.status.success(),
"install failed: {}",
String::from_utf8_lossy(&installed.stderr)
);
let skill_dir = dir.join("agent-first-slug");
assert!(
skill_dir.join("SKILL.md").is_file(),
"SKILL.md must install"
);
assert!(
skill_dir.join("agents").join("openai.yaml").is_file(),
"the bundled agents/openai.yaml asset must install alongside SKILL.md"
);
let mut status = vec!["skill", "status"];
status.extend_from_slice(&target);
let value = stdout_json(&run(&status));
assert_eq!(value["result"]["current_all"], json!(true));
let mut uninstall = vec!["skill", "uninstall"];
uninstall.extend_from_slice(&target);
let removed = run(&uninstall);
assert!(removed.status.success());
assert!(
!skill_dir.exists(),
"uninstall must remove the skill directory"
);
let _ = std::fs::remove_dir_all(&dir);
}