chore(release): prepare public source release
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "common"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
authors.workspace = true
|
||||
readme.workspace = true
|
||||
publish.workspace = true
|
||||
description = "Shared CLI runtime helpers for the AI-friendly CLI toolbox."
|
||||
keywords.workspace = true
|
||||
categories.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
walkdir.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod ison;
|
||||
pub mod tonl;
|
||||
pub mod toon;
|
||||
pub mod zon;
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Shared ISON/ISONL v1 helpers for compact JSON-family records.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use serde_json::{Map, Number, Value};
|
||||
|
||||
use crate::CliError;
|
||||
|
||||
const RECORD_SEGMENT_ERROR: &str = "ISONL record expects 3 pipe-delimited segments";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FieldType {
|
||||
Int,
|
||||
Float,
|
||||
Bool,
|
||||
Str,
|
||||
Null,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Field {
|
||||
name: String,
|
||||
kind: FieldType,
|
||||
}
|
||||
|
||||
/// Encodes JSON object records into newline-delimited ISONL text.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CliError::Usage`] when any record is not an object or has no fields.
|
||||
pub fn encode_records(records: &[Value], record_name: &str) -> Result<String, CliError> {
|
||||
let mut output = String::with_capacity(records.len().saturating_mul(96));
|
||||
for record in records {
|
||||
output.push_str(&encode_record(record, record_name)?);
|
||||
output.push('\n');
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Encodes one JSON object into an ISONL record line.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CliError::Usage`] when `record` is not an object or has no fields.
|
||||
pub fn encode_record(record: &Value, record_name: &str) -> Result<String, CliError> {
|
||||
let object = record
|
||||
.as_object()
|
||||
.ok_or_else(|| CliError::usage("ISONL v1 expects each record to be a JSON object"))?;
|
||||
if object.is_empty() {
|
||||
return Err(CliError::usage(
|
||||
"ISONL v1 cannot infer fields from an empty object",
|
||||
));
|
||||
}
|
||||
let fields = infer_fields(object);
|
||||
let mut output =
|
||||
String::with_capacity(record_name.len() + fields.len().saturating_mul(24) + 16);
|
||||
output.push_str("object.");
|
||||
output.push_str(record_name);
|
||||
output.push('|');
|
||||
push_fields(&mut output, &fields);
|
||||
output.push('|');
|
||||
push_row(&mut output, object, &fields);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Decodes one ISONL record line into a JSON object.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CliError::Usage`] when the line is malformed.
|
||||
pub fn decode_record_line(line: &str, line_number: usize) -> Result<Value, CliError> {
|
||||
let (header, fields_text, row_text) = split_record_line(line, line_number)?;
|
||||
parse_header(header.trim(), line_number)?;
|
||||
let fields = parse_fields(fields_text.trim(), line_number)?;
|
||||
let row = parse_row(row_text.trim(), line_number, &fields)?;
|
||||
Ok(Value::Object(row))
|
||||
}
|
||||
|
||||
/// Decodes newline-delimited ISONL records into JSON objects.
|
||||
///
|
||||
/// Reuses the previous field schema when consecutive records share the same
|
||||
/// header and field definition, which is the common JSONL-style stream shape.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CliError::Usage`] when any line is malformed.
|
||||
pub fn decode_records(input: &str) -> Result<Vec<Value>, CliError> {
|
||||
let mut records = Vec::new();
|
||||
let mut cached_header = "";
|
||||
let mut cached_fields_text = "";
|
||||
let mut cached_fields = Vec::new();
|
||||
|
||||
for (index, raw_line) in input.lines().enumerate() {
|
||||
let line_number = index + 1;
|
||||
let line = raw_line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let (header, fields_text, row_text) = split_record_line(line, line_number)?;
|
||||
if header != cached_header || fields_text != cached_fields_text {
|
||||
parse_header(header, line_number)?;
|
||||
cached_fields = parse_fields(fields_text, line_number)?;
|
||||
cached_header = header;
|
||||
cached_fields_text = fields_text;
|
||||
}
|
||||
|
||||
records.push(Value::Object(parse_row(
|
||||
row_text,
|
||||
line_number,
|
||||
&cached_fields,
|
||||
)?));
|
||||
}
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
fn split_record_line(line: &str, line_number: usize) -> Result<(&str, &str, &str), CliError> {
|
||||
let mut parts = line.splitn(3, '|');
|
||||
let Some(header) = parts.next() else {
|
||||
return Err(record_segment_error(line_number));
|
||||
};
|
||||
let Some(fields_text) = parts.next() else {
|
||||
return Err(record_segment_error(line_number));
|
||||
};
|
||||
let Some(row_text) = parts.next() else {
|
||||
return Err(record_segment_error(line_number));
|
||||
};
|
||||
Ok((header.trim(), fields_text.trim(), row_text.trim()))
|
||||
}
|
||||
|
||||
fn record_segment_error(line_number: usize) -> CliError {
|
||||
CliError::usage(format!("line {line_number}: {RECORD_SEGMENT_ERROR}"))
|
||||
}
|
||||
|
||||
fn infer_fields(object: &Map<String, Value>) -> Vec<Field> {
|
||||
object
|
||||
.iter()
|
||||
.map(|(name, value)| Field {
|
||||
name: name.clone(),
|
||||
kind: infer_type(value),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn infer_type(value: &Value) -> FieldType {
|
||||
match value {
|
||||
Value::Bool(_) => FieldType::Bool,
|
||||
Value::Number(number) if number.is_i64() || number.is_u64() => FieldType::Int,
|
||||
Value::Number(_) => FieldType::Float,
|
||||
Value::String(_) | Value::Array(_) | Value::Object(_) => FieldType::Str,
|
||||
Value::Null => FieldType::Null,
|
||||
}
|
||||
}
|
||||
|
||||
fn push_fields(output: &mut String, fields: &[Field]) {
|
||||
for (index, field) in fields.iter().enumerate() {
|
||||
if index > 0 {
|
||||
output.push(' ');
|
||||
}
|
||||
output.push_str(&field.name);
|
||||
output.push(':');
|
||||
output.push_str(field.kind.as_str());
|
||||
}
|
||||
}
|
||||
|
||||
fn push_row(output: &mut String, object: &Map<String, Value>, fields: &[Field]) {
|
||||
for (index, field) in fields.iter().enumerate() {
|
||||
if index > 0 {
|
||||
output.push(' ');
|
||||
}
|
||||
push_value(output, object.get(&field.name).unwrap_or(&Value::Null));
|
||||
}
|
||||
}
|
||||
|
||||
fn push_value(output: &mut String, value: &Value) {
|
||||
match value {
|
||||
Value::Null => output.push_str("null"),
|
||||
Value::Bool(value) => output.push_str(if *value { "true" } else { "false" }),
|
||||
Value::Number(value) => {
|
||||
let _ = write!(output, "{value}");
|
||||
}
|
||||
Value::String(value) => push_string(output, value),
|
||||
Value::Array(_) | Value::Object(_) => push_string(output, &value.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn push_string(output: &mut String, value: &str) {
|
||||
if value.is_empty()
|
||||
|| value
|
||||
.chars()
|
||||
.any(|character| character.is_whitespace() || matches!(character, '"' | '\\' | '|'))
|
||||
{
|
||||
output.push_str(&serde_json::to_string(value).unwrap_or_else(|_| "\"\"".to_string()));
|
||||
} else {
|
||||
output.push_str(value);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_header(line: &str, line_number: usize) -> Result<(), CliError> {
|
||||
let Some((kind, name)) = line.split_once('.') else {
|
||||
return Err(CliError::usage(format!(
|
||||
"line {line_number}: expected block header kind.name"
|
||||
)));
|
||||
};
|
||||
if kind != "object" || name.is_empty() {
|
||||
return Err(CliError::usage(format!(
|
||||
"line {line_number}: expected object.<name> header"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_fields(line: &str, line_number: usize) -> Result<Vec<Field>, CliError> {
|
||||
let fields = line
|
||||
.split_whitespace()
|
||||
.map(|token| parse_field(token, line_number))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
if fields.is_empty() {
|
||||
return Err(CliError::usage(format!(
|
||||
"line {line_number}: field definition is empty"
|
||||
)));
|
||||
}
|
||||
Ok(fields)
|
||||
}
|
||||
|
||||
fn parse_field(token: &str, line_number: usize) -> Result<Field, CliError> {
|
||||
let Some((name, kind)) = token.split_once(':') else {
|
||||
return Err(CliError::usage(format!(
|
||||
"line {line_number}: field '{token}' must be name:type"
|
||||
)));
|
||||
};
|
||||
if name.is_empty() {
|
||||
return Err(CliError::usage(format!(
|
||||
"line {line_number}: field name must not be empty"
|
||||
)));
|
||||
}
|
||||
Ok(Field {
|
||||
name: name.to_string(),
|
||||
kind: parse_field_type(kind, line_number)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_field_type(kind: &str, line_number: usize) -> Result<FieldType, CliError> {
|
||||
match kind {
|
||||
"int" => Ok(FieldType::Int),
|
||||
"float" => Ok(FieldType::Float),
|
||||
"bool" => Ok(FieldType::Bool),
|
||||
"str" => Ok(FieldType::Str),
|
||||
"null" => Ok(FieldType::Null),
|
||||
other => Err(CliError::usage(format!(
|
||||
"line {line_number}: unknown field type '{other}'"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_row(
|
||||
line: &str,
|
||||
line_number: usize,
|
||||
fields: &[Field],
|
||||
) -> Result<Map<String, Value>, CliError> {
|
||||
let values = split_row(line)
|
||||
.map_err(|message| CliError::usage(format!("line {line_number}: {message}")))?;
|
||||
if values.len() != fields.len() {
|
||||
return Err(CliError::usage(format!(
|
||||
"line {line_number}: expected {} values, got {}",
|
||||
fields.len(),
|
||||
values.len()
|
||||
)));
|
||||
}
|
||||
fields
|
||||
.iter()
|
||||
.zip(values)
|
||||
.map(|(field, raw)| {
|
||||
parse_value(raw, field.kind, line_number).map(|value| (field.name.clone(), value))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn split_row(line: &str) -> Result<Vec<&str>, String> {
|
||||
let mut values = Vec::new();
|
||||
let mut token_start = None;
|
||||
let mut in_string = false;
|
||||
let mut escaped = false;
|
||||
for (index, character) in line.char_indices() {
|
||||
if in_string {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
} else if character == '\\' {
|
||||
escaped = true;
|
||||
} else if character == '"' {
|
||||
in_string = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if character == '"' {
|
||||
in_string = true;
|
||||
token_start.get_or_insert(index);
|
||||
} else if character.is_whitespace() {
|
||||
if let Some(start) = token_start.take() {
|
||||
values.push(&line[start..index]);
|
||||
}
|
||||
} else {
|
||||
token_start.get_or_insert(index);
|
||||
}
|
||||
}
|
||||
if in_string {
|
||||
return Err("unterminated quoted string".to_string());
|
||||
}
|
||||
if let Some(start) = token_start {
|
||||
values.push(&line[start..]);
|
||||
}
|
||||
Ok(values)
|
||||
}
|
||||
|
||||
fn parse_value(raw: &str, kind: FieldType, line_number: usize) -> Result<Value, CliError> {
|
||||
match kind {
|
||||
FieldType::Int => parse_number_value(raw, line_number),
|
||||
FieldType::Float => raw
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.and_then(serde_json::Number::from_f64)
|
||||
.map(Value::Number)
|
||||
.ok_or_else(|| CliError::usage(format!("line {line_number}: invalid float '{raw}'"))),
|
||||
FieldType::Bool => raw.parse::<bool>().map(Value::Bool).map_err(|error| {
|
||||
CliError::usage(format!("line {line_number}: invalid bool '{raw}': {error}"))
|
||||
}),
|
||||
FieldType::Str => {
|
||||
if raw.starts_with('"') {
|
||||
serde_json::from_str::<String>(raw)
|
||||
.map(Value::String)
|
||||
.map_err(|error| {
|
||||
CliError::usage(format!("line {line_number}: invalid string: {error}"))
|
||||
})
|
||||
} else {
|
||||
Ok(Value::String(raw.to_string()))
|
||||
}
|
||||
}
|
||||
FieldType::Null => Ok(Value::Null),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_number_value(raw: &str, line_number: usize) -> Result<Value, CliError> {
|
||||
if let Ok(value) = raw.parse::<i64>() {
|
||||
return Ok(Value::Number(Number::from(value)));
|
||||
}
|
||||
raw.parse::<u64>()
|
||||
.map(Number::from)
|
||||
.map(Value::Number)
|
||||
.map_err(|error| {
|
||||
CliError::usage(format!("line {line_number}: invalid int '{raw}': {error}"))
|
||||
})
|
||||
}
|
||||
|
||||
impl FieldType {
|
||||
const fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Int => "int",
|
||||
Self::Float => "float",
|
||||
Self::Bool => "bool",
|
||||
Self::Str => "str",
|
||||
Self::Null => "null",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Shared TONL v1 helpers for JSON-backed key/value documents.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::CliError;
|
||||
|
||||
/// Encodes JSON records into TONL documents.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CliError::Runtime`] when a JSON value cannot be rendered.
|
||||
pub fn encode_documents(records: &[Value]) -> Result<String, CliError> {
|
||||
let mut output = String::with_capacity(records.len().saturating_mul(96));
|
||||
for (index, record) in records.iter().enumerate() {
|
||||
if records.len() > 1 {
|
||||
output.push_str("---\n");
|
||||
} else if index > 0 {
|
||||
output.push('\n');
|
||||
}
|
||||
push_document(&mut output, record)?;
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Decodes TONL documents into JSON records.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CliError::Usage`] when a line is malformed or a value is not JSON.
|
||||
pub fn decode_documents(content: &str) -> Result<Vec<Value>, CliError> {
|
||||
let mut records = Vec::new();
|
||||
let mut current = Map::new();
|
||||
let mut root_value = None;
|
||||
for (index, line) in content.lines().enumerate() {
|
||||
let line_number = index + 1;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if trimmed == "---" {
|
||||
finish_record(&mut records, &mut current, &mut root_value);
|
||||
continue;
|
||||
}
|
||||
let (key, raw_value) = trimmed.split_once('=').ok_or_else(|| {
|
||||
CliError::usage(format!(
|
||||
"line {line_number}: expected 'key = <valid JSON value>'"
|
||||
))
|
||||
})?;
|
||||
let key = key.trim();
|
||||
if key.is_empty() {
|
||||
return Err(CliError::usage(format!(
|
||||
"line {line_number}: key cannot be empty"
|
||||
)));
|
||||
}
|
||||
let value = serde_json::from_str::<Value>(raw_value.trim()).map_err(|error| {
|
||||
CliError::usage(format!(
|
||||
"line {line_number}: value must be a valid JSON value: {error}"
|
||||
))
|
||||
})?;
|
||||
if key == "$" {
|
||||
if !current.is_empty() {
|
||||
return Err(CliError::usage(format!(
|
||||
"line {line_number}: '$' root value cannot be mixed with object fields"
|
||||
)));
|
||||
}
|
||||
root_value = Some(value);
|
||||
} else {
|
||||
if root_value.is_some() {
|
||||
return Err(CliError::usage(format!(
|
||||
"line {line_number}: object fields cannot be mixed with '$' root value"
|
||||
)));
|
||||
}
|
||||
current.insert(key.to_string(), value);
|
||||
}
|
||||
}
|
||||
finish_record(&mut records, &mut current, &mut root_value);
|
||||
if records.is_empty() {
|
||||
return Err(CliError::usage("no TONL records found"));
|
||||
}
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
fn push_document(output: &mut String, record: &Value) -> Result<(), CliError> {
|
||||
match record {
|
||||
Value::Object(object) => {
|
||||
for (key, value) in object {
|
||||
output.push_str(key);
|
||||
output.push_str(" = ");
|
||||
output.push_str(&compact_json(value)?);
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
value => {
|
||||
output.push_str("$ = ");
|
||||
output.push_str(&compact_json(value)?);
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish_record(
|
||||
records: &mut Vec<Value>,
|
||||
current: &mut Map<String, Value>,
|
||||
root_value: &mut Option<Value>,
|
||||
) {
|
||||
if let Some(value) = root_value.take() {
|
||||
records.push(value);
|
||||
} else if !current.is_empty() {
|
||||
records.push(Value::Object(std::mem::take(current)));
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_json(value: &Value) -> Result<String, CliError> {
|
||||
serde_json::to_string(value)
|
||||
.map_err(|error| CliError::runtime(format!("failed to render json: {error}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{decode_documents, encode_documents};
|
||||
|
||||
#[test]
|
||||
fn shared_tonl_roundtrips_objects_and_root_values() {
|
||||
let records = vec![json!({"id": "a", "ok": true}), json!([1, 2])];
|
||||
let encoded = encode_documents(&records).expect("encode tonl");
|
||||
|
||||
assert!(encoded.contains("---\nid = \"a\"\nok = true\n"));
|
||||
assert!(encoded.contains("---\n$ = [1,2]\n"));
|
||||
assert_eq!(decode_documents(&encoded).expect("decode tonl"), records);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_tonl_reports_malformed_inputs() {
|
||||
assert!(decode_documents("\n# comment\n").is_err());
|
||||
assert!(decode_documents("missing separator\n").is_err());
|
||||
assert!(decode_documents(" = 1\n").is_err());
|
||||
assert!(decode_documents("id = not-json\n").is_err());
|
||||
assert!(decode_documents("id = 1\n$ = 2\n").is_err());
|
||||
assert!(decode_documents("$ = 1\nid = 2\n").is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
//! Shared Zero Overhead Notation (ZON) v1 helpers.
|
||||
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::CliError;
|
||||
|
||||
/// Encodes a JSON value into the Mercury ZON v1 subset.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CliError::Runtime`] when a JSON value cannot be rendered.
|
||||
pub fn encode_value(value: &Value) -> Result<String, CliError> {
|
||||
let mut output = String::with_capacity(estimate_capacity(value));
|
||||
match value {
|
||||
Value::Object(object) => encode_object(object, &mut output)?,
|
||||
primitive => {
|
||||
output.push_str(&render_inline_value(primitive)?);
|
||||
output.push('\n');
|
||||
}
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn estimate_capacity(value: &Value) -> usize {
|
||||
match value {
|
||||
Value::Object(object) => object.len().saturating_mul(64),
|
||||
Value::Array(items) => items.len().saturating_mul(48),
|
||||
Value::String(text) => text.len() + 8,
|
||||
Value::Null | Value::Bool(_) | Value::Number(_) => 16,
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes the Mercury ZON v1 subset into JSON.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns [`CliError::Usage`] when a line is malformed.
|
||||
pub fn decode_str(input: &str) -> Result<Value, CliError> {
|
||||
let mut object = Map::new();
|
||||
let mut scalar = None;
|
||||
for (index, line) in input.lines().enumerate() {
|
||||
let line_number = index + 1;
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some((key, raw_value)) = trimmed.split_once(':') {
|
||||
if key.is_empty() {
|
||||
return Err(CliError::usage(format!(
|
||||
"line {line_number}: key cannot be empty"
|
||||
)));
|
||||
}
|
||||
object.insert(
|
||||
key.to_string(),
|
||||
parse_inline_value(raw_value.trim(), line_number)?,
|
||||
);
|
||||
} else if scalar
|
||||
.replace(parse_inline_value(trimmed, line_number)?)
|
||||
.is_some()
|
||||
{
|
||||
return Err(CliError::usage(format!(
|
||||
"line {line_number}: multiple scalar ZON values are not supported"
|
||||
)));
|
||||
}
|
||||
}
|
||||
if object.is_empty() {
|
||||
scalar.ok_or_else(|| CliError::usage("ZON input is empty"))
|
||||
} else {
|
||||
Ok(Value::Object(object))
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_object(object: &Map<String, Value>, output: &mut String) -> Result<(), CliError> {
|
||||
for (key, value) in object {
|
||||
output.push_str(key);
|
||||
output.push(':');
|
||||
output.push_str(&render_inline_value(value)?);
|
||||
output.push('\n');
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_inline_value(value: &Value) -> Result<String, CliError> {
|
||||
serde_json::to_string(value)
|
||||
.map_err(|error| CliError::runtime(format!("failed to render ZON inline value: {error}")))
|
||||
}
|
||||
|
||||
fn parse_inline_value(raw: &str, line_number: usize) -> Result<Value, CliError> {
|
||||
if raw.is_empty() {
|
||||
return Ok(Value::String(String::new()));
|
||||
}
|
||||
if let Some(value) = parse_keyword_value(raw) {
|
||||
return Ok(value);
|
||||
}
|
||||
if raw.starts_with(['"', '[', '{']) || looks_like_number(raw) {
|
||||
return serde_json::from_str::<Value>(raw).map_err(|error| {
|
||||
CliError::usage(format!(
|
||||
"line {line_number}: invalid inline JSON value: {error}"
|
||||
))
|
||||
});
|
||||
}
|
||||
Ok(Value::String(raw.to_string()))
|
||||
}
|
||||
|
||||
fn parse_keyword_value(raw: &str) -> Option<Value> {
|
||||
match raw {
|
||||
"true" => Some(Value::Bool(true)),
|
||||
"false" => Some(Value::Bool(false)),
|
||||
"null" => Some(Value::Null),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_number(value: &str) -> bool {
|
||||
value
|
||||
.as_bytes()
|
||||
.first()
|
||||
.is_some_and(|byte| matches!(byte, b'-' | b'0'..=b'9'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn encodes_and_decodes_object_and_scalar_values() {
|
||||
let object = json!({
|
||||
"active": true,
|
||||
"count": 3,
|
||||
"name": "mercury",
|
||||
"notes": "",
|
||||
"tags": ["fast", "portable"],
|
||||
});
|
||||
|
||||
let encoded = encode_value(&object).expect("encoded object");
|
||||
let decoded = decode_str(&encoded).expect("decoded object");
|
||||
|
||||
assert_eq!(decoded, object);
|
||||
assert_eq!(decode_str("null\n").expect("null scalar"), Value::Null);
|
||||
assert_eq!(
|
||||
decode_str("plain-text\n").expect("bare string"),
|
||||
json!("plain-text")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_malformed_zon_inputs() {
|
||||
let empty_key = decode_str(":true\n").expect_err("empty key");
|
||||
assert!(matches!(
|
||||
empty_key,
|
||||
CliError::Usage(message) if message.contains("key cannot be empty")
|
||||
));
|
||||
|
||||
let multiple_scalars = decode_str("1\n2\n").expect_err("multiple scalars");
|
||||
assert!(matches!(
|
||||
multiple_scalars,
|
||||
CliError::Usage(message) if message.contains("multiple scalar ZON values")
|
||||
));
|
||||
|
||||
let invalid_inline = decode_str("value:[1,,2]\n").expect_err("invalid inline JSON");
|
||||
assert!(matches!(
|
||||
invalid_inline,
|
||||
CliError::Usage(message) if message.contains("invalid inline JSON value")
|
||||
));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,276 @@
|
||||
//! Tests for the generated Mercury Toolbox AI assets.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.canonicalize()
|
||||
.expect("workspace root")
|
||||
}
|
||||
|
||||
fn workspace_path(root: &Path, relative_path: &[&str]) -> PathBuf {
|
||||
relative_path
|
||||
.iter()
|
||||
.fold(root.to_path_buf(), |path, component| path.join(component))
|
||||
}
|
||||
|
||||
fn read_workspace_text(root: &Path, relative_path: &[&str], label: &str) -> String {
|
||||
fs::read_to_string(workspace_path(root, relative_path))
|
||||
.unwrap_or_else(|error| panic!("failed to read {label}: {error}"))
|
||||
}
|
||||
|
||||
fn toolbox_commands(root: &Path) -> Vec<String> {
|
||||
let commands = read_workspace_text(
|
||||
root,
|
||||
&["scripts", "toolbox-commands.ps1"],
|
||||
"scripts/toolbox-commands.ps1",
|
||||
);
|
||||
let mut in_command_list = false;
|
||||
let mut consumed_command_list = false;
|
||||
let mut names = commands
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == "return @(" && !in_command_list && !consumed_command_list {
|
||||
in_command_list = true;
|
||||
return None;
|
||||
}
|
||||
if in_command_list && trimmed == ")" {
|
||||
in_command_list = false;
|
||||
consumed_command_list = true;
|
||||
return None;
|
||||
}
|
||||
if !in_command_list {
|
||||
return None;
|
||||
}
|
||||
trimmed
|
||||
.strip_prefix('\'')
|
||||
.and_then(|rest| rest.split_once('\''))
|
||||
.map(|(name, _)| name.to_string())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
|
||||
fn assert_lf_only(root: &Path, relative_path: &[&str]) {
|
||||
let path = workspace_path(root, relative_path);
|
||||
let bytes = fs::read(&path).unwrap_or_else(|error| panic!("read {}: {error}", path.display()));
|
||||
assert!(
|
||||
!bytes.windows(2).any(|window| window == b"\r\n"),
|
||||
"{} should use LF line endings",
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_prompt_assets_exist_and_cover_every_tool() {
|
||||
let root = workspace_root();
|
||||
let prompt = read_workspace_text(
|
||||
&root,
|
||||
&["docs", "ai", "mercury-toolbox-ai-prompt.md"],
|
||||
"generated AI prompt",
|
||||
);
|
||||
let notes = read_workspace_text(
|
||||
&root,
|
||||
&["docs", "ai", "toolbox-ai-prompt-notes.json"],
|
||||
"AI prompt notes",
|
||||
);
|
||||
let commands = toolbox_commands(&root);
|
||||
|
||||
assert!(prompt.contains("Mercury Toolbox"));
|
||||
assert!(prompt.contains("PowerShell"));
|
||||
assert!(prompt.contains("`--json`"));
|
||||
assert!(prompt.contains("`--toon`"));
|
||||
assert!(prompt.contains("Available tools:"));
|
||||
assert!(prompt.contains("Rules:"));
|
||||
assert!(prompt.contains("Pipe external JSON into `toon`"));
|
||||
assert!(prompt.contains("Every tool has guided triage metadata"));
|
||||
assert!(prompt.contains("Guided: answer="));
|
||||
assert!(prompt.contains("`report_quality`"));
|
||||
assert!(prompt.contains("`next_actions`"));
|
||||
assert!(!prompt.contains("toon --from json --to toon"));
|
||||
assert!(prompt.contains("Usage: `"));
|
||||
assert!(prompt.contains("Example:"));
|
||||
assert!(prompt.contains("msudo:"));
|
||||
assert!(prompt.contains("Top-level high-risk command"));
|
||||
assert!(prompt.contains(
|
||||
"msudo status --json | ConvertFrom-Json | Select-Object ok,host,supports_runas,is_elevated"
|
||||
));
|
||||
assert!(!prompt.contains("## Tool Catalog"));
|
||||
assert!(!prompt.contains("### `"));
|
||||
assert!(!prompt.contains("$Fence"));
|
||||
assert!(
|
||||
prompt.lines().count() <= 120,
|
||||
"prompt should stay compact for AI consumption"
|
||||
);
|
||||
|
||||
assert_eq!(commands.len(), 59, "toolbox command inventory changed");
|
||||
let notes_json = serde_json::from_str::<Value>(¬es).expect("AI prompt notes JSON");
|
||||
let tools = notes_json["tools"].as_object().expect("notes tools object");
|
||||
|
||||
for command in commands {
|
||||
assert!(
|
||||
prompt.contains(&format!("{command}:")),
|
||||
"prompt should contain {command}"
|
||||
);
|
||||
assert!(
|
||||
notes.contains(&format!("\"{command}\"")),
|
||||
"notes should contain {command}"
|
||||
);
|
||||
let guided = tools
|
||||
.get(&command)
|
||||
.and_then(|tool| tool.get("guided_triage"))
|
||||
.unwrap_or_else(|| panic!("notes should contain guided_triage for {command}"));
|
||||
assert!(
|
||||
guided["answer"]
|
||||
.as_str()
|
||||
.is_some_and(|value| !value.is_empty()),
|
||||
"guided_triage.answer should be non-empty for {command}"
|
||||
);
|
||||
assert!(
|
||||
guided["trust"]
|
||||
.as_str()
|
||||
.is_some_and(|value| !value.is_empty()),
|
||||
"guided_triage.trust should be non-empty for {command}"
|
||||
);
|
||||
assert!(
|
||||
guided["next_actions"]
|
||||
.as_array()
|
||||
.is_some_and(|items| items.len() >= 2
|
||||
&& items
|
||||
.iter()
|
||||
.all(|item| item.as_str().is_some_and(|value| !value.is_empty()))),
|
||||
"guided_triage.next_actions should list at least two actions for {command}"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(
|
||||
notes.contains("msudo status --json | ConvertFrom-Json | Select-Object ok,host,supports_runas,is_elevated"),
|
||||
"notes should document the stable msudo status discovery fields"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_skill_assets_exist_and_cover_every_tool() {
|
||||
let root = workspace_root();
|
||||
let skill = read_workspace_text(
|
||||
&root,
|
||||
&["skills", "mercury-toolbox", "SKILL.md"],
|
||||
"generated skill",
|
||||
);
|
||||
let catalog = read_workspace_text(
|
||||
&root,
|
||||
&[
|
||||
"skills",
|
||||
"mercury-toolbox",
|
||||
"references",
|
||||
"command-catalog.md",
|
||||
],
|
||||
"generated skill catalog",
|
||||
);
|
||||
let openai_yaml = read_workspace_text(
|
||||
&root,
|
||||
&["skills", "mercury-toolbox", "agents", "openai.yaml"],
|
||||
"generated openai.yaml",
|
||||
);
|
||||
|
||||
assert!(skill.contains("Mercury Toolbox"));
|
||||
assert!(skill.contains("Prefer Mercury readers over `Get-Content`"));
|
||||
assert!(skill.contains("## Modern Pairings"));
|
||||
assert!(skill.contains("## Job Routing"));
|
||||
assert!(skill.contains("Windows driver: start with `drvshape <SYS>`"));
|
||||
assert!(skill.contains("`report_quality` and `next_actions`"));
|
||||
assert!(skill.contains("Use `rg` over recursive `grep`"));
|
||||
assert!(skill.contains("Treat `msudo` as the top-level high-risk toolbox command"));
|
||||
assert!(skill.contains("`msudo status --json`"));
|
||||
assert!(skill.contains("references/command-catalog.md"));
|
||||
assert!(skill.contains("switch to `--toon` or `--format toon`"));
|
||||
assert!(!skill.contains("toon --from json --to toon"));
|
||||
assert!(skill.lines().count() <= 95, "skill should stay concise");
|
||||
|
||||
assert!(catalog.contains("# Mercury Toolbox Command Catalog"));
|
||||
assert!(catalog.contains("Keep output compact"));
|
||||
assert!(catalog.contains("TOON example:"));
|
||||
assert!(catalog.contains("Guided answer:"));
|
||||
assert!(catalog.contains("Trust basis:"));
|
||||
assert!(catalog.contains("Next actions:"));
|
||||
assert!(catalog.contains("--toon"));
|
||||
assert!(!catalog.contains("toon --from json --to toon"));
|
||||
assert!(catalog.contains("### `msudo`"));
|
||||
assert!(catalog.contains("Top-level high-risk command"));
|
||||
assert!(catalog.contains(
|
||||
"msudo status --json | ConvertFrom-Json | Select-Object ok,host,supports_runas,is_elevated"
|
||||
));
|
||||
assert!(
|
||||
catalog.lines().count() <= 520,
|
||||
"catalog should stay compact"
|
||||
);
|
||||
|
||||
assert!(openai_yaml.contains("display_name: \"Mercury Toolbox\""));
|
||||
assert!(openai_yaml.contains("icon_small: \"./assets/logo.png\""));
|
||||
assert!(openai_yaml.contains("icon_large: \"./assets/logo.png\""));
|
||||
assert!(openai_yaml.contains("brand_color: \"#35C2FF\""));
|
||||
assert!(openai_yaml.contains("default_prompt: \"Use $mercury-toolbox first"));
|
||||
assert!(
|
||||
root.join("skills")
|
||||
.join("mercury-toolbox")
|
||||
.join("assets")
|
||||
.join("logo.png")
|
||||
.is_file(),
|
||||
"generated skill should include its logo asset"
|
||||
);
|
||||
|
||||
for command in toolbox_commands(&root) {
|
||||
assert!(
|
||||
catalog.contains(&format!("### `{command}`")),
|
||||
"catalog should contain {command}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_ai_markdown_assets_use_lf_line_endings() {
|
||||
let root = workspace_root();
|
||||
assert_lf_only(&root, &["docs", "ai", "mercury-toolbox-ai-prompt.md"]);
|
||||
assert_lf_only(&root, &["skills", "mercury-toolbox", "SKILL.md"]);
|
||||
assert_lf_only(
|
||||
&root,
|
||||
&[
|
||||
"skills",
|
||||
"mercury-toolbox",
|
||||
"references",
|
||||
"command-catalog.md",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readme_tool_map_covers_every_toolbox_command() {
|
||||
let root = workspace_root();
|
||||
let readme = read_workspace_text(&root, &["README.md"], "README.md");
|
||||
|
||||
assert!(readme.contains("### Tool Map"));
|
||||
assert!(readme.contains("## Which Tool First"));
|
||||
assert!(readme.contains("Safe starter commands"));
|
||||
assert!(readme.contains("Every command has guided triage notes"));
|
||||
assert!(readme.contains("`report_quality` and `next_actions`"));
|
||||
assert!(readme.contains("Every command supports `--help`"));
|
||||
for command in toolbox_commands(&root) {
|
||||
assert!(
|
||||
readme.contains(&format!("| `{command}` |")),
|
||||
"README tool map should contain {command}"
|
||||
);
|
||||
}
|
||||
|
||||
assert!(readme.contains("### `asmflow`"));
|
||||
assert!(readme.contains("### `unityasset`"));
|
||||
assert!(readme.contains("### `unityprobe`"));
|
||||
assert!(readme.contains("### `unitydiag`"));
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Contract tests for shared CLI helpers.
|
||||
|
||||
use common::{
|
||||
CliError, ColorChoice, CommonArgs, ExitCode, InputFormat, RenderMode, emit_json,
|
||||
emit_structured, formats, map_result_count, parse_color_choice, parse_format_choice,
|
||||
parse_input_format, should_read_stdin,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
#[test]
|
||||
fn parses_shared_choice_values() {
|
||||
let parsed = CommonArgs {
|
||||
json: true,
|
||||
format: None,
|
||||
input_format: parse_input_format("jsonl").expect("input format"),
|
||||
color: parse_color_choice("never").expect("color choice"),
|
||||
quiet: false,
|
||||
};
|
||||
|
||||
assert!(parsed.json);
|
||||
assert_eq!(parsed.input_format, InputFormat::Jsonl);
|
||||
assert_eq!(parsed.color, ColorChoice::Never);
|
||||
assert_eq!(parsed.render_mode(), RenderMode::Json);
|
||||
assert_eq!(
|
||||
parse_format_choice("toon").expect("format choice"),
|
||||
RenderMode::Toon
|
||||
);
|
||||
|
||||
let input_error = parse_input_format("yaml").expect_err("invalid input format");
|
||||
let color_error = parse_color_choice("always").expect_err("invalid color choice");
|
||||
let format_error = parse_format_choice("yaml").expect_err("invalid format choice");
|
||||
assert!(matches!(input_error, CliError::Usage(_)));
|
||||
assert!(matches!(color_error, CliError::Usage(_)));
|
||||
assert!(matches!(format_error, CliError::Usage(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_stdin_reads_only_without_explicit_input_and_when_not_terminal() {
|
||||
assert!(should_read_stdin(false, false));
|
||||
assert!(!should_read_stdin(false, true));
|
||||
assert!(!should_read_stdin(true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_result_count_to_exit_code() {
|
||||
assert_eq!(map_result_count(1), ExitCode::Success);
|
||||
assert_eq!(map_result_count(0), ExitCode::NoResults);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_single_json_document() {
|
||||
#[derive(Serialize)]
|
||||
struct Demo<'a> {
|
||||
name: &'a str,
|
||||
}
|
||||
|
||||
let rendered = emit_json(&Demo { name: "jsonlgrep" }).expect("json output");
|
||||
|
||||
assert_eq!(rendered, "{\"name\":\"jsonlgrep\"}\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_structured_toon_document() {
|
||||
#[derive(Serialize)]
|
||||
struct Demo<'a> {
|
||||
name: &'a str,
|
||||
count: u8,
|
||||
}
|
||||
|
||||
let rendered = emit_structured(
|
||||
&Demo {
|
||||
name: "jsonlgrep",
|
||||
count: 2,
|
||||
},
|
||||
RenderMode::Toon,
|
||||
)
|
||||
.expect("toon output");
|
||||
|
||||
let lines = rendered.lines().collect::<Vec<_>>();
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert!(lines.contains(&"name: jsonlgrep"));
|
||||
assert!(lines.contains(&"count: 2"));
|
||||
assert!(rendered.ends_with('\n'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_toon_render_rejects_values_past_explicit_depth_limit() {
|
||||
let error = emit_structured(&deeply_nested_value(260), RenderMode::Toon)
|
||||
.expect_err("deep TOON encode should fail");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
CliError::Runtime(message) if message.contains("maximum TOON encoding depth")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exposes_json_family_format_modules() {
|
||||
let records = vec![json!({"id": "user-1", "active": true})];
|
||||
|
||||
let ison = formats::ison::encode_records(&records, "record").expect("ison records");
|
||||
assert!(ison.contains("object.record|"));
|
||||
assert!(ison.contains("id:str"));
|
||||
assert!(ison.contains("active:bool"));
|
||||
assert_eq!(
|
||||
formats::ison::decode_record_line(&ison, 1).expect("ison decode"),
|
||||
records[0]
|
||||
);
|
||||
|
||||
let zon = formats::zon::encode_value(&records[0]).expect("zon");
|
||||
assert!(zon.contains("id:\"user-1\""));
|
||||
assert_eq!(
|
||||
formats::zon::decode_str(&zon).expect("zon decode"),
|
||||
records[0]
|
||||
);
|
||||
|
||||
let tonl = formats::tonl::encode_documents(&records).expect("tonl");
|
||||
assert!(tonl.contains("id = \"user-1\""));
|
||||
assert_eq!(
|
||||
formats::tonl::decode_documents(&tonl).expect("tonl decode"),
|
||||
records
|
||||
);
|
||||
}
|
||||
|
||||
fn deeply_nested_value(depth: usize) -> Value {
|
||||
let mut value = json!(1);
|
||||
for index in 0..depth {
|
||||
let mut object = Map::new();
|
||||
object.insert(format!("k{index}"), value);
|
||||
value = Value::Object(object);
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isonl_roundtrips_pipe_inside_quoted_string() {
|
||||
let records = vec![json!({"message": "left|right", "ok": true})];
|
||||
|
||||
let ison = formats::ison::encode_records(&records, "record").expect("ison records");
|
||||
|
||||
assert_eq!(
|
||||
formats::ison::decode_record_line(ison.trim_end(), 1).expect("ison decode"),
|
||||
records[0]
|
||||
);
|
||||
assert_eq!(
|
||||
formats::ison::decode_records(&ison).expect("ison batch decode"),
|
||||
records
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
//! Workspace policy tests for Jade Discipline.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn workspace_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("..")
|
||||
.join("..")
|
||||
.canonicalize()
|
||||
.expect("workspace root")
|
||||
}
|
||||
|
||||
fn workspace_path(components: &[&str]) -> PathBuf {
|
||||
components
|
||||
.iter()
|
||||
.fold(workspace_root(), |path, component| path.join(component))
|
||||
}
|
||||
|
||||
fn script_path(script_name: &str) -> PathBuf {
|
||||
workspace_path(&["scripts", script_name])
|
||||
}
|
||||
|
||||
fn read_workspace_text(components: &[&str], label: &str) -> String {
|
||||
fs::read_to_string(workspace_path(components))
|
||||
.unwrap_or_else(|error| panic!("failed to read {label}: {error}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_cargo_toml_publishes_jade_lints_and_profiles() {
|
||||
let cargo_toml = read_workspace_text(&["Cargo.toml"], "Cargo.toml");
|
||||
|
||||
for required_line in [
|
||||
"rust-version = \"1.86\"",
|
||||
"missing_docs = \"deny\"",
|
||||
"pedantic = { level = \"deny\", priority = -3 }",
|
||||
"nursery = { level = \"deny\", priority = -2 }",
|
||||
"[profile.release-fast]",
|
||||
"lto = \"fat\"",
|
||||
"[profile.release-size]",
|
||||
"opt-level = \"z\"",
|
||||
] {
|
||||
assert!(
|
||||
cargo_toml.contains(required_line),
|
||||
"Cargo.toml should contain {required_line:?}"
|
||||
);
|
||||
}
|
||||
|
||||
for forbidden_line in [
|
||||
"missing_copy_implementations",
|
||||
"implicit_return",
|
||||
"missing_const_for_fn",
|
||||
"module_name_repetitions",
|
||||
"multiple_crate_versions",
|
||||
"must_use_candidate",
|
||||
"needless_pass_by_value",
|
||||
] {
|
||||
assert!(
|
||||
!cargo_toml.contains(forbidden_line),
|
||||
"Cargo.toml should not contain {forbidden_line:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cargo_config_and_jade_docs_exist() {
|
||||
let config = read_workspace_text(&[".cargo", "config.toml"], ".cargo/config.toml");
|
||||
assert!(config.contains("git-fetch-with-cli = true"));
|
||||
assert!(config.contains("rustflags = [\"-Dwarnings\"]"));
|
||||
assert!(config.contains("frequency = \"always\""));
|
||||
assert!(workspace_path(&["justfile"]).is_file(), "missing justfile");
|
||||
assert!(
|
||||
workspace_path(&["bacon.toml"]).is_file(),
|
||||
"missing bacon.toml"
|
||||
);
|
||||
|
||||
let docs = read_workspace_text(&["docs", "jade-discipline.md"], "jade discipline docs");
|
||||
let maintainer_notes =
|
||||
read_workspace_text(&["docs", "maintainer-notes.md"], "maintainer notes");
|
||||
assert!(docs.contains("Jade Discipline"));
|
||||
assert!(docs.contains("cargo clippy --all-targets --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery"));
|
||||
assert!(docs.contains("Miri, fuzzing, sanitizer, no-panic, and Loom checks are Jade gates"));
|
||||
assert!(docs.contains(
|
||||
"Missing tools, missing harnesses, or platform discomfort are failures by default"
|
||||
));
|
||||
assert!(docs.contains("Global `allow` is reserved for two cases only"));
|
||||
assert!(docs.contains("smallest code-local scope"));
|
||||
assert!(docs.contains("Install"));
|
||||
assert!(docs.contains("just"));
|
||||
assert!(docs.contains("bacon"));
|
||||
assert!(maintainer_notes.contains("Jade has no optional safety tier"));
|
||||
assert!(maintainer_notes.contains("Every JSON-capable Mercury tool"));
|
||||
assert!(maintainer_notes.contains("is the shared AST/indexing engine"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_packaging_scripts_and_docs_exist() {
|
||||
for script_name in [
|
||||
"package-toolbox.ps1",
|
||||
"install-package-toolbox.ps1",
|
||||
"uninstall-package-toolbox.ps1",
|
||||
"generate-ai-skill.ps1",
|
||||
"check-ai-skill.ps1",
|
||||
] {
|
||||
assert!(
|
||||
script_path(script_name).is_file(),
|
||||
"missing packaging script {script_name}"
|
||||
);
|
||||
}
|
||||
|
||||
let readme = read_workspace_text(&["README.md"], "README.md");
|
||||
assert!(readme.contains("just"));
|
||||
assert!(readme.contains("bacon"));
|
||||
assert!(readme.contains("## Portable Package"));
|
||||
assert!(readme.contains(r".\scripts\package-toolbox.ps1"));
|
||||
assert!(readme.contains("install-package-toolbox.ps1"));
|
||||
assert!(readme.contains("mercury-toolbox-package.json"));
|
||||
assert!(readme.contains("SHA256SUMS.txt"));
|
||||
assert!(readme.contains("generate-ai-skill.ps1"));
|
||||
assert!(readme.contains(r".\skills\mercury-toolbox\"));
|
||||
assert!(readme.contains("### `msudo`"));
|
||||
assert!(readme.contains("HIGH RISK"));
|
||||
assert!(readme.contains("top-level high-risk toolbox command"));
|
||||
assert!(readme.contains("msudo status --json"));
|
||||
assert!(readme.contains("Select-Object ok,host,supports_runas,is_elevated"));
|
||||
assert!(readme.contains("msudo --help"));
|
||||
assert!(readme.contains("msudo run --help"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn powershell_gate_assets_and_docs_exist() {
|
||||
let root = workspace_root();
|
||||
assert!(
|
||||
script_path("check-powershell.ps1").is_file(),
|
||||
"missing PowerShell gate script"
|
||||
);
|
||||
assert!(
|
||||
script_path("cargo-flamegraph-windows.ps1").is_file(),
|
||||
"missing Windows flamegraph wrapper script"
|
||||
);
|
||||
assert!(
|
||||
workspace_path(&["PSScriptAnalyzerSettings.psd1"]).is_file(),
|
||||
"missing PowerShell analyzer settings"
|
||||
);
|
||||
|
||||
let check_jade = read_workspace_text(&["scripts", "check-jade.ps1"], "scripts/check-jade.ps1");
|
||||
assert!(check_jade.contains("check-powershell.ps1"));
|
||||
assert!(check_jade.contains("check-ai-skill.ps1"));
|
||||
assert!(check_jade.contains("check-jade-hardening.ps1"));
|
||||
assert!(
|
||||
check_jade.contains("Invoke-TimedNativeWithEnvironment"),
|
||||
"Jade coverage gate should be able to isolate cargo-llvm-cov environment"
|
||||
);
|
||||
assert!(
|
||||
check_jade.contains("CARGO_INCREMENTAL") && check_jade.contains("RUSTC_WRAPPER"),
|
||||
"Jade coverage gate should disable incremental and rustc-wrapper for cargo-llvm-cov"
|
||||
);
|
||||
assert!(
|
||||
check_jade.contains("CARGO_TARGET_DIR")
|
||||
&& check_jade.contains("mercury-jade-llvm-cov")
|
||||
&& check_jade.contains("cargo llvm-cov clean")
|
||||
&& check_jade
|
||||
.contains("Invoke-TimedNativeWithEnvironment -Name 'cargo llvm-cov clean'"),
|
||||
"Jade coverage gate should use a per-run isolated cargo target dir for clean and nextest"
|
||||
);
|
||||
assert!(
|
||||
check_jade.contains("MERCURY_JADE_COVERAGE_ROOT")
|
||||
&& check_jade.contains("C:\\tmp")
|
||||
&& check_jade.contains("'mtcov'")
|
||||
&& check_jade.contains(".Substring(0, 8)"),
|
||||
"Jade coverage target dir should stay short enough for Windows llvm-cov object argv"
|
||||
);
|
||||
assert!(
|
||||
check_jade.contains(
|
||||
"'llvm-cov',\n '--jobs',\n '1',\n 'nextest'"
|
||||
),
|
||||
"Jade coverage gate should limit cargo-llvm-cov build jobs before the nextest subcommand"
|
||||
);
|
||||
|
||||
assert_justfile_test_recipes(&root);
|
||||
|
||||
let ecosystem = read_workspace_text(
|
||||
&["scripts", "check-ecosystem.ps1"],
|
||||
"ecosystem check script",
|
||||
);
|
||||
assert!(
|
||||
ecosystem.contains("toolbox:all-binaries-report-version")
|
||||
&& ecosystem.contains("Test-ToolboxBinaryVersions"),
|
||||
"ecosystem gate should prove every toolbox binary reports --version"
|
||||
);
|
||||
assert!(
|
||||
ecosystem.contains("toolbox:all-binaries-no-args-contract")
|
||||
&& ecosystem.contains("Test-ToolboxNoArgsContracts"),
|
||||
"ecosystem gate should prove every toolbox binary has bounded no-args behavior"
|
||||
);
|
||||
assert!(
|
||||
ecosystem.contains("toolbox:all-binaries-invalid-flag-contract")
|
||||
&& ecosystem.contains("Test-ToolboxInvalidFlagContracts"),
|
||||
"ecosystem gate should prove every toolbox binary has bounded invalid-flag diagnostics"
|
||||
);
|
||||
assert!(
|
||||
ecosystem.contains("toolbox:all-binaries-structured-output-help")
|
||||
&& ecosystem.contains("Test-ToolboxStructuredOutputHelpContracts"),
|
||||
"ecosystem gate should prove every toolbox binary exposes structured output help"
|
||||
);
|
||||
assert!(
|
||||
ecosystem.contains("toolbox:malformed-jsonl-stdin-contract")
|
||||
&& ecosystem.contains("Test-ToolboxMalformedJsonlStdinContracts"),
|
||||
"ecosystem gate should prove malformed JSONL stdin is bounded for input-format commands"
|
||||
);
|
||||
assert!(
|
||||
ecosystem.contains("toolbox:valid-jsonl-path-stream-smokes"),
|
||||
"ecosystem gate should prove representative positive JSONL path-stream behavior"
|
||||
);
|
||||
assert!(
|
||||
ecosystem.contains("toolbox:functional-toon-smokes"),
|
||||
"ecosystem gate should prove representative functional TOON output smokes"
|
||||
);
|
||||
|
||||
let check_hardening = read_workspace_text(
|
||||
&["scripts", "check-jade-hardening.ps1"],
|
||||
"scripts/check-jade-hardening.ps1",
|
||||
);
|
||||
for required_gate in [
|
||||
"cargo miri setup",
|
||||
"NightlyToolchain",
|
||||
"'fuzz'",
|
||||
"'run'",
|
||||
"json_family_decode",
|
||||
"-Zsanitizer=address",
|
||||
"check-no-panic.ps1",
|
||||
"loom_capture",
|
||||
"Mode 'hardening'",
|
||||
"ValidateRange(1, 3600)",
|
||||
"Only = 'All'",
|
||||
] {
|
||||
assert!(
|
||||
check_hardening.contains(required_gate),
|
||||
"hardening script should contain {required_gate:?}"
|
||||
);
|
||||
}
|
||||
assert!(check_hardening.contains("exemption requires a non-empty reason"));
|
||||
|
||||
let jade_install = read_workspace_text(
|
||||
&["scripts", "install-jade-tooling.ps1"],
|
||||
"scripts/install-jade-tooling.ps1",
|
||||
);
|
||||
assert!(jade_install.contains("PSScriptAnalyzer"));
|
||||
assert!(jade_install.contains("cargo-binstall"));
|
||||
assert!(jade_install.contains("\"just\", \"bacon\""));
|
||||
assert!(jade_install.contains("\"component\", \"add\", \"miri\""));
|
||||
assert!(jade_install.contains("\"cargo-udeps\", \"cargo-llvm-cov\""));
|
||||
assert!(jade_install.contains("\"install\", \"cargo-fuzz\""));
|
||||
|
||||
let docs = read_workspace_text(&["docs", "jade-discipline.md"], "jade discipline docs");
|
||||
assert!(docs.contains("PowerShell Gate"));
|
||||
assert!(docs.contains("check-powershell.ps1"));
|
||||
assert!(docs.contains("check-ai-skill.ps1"));
|
||||
assert!(docs.contains("PSScriptAnalyzer"));
|
||||
assert!(docs.contains("cargo-flamegraph-windows.ps1"));
|
||||
|
||||
let readme = read_workspace_text(&["README.md"], "README.md");
|
||||
assert!(readme.contains("cargo-flamegraph-windows.ps1"));
|
||||
}
|
||||
|
||||
fn assert_justfile_test_recipes(root: &std::path::Path) {
|
||||
let justfile = fs::read_to_string(root.join("justfile")).expect("justfile");
|
||||
assert!(
|
||||
justfile.contains("coverage:\n cargo llvm-cov nextest --all-features --summary-only"),
|
||||
"just coverage should keep a fast local coverage path without forcing Jade's serial coverage gate"
|
||||
);
|
||||
assert!(
|
||||
!justfile.contains("coverage:\n cargo llvm-cov clean --workspace"),
|
||||
"just coverage should not pre-clean coverage artifacts on every local iteration"
|
||||
);
|
||||
assert!(
|
||||
justfile.contains("test:\n cargo nextest run --all-features"),
|
||||
"just test should keep the fast incremental nextest path for local iteration"
|
||||
);
|
||||
assert!(
|
||||
justfile.contains("stable-test:")
|
||||
&& justfile.contains(
|
||||
"CARGO_INCREMENTAL = '0'; cargo nextest run --all-features --run-ignored all",
|
||||
),
|
||||
"stable-test should keep the non-incremental Windows cleanup-race path and slow integration coverage"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(clippy::too_many_lines)]
|
||||
fn repo_temp_paths_are_audited_and_use_exclusive_writes() {
|
||||
let root = workspace_root();
|
||||
let production_temp_dir_hits = production_source_hits(
|
||||
&root,
|
||||
&[
|
||||
"std::env::temp_dir()",
|
||||
"env::temp_dir()",
|
||||
"tempfile::",
|
||||
"NamedTempFile",
|
||||
"TempDir::new",
|
||||
"tempdir()",
|
||||
],
|
||||
);
|
||||
assert_eq!(
|
||||
production_temp_dir_hits,
|
||||
[
|
||||
"crates\\argv\\src\\lib.rs:std::env::temp_dir().join(format!(",
|
||||
"crates\\envdiff\\src\\lib.rs:std::env::temp_dir().join(format!(",
|
||||
"crates\\msudo\\src\\lib.rs:let temp_dir = std::env::temp_dir();",
|
||||
"crates\\runtimekit\\src\\lib.rs:std::env::temp_dir().join(format!(\"{prefix}-{unique}.{extension}\"))",
|
||||
],
|
||||
"production temp root use must stay explicitly audited"
|
||||
);
|
||||
|
||||
for (path, required) in [
|
||||
(
|
||||
"crates/runtimekit/src/lib.rs",
|
||||
&[
|
||||
"fn write_shell_wrapper_file",
|
||||
".create_new(true)",
|
||||
"refusing to replace existing shell wrapper",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"crates/argv/src/lib.rs",
|
||||
&[
|
||||
"fn write_cmd_wrapper_file",
|
||||
".create_new(true)",
|
||||
"refusing to replace existing cmd inspect wrapper",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"crates/envdiff/src/lib.rs",
|
||||
&[
|
||||
"fn write_temp_file_exclusive",
|
||||
".create_new(true)",
|
||||
"fn create_temp_dir_exclusive",
|
||||
"fs::create_dir(path)",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"crates/msudo/src/lib.rs",
|
||||
&[
|
||||
"fn write_relay_exit_status",
|
||||
".create_new(true)",
|
||||
"failed to create relay exit status",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"crates/runprobe/src/lib.rs",
|
||||
&[
|
||||
"fn write_log_file_exclusive",
|
||||
".create_new(true)",
|
||||
"refusing to replace existing runprobe log",
|
||||
][..],
|
||||
),
|
||||
(
|
||||
"crates/windowsupport/src/sudo.rs",
|
||||
&[
|
||||
"fn create_relay_output_file",
|
||||
".create_new(true)",
|
||||
"FILE_FLAG_OPEN_REPARSE_POINT",
|
||||
][..],
|
||||
),
|
||||
] {
|
||||
let body = fs::read_to_string(root.join(path)).unwrap_or_else(|error| {
|
||||
panic!("failed to read {path}: {error}");
|
||||
});
|
||||
for needle in required {
|
||||
assert!(
|
||||
body.contains(needle),
|
||||
"{path} should keep temp/log output guard {needle:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn production_source_hits(root: &std::path::Path, needles: &[&str]) -> Vec<String> {
|
||||
let mut hits = Vec::new();
|
||||
collect_production_source_hits(&root.join("crates"), root, needles, &mut hits);
|
||||
hits.sort();
|
||||
hits
|
||||
}
|
||||
|
||||
fn collect_production_source_hits(
|
||||
directory: &std::path::Path,
|
||||
root: &std::path::Path,
|
||||
needles: &[&str],
|
||||
hits: &mut Vec<String>,
|
||||
) {
|
||||
for entry in fs::read_dir(directory).unwrap_or_else(|error| {
|
||||
panic!("failed to read {}: {error}", directory.display());
|
||||
}) {
|
||||
let path = entry.expect("directory entry").path();
|
||||
if path.is_dir() {
|
||||
if path.file_name().and_then(|name| name.to_str()) != Some("tests") {
|
||||
collect_production_source_hits(&path, root, needles, hits);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if path.extension().and_then(|extension| extension.to_str()) != Some("rs") {
|
||||
continue;
|
||||
}
|
||||
collect_file_hits(&path, root, needles, hits);
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_file_hits(
|
||||
path: &std::path::Path,
|
||||
root: &std::path::Path,
|
||||
needles: &[&str],
|
||||
hits: &mut Vec<String>,
|
||||
) {
|
||||
let body = fs::read_to_string(path).unwrap_or_else(|error| {
|
||||
panic!("failed to read {}: {error}", path.display());
|
||||
});
|
||||
let mut in_test_region = path.components().any(|component| {
|
||||
component
|
||||
.as_os_str()
|
||||
.to_string_lossy()
|
||||
.eq_ignore_ascii_case("tests")
|
||||
});
|
||||
for line in body.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed == "#[cfg(test)]" || trimmed.starts_with("#[test]") {
|
||||
in_test_region = true;
|
||||
}
|
||||
if !in_test_region && needles.iter().any(|needle| trimmed.contains(needle)) {
|
||||
let relative = path.strip_prefix(root).unwrap_or(path);
|
||||
hits.push(format!("{}:{}", relative.display(), trimmed));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deny_advisory_ignores_carry_review_evidence() {
|
||||
let deny = read_workspace_text(&["deny.toml"], "deny.toml");
|
||||
let advisory = "RUSTSEC-2024-0436";
|
||||
let offset = deny
|
||||
.find(advisory)
|
||||
.unwrap_or_else(|| panic!("deny.toml should mention {advisory}"));
|
||||
let context_start = deny[..offset]
|
||||
.rfind("[advisories]")
|
||||
.expect("advisories section");
|
||||
let context = &deny[context_start..offset];
|
||||
|
||||
for required in [
|
||||
"Package:",
|
||||
"Reachability:",
|
||||
"Reviewed:",
|
||||
"Upgrade/follow-up:",
|
||||
] {
|
||||
assert!(
|
||||
context.contains(required),
|
||||
"advisory ignore {advisory} should document {required}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_asset_checks_self_heal_generated_drift_before_failing() {
|
||||
for script_name in ["check-ai-prompt.ps1", "check-ai-skill.ps1"] {
|
||||
let script = read_workspace_text(&["scripts", script_name], script_name);
|
||||
assert!(
|
||||
script.contains("Invoke-GeneratorCheck"),
|
||||
"{script_name} should use the shared check/regenerate/recheck helper"
|
||||
);
|
||||
assert!(
|
||||
script.contains("Invoke-GeneratorWrite"),
|
||||
"{script_name} should regenerate stale generated assets automatically"
|
||||
);
|
||||
assert!(
|
||||
script.contains("still out of date after regeneration"),
|
||||
"{script_name} should only ask for manual intervention after regeneration fails"
|
||||
);
|
||||
}
|
||||
|
||||
let docs = read_workspace_text(&["docs", "jade-discipline.md"], "jade docs");
|
||||
assert!(
|
||||
docs.contains("self-heal generated asset drift"),
|
||||
"Jade docs should describe the AI asset gate's self-healing behavior"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//! Miri regression coverage for compact JSON-family parsers.
|
||||
|
||||
use common::formats::toon::{DecodeOptions, EncodeOptions};
|
||||
use common::formats::{ison, tonl, toon, zon};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn toon_roundtrip_exercises_nested_and_tabular_paths() {
|
||||
for value in [
|
||||
json!({
|
||||
"meta": {
|
||||
"ok": true,
|
||||
"count": 2
|
||||
},
|
||||
"items": [
|
||||
{"id": 1, "name": "Ada"},
|
||||
{"id": 2, "name": "Bob"}
|
||||
],
|
||||
"literal.path": "quoted when encoded"
|
||||
}),
|
||||
json!([1, 2, 3]),
|
||||
json!([{"id": 1}, {"id": 2}]),
|
||||
json!("plain"),
|
||||
] {
|
||||
let encoded = toon::encode_value(&value, EncodeOptions::default()).expect("TOON encode");
|
||||
let decoded = toon::decode_str(&encoded, DecodeOptions::default());
|
||||
|
||||
assert_eq!(decoded.expect("TOON decode"), value);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toon_decoder_rejects_malformed_counts_and_path_conflicts() {
|
||||
let bad_count = toon::decode_str("[3]: a,b\n", DecodeOptions::default());
|
||||
assert!(bad_count.is_err());
|
||||
|
||||
let path_conflict = toon::decode_str(
|
||||
"a: 1\na.b: 2\n",
|
||||
DecodeOptions {
|
||||
expand_paths: common::formats::toon::SafeMode::Safe,
|
||||
..DecodeOptions::default()
|
||||
},
|
||||
);
|
||||
assert!(path_conflict.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_formats_decode_without_panicking() {
|
||||
let records = vec![json!({"id": 1, "name": "Ada", "active": true})];
|
||||
let isonl = ison::encode_records(&records, "user").expect("ISONL encode");
|
||||
assert_eq!(ison::decode_records(&isonl).expect("ISONL decode"), records);
|
||||
|
||||
assert_eq!(
|
||||
zon::decode_str("id:1\nname:\"Ada\"\nactive:true\n").expect("ZON decode"),
|
||||
json!({"id": 1, "name": "Ada", "active": true})
|
||||
);
|
||||
|
||||
let tonl = tonl::encode_documents(&[json!({"id": 1, "name": "Ada"})]).expect("TONL encode");
|
||||
assert_eq!(
|
||||
tonl::decode_documents(&tonl).expect("TONL decode"),
|
||||
vec![json!({"id": 1, "name": "Ada"})]
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user