1use crate::actor::Actor;
33use crate::date::DateTimeField;
34use crate::footnotes;
35use crate::yaml::Value;
36use std::fmt;
37
38#[derive(Clone, Debug, Default, PartialEq, Eq)]
43pub struct UsageWindow {
44 pub from: Option<DateTimeField>,
46 pub to: Option<DateTimeField>,
48}
49
50impl UsageWindow {
51 pub fn from_value(value: &Value) -> Option<Self> {
54 let map = value.as_mapping()?;
55 Some(Self {
56 from: map
57 .get("from")
58 .and_then(Value::as_display_string)
59 .map(DateTimeField::new),
60 to: map
61 .get("to")
62 .and_then(Value::as_display_string)
63 .map(DateTimeField::new),
64 })
65 }
66}
67
68impl fmt::Display for UsageWindow {
69 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70 let dash = |d: &Option<DateTimeField>| {
71 d.as_ref()
72 .map_or_else(|| "?".to_string(), |d| d.raw.clone())
73 };
74 write!(f, "{} to {}", dash(&self.from), dash(&self.to))
75 }
76}
77
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub enum ResourceKind {
81 Url,
83 Path,
85 Scope,
88 Missing,
91}
92
93#[derive(Clone, Debug, Default, PartialEq, Eq)]
95pub struct Source {
96 pub id: Option<String>,
99 pub resource: Option<String>,
101 pub title: Option<String>,
103 pub author: Option<Actor>,
106 pub usage_count: Option<i64>,
109 pub last_modified: Option<DateTimeField>,
112 pub usage_window: Option<UsageWindow>,
114}
115
116impl Source {
117 pub fn from_value(value: &Value) -> Option<Self> {
120 let map = value.as_mapping()?;
121 let string = |k: &str| map.get(k).and_then(Value::as_display_string);
122 Some(Self {
123 id: string("id"),
124 resource: string("resource"),
125 title: string("title"),
126 author: string("author").map(Actor::parse),
127 usage_count: map.get("usage_count").and_then(Value::as_int),
128 last_modified: string("last_modified").map(DateTimeField::new),
129 usage_window: map.get("usage_window").and_then(UsageWindow::from_value),
130 })
131 }
132
133 pub fn list_from_value(value: &Value) -> Vec<Self> {
138 match value {
139 Value::Sequence(items) => items.iter().filter_map(Self::from_value).collect(),
140 Value::Mapping(_) => Self::from_value(value).into_iter().collect(),
141 _ => Vec::new(),
142 }
143 }
144
145 pub fn resource_kind(&self) -> ResourceKind {
153 match self.resource.as_deref().map(str::trim) {
154 None | Some("") => ResourceKind::Missing,
155 Some(r) if r.contains("://") || r.starts_with("mailto:") => ResourceKind::Url,
156 Some(r) if r.chars().any(char::is_whitespace) => ResourceKind::Scope,
157 Some(_) => ResourceKind::Path,
158 }
159 }
160
161 #[must_use]
164 pub fn effective_usage_window<'a>(
165 &'a self,
166 shared: Option<&'a UsageWindow>,
167 ) -> Option<&'a UsageWindow> {
168 self.usage_window.as_ref().or(shared)
169 }
170
171 #[must_use]
173 pub fn label(&self) -> &str {
174 self.title
175 .as_deref()
176 .or(self.resource.as_deref())
177 .or(self.id.as_deref())
178 .unwrap_or("(unnamed source)")
179 }
180}
181
182impl fmt::Display for Source {
183 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
184 match &self.id {
185 Some(id) => write!(f, "[{id}] {}", self.label()),
186 None => f.write_str(self.label()),
187 }
188 }
189}
190
191#[derive(Clone, Debug, PartialEq, Eq)]
194pub struct Attribution {
195 pub label: String,
197 pub source: Option<Source>,
199 pub references: usize,
201 pub definitions: usize,
203}
204
205impl Attribution {
206 #[must_use]
208 pub const fn is_resolved(&self) -> bool {
209 self.source.is_some()
210 }
211}
212
213#[must_use]
221pub fn attributions(sources: &[Source], body: &str) -> Vec<Attribution> {
222 let refs = footnotes::extract_refs(body);
223 let defs = footnotes::extract_definitions(body);
224
225 let mut order: Vec<String> = Vec::new();
226 let push = |label: &str, order: &mut Vec<String>| {
227 if !order.iter().any(|l| l == label) {
228 order.push(label.to_string());
229 }
230 };
231 for r in &refs {
232 push(&r.label, &mut order);
233 }
234 for d in &defs {
235 push(&d.label, &mut order);
236 }
237
238 order
239 .into_iter()
240 .map(|label| Attribution {
241 references: refs.iter().filter(|r| r.label == label).count(),
242 definitions: defs.iter().filter(|d| d.label == label).count(),
243 source: sources
244 .iter()
245 .find(|s| s.id.as_deref() == Some(label.as_str()))
246 .cloned(),
247 label,
248 })
249 .collect()
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use crate::date::Date;
256
257 const SOURCES: &str = "\
258- id: rev-policy
259 resource: https://wiki.acme/finance/revenue-recognition
260 title: Revenue recognition policy
261 author: team:finance-fpa
262 last_modified: 2026-04-02T00:00:00Z
263- id: exec-rev-dash
264 resource: dashboards/exec-revenue
265 title: Executive revenue dashboard
266 author: team:finance-fpa
267 usage_count: 5000
268 last_modified: 2026-06-18T00:00:00Z
269";
270
271 fn sources() -> Vec<Source> {
272 Source::list_from_value(&Value::parse(SOURCES).unwrap())
273 }
274
275 #[test]
276 fn reads_entries_and_credibility_signals() {
277 let s = sources();
278 assert_eq!(s.len(), 2);
279 assert_eq!(s[0].id.as_deref(), Some("rev-policy"));
280 assert_eq!(s[0].resource_kind(), ResourceKind::Url);
281 assert_eq!(s[0].author.as_ref().unwrap().as_str(), "team:finance-fpa");
282 assert_eq!(
283 s[0].last_modified.as_ref().unwrap().datetime.unwrap().date,
284 Date::new(2026, 4, 2).unwrap()
285 );
286 assert_eq!(s[0].usage_count, None);
287
288 assert_eq!(s[1].usage_count, Some(5000));
289 assert_eq!(s[1].resource_kind(), ResourceKind::Path);
290 assert_eq!(s[1].label(), "Executive revenue dashboard");
291 }
292
293 #[test]
294 fn scope_descriptors_are_not_paths() {
295 let s = Source::from_value(
296 &Value::parse("{ resource: all queries in BigQuery project X }").unwrap(),
297 )
298 .unwrap();
299 assert_eq!(s.resource_kind(), ResourceKind::Scope);
300
301 let missing = Source::from_value(&Value::parse("{ id: x }").unwrap()).unwrap();
302 assert_eq!(missing.resource_kind(), ResourceKind::Missing);
303 }
304
305 #[test]
306 fn usage_window_entry_overrides_shared() {
307 let shared = UsageWindow::from_value(
308 &Value::parse("{ from: 2026-06-01T00:00:00Z, to: 2026-06-30T00:00:00Z }").unwrap(),
309 )
310 .unwrap();
311 let plain = &sources()[1];
312 assert_eq!(plain.effective_usage_window(Some(&shared)), Some(&shared));
313
314 let overridden = Source::from_value(
315 &Value::parse(
316 "{ resource: x, usage_window: { from: 2026-01-01T00:00:00Z, to: 2026-01-31T00:00:00Z } }",
317 )
318 .unwrap(),
319 )
320 .unwrap();
321 let window = overridden.effective_usage_window(Some(&shared)).unwrap();
322 assert_eq!(window.from.as_ref().unwrap().raw, "2026-01-01T00:00:00Z");
323 }
324
325 #[test]
326 fn attribution_joins_footnote_labels_to_source_ids() {
327 let body = "Per the recognition policy,[^rev-policy] corroborated by the \
328 dashboard.[^exec-rev-dash] And once more.[^rev-policy]\n\n\
329 [^rev-policy]: Revenue recognition policy\n\
330 [^exec-rev-dash]: Executive revenue dashboard\n\
331 [^ghost]: Not in sources\n";
332 let attributions = attributions(&sources(), body);
333 assert_eq!(attributions.len(), 3);
334
335 assert_eq!(attributions[0].label, "rev-policy");
336 assert_eq!(attributions[0].references, 2);
337 assert_eq!(attributions[0].definitions, 1);
338 assert!(attributions[0].is_resolved());
339 assert_eq!(
340 attributions[0].source.as_ref().unwrap().title.as_deref(),
341 Some("Revenue recognition policy")
342 );
343
344 assert_eq!(attributions[2].label, "ghost");
346 assert_eq!(attributions[2].references, 0);
347 assert!(!attributions[2].is_resolved());
348 }
349
350 #[test]
351 fn reordering_sources_does_not_change_attribution() {
352 let body = "Claim.[^exec-rev-dash]\n\n[^exec-rev-dash]: Executive revenue dashboard\n";
353 let mut reversed = sources();
354 reversed.reverse();
355 let a = attributions(&sources(), body);
356 let b = attributions(&reversed, body);
357 assert_eq!(a, b);
358 assert_eq!(
359 a[0].source.as_ref().unwrap().id.as_deref(),
360 Some("exec-rev-dash")
361 );
362 }
363}