moonlight-core 0.1.2

Shared comparison, diffing, classification, and JSONL storage primitives for Moonlight.
Documentation
use serde_json::Value;

const REDACTED: &str = "[redacted]";

#[derive(Debug, Clone, PartialEq, Eq)]
enum Segment {
    Root,
    Key(String),
    Index(usize),
    AnyKey,
    AnyIndex,
}

pub(super) fn matches_path(path: &str, pattern: &str) -> bool {
    let Some(path_segments) = parse(path, false) else {
        return false;
    };
    let Some(pattern_segments) = parse(pattern, true) else {
        return false;
    };
    if path_segments.len() != pattern_segments.len() {
        return false;
    }

    path_segments
        .iter()
        .zip(pattern_segments.iter())
        .all(|(path, pattern)| match pattern {
            Segment::AnyKey => matches!(path, Segment::Key(_)),
            Segment::AnyIndex => matches!(path, Segment::Index(_)),
            _ => path == pattern,
        })
}

pub(super) fn matches_any_path(path: &str, patterns: &[String]) -> bool {
    patterns.iter().any(|pattern| matches_path(path, pattern))
}

pub(super) fn redact_value_at_path(value: &mut Value, path: &str) -> bool {
    let Some(segments) = parse(path, false) else {
        return false;
    };
    redact_matches(value, &segments[1..])
}

pub(super) fn redact_value_at_matching_paths(value: &mut Value, pattern: &str) -> bool {
    let Some(pattern) = parse(pattern, true) else {
        return false;
    };
    redact_matches(value, &pattern[1..])
}

fn redact_matches(value: &mut Value, segments: &[Segment]) -> bool {
    let Some((segment, remaining)) = segments.split_first() else {
        *value = Value::String(REDACTED.to_string());
        return true;
    };

    match segment {
        Segment::Key(key) => value
            .get_mut(key)
            .is_some_and(|next| redact_matches(next, remaining)),
        Segment::Index(index) => value
            .get_mut(*index)
            .is_some_and(|next| redact_matches(next, remaining)),
        Segment::AnyKey => {
            let Some(map) = value.as_object_mut() else {
                return false;
            };
            let mut changed = false;
            for next in map.values_mut() {
                changed = redact_matches(next, remaining) || changed;
            }
            changed
        }
        Segment::AnyIndex => {
            let Some(items) = value.as_array_mut() else {
                return false;
            };
            let mut changed = false;
            for next in items {
                changed = redact_matches(next, remaining) || changed;
            }
            changed
        }
        Segment::Root => false,
    }
}

fn parse(path: &str, allow_wildcards: bool) -> Option<Vec<Segment>> {
    let mut chars = path.chars().peekable();
    if chars.next()? != '$' {
        return None;
    }

    let mut segments = vec![Segment::Root];
    while let Some(next) = chars.peek().copied() {
        match next {
            '.' => {
                chars.next();
                let mut key = String::new();
                while let Some(ch) = chars.peek().copied() {
                    if ch == '.' || ch == '[' {
                        break;
                    }
                    key.push(ch);
                    chars.next();
                }
                if key.is_empty() {
                    return None;
                }
                if allow_wildcards && key == "*" {
                    segments.push(Segment::AnyKey);
                } else {
                    segments.push(Segment::Key(key));
                }
            }
            '[' => {
                chars.next();
                let mut index = String::new();
                while let Some(ch) = chars.peek().copied() {
                    if ch == ']' {
                        break;
                    }
                    index.push(ch);
                    chars.next();
                }
                if chars.next() != Some(']') {
                    return None;
                }
                if allow_wildcards && index == "*" {
                    segments.push(Segment::AnyIndex);
                } else {
                    segments.push(Segment::Index(index.parse().ok()?));
                }
            }
            _ => return None,
        }
    }

    Some(segments)
}