use serde_yaml::Value;
use crate::query::wire::RawProjection;
use crate::query::{
build_projection, FieldPath, Projection, ProjectionField, ProjectionMode, ProjectionSource,
PseudoField,
};
pub fn parse_projection(s: &str, mode: ProjectionMode) -> Result<Projection, String> {
let trimmed = s.trim();
if trimmed.is_empty() {
return Err("projection argument cannot be empty".to_string());
}
if let Ok(value) = serde_yaml::from_str::<Value>(trimmed) {
if let Value::Mapping(map) = value {
let raw = RawProjection(map);
return build_projection(raw, mode).map_err(|e| format!("invalid projection: {}", e));
}
}
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, mode })
}
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 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 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 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, ProjectionMode::Replace)
}
#[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_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)
));
}
#[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 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_colon_form() {
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");
assert!(matches!(
p.fields[1].source,
ProjectionSource::Pseudo(PseudoField::Key)
));
}
#[test]
fn output_name_with_whitespace_rejected() {
let err = parse_projection_replace("bad name=$key").unwrap_err();
assert!(
err.contains("whitespace"),
"error should mention whitespace: {}",
err
);
}
#[test]
fn unknown_pseudo_rejected() {
assert!(parse_projection_replace("$bogus").is_err());
assert!(parse_projection_replace("x=$bogus").is_err());
}
#[test]
fn reserved_output_rejected() {
assert!(parse_projection_replace("$key=$key").is_err());
}
}