use std::collections::BTreeSet;
#[derive(Debug, Clone)]
pub(crate) struct MarkupOpener {
pub(crate) opener: String,
pub(crate) close: String,
}
#[derive(Debug, Clone)]
pub(crate) struct HarmonyVocab {
pub(crate) header_markers: Vec<String>,
pub(crate) standalone_markers: Vec<String>,
pub(crate) frame_prefix: String,
pub(crate) frame_suffix: String,
pub(crate) message_marker: String,
pub(crate) tool_call_header_prefix: String,
pub(crate) corrupted_openers: Vec<String>,
}
#[derive(Debug, Clone)]
pub(crate) struct ScanSpec {
pub(crate) call_tags: Vec<String>,
pub(crate) block_tags: Vec<String>,
pub(crate) wrapper_tags: Vec<String>,
pub(crate) markup_openers: Vec<MarkupOpener>,
pub(crate) reserved_openers: Vec<String>,
pub(crate) harmony: HarmonyVocab,
pub(crate) known_tools: BTreeSet<String>,
pub(crate) strip_thinking: bool,
}
impl ScanSpec {
pub(crate) fn from_json(spec: &serde_json::Value) -> Result<Self, String> {
let Some(obj) = spec.as_object() else {
return Err(format!(
"spec must be a dict of dialect vocabulary; got {}",
json_type_name(spec)
));
};
let call_tags = string_list(obj, "call_tags")?;
let block_tags = string_list(obj, "block_tags")?;
let wrapper_tags = string_list(obj, "wrapper_tags")?;
let openers = markup_openers(obj)?;
let reserved_openers = string_list(obj, "reserved_openers")?;
let harmony_value = required(obj, "harmony")?;
let harmony_obj = harmony_value.as_object().ok_or_else(|| {
format!(
"spec.harmony must be a dict; got {}",
json_type_name(harmony_value)
)
})?;
Ok(Self {
call_tags,
block_tags,
wrapper_tags,
markup_openers: openers,
reserved_openers,
harmony: HarmonyVocab {
header_markers: string_list_at(harmony_obj, "harmony", "header_markers")?,
standalone_markers: string_list_at(harmony_obj, "harmony", "standalone_markers")?,
frame_prefix: string_at(harmony_obj, "harmony", "frame_prefix")?,
frame_suffix: string_at(harmony_obj, "harmony", "frame_suffix")?,
message_marker: string_at(harmony_obj, "harmony", "message_marker")?,
tool_call_header_prefix: string_at(
harmony_obj,
"harmony",
"tool_call_header_prefix",
)?,
corrupted_openers: string_list_at(harmony_obj, "harmony", "corrupted_openers")?,
},
known_tools: string_list(obj, "known_tools")?.into_iter().collect(),
strip_thinking: match obj.get("strip_thinking") {
None | Some(serde_json::Value::Null) => true,
Some(serde_json::Value::Bool(flag)) => *flag,
Some(other) => {
return Err(format!(
"spec.strip_thinking must be a bool; got {}",
json_type_name(other)
))
}
},
})
}
}
type JsonObject = serde_json::Map<String, serde_json::Value>;
fn required<'a>(obj: &'a JsonObject, field: &str) -> Result<&'a serde_json::Value, String> {
match obj.get(field) {
Some(serde_json::Value::Null) | None => Err(format!(
"spec.{field} is required — the scanner holds no dialect vocabulary of its own, \
so an absent field is an error rather than a fallback"
)),
Some(value) => Ok(value),
}
}
fn string_list(obj: &JsonObject, field: &str) -> Result<Vec<String>, String> {
let value = required(obj, field)?;
let items = value
.as_array()
.ok_or_else(|| format!("spec.{field} must be a list; got {}", json_type_name(value)))?;
items
.iter()
.map(|item| {
item.as_str().map(str::to_string).ok_or_else(|| {
format!(
"spec.{field} entries must be strings; got {}",
json_type_name(item)
)
})
})
.collect()
}
fn string_list_at(obj: &JsonObject, parent: &str, field: &str) -> Result<Vec<String>, String> {
string_list(obj, field).map_err(|error| error.replacen("spec.", &format!("spec.{parent}."), 1))
}
fn string_at(obj: &JsonObject, parent: &str, field: &str) -> Result<String, String> {
let value = required(obj, field)
.map_err(|error| error.replacen("spec.", &format!("spec.{parent}."), 1))?;
value.as_str().map(str::to_string).ok_or_else(|| {
format!(
"spec.{parent}.{field} must be a string; got {}",
json_type_name(value)
)
})
}
fn markup_openers(obj: &JsonObject) -> Result<Vec<MarkupOpener>, String> {
let value = required(obj, "markup_openers")?;
let items = value.as_array().ok_or_else(|| {
format!(
"spec.markup_openers must be a list; got {}",
json_type_name(value)
)
})?;
items
.iter()
.map(|item| {
let entry = item.as_object().ok_or_else(|| {
format!(
"spec.markup_openers entries must be dicts with `opener` and `close`; got {}",
json_type_name(item)
)
})?;
Ok(MarkupOpener {
opener: string_at(entry, "markup_openers", "opener")?,
close: string_at(entry, "markup_openers", "close")?,
})
})
.collect()
}
fn json_type_name(value: &serde_json::Value) -> &'static str {
match value {
serde_json::Value::Null => "nil",
serde_json::Value::Bool(_) => "bool",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "list",
serde_json::Value::Object(_) => "dict",
}
}