cli-engine 0.3.3

Rust CLI framework for consistent command modules
Documentation
use std::{sync::Arc, thread};

use cli_engine::{
    Envelope, FieldInfo, HumanViewDef, OutputFormat, PaginationMeta, PipelineOpts, SchemaInfo,
    TableColumn, TreeNode, apply_pipeline, filter_fields, global_human_view_registry_snapshot,
    global_schema_registry_snapshot, is_valid_output_format, register_global_human_view,
    register_global_schema_info, render, render_format, render_human_with_view,
};
use serde_json::{Value, json};

#[test]
fn field_projection_exhaustive_known_shapes() {
    let data = json!({
        "id": "p1",
        "name": "portal",
        "owner": {
            "name": "Ada",
            "email": "ada@example.test",
            "team": {"id": "team-1", "name": "Platform"}
        },
        "tags": [
            {"key": "env", "value": "prod", "secret": "no"},
            {"key": "tier", "value": "one", "secret": "no"}
        ],
        "enabled": true
    });

    let cases = [
        ("", data.clone()),
        ("all", data.clone()),
        ("*", data.clone()),
        ("id", json!({"id": "p1"})),
        ("missing", json!({})),
        (" id , name ", json!({"id": "p1", "name": "portal"})),
        ("owner.name", json!({"owner": {"name": "Ada"}})),
        (
            "owner.team.name",
            json!({"owner": {"team": {"name": "Platform"}}}),
        ),
        (
            "tags.key,tags.value",
            json!({"tags": [{"key": "env", "value": "prod"}, {"key": "tier", "value": "one"}]}),
        ),
        ("owner,owner.name", json!({"owner": data["owner"].clone()})),
        (
            "owner.name,owner",
            json!({"owner": {"name": "Ada", "email": "ada@example.test", "team": {"id": "team-1", "name": "Platform"}}}),
        ),
    ];

    for (fields, expected) in cases {
        assert_eq!(filter_fields(&data, fields), expected, "fields={fields:?}");
    }
}

#[test]
fn field_projection_preserves_non_projectable_inputs() {
    let mixed = json!([{"id": 1}, true, {"id": 2}]);
    assert_eq!(filter_fields(&mixed, "id"), mixed);

    for scalar in [Value::Null, json!(true), json!(42), json!("name")] {
        assert_eq!(filter_fields(&scalar, "id"), scalar);
    }
}

#[test]
fn pagination_matrix_covers_offsets_limits_and_errors() {
    for len in 0_usize..=6 {
        for offset in -2_i64..=8 {
            for limit in -1_i64..=8 {
                let mut data = Value::Array((0..len).map(|item| json!({"n": item})).collect());
                let result = apply_pipeline(
                    &mut data,
                    &PipelineOpts {
                        offset,
                        limit,
                        ..PipelineOpts::default()
                    },
                );

                if limit <= 0 && offset <= 0 {
                    assert!(result.expect("no pagination").is_none());
                    assert_eq!(data.as_array().expect("array").len(), len);
                    continue;
                }

                if offset < 0 {
                    assert!(
                        result
                            .expect_err("negative offset with active pagination should fail")
                            .to_string()
                            .contains("offset must be non-negative")
                    );
                    continue;
                }

                let pagination = result
                    .expect("pagination should succeed")
                    .expect("metadata");
                let start = usize::try_from(offset).expect("non-negative").min(len);
                let end = if limit > 0 {
                    start
                        .saturating_add(usize::try_from(limit).expect("positive"))
                        .min(len)
                } else {
                    len
                };
                assert_eq!(
                    pagination,
                    PaginationMeta {
                        total: i64::try_from(len).expect("small len"),
                        offset,
                        limit,
                        count: i64::try_from(end - start).expect("small count"),
                    }
                );
                let actual = data
                    .as_array()
                    .expect("array")
                    .iter()
                    .map(|item| item["n"].as_u64().expect("n") as usize)
                    .collect::<Vec<_>>();
                assert_eq!(actual, (start..end).collect::<Vec<_>>());
            }
        }
    }
}

#[test]
fn pipeline_reports_malformed_filter_and_expr_inputs() {
    let mut object = json!({"id": 1});
    let filter_error = apply_pipeline(
        &mut object,
        &PipelineOpts {
            filter: "id == `1`".to_owned(),
            ..PipelineOpts::default()
        },
    )
    .expect_err("filter on non-list data should fail");
    assert_eq!(
        filter_error.to_string(),
        "filter requires list data; use --expr for single objects"
    );

    let mut list = json!([{"id": 1}]);
    let query_error = apply_pipeline(
        &mut list,
        &PipelineOpts {
            expr: "[?".to_owned(),
            ..PipelineOpts::default()
        },
    )
    .expect_err("invalid JMESPath should fail");
    assert!(query_error.to_string().contains("invalid JMESPath query"));
}

#[test]
fn renderer_format_matrix_has_stable_success_and_error_shapes() {
    assert!(is_valid_output_format("json"));
    assert!(is_valid_output_format("human"));
    assert!(is_valid_output_format("toon"));
    for invalid in ["", "yaml", "JSON", "table"] {
        assert!(!is_valid_output_format(invalid));
    }

    let mut success = Envelope::success(json!({"name": "alpha", "enabled": true}), "things");
    success.metadata = None;
    assert_eq!(
        render(OutputFormat::Json, &success).expect("json"),
        "{\n  \"data\": {\n    \"enabled\": true,\n    \"name\": \"alpha\"\n  }\n}\n"
    );
    assert_eq!(
        render(OutputFormat::Human, &success).expect("human"),
        "enabled: yes\nname: alpha\n"
    );
    assert_eq!(
        render(OutputFormat::Toon, &success).expect("toon"),
        "data:\n  enabled: true\n  name: alpha"
    );

    let mut error = Envelope::error("BAD_REQUEST", "bad request", "things");
    error.metadata = None;
    assert_eq!(
        render_format("json", &error).expect("json error"),
        "{\n  \"error\": {\n    \"code\": \"BAD_REQUEST\",\n    \"message\": \"bad request\",\n    \"system\": \"things\"\n  }\n}\n"
    );
    assert_eq!(
        render_format("human", &error).expect("human error"),
        "Error: bad request\n"
    );
}

#[test]
fn human_view_columns_preserve_shape_for_empty_missing_and_nested_values() {
    let columns = vec![
        TableColumn::new("id", "ID"),
        TableColumn::new("owner.name", "Owner"),
        TableColumn::new("missing", "Missing"),
    ];
    let envelope = Envelope::success(
        json!([
            {"id": "p1", "owner": {"name": "Ada"}},
            {"id": "p2", "owner": {}}
        ]),
        "project:list",
    );

    assert_eq!(
        render_human_with_view(&envelope, Some(&columns)),
        "ID  OWNER  MISSING\n--  -----  -------\np1                \np2                \n\n(2 rows)\n"
    );
}

#[test]
fn global_registries_tolerate_repeated_and_concurrent_registration() {
    let prefix = format!(
        "concurrent:{}:{:?}",
        std::process::id(),
        thread::current().id()
    );
    let prefix = Arc::new(prefix);
    let mut handles = Vec::new();

    for index in 0..16 {
        let prefix = Arc::clone(&prefix);
        handles.push(thread::spawn(move || {
            let id = format!("{prefix}:command:{index}");
            register_global_schema_info(
                id.clone(),
                SchemaInfo::new(id.clone()).with_fields(vec![FieldInfo::new("id", "string")]),
            );
            register_global_human_view(HumanViewDef::new(id, vec![TableColumn::new("id", "ID")]));
        }));
    }

    for handle in handles {
        handle.join().expect("registry thread should not panic");
    }

    let schemas = global_schema_registry_snapshot();
    let views = global_human_view_registry_snapshot();
    for index in 0..16 {
        let id = format!("{prefix}:command:{index}");
        assert_eq!(
            schemas.get_by_path(&id).expect("schema").fields,
            vec![FieldInfo::new("id", "string")]
        );
        assert_eq!(
            views.columns(&id).expect("columns"),
            &[TableColumn::new("id", "ID")]
        );
    }
}

#[test]
fn tree_builder_preserves_explicit_hierarchy() {
    let tree =
        TreeNode::new("my-cli", "Team CLI", "my-cli").with_child(
            TreeNode::new("project", "Manage projects", "my-cli project").with_child(
                TreeNode::new("list", "List projects", "my-cli project list"),
            ),
        );

    assert_eq!(tree.children[0].children[0].path, "my-cli project list");
    assert_eq!(
        cli_engine::render_tree_human(&tree),
        "my-cli\n└── project ··· Manage projects\n    └── list ··· List projects\n"
    );
}