use crate::domain::Domain;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Entry {
pub id: String,
pub title: String,
pub domain: Domain,
pub summary: String,
pub kind: EntryKind,
pub source: String,
pub tags: Vec<String>,
#[serde(default)]
pub related: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub enum EntryKind {
Fact(Fact),
Constant(Constant),
Procedure(Procedure),
Table(Table),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Fact {
pub statement: String,
pub explanation: String,
pub verification: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Constant {
pub symbol: String,
pub value: String,
pub unit: String,
pub numeric: f64,
pub uncertainty: Option<String>,
pub authority: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Procedure {
pub when: String,
pub steps: Vec<String>,
pub warnings: Vec<String>,
pub requires: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Table {
pub columns: Vec<String>,
pub rows: Vec<Vec<String>>,
pub description: String,
}
impl Entry {
#[must_use]
pub fn new(
id: impl Into<String>,
title: impl Into<String>,
domain: Domain,
summary: impl Into<String>,
kind: EntryKind,
source: impl Into<String>,
tags: Vec<String>,
) -> Self {
Self {
id: id.into(),
title: title.into(),
domain,
summary: summary.into(),
kind,
source: source.into(),
tags,
related: Vec::new(),
}
}
#[must_use]
#[inline]
pub fn has_tag(&self, tag: &str) -> bool {
self.tags.iter().any(|t| t.eq_ignore_ascii_case(tag))
}
#[must_use]
#[inline]
pub fn estimated_size(&self) -> usize {
self.summary.len()
+ self.source.len()
+ self.related.iter().map(|r| r.len()).sum::<usize>()
+ match &self.kind {
EntryKind::Fact(f) => f.statement.len() + f.explanation.len(),
EntryKind::Constant(c) => c.value.len() + c.unit.len() + c.authority.len(),
EntryKind::Procedure(p) => {
p.steps.iter().map(|s| s.len()).sum::<usize>()
+ p.warnings.iter().map(|w| w.len()).sum::<usize>()
}
EntryKind::Table(t) => t.rows.iter().flat_map(|r| r.iter()).map(|c| c.len()).sum(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_constant() -> Entry {
Entry {
id: "speed_of_light".into(),
title: "Speed of Light in Vacuum".into(),
domain: Domain::Physics,
summary: "The speed of light in vacuum, a fundamental physical constant.".into(),
kind: EntryKind::Constant(Constant {
symbol: "c".into(),
value: "299792458".into(),
unit: "m/s".into(),
numeric: 299_792_458.0,
uncertainty: None,
authority: "CODATA 2022 (exact)".into(),
}),
source: "prakash, tanmatra".into(),
tags: vec![
"light".into(),
"speed".into(),
"fundamental".into(),
"exact".into(),
],
related: vec![],
}
}
fn sample_procedure() -> Entry {
Entry {
id: "cpr_adult".into(),
title: "CPR for Adults".into(),
domain: Domain::Medicine,
summary: "Cardiopulmonary resuscitation for unresponsive adults.".into(),
kind: EntryKind::Procedure(Procedure {
when: "Person is unresponsive and not breathing normally.".into(),
steps: vec![
"Call emergency services.".into(),
"Place heel of hand on center of chest.".into(),
"Push hard and fast — 100-120 compressions per minute, 2 inches deep.".into(),
"After 30 compressions, give 2 rescue breaths.".into(),
"Continue until help arrives or person recovers.".into(),
],
warnings: vec![
"Do not stop compressions to check for pulse.".into(),
"Push hard enough — ribs may crack, that's expected.".into(),
],
requires: vec!["Flat surface".into()],
}),
source: "Red Cross First Aid Manual".into(),
tags: vec!["first-aid".into(), "emergency".into(), "cardiac".into()],
related: vec![],
}
}
#[test]
fn entry_has_tag() {
let e = sample_constant();
assert!(e.has_tag("light"));
assert!(e.has_tag("LIGHT"));
assert!(!e.has_tag("gravity"));
}
#[test]
fn entry_estimated_size() {
let e = sample_constant();
assert!(e.estimated_size() > 0);
}
#[test]
fn constant_numeric() {
if let EntryKind::Constant(c) = &sample_constant().kind {
assert!((c.numeric - 299_792_458.0).abs() < 1.0);
} else {
panic!("expected constant");
}
}
#[test]
fn procedure_steps() {
if let EntryKind::Procedure(p) = &sample_procedure().kind {
assert_eq!(p.steps.len(), 5);
assert!(!p.warnings.is_empty());
} else {
panic!("expected procedure");
}
}
#[test]
fn entry_serde_roundtrip() {
let e = sample_constant();
let json = serde_json::to_string(&e).unwrap();
let decoded: Entry = serde_json::from_str(&json).unwrap();
assert_eq!(e.id, decoded.id);
assert_eq!(e.domain, decoded.domain);
}
#[test]
fn procedure_serde_roundtrip() {
let e = sample_procedure();
let json = serde_json::to_string(&e).unwrap();
let decoded: Entry = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.id, "cpr_adult");
}
#[test]
fn has_tag_case_insensitive() {
let e = sample_constant();
assert!(e.has_tag("Light"));
assert!(e.has_tag("EXACT"));
assert!(e.has_tag("fundamental"));
}
#[test]
fn has_tag_no_match() {
let e = sample_constant();
assert!(!e.has_tag("quantum"));
assert!(!e.has_tag(""));
}
#[test]
fn estimated_size_table() {
let e = Entry::new(
"elements",
"Periodic Table",
Domain::Chemistry,
"The periodic table.",
EntryKind::Table(Table {
columns: vec!["Symbol".into(), "Name".into()],
rows: vec![
vec!["H".into(), "Hydrogen".into()],
vec!["He".into(), "Helium".into()],
],
description: "First two elements.".into(),
}),
"kimiya",
vec![],
);
assert!(e.estimated_size() > 0);
}
#[test]
fn entry_new_constructor() {
let e = Entry::new(
"test",
"Test Entry",
Domain::Mathematics,
"A test.",
EntryKind::Fact(Fact {
statement: "1+1=2".into(),
explanation: "Arithmetic.".into(),
verification: Some("test_addition".into()),
}),
"hisab",
vec!["math".into()],
);
assert_eq!(e.id, "test");
assert_eq!(e.domain, Domain::Mathematics);
assert!(e.has_tag("math"));
assert!(
e.related.is_empty(),
"new entries should have empty related"
);
}
#[test]
fn estimated_size_procedure() {
let e = sample_procedure();
let size = e.estimated_size();
assert!(size > 0);
if let EntryKind::Procedure(p) = &e.kind {
let step_size: usize = p.steps.iter().map(|s| s.len()).sum();
assert!(size >= step_size);
}
}
#[test]
fn estimated_size_fact() {
let e = Entry::new(
"fact",
"Fact",
Domain::Mathematics,
"A fact.",
EntryKind::Fact(Fact {
statement: "Water boils at 100C at 1 atm.".into(),
explanation: "Standard boiling point.".into(),
verification: None,
}),
"test",
vec![],
);
assert!(e.estimated_size() > 0);
}
#[test]
fn estimated_size_includes_related() {
let mut e = sample_constant();
let size_without = e.estimated_size();
e.related = vec!["some_related_entry".into(), "another_entry".into()];
let size_with = e.estimated_size();
assert!(size_with > size_without);
}
#[test]
fn serde_roundtrip_with_related() {
let mut e = sample_constant();
e.related = vec!["pi".into(), "planck".into()];
let json = serde_json::to_string(&e).unwrap();
let decoded: Entry = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.related, vec!["pi", "planck"]);
}
#[test]
fn serde_roundtrip_without_related() {
let json = r#"{
"id": "test",
"title": "Test",
"domain": "Physics",
"summary": "Test entry.",
"kind": {"Fact": {"statement": "x", "explanation": "y", "verification": null}},
"source": "test",
"tags": []
}"#;
let decoded: Entry = serde_json::from_str(json).unwrap();
assert!(decoded.related.is_empty());
}
}