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#[derive(Clone, Debug, Default, Eq, PartialEq)]
11pub struct PipelineOpts {
12 pub filter: String,
14 pub limit: i64,
16 pub offset: i64,
18 pub expr: String,
20 pub fields: String,
22 pub fields_are_default: bool,
27}
28
29pub 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 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
63fn 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
106pub(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
131fn 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 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 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}