use std::io::Read;
use serde::{Deserialize, Serialize};
use terraphim_types::NormalizedTerm;
use crate::compiled::{CompiledMatcher, MatcherBuilder, MatcherOptions};
use crate::{Result, TerraphimAutomataError};
pub const MATCHER_FORMAT_ID: &str = "terraphim.automata.matcher";
pub const MATCHER_FORMAT_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PersistedMatcherV1 {
pub options: MatcherOptions,
pub patterns: Vec<(String, NormalizedTerm)>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "version")]
pub enum PersistedMatcherPayload {
#[serde(rename = "1")]
V1(PersistedMatcherV1),
}
#[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 {
Self {
format_id: MATCHER_FORMAT_ID.to_string(),
payload: PersistedMatcherPayload::V1(PersistedMatcherV1 {
options: matcher.options().clone(),
patterns: matcher.patterns().to_vec(),
}),
}
}
}
impl PersistedMatcherEnvelope {
pub fn from_matcher(matcher: &CompiledMatcher) -> Self {
Self::from(matcher)
}
pub fn to_matcher(&self) -> Result<CompiledMatcher> {
let PersistedMatcherPayload::V1(data) = &self.payload;
let mut builder = MatcherBuilder::new(data.options.clone());
for (pattern, term) in &data.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,
}
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(),
});
}
let envelope: Self = serde_json::from_str(json).map_err(|err| {
if err.to_string().contains("unknown variant") {
TerraphimAutomataError::UnsupportedVersion {
format_id: MATCHER_FORMAT_ID.to_string(),
found: extract_reported_version(err.to_string()),
supported: supported_versions(),
}
} else {
TerraphimAutomataError::MalformedPersisted {
format_id: MATCHER_FORMAT_ID.to_string(),
reason: err.to_string(),
}
}
})?;
Ok(envelope)
}
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> {
vec![MATCHER_FORMAT_VERSION]
}
fn extract_reported_version(message: String) -> Option<String> {
message
.split("unknown variant `")
.nth(1)
.and_then(|rest| rest.split('`').next())
.map(str::to_string)
}
#[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 {
case_insensitive: false,
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]);
}
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 {
case_insensitive: true,
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 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(())
}
}