smol_workflow_engine/
metadata.rs1use serde::{Deserialize, Serialize};
2use std::fs;
3use std::path::Path;
4
5#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
6pub struct WorkflowMetadata {
7 pub name: String,
8 pub description: String,
9 #[serde(rename = "whenToUse", skip_serializing_if = "Option::is_none")]
10 pub when_to_use: Option<String>,
11 #[serde(default, skip_serializing_if = "Vec::is_empty")]
12 pub phases: Vec<WorkflowPhaseMetadata>,
13}
14
15#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
16pub struct WorkflowPhaseMetadata {
17 pub title: String,
18 #[serde(skip_serializing_if = "Option::is_none")]
19 pub detail: Option<String>,
20 #[serde(skip_serializing_if = "Option::is_none")]
21 pub model: Option<String>,
22 #[serde(skip_serializing_if = "Option::is_none")]
23 pub provider: Option<String>,
24}
25
26pub fn read_workflow_metadata(path: impl AsRef<Path>) -> anyhow::Result<Option<WorkflowMetadata>> {
27 let source = match fs::read_to_string(path.as_ref()) {
28 Ok(source) => source,
29 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
30 Err(error) => return Err(error.into()),
31 };
32
33 Ok(extract_workflow_metadata(&source))
34}
35
36pub fn extract_workflow_metadata(source: &str) -> Option<WorkflowMetadata> {
37 let mut parser = Parser::new(source);
38 let value = parser.find_exported_const_meta()?;
39 to_workflow_metadata(value)
40}
41
42fn to_workflow_metadata(value: serde_json::Value) -> Option<WorkflowMetadata> {
43 let object = value.as_object()?;
44 let name = object.get("name")?.as_str()?.to_string();
45 let description = object.get("description")?.as_str()?.to_string();
46 let when_to_use = object
47 .get("whenToUse")
48 .and_then(|value| value.as_str())
49 .map(ToString::to_string);
50 let phases = object
51 .get("phases")
52 .and_then(|value| value.as_array())
53 .map(|phases| {
54 phases
55 .iter()
56 .filter_map(to_workflow_phase_metadata)
57 .collect::<Vec<_>>()
58 })
59 .unwrap_or_default();
60
61 Some(WorkflowMetadata {
62 name,
63 description,
64 when_to_use,
65 phases,
66 })
67}
68
69fn to_workflow_phase_metadata(value: &serde_json::Value) -> Option<WorkflowPhaseMetadata> {
70 let object = value.as_object()?;
71 Some(WorkflowPhaseMetadata {
72 title: object.get("title")?.as_str()?.to_string(),
73 detail: object
74 .get("detail")
75 .and_then(|value| value.as_str())
76 .map(ToString::to_string),
77 model: object
78 .get("model")
79 .and_then(|value| value.as_str())
80 .map(ToString::to_string),
81 provider: object
82 .get("provider")
83 .and_then(|value| value.as_str())
84 .map(ToString::to_string),
85 })
86}
87
88struct Parser<'a> {
89 source: &'a str,
90 bytes: &'a [u8],
91 pos: usize,
92}
93
94impl<'a> Parser<'a> {
95 fn new(source: &'a str) -> Self {
96 Self {
97 source,
98 bytes: source.as_bytes(),
99 pos: 0,
100 }
101 }
102
103 fn find_exported_const_meta(&mut self) -> Option<serde_json::Value> {
104 while self.skip_ws_comments() {
105 let checkpoint = self.pos;
106 if self.consume_keyword("export")
107 && self.skip_ws_comments()
108 && self.consume_keyword("const")
109 && self.skip_ws_comments()
110 && self.consume_keyword("meta")
111 && self.skip_ws_comments()
112 && self.consume_byte(b'=')
113 {
114 self.skip_ws_comments();
115 let value = self.parse_value().ok()?;
116 return Some(value);
117 }
118 self.pos = checkpoint;
119 self.skip_one_token();
120 }
121 None
122 }
123
124 fn parse_value(&mut self) -> anyhow::Result<serde_json::Value> {
125 self.skip_ws_comments();
126 match self.peek_byte() {
127 Some(b'{') => self.parse_object(),
128 Some(b'[') => self.parse_array(),
129 Some(b'\'') | Some(b'"') => self.parse_string().map(serde_json::Value::String),
130 Some(b'-') | Some(b'+') | Some(b'0'..=b'9') => {
131 self.parse_number().map(serde_json::Value::from)
132 }
133 Some(_) if self.consume_keyword("true") => Ok(serde_json::Value::Bool(true)),
134 Some(_) if self.consume_keyword("false") => Ok(serde_json::Value::Bool(false)),
135 Some(_) if self.consume_keyword("null") => Ok(serde_json::Value::Null),
136 _ => anyhow::bail!("unsupported metadata literal"),
137 }
138 }
139
140 fn parse_object(&mut self) -> anyhow::Result<serde_json::Value> {
141 self.expect_byte(b'{')?;
142 let mut object = serde_json::Map::new();
143 loop {
144 self.skip_ws_comments();
145 if self.consume_byte(b'}') {
146 break;
147 }
148 if self.starts_with("...") || self.peek_byte() == Some(b'[') {
149 anyhow::bail!("unsupported object property");
150 }
151 let key = self.parse_property_key()?;
152 self.skip_ws_comments();
153 self.expect_byte(b':')?;
154 let value = self.parse_value()?;
155 object.insert(key, value);
156 self.skip_ws_comments();
157 if self.consume_byte(b'}') {
158 break;
159 }
160 self.expect_byte(b',')?;
161 }
162 Ok(serde_json::Value::Object(object))
163 }
164
165 fn parse_array(&mut self) -> anyhow::Result<serde_json::Value> {
166 self.expect_byte(b'[')?;
167 let mut array = Vec::new();
168 loop {
169 self.skip_ws_comments();
170 if self.consume_byte(b']') {
171 break;
172 }
173 if self.starts_with("...") || self.peek_byte() == Some(b',') {
174 anyhow::bail!("unsupported array element");
175 }
176 array.push(self.parse_value()?);
177 self.skip_ws_comments();
178 if self.consume_byte(b']') {
179 break;
180 }
181 self.expect_byte(b',')?;
182 }
183 Ok(serde_json::Value::Array(array))
184 }
185
186 fn parse_property_key(&mut self) -> anyhow::Result<String> {
187 self.skip_ws_comments();
188 match self.peek_byte() {
189 Some(b'\'') | Some(b'"') => self.parse_string(),
190 Some(b'0'..=b'9') => self.parse_number().map(|number| {
191 if number.fract() == 0.0 {
192 format!("{}", number as i64)
193 } else {
194 number.to_string()
195 }
196 }),
197 Some(_) => self.parse_identifier(),
198 None => anyhow::bail!("unexpected end of metadata"),
199 }
200 }
201
202 fn parse_identifier(&mut self) -> anyhow::Result<String> {
203 let start = self.pos;
204 let Some(byte) = self.peek_byte() else {
205 anyhow::bail!("unexpected end of metadata")
206 };
207 if !is_ident_start(byte) {
208 anyhow::bail!("expected identifier")
209 }
210 self.pos += 1;
211 while matches!(self.peek_byte(), Some(byte) if is_ident_continue(byte)) {
212 self.pos += 1;
213 }
214 Ok(self.source[start..self.pos].to_string())
215 }
216
217 fn parse_string(&mut self) -> anyhow::Result<String> {
218 let quote = self
219 .peek_byte()
220 .ok_or_else(|| anyhow::anyhow!("expected string"))?;
221 if quote != b'\'' && quote != b'"' {
222 anyhow::bail!("expected string")
223 }
224 self.pos += 1;
225 let mut output = String::new();
226 while let Some(byte) = self.peek_byte() {
227 self.pos += 1;
228 match byte {
229 b if b == quote => return Ok(output),
230 b'\\' => output.push(self.parse_escape()?),
231 b => output.push(b as char),
232 }
233 }
234 anyhow::bail!("unterminated string")
235 }
236
237 fn parse_escape(&mut self) -> anyhow::Result<char> {
238 let byte = self
239 .peek_byte()
240 .ok_or_else(|| anyhow::anyhow!("unterminated escape"))?;
241 self.pos += 1;
242 Ok(match byte {
243 b'"' => '"',
244 b'\'' => '\'',
245 b'\\' => '\\',
246 b'/' => '/',
247 b'b' => '\u{0008}',
248 b'f' => '\u{000c}',
249 b'n' => '\n',
250 b'r' => '\r',
251 b't' => '\t',
252 b'u' => {
253 let hex = self.take_chars(4)?;
254 let value = u16::from_str_radix(hex, 16)?;
255 char::from_u32(value as u32).ok_or_else(|| anyhow::anyhow!("invalid unicode"))?
256 }
257 b => b as char,
258 })
259 }
260
261 fn parse_number(&mut self) -> anyhow::Result<f64> {
262 let start = self.pos;
263 if matches!(self.peek_byte(), Some(b'-' | b'+')) {
264 self.pos += 1;
265 }
266 while matches!(self.peek_byte(), Some(b'0'..=b'9')) {
267 self.pos += 1;
268 }
269 if self.consume_byte(b'.') {
270 while matches!(self.peek_byte(), Some(b'0'..=b'9')) {
271 self.pos += 1;
272 }
273 }
274 if matches!(self.peek_byte(), Some(b'e' | b'E')) {
275 self.pos += 1;
276 if matches!(self.peek_byte(), Some(b'-' | b'+')) {
277 self.pos += 1;
278 }
279 while matches!(self.peek_byte(), Some(b'0'..=b'9')) {
280 self.pos += 1;
281 }
282 }
283 let text = &self.source[start..self.pos];
284 if text == "+" || text == "-" || text.is_empty() {
285 anyhow::bail!("invalid number")
286 }
287 Ok(text.parse()?)
288 }
289
290 fn skip_ws_comments(&mut self) -> bool {
291 loop {
292 while matches!(self.peek_byte(), Some(b' ' | b'\t' | b'\r' | b'\n')) {
293 self.pos += 1;
294 }
295 if self.starts_with("//") {
296 while !matches!(self.peek_byte(), None | Some(b'\n')) {
297 self.pos += 1;
298 }
299 continue;
300 }
301 if self.starts_with("/*") {
302 self.pos += 2;
303 while self.pos + 1 < self.bytes.len() && !self.starts_with("*/") {
304 self.pos += 1;
305 }
306 self.pos = (self.pos + 2).min(self.bytes.len());
307 continue;
308 }
309 return self.pos < self.bytes.len();
310 }
311 }
312
313 fn skip_one_token(&mut self) {
314 match self.peek_byte() {
315 Some(b'\'' | b'"' | b'`') => self.skip_string_like(),
316 Some(_) => self.pos += 1,
317 None => {}
318 }
319 }
320
321 fn skip_string_like(&mut self) {
322 let Some(quote) = self.peek_byte() else {
323 return;
324 };
325 self.pos += 1;
326 while let Some(byte) = self.peek_byte() {
327 self.pos += 1;
328 if byte == b'\\' {
329 self.pos = (self.pos + 1).min(self.bytes.len());
330 } else if byte == quote {
331 break;
332 }
333 }
334 }
335
336 fn consume_keyword(&mut self, keyword: &str) -> bool {
337 if !self.starts_with(keyword) {
338 return false;
339 }
340 let end = self.pos + keyword.len();
341 if end < self.bytes.len() && is_ident_continue(self.bytes[end]) {
342 return false;
343 }
344 if self.pos > 0 && is_ident_continue(self.bytes[self.pos - 1]) {
345 return false;
346 }
347 self.pos = end;
348 true
349 }
350
351 fn consume_byte(&mut self, byte: u8) -> bool {
352 if self.peek_byte() == Some(byte) {
353 self.pos += 1;
354 true
355 } else {
356 false
357 }
358 }
359
360 fn expect_byte(&mut self, byte: u8) -> anyhow::Result<()> {
361 if self.consume_byte(byte) {
362 Ok(())
363 } else {
364 anyhow::bail!("expected byte {}", byte as char)
365 }
366 }
367
368 fn starts_with(&self, needle: &str) -> bool {
369 self.source[self.pos..].starts_with(needle)
370 }
371
372 fn peek_byte(&self) -> Option<u8> {
373 self.bytes.get(self.pos).copied()
374 }
375
376 fn take_chars(&mut self, count: usize) -> anyhow::Result<&'a str> {
377 let start = self.pos;
378 let end = self.pos + count;
379 if end > self.source.len() {
380 anyhow::bail!("unexpected end")
381 }
382 self.pos = end;
383 Ok(&self.source[start..end])
384 }
385}
386
387fn is_ident_start(byte: u8) -> bool {
388 byte == b'_' || byte == b'$' || byte.is_ascii_alphabetic()
389}
390
391fn is_ident_continue(byte: u8) -> bool {
392 is_ident_start(byte) || byte.is_ascii_digit()
393}