monochange 0.8.0

Manage versions and releases for your multiplatform, multilanguage monorepo
Documentation
use std::fmt::Write as _;

use monochange_core::MonochangeError;
use monochange_core::MonochangeResult;
use serde_json::Value;

pub(crate) fn apply_jq_filter(output: &str, expression: &str) -> MonochangeResult<String> {
	let value = serde_json::from_str::<Value>(output.trim()).map_err(|error| {
		MonochangeError::Config(format!(
			"--jq requires JSON output; add `--format json` before filtering: {error}"
		))
	})?;
	let values = evaluate_pipeline(vec![value], expression)?;
	Ok(render_values(&values))
}

fn evaluate_pipeline(values: Vec<Value>, expression: &str) -> MonochangeResult<Vec<Value>> {
	let mut current = values;
	for stage in split_top_level(expression, '|') {
		let stage = stage.trim();
		if stage.is_empty() {
			continue;
		}

		if let Some(condition) = select_condition(stage) {
			let mut filtered = Vec::new();
			for value in current {
				if condition_matches(&value, condition)? {
					filtered.push(value);
				}
			}
			current = filtered;
			continue;
		}

		if is_comparison(stage) {
			current = current
				.into_iter()
				.map(|value| condition_matches(&value, stage).map(Value::Bool))
				.collect::<MonochangeResult<Vec<_>>>()?;
			continue;
		}

		current = current
			.iter()
			.flat_map(|value| evaluate_path(value, stage))
			.collect();
	}
	Ok(current)
}

fn select_condition(stage: &str) -> Option<&str> {
	stage
		.strip_prefix("select(")
		.and_then(|inner| inner.strip_suffix(')'))
		.map(str::trim)
}

fn is_comparison(stage: &str) -> bool {
	find_top_level_operator(stage, "==").is_some() || find_top_level_operator(stage, "!=").is_some()
}

fn condition_matches(value: &Value, condition: &str) -> MonochangeResult<bool> {
	for part in split_top_level_and(condition) {
		if !single_condition_matches(value, part.trim())? {
			return Ok(false);
		}
	}
	Ok(true)
}

fn single_condition_matches(value: &Value, condition: &str) -> MonochangeResult<bool> {
	if let Some(index) = find_top_level_operator(condition, "==") {
		return compare_operands(value, &condition[..index], &condition[index + 2..], true);
	}
	if let Some(index) = find_top_level_operator(condition, "!=") {
		return compare_operands(value, &condition[..index], &condition[index + 2..], false);
	}

	Ok(evaluate_operand(value, condition)?
		.into_iter()
		.any(|candidate| truthy(&candidate)))
}

fn compare_operands(value: &Value, left: &str, right: &str, equal: bool) -> MonochangeResult<bool> {
	let left_values = evaluate_operand(value, left.trim())?;
	let right_values = evaluate_operand(value, right.trim())?;
	let matched = left_values.iter().any(|left_value| {
		right_values
			.iter()
			.any(|right_value| left_value == right_value)
	});
	Ok(if equal { matched } else { !matched })
}

fn evaluate_operand(value: &Value, operand: &str) -> MonochangeResult<Vec<Value>> {
	let operand = operand.trim();
	if operand.starts_with('.') {
		return Ok(evaluate_path(value, operand));
	}
	parse_literal(operand).map(|literal| vec![literal])
}

fn parse_literal(value: &str) -> MonochangeResult<Value> {
	match value {
		"true" => Ok(Value::Bool(true)),
		"false" => Ok(Value::Bool(false)),
		"null" => Ok(Value::Null),
		_ if value.starts_with('"') => {
			serde_json::from_str::<Value>(value).map_err(|error| {
				MonochangeError::Config(format!("invalid --jq string literal `{value}`: {error}"))
			})
		}
		_ => {
			serde_json::from_str::<Value>(value).map_err(|error| {
				MonochangeError::Config(format!("unsupported --jq operand `{value}`: {error}"))
			})
		}
	}
}

fn evaluate_path(value: &Value, path: &str) -> Vec<Value> {
	let path = path.trim();
	if path == "." {
		return vec![value.clone()];
	}
	if !path.starts_with('.') {
		return Vec::new();
	}

	let mut current = vec![value.clone()];
	let chars = path.chars().collect::<Vec<_>>();
	let mut index = 1;
	while let Some(character) = chars.get(index).copied() {
		match character {
			'.' => index += 1,
			'[' if chars.get(index + 1) == Some(&']') => {
				current = current
					.into_iter()
					.flat_map(|candidate| {
						match candidate {
							Value::Array(values) => values,
							_ => Vec::new(),
						}
					})
					.collect();
				index += 2;
			}
			'[' => {
				let Some(end) = chars
					.get(index..)
					.and_then(|remaining| remaining.iter().position(|character| *character == ']'))
				else {
					return Vec::new();
				};
				let end = index + end;
				let Some(array_index) = chars
					.get(index + 1..end)
					.and_then(|range| range.iter().collect::<String>().parse::<usize>().ok())
				else {
					return Vec::new();
				};
				current = current
					.into_iter()
					.filter_map(|candidate| {
						match candidate {
							Value::Array(values) => values.get(array_index).cloned(),
							_ => None,
						}
					})
					.collect();
				index = end + 1;
			}
			_ => {
				let start = index;
				while chars
					.get(index)
					.is_some_and(|character| is_field_character(*character))
				{
					index += 1;
				}
				if start == index {
					return Vec::new();
				}
				let field = chars
					.iter()
					.skip(start)
					.take(index - start)
					.collect::<String>();
				current = current
					.into_iter()
					.filter_map(|candidate| {
						match candidate {
							Value::Object(map) => map.get(&field).cloned(),
							_ => None,
						}
					})
					.collect();
			}
		}
	}
	current
}

fn is_field_character(character: char) -> bool {
	character.is_ascii_alphanumeric() || matches!(character, '_' | '-')
}

fn split_top_level(expression: &str, delimiter: char) -> Vec<&str> {
	let mut parts = Vec::new();
	let mut start = 0;
	let mut depth = 0usize;
	let mut in_string = false;
	let mut escaped = false;
	for (index, character) in expression.char_indices() {
		if in_string {
			if escaped {
				escaped = false;
			} else if character == '\\' {
				escaped = true;
			} else if character == '"' {
				in_string = false;
			}
			continue;
		}
		match character {
			'"' => in_string = true,
			'(' | '[' => depth += 1,
			')' | ']' => depth = depth.saturating_sub(1),
			_ if character == delimiter && depth == 0 => {
				parts.push(&expression[start..index]);
				start = index + character.len_utf8();
			}
			_ => {}
		}
	}
	parts.push(&expression[start..]);
	parts
}

fn split_top_level_and(expression: &str) -> Vec<&str> {
	let mut parts = Vec::new();
	let mut start = 0;
	let mut depth = 0usize;
	let mut in_string = false;
	let mut escaped = false;
	let bytes = expression.as_bytes();
	let mut index = 0;
	while index < bytes.len() {
		let character = expression[index..].chars().next().unwrap_or_default();
		if in_string {
			if escaped {
				escaped = false;
			} else if character == '\\' {
				escaped = true;
			} else if character == '"' {
				in_string = false;
			}
			index += character.len_utf8();
			continue;
		}
		match character {
			'"' => in_string = true,
			'(' | '[' => depth += 1,
			')' | ']' => depth = depth.saturating_sub(1),
			'a' if depth == 0 && expression[index..].starts_with("and") => {
				let before =
					index == 0 || bytes.get(index - 1).is_some_and(u8::is_ascii_whitespace);
				let after_index = index + 3;
				let after = after_index >= bytes.len()
					|| bytes.get(after_index).is_some_and(u8::is_ascii_whitespace);
				if before && after {
					parts.push(&expression[start..index]);
					start = after_index;
					index = after_index;
					continue;
				}
			}
			_ => {}
		}
		index += character.len_utf8();
	}
	parts.push(&expression[start..]);
	parts
}

fn find_top_level_operator(expression: &str, operator: &str) -> Option<usize> {
	let mut depth = 0usize;
	let mut in_string = false;
	let mut escaped = false;
	for (index, character) in expression.char_indices() {
		if in_string {
			if escaped {
				escaped = false;
			} else if character == '\\' {
				escaped = true;
			} else if character == '"' {
				in_string = false;
			}
			continue;
		}
		match character {
			'"' => in_string = true,
			'(' | '[' => depth += 1,
			')' | ']' => depth = depth.saturating_sub(1),
			_ if depth == 0 && expression[index..].starts_with(operator) => return Some(index),
			_ => {}
		}
	}
	None
}

fn truthy(value: &Value) -> bool {
	!matches!(value, Value::Null | Value::Bool(false))
}

fn render_values(values: &[Value]) -> String {
	let mut rendered = String::new();
	for (index, value) in values.iter().enumerate() {
		if index > 0 {
			rendered.push('\n');
		}
		write_value(&mut rendered, value);
	}
	rendered
}

fn write_value(output: &mut String, value: &Value) {
	match value {
		Value::Null => output.push_str("null"),
		Value::Bool(value) => {
			let _ = write!(output, "{value}");
		}
		Value::Number(value) => {
			let _ = write!(output, "{value}");
		}
		Value::String(value) => output.push_str(value),
		Value::Array(_) | Value::Object(_) => {
			let _ = write!(output, "{value}");
		}
	}
}

#[cfg(test)]
#[path = "__tests__/jq_filter_tests.rs"]
mod tests;