smol-workflow-engine 0.2.1

Rust implementation of the smol-workflows engine.
Documentation
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;

#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct WorkflowMetadata {
    pub name: String,
    pub description: String,
    #[serde(rename = "whenToUse", skip_serializing_if = "Option::is_none")]
    pub when_to_use: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub phases: Vec<WorkflowPhaseMetadata>,
}

#[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
pub struct WorkflowPhaseMetadata {
    pub title: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub detail: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub provider: Option<String>,
}

pub fn read_workflow_metadata(path: impl AsRef<Path>) -> anyhow::Result<Option<WorkflowMetadata>> {
    let source = match fs::read_to_string(path.as_ref()) {
        Ok(source) => source,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
        Err(error) => return Err(error.into()),
    };

    Ok(extract_workflow_metadata(&source))
}

pub fn extract_workflow_metadata(source: &str) -> Option<WorkflowMetadata> {
    let mut parser = Parser::new(source);
    let value = parser.find_exported_const_meta()?;
    to_workflow_metadata(value)
}

fn to_workflow_metadata(value: serde_json::Value) -> Option<WorkflowMetadata> {
    let object = value.as_object()?;
    let name = object.get("name")?.as_str()?.to_string();
    let description = object.get("description")?.as_str()?.to_string();
    let when_to_use = object
        .get("whenToUse")
        .and_then(|value| value.as_str())
        .map(ToString::to_string);
    let phases = object
        .get("phases")
        .and_then(|value| value.as_array())
        .map(|phases| {
            phases
                .iter()
                .filter_map(to_workflow_phase_metadata)
                .collect::<Vec<_>>()
        })
        .unwrap_or_default();

    Some(WorkflowMetadata {
        name,
        description,
        when_to_use,
        phases,
    })
}

fn to_workflow_phase_metadata(value: &serde_json::Value) -> Option<WorkflowPhaseMetadata> {
    let object = value.as_object()?;
    Some(WorkflowPhaseMetadata {
        title: object.get("title")?.as_str()?.to_string(),
        detail: object
            .get("detail")
            .and_then(|value| value.as_str())
            .map(ToString::to_string),
        model: object
            .get("model")
            .and_then(|value| value.as_str())
            .map(ToString::to_string),
        provider: object
            .get("provider")
            .and_then(|value| value.as_str())
            .map(ToString::to_string),
    })
}

struct Parser<'a> {
    source: &'a str,
    bytes: &'a [u8],
    pos: usize,
}

impl<'a> Parser<'a> {
    fn new(source: &'a str) -> Self {
        Self {
            source,
            bytes: source.as_bytes(),
            pos: 0,
        }
    }

    fn find_exported_const_meta(&mut self) -> Option<serde_json::Value> {
        while self.skip_ws_comments() {
            let checkpoint = self.pos;
            if self.consume_keyword("export")
                && self.skip_ws_comments()
                && self.consume_keyword("const")
                && self.skip_ws_comments()
                && self.consume_keyword("meta")
                && self.skip_ws_comments()
                && self.consume_byte(b'=')
            {
                self.skip_ws_comments();
                let value = self.parse_value().ok()?;
                return Some(value);
            }
            self.pos = checkpoint;
            self.skip_one_token();
        }
        None
    }

    fn parse_value(&mut self) -> anyhow::Result<serde_json::Value> {
        self.skip_ws_comments();
        match self.peek_byte() {
            Some(b'{') => self.parse_object(),
            Some(b'[') => self.parse_array(),
            Some(b'\'') | Some(b'"') => self.parse_string().map(serde_json::Value::String),
            Some(b'-') | Some(b'+') | Some(b'0'..=b'9') => {
                self.parse_number().map(serde_json::Value::from)
            }
            Some(_) if self.consume_keyword("true") => Ok(serde_json::Value::Bool(true)),
            Some(_) if self.consume_keyword("false") => Ok(serde_json::Value::Bool(false)),
            Some(_) if self.consume_keyword("null") => Ok(serde_json::Value::Null),
            _ => anyhow::bail!("unsupported metadata literal"),
        }
    }

    fn parse_object(&mut self) -> anyhow::Result<serde_json::Value> {
        self.expect_byte(b'{')?;
        let mut object = serde_json::Map::new();
        loop {
            self.skip_ws_comments();
            if self.consume_byte(b'}') {
                break;
            }
            if self.starts_with("...") || self.peek_byte() == Some(b'[') {
                anyhow::bail!("unsupported object property");
            }
            let key = self.parse_property_key()?;
            self.skip_ws_comments();
            self.expect_byte(b':')?;
            let value = self.parse_value()?;
            object.insert(key, value);
            self.skip_ws_comments();
            if self.consume_byte(b'}') {
                break;
            }
            self.expect_byte(b',')?;
        }
        Ok(serde_json::Value::Object(object))
    }

    fn parse_array(&mut self) -> anyhow::Result<serde_json::Value> {
        self.expect_byte(b'[')?;
        let mut array = Vec::new();
        loop {
            self.skip_ws_comments();
            if self.consume_byte(b']') {
                break;
            }
            if self.starts_with("...") || self.peek_byte() == Some(b',') {
                anyhow::bail!("unsupported array element");
            }
            array.push(self.parse_value()?);
            self.skip_ws_comments();
            if self.consume_byte(b']') {
                break;
            }
            self.expect_byte(b',')?;
        }
        Ok(serde_json::Value::Array(array))
    }

    fn parse_property_key(&mut self) -> anyhow::Result<String> {
        self.skip_ws_comments();
        match self.peek_byte() {
            Some(b'\'') | Some(b'"') => self.parse_string(),
            Some(b'0'..=b'9') => self.parse_number().map(|number| {
                if number.fract() == 0.0 {
                    format!("{}", number as i64)
                } else {
                    number.to_string()
                }
            }),
            Some(_) => self.parse_identifier(),
            None => anyhow::bail!("unexpected end of metadata"),
        }
    }

    fn parse_identifier(&mut self) -> anyhow::Result<String> {
        let start = self.pos;
        let Some(byte) = self.peek_byte() else {
            anyhow::bail!("unexpected end of metadata")
        };
        if !is_ident_start(byte) {
            anyhow::bail!("expected identifier")
        }
        self.pos += 1;
        while matches!(self.peek_byte(), Some(byte) if is_ident_continue(byte)) {
            self.pos += 1;
        }
        Ok(self.source[start..self.pos].to_string())
    }

    fn parse_string(&mut self) -> anyhow::Result<String> {
        let quote = self
            .peek_byte()
            .ok_or_else(|| anyhow::anyhow!("expected string"))?;
        if quote != b'\'' && quote != b'"' {
            anyhow::bail!("expected string")
        }
        self.pos += 1;
        let mut output = String::new();
        while let Some(byte) = self.peek_byte() {
            self.pos += 1;
            match byte {
                b if b == quote => return Ok(output),
                b'\\' => output.push(self.parse_escape()?),
                b => output.push(b as char),
            }
        }
        anyhow::bail!("unterminated string")
    }

    fn parse_escape(&mut self) -> anyhow::Result<char> {
        let byte = self
            .peek_byte()
            .ok_or_else(|| anyhow::anyhow!("unterminated escape"))?;
        self.pos += 1;
        Ok(match byte {
            b'"' => '"',
            b'\'' => '\'',
            b'\\' => '\\',
            b'/' => '/',
            b'b' => '\u{0008}',
            b'f' => '\u{000c}',
            b'n' => '\n',
            b'r' => '\r',
            b't' => '\t',
            b'u' => {
                let hex = self.take_chars(4)?;
                let value = u16::from_str_radix(hex, 16)?;
                char::from_u32(value as u32).ok_or_else(|| anyhow::anyhow!("invalid unicode"))?
            }
            b => b as char,
        })
    }

    fn parse_number(&mut self) -> anyhow::Result<f64> {
        let start = self.pos;
        if matches!(self.peek_byte(), Some(b'-' | b'+')) {
            self.pos += 1;
        }
        while matches!(self.peek_byte(), Some(b'0'..=b'9')) {
            self.pos += 1;
        }
        if self.consume_byte(b'.') {
            while matches!(self.peek_byte(), Some(b'0'..=b'9')) {
                self.pos += 1;
            }
        }
        if matches!(self.peek_byte(), Some(b'e' | b'E')) {
            self.pos += 1;
            if matches!(self.peek_byte(), Some(b'-' | b'+')) {
                self.pos += 1;
            }
            while matches!(self.peek_byte(), Some(b'0'..=b'9')) {
                self.pos += 1;
            }
        }
        let text = &self.source[start..self.pos];
        if text == "+" || text == "-" || text.is_empty() {
            anyhow::bail!("invalid number")
        }
        Ok(text.parse()?)
    }

    fn skip_ws_comments(&mut self) -> bool {
        loop {
            while matches!(self.peek_byte(), Some(b' ' | b'\t' | b'\r' | b'\n')) {
                self.pos += 1;
            }
            if self.starts_with("//") {
                while !matches!(self.peek_byte(), None | Some(b'\n')) {
                    self.pos += 1;
                }
                continue;
            }
            if self.starts_with("/*") {
                self.pos += 2;
                while self.pos + 1 < self.bytes.len() && !self.starts_with("*/") {
                    self.pos += 1;
                }
                self.pos = (self.pos + 2).min(self.bytes.len());
                continue;
            }
            return self.pos < self.bytes.len();
        }
    }

    fn skip_one_token(&mut self) {
        match self.peek_byte() {
            Some(b'\'' | b'"' | b'`') => self.skip_string_like(),
            Some(_) => self.pos += 1,
            None => {}
        }
    }

    fn skip_string_like(&mut self) {
        let Some(quote) = self.peek_byte() else {
            return;
        };
        self.pos += 1;
        while let Some(byte) = self.peek_byte() {
            self.pos += 1;
            if byte == b'\\' {
                self.pos = (self.pos + 1).min(self.bytes.len());
            } else if byte == quote {
                break;
            }
        }
    }

    fn consume_keyword(&mut self, keyword: &str) -> bool {
        if !self.starts_with(keyword) {
            return false;
        }
        let end = self.pos + keyword.len();
        if end < self.bytes.len() && is_ident_continue(self.bytes[end]) {
            return false;
        }
        if self.pos > 0 && is_ident_continue(self.bytes[self.pos - 1]) {
            return false;
        }
        self.pos = end;
        true
    }

    fn consume_byte(&mut self, byte: u8) -> bool {
        if self.peek_byte() == Some(byte) {
            self.pos += 1;
            true
        } else {
            false
        }
    }

    fn expect_byte(&mut self, byte: u8) -> anyhow::Result<()> {
        if self.consume_byte(byte) {
            Ok(())
        } else {
            anyhow::bail!("expected byte {}", byte as char)
        }
    }

    fn starts_with(&self, needle: &str) -> bool {
        self.source[self.pos..].starts_with(needle)
    }

    fn peek_byte(&self) -> Option<u8> {
        self.bytes.get(self.pos).copied()
    }

    fn take_chars(&mut self, count: usize) -> anyhow::Result<&'a str> {
        let start = self.pos;
        let end = self.pos + count;
        if end > self.source.len() {
            anyhow::bail!("unexpected end")
        }
        self.pos = end;
        Ok(&self.source[start..end])
    }
}

fn is_ident_start(byte: u8) -> bool {
    byte == b'_' || byte == b'$' || byte.is_ascii_alphabetic()
}

fn is_ident_continue(byte: u8) -> bool {
    is_ident_start(byte) || byte.is_ascii_digit()
}