1use std::collections::BTreeMap;
5use std::fs;
6use std::path::{Path, PathBuf};
7
8pub use runx_contracts::{
9 RunxListEmit, RunxListItem, RunxListItemKind, RunxListReport, RunxListRequestedKind,
10 RunxListSchema, RunxListSource, RunxListStatus,
11};
12use serde::Deserialize;
13
14use crate::RuntimeError;
15use crate::filesystem::{read_dir_sorted, read_to_string};
16use crate::path_util::{count_yaml_files, display_path, lexical_normalize, project_path};
17
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct RunxListOptions {
20 pub root: PathBuf,
21 pub requested_kind: RunxListRequestedKind,
22}
23
24#[must_use]
25pub fn default_list_options(root: PathBuf) -> RunxListOptions {
26 RunxListOptions {
27 root,
28 requested_kind: RunxListRequestedKind::All,
29 }
30}
31
32pub fn list_authoring_primitives(
33 options: &RunxListOptions,
34) -> Result<RunxListReport, RuntimeError> {
35 let root = lexical_normalize(&options.root);
36 let mut items = discover_list_items(&root, options.requested_kind)?;
37 sort_list_items(&mut items);
38 Ok(RunxListReport {
39 schema: RunxListSchema::V1,
40 root: display_path(&root),
41 requested_kind: options.requested_kind,
42 items,
43 })
44}
45
46fn discover_list_items(
47 root: &Path,
48 requested_kind: RunxListRequestedKind,
49) -> Result<Vec<RunxListItem>, RuntimeError> {
50 let mut items = Vec::new();
51 if matches!(
52 requested_kind,
53 RunxListRequestedKind::All | RunxListRequestedKind::Tools
54 ) {
55 items.extend(discover_tool_list_items(root)?);
56 }
57 if matches!(
58 requested_kind,
59 RunxListRequestedKind::All | RunxListRequestedKind::Skills | RunxListRequestedKind::Graphs
60 ) {
61 items.extend(
62 discover_skill_and_graph_list_items(root)?
63 .into_iter()
64 .filter(|item| match requested_kind {
65 RunxListRequestedKind::All => true,
66 RunxListRequestedKind::Skills => {
67 matches!(item.kind, RunxListItemKind::Skill | RunxListItemKind::Graph)
68 }
69 RunxListRequestedKind::Graphs => item.kind == RunxListItemKind::Graph,
70 _ => false,
71 }),
72 );
73 }
74 if matches!(
75 requested_kind,
76 RunxListRequestedKind::All | RunxListRequestedKind::Packets
77 ) {
78 items.extend(discover_packet_list_items(root)?);
79 }
80 if matches!(
81 requested_kind,
82 RunxListRequestedKind::All | RunxListRequestedKind::Overlays
83 ) {
84 items.extend(discover_overlay_list_items(root)?);
85 }
86 Ok(items)
87}
88
89fn discover_tool_list_items(root: &Path) -> Result<Vec<RunxListItem>, RuntimeError> {
90 let tools_root = root.join("tools");
91 let mut items = Vec::new();
92 for namespace_entry in read_dir_sorted(&tools_root)? {
93 if !namespace_entry.is_dir {
94 continue;
95 }
96 for tool_entry in read_dir_sorted(&namespace_entry.path)? {
97 if !tool_entry.is_dir {
98 continue;
99 }
100 let manifest_path = tool_entry.path.join("manifest.json");
101 if !manifest_path.exists() {
102 continue;
103 }
104 let relative_path = project_path(root, &manifest_path);
105 match read_validated_tool_manifest(&manifest_path) {
106 Ok(tool) => items.push(RunxListItem {
107 kind: RunxListItemKind::Tool,
108 name: tool.name,
109 source: RunxListSource::Local,
110 path: relative_path,
111 status: RunxListStatus::Ok,
112 diagnostics: None,
113 scopes: Some(tool.scopes),
114 emits: tool
115 .artifacts
116 .as_ref()
117 .map(tool_emits)
118 .filter(|items| !items.is_empty()),
119 fixtures: Some(count_yaml_files(&tool_entry.path.join("fixtures"))?),
120 harness_cases: None,
121 steps: None,
122 wraps: None,
123 }),
124 Err(()) => items.push(invalid_item(
125 RunxListItemKind::Tool,
126 format!("{}.{}", namespace_entry.name, tool_entry.name),
127 relative_path,
128 "runx.tool.manifest.invalid",
129 )),
130 }
131 }
132 }
133 Ok(items)
134}
135
136fn read_validated_tool_manifest(manifest_path: &Path) -> Result<runx_parser::ValidatedTool, ()> {
137 let source = fs::read_to_string(manifest_path).map_err(|_| ())?;
138 let raw = runx_parser::parse_tool_manifest_json(&source).map_err(|_| ())?;
139 runx_parser::validate_tool_manifest(raw).map_err(|_| ())
140}
141
142fn tool_emits(artifacts: &runx_parser::SkillArtifactContract) -> Vec<RunxListEmit> {
143 if let Some(named_emits) = &artifacts.named_emits {
144 return named_emits
145 .iter()
146 .map(|(name, packet)| RunxListEmit {
147 name: name.clone(),
148 packet: Some(packet.clone()),
149 })
150 .collect();
151 }
152 artifacts
153 .wrap_as
154 .iter()
155 .map(|name| RunxListEmit {
156 name: name.clone(),
157 packet: None,
158 })
159 .collect()
160}
161
162fn discover_skill_and_graph_list_items(root: &Path) -> Result<Vec<RunxListItem>, RuntimeError> {
163 let mut items = Vec::new();
164 for profile_path in discover_skill_profile_paths(root)? {
165 let skill_dir = profile_path.parent().map_or(root, |parent| parent);
166 let fallback_name = fallback_skill_name(root, skill_dir);
167 let relative_path = project_path(root, &profile_path);
168 match read_validated_runner_manifest(&profile_path) {
169 Ok(manifest) => {
170 let graph_steps = manifest
171 .runners
172 .values()
173 .filter_map(|runner| {
174 runner
175 .source
176 .graph
177 .as_ref()
178 .map(|graph| graph.steps.len() as u64)
179 })
180 .collect::<Vec<_>>();
181 let is_graph = !graph_steps.is_empty();
182 items.push(RunxListItem {
183 kind: if is_graph {
184 RunxListItemKind::Graph
185 } else {
186 RunxListItemKind::Skill
187 },
188 name: manifest.skill.unwrap_or(fallback_name),
189 source: RunxListSource::Local,
190 path: relative_path,
191 status: RunxListStatus::Ok,
192 diagnostics: None,
193 scopes: None,
194 emits: None,
195 fixtures: Some(count_yaml_files(&skill_dir.join("fixtures"))?),
196 harness_cases: Some(
197 manifest
198 .harness
199 .as_ref()
200 .map_or(0, |harness| harness.cases.len() as u64),
201 ),
202 steps: is_graph.then(|| graph_steps.iter().sum()),
203 wraps: None,
204 });
205 }
206 Err(()) => items.push(invalid_item(
207 RunxListItemKind::Skill,
208 fallback_name,
209 relative_path,
210 "runx.skill.profile.invalid",
211 )),
212 }
213 }
214 Ok(items)
215}
216
217fn read_validated_runner_manifest(
218 profile_path: &Path,
219) -> Result<runx_parser::SkillRunnerManifest, ()> {
220 let source = fs::read_to_string(profile_path).map_err(|_| ())?;
221 let raw = runx_parser::parse_runner_manifest_yaml(&source).map_err(|_| ())?;
222 runx_parser::validate_runner_manifest(raw).map_err(|_| ())
223}
224
225fn discover_packet_list_items(root: &Path) -> Result<Vec<RunxListItem>, RuntimeError> {
228 let package_json_path = root.join("package.json");
229 if !package_json_path.exists() {
230 return Ok(Vec::new());
231 }
232
233 let source = read_to_string(&package_json_path)?;
234 let package_json = match serde_json::from_str::<PackageJson>(&source) {
235 Ok(package_json) => package_json,
236 Err(_) => {
237 return Ok(vec![invalid_item(
238 RunxListItemKind::Packet,
239 "package.json".to_owned(),
240 "package.json".to_owned(),
241 "runx.packet.package.invalid",
242 )]);
243 }
244 };
245
246 let mut items = Vec::new();
247 let mut seen = BTreeMap::<String, String>::new();
248 for packet_glob in package_json
249 .runx
250 .as_ref()
251 .map(|runx| runx.packets.as_slice())
252 .unwrap_or_default()
253 {
254 let files = expand_local_glob(root, packet_glob)?;
255 if files.is_empty() {
256 items.push(invalid_item(
257 RunxListItemKind::Packet,
258 packet_glob.clone(),
259 "package.json".to_owned(),
260 "runx.packet.ref.missing",
261 ));
262 continue;
263 }
264 for file_path in files {
265 let relative_path = project_path(root, &file_path);
266 let source = match fs::read_to_string(&file_path) {
267 Ok(source) => source,
268 Err(_) => {
269 items.push(invalid_item(
270 RunxListItemKind::Packet,
271 relative_path.clone(),
272 relative_path,
273 "runx.packet.schema.invalid",
274 ));
275 continue;
276 }
277 };
278 let schema = match serde_json::from_str::<PacketSchema>(&source) {
279 Ok(schema) => schema,
280 _ => {
281 items.push(invalid_item(
282 RunxListItemKind::Packet,
283 relative_path.clone(),
284 relative_path,
285 "runx.packet.schema.invalid",
286 ));
287 continue;
288 }
289 };
290 let Some(packet_id) = packet_id(&schema) else {
291 items.push(invalid_item(
292 RunxListItemKind::Packet,
293 relative_path.clone(),
294 relative_path,
295 "runx.packet.id.mismatch",
296 ));
297 continue;
298 };
299 if let Some(existing_source) = seen.get(&packet_id) {
300 if existing_source != &source {
301 items.push(invalid_item(
302 RunxListItemKind::Packet,
303 packet_id,
304 relative_path,
305 "runx.packet.id.collision",
306 ));
307 continue;
308 }
309 }
310 seen.insert(packet_id.clone(), source);
311 items.push(RunxListItem {
312 kind: RunxListItemKind::Packet,
313 name: packet_id,
314 source: RunxListSource::Local,
315 path: relative_path,
316 status: RunxListStatus::Ok,
317 diagnostics: None,
318 scopes: None,
319 emits: None,
320 fixtures: None,
321 harness_cases: None,
322 steps: None,
323 wraps: None,
324 });
325 }
326 }
327 Ok(items)
328}
329
330fn discover_overlay_list_items(root: &Path) -> Result<Vec<RunxListItem>, RuntimeError> {
331 let overlays_root = root.join("skills-overlays");
332 let mut items = Vec::new();
333 for vendor_entry in read_dir_sorted(&overlays_root)? {
334 if !vendor_entry.is_dir {
335 continue;
336 }
337 for skill_entry in read_dir_sorted(&vendor_entry.path)? {
338 if !skill_entry.is_dir {
339 continue;
340 }
341 let profile_path = skill_entry.path.join("X.yaml");
342 if !profile_path.exists() {
343 continue;
344 }
345 let contents = read_to_string(&profile_path)?;
346 items.push(RunxListItem {
347 kind: RunxListItemKind::Overlay,
348 name: format!("{}/{}", vendor_entry.name, skill_entry.name),
349 source: RunxListSource::Local,
350 path: project_path(root, &profile_path),
351 status: RunxListStatus::Ok,
352 diagnostics: None,
353 scopes: None,
354 emits: None,
355 fixtures: None,
356 harness_cases: None,
357 steps: None,
358 wraps: overlay_wraps(&contents),
359 });
360 }
361 }
362 Ok(items)
363}
364
365fn invalid_item(
366 kind: RunxListItemKind,
367 name: String,
368 path: String,
369 diagnostic: &str,
370) -> RunxListItem {
371 RunxListItem {
372 kind,
373 name,
374 source: RunxListSource::Local,
375 path,
376 status: RunxListStatus::Invalid,
377 diagnostics: Some(vec![diagnostic.to_owned()]),
378 scopes: None,
379 emits: None,
380 fixtures: None,
381 harness_cases: None,
382 steps: None,
383 wraps: None,
384 }
385}
386
387#[derive(Deserialize)]
388struct PackageJson {
389 runx: Option<PackageRunxConfig>,
390}
391
392#[derive(Deserialize)]
393struct PackageRunxConfig {
394 #[serde(default)]
395 packets: Vec<String>,
396}
397
398#[derive(Deserialize)]
399struct PacketSchema {
400 #[serde(rename = "x-runx-packet-id")]
401 packet_id: Option<String>,
402 #[serde(rename = "$id")]
403 schema_id: Option<String>,
404}
405
406fn packet_id(schema: &PacketSchema) -> Option<String> {
407 schema
408 .packet_id
409 .as_deref()
410 .or(schema.schema_id.as_deref())
411 .map(str::to_owned)
412}
413
414fn expand_local_glob(root: &Path, glob: &str) -> Result<Vec<PathBuf>, RuntimeError> {
415 if !glob.contains('*') {
416 let path = root.join(glob);
417 return Ok(path.exists().then_some(path).into_iter().collect());
418 }
419
420 let normalized = glob.replace('\\', "/");
421 let Some(star) = normalized.find('*') else {
422 return Ok(Vec::new());
423 };
424 let base = &normalized[..star];
425 let base_dir = base.rfind('/').map_or("", |slash| &base[..=slash]);
426 let suffix = &normalized[star + 1..];
427 let mut files = read_dir_sorted(&root.join(base_dir))?
428 .into_iter()
429 .filter(|entry| entry.is_file && display_path(&entry.path).ends_with(suffix))
430 .map(|entry| entry.path)
431 .collect::<Vec<_>>();
432 files.sort();
433 Ok(files)
434}
435
436fn discover_skill_profile_paths(root: &Path) -> Result<Vec<PathBuf>, RuntimeError> {
437 let mut paths = Vec::new();
438 let root_profile = root.join("X.yaml");
439 if root_profile.exists() {
440 paths.push(root_profile);
441 }
442 for skill_entry in read_dir_sorted(&root.join("skills"))? {
443 if !skill_entry.is_dir {
444 continue;
445 }
446 let profile_path = skill_entry.path.join("X.yaml");
447 if profile_path.exists() {
448 paths.push(profile_path);
449 }
450 }
451 paths.sort();
452 Ok(paths)
453}
454
455fn fallback_skill_name(root: &Path, skill_dir: &Path) -> String {
456 if skill_dir == root {
457 return root.file_name().map_or_else(
458 || ".".to_owned(),
459 |name| name.to_string_lossy().into_owned(),
460 );
461 }
462 skill_dir.file_name().map_or_else(
463 || ".".to_owned(),
464 |name| name.to_string_lossy().into_owned(),
465 )
466}
467
468fn overlay_wraps(contents: &str) -> Option<String> {
469 contents.lines().find_map(|line| {
470 let trimmed = line.trim();
471 trimmed
472 .strip_prefix("wraps:")
473 .map(str::trim)
474 .filter(|value| !value.is_empty())
475 .map(str::to_owned)
476 })
477}
478
479fn sort_list_items(items: &mut [RunxListItem]) {
480 items.sort_by(|left, right| {
481 source_order(left.source)
482 .cmp(&source_order(right.source))
483 .then_with(|| kind_order(left.kind).cmp(&kind_order(right.kind)))
484 .then_with(|| left.name.cmp(&right.name))
485 });
486}
487
488fn source_order(source: RunxListSource) -> u8 {
489 match source {
490 RunxListSource::Local => 0,
491 RunxListSource::Workspace => 1,
492 RunxListSource::Dependencies => 2,
493 RunxListSource::BuiltIn => 3,
494 }
495}
496
497fn kind_order(kind: RunxListItemKind) -> u8 {
498 match kind {
499 RunxListItemKind::Tool => 0,
500 RunxListItemKind::Skill => 1,
501 RunxListItemKind::Graph => 2,
502 RunxListItemKind::Packet => 3,
503 RunxListItemKind::Overlay => 4,
504 }
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 #[test]
512 fn overlay_wraps_reads_plain_wraps_line() {
513 assert_eq!(
514 overlay_wraps("name: demo\n wraps: vendor/base\n"),
515 Some("vendor/base".to_owned())
516 );
517 }
518
519 #[test]
520 fn sorts_by_kind_then_name() {
521 let mut items = vec![
522 valid_item(RunxListItemKind::Packet, "b"),
523 valid_item(RunxListItemKind::Tool, "z"),
524 valid_item(RunxListItemKind::Tool, "a"),
525 valid_item(RunxListItemKind::Skill, "a"),
526 ];
527 sort_list_items(&mut items);
528 assert_eq!(
529 items
530 .iter()
531 .map(|item| (item.kind, item.name.as_str()))
532 .collect::<Vec<_>>(),
533 vec![
534 (RunxListItemKind::Tool, "a"),
535 (RunxListItemKind::Tool, "z"),
536 (RunxListItemKind::Skill, "a"),
537 (RunxListItemKind::Packet, "b"),
538 ]
539 );
540 }
541
542 fn valid_item(kind: RunxListItemKind, name: &str) -> RunxListItem {
543 RunxListItem {
544 kind,
545 name: name.to_owned(),
546 source: RunxListSource::Local,
547 path: ".".to_owned(),
548 status: RunxListStatus::Ok,
549 diagnostics: None,
550 scopes: None,
551 emits: None,
552 fixtures: None,
553 harness_cases: None,
554 steps: None,
555 wraps: None,
556 }
557 }
558}