modewise_operations/
catalogue.rs

1//! Self-description of the built-in operations: a JSON catalogue for tools and agents, and the
2//! generated reference page (docs/reference/operations.md).
3
4use modewise_pipeline::{Operation, Port};
5use serde_json::{Value, json};
6
7use crate::builtin_registry;
8
9fn ports(ports: &[Port]) -> Value {
10    ports.iter().map(|p| json!({"name": p.name, "kinds": p.kinds.iter().map(ToString::to_string).collect::<Vec<_>>()})).collect()
11}
12
13fn entry(name: &str, op: &dyn Operation) -> Value {
14    let d = op.descriptor();
15    json!({
16        "name": name,
17        "version": d.version,
18        "summary": op.summary(),
19        "inputs": ports(d.inputs),
20        "outputs": ports(d.outputs),
21        "params": op.params_schema(),
22    })
23}
24
25/// Every built-in operation: name, version, summary, ports and parameter JSON Schema.
26pub fn catalogue() -> Value {
27    let registry = builtin_registry();
28    let operations: Vec<Value> = registry
29        .names()
30        .filter_map(|n| registry.get(n).map(|op| entry(n, op.as_ref())))
31        .collect();
32    json!({ "operations": operations })
33}
34
35fn resolve<'a>(schema: &'a Value, spec: &'a Value) -> &'a Value {
36    match spec.get("$ref").and_then(Value::as_str) {
37        Some(r) => &schema["$defs"][r.trim_start_matches("#/$defs/")],
38        None => spec,
39    }
40}
41
42/// A short human type for a schema node.
43fn type_name(schema: &Value, spec: &Value) -> String {
44    let spec = resolve(schema, spec);
45    if let Some(values) = spec.get("enum").and_then(Value::as_array) {
46        return values
47            .iter()
48            .map(|v| format!("`{}`", v.as_str().unwrap_or_default()))
49            .collect::<Vec<_>>()
50            .join(", ");
51    }
52    if let Some(options) = spec
53        .get("anyOf")
54        .or_else(|| spec.get("oneOf"))
55        .and_then(Value::as_array)
56    {
57        let names: Vec<String> = options
58            .iter()
59            .filter(|o| o.get("type") != Some(&json!("null")))
60            .map(|o| type_name(schema, o))
61            .collect();
62        return names.join(" or ");
63    }
64    let kind = match spec.get("type") {
65        Some(Value::Array(kinds)) => kinds
66            .iter()
67            .filter_map(Value::as_str)
68            .find(|k| *k != "null")
69            .unwrap_or("value"),
70        Some(Value::String(kind)) => kind.as_str(),
71        _ => "value",
72    };
73    match kind {
74        "array" => format!(
75            "list of {}",
76            spec.get("items")
77                .map_or("values".into(), |i| type_name(schema, i))
78        ),
79        "object" if spec.get("properties").is_some() => {
80            // Sorted, so the page does not depend on serde_json's map order.
81            let mut keys: Vec<&String> = spec["properties"]
82                .as_object()
83                .map(|p| p.keys().collect())
84                .unwrap_or_default();
85            keys.sort();
86            format!(
87                "table ({})",
88                keys.iter()
89                    .map(|k| format!("`{k}`"))
90                    .collect::<Vec<_>>()
91                    .join(", ")
92            )
93        }
94        "object" => "table".into(),
95        "number" => "number".into(),
96        other => other.into(),
97    }
98}
99
100fn cell(text: &str) -> String {
101    text.replace('|', "\\|").replace('\n', " ")
102}
103
104fn port_line(ports: &Value) -> String {
105    ports
106        .as_array()
107        .map(|ps| {
108            ps.iter()
109                .map(|p| {
110                    let kinds: Vec<&str> = p["kinds"]
111                        .as_array()
112                        .map(|k| k.iter().filter_map(Value::as_str).collect())
113                        .unwrap_or_default();
114                    format!(
115                        "`{}` ({})",
116                        p["name"].as_str().unwrap_or_default(),
117                        kinds.join(" or ")
118                    )
119                })
120                .collect::<Vec<_>>()
121                .join(", ")
122        })
123        .unwrap_or_default()
124}
125
126/// The reference page for every built-in operation, generated from `catalogue()`.
127pub fn reference_markdown() -> String {
128    let mut out = String::from(
129        "# Operation reference\n\n\
130         <!-- Generated from the operations' own schemas by `modewise ops --markdown`; do not edit.\n\
131         Regenerate with `MODEWISE_UPDATE_DOCS=1 cargo test -p modewise_operations --test reference`. -->\n\n\
132         Every step in a pipeline names one of these operations. Parameters go in the step's `params` table; \
133         anything not listed is rejected, and missing parameters take the defaults shown. \
134         `modewise ops --json` gives the same information as JSON Schema, for tools and agents.\n",
135    );
136    for op in catalogue()["operations"].as_array().into_iter().flatten() {
137        let name = op["name"].as_str().unwrap_or_default();
138        out.push_str(&format!(
139            "\n## `{name}`\n\n{}\n\n",
140            op["summary"].as_str().unwrap_or_default()
141        ));
142        out.push_str(&format!(
143            "- **Inputs:** {}\n- **Outputs:** {}\n",
144            port_line(&op["inputs"]),
145            port_line(&op["outputs"])
146        ));
147        let schema = &op["params"];
148        let required: Vec<&str> = schema["required"]
149            .as_array()
150            .map(|r| r.iter().filter_map(Value::as_str).collect())
151            .unwrap_or_default();
152        let Some(properties) = schema["properties"].as_object().filter(|p| !p.is_empty()) else {
153            out.push_str("\nNo parameters.\n");
154            continue;
155        };
156        let mut fields: Vec<(&String, &Value)> = properties.iter().collect();
157        fields.sort_by_key(|(k, _)| (!required.contains(&k.as_str()), k.as_str()));
158        out.push_str("\n| Parameter | Type | Default | Meaning |\n|---|---|---|---|\n");
159        for (field, spec) in fields {
160            let default = match spec.get("default") {
161                _ if required.contains(&field.as_str()) => "required".to_string(),
162                Some(Value::Null) | None => "—".to_string(),
163                Some(v) => format!("`{v}`"),
164            };
165            let meaning = spec
166                .get("description")
167                .and_then(Value::as_str)
168                .unwrap_or_default();
169            out.push_str(&format!(
170                "| `{field}` | {} | {default} | {} |\n",
171                cell(&type_name(schema, spec)),
172                cell(meaning)
173            ));
174        }
175    }
176    out
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn types_read_from_schema_nodes() {
185        let schema = json!({"$defs": {"Side": {"enum": ["left", "right"], "type": "string"}}});
186        let name = |spec: Value| type_name(&schema, &spec);
187        assert_eq!(name(json!({"$ref": "#/$defs/Side"})), "`left`, `right`");
188        assert_eq!(name(json!({"type": ["integer", "null"]})), "integer");
189        assert_eq!(
190            name(json!({"type": "array", "items": {"type": "string"}})),
191            "list of string"
192        );
193        assert_eq!(
194            name(json!({"type": "object", "additionalProperties": {"type": "string"}})),
195            "table"
196        );
197        assert_eq!(
198            name(json!({"type": "object", "properties": {"b": {}, "a": {}}})),
199            "table (`a`, `b`)"
200        );
201        assert_eq!(
202            name(json!({"anyOf": [{"$ref": "#/$defs/Side"}, {"type": "null"}]})),
203            "`left`, `right`"
204        );
205        assert_eq!(name(json!({})), "value");
206        assert_eq!(cell("a | b\nc"), "a \\| b c");
207    }
208}