use crate::actor::Actor;
use crate::date::DateField;
use crate::footnotes;
use crate::yaml::Value;
use std::fmt;
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct UsageWindow {
pub from: Option<DateField>,
pub to: Option<DateField>,
}
impl UsageWindow {
pub fn from_value(value: &Value) -> Option<Self> {
let map = value.as_mapping()?;
Some(Self {
from: map
.get("from")
.and_then(Value::as_display_string)
.map(DateField::new),
to: map
.get("to")
.and_then(Value::as_display_string)
.map(DateField::new),
})
}
}
impl fmt::Display for UsageWindow {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let dash = |d: &Option<DateField>| {
d.as_ref()
.map_or_else(|| "?".to_string(), |d| d.raw.clone())
};
write!(f, "{} to {}", dash(&self.from), dash(&self.to))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ResourceKind {
Url,
Path,
Scope,
Missing,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Source {
pub id: Option<String>,
pub resource: Option<String>,
pub title: Option<String>,
pub author: Option<Actor>,
pub usage_count: Option<i64>,
pub last_modified: Option<DateField>,
pub usage_window: Option<UsageWindow>,
}
impl Source {
pub fn from_value(value: &Value) -> Option<Self> {
let map = value.as_mapping()?;
let string = |k: &str| map.get(k).and_then(Value::as_display_string);
Some(Self {
id: string("id"),
resource: string("resource"),
title: string("title"),
author: string("author").map(Actor::parse),
usage_count: map.get("usage_count").and_then(Value::as_int),
last_modified: string("last_modified").map(DateField::new),
usage_window: map.get("usage_window").and_then(UsageWindow::from_value),
})
}
pub fn list_from_value(value: &Value) -> Vec<Self> {
match value {
Value::Sequence(items) => items.iter().filter_map(Self::from_value).collect(),
Value::Mapping(_) => Self::from_value(value).into_iter().collect(),
_ => Vec::new(),
}
}
pub fn resource_kind(&self) -> ResourceKind {
match self.resource.as_deref().map(str::trim) {
None | Some("") => ResourceKind::Missing,
Some(r) if r.contains("://") || r.starts_with("mailto:") => ResourceKind::Url,
Some(r) if r.chars().any(char::is_whitespace) => ResourceKind::Scope,
Some(_) => ResourceKind::Path,
}
}
#[must_use]
pub fn effective_usage_window<'a>(
&'a self,
shared: Option<&'a UsageWindow>,
) -> Option<&'a UsageWindow> {
self.usage_window.as_ref().or(shared)
}
#[must_use]
pub fn label(&self) -> &str {
self.title
.as_deref()
.or(self.resource.as_deref())
.or(self.id.as_deref())
.unwrap_or("(unnamed source)")
}
}
impl fmt::Display for Source {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.id {
Some(id) => write!(f, "[{id}] {}", self.label()),
None => f.write_str(self.label()),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Attribution {
pub label: String,
pub source: Option<Source>,
pub references: usize,
pub definitions: usize,
}
impl Attribution {
#[must_use]
pub const fn is_resolved(&self) -> bool {
self.source.is_some()
}
}
#[must_use]
pub fn attributions(sources: &[Source], body: &str) -> Vec<Attribution> {
let refs = footnotes::extract_refs(body);
let defs = footnotes::extract_definitions(body);
let mut order: Vec<String> = Vec::new();
let push = |label: &str, order: &mut Vec<String>| {
if !order.iter().any(|l| l == label) {
order.push(label.to_string());
}
};
for r in &refs {
push(&r.label, &mut order);
}
for d in &defs {
push(&d.label, &mut order);
}
order
.into_iter()
.map(|label| Attribution {
references: refs.iter().filter(|r| r.label == label).count(),
definitions: defs.iter().filter(|d| d.label == label).count(),
source: sources
.iter()
.find(|s| s.id.as_deref() == Some(label.as_str()))
.cloned(),
label,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::date::Date;
const SOURCES: &str = "\
- id: rev-policy
resource: https://wiki.acme/finance/revenue-recognition
title: Revenue recognition policy
author: team:finance-fpa
last_modified: 2026-04-02
- id: exec-rev-dash
resource: dashboards/exec-revenue
title: Executive revenue dashboard
author: team:finance-fpa
usage_count: 5000
last_modified: 2026-06-18
";
fn sources() -> Vec<Source> {
Source::list_from_value(&Value::parse(SOURCES).unwrap())
}
#[test]
fn reads_entries_and_credibility_signals() {
let s = sources();
assert_eq!(s.len(), 2);
assert_eq!(s[0].id.as_deref(), Some("rev-policy"));
assert_eq!(s[0].resource_kind(), ResourceKind::Url);
assert_eq!(s[0].author.as_ref().unwrap().as_str(), "team:finance-fpa");
assert_eq!(
s[0].last_modified.as_ref().unwrap().date,
Date::new(2026, 4, 2)
);
assert_eq!(s[0].usage_count, None);
assert_eq!(s[1].usage_count, Some(5000));
assert_eq!(s[1].resource_kind(), ResourceKind::Path);
assert_eq!(s[1].label(), "Executive revenue dashboard");
}
#[test]
fn scope_descriptors_are_not_paths() {
let s = Source::from_value(
&Value::parse("{ resource: all queries in BigQuery project X }").unwrap(),
)
.unwrap();
assert_eq!(s.resource_kind(), ResourceKind::Scope);
let missing = Source::from_value(&Value::parse("{ id: x }").unwrap()).unwrap();
assert_eq!(missing.resource_kind(), ResourceKind::Missing);
}
#[test]
fn usage_window_entry_overrides_shared() {
let shared =
UsageWindow::from_value(&Value::parse("{ from: 2026-06-01, to: 2026-06-30 }").unwrap())
.unwrap();
let plain = &sources()[1];
assert_eq!(plain.effective_usage_window(Some(&shared)), Some(&shared));
let overridden = Source::from_value(
&Value::parse("{ resource: x, usage_window: { from: 2026-01-01, to: 2026-01-31 } }")
.unwrap(),
)
.unwrap();
let window = overridden.effective_usage_window(Some(&shared)).unwrap();
assert_eq!(window.from.as_ref().unwrap().raw, "2026-01-01");
}
#[test]
fn attribution_joins_footnote_labels_to_source_ids() {
let body = "Per the recognition policy,[^rev-policy] corroborated by the \
dashboard.[^exec-rev-dash] And once more.[^rev-policy]\n\n\
[^rev-policy]: Revenue recognition policy\n\
[^exec-rev-dash]: Executive revenue dashboard\n\
[^ghost]: Not in sources\n";
let attributions = attributions(&sources(), body);
assert_eq!(attributions.len(), 3);
assert_eq!(attributions[0].label, "rev-policy");
assert_eq!(attributions[0].references, 2);
assert_eq!(attributions[0].definitions, 1);
assert!(attributions[0].is_resolved());
assert_eq!(
attributions[0].source.as_ref().unwrap().title.as_deref(),
Some("Revenue recognition policy")
);
assert_eq!(attributions[2].label, "ghost");
assert_eq!(attributions[2].references, 0);
assert!(!attributions[2].is_resolved());
}
#[test]
fn reordering_sources_does_not_change_attribution() {
let body = "Claim.[^exec-rev-dash]\n\n[^exec-rev-dash]: Executive revenue dashboard\n";
let mut reversed = sources();
reversed.reverse();
let a = attributions(&sources(), body);
let b = attributions(&reversed, body);
assert_eq!(a, b);
assert_eq!(
a[0].source.as_ref().unwrap().id.as_deref(),
Some("exec-rev-dash")
);
}
}