osp-cli 1.5.1

CLI and REPL for querying and managing OSP infrastructure data
Documentation
use anyhow::{Result, anyhow};

use crate::dsl::{
    model::{ParsedPipeline, ParsedStage, ParsedStageKind},
    verbs::{aggregate, filter, group, jq, limit, project, quick, sort, unroll, values},
};

#[derive(Debug, Clone)]
pub(crate) struct CompiledPipeline {
    pub(crate) stages: Vec<CompiledStage>,
}

#[derive(Debug, Clone)]
pub(crate) enum CompiledStage {
    Quick(quick::QuickPlan),
    Filter(filter::FilterPlan),
    Project(project::ProjectPlan),
    Sort(sort::SortPlan),
    Group(group::GroupPlan),
    Aggregate(aggregate::AggregatePlan),
    Limit(limit::LimitSpec),
    Collapse,
    CountMacro,
    Copy,
    Unroll(unroll::UnrollPlan),
    Clean,
    Question(quick::QuickPlan),
    Jq(String),
    Values(values::ValuesPlan),
    ValueQuick(quick::QuickPlan),
    KeyQuick(quick::QuickPlan),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SemanticEffect {
    Preserve,
    Transform,
    Degrade,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct StageBehavior {
    pub(crate) can_stream: bool,
    pub(crate) preserves_render_recommendation: bool,
    pub(crate) semantic_effect: SemanticEffect,
}

impl CompiledPipeline {
    pub(crate) fn from_parsed(parsed: ParsedPipeline) -> Result<Self> {
        let stages = parsed
            .stages
            .iter()
            .filter(|stage| !stage.verb.is_empty())
            .map(CompiledStage::from_parsed)
            .collect::<Result<Vec<_>>>()?;
        Ok(Self { stages })
    }
}

impl CompiledStage {
    pub(crate) fn from_parsed(stage: &ParsedStage) -> Result<Self> {
        match stage.kind {
            ParsedStageKind::Quick => Ok(Self::Quick(quick::compile(&stage.raw)?)),
            ParsedStageKind::UnknownExplicit => Err(anyhow!("unknown DSL verb: {}", stage.verb)),
            ParsedStageKind::Explicit => match stage.verb.as_str() {
                "F" => Ok(Self::Filter(filter::compile(&stage.spec)?)),
                "P" => Ok(Self::Project(project::compile(&stage.spec)?)),
                "S" => Ok(Self::Sort(sort::compile(&stage.spec)?)),
                "G" => Ok(Self::Group(group::compile(&stage.spec)?)),
                "A" => Ok(Self::Aggregate(aggregate::compile(&stage.spec)?)),
                "L" => Ok(Self::Limit(limit::parse_limit_spec(&stage.spec)?)),
                "Z" => Ok(Self::Collapse),
                "C" => {
                    if !stage.spec.trim().is_empty() {
                        return Err(anyhow!("C takes no arguments"));
                    }
                    Ok(Self::CountMacro)
                }
                "Y" => Ok(Self::Copy),
                "U" => Ok(Self::Unroll(unroll::compile(&stage.spec)?)),
                "?" => {
                    let trimmed = stage.spec.trim();
                    if trimmed.is_empty() {
                        Ok(Self::Clean)
                    } else {
                        Ok(Self::Question(quick::compile(&format!("?{trimmed}"))?))
                    }
                }
                "JQ" => Ok(Self::Jq(jq::compile(&stage.spec)?)),
                "VAL" | "VALUE" => Ok(Self::Values(values::compile(&stage.spec)?)),
                "V" => {
                    let raw = if stage.spec.trim().is_empty() {
                        "V".to_string()
                    } else {
                        format!("V {}", stage.spec.trim())
                    };
                    Ok(Self::ValueQuick(quick::compile(&raw)?))
                }
                "K" => {
                    let raw = if stage.spec.trim().is_empty() {
                        "K".to_string()
                    } else {
                        format!("K {}", stage.spec.trim())
                    };
                    Ok(Self::KeyQuick(quick::compile(&raw)?))
                }
                other => Err(anyhow!("unknown DSL verb: {other}")),
            },
        }
    }

    pub(crate) fn behavior(&self) -> StageBehavior {
        match self {
            Self::Quick(_)
            | Self::Filter(_)
            | Self::Copy
            | Self::Clean
            | Self::Question(_)
            | Self::ValueQuick(_)
            | Self::KeyQuick(_) => StageBehavior {
                can_stream: true,
                preserves_render_recommendation: true,
                semantic_effect: SemanticEffect::Preserve,
            },
            Self::Project(_) | Self::Unroll(_) | Self::Values(_) => StageBehavior {
                can_stream: true,
                preserves_render_recommendation: false,
                semantic_effect: SemanticEffect::Transform,
            },
            Self::Limit(spec) => StageBehavior {
                can_stream: spec.is_head_only(),
                preserves_render_recommendation: true,
                semantic_effect: SemanticEffect::Preserve,
            },
            Self::Sort(_) => StageBehavior {
                can_stream: false,
                preserves_render_recommendation: true,
                semantic_effect: SemanticEffect::Preserve,
            },
            Self::Group(_) | Self::Aggregate(_) => StageBehavior {
                can_stream: false,
                preserves_render_recommendation: false,
                semantic_effect: SemanticEffect::Transform,
            },
            Self::Collapse | Self::CountMacro | Self::Jq(_) => StageBehavior {
                can_stream: false,
                preserves_render_recommendation: false,
                semantic_effect: SemanticEffect::Degrade,
            },
        }
    }

    pub(crate) fn quick_plan(&self) -> Option<&quick::QuickPlan> {
        match self {
            Self::Quick(plan)
            | Self::Question(plan)
            | Self::ValueQuick(plan)
            | Self::KeyQuick(plan) => Some(plan),
            _ => None,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::{CompiledStage, SemanticEffect};
    use crate::dsl::verbs::{filter, limit, project};

    #[test]
    fn stage_behavior_centralizes_stream_render_and_semantic_rules_unit() {
        let filter = CompiledStage::Filter(filter::compile("uid=alice").expect("filter plan"));
        let behavior = filter.behavior();
        assert!(behavior.can_stream);
        assert!(behavior.preserves_render_recommendation);
        assert_eq!(behavior.semantic_effect, SemanticEffect::Preserve);

        let project = CompiledStage::Project(project::compile("uid").expect("project plan"));
        let behavior = project.behavior();
        assert!(behavior.can_stream);
        assert!(!behavior.preserves_render_recommendation);
        assert_eq!(behavior.semantic_effect, SemanticEffect::Transform);

        let head = CompiledStage::Limit(limit::parse_limit_spec("2").expect("head limit"));
        let behavior = head.behavior();
        assert!(behavior.can_stream);
        assert!(behavior.preserves_render_recommendation);
        assert_eq!(behavior.semantic_effect, SemanticEffect::Preserve);

        let tail = CompiledStage::Limit(limit::parse_limit_spec("-2").expect("tail limit"));
        let behavior = tail.behavior();
        assert!(!behavior.can_stream);
        assert!(behavior.preserves_render_recommendation);
        assert_eq!(behavior.semantic_effect, SemanticEffect::Preserve);
    }

    #[test]
    fn quick_plan_helper_covers_all_quick_family_variants_unit() {
        let quick = CompiledStage::Quick(crate::dsl::verbs::quick::compile("alice").expect("plan"));
        assert!(quick.quick_plan().is_some());

        let question =
            CompiledStage::Question(crate::dsl::verbs::quick::compile("?uid").expect("plan"));
        assert!(question.quick_plan().is_some());

        let value_quick =
            CompiledStage::ValueQuick(crate::dsl::verbs::quick::compile("V uid").expect("plan"));
        assert!(value_quick.quick_plan().is_some());

        let key_quick =
            CompiledStage::KeyQuick(crate::dsl::verbs::quick::compile("K uid").expect("plan"));
        assert!(key_quick.quick_plan().is_some());

        let filter = CompiledStage::Filter(filter::compile("uid=alice").expect("filter plan"));
        assert!(filter.quick_plan().is_none());
    }
}