use serde::de::DeserializeOwned;
use serde_json::Value;
#[must_use]
fn found_type(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "a boolean",
Value::Number(_) => "a number",
Value::String(_) => "a string",
Value::Array(_) => "an array",
Value::Object(_) => "an object",
}
}
pub(crate) fn wrong_type(key: &str, expected: &str, found_value: &Value) -> anyhow::Error {
let hint = match expected {
"a string" => "wrap the value in double quotes".to_string(),
"a boolean" => {
"pass unquoted true or false, or omit the argument to use the default".to_string()
}
"a non-negative integer" => format!("pass a JSON number, e.g. {key}: 5"),
"an integer" => "pass a JSON number".to_string(),
"an array of strings" => {
format!("pass a JSON array of strings, e.g. {key}: [\"a\", \"b\"]")
}
_ => "pass a JSON value of the expected type".to_string(),
};
anyhow::anyhow!(
"usage: argument \"{key}\" must be {expected}, got {} — hint: {hint}",
found_type(found_value)
)
}
pub(crate) fn get_str<'a>(val: &'a Value, key: &str) -> anyhow::Result<&'a str> {
match val.get(key) {
None | Some(Value::Null) => Err(anyhow::anyhow!(
"usage: missing required argument \"{key}\" (expected a string) — \
hint: pass it as a JSON string, e.g. \"{key}\": \"value\""
)),
Some(v) => v.as_str().ok_or_else(|| wrong_type(key, "a string", v)),
}
}
pub(crate) fn get_opt_str<'a>(val: &'a Value, key: &str) -> Option<&'a str> {
val.get(key).and_then(Value::as_str)
}
pub(crate) fn get_bool(val: &Value, key: &str, default: bool) -> anyhow::Result<bool> {
match val.get(key) {
None | Some(Value::Null) => Ok(default),
Some(Value::Bool(b)) => Ok(*b),
Some(v) => Err(wrong_type(key, "a boolean", v)),
}
}
pub(crate) fn get_opt_i64(val: &Value, key: &str) -> anyhow::Result<Option<i64>> {
match val.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_i64()
.map(Some)
.ok_or_else(|| wrong_type(key, "an integer", v)),
}
}
pub(crate) fn get_opt_u64(val: &Value, key: &str) -> anyhow::Result<Option<u64>> {
match val.get(key) {
None | Some(Value::Null) => Ok(None),
Some(v) => v
.as_u64()
.map(Some)
.ok_or_else(|| wrong_type(key, "a non-negative integer", v)),
}
}
pub(crate) fn get_usize(val: &Value, key: &str, default: usize) -> anyhow::Result<usize> {
match val.get(key) {
None | Some(Value::Null) => Ok(default),
Some(v) => {
let n = v
.as_u64()
.ok_or_else(|| wrong_type(key, "a non-negative integer", v))?;
usize::try_from(n).map_err(|_| wrong_type(key, "a non-negative integer", v))
}
}
}
pub(crate) fn get_str_array(val: &Value, key: &str) -> anyhow::Result<Vec<String>> {
match val.get(key) {
None | Some(Value::Null) => Ok(Vec::new()),
Some(Value::Array(arr)) => {
let mut out = Vec::with_capacity(arr.len());
for v in arr {
let Some(s) = v.as_str() else {
return Err(wrong_type(key, "an array of strings", v));
};
out.push(s.to_string());
}
Ok(out)
}
Some(v) => Err(wrong_type(key, "an array of strings", v)),
}
}
pub(crate) fn get_object(val: &Value, key: &str) -> anyhow::Result<serde_json::Map<String, Value>> {
match val.get(key) {
None | Some(Value::Null) => Ok(serde_json::Map::new()),
Some(Value::Object(map)) => Ok(map.clone()),
Some(v) => Err(wrong_type(key, "an object", v)),
}
}
pub(crate) fn get_opt_bool(val: &Value, key: &str) -> anyhow::Result<Option<bool>> {
match val.get(key) {
None | Some(Value::Null) => Ok(None),
Some(Value::Bool(b)) => Ok(Some(*b)),
Some(v) => Err(wrong_type(key, "a boolean", v)),
}
}
#[must_use]
pub(crate) fn try_repair_json<T: DeserializeOwned>(s: &str) -> Option<T> {
jsonrepair_rs::jsonrepair(s)
.ok()
.and_then(|repaired| serde_json::from_str(&repaired).ok())
}
pub(crate) fn parse_fenced_json<T: DeserializeOwned>(text: &str) -> anyhow::Result<T> {
let trimmed = text.trim();
let json_str = if let Some(start) = trimmed.find("```json") {
extract_fenced_content(&trimmed[start + 7..])
} else if let Some(start) = trimmed.find("```") {
extract_fenced_content(&trimmed[start + 3..])
} else {
trimmed
};
serde_json::from_str::<T>(json_str).or_else(|parse_err| {
if let Some(value) = try_repair_json::<T>(json_str) {
return Ok(value);
}
Err(anyhow::anyhow!("Failed to parse JSON: {parse_err}"))
})
}
fn extract_fenced_content(text: &str) -> &str {
let end = text.find("```").unwrap_or(text.len());
text.get(..end).unwrap_or(text).trim()
}
#[cfg(test)]
mod tests {
use super::parse_fenced_json;
use crate::Verdict;
#[derive(serde::Deserialize, Debug, PartialEq)]
struct TestVerdict {
score: u8,
#[serde(default)]
critique: String,
#[serde(default)]
issues: Vec<String>,
}
#[test]
fn parse_fenced_json_valid_inputs() {
struct Case {
name: &'static str,
input: &'static str,
expected_score: u8,
expected_critique: &'static str,
}
let cases = [
Case {
name: "json-tagged fence",
input: "Based on the analysis, here's my verdict:\n\n```json\n{\"score\": 8, \"critique\": \"Looks good\"}\n```",
expected_score: 8,
expected_critique: "Looks good",
},
Case {
name: "bare fence",
input: "```\n{\"score\": 7, \"critique\": \"Some issues\"}\n```",
expected_score: 7,
expected_critique: "Some issues",
},
Case {
name: "unfenced",
input: r#"{"score": 10, "critique": "Perfect", "issues": []}"#,
expected_score: 10,
expected_critique: "Perfect",
},
Case {
name: "commentary before fence",
input: "I have reviewed the code.\n\n```json\n{\"score\": 6, \"critique\": \"Needs improvement\"}\n```\n\nOverall, acceptable.",
expected_score: 6,
expected_critique: "Needs improvement",
},
Case {
name: "multiple fences uses first json",
input: "```json\n{\"score\": 9}\n```\n\nSome text\n\n```\n{\"score\": 5}\n```",
expected_score: 9,
expected_critique: "",
},
];
for case in &cases {
let result: TestVerdict = parse_fenced_json(case.input).unwrap();
assert_eq!(result.score, case.expected_score, "case: {}", case.name);
assert_eq!(
result.critique, case.expected_critique,
"case: {}",
case.name
);
}
}
#[test]
fn parse_fenced_json_with_issues() {
let text = r#"```json
{"score": 5, "critique": "Problems found", "issues": ["Bug in edge case", "Missing error handling"]}
```"#;
let result: Verdict = parse_fenced_json(text).unwrap();
assert_eq!(result.score, 5);
assert_eq!(result.issues_detected.len(), 2);
assert!(
result
.issues_detected
.contains(&"Bug in edge case".to_string())
);
}
#[test]
fn parse_fenced_json_invalid_json_returns_err() {
let text = "```json\n{invalid: true}\n```";
let result = parse_fenced_json::<Verdict>(text);
assert!(result.is_err());
}
#[test]
fn parse_fenced_json_no_json_at_all() {
let text = "This is just plain text with no JSON whatsoever.";
let result = parse_fenced_json::<Verdict>(text);
assert!(result.is_err());
}
}
#[cfg(test)]
mod arg_extraction_tests {
use super::{
get_bool, get_opt_bool, get_opt_i64, get_opt_str, get_opt_u64, get_str, get_str_array,
get_usize,
};
use serde_json::{Value, json};
#[test]
fn absent_and_null_yield_defaults() {
let absent = json!({});
let null = json!({ "k": null });
for (val, label) in [(&absent, "absent"), (&null, "null")] {
assert!(get_bool(val, "k", true).unwrap(), "{label}");
assert_eq!(get_usize(val, "k", 7).unwrap(), 7, "{label}");
assert_eq!(get_opt_i64(val, "k").unwrap(), None, "{label}");
assert_eq!(get_opt_u64(val, "k").unwrap(), None, "{label}");
assert_eq!(get_opt_bool(val, "k").unwrap(), None, "{label}");
assert!(get_str_array(val, "k").unwrap().is_empty(), "{label}");
}
assert_eq!(get_opt_str(&absent, "k"), None);
}
#[test]
fn get_str_distinguishes_absent_from_wrong_type() {
let absent = json!({});
let err = get_str(&absent, "path").unwrap_err().to_string();
assert!(err.contains("usage:"), "got: {err}");
assert!(
err.contains("missing required argument \"path\""),
"got: {err}"
);
let wrong = json!({ "path": 42 });
let err = get_str(&wrong, "path").unwrap_err().to_string();
assert!(err.contains("usage:"), "got: {err}");
assert!(
err.contains("argument \"path\" must be a string"),
"got: {err}"
);
assert!(err.contains("got a number"), "got: {err}");
let null = json!({ "path": null });
let err = get_str(&null, "path").unwrap_err().to_string();
assert!(err.contains("missing required argument"), "got: {err}");
let ok = json!({ "path": "a" });
assert_eq!(get_str(&ok, "path").unwrap(), "a");
}
#[test]
fn wrong_type_errors_are_usage_errors() {
let cases: [(&Value, &str, &str, &str); 5] = [
(
&json!({ "k": "yes" }),
"bool",
"must be a boolean",
"got a string",
),
(
&json!({ "k": "5" }),
"usize",
"must be a non-negative integer",
"got a string",
),
(
&json!({ "k": "x" }),
"opt_i64",
"must be an integer",
"got a string",
),
(
&json!({ "k": -1 }),
"opt_u64",
"must be a non-negative integer",
"got a number",
),
(
&json!({ "k": 1.5 }),
"opt_bool",
"must be a boolean",
"got a number",
),
];
for (val, helper, expected_phrase, found_phrase) in cases {
let err = match helper {
"bool" => get_bool(val, "k", false).unwrap_err().to_string(),
"usize" => get_usize(val, "k", 0).unwrap_err().to_string(),
"opt_i64" => get_opt_i64(val, "k").unwrap_err().to_string(),
"opt_u64" => get_opt_u64(val, "k").unwrap_err().to_string(),
"opt_bool" => get_opt_bool(val, "k").unwrap_err().to_string(),
_ => unreachable!(),
};
assert!(err.contains("usage:"), "helper {helper}: got: {err}");
assert!(err.contains("\"k\""), "helper {helper}: got: {err}");
assert!(err.contains(expected_phrase), "helper {helper}: got: {err}");
assert!(err.contains(found_phrase), "helper {helper}: got: {err}");
}
}
#[test]
fn get_str_array_rejects_non_string_elements() {
let non_array = json!({ "k": "a, b" });
let err = get_str_array(&non_array, "k").unwrap_err().to_string();
assert!(err.contains("usage:"), "got: {err}");
assert!(
err.contains("\"k\" must be an array of strings"),
"got: {err}"
);
let mixed = json!({ "k": ["a", 2, "b"] });
let err = get_str_array(&mixed, "k").unwrap_err().to_string();
assert!(
err.contains("\"k\" must be an array of strings"),
"got: {err}"
);
assert!(err.contains("got a number"), "got: {err}");
let ok = json!({ "k": ["a", "b"] });
assert_eq!(
get_str_array(&ok, "k").unwrap(),
vec!["a".to_string(), "b".to_string()]
);
}
#[test]
fn valid_values_round_trip() {
let val = json!({
"flag": false,
"count": 5,
"signed": -3,
"text": "hello",
"list": ["a"],
});
assert!(!get_bool(&val, "flag", true).unwrap());
assert_eq!(get_usize(&val, "count", 0).unwrap(), 5);
assert_eq!(get_opt_i64(&val, "signed").unwrap(), Some(-3));
assert_eq!(get_opt_u64(&val, "count").unwrap(), Some(5));
assert_eq!(get_opt_bool(&val, "flag").unwrap(), Some(false));
assert_eq!(get_str_array(&val, "list").unwrap(), vec!["a".to_string()]);
}
}