1#![forbid(unsafe_code)]
4use serde_json::{Map, Value};
39use std::sync::Mutex;
40
41const ARRAY_KEYS: &[&str] = &[
46 "results", "items", "hosts", "entries", "vps", "matches", "rows", "data", "steps",
47];
48
49const REPORT_KEY: &str = "agent_shape";
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct Filter {
55 pub path: String,
57 pub op: FilterOp,
59 pub value: String,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum FilterOp {
66 Equals,
68 NotEquals,
70 Contains,
72}
73
74impl Filter {
75 pub fn parse(raw: &str) -> Result<Self, String> {
82 if let Some((k, v)) = raw.split_once("!=") {
83 return Self::build(k, FilterOp::NotEquals, v);
84 }
85 if let Some((k, v)) = raw.split_once("==") {
86 return Self::build(k, FilterOp::Equals, v);
87 }
88 if let Some((k, v)) = raw.split_once('~') {
89 return Self::build(k, FilterOp::Contains, v);
90 }
91 if let Some((k, v)) = raw.split_once('=') {
92 return Self::build(k, FilterOp::Equals, v);
93 }
94 Err(format!(
95 "invalid --filter `{raw}`: expected key=value, key!=value or key~substring"
96 ))
97 }
98
99 fn build(key: &str, op: FilterOp, value: &str) -> Result<Self, String> {
100 let key = key.trim();
101 if key.is_empty() {
102 return Err("invalid --filter: empty key".to_string());
103 }
104 Ok(Self {
105 path: key.to_string(),
106 op,
107 value: value.to_string(),
108 })
109 }
110
111 fn matches(&self, element: &Value) -> bool {
112 let actual = lookup(element, &self.path).map(scalar_to_string);
113 match (&self.op, actual) {
114 (_, None) => false,
117 (FilterOp::Equals, Some(a)) => a == self.value,
118 (FilterOp::NotEquals, Some(a)) => a != self.value,
119 (FilterOp::Contains, Some(a)) => a.contains(&self.value),
120 }
121 }
122}
123
124#[derive(Debug, Clone, Default, PartialEq, Eq)]
126pub struct ShapeConfig {
127 pub select: Vec<String>,
129 pub filters: Vec<Filter>,
131 pub limit: Option<usize>,
133 pub sort: Option<String>,
135 pub dedupe_by: Option<String>,
137 pub count_only: bool,
139 pub truncate_content: Option<usize>,
141 pub max_output_bytes: Option<usize>,
143}
144
145impl ShapeConfig {
146 #[must_use]
148 pub fn is_active(&self) -> bool {
149 !self.select.is_empty()
150 || !self.filters.is_empty()
151 || self.limit.is_some()
152 || self.sort.is_some()
153 || self.dedupe_by.is_some()
154 || self.count_only
155 || self.truncate_content.is_some()
156 || self.max_output_bytes.is_some()
157 }
158}
159
160static SHAPE: Mutex<Option<ShapeConfig>> = Mutex::new(None);
161
162fn lock_shape() -> std::sync::MutexGuard<'static, Option<ShapeConfig>> {
163 SHAPE.lock().unwrap_or_else(|poisoned| {
164 tracing::warn!("agent-shape mutex was poisoned; recovering (one-shot CLI)");
165 poisoned.into_inner()
166 })
167}
168
169pub fn set_shape(cfg: ShapeConfig) {
171 *lock_shape() = if cfg.is_active() { Some(cfg) } else { None };
172}
173
174#[must_use]
176pub fn is_active() -> bool {
177 lock_shape().is_some()
178}
179
180#[must_use]
182pub fn current() -> Option<ShapeConfig> {
183 lock_shape().clone()
184}
185
186fn lookup<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
188 let mut cur = value;
189 for segment in path.split('.') {
190 cur = cur.as_object()?.get(segment)?;
191 }
192 Some(cur)
193}
194
195fn scalar_to_string(v: &Value) -> String {
197 match v {
198 Value::String(s) => s.clone(),
199 Value::Number(n) => n.to_string(),
200 Value::Bool(b) => b.to_string(),
201 Value::Null => "null".to_string(),
202 other => other.to_string(),
203 }
204}
205
206fn compare(a: Option<&Value>, b: Option<&Value>) -> std::cmp::Ordering {
208 use std::cmp::Ordering;
209 match (a, b) {
210 (None, None) => Ordering::Equal,
213 (None, Some(_)) => Ordering::Greater,
214 (Some(_), None) => Ordering::Less,
215 (Some(x), Some(y)) => match (x.as_f64(), y.as_f64()) {
216 (Some(nx), Some(ny)) => nx.partial_cmp(&ny).unwrap_or(Ordering::Equal),
217 _ => scalar_to_string(x).cmp(&scalar_to_string(y)),
218 },
219 }
220}
221
222fn project(element: &Value, paths: &[String]) -> Value {
224 let mut out = Map::new();
225 for path in paths {
226 if let Some(found) = lookup(element, path) {
229 insert_path(&mut out, path, found.clone());
230 }
231 }
232 Value::Object(out)
233}
234
235fn insert_path(target: &mut Map<String, Value>, path: &str, value: Value) {
236 let mut segments = path.split('.').peekable();
237 let mut cursor = target;
238 while let Some(seg) = segments.next() {
239 if segments.peek().is_none() {
240 cursor.insert(seg.to_string(), value);
241 return;
242 }
243 let entry = cursor
244 .entry(seg.to_string())
245 .or_insert_with(|| Value::Object(Map::new()));
246 if !entry.is_object() {
247 *entry = Value::Object(Map::new());
248 }
249 match entry.as_object_mut() {
250 Some(next) => cursor = next,
251 None => return,
252 }
253 }
254}
255
256fn truncate_strings(value: &mut Value, max: usize, changed: &mut bool) {
258 match value {
259 Value::String(s) => {
260 if s.chars().count() > max {
263 let cut: String = s.chars().take(max).collect();
264 *s = cut;
265 *changed = true;
266 }
267 }
268 Value::Array(items) => {
269 for item in items {
270 truncate_strings(item, max, changed);
271 }
272 }
273 Value::Object(map) => {
274 for (_, v) in map.iter_mut() {
275 truncate_strings(v, max, changed);
276 }
277 }
278 _ => {}
279 }
280}
281
282fn find_array_key(map: &Map<String, Value>) -> Option<String> {
284 ARRAY_KEYS
285 .iter()
286 .find(|k| map.get(**k).is_some_and(Value::is_array))
287 .map(|k| (*k).to_string())
288}
289
290#[derive(Debug, Clone, Copy, Default)]
292struct ShapeReport {
293 input_count: usize,
294 output_count: usize,
295 content_truncated: bool,
296}
297
298impl ShapeReport {
299 fn dropped(&self) -> usize {
300 self.input_count.saturating_sub(self.output_count)
301 }
302
303 fn changed_anything(&self) -> bool {
304 self.dropped() > 0 || self.content_truncated
305 }
306}
307
308fn shape_items(items: &mut Vec<Value>, cfg: &ShapeConfig) -> ShapeReport {
310 let input_count = items.len();
311 let mut content_truncated = false;
312
313 if !cfg.filters.is_empty() {
314 items.retain(|item| cfg.filters.iter().all(|f| f.matches(item)));
315 }
316
317 if let Some(path) = &cfg.sort {
318 items.sort_by(|a, b| compare(lookup(a, path), lookup(b, path)));
319 }
320
321 if let Some(path) = &cfg.dedupe_by {
322 let mut seen = std::collections::HashSet::new();
323 items.retain(|item| match lookup(item, path) {
324 None => true,
327 Some(v) => seen.insert(scalar_to_string(v)),
328 });
329 }
330
331 if let Some(limit) = cfg.limit {
332 items.truncate(limit);
333 }
334
335 if !cfg.select.is_empty() {
336 for item in items.iter_mut() {
337 *item = project(item, &cfg.select);
338 }
339 }
340
341 if let Some(max) = cfg.truncate_content {
342 for item in items.iter_mut() {
343 truncate_strings(item, max, &mut content_truncated);
344 }
345 }
346
347 ShapeReport {
348 input_count,
349 output_count: items.len(),
350 content_truncated,
351 }
352}
353
354pub fn apply(root: &mut Value, cfg: &ShapeConfig) -> bool {
363 match root {
364 Value::Array(items) => {
365 let report = shape_items(items, cfg);
366 if cfg.count_only {
367 *root = Value::Object({
368 let mut m = Map::new();
369 m.insert("count".to_string(), Value::from(report.output_count));
370 m
371 });
372 return true;
373 }
374 let byte_capped = cap_output_bytes(root, cfg);
375 if report.changed_anything() || byte_capped {
379 tracing::info!(
380 input_count = report.input_count,
381 output_count = report.output_count,
382 dropped = report.dropped(),
383 content_truncated = report.content_truncated,
384 output_truncated = byte_capped,
385 "agent-shape reduced the payload"
386 );
387 }
388 true
389 }
390 Value::Object(_) => apply_to_envelope(root, cfg),
391 _ => false,
392 }
393}
394
395fn apply_to_envelope(root: &mut Value, cfg: &ShapeConfig) -> bool {
397 let Some(map) = root.as_object_mut() else {
398 return false;
399 };
400 let Some(key) = find_array_key(map) else {
401 return false;
405 };
406 let Some(Value::Array(items)) = map.get_mut(&key) else {
407 return false;
408 };
409
410 let report = shape_items(items, cfg);
411 let mut output_count = report.output_count;
412
413 let mut byte_capped = false;
414 if cfg.count_only {
415 map.remove(&key);
418 map.insert("count".to_string(), Value::from(output_count));
419 } else if let Some(max_bytes) = cfg.max_output_bytes {
420 loop {
423 let too_big = serde_json::to_string(&Value::Object(map.clone()))
424 .map(|s| s.len() > max_bytes)
425 .unwrap_or(false);
426 if !too_big {
427 break;
428 }
429 let Some(Value::Array(items)) = map.get_mut(&key) else {
430 break;
431 };
432 if items.pop().is_none() {
433 break;
434 }
435 byte_capped = true;
436 output_count = items.len();
437 }
438 }
439
440 let mut out = Map::new();
441 out.insert("input_count".to_string(), Value::from(report.input_count));
442 out.insert("output_count".to_string(), Value::from(output_count));
443 out.insert(
444 "dropped".to_string(),
445 Value::from(report.input_count.saturating_sub(output_count)),
446 );
447 if report.content_truncated {
448 out.insert("content_truncated".to_string(), Value::Bool(true));
449 }
450 if byte_capped {
451 out.insert("output_truncated".to_string(), Value::Bool(true));
452 }
453 map.insert(REPORT_KEY.to_string(), Value::Object(out));
454 true
455}
456
457fn cap_output_bytes(root: &mut Value, cfg: &ShapeConfig) -> bool {
459 let Some(max_bytes) = cfg.max_output_bytes else {
460 return false;
461 };
462 let mut capped = false;
463 loop {
464 let too_big = serde_json::to_string(&*root)
465 .map(|s| s.len() > max_bytes)
466 .unwrap_or(false);
467 if !too_big {
468 return capped;
469 }
470 let Some(items) = root.as_array_mut() else {
471 return capped;
472 };
473 if items.pop().is_none() {
474 return capped;
475 }
476 capped = true;
477 }
478}
479
480#[cfg(test)]
481#[path = "agent_shape_tests.rs"]
482mod tests;