Skip to main content

cli_engine/output/
pipeline.rs

1use serde_json::Value;
2
3use crate::{CliCoreError, Result};
4
5use super::{PaginationMeta, filter_fields};
6
7/// Options for the output pipeline.
8#[derive(Clone, Debug, Default, Eq, PartialEq)]
9pub struct PipelineOpts {
10    /// JMESPath predicate applied to each list item.
11    pub filter: String,
12    /// Client-side page size.
13    pub limit: i64,
14    /// Client-side page offset.
15    pub offset: i64,
16    /// JMESPath expression applied to the whole result.
17    pub expr: String,
18    /// Comma-separated field projection.
19    pub fields: String,
20}
21
22/// Applies filter, pagination, expression, and field projection in framework order.
23pub fn apply_pipeline(data: &mut Value, opts: &PipelineOpts) -> Result<Option<PaginationMeta>> {
24    if !opts.filter.is_empty() {
25        apply_filter(data, &opts.filter)?;
26    }
27    let pagination = if opts.limit > 0 || opts.offset > 0 {
28        apply_pagination(data, opts.offset, opts.limit)?
29    } else {
30        None
31    };
32    if !opts.expr.is_empty() {
33        apply_expr(data, &opts.expr)?;
34    }
35    if !opts.fields.is_empty() {
36        *data = filter_fields(data, &opts.fields);
37    }
38    Ok(pagination)
39}
40
41fn apply_pagination(data: &mut Value, offset: i64, limit: i64) -> Result<Option<PaginationMeta>> {
42    let Value::Array(items) = data else {
43        return Ok(None);
44    };
45    let total = items.len();
46    let total_i64 = match i64::try_from(total) {
47        Ok(total) => total,
48        Err(_) => {
49            return Err(CliCoreError::message(
50                "pagination: list length exceeds supported range",
51            ));
52        }
53    };
54    let start = offset.min(total_i64);
55    let start = match usize::try_from(start) {
56        Ok(start) => start,
57        Err(_) => {
58            return Err(CliCoreError::message(
59                "pagination: offset must be non-negative",
60            ));
61        }
62    };
63    let mut end = total;
64    if limit > 0 {
65        let limit = match usize::try_from(limit) {
66            Ok(limit) => limit,
67            Err(_) => {
68                return Err(CliCoreError::message(
69                    "pagination: limit exceeds supported range",
70                ));
71            }
72        };
73        if start + limit < end {
74            end = start + limit;
75        }
76    }
77    let sliced = items[start..end].to_vec();
78    *items = sliced;
79    Ok(Some(PaginationMeta {
80        total: total_i64,
81        offset,
82        limit,
83        count: match i64::try_from(end - start) {
84            Ok(count) => count,
85            Err(_) => {
86                return Err(CliCoreError::message(
87                    "pagination: count exceeds supported range",
88                ));
89            }
90        },
91        has_more: end < total,
92    }))
93}
94
95fn apply_filter(data: &mut Value, expression: &str) -> Result<()> {
96    let Value::Array(items) = data else {
97        return Err(CliCoreError::message(
98            "filter requires list data; use --expr for single objects",
99        ));
100    };
101
102    let expression = compile_query(expression)?;
103    let mut retained = Vec::with_capacity(items.len());
104    for item in items.drain(..) {
105        if search_query(&expression, &item)?.is_truthy() {
106            retained.push(item);
107        }
108    }
109    *items = retained;
110    Ok(())
111}
112
113fn apply_expr(data: &mut Value, expression: &str) -> Result<()> {
114    let expression = compile_query(expression)?;
115    let result = search_query(&expression, data)?;
116    *data = serde_json::to_value(result.as_ref())
117        .map_err(|error| CliCoreError::message(format!("expr: invalid result: {error}")))?;
118    Ok(())
119}
120
121fn compile_query(expression: &str) -> Result<jmespath::Expression<'static>> {
122    jmespath::compile(expression.trim())
123        .map_err(|error| CliCoreError::message(format!("expr: invalid JMESPath query: {error}")))
124}
125
126fn search_query(expression: &jmespath::Expression<'_>, data: &Value) -> Result<jmespath::Rcvar> {
127    expression
128        .search(data)
129        .map_err(|error| CliCoreError::message(format!("expr: JMESPath query failed: {error}")))
130}
131
132#[cfg(test)]
133mod tests {
134    use serde_json::json;
135
136    use super::{apply_expr, apply_pagination, compile_query, search_query};
137
138    #[test]
139    fn private_pipeline_helpers_cover_boundary_paths_directly() {
140        let mut object = json!({"id": "p1"});
141        assert_eq!(
142            apply_pagination(&mut object, 10, 1).expect("object pagination should no-op"),
143            None
144        );
145        assert_eq!(object, json!({"id": "p1"}));
146
147        let mut items = json!([{"id": "p1"}, {"id": "p2"}]);
148        let err =
149            apply_pagination(&mut items, -1, 1).expect_err("negative offset should be rejected");
150        assert_eq!(err.to_string(), "pagination: offset must be non-negative");
151
152        let expression = compile_query("items[?enabled].id").expect("query should compile");
153        let result = search_query(
154            &expression,
155            &json!({"items": [{"id": "p1", "enabled": true}, {"id": "p2", "enabled": false}]}),
156        )
157        .expect("query should evaluate");
158        assert_eq!(
159            serde_json::to_value(result.as_ref()).expect("result should serialize"),
160            json!(["p1"])
161        );
162
163        let mut data = json!({"items": [{"id": "p1"}]});
164        apply_expr(&mut data, "items[0].id").expect("expr should replace data");
165        assert_eq!(data, json!("p1"));
166    }
167}