#[allow(unused_imports)]
pub use crate::expr::eval::{FactType, FactValue};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FactValueType {
Text,
Num,
Time,
}
impl std::fmt::Display for FactValueType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
FactValueType::Text => write!(f, "text"),
FactValueType::Num => write!(f, "num"),
FactValueType::Time => write!(f, "time"),
}
}
}
#[derive(Debug, Clone)]
pub struct SourceFact {
pub id: i64,
pub key: String,
pub value_text: Option<String>,
pub value_num: Option<f64>,
pub value_time: Option<i64>,
pub observed_at: i64,
}
pub fn normalize_fact_key(key: &str) -> Result<String, &'static str> {
if key.starts_with("source.") {
return Err("source.* namespace is reserved for built-in facts");
}
if key.starts_with("content.") {
return Ok(key.to_string());
}
Ok(format!("content.{key}"))
}
pub fn is_content_fact(key: &str) -> bool {
key.starts_with("content.")
}
#[derive(Debug, Clone)]
pub struct FactEntry {
pub key: String,
pub value: FactValue,
#[allow(dead_code)]
pub entity_type: String,
#[allow(dead_code)]
pub entity_id: i64,
}
impl FactEntry {
pub fn new(key: String, value: FactValue, entity_type: String, entity_id: i64) -> Self {
Self {
key,
value,
entity_type,
entity_id,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fact_entry_new_creates_entry() {
let entry = FactEntry::new(
"content.Make".to_string(),
FactValue::Text("Canon".to_string()),
"object".to_string(),
42,
);
assert_eq!(entry.key, "content.Make");
assert_eq!(entry.entity_type, "object");
assert_eq!(entry.entity_id, 42);
match entry.value {
FactValue::Text(s) => assert_eq!(s, "Canon"),
_ => panic!("Expected Text variant"),
}
}
#[test]
fn fact_entry_with_num_value() {
let entry = FactEntry::new(
"source.size".to_string(),
FactValue::Num(1024.0),
"source".to_string(),
1,
);
match entry.value {
FactValue::Num(n) => assert_eq!(n, 1024.0),
_ => panic!("Expected Num variant"),
}
}
#[test]
fn fact_entry_with_time_value() {
let entry = FactEntry::new(
"content.DateTimeOriginal".to_string(),
FactValue::Time(1704067200), "object".to_string(),
100,
);
match entry.value {
FactValue::Time(ts) => assert_eq!(ts, 1704067200),
_ => panic!("Expected Time variant"),
}
}
#[test]
fn fact_entry_with_path_value() {
let entry = FactEntry::new(
"source.rel_path".to_string(),
FactValue::Path("/photos/2024/image.jpg".to_string()),
"source".to_string(),
5,
);
match entry.value {
FactValue::Path(p) => assert_eq!(p, "/photos/2024/image.jpg"),
_ => panic!("Expected Path variant"),
}
}
#[test]
fn fact_entry_clone_creates_independent_copy() {
let original = FactEntry::new(
"content.Make".to_string(),
FactValue::Text("Canon".to_string()),
"object".to_string(),
42,
);
let cloned = original.clone();
assert_eq!(cloned.key, original.key);
assert_eq!(cloned.entity_type, original.entity_type);
assert_eq!(cloned.entity_id, original.entity_id);
}
#[test]
fn fact_value_type_display() {
assert_eq!(format!("{}", FactValueType::Text), "text");
assert_eq!(format!("{}", FactValueType::Num), "num");
assert_eq!(format!("{}", FactValueType::Time), "time");
}
#[test]
fn fact_value_type_equality() {
assert_eq!(FactValueType::Text, FactValueType::Text);
assert_ne!(FactValueType::Text, FactValueType::Num);
assert_ne!(FactValueType::Num, FactValueType::Time);
}
#[test]
fn normalize_fact_key_adds_content_prefix() {
assert_eq!(normalize_fact_key("Make"), Ok("content.Make".to_string()));
assert_eq!(
normalize_fact_key("hash.sha256"),
Ok("content.hash.sha256".to_string())
);
assert_eq!(
normalize_fact_key("DateTimeOriginal"),
Ok("content.DateTimeOriginal".to_string())
);
}
#[test]
fn normalize_fact_key_preserves_content_prefix() {
assert_eq!(
normalize_fact_key("content.Make"),
Ok("content.Make".to_string())
);
assert_eq!(
normalize_fact_key("content.hash.sha256"),
Ok("content.hash.sha256".to_string())
);
}
#[test]
fn normalize_fact_key_rejects_source_namespace() {
assert!(normalize_fact_key("source.size").is_err());
assert!(normalize_fact_key("source.mtime").is_err());
assert!(normalize_fact_key("source.ext").is_err());
let err = normalize_fact_key("source.size").unwrap_err();
assert!(err.contains("reserved"));
}
#[test]
fn is_content_fact_returns_true_for_content_keys() {
assert!(is_content_fact("content.Make"));
assert!(is_content_fact("content.hash.sha256"));
assert!(is_content_fact("content.DateTimeOriginal"));
}
#[test]
fn is_content_fact_returns_false_for_other_keys() {
assert!(!is_content_fact("source.size"));
assert!(!is_content_fact("Make")); assert!(!is_content_fact("policy.reviewed"));
}
#[test]
fn source_fact_construction() {
let fact = SourceFact {
id: 1,
key: "content.Make".to_string(),
value_text: Some("Canon".to_string()),
value_num: None,
value_time: None,
observed_at: 1704067200,
};
assert_eq!(fact.id, 1);
assert_eq!(fact.key, "content.Make");
assert_eq!(fact.value_text, Some("Canon".to_string()));
assert!(fact.value_num.is_none());
assert!(fact.value_time.is_none());
assert_eq!(fact.observed_at, 1704067200);
}
#[test]
fn source_fact_with_num_value() {
let fact = SourceFact {
id: 2,
key: "content.Duration".to_string(),
value_text: None,
value_num: Some(120.5),
value_time: None,
observed_at: 1704067200,
};
assert_eq!(fact.value_num, Some(120.5));
}
#[test]
fn source_fact_with_time_value() {
let fact = SourceFact {
id: 3,
key: "content.DateTimeOriginal".to_string(),
value_text: None,
value_num: None,
value_time: Some(1704067200),
observed_at: 1704067200,
};
assert_eq!(fact.value_time, Some(1704067200));
}
}