Packages
rbt_weave
0.2.64
0.2.67
0.2.66
0.2.65
0.2.64
0.2.63
0.2.62
0.2.61
0.2.60
0.2.59
0.2.58
0.2.57
0.2.56
0.2.55
0.2.54
0.2.53
0.2.52
0.2.51
0.2.50
0.2.49
0.2.48
0.2.47
0.2.46
0.2.45
0.2.44
0.2.43
0.2.42
0.2.41
0.2.40
0.2.39
0.2.38
0.2.37
0.2.36
0.2.35
0.2.34
0.2.33
0.2.32
0.2.31
0.2.30
0.2.29
0.2.28
0.2.27
0.2.26
0.2.25
0.2.24
0.2.23
0.2.22
0.2.21
0.2.20
0.2.19
0.2.18
0.2.17
0.2.16
0.2.15
0.2.14
0.2.13
0.2.12
0.2.11
0.2.10
0.2.9
0.2.8
0.2.7
0.2.6
0.2.5
0.2.4
0.2.3
0.2.2
0.2.1
0.2.0
0.1.0
Graph conflict detection and image processing NIFs for OSINT knowledge graphs, powered by Rust.
Current section
Files
Jump to
Current section
Files
crates/weave-content/src/relationship.rs
use std::collections::HashSet;
use crate::parser::{ParseError, SourceEntry};
/// Maximum relationships per file.
const MAX_RELATIONSHIPS_PER_FILE: usize = 500;
/// All known relationship types, organized by category per ADR-014 §3.
const KNOWN_REL_TYPES: &[&str] = &[
// Organizational
"employed_by",
"member_of",
"leads",
"founded",
"owns",
"subsidiary_of",
// Legal/Criminal
"charged_with",
"convicted_of",
"investigated_by",
"prosecuted_by",
"defended_by",
"testified_in",
"sentenced_to",
"appealed",
"acquitted_of",
"pardoned_by",
"arrested_by",
// Financial
"paid_to",
"received_from",
"funded_by",
"awarded_contract",
"approved_budget",
"seized_from",
// Governance
"appointed_by",
"approved_by",
"regulated_by",
"licensed_by",
"lobbied",
// Personal
"family_of",
"associate_of",
// Temporal
"preceded_by",
// Document
"documents",
"authorizes",
"references",
// Case
"related_to",
"part_of",
"involved_in",
// Source
"sourced_by",
];
/// Known fields on relationships (nested bullets).
const REL_FIELDS: &[&str] = &[
"id",
"source",
"description",
"amounts",
"valid_from",
"valid_until",
];
/// A parsed relationship.
#[derive(Debug)]
pub struct Rel {
pub source_name: String,
pub target_name: String,
pub rel_type: String,
pub source_urls: Vec<String>,
pub fields: Vec<(String, String)>,
/// Stored NULID from `id:` field (None if not yet generated).
pub id: Option<String>,
/// Line number (1-indexed) in the original file.
pub line: usize,
}
/// Parse relationships from the `## Relationships` section body.
///
/// `entity_names` is the set of entity names defined in the file (for resolution).
/// `default_sources` are the front matter sources used when no `source:` override.
#[allow(clippy::too_many_lines)]
pub fn parse_relationships(
body: &str,
section_start_line: usize,
entity_names: &HashSet<&str>,
default_sources: &[SourceEntry],
errors: &mut Vec<ParseError>,
) -> Vec<Rel> {
let lines: Vec<&str> = body.lines().collect();
let mut rels: Vec<Rel> = Vec::new();
// Current relationship being built
let mut current: Option<RelBuilder> = None;
for (i, line) in lines.iter().enumerate() {
let file_line = section_start_line + 1 + i;
let trimmed = line.trim();
// Top-level bullet: `- Source -> Target: type`
if trimmed.starts_with("- ") && !line.starts_with(" ") {
// Flush previous
if let Some(builder) = current.take() {
rels.push(builder.finish(default_sources));
}
let item = &trimmed[2..];
match parse_rel_line(item) {
Some((source, target, rel_type)) => {
// Validate rel_type
if !KNOWN_REL_TYPES.contains(&rel_type.as_str()) {
errors.push(ParseError {
line: file_line,
message: format!(
"unknown relationship type {rel_type:?} (known: {})",
KNOWN_REL_TYPES.join(", ")
),
});
}
// Validate entity names
if !entity_names.contains(&source.as_str()) {
errors.push(ParseError {
line: file_line,
message: format!(
"entity {source:?} in relationship not defined in file"
),
});
}
if !entity_names.contains(&target.as_str()) {
errors.push(ParseError {
line: file_line,
message: format!(
"entity {target:?} in relationship not defined in file"
),
});
}
current = Some(RelBuilder {
source_name: source,
target_name: target,
rel_type,
source_urls: Vec::new(),
fields: Vec::new(),
id: None,
line: file_line,
});
}
None => {
errors.push(ParseError {
line: file_line,
message: format!(
"invalid relationship syntax: expected `- Source -> Target: type`, got {trimmed:?}"
),
});
}
}
continue;
}
// Indented field: ` key: value`
if line.starts_with(" ") && current.is_some() {
if let Some((key, value)) = parse_kv(trimmed) {
if !REL_FIELDS.contains(&key.as_str()) {
errors.push(ParseError {
line: file_line,
message: format!("unknown relationship field {key:?}"),
});
continue;
}
let builder = current.as_mut().unwrap_or_else(|| unreachable!());
if key == "id" {
builder.id = Some(value);
} else if key == "source" {
if !value.starts_with("https://") {
errors.push(ParseError {
line: file_line,
message: format!("relationship source URL must be HTTPS: {value:?}"),
});
}
if builder.source_urls.contains(&value) {
errors.push(ParseError {
line: file_line,
message: format!("duplicate relationship source url: {value:?}"),
});
}
builder.source_urls.push(value);
} else {
// Validate field constraints
validate_rel_field(&key, &value, file_line, errors);
builder.fields.push((key, value));
}
} else {
errors.push(ParseError {
line: file_line,
message: format!(
"invalid field syntax: expected `key: value`, got {trimmed:?}"
),
});
}
}
// Ignore blank lines
}
// Flush last
if let Some(builder) = current.take() {
rels.push(builder.finish(default_sources));
}
// Boundary check
if rels.len() > MAX_RELATIONSHIPS_PER_FILE {
errors.push(ParseError {
line: section_start_line,
message: format!(
"too many relationships (max {MAX_RELATIONSHIPS_PER_FILE}, got {})",
rels.len()
),
});
}
rels
}
struct RelBuilder {
source_name: String,
target_name: String,
rel_type: String,
source_urls: Vec<String>,
fields: Vec<(String, String)>,
id: Option<String>,
line: usize,
}
impl RelBuilder {
fn finish(self, default_sources: &[SourceEntry]) -> Rel {
let source_urls = if self.source_urls.is_empty() {
default_sources
.iter()
.map(|s| s.url().to_string())
.collect()
} else {
self.source_urls
};
Rel {
source_name: self.source_name,
target_name: self.target_name,
rel_type: self.rel_type,
source_urls,
fields: self.fields,
id: self.id,
line: self.line,
}
}
}
/// Parse `Source -> Target: type` from a relationship bullet.
fn parse_rel_line(item: &str) -> Option<(String, String, String)> {
let arrow_pos = item.find(" -> ")?;
let source = item[..arrow_pos].trim();
let after_arrow = &item[arrow_pos + 4..];
let colon_pos = after_arrow.rfind(':')?;
let target = after_arrow[..colon_pos].trim();
let rel_type = after_arrow[colon_pos + 1..]
.trim()
.to_lowercase()
.replace(' ', "_");
if source.is_empty() || target.is_empty() || rel_type.is_empty() {
return None;
}
Some((source.to_string(), target.to_string(), rel_type))
}
fn parse_kv(s: &str) -> Option<(String, String)> {
let colon = s.find(':')?;
let key = s[..colon].trim();
if key.is_empty() {
return None;
}
let value = s[colon + 1..].trim();
Some((key.to_string(), value.to_string()))
}
fn validate_rel_field(key: &str, value: &str, line: usize, errors: &mut Vec<ParseError>) {
let max = match key {
"description" => 1000,
"amounts" => 200,
"valid_from" | "valid_until" => 10,
_ => return,
};
if value.len() > max {
errors.push(ParseError {
line,
message: format!(
"relationship field {key:?} exceeds {max} chars (got {})",
value.len()
),
});
}
// Date format validation
if matches!(key, "valid_from" | "valid_until") && !value.is_empty() {
let valid = matches!(value.len(), 4 | 7 | 10)
&& value.chars().enumerate().all(|(i, c)| match i {
4 | 7 => c == '-',
_ => c.is_ascii_digit(),
});
if !valid {
errors.push(ParseError {
line,
message: format!(
"relationship field {key:?} must be YYYY, YYYY-MM, or YYYY-MM-DD, got {value:?}"
),
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_basic_relationship() {
let body = "\n- Alice -> Bob: employed_by\n";
let names = HashSet::from(["Alice", "Bob"]);
let sources = vec![SourceEntry::Url("https://example.com/src".into())];
let mut errors = Vec::new();
let rels = parse_relationships(body, 50, &names, &sources, &mut errors);
assert!(errors.is_empty(), "errors: {errors:?}");
assert_eq!(rels.len(), 1);
assert_eq!(rels[0].source_name, "Alice");
assert_eq!(rels[0].target_name, "Bob");
assert_eq!(rels[0].rel_type, "employed_by");
// Should default to front matter sources
assert_eq!(rels[0].source_urls, vec!["https://example.com/src"]);
}
#[test]
fn parse_relationship_with_source_override() {
let body = [
"",
"- Alice -> Bob: associate_of",
" source: https://specific.com/article",
"",
]
.join("\n");
let names = HashSet::from(["Alice", "Bob"]);
let sources = vec![SourceEntry::Url("https://default.com".into())];
let mut errors = Vec::new();
let rels = parse_relationships(&body, 10, &names, &sources, &mut errors);
assert!(errors.is_empty(), "errors: {errors:?}");
assert_eq!(rels[0].source_urls, vec!["https://specific.com/article"]);
}
#[test]
fn parse_relationship_with_fields() {
let body = [
"",
"- Alice -> Corp: paid_to",
" amounts: 50000 EUR",
" valid_from: 2020-01",
" description: Campaign donation",
"",
]
.join("\n");
let names = HashSet::from(["Alice", "Corp"]);
let mut errors = Vec::new();
let rels = parse_relationships(&body, 10, &names, &[], &mut errors);
assert!(errors.is_empty(), "errors: {errors:?}");
assert_eq!(rels[0].fields.len(), 3);
}
#[test]
fn reject_unknown_rel_type() {
let body = "\n- Alice -> Bob: best_friends\n";
let names = HashSet::from(["Alice", "Bob"]);
let mut errors = Vec::new();
parse_relationships(body, 1, &names, &[], &mut errors);
assert!(
errors
.iter()
.any(|e| e.message.contains("unknown relationship type"))
);
}
#[test]
fn reject_unresolved_entity() {
let body = "\n- Alice -> Unknown: employed_by\n";
let names = HashSet::from(["Alice"]);
let mut errors = Vec::new();
parse_relationships(body, 1, &names, &[], &mut errors);
assert!(
errors
.iter()
.any(|e| e.message.contains("not defined in file"))
);
}
#[test]
fn reject_non_https_source_override() {
let body = [
"",
"- Alice -> Bob: associate_of",
" source: http://insecure.com",
"",
]
.join("\n");
let names = HashSet::from(["Alice", "Bob"]);
let mut errors = Vec::new();
parse_relationships(&body, 1, &names, &[], &mut errors);
assert!(errors.iter().any(|e| e.message.contains("HTTPS")));
}
#[test]
fn reject_unknown_rel_field() {
let body = ["", "- Alice -> Bob: associate_of", " foobar: value", ""].join("\n");
let names = HashSet::from(["Alice", "Bob"]);
let mut errors = Vec::new();
parse_relationships(&body, 1, &names, &[], &mut errors);
assert!(
errors
.iter()
.any(|e| e.message.contains("unknown relationship field"))
);
}
#[test]
fn multiple_relationships() {
let body = [
"",
"- Alice -> Bob: employed_by",
"- Bob -> Corp: member_of",
"- Corp -> Alice: charged_with",
"",
]
.join("\n");
let names = HashSet::from(["Alice", "Bob", "Corp"]);
let mut errors = Vec::new();
let rels = parse_relationships(&body, 1, &names, &[], &mut errors);
assert!(errors.is_empty(), "errors: {errors:?}");
assert_eq!(rels.len(), 3);
}
#[test]
fn parse_rel_line_syntax() {
let result = parse_rel_line("Mark Bonnick -> Arsenal FC: employed_by");
assert_eq!(
result,
Some((
"Mark Bonnick".into(),
"Arsenal FC".into(),
"employed_by".into()
))
);
}
#[test]
fn parse_rel_line_invalid() {
assert!(parse_rel_line("not a relationship").is_none());
assert!(parse_rel_line("-> Target: type").is_none());
assert!(parse_rel_line("Source -> : type").is_none());
}
#[test]
fn relationship_date_validation() {
let body = [
"",
"- Alice -> Bob: associate_of",
" valid_from: not-a-date",
"",
]
.join("\n");
let names = HashSet::from(["Alice", "Bob"]);
let mut errors = Vec::new();
parse_relationships(&body, 1, &names, &[], &mut errors);
assert!(errors.iter().any(|e| e.message.contains("YYYY")));
}
#[test]
fn multiple_source_overrides() {
let body = [
"",
"- Alice -> Bob: associate_of",
" source: https://first.com",
" source: https://second.com",
"",
]
.join("\n");
let names = HashSet::from(["Alice", "Bob"]);
let mut errors = Vec::new();
let rels = parse_relationships(&body, 1, &names, &[], &mut errors);
assert!(errors.is_empty(), "errors: {errors:?}");
assert_eq!(rels[0].source_urls.len(), 2);
}
}