modewise_operations/
catalogue.rs1use 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
25pub 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
42fn 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 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
126pub 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}