use std::path::{Path, PathBuf};
use clap::Args;
use leviath_scripting::{ScriptToolMeta, ScriptToolSet, SkippedTool};
#[derive(Args)]
pub struct ToolsArgs {
#[arg(long)]
pub(crate) json: bool,
}
fn global_tools_dir() -> Option<PathBuf> {
leviath_core::tools_dir()
}
struct ToolsReport {
valid: Vec<ScriptToolMeta>,
skipped: Vec<SkippedTool>,
}
fn scan_tools(dir: Option<&Path>) -> ToolsReport {
let dirs: Vec<PathBuf> = dir.map(Path::to_path_buf).into_iter().collect();
let (set, skipped) = ScriptToolSet::discover(&dirs);
let mut valid = set.metas();
valid.sort_by(|a, b| a.name.cmp(&b.name));
ToolsReport { valid, skipped }
}
fn param_type_label(p: &leviath_scripting::ParamSpec) -> String {
match &p.schema {
Some(frag) => frag
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("schema")
.to_string(),
None => p.ty.clone(),
}
}
fn params_summary(meta: &ScriptToolMeta) -> String {
meta.params
.iter()
.map(|p| {
let req = if p.required { "!" } else { "" };
format!("{}:{}{req}", p.name, param_type_label(p))
})
.collect::<Vec<_>>()
.join(", ")
}
fn report_json(dir_label: &str, report: &ToolsReport) -> serde_json::Value {
let tools: Vec<serde_json::Value> = report
.valid
.iter()
.map(|m| {
let params: Vec<serde_json::Value> = m
.params
.iter()
.map(|p| {
match &p.schema {
Some(frag) => serde_json::json!({
"name": p.name,
"required": p.required,
"schema": frag,
}),
None => serde_json::json!({
"name": p.name,
"type": p.ty,
"required": p.required,
"description": p.description,
}),
}
})
.collect();
serde_json::json!({
"name": m.name,
"description": m.description,
"requires": m.required_caps,
"available": crate::daemon::spawn::current_platform_satisfies(&m.required_caps),
"params": params,
})
})
.collect();
let skipped: Vec<serde_json::Value> = report
.skipped
.iter()
.map(|s| {
serde_json::json!({
"path": s.path.display().to_string(),
"reason": s.reason,
})
})
.collect();
serde_json::json!({ "dir": dir_label, "tools": tools, "skipped": skipped })
}
fn print_human(dir_label: &str, report: &ToolsReport) {
println!("Global script tools ({dir_label}):");
if report.valid.is_empty() && report.skipped.is_empty() {
println!(" (none)");
return;
}
for meta in &report.valid {
let desc = if meta.description.is_empty() {
String::new()
} else {
format!(" - {}", meta.description)
};
let available = crate::daemon::spawn::current_platform_satisfies(&meta.required_caps);
let marker = if available { "✓" } else { "⚠" };
println!(" {marker} {}{desc}", meta.name);
if !available {
println!(" unavailable on this platform (unsatisfiable @requires)");
}
let params = params_summary(meta);
if !params.is_empty() {
println!(" params: {params}");
}
if !meta.required_caps.is_empty() {
println!(" requires: {}", meta.required_caps.join(", "));
}
}
for s in &report.skipped {
println!(" ✗ {}: {}", s.path.display(), s.reason);
}
}
fn run(dir: Option<&Path>, json: bool) -> anyhow::Result<()> {
let report = scan_tools(dir);
let dir_label = dir.map_or_else(
|| "<no home directory>".to_string(),
|d| d.display().to_string(),
);
if json {
let text = serde_json::to_string_pretty(&report_json(&dir_label, &report))
.expect("tools report serializes");
println!("{text}");
} else {
print_human(&dir_label, &report);
}
Ok(())
}
pub async fn execute(args: ToolsArgs) -> anyhow::Result<()> {
run(global_tools_dir().as_deref(), args.json)
}
#[cfg(test)]
mod tests {
use super::*;
fn dir_with_mixed_tools() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("upper.rhai"),
"// @tool upper\n// @description Upper-case text\n// @param text string required \"in\"\n// @requires network\nparams.text",
)
.unwrap();
std::fs::write(
dir.path().join("broken.rhai"),
"no tool directive here\nlet",
)
.unwrap();
dir
}
#[test]
fn scan_tools_lists_valid_and_skipped() {
let dir = dir_with_mixed_tools();
let report = scan_tools(Some(dir.path()));
assert_eq!(report.valid.len(), 1);
assert_eq!(report.valid[0].name, "upper");
assert_eq!(report.valid[0].required_caps, ["network"]);
assert_eq!(report.skipped.len(), 1);
assert!(report.skipped[0].reason.to_lowercase().contains("tool"));
}
#[test]
fn scan_tools_none_dir_is_empty() {
let report = scan_tools(None);
assert!(report.valid.is_empty() && report.skipped.is_empty());
}
#[test]
fn params_summary_marks_required() {
let meta = ScriptToolMeta {
name: "t".to_string(),
description: String::new(),
params: vec![
leviath_scripting::ParamSpec {
name: "a".to_string(),
ty: "string".to_string(),
required: true,
description: String::new(),
schema: None,
},
leviath_scripting::ParamSpec {
name: "b".to_string(),
ty: "integer".to_string(),
required: false,
description: String::new(),
schema: None,
},
],
required_caps: vec![],
};
assert_eq!(params_summary(&meta), "a:string!, b:integer");
}
#[test]
fn param_type_label_reads_flat_and_fragment() {
let flat = leviath_scripting::ParamSpec {
name: "a".into(),
ty: "integer".into(),
required: false,
description: String::new(),
schema: None,
};
assert_eq!(param_type_label(&flat), "integer");
let typed = leviath_scripting::ParamSpec {
schema: Some(serde_json::json!({ "type": "string", "enum": ["a", "b"] })),
..flat.clone()
};
assert_eq!(param_type_label(&typed), "string");
let typeless = leviath_scripting::ParamSpec {
schema: Some(serde_json::json!({ "oneOf": [] })),
..flat
};
assert_eq!(param_type_label(&typeless), "schema");
}
#[test]
fn report_json_surfaces_raw_schema_fragment() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("pick.rhai"), "params.choice").unwrap();
std::fs::write(
dir.path().join("pick.toml"),
"[tool]\nname = \"pick\"\n[[tool.params]]\nname = \"choice\"\nrequired = true\nschema = { type = \"string\", enum = [\"x\", \"y\"] }\n",
)
.unwrap();
let report = scan_tools(Some(dir.path()));
assert_eq!(params_summary(&report.valid[0]), "choice:string!");
let v = report_json("d", &report);
let param = &v["tools"][0]["params"][0];
assert_eq!(param["schema"]["enum"][1], "y");
assert!(
param.get("type").is_none(),
"no flat type when a fragment is present"
);
}
#[test]
fn report_json_shape() {
let dir = dir_with_mixed_tools();
let report = scan_tools(Some(dir.path()));
let v = report_json("d", &report);
assert_eq!(v["dir"], "d");
assert_eq!(v["tools"][0]["name"], "upper");
assert_eq!(v["tools"][0]["requires"][0], "network");
assert_eq!(v["tools"][0]["available"], true);
assert_eq!(v["tools"][0]["params"][0]["required"], true);
assert!(v["skipped"][0]["reason"].as_str().is_some());
}
#[test]
fn run_text_and_json_and_empty() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("zeta.rhai"),
"// @tool zeta\n// @description Full tool\n// @param x string required\n// @requires network\nparams.x",
)
.unwrap();
std::fs::write(dir.path().join("alpha.rhai"), "// @tool alpha\n1").unwrap();
std::fs::write(
dir.path().join("gpu.rhai"),
"// @tool gpu\n// @requires gpu\n1",
)
.unwrap();
std::fs::write(dir.path().join("broken.rhai"), "no directive\nlet").unwrap();
run(Some(dir.path()), false).unwrap();
run(Some(dir.path()), true).unwrap();
let empty = tempfile::tempdir().unwrap();
run(Some(empty.path()), false).unwrap();
run(None, true).unwrap();
}
#[test]
fn global_tools_dir_is_under_the_leviath_data_dir() {
let home = tempfile::tempdir().unwrap();
temp_env::with_var("LEVIATH_HOME", Some(home.path().to_str().unwrap()), || {
assert_eq!(
global_tools_dir(),
Some(home.path().join(".leviath").join("tools"))
);
});
}
}