Skip to main content

runx_runtime/
list.rs

1// rust-style-allow: large-file because native list discovery intentionally keeps
2// tool, skill, graph, packet, and overlay projection in one audited cutover
3// surface until the TypeScript list command is fully retired.
4use 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
225// rust-style-allow: long-function because packet discovery keeps glob expansion,
226// schema-id extraction, and duplicate-id diagnostics in one deterministic pass.
227fn 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}