mod gather;
pub mod check;
pub mod deep;
use uuid::Uuid;
pub use crate::ken::{ScenePos, Severity};
pub fn ties(
layout: &crate::project::ProjectLayout,
h: &crate::store::hierarchy::Hierarchy,
book: &crate::store::node::Node,
) -> Vec<Declared> {
let (declared, _coscenes, _paras) = gather::build_bonds(layout, h, book);
declared
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Declared {
pub a: String,
pub b: String,
pub kind: String,
pub at: ScenePos,
pub anchor: Uuid,
}
impl Declared {
pub fn new(kind: &str, a: &str, b: &str, at: ScenePos, anchor: Uuid) -> Self {
let (a, b) = pair_key(a, b);
Declared { a, b, kind: normalize(kind), at, anchor }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CoScene {
pub a: String,
pub b: String,
pub at: ScenePos,
pub anchor: Uuid,
}
impl CoScene {
pub fn new(a: &str, b: &str, at: ScenePos, anchor: Uuid) -> Self {
let (a, b) = pair_key(a, b);
CoScene { a, b, at, anchor }
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BondFinding {
pub kind: &'static str,
pub severity: Severity,
pub chapter: u32,
pub anchor: Option<Uuid>,
pub a: String,
pub b: String,
pub message: String,
}
fn normalize(s: &str) -> String {
crate::ken::grants::normalize_topic(s)
}
pub fn pair_key(a: &str, b: &str) -> (String, String) {
let (a, b) = (normalize(a), normalize(b));
if a <= b { (a, b) } else { (b, a) }
}
pub fn parse_rel_tag(tag: &str) -> Option<(String, String, String)> {
let rest = tag.trim().strip_prefix("rel:")?;
let mut it = rest.splitn(3, ':');
let kind = it.next()?;
let a = it.next()?;
let b = it.next()?;
if b.contains(':') {
return None; }
let (kind, a, b) = (normalize(kind), normalize(a), normalize(b));
if kind.is_empty() || a.is_empty() || b.is_empty() {
return None;
}
Some((kind, a, b))
}
#[cfg(test)]
mod tests {
use super::*;
fn pos(c: u32, s: u32) -> ScenePos {
ScenePos { chapter_ord: c, scene_index: s }
}
#[test]
fn parse_rel_tag_accepts_well_formed_tags() {
assert_eq!(
parse_rel_tag("rel:ally:Mara:Kell"),
Some(("ally".into(), "Mara".into(), "Kell".into()))
);
assert_eq!(
parse_rel_tag("rel: rival : Ser Danel : Mara "),
Some(("rival".into(), "Ser Danel".into(), "Mara".into()))
);
}
#[test]
fn parse_rel_tag_rejects_malformed() {
assert!(parse_rel_tag("know:secret").is_none(), "wrong prefix");
assert!(parse_rel_tag("rel:ally:Mara").is_none(), "only two fields");
assert!(parse_rel_tag("rel:ally").is_none(), "one field");
assert!(parse_rel_tag("rel:ally:Mara:Kell:extra").is_none(), "four fields");
assert!(parse_rel_tag("rel::Mara:Kell").is_none(), "empty kind");
assert!(parse_rel_tag("rel:ally::Kell").is_none(), "empty A");
}
#[test]
fn pair_key_is_order_independent_and_normalized() {
assert_eq!(pair_key("Mara", "Kell"), pair_key("Kell", "Mara"));
assert_eq!(pair_key(" Mara ", "Kell"), ("Kell".into(), "Mara".into()));
}
#[test]
fn declared_canonicalizes_the_pair() {
let d1 = Declared::new("ally", "Mara", "Kell", pos(1, 0), Uuid::nil());
let d2 = Declared::new("ally", "Kell", "Mara", pos(1, 0), Uuid::nil());
assert_eq!((d1.a.clone(), d1.b.clone()), (d2.a.clone(), d2.b.clone()));
assert_eq!(d1.a, "Kell");
assert_eq!(d1.b, "Mara");
}
#[test]
fn scene_pos_reused_from_ken_still_orders_by_reading_position() {
assert!(pos(1, 3) < pos(2, 0), "chapter dominates scene");
}
}