Skip to main content

cli_engine/output/
pipeline.rs

1use std::collections::BTreeSet;
2
3use serde_json::Value;
4
5use crate::{CliCoreError, Result};
6
7use super::{FieldTree, PaginationMeta, parse_fields, project_fields};
8
9/// Options for the output pipeline.
10#[derive(Clone, Debug, Default, Eq, PartialEq)]
11pub struct PipelineOpts {
12    /// JMESPath predicate applied to each list item.
13    pub filter: String,
14    /// Client-side page size.
15    pub limit: i64,
16    /// Client-side page offset.
17    pub offset: i64,
18    /// JMESPath expression applied to the whole result.
19    pub expr: String,
20    /// Comma-separated field projection.
21    pub fields: String,
22    /// Whether `fields` came from a command's `default_fields` fallback
23    /// rather than an explicit `--fields` flag. Default-field selections are
24    /// author-controlled, not user input, so they're projected but not
25    /// validated — see [`apply_pipeline`]'s note on why.
26    pub fields_are_default: bool,
27}
28
29/// Applies filter, pagination, expression, and field projection in framework order.
30///
31/// Field validation (rejecting names absent from the response data) only
32/// runs for an explicit `--fields` flag, not for a command's
33/// `default_fields` fallback (`opts.fields_are_default`): default fields are
34/// author-controlled and applied to every invocation of a command, so a
35/// legitimate optional field that happens to be absent from every row of one
36/// particular response (rather than genuinely misspelled) would otherwise
37/// hard-error that command for everyone until the author noticed.
38pub fn apply_pipeline(data: &mut Value, opts: &PipelineOpts) -> Result<Option<PaginationMeta>> {
39    if !opts.filter.is_empty() {
40        apply_filter(data, &opts.filter)?;
41    }
42    let pagination = if opts.limit > 0 || opts.offset > 0 {
43        apply_pagination(data, opts.offset, opts.limit)?
44    } else {
45        None
46    };
47    if !opts.expr.is_empty() {
48        apply_expr(data, &opts.expr)?;
49    }
50    let fields = opts.fields.trim();
51    if !fields.is_empty() && fields != "all" && fields != "*" {
52        // Parsed once and reused for both validation and projection, instead
53        // of paying for `parse_fields` (and a data traversal) twice.
54        let tree = parse_fields(fields);
55        if !opts.fields_are_default {
56            validate_fields(data, &tree)?;
57        }
58        *data = project_fields(data, &tree);
59    }
60    Ok(pagination)
61}
62
63/// Rejects `--fields` names absent from the response data, instead of
64/// silently projecting them into empty rows. Skipped when the data's shape
65/// gives no signal about which fields exist — an empty list, a list of
66/// non-object items, or a scalar — since [`project_fields`] passes those
67/// through untouched too.
68fn validate_fields(data: &Value, requested: &FieldTree) -> Result<()> {
69    let Some(known) = known_top_level_keys(data) else {
70        return Ok(());
71    };
72    let unknown: Vec<&str> = requested
73        .top_level_names()
74        .filter(|name| !known.contains(*name))
75        .collect();
76    if unknown.is_empty() {
77        return Ok(());
78    }
79    Err(CliCoreError::message(unknown_fields_message(
80        &unknown, &known,
81    )))
82}
83
84fn known_top_level_keys(data: &Value) -> Option<BTreeSet<String>> {
85    match data {
86        Value::Object(map) => Some(map.keys().cloned().collect()),
87        Value::Array(items) => {
88            let mut keys = BTreeSet::new();
89            let mut saw_object = false;
90            for item in items {
91                match item {
92                    Value::Object(map) => {
93                        saw_object = true;
94                        keys.extend(map.keys().cloned());
95                    }
96                    Value::Null => {}
97                    _ => return None,
98                }
99            }
100            saw_object.then_some(keys)
101        }
102        _ => None,
103    }
104}
105
106/// Formats an "unknown field" error: a quoted list of the bad names, a
107/// nearest-match suggestion for the first one, and the valid names — shared
108/// by response-data field validation ([`validate_fields`]) and human-view
109/// column validation (`middleware`'s explicit-`--fields`-against-a-view-
110/// registered-columns check), so both surfaces produce the same message shape.
111pub(crate) fn unknown_fields_message(unknown: &[&str], known: &BTreeSet<String>) -> String {
112    let plural = if unknown.len() > 1 { "s" } else { "" };
113    let quoted = unknown
114        .iter()
115        .map(|name| format!("\"{name}\""))
116        .collect::<Vec<_>>()
117        .join(", ");
118    let mut message = format!("fields: unknown field{plural} {quoted}");
119    if let Some(first) = unknown.first()
120        && let Some(suggestion) = nearest_field(first, known)
121    {
122        message.push_str(&format!(" (did you mean \"{suggestion}\"?)"));
123    }
124    if !known.is_empty() {
125        let valid = known.iter().cloned().collect::<Vec<_>>().join(", ");
126        message.push_str(&format!("; valid fields: {valid}"));
127    }
128    message
129}
130
131/// Finds the closest known field name within edit-distance `max(1, name_len /
132/// 3)`, mirroring `nearest_subcommand`'s tolerance in `cli.rs`. Ties break
133/// alphabetically.
134fn nearest_field(name: &str, known: &BTreeSet<String>) -> Option<String> {
135    let name = name.to_ascii_lowercase();
136    let max_distance = 1.max(name.chars().count() / 3);
137    known
138        .iter()
139        .map(|candidate| {
140            (
141                strsim::osa_distance(&name, &candidate.to_ascii_lowercase()),
142                candidate,
143            )
144        })
145        .filter(|(distance, _)| *distance <= max_distance)
146        .min_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1)))
147        .map(|(_, candidate)| candidate.clone())
148}
149
150fn apply_pagination(data: &mut Value, offset: i64, limit: i64) -> Result<Option<PaginationMeta>> {
151    let Value::Array(items) = data else {
152        return Ok(None);
153    };
154    let total = items.len();
155    let total_i64 = match i64::try_from(total) {
156        Ok(total) => total,
157        Err(_) => {
158            return Err(CliCoreError::message(
159                "pagination: list length exceeds supported range",
160            ));
161        }
162    };
163    let start = offset.min(total_i64);
164    let start = match usize::try_from(start) {
165        Ok(start) => start,
166        Err(_) => {
167            return Err(CliCoreError::message(
168                "pagination: offset must be non-negative",
169            ));
170        }
171    };
172    let mut end = total;
173    if limit > 0 {
174        let limit = match usize::try_from(limit) {
175            Ok(limit) => limit,
176            Err(_) => {
177                return Err(CliCoreError::message(
178                    "pagination: limit exceeds supported range",
179                ));
180            }
181        };
182        if start + limit < end {
183            end = start + limit;
184        }
185    }
186    let sliced = items[start..end].to_vec();
187    *items = sliced;
188    Ok(Some(PaginationMeta {
189        total: total_i64,
190        offset,
191        limit,
192        count: match i64::try_from(end - start) {
193            Ok(count) => count,
194            Err(_) => {
195                return Err(CliCoreError::message(
196                    "pagination: count exceeds supported range",
197                ));
198            }
199        },
200        has_more: end < total,
201    }))
202}
203
204fn apply_filter(data: &mut Value, expression: &str) -> Result<()> {
205    let Value::Array(items) = data else {
206        return Err(CliCoreError::message(
207            "filter requires list data; use --expr for single objects",
208        ));
209    };
210
211    let expression = compile_query(expression)?;
212    let mut retained = Vec::with_capacity(items.len());
213    for item in items.drain(..) {
214        if search_query(&expression, &item)?.is_truthy() {
215            retained.push(item);
216        }
217    }
218    *items = retained;
219    Ok(())
220}
221
222fn apply_expr(data: &mut Value, expression: &str) -> Result<()> {
223    let expression = compile_query(expression)?;
224    let result = search_query(&expression, data)?;
225    *data = serde_json::to_value(result.as_ref())
226        .map_err(|error| CliCoreError::message(format!("expr: invalid result: {error}")))?;
227    Ok(())
228}
229
230fn compile_query(expression: &str) -> Result<jmespath::Expression<'static>> {
231    jmespath::compile(expression.trim())
232        .map_err(|error| CliCoreError::message(format!("expr: invalid JMESPath query: {error}")))
233}
234
235fn search_query(expression: &jmespath::Expression<'_>, data: &Value) -> Result<jmespath::Rcvar> {
236    expression
237        .search(data)
238        .map_err(|error| CliCoreError::message(format!("expr: JMESPath query failed: {error}")))
239}
240
241#[cfg(test)]
242mod tests {
243    use serde_json::json;
244
245    use super::{
246        PipelineOpts, apply_expr, apply_pagination, apply_pipeline, compile_query, parse_fields,
247        search_query, validate_fields,
248    };
249
250    #[test]
251    fn private_pipeline_helpers_cover_boundary_paths_directly() {
252        let mut object = json!({"id": "p1"});
253        assert_eq!(
254            apply_pagination(&mut object, 10, 1).expect("object pagination should no-op"),
255            None
256        );
257        assert_eq!(object, json!({"id": "p1"}));
258
259        let mut items = json!([{"id": "p1"}, {"id": "p2"}]);
260        let err =
261            apply_pagination(&mut items, -1, 1).expect_err("negative offset should be rejected");
262        assert_eq!(err.to_string(), "pagination: offset must be non-negative");
263
264        let expression = compile_query("items[?enabled].id").expect("query should compile");
265        let result = search_query(
266            &expression,
267            &json!({"items": [{"id": "p1", "enabled": true}, {"id": "p2", "enabled": false}]}),
268        )
269        .expect("query should evaluate");
270        assert_eq!(
271            serde_json::to_value(result.as_ref()).expect("result should serialize"),
272            json!(["p1"])
273        );
274
275        let mut data = json!({"items": [{"id": "p1"}]});
276        apply_expr(&mut data, "items[0].id").expect("expr should replace data");
277        assert_eq!(data, json!("p1"));
278    }
279
280    #[test]
281    fn validate_fields_rejects_a_name_absent_from_every_row_with_a_helpful_message() {
282        let data = json!([
283            {"operationId": "search", "description": "Search the catalog"},
284            {"operationId": "get", "description": "Get an item"},
285        ]);
286
287        let tree = parse_fields("OPERATIONID,description");
288        let err = validate_fields(&data, &tree).expect_err("uppercase name is not a real key");
289        let message = err.to_string();
290        assert!(
291            message.contains("unknown field \"OPERATIONID\""),
292            "{message}"
293        );
294        assert!(
295            message.contains("did you mean \"operationId\"?"),
296            "{message}"
297        );
298        assert!(
299            message.contains("valid fields: description, operationId"),
300            "{message}"
301        );
302    }
303
304    #[test]
305    fn validate_fields_accepts_names_present_on_at_least_one_row() {
306        let data = json!([{"id": "p1", "extra": true}, {"id": "p2"}]);
307        validate_fields(&data, &parse_fields("id,extra")).expect("both names appear on some row");
308    }
309
310    #[test]
311    fn validate_fields_only_checks_the_top_level_segment_of_nested_paths() {
312        let data = json!({"content": {"text": "hi"}});
313        validate_fields(&data, &parse_fields("content.text"))
314            .expect("nested path's top segment is known");
315    }
316
317    #[test]
318    fn validate_fields_skips_shapes_that_give_no_signal() {
319        let requested = parse_fields("anything");
320
321        let empty_list = json!([]);
322        validate_fields(&empty_list, &requested).expect("empty list can't validate");
323
324        let scalar_list = json!(["a", "b"]);
325        validate_fields(&scalar_list, &requested).expect("non-object items can't validate");
326
327        let scalar = json!("just a string");
328        validate_fields(&scalar, &requested).expect("scalar data can't validate");
329    }
330
331    #[test]
332    fn apply_pipeline_passes_all_and_star_and_empty_through_without_validating() {
333        // "all"/"*"/empty bypass validate_fields entirely in apply_pipeline,
334        // before a `FieldTree` is even built, so a name that couldn't
335        // possibly be a real field (like the literal string "all") must not
336        // be mistaken for a requested field name here.
337        for fields in ["all", "*", ""] {
338            let mut data = json!({"id": "p1"});
339            let opts = PipelineOpts {
340                fields: fields.to_owned(),
341                ..PipelineOpts::default()
342            };
343            apply_pipeline(&mut data, &opts).expect("bypassed fields value should not error");
344            assert_eq!(data, json!({"id": "p1"}), "fields={fields:?}");
345        }
346    }
347
348    #[test]
349    fn apply_pipeline_rejects_an_unknown_field_end_to_end() {
350        let mut data = json!([{"id": "p1", "status": "active"}]);
351        let opts = PipelineOpts {
352            fields: "id,STATUS".to_owned(),
353            ..PipelineOpts::default()
354        };
355        let err = apply_pipeline(&mut data, &opts).expect_err("STATUS is not a real key");
356        assert!(
357            err.to_string().contains("did you mean \"status\"?"),
358            "{err}"
359        );
360    }
361
362    #[test]
363    fn apply_pipeline_does_not_validate_a_default_fields_projection() {
364        // default_fields is author-controlled, not user input, and applies to
365        // every invocation of a command — see the doc comment on
366        // `PipelineOpts::fields_are_default`. A field the author listed that
367        // happens to be absent from every row of this particular response
368        // (e.g. a legitimately optional field, not a typo) must not
369        // hard-error the command; it should still project away as before
370        // this change.
371        let mut data = json!([{"id": "p1"}]);
372        let opts = PipelineOpts {
373            fields: "id,description".to_owned(),
374            fields_are_default: true,
375            ..PipelineOpts::default()
376        };
377        apply_pipeline(&mut data, &opts)
378            .expect("default_fields projection must not validate against the response");
379        assert_eq!(data, json!([{"id": "p1"}]));
380    }
381}