#![forbid(unsafe_code)]
use serde_json::{Map, Value};
use std::sync::Mutex;
const ARRAY_KEYS: &[&str] = &[
"results", "items", "hosts", "entries", "vps", "matches", "rows", "data", "steps",
];
const REPORT_KEY: &str = "agent_shape";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Filter {
pub path: String,
pub op: FilterOp,
pub value: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FilterOp {
Equals,
NotEquals,
Contains,
}
impl Filter {
pub fn parse(raw: &str) -> Result<Self, String> {
if let Some((k, v)) = raw.split_once("!=") {
return Self::build(k, FilterOp::NotEquals, v);
}
if let Some((k, v)) = raw.split_once("==") {
return Self::build(k, FilterOp::Equals, v);
}
if let Some((k, v)) = raw.split_once('~') {
return Self::build(k, FilterOp::Contains, v);
}
if let Some((k, v)) = raw.split_once('=') {
return Self::build(k, FilterOp::Equals, v);
}
Err(format!(
"invalid --filter `{raw}`: expected key=value, key!=value or key~substring"
))
}
fn build(key: &str, op: FilterOp, value: &str) -> Result<Self, String> {
let key = key.trim();
if key.is_empty() {
return Err("invalid --filter: empty key".to_string());
}
Ok(Self {
path: key.to_string(),
op,
value: value.to_string(),
})
}
fn matches(&self, element: &Value) -> bool {
let actual = lookup(element, &self.path).map(scalar_to_string);
match (&self.op, actual) {
(_, None) => false,
(FilterOp::Equals, Some(a)) => a == self.value,
(FilterOp::NotEquals, Some(a)) => a != self.value,
(FilterOp::Contains, Some(a)) => a.contains(&self.value),
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ShapeConfig {
pub select: Vec<String>,
pub filters: Vec<Filter>,
pub limit: Option<usize>,
pub sort: Option<String>,
pub dedupe_by: Option<String>,
pub count_only: bool,
pub truncate_content: Option<usize>,
pub max_output_bytes: Option<usize>,
}
impl ShapeConfig {
#[must_use]
pub fn is_active(&self) -> bool {
!self.select.is_empty()
|| !self.filters.is_empty()
|| self.limit.is_some()
|| self.sort.is_some()
|| self.dedupe_by.is_some()
|| self.count_only
|| self.truncate_content.is_some()
|| self.max_output_bytes.is_some()
}
}
static SHAPE: Mutex<Option<ShapeConfig>> = Mutex::new(None);
fn lock_shape() -> std::sync::MutexGuard<'static, Option<ShapeConfig>> {
SHAPE.lock().unwrap_or_else(|poisoned| {
tracing::warn!("agent-shape mutex was poisoned; recovering (one-shot CLI)");
poisoned.into_inner()
})
}
pub fn set_shape(cfg: ShapeConfig) {
*lock_shape() = if cfg.is_active() { Some(cfg) } else { None };
}
#[must_use]
pub fn is_active() -> bool {
lock_shape().is_some()
}
#[must_use]
pub fn current() -> Option<ShapeConfig> {
lock_shape().clone()
}
fn lookup<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
let mut cur = value;
for segment in path.split('.') {
cur = cur.as_object()?.get(segment)?;
}
Some(cur)
}
fn scalar_to_string(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null => "null".to_string(),
other => other.to_string(),
}
}
fn compare(a: Option<&Value>, b: Option<&Value>) -> std::cmp::Ordering {
use std::cmp::Ordering;
match (a, b) {
(None, None) => Ordering::Equal,
(None, Some(_)) => Ordering::Greater,
(Some(_), None) => Ordering::Less,
(Some(x), Some(y)) => match (x.as_f64(), y.as_f64()) {
(Some(nx), Some(ny)) => nx.partial_cmp(&ny).unwrap_or(Ordering::Equal),
_ => scalar_to_string(x).cmp(&scalar_to_string(y)),
},
}
}
fn project(element: &Value, paths: &[String]) -> Value {
let mut out = Map::new();
for path in paths {
if let Some(found) = lookup(element, path) {
insert_path(&mut out, path, found.clone());
}
}
Value::Object(out)
}
fn insert_path(target: &mut Map<String, Value>, path: &str, value: Value) {
let mut segments = path.split('.').peekable();
let mut cursor = target;
while let Some(seg) = segments.next() {
if segments.peek().is_none() {
cursor.insert(seg.to_string(), value);
return;
}
let entry = cursor
.entry(seg.to_string())
.or_insert_with(|| Value::Object(Map::new()));
if !entry.is_object() {
*entry = Value::Object(Map::new());
}
match entry.as_object_mut() {
Some(next) => cursor = next,
None => return,
}
}
}
fn truncate_strings(value: &mut Value, max: usize, changed: &mut bool) {
match value {
Value::String(s) => {
if s.chars().count() > max {
let cut: String = s.chars().take(max).collect();
*s = cut;
*changed = true;
}
}
Value::Array(items) => {
for item in items {
truncate_strings(item, max, changed);
}
}
Value::Object(map) => {
for (_, v) in map.iter_mut() {
truncate_strings(v, max, changed);
}
}
_ => {}
}
}
fn find_array_key(map: &Map<String, Value>) -> Option<String> {
ARRAY_KEYS
.iter()
.find(|k| map.get(**k).is_some_and(Value::is_array))
.map(|k| (*k).to_string())
}
#[derive(Debug, Clone, Copy, Default)]
struct ShapeReport {
input_count: usize,
output_count: usize,
content_truncated: bool,
}
impl ShapeReport {
fn dropped(&self) -> usize {
self.input_count.saturating_sub(self.output_count)
}
fn changed_anything(&self) -> bool {
self.dropped() > 0 || self.content_truncated
}
}
fn shape_items(items: &mut Vec<Value>, cfg: &ShapeConfig) -> ShapeReport {
let input_count = items.len();
let mut content_truncated = false;
if !cfg.filters.is_empty() {
items.retain(|item| cfg.filters.iter().all(|f| f.matches(item)));
}
if let Some(path) = &cfg.sort {
items.sort_by(|a, b| compare(lookup(a, path), lookup(b, path)));
}
if let Some(path) = &cfg.dedupe_by {
let mut seen = std::collections::HashSet::new();
items.retain(|item| match lookup(item, path) {
None => true,
Some(v) => seen.insert(scalar_to_string(v)),
});
}
if let Some(limit) = cfg.limit {
items.truncate(limit);
}
if !cfg.select.is_empty() {
for item in items.iter_mut() {
*item = project(item, &cfg.select);
}
}
if let Some(max) = cfg.truncate_content {
for item in items.iter_mut() {
truncate_strings(item, max, &mut content_truncated);
}
}
ShapeReport {
input_count,
output_count: items.len(),
content_truncated,
}
}
pub fn apply(root: &mut Value, cfg: &ShapeConfig) -> bool {
match root {
Value::Array(items) => {
let report = shape_items(items, cfg);
if cfg.count_only {
*root = Value::Object({
let mut m = Map::new();
m.insert("count".to_string(), Value::from(report.output_count));
m
});
return true;
}
let byte_capped = cap_output_bytes(root, cfg);
if report.changed_anything() || byte_capped {
tracing::info!(
input_count = report.input_count,
output_count = report.output_count,
dropped = report.dropped(),
content_truncated = report.content_truncated,
output_truncated = byte_capped,
"agent-shape reduced the payload"
);
}
true
}
Value::Object(_) => apply_to_envelope(root, cfg),
_ => false,
}
}
fn apply_to_envelope(root: &mut Value, cfg: &ShapeConfig) -> bool {
let Some(map) = root.as_object_mut() else {
return false;
};
let Some(key) = find_array_key(map) else {
return false;
};
let Some(Value::Array(items)) = map.get_mut(&key) else {
return false;
};
let report = shape_items(items, cfg);
let mut output_count = report.output_count;
let mut byte_capped = false;
if cfg.count_only {
map.remove(&key);
map.insert("count".to_string(), Value::from(output_count));
} else if let Some(max_bytes) = cfg.max_output_bytes {
loop {
let too_big = serde_json::to_string(&Value::Object(map.clone()))
.map(|s| s.len() > max_bytes)
.unwrap_or(false);
if !too_big {
break;
}
let Some(Value::Array(items)) = map.get_mut(&key) else {
break;
};
if items.pop().is_none() {
break;
}
byte_capped = true;
output_count = items.len();
}
}
let mut out = Map::new();
out.insert("input_count".to_string(), Value::from(report.input_count));
out.insert("output_count".to_string(), Value::from(output_count));
out.insert(
"dropped".to_string(),
Value::from(report.input_count.saturating_sub(output_count)),
);
if report.content_truncated {
out.insert("content_truncated".to_string(), Value::Bool(true));
}
if byte_capped {
out.insert("output_truncated".to_string(), Value::Bool(true));
}
map.insert(REPORT_KEY.to_string(), Value::Object(out));
true
}
fn cap_output_bytes(root: &mut Value, cfg: &ShapeConfig) -> bool {
let Some(max_bytes) = cfg.max_output_bytes else {
return false;
};
let mut capped = false;
loop {
let too_big = serde_json::to_string(&*root)
.map(|s| s.len() > max_bytes)
.unwrap_or(false);
if !too_big {
return capped;
}
let Some(items) = root.as_array_mut() else {
return capped;
};
if items.pop().is_none() {
return capped;
}
capped = true;
}
}
#[cfg(test)]
#[path = "agent_shape_tests.rs"]
mod tests;