1use std::path::{Path, PathBuf};
8
9use clap::Args;
10use leviath_scripting::{ScriptToolMeta, ScriptToolSet, SkippedTool};
11
12#[derive(Args)]
14pub struct ToolsArgs {
15 #[arg(long)]
17 pub(crate) json: bool,
18}
19
20fn global_tools_dir() -> Option<PathBuf> {
31 leviath_core::tools_dir()
32}
33
34struct ToolsReport {
37 valid: Vec<ScriptToolMeta>,
38 skipped: Vec<SkippedTool>,
39}
40
41fn scan_tools(dir: Option<&Path>) -> ToolsReport {
45 let dirs: Vec<PathBuf> = dir.map(Path::to_path_buf).into_iter().collect();
46 let (set, skipped) = ScriptToolSet::discover(&dirs);
47 let mut valid = set.metas();
48 valid.sort_by(|a, b| a.name.cmp(&b.name));
49 ToolsReport { valid, skipped }
50}
51
52fn param_type_label(p: &leviath_scripting::ParamSpec) -> String {
56 match &p.schema {
57 Some(frag) => frag
58 .get("type")
59 .and_then(|v| v.as_str())
60 .unwrap_or("schema")
61 .to_string(),
62 None => p.ty.clone(),
63 }
64}
65
66fn params_summary(meta: &ScriptToolMeta) -> String {
69 meta.params
70 .iter()
71 .map(|p| {
72 let req = if p.required { "!" } else { "" };
73 format!("{}:{}{req}", p.name, param_type_label(p))
74 })
75 .collect::<Vec<_>>()
76 .join(", ")
77}
78
79fn report_json(dir_label: &str, report: &ToolsReport) -> serde_json::Value {
82 let tools: Vec<serde_json::Value> = report
83 .valid
84 .iter()
85 .map(|m| {
86 let params: Vec<serde_json::Value> = m
87 .params
88 .iter()
89 .map(|p| {
90 match &p.schema {
94 Some(frag) => serde_json::json!({
95 "name": p.name,
96 "required": p.required,
97 "schema": frag,
98 }),
99 None => serde_json::json!({
100 "name": p.name,
101 "type": p.ty,
102 "required": p.required,
103 "description": p.description,
104 }),
105 }
106 })
107 .collect();
108 serde_json::json!({
109 "name": m.name,
110 "description": m.description,
111 "requires": m.required_caps,
112 "available": crate::daemon::spawn::current_platform_satisfies(&m.required_caps),
113 "params": params,
114 })
115 })
116 .collect();
117 let skipped: Vec<serde_json::Value> = report
118 .skipped
119 .iter()
120 .map(|s| {
121 serde_json::json!({
122 "path": s.path.display().to_string(),
123 "reason": s.reason,
124 })
125 })
126 .collect();
127 serde_json::json!({ "dir": dir_label, "tools": tools, "skipped": skipped })
128}
129
130fn print_human(dir_label: &str, report: &ToolsReport) {
134 println!("Global script tools ({dir_label}):");
135 if report.valid.is_empty() && report.skipped.is_empty() {
136 println!(" (none)");
137 return;
138 }
139 for meta in &report.valid {
140 let desc = if meta.description.is_empty() {
141 String::new()
142 } else {
143 format!(" - {}", meta.description)
144 };
145 let available = crate::daemon::spawn::current_platform_satisfies(&meta.required_caps);
149 let marker = if available { "✓" } else { "⚠" };
150 println!(" {marker} {}{desc}", meta.name);
151 if !available {
152 println!(" unavailable on this platform (unsatisfiable @requires)");
153 }
154 let params = params_summary(meta);
155 if !params.is_empty() {
156 println!(" params: {params}");
157 }
158 if !meta.required_caps.is_empty() {
159 println!(" requires: {}", meta.required_caps.join(", "));
160 }
161 }
162 for s in &report.skipped {
163 println!(" ✗ {}: {}", s.path.display(), s.reason);
164 }
165}
166
167fn run(dir: Option<&Path>, json: bool) -> anyhow::Result<()> {
169 let report = scan_tools(dir);
170 let dir_label = dir.map_or_else(
171 || "<no home directory>".to_string(),
172 |d| d.display().to_string(),
173 );
174 if json {
175 let text = serde_json::to_string_pretty(&report_json(&dir_label, &report))
177 .expect("tools report serializes");
178 println!("{text}");
179 } else {
180 print_human(&dir_label, &report);
181 }
182 Ok(())
183}
184
185pub async fn execute(args: ToolsArgs) -> anyhow::Result<()> {
187 run(global_tools_dir().as_deref(), args.json)
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 fn dir_with_mixed_tools() -> tempfile::TempDir {
197 let dir = tempfile::tempdir().unwrap();
198 std::fs::write(
199 dir.path().join("upper.rhai"),
200 "// @tool upper\n// @description Upper-case text\n// @param text string required \"in\"\n// @requires network\nparams.text",
201 )
202 .unwrap();
203 std::fs::write(
204 dir.path().join("broken.rhai"),
205 "no tool directive here\nlet",
206 )
207 .unwrap();
208 dir
209 }
210
211 #[test]
212 fn scan_tools_lists_valid_and_skipped() {
213 let dir = dir_with_mixed_tools();
214 let report = scan_tools(Some(dir.path()));
215 assert_eq!(report.valid.len(), 1);
216 assert_eq!(report.valid[0].name, "upper");
217 assert_eq!(report.valid[0].required_caps, ["network"]);
218 assert_eq!(report.skipped.len(), 1);
219 assert!(report.skipped[0].reason.to_lowercase().contains("tool"));
220 }
221
222 #[test]
223 fn scan_tools_none_dir_is_empty() {
224 let report = scan_tools(None);
225 assert!(report.valid.is_empty() && report.skipped.is_empty());
226 }
227
228 #[test]
229 fn params_summary_marks_required() {
230 let meta = ScriptToolMeta {
231 name: "t".to_string(),
232 description: String::new(),
233 params: vec![
234 leviath_scripting::ParamSpec {
235 name: "a".to_string(),
236 ty: "string".to_string(),
237 required: true,
238 description: String::new(),
239 schema: None,
240 },
241 leviath_scripting::ParamSpec {
242 name: "b".to_string(),
243 ty: "integer".to_string(),
244 required: false,
245 description: String::new(),
246 schema: None,
247 },
248 ],
249 required_caps: vec![],
250 };
251 assert_eq!(params_summary(&meta), "a:string!, b:integer");
252 }
253
254 #[test]
255 fn param_type_label_reads_flat_and_fragment() {
256 let flat = leviath_scripting::ParamSpec {
257 name: "a".into(),
258 ty: "integer".into(),
259 required: false,
260 description: String::new(),
261 schema: None,
262 };
263 assert_eq!(param_type_label(&flat), "integer");
264 let typed = leviath_scripting::ParamSpec {
266 schema: Some(serde_json::json!({ "type": "string", "enum": ["a", "b"] })),
267 ..flat.clone()
268 };
269 assert_eq!(param_type_label(&typed), "string");
270 let typeless = leviath_scripting::ParamSpec {
272 schema: Some(serde_json::json!({ "oneOf": [] })),
273 ..flat
274 };
275 assert_eq!(param_type_label(&typeless), "schema");
276 }
277
278 #[test]
279 fn report_json_surfaces_raw_schema_fragment() {
280 let dir = tempfile::tempdir().unwrap();
283 std::fs::write(dir.path().join("pick.rhai"), "params.choice").unwrap();
284 std::fs::write(
285 dir.path().join("pick.toml"),
286 "[tool]\nname = \"pick\"\n[[tool.params]]\nname = \"choice\"\nrequired = true\nschema = { type = \"string\", enum = [\"x\", \"y\"] }\n",
287 )
288 .unwrap();
289 let report = scan_tools(Some(dir.path()));
290 assert_eq!(params_summary(&report.valid[0]), "choice:string!");
292 let v = report_json("d", &report);
293 let param = &v["tools"][0]["params"][0];
294 assert_eq!(param["schema"]["enum"][1], "y");
295 assert!(
296 param.get("type").is_none(),
297 "no flat type when a fragment is present"
298 );
299 }
300
301 #[test]
302 fn report_json_shape() {
303 let dir = dir_with_mixed_tools();
304 let report = scan_tools(Some(dir.path()));
305 let v = report_json("d", &report);
306 assert_eq!(v["dir"], "d");
307 assert_eq!(v["tools"][0]["name"], "upper");
308 assert_eq!(v["tools"][0]["requires"][0], "network");
309 assert_eq!(v["tools"][0]["available"], true);
311 assert_eq!(v["tools"][0]["params"][0]["required"], true);
312 assert!(v["skipped"][0]["reason"].as_str().is_some());
313 }
314
315 #[test]
316 fn run_text_and_json_and_empty() {
317 let dir = tempfile::tempdir().unwrap();
321 std::fs::write(
322 dir.path().join("zeta.rhai"),
323 "// @tool zeta\n// @description Full tool\n// @param x string required\n// @requires network\nparams.x",
324 )
325 .unwrap();
326 std::fs::write(dir.path().join("alpha.rhai"), "// @tool alpha\n1").unwrap();
327 std::fs::write(
329 dir.path().join("gpu.rhai"),
330 "// @tool gpu\n// @requires gpu\n1",
331 )
332 .unwrap();
333 std::fs::write(dir.path().join("broken.rhai"), "no directive\nlet").unwrap();
334 run(Some(dir.path()), false).unwrap();
336 run(Some(dir.path()), true).unwrap();
337 let empty = tempfile::tempdir().unwrap();
339 run(Some(empty.path()), false).unwrap();
340 run(None, true).unwrap();
342 }
343
344 #[test]
345 fn global_tools_dir_is_under_the_leviath_data_dir() {
346 let home = tempfile::tempdir().unwrap();
347 temp_env::with_var("LEVIATH_HOME", Some(home.path().to_str().unwrap()), || {
348 assert_eq!(
355 global_tools_dir(),
356 Some(home.path().join(".leviath").join("tools"))
357 );
358 });
359 }
360}