use std::collections::{HashMap, HashSet};
use regex::RegexBuilder;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::catalog::Catalog;
use crate::config::HarnessConfig;
use crate::error::MifRhError;
use crate::finding::Finding;
use crate::ontology_pack::OntologyPack;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Basis {
Declared,
Resolved,
Discovery,
Untyped,
Unresolved,
Ambiguous,
}
impl Basis {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Declared => "declared",
Self::Resolved => "resolved",
Self::Discovery => "discovery",
Self::Untyped => "untyped",
Self::Unresolved => "unresolved",
Self::Ambiguous => "ambiguous",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MapRecord {
pub finding_id: String,
pub entity_type: Option<String>,
pub resolved_ontology: Option<String>,
pub basis: Basis,
pub valid: bool,
}
pub struct ResolveContext<'a> {
pub topic: &'a str,
pub catalog: &'a Catalog,
pub config: &'a HarnessConfig,
pub ontology_packs: &'a HashMap<String, OntologyPack>,
}
pub fn build_allowed<'a>(ctx: &ResolveContext<'a>) -> Result<Vec<&'a OntologyPack>, MifRhError> {
let mut direct_ids: HashSet<String> = ctx.catalog.core_ids().map(str::to_string).collect();
for binding in ctx.config.topic_bindings(ctx.topic) {
let entry =
ctx.catalog
.find(&binding.id)
.ok_or_else(|| MifRhError::DirectBindingInvalid {
topic: ctx.topic.to_string(),
id: binding.id.clone(),
})?;
if let Some(pinned) = &binding.pinned_version
&& *pinned != entry.version
{
return Err(MifRhError::DirectBindingInvalid {
topic: ctx.topic.to_string(),
id: binding.id.clone(),
});
}
direct_ids.insert(binding.id);
}
let metadata_map: HashMap<String, mif_ontology::OntologyMetadata> = ctx
.ontology_packs
.values()
.map(|pack| {
(
pack.id.clone(),
mif_ontology::OntologyMetadata {
id: pack.id.clone(),
version: pack.version.clone(),
description: None,
extends: pack.extends.clone(),
},
)
})
.collect();
let mut allowed_ids: HashSet<String> = HashSet::new();
for id in &direct_ids {
let chain = mif_ontology::resolve_chain(id, &metadata_map)?;
allowed_ids.extend(chain.into_iter().map(|m| m.id));
}
allowed_ids.extend(direct_ids);
Ok(allowed_ids
.iter()
.filter_map(|id| ctx.ontology_packs.get(id))
.collect())
}
fn discovery_classify(finding: &Finding, allowed: &[&OntologyPack]) -> MapRecord {
let text = finding.discovery_text();
let mut matches: Vec<(&str, &str)> = Vec::new();
for pack in allowed {
if !pack.discovery.enabled {
continue;
}
for pattern in &pack.discovery.patterns {
let Some(content_pattern) = &pattern.content_pattern else {
continue;
};
let Ok(re) = RegexBuilder::new(content_pattern)
.case_insensitive(true)
.build()
else {
continue;
};
if re.is_match(&text)
&& let Some(entity_type) = pattern.suggest_entity.as_deref()
{
matches.push((entity_type, pack.id.as_str()));
}
}
}
let unique: HashSet<(&str, &str)> = matches.iter().copied().collect();
if unique.len() == 1 {
let (entity_type, ontology_id) = matches[0];
let version = allowed
.iter()
.find(|p| p.id == ontology_id)
.map_or_else(String::new, |p| p.version.clone());
MapRecord {
finding_id: finding.id.clone(),
entity_type: Some(entity_type.to_string()),
resolved_ontology: Some(format!("{ontology_id}@{version}")),
basis: Basis::Discovery,
valid: true,
}
} else {
MapRecord {
finding_id: finding.id.clone(),
entity_type: None,
resolved_ontology: None,
basis: Basis::Untyped,
valid: true,
}
}
}
fn unresolved(finding_id: &str, entity_type: Option<&str>) -> MapRecord {
MapRecord {
finding_id: finding_id.to_string(),
entity_type: entity_type.map(str::to_string),
resolved_ontology: None,
basis: Basis::Unresolved,
valid: false,
}
}
fn full_entity_schema(entity_type_schema: &Value) -> Value {
let required = entity_type_schema
.get("required")
.cloned()
.unwrap_or_else(|| json!([]));
let properties = entity_type_schema
.get("properties")
.cloned()
.unwrap_or_else(|| json!({}));
json!({
"type": "object",
"additionalProperties": true,
"required": required,
"properties": properties,
})
}
fn validate_entity(
entity: Option<&Value>,
entity_type: &str,
schema: &Value,
) -> Result<bool, MifRhError> {
let Some(entity) = entity else {
return Ok(false);
};
let full_schema = full_entity_schema(schema);
let validator = jsonschema::options()
.build(&full_schema)
.map_err(|source| MifRhError::EntityTypeSchemaInvalid {
entity_type: entity_type.to_string(),
detail: source.to_string(),
})?;
Ok(validator.is_valid(entity))
}
pub fn resolve_finding(
finding: &Finding,
ctx: &ResolveContext<'_>,
) -> Result<MapRecord, MifRhError> {
if !finding.has_typing_intent() {
let allowed = build_allowed(ctx)?;
return Ok(discovery_classify(finding, &allowed));
}
let Some(entity_type) = finding.entity_type() else {
return Ok(unresolved(&finding.id, None));
};
let allowed = build_allowed(ctx)?;
let matches: Vec<(&OntologyPack, &crate::ontology_pack::EntityTypeDef)> = allowed
.iter()
.filter_map(|pack| {
pack.entity_types
.iter()
.find(|et| et.name == entity_type)
.map(|def| (*pack, def))
})
.collect();
let declared_ontology_id = finding.ontology.as_ref().map(|o| o.id.as_str());
let (pack, entity_type_def, basis) = match matches.len() {
0 => return Ok(unresolved(&finding.id, Some(entity_type))),
1 => {
let (pack, def) = matches[0];
match declared_ontology_id {
Some(oid) if oid != pack.id => {
return Ok(unresolved(&finding.id, Some(entity_type)));
},
Some(_) => (pack, def, Basis::Declared),
None => (pack, def, Basis::Resolved),
}
},
_ => {
let Some(oid) = declared_ontology_id else {
return Ok(MapRecord {
finding_id: finding.id.clone(),
entity_type: Some(entity_type.to_string()),
resolved_ontology: None,
basis: Basis::Ambiguous,
valid: false,
});
};
match matches.into_iter().find(|(pack, _)| pack.id == oid) {
Some((pack, def)) => (pack, def, Basis::Declared),
None => {
return Ok(MapRecord {
finding_id: finding.id.clone(),
entity_type: Some(entity_type.to_string()),
resolved_ontology: None,
basis: Basis::Ambiguous,
valid: false,
});
},
}
},
};
let valid = validate_entity(
finding.entity.as_ref(),
entity_type,
&entity_type_def.schema,
)?;
Ok(MapRecord {
finding_id: finding.id.clone(),
entity_type: Some(entity_type.to_string()),
resolved_ontology: Some(format!("{}@{}", pack.id, pack.version)),
basis,
valid,
})
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use serde_json::json;
use super::{Basis, ResolveContext, resolve_finding};
use crate::catalog::{Catalog, CatalogEntry};
use crate::config::{HarnessConfig, TopicConfig};
use crate::finding::Finding;
use crate::ontology_pack::parse_pack;
fn finding_from_json(value: serde_json::Value) -> Finding {
serde_json::from_value(value).unwrap()
}
fn edu_fixture_pack() -> crate::ontology_pack::OntologyPack {
parse_pack(
"
ontology:
id: edu-fixture
version: \"0.1.0\"
entity_types:
- name: title
schema:
required: [name, isbn]
properties: {name: {type: string}, isbn: {type: string}}
discovery:
enabled: true
patterns:
- content_pattern: \"\\\\b(ISBN|textbook)\\\\b\"
suggest_entity: title
",
"edu-fixture.yaml",
)
.unwrap()
}
fn ctx_fixture<'a>(
packs: &'a HashMap<String, crate::ontology_pack::OntologyPack>,
catalog: &'a Catalog,
config: &'a HarnessConfig,
topic: &'a str,
) -> ResolveContext<'a> {
ResolveContext {
topic,
catalog,
config,
ontology_packs: packs,
}
}
fn edu_setup() -> (
HashMap<String, crate::ontology_pack::OntologyPack>,
Catalog,
HarnessConfig,
) {
let mut packs = HashMap::new();
packs.insert("edu-fixture".to_string(), edu_fixture_pack());
let catalog = Catalog {
ontologies: vec![CatalogEntry {
id: "edu-fixture".to_string(),
version: "0.1.0".to_string(),
source: None,
core: false,
}],
};
let config = HarnessConfig {
topics: vec![TopicConfig {
id: "edu".to_string(),
ontologies: vec!["edu-fixture".to_string()],
}],
};
(packs, catalog, config)
}
#[test]
fn resolves_a_declared_type_and_validates_the_entity() {
let (packs, catalog, config) = edu_setup();
let ctx = ctx_fixture(&packs, &catalog, &config, "edu");
let finding = finding_from_json(json!({
"@id": "f-good",
"entity": {"name": "Algebra I", "entity_type": "title", "isbn": "9780000000002"}
}));
let record = resolve_finding(&finding, &ctx).unwrap();
assert_eq!(record.basis, Basis::Resolved);
assert!(record.valid);
assert_eq!(
record.resolved_ontology.as_deref(),
Some("edu-fixture@0.1.0")
);
}
#[test]
fn extra_properties_are_allowed_additively() {
let (packs, catalog, config) = edu_setup();
let ctx = ctx_fixture(&packs, &catalog, &config, "edu");
let finding = finding_from_json(json!({
"@id": "f-extra",
"entity": {"name": "Algebra I", "entity_type": "title", "isbn": "9780000000002", "vibe": "x"}
}));
let record = resolve_finding(&finding, &ctx).unwrap();
assert!(record.valid);
}
#[test]
fn missing_required_field_fails_validation_but_still_records() {
let (packs, catalog, config) = edu_setup();
let ctx = ctx_fixture(&packs, &catalog, &config, "edu");
let finding = finding_from_json(json!({
"@id": "f-missing",
"entity": {"entity_type": "title"}
}));
let record = resolve_finding(&finding, &ctx).unwrap();
assert_eq!(record.basis, Basis::Resolved);
assert!(!record.valid);
}
#[test]
fn undeclared_type_is_unresolved() {
let (packs, catalog, config) = edu_setup();
let ctx = ctx_fixture(&packs, &catalog, &config, "edu");
let finding = finding_from_json(json!({
"@id": "f-undecl",
"entity": {"entity_type": "not-a-type"}
}));
let record = resolve_finding(&finding, &ctx).unwrap();
assert_eq!(record.basis, Basis::Unresolved);
assert!(!record.valid);
}
#[test]
fn untyped_finding_with_no_discovery_match_is_untyped() {
let (packs, catalog, config) = edu_setup();
let ctx = ctx_fixture(&packs, &catalog, &config, "edu");
let finding = finding_from_json(json!({"@id": "f-untyped", "content": "nothing special"}));
let record = resolve_finding(&finding, &ctx).unwrap();
assert_eq!(record.basis, Basis::Untyped);
assert!(record.valid);
assert_eq!(record.entity_type, None);
}
#[test]
fn discovery_pattern_classifies_an_untyped_finding() {
let (packs, catalog, config) = edu_setup();
let ctx = ctx_fixture(&packs, &catalog, &config, "edu");
let finding = finding_from_json(
json!({"@id": "f-discovery", "content": "a great textbook, ISBN included"}),
);
let record = resolve_finding(&finding, &ctx).unwrap();
assert_eq!(record.basis, Basis::Discovery);
assert_eq!(record.entity_type.as_deref(), Some("title"));
assert!(record.valid);
}
#[test]
fn declared_type_on_an_unbound_topic_only_resolves_core() {
let (packs, catalog, config) = edu_setup();
let ctx = ctx_fixture(&packs, &catalog, &config, "bare");
let finding = finding_from_json(json!({
"@id": "f-good",
"entity": {"name": "x", "entity_type": "title", "isbn": "y"}
}));
let record = resolve_finding(&finding, &ctx).unwrap();
assert_eq!(record.basis, Basis::Unresolved);
}
#[test]
fn ambiguous_type_across_two_bound_ontologies_without_explicit_id() {
let mut packs = HashMap::new();
packs.insert(
"a".to_string(),
parse_pack(
"ontology:\n id: a\n version: \"1.0.0\"\nentity_types:\n - name: technology\n schema: {}\n",
"a.yaml",
)
.unwrap(),
);
packs.insert(
"b".to_string(),
parse_pack(
"ontology:\n id: b\n version: \"1.0.0\"\nentity_types:\n - name: technology\n schema: {}\n",
"b.yaml",
)
.unwrap(),
);
let catalog = Catalog {
ontologies: vec![
CatalogEntry {
id: "a".to_string(),
version: "1.0.0".to_string(),
source: None,
core: false,
},
CatalogEntry {
id: "b".to_string(),
version: "1.0.0".to_string(),
source: None,
core: false,
},
],
};
let config = HarnessConfig {
topics: vec![TopicConfig {
id: "eng".to_string(),
ontologies: vec!["a".to_string(), "b".to_string()],
}],
};
let ctx = ctx_fixture(&packs, &catalog, &config, "eng");
let ambiguous = finding_from_json(json!({
"@id": "f-amb",
"entity": {"entity_type": "technology"}
}));
let record = resolve_finding(&ambiguous, &ctx).unwrap();
assert_eq!(record.basis, Basis::Ambiguous);
let disambiguated = finding_from_json(json!({
"@id": "f-disambig",
"entity": {"entity_type": "technology"},
"ontology": {"id": "b"}
}));
let record = resolve_finding(&disambiguated, &ctx).unwrap();
assert_eq!(record.basis, Basis::Declared);
assert_eq!(record.resolved_ontology.as_deref(), Some("b@1.0.0"));
let wrong_id = finding_from_json(json!({
"@id": "f-wrong-id",
"entity": {"entity_type": "technology"},
"ontology": {"id": "c"}
}));
let record = resolve_finding(&wrong_id, &ctx).unwrap();
assert_eq!(record.basis, Basis::Ambiguous);
assert!(!record.valid);
}
#[test]
fn file_pattern_only_discovery_entry_is_skipped_not_treated_as_match_all() {
let mut packs = HashMap::new();
packs.insert(
"se-fixture".to_string(),
parse_pack(
"
ontology:
id: se-fixture
version: \"0.1.0\"
entity_types:
- name: decision
schema: {}
discovery:
enabled: true
patterns:
- file_pattern: \"*.md\"
- content_pattern: \"\\\\bADR\\\\b\"
suggest_entity: decision
",
"se-fixture.yaml",
)
.unwrap(),
);
let catalog = Catalog {
ontologies: vec![CatalogEntry {
id: "se-fixture".to_string(),
version: "0.1.0".to_string(),
source: None,
core: false,
}],
};
let config = HarnessConfig {
topics: vec![TopicConfig {
id: "eng".to_string(),
ontologies: vec!["se-fixture".to_string()],
}],
};
let ctx = ctx_fixture(&packs, &catalog, &config, "eng");
let finding = finding_from_json(json!({
"@id": "f-no-match",
"content": "totally unrelated prose about lunch"
}));
let record = resolve_finding(&finding, &ctx).unwrap();
assert_eq!(record.basis, Basis::Untyped);
assert_eq!(record.entity_type, None);
}
#[test]
fn topic_binding_an_uncataloged_ontology_is_a_hard_error() {
let (packs, catalog, _config) = edu_setup();
let config = HarnessConfig {
topics: vec![TopicConfig {
id: "edu".to_string(),
ontologies: vec!["not-cataloged".to_string()],
}],
};
let ctx = ctx_fixture(&packs, &catalog, &config, "edu");
let finding = finding_from_json(json!({"@id": "f-x", "content": "x"}));
let error = resolve_finding(&finding, &ctx).unwrap_err();
assert!(matches!(
error,
super::MifRhError::DirectBindingInvalid { .. }
));
}
}