osp-cli 1.5.1

CLI and REPL for querying and managing OSP infrastructure data
Documentation
use serde_json::json;

use crate::{
    core::{
        output_model::{Group, OutputItems, OutputResult},
        row::Row,
    },
    dsl::{apply_output_pipeline, apply_pipeline},
};

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

fn grouped_output() -> OutputResult {
    OutputResult {
        items: OutputItems::Groups(vec![Group {
            groups: row(json!({"team": "ops"})),
            aggregates: row(json!({"count": 2})),
            rows: vec![
                row(json!({"uid": "alice", "roles": ["eng", "ops"], "city": "Ålesund"})),
                row(json!({"uid": "bob", "roles": ["sales"], "city": "Oslo"})),
            ],
        }]),
        document: None,
        meta: Default::default(),
    }
}

#[test]
fn quoted_term_contract_is_shared_by_project_and_value_stages() {
    let rows = vec![row(
        json!({"display,name": "Alice", "team ops": "platform"}),
    )];

    let projected = apply_pipeline(rows.clone(), &["P \"display,name\"".to_string()])
        .expect("quoted project term should work");
    let OutputItems::Rows(projected_rows) = projected.items else {
        panic!("expected row output");
    };
    assert_eq!(projected_rows, vec![row(json!({"display,name": "Alice"}))]);

    let values = apply_pipeline(rows, &["VALUE \"display,name\"".to_string()])
        .expect("quoted value term should work");
    let OutputItems::Rows(value_rows) = values.items else {
        panic!("expected row output");
    };
    assert_eq!(value_rows, vec![row(json!({"value": "Alice"}))]);
}

#[test]
fn nested_path_contract_is_shared_by_filter_project_and_value_stages() {
    let rows = vec![row(json!({
        "metadata": {"owner": "Åse"},
        "members": [{"uid": "alice"}, {"uid": "bob"}]
    }))];

    let filtered = apply_pipeline(rows.clone(), &["F metadata.owner=åse".to_string()])
        .expect("nested filter path should work");
    let OutputItems::Rows(filtered_rows) = filtered.items else {
        panic!("expected row output");
    };
    assert_eq!(filtered_rows.len(), 1);

    let projected = apply_pipeline(rows.clone(), &["P metadata.owner".to_string()])
        .expect("nested project path should work");
    let OutputItems::Rows(projected_rows) = projected.items else {
        panic!("expected row output");
    };
    assert_eq!(
        projected_rows,
        vec![row(json!({"metadata": {"owner": "Åse"}}))]
    );

    let owner_values = apply_pipeline(rows.clone(), &["VALUE metadata.owner".to_string()])
        .expect("nested value path should work");
    let OutputItems::Rows(owner_rows) = owner_values.items else {
        panic!("expected row output");
    };
    assert_eq!(owner_rows, vec![row(json!({"value": "Åse"}))]);

    let member_values = apply_pipeline(rows, &["VALUE members[].uid".to_string()])
        .expect("selector value path should work");
    let OutputItems::Rows(member_rows) = member_values.items else {
        panic!("expected row output");
    };
    assert_eq!(
        member_rows,
        vec![row(json!({"value": "alice"})), row(json!({"value": "bob"}))]
    );
}

#[test]
fn grouped_row_stage_contract_preserves_metadata_and_transforms_rows() {
    fn assert_group_metadata(output: &OutputResult, expected_rows: usize) {
        let OutputItems::Groups(groups) = &output.items 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(), expected_rows);
    }

    let projected = apply_output_pipeline(grouped_output(), &["P uid".to_string()])
        .expect("grouped project should work");
    assert_group_metadata(&projected, 2);

    let filtered = apply_output_pipeline(grouped_output(), &["F uid=alice".to_string()])
        .expect("grouped filter should work");
    assert_group_metadata(&filtered, 1);

    let quick = apply_output_pipeline(grouped_output(), &["ops".to_string()])
        .expect("grouped quick should work");
    assert_group_metadata(&quick, 1);

    let value_only = apply_output_pipeline(grouped_output(), &["V åle".to_string()])
        .expect("grouped value quick should work");
    assert_group_metadata(&value_only, 1);

    let key_only = apply_output_pipeline(grouped_output(), &["K uid".to_string()])
        .expect("grouped key quick should work");
    assert_group_metadata(&key_only, 2);

    let values = apply_output_pipeline(grouped_output(), &["VALUE uid".to_string()])
        .expect("grouped values should work");
    assert_group_metadata(&values, 2);

    let unrolled = apply_output_pipeline(grouped_output(), &["U roles".to_string()])
        .expect("grouped unroll should work");
    assert_group_metadata(&unrolled, 3);

    let cleaned = apply_output_pipeline(grouped_output(), &["? uid".to_string()])
        .expect("grouped clean should work");
    assert_group_metadata(&cleaned, 2);
}

#[test]
fn unsupported_group_only_stage_fails_loudly() {
    let rows = vec![row(json!({"uid": "alice"}))];
    let err = apply_pipeline(rows, &["Z".to_string()]).expect_err("flat collapse should fail");
    assert!(err.to_string().contains("Z requires grouped output"));
}

#[test]
fn unicode_case_insensitive_matching_is_shared_by_quick_and_filter() {
    let rows = vec![
        row(json!({"city": "Ålesund", "Grønn": true})),
        row(json!({"city": "Oslo", "status": true})),
    ];

    let value_quick = apply_pipeline(rows.clone(), &["V åle".to_string()])
        .expect("unicode quick value match should work");
    let OutputItems::Rows(value_rows) = value_quick.items else {
        panic!("expected row output");
    };
    assert_eq!(value_rows.len(), 1);

    let key_quick = apply_pipeline(rows.clone(), &["K grønn".to_string()])
        .expect("unicode quick key match should work");
    let OutputItems::Rows(key_rows) = key_quick.items else {
        panic!("expected row output");
    };
    assert_eq!(key_rows.len(), 1);

    let filtered = apply_pipeline(rows, &["F city=ålesund".to_string()])
        .expect("unicode filter match should work");
    let OutputItems::Rows(filtered_rows) = filtered.items else {
        panic!("expected row output");
    };
    assert_eq!(filtered_rows.len(), 1);
}

#[test]
fn quick_contract_preserves_matching_parent_objects_in_object_arrays() {
    let rows = vec![row(json!({
        "commands": [
            {
                "name": "alpha",
                "short_help": "shared help text"
            },
            {
                "name": "beta",
                "short_help": "shared help text"
            }
        ]
    }))];

    let output = apply_pipeline(rows, &["shared".to_string()])
        .expect("quick should preserve full matching command entries");
    let OutputItems::Rows(result_rows) = output.items else {
        panic!("expected row output");
    };
    assert_eq!(
        result_rows,
        vec![row(json!({
            "commands": [
                {
                    "name": "alpha",
                    "short_help": "shared help text"
                },
                {
                    "name": "beta",
                    "short_help": "shared help text"
                }
            ]
        }))]
    );
}