use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Kind {
SoundChange,
Cognacy,
Borrowing,
Other,
}
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
#[default]
Proposed,
Supported,
Refuted,
Retired,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Hypothesis {
pub id: String,
pub kind: Kind,
pub claim: String,
#[serde(default)]
pub note: String,
#[serde(default)]
pub evidence: Vec<String>,
#[serde(default)]
pub status: Status,
}
impl Kind {
pub fn parse(s: &str) -> Option<Kind> {
match s.trim().to_ascii_lowercase().replace(['_', ' '], "-").as_str() {
"sound-change" | "sound" | "change" | "rule" => Some(Kind::SoundChange),
"cognacy" | "cognate" | "cognates" => Some(Kind::Cognacy),
"borrowing" | "loan" | "borrow" => Some(Kind::Borrowing),
"other" => Some(Kind::Other),
_ => None,
}
}
pub fn label(self) -> &'static str {
match self {
Kind::SoundChange => "sound-change",
Kind::Cognacy => "cognacy",
Kind::Borrowing => "borrowing",
Kind::Other => "other",
}
}
}
impl Status {
pub fn parse(s: &str) -> Option<Status> {
match s.trim().to_ascii_lowercase().as_str() {
"proposed" | "propose" => Some(Status::Proposed),
"supported" | "support" | "confirmed" => Some(Status::Supported),
"refuted" | "refute" | "rejected" => Some(Status::Refuted),
"retired" | "retire" | "withdrawn" => Some(Status::Retired),
_ => None,
}
}
pub fn label(self) -> &'static str {
match self {
Status::Proposed => "proposed",
Status::Supported => "supported",
Status::Refuted => "refuted",
Status::Retired => "retired",
}
}
pub fn icon(self) -> char {
match self {
Status::Proposed => '?',
Status::Supported => '✓',
Status::Refuted => '✗',
Status::Retired => '—',
}
}
}
impl Hypothesis {
pub fn summary(&self) -> String {
format!("{} [{}] {} — {}", self.status.icon(), self.kind.label(), self.id, self.claim)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn kind_and_status_parse_flexibly() {
assert_eq!(Kind::parse("sound_change"), Some(Kind::SoundChange));
assert_eq!(Kind::parse("Loan"), Some(Kind::Borrowing));
assert_eq!(Kind::parse("cognates"), Some(Kind::Cognacy));
assert_eq!(Kind::parse("nonsense"), None);
assert_eq!(Status::parse("Confirmed"), Some(Status::Supported));
assert_eq!(Status::parse("refute"), Some(Status::Refuted));
}
#[test]
fn a_hypothesis_round_trips_through_json() {
let h = Hypothesis {
id: "palatalization".into(),
kind: Kind::SoundChange,
claim: "k > tʃ / _ i".into(),
note: "Northern branch only.".into(),
evidence: vec!["kina → tʃina".into()],
status: Status::Supported,
};
let json = serde_json::to_string(&h).unwrap();
let back: Hypothesis = serde_json::from_str(&json).unwrap();
assert_eq!(h, back);
}
#[test]
fn defaults_fill_in_for_a_minimal_record() {
let h: Hypothesis =
serde_json::from_str(r#"{"id":"x","kind":"borrowing","claim":"tea < Y"}"#).unwrap();
assert_eq!(h.status, Status::Proposed);
assert!(h.evidence.is_empty());
assert!(h.note.is_empty());
}
#[test]
fn summary_reads_as_a_register_line() {
let h = Hypothesis {
id: "p1".into(),
kind: Kind::SoundChange,
claim: "s > h / #_".into(),
note: String::new(),
evidence: vec![],
status: Status::Proposed,
};
assert_eq!(h.summary(), "? [sound-change] p1 — s > h / #_");
}
}