osp-cli 1.5.1

CLI and REPL for querying and managing OSP infrastructure data
Documentation
use crate::core::{
    output_model::{Group, OutputItems},
    row::Row,
};
use anyhow::Result;
use serde_json::Value;

use super::quick;

#[cfg(test)]
/// Applies the `?` stage to flat or grouped output.
///
/// With an empty spec, empty values are removed from rows. Otherwise the stage
/// reuses DSL quick-search semantics with `?`-prefixed matching.
pub fn apply(items: OutputItems, spec: &str) -> Result<OutputItems> {
    let trimmed = spec.trim();
    if trimmed.is_empty() {
        return Ok(clean_items(items));
    }

    let raw = format!("?{trimmed}");
    let plan = quick::compile(&raw)?;
    let out = match items {
        OutputItems::Rows(rows) => OutputItems::Rows(quick::apply_with_plan(rows, &plan)?),
        OutputItems::Groups(groups) => {
            OutputItems::Groups(quick::apply_groups_with_plan(groups, &plan)?)
        }
    };
    Ok(out)
}

pub(crate) fn clean_items(items: OutputItems) -> OutputItems {
    match items {
        OutputItems::Rows(rows) => OutputItems::Rows(clean_rows(rows)),
        OutputItems::Groups(groups) => OutputItems::Groups(
            groups
                .into_iter()
                .map(|group| Group {
                    groups: group.groups,
                    aggregates: group.aggregates,
                    rows: clean_rows(group.rows),
                })
                .collect(),
        ),
    }
}

fn clean_rows(rows: Vec<Row>) -> Vec<Row> {
    rows.into_iter().filter_map(clean_row).collect()
}

pub(crate) fn clean_row(row: Row) -> Option<Row> {
    let cleaned = row
        .into_iter()
        .filter(|(_, value)| !is_empty_value(value))
        .collect::<Row>();
    if cleaned.is_empty() {
        None
    } else {
        Some(cleaned)
    }
}

fn is_empty_value(value: &Value) -> bool {
    match value {
        Value::Null => true,
        Value::String(text) => text.is_empty(),
        Value::Array(items) => items.is_empty(),
        _ => false,
    }
}

pub(crate) fn apply_value(value: Value, spec: &str) -> Result<Value> {
    let trimmed = spec.trim();
    if trimmed.is_empty() {
        return Ok(super::json::clean_value(value).unwrap_or(Value::Null));
    }
    quick::apply_value(value, &format!("?{trimmed}"))
}

#[cfg(test)]
mod tests {
    use crate::core::output_model::{Group, OutputItems};
    use serde_json::json;

    use super::apply;

    fn row(value: serde_json::Value) -> crate::core::row::Row {
        value
            .as_object()
            .cloned()
            .expect("fixture should be an object")
    }

    #[test]
    fn empty_spec_cleans_rows_and_drops_empty_results() {
        let items = OutputItems::Rows(vec![
            row(json!({"uid": "oistes", "mail": "", "tags": [], "note": null})),
            row(json!({"mail": "", "tags": [], "note": null})),
        ]);

        let cleaned = apply(items, "   ").expect("cleaning should succeed");
        let OutputItems::Rows(rows) = cleaned else {
            panic!("expected row output");
        };

        assert_eq!(rows.len(), 1);
        assert_eq!(rows[0].len(), 1);
        assert_eq!(
            rows[0].get("uid").and_then(|value| value.as_str()),
            Some("oistes")
        );
    }

    #[test]
    fn empty_spec_cleans_group_rows_without_touching_group_metadata() {
        let items = OutputItems::Groups(vec![Group {
            groups: row(json!({"team": "ops"})),
            aggregates: row(json!({"count": 2})),
            rows: vec![
                row(json!({"uid": "oistes", "mail": ""})),
                row(json!({"mail": "", "tags": []})),
            ],
        }]);

        let cleaned = apply(items, "").expect("group cleaning should succeed");
        let OutputItems::Groups(groups) = cleaned else {
            panic!("expected grouped output");
        };

        assert_eq!(groups.len(), 1);
        assert_eq!(
            groups[0]
                .groups
                .get("team")
                .and_then(|value| value.as_str()),
            Some("ops")
        );
        assert_eq!(
            groups[0]
                .aggregates
                .get("count")
                .and_then(|value| value.as_i64()),
            Some(2)
        );
        assert_eq!(groups[0].rows.len(), 1);
        assert_eq!(groups[0].rows[0].len(), 1);
        assert_eq!(
            groups[0].rows[0]
                .get("uid")
                .and_then(|value| value.as_str()),
            Some("oistes")
        );
    }

    #[test]
    fn non_empty_spec_reuses_quick_filter_for_rows_and_groups() {
        let rows = OutputItems::Rows(vec![
            row(json!({"uid": "oistes"})),
            row(json!({"mail": "other@example.org"})),
        ]);
        let filtered = apply(rows, "uid").expect("row filter should succeed");
        let OutputItems::Rows(rows) = filtered else {
            panic!("expected row output");
        };
        assert_eq!(rows.len(), 1);
        assert_eq!(
            rows[0].get("uid").and_then(|value| value.as_str()),
            Some("oistes")
        );

        let groups = OutputItems::Groups(vec![Group {
            groups: row(json!({"team": "ops"})),
            aggregates: row(json!({"count": 2})),
            rows: vec![
                row(json!({"uid": "oistes"})),
                row(json!({"mail": "other@example.org"})),
            ],
        }]);
        let filtered = apply(groups, "uid").expect("group filter should succeed");
        let OutputItems::Groups(groups) = filtered else {
            panic!("expected grouped output");
        };
        assert_eq!(groups[0].rows.len(), 1);
        assert_eq!(
            groups[0].rows[0]
                .get("uid")
                .and_then(|value| value.as_str()),
            Some("oistes")
        );
    }
}