use std::io::Read;
use serde::{Deserialize, Serialize};
use terraphim_types::NormalizedTerm;
use crate::compiled::{CompiledMatcher, MatchOverlap, MatcherBuilder, MatcherOptions};
use crate::{Result, TerraphimAutomataError};
pub const MATCHER_FORMAT_ID: &str = "terraphim.automata.matcher";
pub const MATCHER_FORMAT_VERSION: u32 = 2;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PersistedMatcherV1 {
pub options: MatcherOptions,
pub patterns: Vec<(String, NormalizedTerm)>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PersistedMatcherV2 {
#[serde(deserialize_with = "deserialize_required_options")]
pub options: MatcherOptions,
pub patterns: Vec<(String, NormalizedTerm)>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "version")]
#[non_exhaustive]
pub enum PersistedMatcherPayload {
#[serde(rename = "1")]
V1(PersistedMatcherV1),
#[serde(rename = "2")]
V2(PersistedMatcherV2),
}
impl PersistedMatcherPayload {
pub fn options(&self) -> &MatcherOptions {
match self {
Self::V1(data) => &data.options,
Self::V2(data) => &data.options,
}
}
pub fn patterns(&self) -> &[(String, NormalizedTerm)] {
match self {
Self::V1(data) => &data.patterns,
Self::V2(data) => &data.patterns,
}
}
pub fn version(&self) -> u32 {
match self {
Self::V1(_) => 1,
Self::V2(_) => 2,
}
}
}
fn requires_v2(options: &MatcherOptions) -> bool {
options.overlap != MatchOverlap::LeftmostLongest
|| !options.word_boundaries
|| options.allow_blank_patterns
}
fn deserialize_required_options<'de, D>(
deserializer: D,
) -> std::result::Result<MatcherOptions, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct RequiredOptions {
case_insensitive: bool,
min_pattern_length: usize,
overlap: MatchOverlap,
word_boundaries: bool,
#[serde(default)]
allow_blank_patterns: bool,
}
let raw = RequiredOptions::deserialize(deserializer)?;
Ok(MatcherOptions::default()
.with_case_insensitive(raw.case_insensitive)
.with_min_pattern_length(raw.min_pattern_length)
.with_overlap(raw.overlap)
.with_word_boundaries(raw.word_boundaries)
.with_allow_blank_patterns(raw.allow_blank_patterns))
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PersistedMatcherEnvelope {
pub format_id: String,
#[serde(flatten)]
pub payload: PersistedMatcherPayload,
}
impl From<&CompiledMatcher> for PersistedMatcherEnvelope {
fn from(matcher: &CompiledMatcher) -> Self {
let options = matcher.options().clone();
let patterns = matcher.patterns().to_vec();
let payload = if requires_v2(&options) {
PersistedMatcherPayload::V2(PersistedMatcherV2 { options, patterns })
} else {
PersistedMatcherPayload::V1(PersistedMatcherV1 { options, patterns })
};
Self {
format_id: MATCHER_FORMAT_ID.to_string(),
payload,
}
}
}
impl PersistedMatcherEnvelope {
pub fn from_matcher(matcher: &CompiledMatcher) -> Self {
Self::from(matcher)
}
pub fn to_matcher(&self) -> Result<CompiledMatcher> {
let mut builder = MatcherBuilder::new(self.payload.options().clone());
for (pattern, term) in self.payload.patterns() {
builder.insert(pattern.clone(), term.clone())?;
}
builder.build()
}
pub fn to_json(&self) -> Result<String> {
serde_json::to_string_pretty(self).map_err(Into::into)
}
pub fn from_json(json: &str) -> Result<Self> {
#[derive(Deserialize)]
struct Probe {
format_id: String,
version: Option<serde_json::Value>,
}
let probe: Probe = serde_json::from_str(json).map_err(|err| {
TerraphimAutomataError::MalformedPersisted {
format_id: MATCHER_FORMAT_ID.to_string(),
reason: err.to_string(),
}
})?;
if probe.format_id != MATCHER_FORMAT_ID {
return Err(TerraphimAutomataError::UnsupportedFormat {
found: probe.format_id,
expected: MATCHER_FORMAT_ID.to_string(),
});
}
if let Some(serde_json::Value::String(tag)) = probe.version {
let supported = tag
.parse::<u32>()
.is_ok_and(|number| supported_versions().contains(&number));
if !supported {
return Err(TerraphimAutomataError::UnsupportedVersion {
format_id: MATCHER_FORMAT_ID.to_string(),
found: Some(tag),
supported: supported_versions(),
});
}
}
serde_json::from_str(json).map_err(|err| TerraphimAutomataError::MalformedPersisted {
format_id: MATCHER_FORMAT_ID.to_string(),
reason: err.to_string(),
})
}
pub fn to_writer<W: std::io::Write>(&self, writer: W) -> Result<()> {
serde_json::to_writer_pretty(writer, self).map_err(Into::into)
}
pub fn from_reader<R: Read>(mut reader: R) -> Result<Self> {
let mut buf = String::new();
reader
.read_to_string(&mut buf)
.map_err(TerraphimAutomataError::Io)?;
Self::from_json(&buf)
}
}
fn supported_versions() -> Vec<u32> {
(1..=MATCHER_FORMAT_VERSION).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use terraphim_types::NormalizedTermValue;
fn term(id: &str) -> NormalizedTerm {
NormalizedTerm::with_auto_id(NormalizedTermValue::from(id.to_string()))
}
fn sample_matcher() -> Result<CompiledMatcher> {
let options = MatcherOptions::default()
.with_case_insensitive(false)
.with_min_pattern_length(4);
let mut builder = MatcherBuilder::new(options);
builder.insert("AlphaMatch".to_string(), term("a1"))?;
builder.insert("Beta Pattern".to_string(), term("b2"))?;
builder.build()
}
#[test]
fn round_trip_preserves_options_and_behaviour()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let matcher = sample_matcher()?;
let envelope = PersistedMatcherEnvelope::from_matcher(&matcher);
let json = envelope.to_json()?;
let restored = PersistedMatcherEnvelope::from_json(&json)?.to_matcher()?;
assert_eq!(matcher.options(), restored.options());
assert_eq!(matcher.len(), restored.len());
let text = "AlphaMatch then beta pattern then AlphaMatch";
let before = matcher
.find_matches(text, true)?
.into_iter()
.map(|m| (m.term, m.pos))
.collect::<Vec<_>>();
let after = restored
.find_matches(text, true)?
.into_iter()
.map(|m| (m.term, m.pos))
.collect::<Vec<_>>();
let names = |ms: &[(String, Option<(usize, usize)>)]| ms.to_vec();
let before = names(&before);
let after = names(&after);
assert_eq!(before, after);
assert_eq!(before.len(), 2);
assert!(
before
.iter()
.all(|(term, _)| term == "AlphaMatch" || term == "Beta Pattern")
);
Ok(())
}
#[test]
fn envelope_carries_format_identity_and_version()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let matcher = sample_matcher()?;
let json = PersistedMatcherEnvelope::from_matcher(&matcher).to_json()?;
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["format_id"], MATCHER_FORMAT_ID);
assert_eq!(value["version"], "1");
Ok(())
}
#[test]
fn future_version_is_rejected_before_payload_use()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let matcher = sample_matcher()?;
let json = PersistedMatcherEnvelope::from_matcher(&matcher).to_json()?;
let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
value["version"] = serde_json::Value::String("99".to_string());
let future = serde_json::to_string(&value).unwrap();
let err = PersistedMatcherEnvelope::from_json(&future).unwrap_err();
match err {
TerraphimAutomataError::UnsupportedVersion {
format_id,
found,
supported,
} => {
assert_eq!(format_id, MATCHER_FORMAT_ID);
assert_eq!(found.as_deref(), Some("99"));
assert_eq!(supported, vec![1, 2]);
}
other => panic!("expected UnsupportedVersion, got: {other}"),
}
Ok(())
}
#[test]
fn unknown_format_id_is_rejected() -> std::result::Result<(), Box<dyn std::error::Error>> {
let matcher = sample_matcher()?;
let json = PersistedMatcherEnvelope::from_matcher(&matcher).to_json()?;
let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
value["format_id"] = serde_json::Value::String("someone.else.format".to_string());
let other = serde_json::to_string(&value).unwrap();
let err = PersistedMatcherEnvelope::from_json(&other).unwrap_err();
assert!(matches!(
err,
TerraphimAutomataError::UnsupportedFormat { .. }
));
Ok(())
}
#[test]
fn malformed_envelope_is_rejected_with_typed_error()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let err = PersistedMatcherEnvelope::from_json("{ not json").unwrap_err();
assert!(matches!(
err,
TerraphimAutomataError::MalformedPersisted { .. }
));
let matcher = sample_matcher()?;
let json = PersistedMatcherEnvelope::from_matcher(&matcher).to_json()?;
let mut value: serde_json::Value = serde_json::from_str(&json).unwrap();
value.as_object_mut().unwrap().remove("version");
let tagless = serde_json::to_string(&value).unwrap();
let err = PersistedMatcherEnvelope::from_json(&tagless).unwrap_err();
assert!(matches!(
err,
TerraphimAutomataError::UnsupportedVersion { .. }
| TerraphimAutomataError::MalformedPersisted { .. }
));
Ok(())
}
#[test]
fn configuration_mismatch_is_reported_on_rebuild()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let data = PersistedMatcherV1 {
options: MatcherOptions::default()
.with_case_insensitive(true)
.with_min_pattern_length(10),
patterns: vec![("short".to_string(), term("s"))],
};
let envelope = PersistedMatcherEnvelope {
format_id: MATCHER_FORMAT_ID.to_string(),
payload: PersistedMatcherPayload::V1(data),
};
let err = envelope.to_matcher().unwrap_err();
assert!(matches!(err, TerraphimAutomataError::InvalidPattern { .. }));
Ok(())
}
#[test]
fn legacy_envelope_without_new_options_loads_with_defaults()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let legacy = r#"{
"format_id": "terraphim.automata.matcher",
"version": "1",
"options": {
"case_insensitive": false,
"min_pattern_length": 4
},
"patterns": []
}"#;
let envelope = PersistedMatcherEnvelope::from_json(legacy)?;
let PersistedMatcherPayload::V1(data) = &envelope.payload else {
return Err("legacy envelope must parse as version 1".into());
};
let expected = MatcherOptions::default()
.with_case_insensitive(false)
.with_min_pattern_length(4)
.with_overlap(crate::compiled::MatchOverlap::LeftmostLongest)
.with_word_boundaries(true)
.with_allow_blank_patterns(false);
assert_eq!(data.options, expected);
let matcher = sample_matcher()?;
let json = PersistedMatcherEnvelope::from_matcher(&matcher).to_json()?;
let mut value: serde_json::Value = serde_json::from_str(&json)?;
let options = value["options"]
.as_object_mut()
.ok_or("options is an object")?;
assert!(options.remove("overlap").is_some());
assert!(options.remove("word_boundaries").is_some());
let stripped = serde_json::to_string(&value)?;
let restored = PersistedMatcherEnvelope::from_json(&stripped)?.to_matcher()?;
assert_eq!(restored.options(), matcher.options());
let text = "AlphaMatch and Beta Pattern";
assert_eq!(
restored.find_matches(text, true)?,
matcher.find_matches(text, true)?
);
Ok(())
}
#[test]
fn round_trip_preserves_overlap_and_word_boundary_options()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let options = MatcherOptions::default()
.with_case_insensitive(false)
.with_min_pattern_length(2)
.with_overlap(crate::compiled::MatchOverlap::All)
.with_word_boundaries(false);
let mut builder = MatcherBuilder::new(options);
builder.insert("aa".to_string(), term("aa"))?;
builder.insert("cat".to_string(), term("cat"))?;
let matcher = builder.build()?;
let envelope = PersistedMatcherEnvelope::from_matcher(&matcher);
assert_eq!(envelope.payload.version(), 2);
let json = envelope.to_json()?;
let value: serde_json::Value = serde_json::from_str(&json)?;
assert_eq!(value["version"], "2");
assert_eq!(value["options"]["overlap"], "all");
assert_eq!(value["options"]["word_boundaries"], false);
let parsed = PersistedMatcherEnvelope::from_json(&json)?;
assert_eq!(parsed, envelope);
let restored = parsed.to_matcher()?;
assert_eq!(restored.options(), matcher.options());
let text = "aaa concatenate";
type Positioned = Vec<(String, Option<(usize, usize)>)>;
let positions = |m: &CompiledMatcher| -> Result<Positioned> {
Ok(m.find_matches(text, true)?
.into_iter()
.map(|found| (found.term, found.pos))
.collect())
};
let before = positions(&matcher)?;
assert_eq!(before, positions(&restored)?);
assert_eq!(
before,
vec![
("aa".to_string(), Some((0, 2))),
("aa".to_string(), Some((1, 3))),
("cat".to_string(), Some((7, 10))),
]
);
Ok(())
}
fn matcher_with(options: MatcherOptions) -> Result<CompiledMatcher> {
let mut builder = MatcherBuilder::new(options);
builder.insert("cat".to_string(), term("cat"))?;
builder.build()
}
fn written_version(options: MatcherOptions) -> Result<serde_json::Value> {
let matcher = matcher_with(options)?;
let json = PersistedMatcherEnvelope::from_matcher(&matcher).to_json()?;
let value: serde_json::Value = serde_json::from_str(&json)?;
Ok(value["version"].clone())
}
#[test]
fn default_overlap_and_boundaries_write_version_1()
-> std::result::Result<(), Box<dyn std::error::Error>> {
assert_eq!(written_version(MatcherOptions::default())?, "1");
assert_eq!(
written_version(
MatcherOptions::default()
.with_case_insensitive(false)
.with_min_pattern_length(3)
)?,
"1"
);
Ok(())
}
#[test]
fn non_default_overlap_or_boundaries_write_version_2()
-> std::result::Result<(), Box<dyn std::error::Error>> {
assert_eq!(
written_version(MatcherOptions::default().with_overlap(MatchOverlap::All))?,
"2"
);
assert_eq!(
written_version(MatcherOptions::default().with_word_boundaries(false))?,
"2"
);
assert_eq!(
written_version(
MatcherOptions::default()
.with_overlap(MatchOverlap::All)
.with_word_boundaries(false)
)?,
"2"
);
Ok(())
}
#[test]
fn version_2_round_trips_through_reader_and_writer()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let matcher = matcher_with(MatcherOptions::default().with_word_boundaries(false))?;
let envelope = PersistedMatcherEnvelope::from_matcher(&matcher);
let mut buf = Vec::new();
envelope.to_writer(&mut buf)?;
let restored_envelope = PersistedMatcherEnvelope::from_reader(&buf[..])?;
assert_eq!(restored_envelope, envelope);
assert_eq!(restored_envelope.payload.version(), 2);
let restored = restored_envelope.to_matcher()?;
assert_eq!(restored.options(), matcher.options());
assert_eq!(
restored.find_matches("concatenate", true)?,
matcher.find_matches("concatenate", true)?
);
assert_eq!(restored.find_matches("concatenate", true)?.len(), 1);
Ok(())
}
#[test]
fn version_1_with_non_default_fields_is_honoured()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let early = r#"{
"format_id": "terraphim.automata.matcher",
"version": "1",
"options": {
"case_insensitive": true,
"min_pattern_length": 2,
"overlap": "all",
"word_boundaries": false
},
"patterns": []
}"#;
let envelope = PersistedMatcherEnvelope::from_json(early)?;
assert_eq!(envelope.payload.version(), 1);
assert_eq!(
envelope.payload.options(),
&MatcherOptions::default()
.with_overlap(MatchOverlap::All)
.with_word_boundaries(false)
);
let resaved = PersistedMatcherEnvelope::from_matcher(&envelope.to_matcher()?);
assert_eq!(resaved.payload.version(), 2);
Ok(())
}
#[test]
fn version_2_requires_every_option_field() -> std::result::Result<(), Box<dyn std::error::Error>>
{
for missing in [
"case_insensitive",
"min_pattern_length",
"overlap",
"word_boundaries",
] {
let matcher = matcher_with(MatcherOptions::default().with_overlap(MatchOverlap::All))?;
let json = PersistedMatcherEnvelope::from_matcher(&matcher).to_json()?;
let mut value: serde_json::Value = serde_json::from_str(&json)?;
assert_eq!(value["version"], "2");
let options = value["options"]
.as_object_mut()
.ok_or("options is an object")?;
assert!(options.remove(missing).is_some());
let truncated = serde_json::to_string(&value)?;
let err = PersistedMatcherEnvelope::from_json(&truncated).unwrap_err();
assert!(
matches!(err, TerraphimAutomataError::MalformedPersisted { .. }),
"missing {missing}: {err}"
);
}
Ok(())
}
#[test]
fn version_3_is_rejected_as_unsupported() -> std::result::Result<(), Box<dyn std::error::Error>>
{
let matcher = matcher_with(MatcherOptions::default().with_overlap(MatchOverlap::All))?;
let json = PersistedMatcherEnvelope::from_matcher(&matcher).to_json()?;
let mut value: serde_json::Value = serde_json::from_str(&json)?;
value["version"] = serde_json::Value::String("3".to_string());
let err = PersistedMatcherEnvelope::from_json(&serde_json::to_string(&value)?).unwrap_err();
assert!(matches!(
err,
TerraphimAutomataError::UnsupportedVersion { found: Some(ref v), .. } if v == "3"
));
Ok(())
}
#[test]
fn bad_option_value_is_malformed_not_unsupported_version()
-> std::result::Result<(), Box<dyn std::error::Error>> {
for version in ["1", "2"] {
let json = format!(
r#"{{"format_id":"terraphim.automata.matcher","version":"{version}",
"options":{{"case_insensitive":true,"min_pattern_length":2,
"overlap":"bogus","word_boundaries":true}},"patterns":[]}}"#
);
let err = PersistedMatcherEnvelope::from_json(&json).unwrap_err();
assert!(
matches!(err, TerraphimAutomataError::MalformedPersisted { .. }),
"version {version}: {err}"
);
}
Ok(())
}
#[test]
fn non_string_version_tag_is_malformed() -> std::result::Result<(), Box<dyn std::error::Error>>
{
let json = r#"{"format_id":"terraphim.automata.matcher","version":1,
"options":{"case_insensitive":true,"min_pattern_length":2},"patterns":[]}"#;
let err = PersistedMatcherEnvelope::from_json(json).unwrap_err();
assert!(
matches!(err, TerraphimAutomataError::MalformedPersisted { .. }),
"{err}"
);
Ok(())
}
fn blank_pattern_matcher() -> Result<CompiledMatcher> {
let options = MatcherOptions::default()
.with_allow_blank_patterns(true)
.with_overlap(MatchOverlap::All)
.with_word_boundaries(false);
let mut builder = MatcherBuilder::new(options);
builder.insert(" ".to_string(), term("gap"))?;
builder.insert("cat".to_string(), term("cat"))?;
builder.build()
}
#[test]
fn allow_blank_patterns_writes_version_2_and_round_trips()
-> std::result::Result<(), Box<dyn std::error::Error>> {
assert_eq!(
written_version(MatcherOptions::default().with_allow_blank_patterns(true))?,
"2"
);
let matcher = blank_pattern_matcher()?;
let envelope = PersistedMatcherEnvelope::from_matcher(&matcher);
assert_eq!(envelope.payload.version(), 2);
let json = envelope.to_json()?;
let value: serde_json::Value = serde_json::from_str(&json)?;
assert_eq!(value["options"]["allow_blank_patterns"], true);
let parsed = PersistedMatcherEnvelope::from_json(&json)?;
assert_eq!(parsed, envelope);
let restored = parsed.to_matcher()?;
assert_eq!(restored.options(), matcher.options());
let text = "a cat";
assert_eq!(
restored.find_matches(text, true)?,
matcher.find_matches(text, true)?
);
assert_eq!(restored.find_matches(text, true)?.len(), 4);
Ok(())
}
#[test]
fn default_envelope_stays_version_1_with_allow_blank_patterns_false()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let json = PersistedMatcherEnvelope::from_matcher(&sample_matcher()?).to_json()?;
let value: serde_json::Value = serde_json::from_str(&json)?;
assert_eq!(value["version"], "1");
assert_eq!(value["options"]["allow_blank_patterns"], false);
Ok(())
}
#[test]
fn version_2_envelope_from_2_0_0_loads_without_allow_blank_patterns()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let legacy = r#"{
"format_id": "terraphim.automata.matcher",
"version": "2",
"options": {
"case_insensitive": true,
"min_pattern_length": 2,
"overlap": "all",
"word_boundaries": false
},
"patterns": [["cat", {"id": 1, "nterm": "cat"}]]
}"#;
let envelope = PersistedMatcherEnvelope::from_json(legacy)?;
assert_eq!(envelope.payload.version(), 2);
assert_eq!(
envelope.payload.options(),
&MatcherOptions::default()
.with_overlap(MatchOverlap::All)
.with_word_boundaries(false)
);
let matcher = envelope.to_matcher()?;
assert_eq!(matcher.find_matches("concatenate", true)?.len(), 1);
Ok(())
}
#[test]
fn version_1_with_allow_blank_patterns_is_honoured()
-> std::result::Result<(), Box<dyn std::error::Error>> {
let early = r#"{
"format_id": "terraphim.automata.matcher",
"version": "1",
"options": {
"case_insensitive": true,
"min_pattern_length": 2,
"allow_blank_patterns": true
},
"patterns": [[" ", {"id": 1, "nterm": "gap"}]]
}"#;
let envelope = PersistedMatcherEnvelope::from_json(early)?;
assert!(envelope.payload.options().allow_blank_patterns);
let matcher = envelope.to_matcher()?;
assert_eq!(matcher.find_matches("- -", true)?.len(), 1);
assert_eq!(
PersistedMatcherEnvelope::from_matcher(&matcher)
.payload
.version(),
2
);
Ok(())
}
#[test]
fn reader_and_writer_round_trip() -> std::result::Result<(), Box<dyn std::error::Error>> {
let matcher = sample_matcher()?;
let envelope = PersistedMatcherEnvelope::from_matcher(&matcher);
let mut buf = Vec::new();
envelope.to_writer(&mut buf).unwrap();
let restored = PersistedMatcherEnvelope::from_reader(&buf[..]).unwrap();
assert_eq!(envelope, restored);
Ok(())
}
}