#[derive(Debug, Clone, PartialEq)]
pub struct FilterPred {
pub key: String,
pub value: Value,
pub not_equal: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SortKey {
Key,
KeyStable,
PathLine,
LinePath,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AggOp {
Count,
Sum(String),
Min(String),
Max(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AggSpec {
pub group: String,
pub op: AggOp,
}
#[derive(Debug, Clone)]
pub struct JoinSpec {
pub key: String,
pub suffix: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum OutputProfile {
#[default]
Full,
Lean,
}
impl OutputProfile {
#[must_use]
pub fn from_name(name: &str) -> Self {
if name.eq_ignore_ascii_case("lean") {
Self::Lean
} else {
Self::Full
}
}
}
#[derive(Debug, Clone, Default)]
pub struct OutputShape {
pub profile: OutputProfile,
pub verbose: bool,
pub select: Option<Vec<String>>,
pub filters: Vec<FilterPred>,
pub limit: Option<u64>,
pub sort_key: Option<String>,
pub sort: Option<SortKey>,
pub dedupe_keys: Vec<String>,
pub aggregates: Vec<AggSpec>,
pub joins: Vec<JoinSpec>,
pub truncate_specs: Vec<(String, usize)>,
pub redact: bool,
pub normalize_encoding: bool,
pub diff_against: Option<std::path::PathBuf>,
pub max_output_bytes: Option<usize>,
}
impl OutputShape {
#[inline]
pub fn is_identity(&self) -> bool {
self.profile == OutputProfile::Full
&& self.select.is_none()
&& self.filters.is_empty()
&& self.limit.is_none()
&& self.sort.is_none()
&& self.dedupe_keys.is_empty()
&& self.aggregates.is_empty()
&& self.joins.is_empty()
&& self.truncate_specs.is_empty()
&& !self.redact
&& !self.normalize_encoding
&& self.diff_against.is_none()
&& self.max_output_bytes.is_none()
}
#[inline]
pub fn is_buffering(&self) -> bool {
self.sort.is_some()
|| !self.dedupe_keys.is_empty()
|| !self.aggregates.is_empty()
|| self.diff_against.is_some()
}
#[inline]
pub fn is_tail_flush(&self) -> bool {
self.is_buffering()
}
pub fn from_cli(
select: Option<&str>,
filters: &[String],
limit: Option<u64>,
) -> Result<Self, crate::error::AtomwriteError> {
Ok(Self {
select: parse_select(select),
filters: parse_filters(filters)?,
limit,
..Self::default()
})
}
pub fn from_global_args(
global: &crate::cli::GlobalArgs,
) -> Result<Self, crate::error::AtomwriteError> {
Self::from_options(ShapeOptions::from_global_args(global))
}
pub fn from_options(
options: ShapeOptions,
) -> Result<Self, crate::error::AtomwriteError> {
let mut dedupe_keys: Vec<String> = Vec::new();
for raw in &options.dedupe_keys {
for key in split_keys(raw) {
if !dedupe_keys.contains(&key) {
dedupe_keys.push(key);
}
}
}
let mut aggregates = Vec::new();
for raw in &options.aggregate_exprs {
aggregates.push(parse_aggregate_expr(raw)?);
}
let mut joins = Vec::new();
for raw in &options.join_exprs {
joins.push(parse_join_expr(raw)?);
}
let mut truncate_specs = Vec::new();
for raw in &options.truncate_exprs {
truncate_specs.push(parse_truncate_expr(raw)?);
}
let sort_key = options
.sort_key
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
let sort = match sort_key.as_deref() {
None => None,
Some("path") | Some("path-line") => Some(SortKey::PathLine),
Some("line") | Some("line-path") => Some(SortKey::LinePath),
Some(_) => Some(SortKey::Key),
};
Ok(Self {
select: parse_select(options.select.as_deref()),
filters: parse_filters(&options.filters)?,
limit: options.limit,
sort_key,
sort,
dedupe_keys,
aggregates,
joins,
truncate_specs,
redact: options.redact_secrets,
normalize_encoding: options.normalize_encoding,
diff_against: options.diff_against.clone(),
max_output_bytes: options.max_output_bytes,
profile: options.profile,
verbose: options.verbose,
})
}
#[must_use]
pub fn with_profile(mut self, profile: OutputProfile) -> Self {
self.profile = profile;
self
}
}
#[derive(Debug, Clone, Default)]
pub struct ShapeOptions {
pub profile: OutputProfile,
pub verbose: bool,
pub select: Option<String>,
pub filters: Vec<String>,
pub limit: Option<u64>,
pub sort_key: Option<String>,
pub dedupe_keys: Vec<String>,
pub aggregate_exprs: Vec<String>,
pub join_exprs: Vec<String>,
pub truncate_exprs: Vec<String>,
pub redact_secrets: bool,
pub normalize_encoding: bool,
pub diff_against: Option<std::path::PathBuf>,
pub max_output_bytes: Option<usize>,
}
impl ShapeOptions {
pub fn from_global_args(global: &crate::cli::GlobalArgs) -> Self {
Self {
select: global.select.clone(),
filters: global.filter.clone(),
limit: global.limit,
sort_key: global.sort_by.clone(),
dedupe_keys: global.dedupe.clone(),
aggregate_exprs: global.aggregate.clone(),
join_exprs: global.join.clone(),
truncate_exprs: global.truncate_content.clone(),
redact_secrets: global.redact,
normalize_encoding: global.normalize_encoding,
diff_against: global.diff_against.clone(),
max_output_bytes: global.max_output_bytes,
profile: OutputProfile::Full,
verbose: global.verbose > 0,
}
}
pub fn from_trio(select: Option<&str>, filters: &[String], limit: Option<u64>) -> Self {
Self {
select: select.map(str::to_string),
filters: filters.to_vec(),
limit,
..Self::default()
}
}
}
fn parse_select(select: Option<&str>) -> Option<Vec<String>> {
let csv = select.map(str::trim).filter(|s| !s.is_empty())?;
let keys: Vec<String> = split_keys(csv);
if keys.is_empty() { None } else { Some(keys) }
}
fn split_keys(csv: &str) -> Vec<String> {
csv.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect()
}
fn parse_filters(filters: &[String]) -> Result<Vec<FilterPred>, crate::error::AtomwriteError> {
let mut preds = Vec::with_capacity(filters.len());
for f in filters {
preds.push(parse_filter_expr(f)?);
}
Ok(preds)
}
pub fn parse_aggregate_expr(raw: &str) -> Result<AggSpec, crate::error::AtomwriteError> {
let parts: Vec<&str> = raw.split(':').collect();
let group = parts.first().map(|s| s.trim()).unwrap_or("");
if group.is_empty() {
return Err(crate::error::AtomwriteError::InvalidInput {
reason: format!("invalid --aggregate {raw:?}: expected key:op[:field] with non-empty key"),
});
}
let op = parts.get(1).map(|s| s.trim()).unwrap_or("count");
let field = parts.get(2).map(|s| s.trim()).filter(|s| !s.is_empty());
let op = match op {
"" | "count" => AggOp::Count,
"sum" | "min" | "max" => {
let Some(field) = field else {
return Err(crate::error::AtomwriteError::InvalidInput {
reason: format!("invalid --aggregate {raw:?}: {op} requires a field"),
});
};
match op {
"sum" => AggOp::Sum(field.to_string()),
"min" => AggOp::Min(field.to_string()),
_ => AggOp::Max(field.to_string()),
}
}
other => {
return Err(crate::error::AtomwriteError::InvalidInput {
reason: format!(
"invalid --aggregate {raw:?}: unknown op {other:?} (expected count, sum, min, max)"
),
});
}
};
Ok(AggSpec {
group: group.to_string(),
op,
})
}
pub fn parse_join_expr(raw: &str) -> Result<JoinSpec, crate::error::AtomwriteError> {
let Some((key, suffix)) = raw.split_once(':') else {
return Err(crate::error::AtomwriteError::InvalidInput {
reason: format!("invalid --join {raw:?}: expected key:suffix"),
});
};
let (key, suffix) = (key.trim(), suffix.trim());
if key.is_empty() || suffix.is_empty() {
return Err(crate::error::AtomwriteError::InvalidInput {
reason: format!("invalid --join {raw:?}: key and suffix must not be empty"),
});
}
Ok(JoinSpec {
key: key.to_string(),
suffix: suffix.to_string(),
})
}
pub fn parse_truncate_expr(raw: &str) -> Result<(String, usize), crate::error::AtomwriteError> {
let Some((key, len)) = raw.split_once(':') else {
return Err(crate::error::AtomwriteError::InvalidInput {
reason: format!("invalid --truncate-content {raw:?}: expected key:len"),
});
};
let key = key.trim();
if key.is_empty() {
return Err(crate::error::AtomwriteError::InvalidInput {
reason: format!("invalid --truncate-content {raw:?}: empty key"),
});
}
let len: usize = len.trim().parse().map_err(|_| crate::error::AtomwriteError::InvalidInput {
reason: format!("invalid --truncate-content {raw:?}: len must be a positive integer"),
})?;
if len == 0 {
return Err(crate::error::AtomwriteError::InvalidInput {
reason: format!("invalid --truncate-content {raw:?}: len must be greater than zero"),
});
}
Ok((key.to_string(), len))
}
pub fn parse_filter_expr(raw: &str) -> Result<FilterPred, crate::error::AtomwriteError> {
let s = raw.trim();
if s.is_empty() {
return Err(crate::error::AtomwriteError::InvalidInput {
reason: "empty --filter expression".into(),
});
}
let (key, val_raw, not_equal) = if let Some(rest) = s.split_once("!=") {
(rest.0, rest.1, true)
} else if let Some(rest) = s.split_once("==") {
(rest.0, rest.1, false)
} else if let Some(rest) = s.split_once('=') {
(rest.0, rest.1, false)
} else {
return Err(crate::error::AtomwriteError::InvalidInput {
reason: format!(
"invalid --filter {raw:?}: expected key=value, key==value, or key!=value"
),
});
};
let key = key.trim();
if key.is_empty() {
return Err(crate::error::AtomwriteError::InvalidInput {
reason: format!("invalid --filter {raw:?}: empty key"),
});
}
let value = parse_filter_value(val_raw.trim());
Ok(FilterPred {
key: key.to_string(),
value,
not_equal,
})
}
fn parse_filter_value(raw: &str) -> Value {
match raw {
"true" => Value::Bool(true),
"false" => Value::Bool(false),
"null" => Value::Null,
s if s.starts_with('"') && s.ends_with('"') && s.len() >= 2 => {
Value::String(s[1..s.len() - 1].to_string())
}
s => {
if let Ok(n) = s.parse::<i64>() {
Value::Number(n.into())
} else if let Ok(n) = s.parse::<u64>() {
Value::Number(n.into())
} else if let Ok(n) = s.parse::<f64>() {
serde_json::Number::from_f64(n)
.map(Value::Number)
.unwrap_or_else(|| Value::String(s.to_string()))
} else {
Value::String(s.to_string())
}
}
}
}