liwe 0.13.0

IWE core library
Documentation
use serde_yaml::Value;

use crate::query::block::BlockPredicate;
use crate::query::wire::RawProjection;
use crate::query::{
    build_projection, FieldPath, Projection, ProjectionBase, ProjectionField, ProjectionSource,
    PseudoField,
};

pub fn parse_projection(s: &str, base: ProjectionBase) -> Result<Projection, String> {
    let trimmed = s.trim();
    if trimmed.is_empty() {
        return Err("projection argument cannot be empty".to_string());
    }

    if trimmed.contains(':') || trimmed.contains('{') || trimmed.contains('}') {
        return parse_mapping(trimmed, base);
    }

    let mut fields: Vec<ProjectionField> = Vec::new();
    for item in trimmed.split(',') {
        let item = item.trim();
        if item.is_empty() {
            continue;
        }
        fields.push(parse_item(item)?);
    }
    if fields.is_empty() {
        return Err("projection argument cannot be empty".to_string());
    }
    Ok(Projection { fields, base })
}

fn parse_mapping(trimmed: &str, base: ProjectionBase) -> Result<Projection, String> {
    let value: Value = serde_yaml::from_str(trimmed).map_err(|_| invalid_mapping(trimmed))?;
    let Value::Mapping(map) = value else {
        return Err(invalid_mapping(trimmed));
    };
    build_projection(RawProjection(map), base).map_err(|e| format!("invalid projection: {}", e))
}

fn invalid_mapping(input: &str) -> String {
    format!(
        "projection '{}' is not a valid YAML mapping; to rename a field or select blocks, \
         wrap the whole projection in braces, e.g. '{{ body: $content }}'",
        input
    )
}

fn parse_item(item: &str) -> Result<ProjectionField, String> {
    if let Some((name, src)) = item.split_once('=') {
        let name = name.trim();
        let src = src.trim();
        check_output_name(name)?;
        let source = parse_source(src)?;
        return Ok(ProjectionField {
            output: name.to_string(),
            source,
        });
    }

    if item == "$blocks" {
        return Ok(ProjectionField {
            output: "blocks".to_string(),
            source: ProjectionSource::Blocks(BlockPredicate::empty()),
        });
    }

    if let Some(stripped) = item.strip_prefix('$') {
        let selector = format!("${}", stripped);
        let pf = PseudoField::from_selector(&selector)
            .ok_or_else(|| format!("unknown projection source '{}'", selector))?;
        return Ok(ProjectionField {
            output: pf.default_output_name().to_string(),
            source: ProjectionSource::Pseudo(pf),
        });
    }

    check_output_name(item)?;
    Ok(ProjectionField {
        output: item.to_string(),
        source: ProjectionSource::Frontmatter(FieldPath(vec![item.to_string()])),
    })
}

fn parse_source(src: &str) -> Result<ProjectionSource, String> {
    if src == "$blocks" {
        return Ok(ProjectionSource::Blocks(BlockPredicate::empty()));
    }
    if let Some(stripped) = src.strip_prefix('$') {
        let selector = format!("${}", stripped);
        let pf = PseudoField::from_selector(&selector)
            .ok_or_else(|| format!("unknown projection source '{}'", selector))?;
        Ok(ProjectionSource::Pseudo(pf))
    } else if !src.is_empty() {
        let segments: Vec<String> = src.split('.').map(|s| s.to_string()).collect();
        Ok(ProjectionSource::Frontmatter(FieldPath(segments)))
    } else {
        Err("projection source cannot be empty".to_string())
    }
}

fn check_output_name(name: &str) -> Result<(), String> {
    if name.is_empty() {
        return Err("projection output name cannot be empty".to_string());
    }
    if name.starts_with('$') {
        return Err(format!(
            "projection output name '{}' must not start with '$'",
            name
        ));
    }
    if name.contains('.') {
        let leaf = name.rsplit('.').next().unwrap_or(name);
        return Err(format!(
            "projection output name '{}' must not contain '.'\n  hint: use '{}={}' to project a nested field",
            name, leaf, name
        ));
    }
    for bad in [':', '=', ',', '{', '}'] {
        if name.contains(bad) {
            return Err(format!(
                "projection output name '{}' must not contain '{}'",
                name, bad
            ));
        }
    }
    if name.chars().any(|c| c.is_whitespace()) {
        return Err(format!(
            "projection output name '{}' must not contain whitespace",
            name
        ));
    }
    if matches!(name.chars().next(), Some('_' | '#' | '@')) {
        return Err(format!(
            "projection output name '{}' starts with reserved character",
            name
        ));
    }
    Ok(())
}

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

    fn parse_projection_replace(s: &str) -> Result<Projection, String> {
        parse_projection(s, ProjectionBase::Empty)
    }

    #[test]
    fn comma_list_simple_fields() {
        let p = parse_projection_replace("title,author").unwrap();
        assert_eq!(p.fields.len(), 2);
        assert_eq!(p.fields[0].output, "title");
        match &p.fields[0].source {
            ProjectionSource::Frontmatter(fp) => assert_eq!(fp.0, vec!["title".to_string()]),
            _ => panic!("expected frontmatter"),
        }
    }

    #[test]
    fn comma_list_bare_pseudos() {
        let p = parse_projection_replace("$content,$includedBy").unwrap();
        assert_eq!(p.fields.len(), 2);
        assert_eq!(p.fields[0].output, "content");
        assert!(matches!(
            p.fields[0].source,
            ProjectionSource::Pseudo(PseudoField::Content)
        ));
        assert_eq!(p.fields[1].output, "includedBy");
        assert!(matches!(
            p.fields[1].source,
            ProjectionSource::Pseudo(PseudoField::IncludedBy)
        ));
    }

    #[test]
    fn comma_list_bare_blocks() {
        let p = parse_projection_replace("$blocks").unwrap();
        assert_eq!(p.fields[0].output, "blocks");
        assert!(matches!(p.fields[0].source, ProjectionSource::Blocks(_)));
    }

    #[test]
    fn bare_pseudo_uses_default_name() {
        let p = parse_projection_replace("$content").unwrap();
        assert_eq!(p.fields[0].output, "content");
        assert!(matches!(
            p.fields[0].source,
            ProjectionSource::Pseudo(PseudoField::Content)
        ));
    }

    #[test]
    fn yaml_mapping_form() {
        let p = parse_projection_replace("body: $content").unwrap();
        assert_eq!(p.fields[0].output, "body");
        assert!(matches!(
            p.fields[0].source,
            ProjectionSource::Pseudo(PseudoField::Content)
        ));
    }

    #[test]
    fn yaml_flow_form() {
        let p = parse_projection_replace("{body: $content, parents: $includedBy}").unwrap();
        assert_eq!(p.fields.len(), 2);
    }

    #[test]
    fn braced_multi_pair_renames() {
        let p = parse_projection_replace("{ test: $key, test2: $key }").unwrap();
        assert_eq!(p.fields.len(), 2);
        assert_eq!(p.fields[0].output, "test");
        assert!(matches!(
            p.fields[0].source,
            ProjectionSource::Pseudo(PseudoField::Key)
        ));
        assert_eq!(p.fields[1].output, "test2");
    }

    #[test]
    fn mapping_dotted_path_source() {
        let p = parse_projection_replace("{ priority: meta.priority }").unwrap();
        match &p.fields[0].source {
            ProjectionSource::Frontmatter(fp) => {
                assert_eq!(fp.0, vec!["meta".to_string(), "priority".to_string()]);
            }
            _ => panic!("expected frontmatter"),
        }
    }

    #[test]
    fn comma_list_aliased_pseudo() {
        let p = parse_projection_replace("body=$content,parents=$includedBy").unwrap();
        assert_eq!(p.fields.len(), 2);
        assert_eq!(p.fields[0].output, "body");
        assert!(matches!(
            p.fields[0].source,
            ProjectionSource::Pseudo(PseudoField::Content)
        ));
        assert_eq!(p.fields[1].output, "parents");
        assert!(matches!(
            p.fields[1].source,
            ProjectionSource::Pseudo(PseudoField::IncludedBy)
        ));
    }

    #[test]
    fn comma_list_dotted_path_source() {
        let p = parse_projection_replace("priority=meta.priority").unwrap();
        match &p.fields[0].source {
            ProjectionSource::Frontmatter(fp) => {
                assert_eq!(fp.0, vec!["meta".to_string(), "priority".to_string()]);
            }
            _ => panic!("expected frontmatter"),
        }
    }

    #[test]
    fn unbraced_multi_pair_is_invalid_mapping() {
        let err = parse_projection_replace("test: $key, test2: $key").unwrap_err();
        assert_eq!(
            err,
            "projection 'test: $key, test2: $key' is not a valid YAML mapping; to rename a field \
             or select blocks, wrap the whole projection in braces, e.g. '{ body: $content }'"
        );
    }

    #[test]
    fn multi_field_mapping_without_braces_errors() {
        let err =
            parse_projection_replace("goals: { $content: x }, usage: { $content: y }").unwrap_err();
        assert_eq!(
            err,
            "projection 'goals: { $content: x }, usage: { $content: y }' is not a valid YAML \
             mapping; to rename a field or select blocks, wrap the whole projection in braces, \
             e.g. '{ body: $content }'"
        );
    }

    #[test]
    fn nested_colon_source_without_braces_errors() {
        let err = parse_projection_replace("a: b: c").unwrap_err();
        assert_eq!(
            err,
            "projection 'a: b: c' is not a valid YAML mapping; to rename a field or select \
             blocks, wrap the whole projection in braces, e.g. '{ body: $content }'"
        );
    }

    #[test]
    fn output_name_with_whitespace_rejected() {
        let err = parse_projection_replace("bad name=$key").unwrap_err();
        assert_eq!(
            err,
            "projection output name 'bad name' must not contain whitespace"
        );
    }

    #[test]
    fn unknown_pseudo_rejected() {
        let bare = parse_projection_replace("$bogus").unwrap_err();
        assert_eq!(bare, "unknown projection source '$bogus'");
        let aliased = parse_projection_replace("x=$bogus").unwrap_err();
        assert_eq!(aliased, "unknown projection source '$bogus'");
    }

    #[test]
    fn reserved_output_rejected() {
        let err = parse_projection_replace("$key=$key").unwrap_err();
        assert_eq!(err, "projection output name '$key' must not start with '$'");
    }
}