use crate::error::{BallError, Result};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use serde_json::Value;
use std::collections::BTreeMap;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LinkType {
RelatesTo,
Duplicates,
Supersedes,
RepliesTo,
Gates,
Unknown(String),
}
impl LinkType {
pub fn parse(s: &str) -> Result<Self> {
match s {
"relates_to" => Ok(LinkType::RelatesTo),
"duplicates" => Ok(LinkType::Duplicates),
"supersedes" => Ok(LinkType::Supersedes),
"replies_to" => Ok(LinkType::RepliesTo),
"gates" => Ok(LinkType::Gates),
_ => Err(BallError::InvalidTask(format!("unknown link type: {s}"))),
}
}
pub fn as_str(&self) -> &str {
match self {
LinkType::RelatesTo => "relates_to",
LinkType::Duplicates => "duplicates",
LinkType::Supersedes => "supersedes",
LinkType::RepliesTo => "replies_to",
LinkType::Gates => "gates",
LinkType::Unknown(s) => s.as_str(),
}
}
}
impl fmt::Display for LinkType {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl Serialize for LinkType {
fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
s.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for LinkType {
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
let s = String::deserialize(d)?;
Ok(match s.as_str() {
"relates_to" => LinkType::RelatesTo,
"duplicates" => LinkType::Duplicates,
"supersedes" => LinkType::Supersedes,
"replies_to" => LinkType::RepliesTo,
"gates" => LinkType::Gates,
_ => LinkType::Unknown(s),
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Link {
pub link_type: LinkType,
pub target: String,
#[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
pub extra: BTreeMap<String, Value>,
}
impl PartialEq for Link {
fn eq(&self, other: &Self) -> bool {
self.link_type == other.link_type && self.target == other.target
}
}
impl Eq for Link {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_known_variants() {
assert_eq!(LinkType::parse("relates_to").unwrap(), LinkType::RelatesTo);
assert_eq!(LinkType::parse("duplicates").unwrap(), LinkType::Duplicates);
assert_eq!(LinkType::parse("supersedes").unwrap(), LinkType::Supersedes);
assert_eq!(LinkType::parse("replies_to").unwrap(), LinkType::RepliesTo);
assert_eq!(LinkType::parse("gates").unwrap(), LinkType::Gates);
}
#[test]
fn parse_rejects_unknown_cli_input() {
assert!(LinkType::parse("bogus").is_err());
assert!(LinkType::parse("").is_err());
}
#[test]
fn as_str_known_variants() {
assert_eq!(LinkType::RelatesTo.as_str(), "relates_to");
assert_eq!(LinkType::Duplicates.as_str(), "duplicates");
assert_eq!(LinkType::Supersedes.as_str(), "supersedes");
assert_eq!(LinkType::RepliesTo.as_str(), "replies_to");
assert_eq!(LinkType::Gates.as_str(), "gates");
}
#[test]
fn as_str_unknown_echoes_original() {
let lt = LinkType::Unknown("future_variant".to_string());
assert_eq!(lt.as_str(), "future_variant");
}
#[test]
fn display_impl() {
assert_eq!(format!("{}", LinkType::RelatesTo), "relates_to");
assert_eq!(format!("{}", LinkType::Gates), "gates");
assert_eq!(
format!("{}", LinkType::Unknown("xyz".into())),
"xyz"
);
}
#[test]
fn serialize_round_trip_all_known() {
for lt in [
LinkType::RelatesTo,
LinkType::Duplicates,
LinkType::Supersedes,
LinkType::RepliesTo,
LinkType::Gates,
] {
let s = serde_json::to_string(<).unwrap();
let back: LinkType = serde_json::from_str(&s).unwrap();
assert_eq!(back, lt);
}
}
#[test]
fn deserialize_unknown_preserves_string() {
let back: LinkType = serde_json::from_str("\"from_the_future\"").unwrap();
assert_eq!(back, LinkType::Unknown("from_the_future".to_string()));
let s = serde_json::to_string(&back).unwrap();
assert_eq!(s, "\"from_the_future\"");
}
fn mk(lt: LinkType, target: &str) -> Link {
Link { link_type: lt, target: target.into(), extra: BTreeMap::new() }
}
#[test]
fn link_equality() {
let a = mk(LinkType::RelatesTo, "bl-x");
let b = mk(LinkType::RelatesTo, "bl-x");
let c = mk(LinkType::Duplicates, "bl-x");
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn link_equality_ignores_extra() {
let mut a = mk(LinkType::Gates, "bl-y");
let b = mk(LinkType::Gates, "bl-y");
a.extra.insert("added_by".into(), serde_json::json!("alice"));
assert_eq!(a, b);
}
#[test]
fn link_unknown_field_round_trips() {
let raw = r#"{"link_type":"gates","target":"bl-z","added_by":"alice"}"#;
let l: Link = serde_json::from_str(raw).unwrap();
assert_eq!(l.link_type, LinkType::Gates);
assert_eq!(l.target, "bl-z");
assert_eq!(
l.extra.get("added_by").and_then(|v| v.as_str()),
Some("alice"),
);
let s = serde_json::to_string(&l).unwrap();
assert!(s.contains("\"added_by\":\"alice\""));
}
#[test]
fn link_empty_extra_is_omitted_on_save() {
let l = mk(LinkType::RelatesTo, "bl-w");
let s = serde_json::to_string(&l).unwrap();
assert!(!s.contains("extra"));
assert_eq!(s, r#"{"link_type":"relates_to","target":"bl-w"}"#);
}
#[test]
fn gates_link_serializes_as_plain_string() {
let link = mk(LinkType::Gates, "bl-child");
let s = serde_json::to_string(&link).unwrap();
assert!(s.contains("\"gates\""));
assert!(s.contains("\"bl-child\""));
}
}