1use crate::error::Result;
18
19#[derive(Debug, Clone)]
21pub struct ProjectionConfig {
22 pub strip_prefixes: Vec<String>,
24 pub strip_names: Vec<String>,
26 pub max_depth: usize,
29 pub strip_empty: bool,
31 pub dedup_timestamps: bool,
34}
35
36impl Default for ProjectionConfig {
37 fn default() -> Self {
38 Self {
39 strip_prefixes: Vec::new(),
40 strip_names: vec![
41 "__v".to_string(),
42 "etag".to_string(),
43 "request_id".to_string(),
44 "x_request_id".to_string(),
45 "correlation_id".to_string(),
46 ],
47 max_depth: 64,
49 strip_empty: true,
50 dedup_timestamps: false,
51 }
52 }
53}
54
55#[derive(Debug, Clone)]
57pub struct ProjectionResult {
58 pub data: String,
60 pub fields_removed: usize,
62 pub tokens_saved: u32,
64}
65
66pub fn project_json(input: &str, config: &ProjectionConfig) -> Result<ProjectionResult> {
71 let trimmed = input.trim();
72 let mut value: serde_json::Value = match serde_json::from_str(trimmed) {
73 Ok(v) => v,
74 Err(_) => {
75 return Ok(ProjectionResult {
76 data: input.to_string(),
77 fields_removed: 0,
78 tokens_saved: 0,
79 });
80 }
81 };
82
83 let original_tokens = estimate_tokens(input);
84 let mut fields_removed = 0;
85
86 project_value(&mut value, config, 0, &mut fields_removed);
87
88 let projected = serde_json::to_string(&value)
89 .unwrap_or_else(|_| input.to_string());
90
91 let projected_tokens = estimate_tokens(&projected);
92 let tokens_saved = original_tokens.saturating_sub(projected_tokens);
93
94 if projected.len() < input.len() || fields_removed > 0 {
96 Ok(ProjectionResult {
97 data: projected,
98 fields_removed,
99 tokens_saved,
100 })
101 } else {
102 Ok(ProjectionResult {
103 data: input.to_string(),
104 fields_removed: 0,
105 tokens_saved: 0,
106 })
107 }
108}
109
110fn project_value(
112 value: &mut serde_json::Value,
113 config: &ProjectionConfig,
114 depth: usize,
115 removed: &mut usize,
116) {
117 match value {
118 serde_json::Value::Object(map) => {
119 if depth >= config.max_depth {
121 let key_count = map.len();
122 if key_count > 0 {
123 map.clear();
124 map.insert(
125 "_sqz_summary".to_string(),
126 serde_json::Value::String(format!("{{...{key_count} keys}}")),
127 );
128 *removed += key_count;
129 }
130 return;
131 }
132
133 let keys_to_remove: Vec<String> = map
134 .keys()
135 .filter(|k| should_strip_field(k, config))
136 .cloned()
137 .collect();
138
139 for key in &keys_to_remove {
140 map.remove(key);
141 *removed += 1;
142 }
143
144 if config.strip_empty {
146 let empty_keys: Vec<String> = map
147 .iter()
148 .filter(|(_, v)| is_empty_collection(v))
149 .map(|(k, _)| k.clone())
150 .collect();
151 for key in &empty_keys {
152 map.remove(key);
153 *removed += 1;
154 }
155 }
156
157 if config.dedup_timestamps {
160 dedup_timestamps(map, removed);
161 }
162
163 for v in map.values_mut() {
165 project_value(v, config, depth + 1, removed);
166 }
167 }
168 serde_json::Value::Array(arr) => {
169 for item in arr.iter_mut() {
170 project_value(item, config, depth + 1, removed);
171 }
172 }
173 _ => {}
174 }
175}
176
177fn should_strip_field(name: &str, config: &ProjectionConfig) -> bool {
179 let lower = name.to_lowercase();
180
181 for strip_name in &config.strip_names {
183 if lower == strip_name.to_lowercase() {
184 return true;
185 }
186 }
187
188 for prefix in &config.strip_prefixes {
190 let prefix_lower = prefix.to_lowercase();
191 if lower.starts_with(&prefix_lower) && lower != prefix_lower {
192 return true;
195 }
196 }
197
198 false
199}
200
201fn is_empty_collection(value: &serde_json::Value) -> bool {
203 match value {
204 serde_json::Value::Array(arr) => arr.is_empty(),
205 serde_json::Value::Object(map) => map.is_empty(),
206 serde_json::Value::String(s) => s.is_empty(),
207 _ => false,
208 }
209}
210
211fn dedup_timestamps(
214 map: &mut serde_json::Map<String, serde_json::Value>,
215 removed: &mut usize,
216) {
217 let timestamp_fields: Vec<(String, String)> = map
218 .iter()
219 .filter_map(|(k, v)| {
220 if (k.ends_with("_at") || k.ends_with("_date") || k.ends_with("_time"))
221 && v.is_string()
222 {
223 let date_prefix = v.as_str().unwrap_or("").chars().take(10).collect::<String>();
224 if date_prefix.len() == 10 && date_prefix.contains('-') {
225 return Some((k.clone(), date_prefix));
226 }
227 }
228 None
229 })
230 .collect();
231
232 if timestamp_fields.len() <= 1 {
233 return;
234 }
235
236 let mut seen_dates: std::collections::HashSet<String> = std::collections::HashSet::new();
238 let mut first_field_per_date: std::collections::HashMap<String, String> = std::collections::HashMap::new();
239 let mut to_remove = Vec::new();
240
241 for (field, date) in ×tamp_fields {
242 if seen_dates.contains(date) {
243 let primary = first_field_per_date.get(date).unwrap();
245 let dominated = if field.contains("created") {
247 to_remove.push(primary.clone());
249 false
250 } else {
251 true
252 };
253 if dominated {
254 to_remove.push(field.clone());
255 }
256 } else {
257 seen_dates.insert(date.clone());
258 first_field_per_date.insert(date.clone(), field.clone());
259 }
260 }
261
262 for field in &to_remove {
263 map.remove(field);
264 *removed += 1;
265 }
266}
267
268fn estimate_tokens(text: &str) -> u32 {
269 ((text.len() as f64) / 4.0).ceil() as u32
270}
271
272#[cfg(test)]
275mod tests {
276 use super::*;
277 use serde_json::json;
278
279 fn project_default(input: serde_json::Value) -> (serde_json::Value, usize) {
280 let result =
281 project_json(&serde_json::to_string(&input).unwrap(), &ProjectionConfig::default()).unwrap();
282 (serde_json::from_str(&result.data).unwrap(), result.fields_removed)
283 }
284
285 #[test]
286 fn test_strips_bookkeeping_fields_only() {
287 let (parsed, removed) = project_default(json!({
288 "id": 1,
289 "name": "Alice",
290 "_id": "abc123",
291 "__v": 3,
292 "etag": "W/\"1\"",
293 "debug_info": "verbose stuff",
294 "internal_state": "hidden"
295 }));
296 assert!(parsed.get("id").is_some(), "id should be kept");
297 assert!(parsed.get("_id").is_some(), "a Mongo _id is the record id");
298 assert!(parsed.get("debug_info").is_some(), "no prefix guessing by default");
299 assert!(parsed.get("internal_state").is_some());
300 assert!(parsed.get("__v").is_none(), "__v should be stripped");
301 assert!(parsed.get("etag").is_none(), "etag should be stripped");
302 assert_eq!(removed, 2);
303 }
304
305 #[test]
306 fn test_keeps_payload_fields_real_apis_put_behind_underscores() {
307 let (parsed, _) = project_default(json!({
308 "hits": {"hits": [{"_id": "doc-1", "_score": 9.5, "_source": {"title": "Runbook"}}]},
309 "_embedded": {"orders": [{"id": 7}]},
310 "_links": {"next": {"href": "/orders?page=2"}},
311 "cursor": "c2",
312 "traceback": "File \"app.py\", line 3",
313 "__typename": "Issue"
314 }));
315 assert_eq!(parsed["hits"]["hits"][0]["_source"]["title"], "Runbook");
316 assert_eq!(parsed["_embedded"]["orders"][0]["id"], 7);
317 assert_eq!(parsed["_links"]["next"]["href"], "/orders?page=2");
318 assert_eq!(parsed["cursor"], "c2");
319 assert!(parsed.get("traceback").is_some());
320 assert_eq!(parsed["__typename"], "Issue");
321 }
322
323 #[test]
324 fn test_keeps_deeply_nested_text() {
325 let (parsed, _) = project_default(json!({
327 "results": [{"properties": {"title": {"title": [{"plain_text": "Design note"}]}}}]
328 }));
329 assert_eq!(
330 parsed["results"][0]["properties"]["title"]["title"][0]["plain_text"],
331 "Design note"
332 );
333 }
334
335 #[test]
336 fn test_keeps_same_day_timestamps_by_default() {
337 let (parsed, removed) = project_default(json!({
338 "created_at": "2024-01-15T10:00:00Z",
339 "closed_at": "2024-01-15T14:30:00Z"
340 }));
341 assert!(parsed.get("closed_at").is_some());
342 assert_eq!(removed, 0);
343 }
344
345 #[test]
346 fn test_strips_empty_collections() {
347 let input = json!({
348 "name": "Bob",
349 "tags": [],
350 "metadata": {},
351 "bio": ""
352 });
353 let config = ProjectionConfig::default();
354 let result = project_json(&serde_json::to_string(&input).unwrap(), &config).unwrap();
355 let parsed: serde_json::Value = serde_json::from_str(&result.data).unwrap();
356 assert!(parsed.get("name").is_some());
357 assert!(parsed.get("tags").is_none(), "empty array should be stripped");
358 assert!(parsed.get("metadata").is_none(), "empty object should be stripped");
359 assert!(parsed.get("bio").is_none(), "empty string should be stripped");
360 }
361
362 #[test]
363 fn test_max_depth_truncation() {
364 let input = json!({
365 "a": {"b": {"c": {"d": {"e": {"f": "deep"}}}}}
366 });
367 let config = ProjectionConfig {
368 max_depth: 3,
369 ..Default::default()
370 };
371 let result = project_json(&serde_json::to_string(&input).unwrap(), &config).unwrap();
372 let parsed: serde_json::Value = serde_json::from_str(&result.data).unwrap();
374 let at_depth = &parsed["a"]["b"]["c"];
376 assert!(
377 at_depth.get("_sqz_summary").is_some() || result.fields_removed > 0,
378 "deep nesting should be truncated at max_depth: {:?}", parsed
379 );
380 }
381
382 #[test]
383 fn test_dedup_timestamps() {
384 let input = json!({
385 "name": "Alice",
386 "created_at": "2024-01-15T10:00:00Z",
387 "updated_at": "2024-01-15T14:30:00Z",
388 "modified_at": "2024-01-15T14:30:00Z"
389 });
390 let config = ProjectionConfig {
391 dedup_timestamps: true,
392 ..Default::default()
393 };
394 let result = project_json(&serde_json::to_string(&input).unwrap(), &config).unwrap();
395 let parsed: serde_json::Value = serde_json::from_str(&result.data).unwrap();
396 assert!(parsed.get("created_at").is_some(), "created_at should be kept");
398 assert!(result.fields_removed > 0, "redundant timestamps should be removed");
399 }
400
401 #[test]
402 fn test_non_json_passthrough() {
403 let input = "not json at all";
404 let config = ProjectionConfig::default();
405 let result = project_json(input, &config).unwrap();
406 assert_eq!(result.data, input);
407 assert_eq!(result.fields_removed, 0);
408 }
409
410 #[test]
411 fn test_preserves_important_fields() {
412 let input = json!({
413 "id": 42,
414 "name": "Alice",
415 "email": "alice@example.com",
416 "role": "admin",
417 "status": "active"
418 });
419 let config = ProjectionConfig::default();
420 let result = project_json(&serde_json::to_string(&input).unwrap(), &config).unwrap();
421 let parsed: serde_json::Value = serde_json::from_str(&result.data).unwrap();
422 assert_eq!(parsed["id"], 42);
423 assert_eq!(parsed["name"], "Alice");
424 assert_eq!(parsed["role"], "admin");
425 }
426
427 #[test]
428 fn test_custom_strip_prefixes() {
429 let input = json!({
430 "name": "Alice",
431 "tmp_cache": "data",
432 "tmp_buffer": "more data"
433 });
434 let config = ProjectionConfig {
435 strip_prefixes: vec!["tmp_".to_string()],
436 ..Default::default()
437 };
438 let result = project_json(&serde_json::to_string(&input).unwrap(), &config).unwrap();
439 let parsed: serde_json::Value = serde_json::from_str(&result.data).unwrap();
440 assert!(parsed.get("name").is_some());
441 assert!(parsed.get("tmp_cache").is_none());
442 assert!(parsed.get("tmp_buffer").is_none());
443 }
444
445 #[test]
446 fn test_nested_projection() {
447 let input = json!({
448 "user": {
449 "id": 1,
450 "name": "Alice",
451 "_internal_id": "xyz",
452 "debug_flags": [1, 2, 3]
453 }
454 });
455 let config = ProjectionConfig {
456 strip_prefixes: vec!["_".to_string(), "debug".to_string()],
457 ..Default::default()
458 };
459 let result = project_json(&serde_json::to_string(&input).unwrap(), &config).unwrap();
460 let parsed: serde_json::Value = serde_json::from_str(&result.data).unwrap();
461 assert!(parsed["user"].get("id").is_some());
462 assert!(parsed["user"].get("_internal_id").is_none());
463 assert!(parsed["user"].get("debug_flags").is_none());
464 }
465
466 use proptest::prelude::*;
467
468 proptest! {
469 #[test]
471 fn prop_projection_produces_valid_json(
472 key1 in "[a-z]{3,10}",
473 key2 in "[a-z]{3,10}",
474 val in "[a-z0-9 ]{1,50}",
475 ) {
476 let input = format!(r#"{{"{key1}":"{val}","{key2}":42}}"#);
477 let config = ProjectionConfig::default();
478 let result = project_json(&input, &config).unwrap();
479 let parsed: std::result::Result<serde_json::Value, _> = serde_json::from_str(&result.data);
480 prop_assert!(parsed.is_ok(), "projection output must be valid JSON");
481 }
482
483 #[test]
485 fn prop_fields_removed_non_negative(
486 val in "[a-z]{1,20}",
487 ) {
488 let input = format!(r#"{{"name":"{val}","_debug":"x","__v":1}}"#);
489 let config = ProjectionConfig::default();
490 let result = project_json(&input, &config).unwrap();
491 let _ = result.fields_removed;
493 }
494 }
495}