Skip to main content

runx_cli/
list.rs

1use std::fmt;
2use std::path::Path;
3
4use crate::router::{FilterMode, ListKind, ListPlan};
5use runx_runtime::{
6    RunxListItem, RunxListItemKind, RunxListOptions, RunxListRequestedKind, RunxListStatus,
7    list_authoring_primitives,
8};
9
10#[derive(Debug)]
11pub enum ListCliError {
12    #[cfg(test)]
13    Io {
14        context: &'static str,
15        source: std::io::Error,
16    },
17    Runtime(runx_runtime::RuntimeError),
18    Serialize(serde_json::Error),
19}
20
21impl fmt::Display for ListCliError {
22    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
23        match self {
24            #[cfg(test)]
25            Self::Io { context, source } => {
26                write!(formatter, "test I/O failed while {context}: {source}")
27            }
28            Self::Runtime(error) => write!(formatter, "{error}"),
29            Self::Serialize(error) => write!(formatter, "failed to serialize list output: {error}"),
30        }
31    }
32}
33
34impl std::error::Error for ListCliError {}
35
36impl From<runx_runtime::RuntimeError> for ListCliError {
37    fn from(error: runx_runtime::RuntimeError) -> Self {
38        Self::Runtime(error)
39    }
40}
41
42impl From<serde_json::Error> for ListCliError {
43    fn from(error: serde_json::Error) -> Self {
44        Self::Serialize(error)
45    }
46}
47
48pub fn run_list_command(plan: &ListPlan, cwd: &Path) -> Result<String, ListCliError> {
49    let options = RunxListOptions {
50        root: cwd.to_path_buf(),
51        requested_kind: requested_kind(plan.kind),
52    };
53    let mut report = list_authoring_primitives(&options)?;
54    report
55        .items
56        .retain(|item| item_visible_for_filter(item, plan.filter));
57
58    if plan.json {
59        return Ok(format!("{}\n", serde_json::to_string_pretty(&report)?));
60    }
61
62    Ok(render_list_items(&report.items))
63}
64
65fn requested_kind(kind: ListKind) -> RunxListRequestedKind {
66    match kind {
67        ListKind::All => RunxListRequestedKind::All,
68        ListKind::Tools => RunxListRequestedKind::Tools,
69        ListKind::Skills => RunxListRequestedKind::Skills,
70        ListKind::Graphs => RunxListRequestedKind::Graphs,
71        ListKind::Packets => RunxListRequestedKind::Packets,
72        ListKind::Overlays => RunxListRequestedKind::Overlays,
73    }
74}
75
76fn item_visible_for_filter(item: &RunxListItem, filter: FilterMode) -> bool {
77    match filter {
78        FilterMode::All => true,
79        FilterMode::OkOnly => item.status == RunxListStatus::Ok,
80        FilterMode::InvalidOnly => item.status == RunxListStatus::Invalid,
81    }
82}
83
84fn render_list_items(items: &[RunxListItem]) -> String {
85    if items.is_empty() {
86        return "No runx authoring primitives found.\n".to_owned();
87    }
88
89    let mut output = String::new();
90    for item in items {
91        output.push_str(&format!(
92            "{}\t{}\t{}\t{}\n",
93            kind_label(item.kind),
94            status_label(item.status),
95            item.name,
96            item.path
97        ));
98        if let Some(diagnostics) = &item.diagnostics {
99            for diagnostic in diagnostics {
100                output.push_str(&format!("  diagnostic\t{diagnostic}\n"));
101            }
102        }
103    }
104    output
105}
106
107fn kind_label(kind: RunxListItemKind) -> &'static str {
108    match kind {
109        RunxListItemKind::Tool => "tool",
110        RunxListItemKind::Skill => "skill",
111        RunxListItemKind::Graph => "graph",
112        RunxListItemKind::Packet => "packet",
113        RunxListItemKind::Overlay => "overlay",
114    }
115}
116
117fn status_label(status: RunxListStatus) -> &'static str {
118    match status {
119        RunxListStatus::Ok => "ok",
120        RunxListStatus::Invalid => "invalid",
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use std::fs;
127
128    use super::*;
129
130    #[derive(serde::Deserialize)]
131    struct TestListReport {
132        schema: String,
133        items: Vec<TestListItem>,
134    }
135
136    #[derive(serde::Deserialize)]
137    struct TestListItem {
138        kind: String,
139        name: String,
140    }
141
142    #[test]
143    fn json_lists_declared_packets() -> Result<(), ListCliError> {
144        let root = temp_workspace("packets");
145        let _ignored = fs::remove_dir_all(&root);
146        fs::create_dir_all(root.join("packets")).map_err(runtime_io("creating packet fixture"))?;
147        fs::write(
148            root.join("package.json"),
149            r#"{"runx":{"packets":["packets/*.json"]}}"#,
150        )
151        .map_err(runtime_io("writing package fixture"))?;
152        fs::write(
153            root.join("packets/payment.quote.json"),
154            r#"{"x-runx-packet-id":"runx.payment.quote.v1"}"#,
155        )
156        .map_err(runtime_io("writing packet fixture"))?;
157
158        let output = run_list_command(
159            &ListPlan {
160                kind: ListKind::Packets,
161                filter: FilterMode::OkOnly,
162                json: true,
163            },
164            &root,
165        )?;
166
167        let report = serde_json::from_str::<TestListReport>(&output)?;
168        assert_eq!(report.schema, "runx.list.v1");
169        assert_eq!(report.items[0].kind, "packet");
170        assert_eq!(report.items[0].name, "runx.payment.quote.v1");
171
172        fs::remove_dir_all(root).map_err(runtime_io("removing packet fixture"))?;
173        Ok(())
174    }
175
176    #[test]
177    fn human_empty_list_is_stable() -> Result<(), ListCliError> {
178        let root = temp_workspace("empty");
179        let _ignored = fs::remove_dir_all(&root);
180        fs::create_dir_all(&root).map_err(runtime_io("creating empty fixture"))?;
181
182        let output = run_list_command(
183            &ListPlan {
184                kind: ListKind::Tools,
185                filter: FilterMode::All,
186                json: false,
187            },
188            &root,
189        )?;
190
191        assert_eq!(output, "No runx authoring primitives found.\n");
192
193        fs::remove_dir_all(root).map_err(runtime_io("removing empty fixture"))?;
194        Ok(())
195    }
196
197    fn runtime_io(context: &'static str) -> impl FnOnce(std::io::Error) -> ListCliError {
198        move |source| ListCliError::Io { context, source }
199    }
200
201    fn temp_workspace(name: &str) -> std::path::PathBuf {
202        std::env::temp_dir().join(format!("runx-list-{name}-{}", std::process::id()))
203    }
204}