thread-rule-engine 0.1.0

Rule-based scanning and transformation engine for Thread
Documentation
// SPDX-FileCopyrightText: 2022 Herrington Darkholme <2883231+HerringtonDarkholme@users.noreply.github.com>
// SPDX-FileCopyrightText: 2025 Knitli Inc. <knitli@knit.li>
// SPDX-FileContributor: Adam Poulemanos <adam@knit.li>
//
// SPDX-License-Identifier: AGPL-3.0-or-later AND MIT

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::ops::Range;

fn capitalize(string: &str) -> String {
    let mut chars = string.chars();
    if let Some(c) = chars.next() {
        c.to_uppercase().chain(chars).collect()
    } else {
        string.to_string()
    }
}

/// An enumeration representing different cases for strings.
#[derive(Serialize, Deserialize, Clone, Copy, Debug, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub enum StringCase {
    LowerCase,
    UpperCase,
    Capitalize,
    CamelCase,
    SnakeCase,
    KebabCase,
    PascalCase,
}

use StringCase::*;

impl StringCase {
    pub fn apply(&self, s: &str, seps: Option<&[Separator]>) -> String {
        match &self {
            LowerCase => s.to_lowercase(),
            UpperCase => s.to_uppercase(),
            Capitalize => capitalize(s),
            CamelCase => join_camel_case(split(s, seps)),
            SnakeCase => join(split(s, seps), '_'),
            KebabCase => join(split(s, seps), '-'),
            PascalCase => split(s, seps).map(capitalize).collect(),
        }
    }
}

#[derive(Serialize, Deserialize, Clone, Copy, Debug, JsonSchema)]
#[serde(rename_all = "camelCase")]
/// Separator to split string. e.g. `user_accountName` -> `user`, `accountName`
/// It will be rejoin according to `StringCase`.
pub enum Separator {
    CaseChange,
    Dash,
    Dot,
    Slash,
    Space,
    Underscore,
}

impl From<&[Separator]> for Delimiter {
    fn from(value: &[Separator]) -> Self {
        use Separator::*;
        let mut delimiter = vec![];
        let mut state = CaseState::IgnoreCase;
        value.iter().for_each(|v| match v {
            CaseChange => state = CaseState::Lower,
            Dash => delimiter.push('-'),
            Dot => delimiter.push('.'),
            Slash => delimiter.push('/'),
            Space => delimiter.push(' '),
            Underscore => delimiter.push('_'),
        });
        Self {
            left: 0,
            right: 0,
            state,
            delimiter,
        }
    }
}

#[derive(PartialEq, Eq, Debug, Clone)]
/// CaseState is used to record the case change between two characters.
/// It will be used if separator is CaseChange.
enum CaseState {
    Lower,
    OneUpper,
    /// MultiUpper records consecutive uppercase characters.
    /// char is the last uppercase char, used to calculate the split range.
    MultiUpper(char),
    IgnoreCase,
}

#[derive(Debug)]
struct Delimiter {
    left: usize,
    right: usize,
    state: CaseState,
    delimiter: Vec<char>,
}
impl Delimiter {
    fn all() -> Delimiter {
        Delimiter {
            left: 0,
            right: 0,
            state: CaseState::Lower,
            delimiter: vec!['-', '.', '/', ' ', '_'],
        }
    }
    fn delimit(&mut self, c: char) -> Option<Range<usize>> {
        let Self {
            left,
            right,
            state,
            delimiter,
        } = self;
        use CaseState::*;
        // normal delimiter
        if delimiter.contains(&c) {
            let range = *left..*right;
            *left = *right + 1;
            *right = *left;
            if *state != IgnoreCase {
                self.state = Lower;
            }
            return Some(range);
        }
        // case delimiter, from lowercase to uppercase
        if *state == Lower && c.is_uppercase() {
            let range = *left..*right;
            *left = *right;
            *right = *left + c.len_utf8();
            self.state = OneUpper;
            return Some(range);
        }
        // case 2, consecutive UpperCases followed by lowercase
        // e.g. XMLHttp -> XML Http
        if let MultiUpper(last_char) = state
            && c.is_lowercase()
        {
            let new_left = *right - last_char.len_utf8();
            let range = *left..new_left;
            *left = new_left;
            *right += c.len_utf8();
            self.state = Lower;
            return Some(range);
        }
        *right += c.len_utf8();
        if *state == CaseState::IgnoreCase {
            return None;
        } else if c.is_lowercase() {
            self.state = Lower;
        } else if *state == Lower {
            self.state = OneUpper;
        } else {
            self.state = MultiUpper(c);
        }
        None
    }
    fn conclude(&mut self, len: usize) -> Option<Range<usize>> {
        let Self { left, right, .. } = self;
        if left < right && *right <= len {
            let range = *left..*right;
            *left = *right;
            Some(range)
        } else {
            None
        }
    }
}

/**
  Split string by Separator
*/
fn split<'a>(s: &'a str, seps: Option<&[Separator]>) -> impl Iterator<Item = &'a str> {
    let mut chars = s.chars();
    let mut delimiter = if let Some(seps) = seps {
        Delimiter::from(seps)
    } else {
        Delimiter::all()
    };
    std::iter::from_fn(move || {
        for c in chars.by_ref() {
            if let Some(range) = delimiter.delimit(c)
                && range.start != range.end
            {
                return Some(&s[range]);
            }
        }
        let range = delimiter.conclude(s.len())?;
        if range.start != range.end {
            Some(&s[range])
        } else {
            None
        }
    })
}

fn join<'a, I>(mut words: I, sep: char) -> String
where
    I: Iterator<Item = &'a str>,
{
    let mut result = String::new();
    if let Some(w) = words.next() {
        result.push_str(&w.to_lowercase());
    }
    for w in words {
        result.push(sep);
        result.push_str(&w.to_lowercase());
    }
    result
}

fn join_camel_case<'a, I>(words: I) -> String
where
    I: Iterator<Item = &'a str>,
{
    let mut result = String::new();
    for (i, word) in words.enumerate() {
        if i == 0 {
            result.push_str(&word.to_lowercase());
        } else {
            result.push_str(&capitalize(word));
        }
    }
    result
}

#[cfg(test)]
mod test {
    use super::*;

    #[test]
    fn test_case_conversions() {
        assert_eq!(StringCase::LowerCase.apply("aBc", None), "abc");
        assert_eq!(StringCase::UpperCase.apply("aBc", None), "ABC");
        assert_eq!(StringCase::Capitalize.apply("aBc", None), "ABc");
    }
    const CAMEL: &str = "camelsLiveInTheDesert";
    const SNAKE: &str = "snakes_live_in_forests";
    const KEBAB: &str = "kebab-is-a-delicious-food";
    const PASCAL: &str = "PascalIsACoolGuy";
    const PATH: &str = "path/is/a/slashed/string";
    const DOT: &str = "www.dot.com";
    const URL: &str = "x.com/hd_nvim";

    fn assert_split(s: &str, v: &[&str]) {
        let actual: Vec<_> = split(s, None).collect();
        assert_eq!(v, actual)
    }

    #[test]
    fn test_split() {
        assert_split(CAMEL, &["camels", "Live", "In", "The", "Desert"]);
        assert_split(SNAKE, &["snakes", "live", "in", "forests"]);
        assert_split(KEBAB, &["kebab", "is", "a", "delicious", "food"]);
        assert_split(PASCAL, &["Pascal", "Is", "A", "Cool", "Guy"]);
        assert_split(PATH, &["path", "is", "a", "slashed", "string"]);
        assert_split(DOT, &["www", "dot", "com"]);
        assert_split(URL, &["x", "com", "hd", "nvim"]);
        assert_split("XMLHttpRequest", &["XML", "Http", "Request"]);
        assert_split("whatHTML", &["what", "HTML"]);
    }

    fn assert_split_sep(s: &str, seps: &[Separator], v: &[&str]) {
        let actual: Vec<_> = split(s, Some(seps)).collect();
        assert_eq!(v, actual)
    }

    #[test]
    fn test_split_by_separator() {
        use Separator::*;
        assert_split_sep("user_accountName", &[Underscore], &["user", "accountName"]);
        assert_split_sep("user_accountName", &[Space], &["user_accountName"]);
        assert_split_sep("user_accountName", &[CaseChange], &["user_account", "Name"]);
    }

    fn assert_format(fmt: StringCase, src: &str, expected: &str) {
        assert_eq!(fmt.apply(src, None), expected)
    }

    #[test]
    fn test_format() {
        assert_format(SnakeCase, CAMEL, "camels_live_in_the_desert");
        assert_format(KebabCase, CAMEL, "camels-live-in-the-desert");
        assert_format(PascalCase, KEBAB, "KebabIsADeliciousFood");
        assert_format(PascalCase, SNAKE, "SnakesLiveInForests");
    }
}