use crate::common::{run_rsconstruct_with_env, setup_test_project};
use serde_json::Value;
#[test]
fn tools_list_shows_all_registry_tools() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let output = run_rsconstruct_with_env(project_path, &["tools", "list"], &[("NO_COLOR", "1")]);
assert!(
output.status.success(),
"tools list failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.is_empty(),
"tools list should show at least one tool"
);
assert!(
!stdout.contains("("),
"registry list should not show processor names in parentheses"
);
assert!(
stdout.contains("clojure"),
"registry list should include all known tools, e.g. clojure"
);
}
#[test]
fn tools_list_shows_configured_tools() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let output = run_rsconstruct_with_env(
project_path,
&["tools", "list-configured"],
&[("NO_COLOR", "1")],
);
assert!(
output.status.success(),
"tools list-configured failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
!stdout.is_empty(),
"tools list-configured should show at least one tool"
);
assert!(
stdout.contains("("),
"Expected processor name in parentheses for each tool"
);
}
#[test]
fn tools_list_configured_all_includes_disabled() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let output_default = run_rsconstruct_with_env(
project_path,
&["tools", "list-configured"],
&[("NO_COLOR", "1")],
);
let output_all = run_rsconstruct_with_env(
project_path,
&["tools", "list-configured", "-a"],
&[("NO_COLOR", "1")],
);
assert!(output_default.status.success());
assert!(output_all.status.success());
let stdout_default = String::from_utf8_lossy(&output_default.stdout);
let stdout_all = String::from_utf8_lossy(&output_all.stdout);
let count_default = stdout_default.lines().count();
let count_all = stdout_all.lines().count();
assert!(
count_all >= count_default,
"tools list-configured -a should include at least as many tools as default ({} vs {})",
count_all,
count_default
);
}
#[test]
fn tools_list_json() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let output = run_rsconstruct_with_env(
project_path,
&["--json", "tools", "list"],
&[("NO_COLOR", "1")],
);
assert!(
output.status.success(),
"tools list --json failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let entries: Vec<serde_json::Value> =
serde_json::from_str(&stdout).expect("Expected valid JSON array");
for entry in &entries {
assert!(
entry.get("tool").is_some(),
"Entry should have 'tool' field"
);
assert!(
entry.get("processors").is_some(),
"Entry should have 'processors' field"
);
assert!(
entry["processors"].is_array(),
"'processors' should be an array"
);
}
}
#[test]
fn tools_check_succeeds() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let lock_output =
run_rsconstruct_with_env(project_path, &["tools", "lock"], &[("NO_COLOR", "1")]);
assert!(
lock_output.status.success(),
"tools lock failed: {}",
String::from_utf8_lossy(&lock_output.stderr)
);
let output = run_rsconstruct_with_env(project_path, &["tools", "check"], &[("NO_COLOR", "1")]);
assert!(
output.status.success(),
"tools check failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
#[test]
fn tools_stats_shows_summary() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let output = run_rsconstruct_with_env(project_path, &["tools", "stats"], &[("NO_COLOR", "1")]);
assert!(
output.status.success(),
"tools stats failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(stdout.contains("Tool"), "Expected 'Tool' table header");
assert!(
stdout.contains("Runtime summary:"),
"Expected 'Runtime summary:' section"
);
assert!(stdout.contains("Total:"), "Expected 'Total:' summary line");
assert!(stdout.contains("installed"), "Expected 'installed' count");
}
#[test]
fn tools_stats_json() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let output = run_rsconstruct_with_env(
project_path,
&["--json", "tools", "stats"],
&[("NO_COLOR", "1")],
);
assert!(
output.status.success(),
"tools stats --json failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let stdout = String::from_utf8_lossy(&output.stdout);
let parsed: Value = serde_json::from_str(&stdout).expect("Expected valid JSON");
assert!(parsed.get("tools").is_some(), "Expected 'tools' field");
assert!(
parsed.get("runtimes").is_some(),
"Expected 'runtimes' field"
);
assert!(parsed.get("summary").is_some(), "Expected 'summary' field");
let tools = parsed["tools"]
.as_array()
.expect("'tools' should be an array");
assert!(!tools.is_empty(), "tools array should not be empty");
for tool in tools {
assert!(tool.get("name").is_some(), "Tool entry should have 'name'");
assert!(
tool.get("installed").is_some(),
"Tool entry should have 'installed'"
);
assert!(
tool.get("runtime").is_some(),
"Tool entry should have 'runtime'"
);
assert!(
tool.get("processors").is_some(),
"Tool entry should have 'processors'"
);
}
let runtimes = parsed["runtimes"]
.as_array()
.expect("'runtimes' should be an array");
for rt in runtimes {
assert!(
rt.get("runtime").is_some(),
"Runtime entry should have 'runtime'"
);
assert!(
rt.get("total").is_some(),
"Runtime entry should have 'total'"
);
assert!(
rt.get("installed").is_some(),
"Runtime entry should have 'installed'"
);
assert!(
rt.get("missing").is_some(),
"Runtime entry should have 'missing'"
);
}
let summary = &parsed["summary"];
assert!(
summary.get("total_tools").is_some(),
"Summary should have 'total_tools'"
);
assert!(
summary.get("installed").is_some(),
"Summary should have 'installed'"
);
assert!(
summary.get("missing").is_some(),
"Summary should have 'missing'"
);
let total_tools = summary["total_tools"].as_u64().unwrap();
assert_eq!(
total_tools as usize,
tools.len(),
"summary.total_tools should match tools array length"
);
let installed = summary["installed"].as_u64().unwrap();
let missing = summary["missing"].as_u64().unwrap();
assert_eq!(
installed + missing,
total_tools,
"installed + missing should equal total_tools"
);
}
#[test]
fn tools_list_uses_only_implemented_install_methods() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let output = run_rsconstruct_with_env(
project_path,
&["--json", "tools", "list"],
&[("NO_COLOR", "1")],
);
assert!(
output.status.success(),
"tools list --json failed: {}",
String::from_utf8_lossy(&output.stderr)
);
const IMPLEMENTED: &[&str] = &[
"apt", "dnf", "pacman", "brew", "snap", "pip", "npm", "cargo", "gem", "binary", "manual",
];
let parsed: Value =
serde_json::from_slice(&output.stdout).expect("tools list --json should emit valid JSON");
let tools = parsed
.as_array()
.expect("tools list --json should be an array");
assert!(!tools.is_empty(), "registry should not be empty");
for tool in tools {
let name = tool["tool"].as_str().unwrap_or("<unnamed>");
let methods = tool["install_methods"]
.as_array()
.expect("tool should have install_methods");
assert!(
!methods.is_empty(),
"tool '{name}' has no install method at all"
);
for m in methods {
let method = m["method"]
.as_str()
.expect("install method should have a 'method' string");
assert!(
IMPLEMENTED.contains(&method),
"tool '{name}' declares install method '{method}', which tools::run() does not implement",
);
}
}
}
#[test]
fn tools_list_names_are_bare_binaries_not_paths() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let output = run_rsconstruct_with_env(
project_path,
&["--json", "tools", "list"],
&[("NO_COLOR", "1")],
);
assert!(
output.status.success(),
"tools list --json failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let parsed: Value =
serde_json::from_slice(&output.stdout).expect("tools list --json should emit valid JSON");
let tools = parsed
.as_array()
.expect("tools list --json should be an array");
assert!(!tools.is_empty(), "registry should not be empty");
for tool in tools {
let name = tool["tool"]
.as_str()
.expect("tool should have a 'tool' name string");
assert!(
!name.contains('/'),
"tool '{name}' is a path, not a bare binary name; which() would resolve it \
relative to the cwd and report it missing from anywhere else. Use the bare \
binary name here and put the vendored path in the processor's `command` config.",
);
}
}
#[test]
fn tools_install_all_has_no_manual_only_entries() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let output = run_rsconstruct_with_env(
project_path,
&["--json", "tools", "list"],
&[("NO_COLOR", "1")],
);
assert!(
output.status.success(),
"tools list --json failed: {}",
String::from_utf8_lossy(&output.stderr)
);
let parsed: Value =
serde_json::from_slice(&output.stdout).expect("tools list --json should emit valid JSON");
let manual_only: Vec<&str> = parsed
.as_array()
.expect("array")
.iter()
.filter(|tool| {
tool["install_methods"].as_array().is_some_and(|ms| {
!ms.is_empty() && ms.iter().all(|m| m["method"].as_str() == Some("manual"))
})
})
.map(|tool| tool["tool"].as_str().unwrap_or("<unnamed>"))
.collect();
assert!(
manual_only.is_empty(),
"these tools have only a manual install method, so `tools install --all` cannot provision them: {manual_only:?}",
);
}
#[test]
fn tools_install_all_conflicts_with_tool_name() {
let temp_dir = setup_test_project();
let project_path = temp_dir.path();
let output = run_rsconstruct_with_env(
project_path,
&["tools", "install", "--all", "ruff"],
&[("NO_COLOR", "1")],
);
assert!(
!output.status.success(),
"`tools install --all ruff` should be rejected"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("cannot be used with"),
"expected a clap conflict error, got: {stderr}",
);
}