Skip to main content

smol_workflow_engine/
metadata.rs

1use 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}