1use crate::actor::Actor;
32use crate::date::DateTimeField;
33use crate::footnotes;
34use crate::yaml::Value;
35use std::fmt;
36
37#[derive(Clone, Debug, Default, PartialEq, Eq)]
42pub struct UsageWindow {
43 pub from: Option<DateTimeField>,
45 pub to: Option<DateTimeField>,
47}
48
49impl UsageWindow {
50 pub fn from_value(value: &Value) -> Option<Self> {
53 let map = value.as_mapping()?;
54 Some(Self {
55 from: map
56 .get("from")
57 .and_then(Value::as_display_string)
58 .map(DateTimeField::new),
59 to: map
60 .get("to")
61 .and_then(Value::as_display_string)
62 .map(DateTimeField::new),
63 })
64 }
65}
66
67impl fmt::Display for UsageWindow {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 let dash = |d: &Option<DateTimeField>| {
70 d.as_ref()
71 .map_or_else(|| "?".to_string(), |d| d.raw.clone())
72 };
73 write!(f, "{} to {}", dash(&self.from), dash(&self.to))
74 }
75}
76
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79pub enum ResourceKind {
80 Url,
82 Path,
84 Scope,
87 Missing,
90}
91
92#[derive(Clone, Debug, Default, PartialEq, Eq)]
94pub struct Source {
95 pub id: Option<String>,
98 pub resource: Option<String>,
100 pub title: Option<String>,
102 pub author: Option<Actor>,
105 pub usage_count: Option<i64>,
108 pub last_modified: Option<DateTimeField>,
111 pub usage_window: Option<UsageWindow>,
113}
114
115impl Source {
116 #[must_use]
118 pub fn to_yaml_value(&self) -> Value {
119 let mut map = crate::yaml::Mapping::new();
120 if let Some(id) = &self.id {
121 map.insert("id", Value::String(id.clone()));
122 }
123 if let Some(res) = &self.resource {
124 map.insert("resource", Value::String(res.clone()));
125 }
126 if let Some(title) = &self.title {
127 map.insert("title", Value::String(title.clone()));
128 }
129 if let Some(author) = &self.author {
130 map.insert("author", Value::String(author.as_str().to_string()));
131 }
132 if let Some(count) = self.usage_count {
133 map.insert("usage_count", Value::Int(count));
134 }
135 if let Some(last_mod) = &self.last_modified {
136 map.insert("last_modified", Value::String(last_mod.raw.clone()));
137 }
138 if let Some(window) = &self.usage_window {
139 let mut w_map = crate::yaml::Mapping::new();
140 if let Some(from) = &window.from {
141 w_map.insert("from", Value::String(from.raw.clone()));
142 }
143 if let Some(to) = &window.to {
144 w_map.insert("to", Value::String(to.raw.clone()));
145 }
146 map.insert("usage_window", Value::Mapping(w_map));
147 }
148 Value::Mapping(map)
149 }
150
151 pub fn from_value(value: &Value) -> Option<Self> {
154 let map = value.as_mapping()?;
155 let string = |k: &str| map.get(k).and_then(Value::as_display_string);
156 Some(Self {
157 id: string("id"),
158 resource: string("resource"),
159 title: string("title"),
160 author: string("author").map(Actor::parse),
161 usage_count: map.get("usage_count").and_then(Value::as_int),
162 last_modified: string("last_modified").map(DateTimeField::new),
163 usage_window: map.get("usage_window").and_then(UsageWindow::from_value),
164 })
165 }
166
167 pub fn list_from_value(value: &Value) -> Vec<Self> {
172 match value {
173 Value::Sequence(items) => items.iter().filter_map(Self::from_value).collect(),
174 Value::Mapping(_) => Self::from_value(value).into_iter().collect(),
175 _ => Vec::new(),
176 }
177 }
178
179 pub fn resource_kind(&self) -> ResourceKind {
187 match self.resource.as_deref().map(str::trim) {
188 None | Some("") => ResourceKind::Missing,
189 Some(r) if r.contains("://") || r.starts_with("mailto:") => ResourceKind::Url,
190 Some(r) if r.chars().any(char::is_whitespace) => ResourceKind::Scope,
191 Some(_) => ResourceKind::Path,
192 }
193 }
194
195 #[must_use]
198 pub fn effective_usage_window<'a>(
199 &'a self,
200 shared: Option<&'a UsageWindow>,
201 ) -> Option<&'a UsageWindow> {
202 self.usage_window.as_ref().or(shared)
203 }
204
205 #[must_use]
207 pub fn label(&self) -> &str {
208 self.title
209 .as_deref()
210 .or(self.resource.as_deref())
211 .or(self.id.as_deref())
212 .unwrap_or("(unnamed source)")
213 }
214}
215
216impl fmt::Display for Source {
217 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
218 match &self.id {
219 Some(id) => write!(f, "[{id}] {}", self.label()),
220 None => f.write_str(self.label()),
221 }
222 }
223}
224
225#[derive(Clone, Debug, PartialEq, Eq)]
228pub struct Attribution {
229 pub label: String,
231 pub source: Option<Source>,
233 pub references: usize,
235 pub definitions: usize,
237}
238
239impl Attribution {
240 #[must_use]
242 pub const fn is_resolved(&self) -> bool {
243 self.source.is_some()
244 }
245}
246
247#[must_use]
255pub fn attributions(sources: &[Source], body: &str) -> Vec<Attribution> {
256 let refs = footnotes::extract_refs(body);
257 let defs = footnotes::extract_definitions(body);
258
259 let mut order: Vec<String> = Vec::new();
260 let push = |label: &str, order: &mut Vec<String>| {
261 if !order.iter().any(|l| l == label) {
262 order.push(label.to_string());
263 }
264 };
265 for r in &refs {
266 push(&r.label, &mut order);
267 }
268 for d in &defs {
269 push(&d.label, &mut order);
270 }
271
272 order
273 .into_iter()
274 .map(|label| Attribution {
275 references: refs.iter().filter(|r| r.label == label).count(),
276 definitions: defs.iter().filter(|d| d.label == label).count(),
277 source: sources
278 .iter()
279 .find(|s| s.id.as_deref() == Some(label.as_str()))
280 .cloned(),
281 label,
282 })
283 .collect()
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use crate::date::Date;
290
291 const SOURCES: &str = "\
292- id: rev-policy
293 resource: https://wiki.acme/finance/revenue-recognition
294 title: Revenue recognition policy
295 author: team:finance-fpa
296 last_modified: 2026-04-02T00:00:00Z
297- id: exec-rev-dash
298 resource: dashboards/exec-revenue
299 title: Executive revenue dashboard
300 author: team:finance-fpa
301 usage_count: 5000
302 last_modified: 2026-06-18T00:00:00Z
303";
304
305 fn sources() -> Vec<Source> {
306 Source::list_from_value(&Value::parse(SOURCES).unwrap())
307 }
308
309 #[test]
310 fn reads_entries_and_credibility_signals() {
311 let s = sources();
312 assert_eq!(s.len(), 2);
313 assert_eq!(s[0].id.as_deref(), Some("rev-policy"));
314 assert_eq!(s[0].resource_kind(), ResourceKind::Url);
315 assert_eq!(s[0].author.as_ref().unwrap().as_str(), "team:finance-fpa");
316 assert_eq!(
317 s[0].last_modified.as_ref().unwrap().datetime.unwrap().date,
318 Date::new(2026, 4, 2).unwrap()
319 );
320 assert_eq!(s[0].usage_count, None);
321
322 assert_eq!(s[1].usage_count, Some(5000));
323 assert_eq!(s[1].resource_kind(), ResourceKind::Path);
324 assert_eq!(s[1].label(), "Executive revenue dashboard");
325 }
326
327 #[test]
328 fn scope_descriptors_are_not_paths() {
329 let s = Source::from_value(
330 &Value::parse("{ resource: all queries in BigQuery project X }").unwrap(),
331 )
332 .unwrap();
333 assert_eq!(s.resource_kind(), ResourceKind::Scope);
334
335 let missing = Source::from_value(&Value::parse("{ id: x }").unwrap()).unwrap();
336 assert_eq!(missing.resource_kind(), ResourceKind::Missing);
337 }
338
339 #[test]
340 fn usage_window_entry_overrides_shared() {
341 let shared = UsageWindow::from_value(
342 &Value::parse("{ from: 2026-06-01T00:00:00Z, to: 2026-06-30T00:00:00Z }").unwrap(),
343 )
344 .unwrap();
345 let plain = &sources()[1];
346 assert_eq!(plain.effective_usage_window(Some(&shared)), Some(&shared));
347
348 let overridden = Source::from_value(
349 &Value::parse(
350 "{ resource: x, usage_window: { from: 2026-01-01T00:00:00Z, to: 2026-01-31T00:00:00Z } }",
351 )
352 .unwrap(),
353 )
354 .unwrap();
355 let window = overridden.effective_usage_window(Some(&shared)).unwrap();
356 assert_eq!(window.from.as_ref().unwrap().raw, "2026-01-01T00:00:00Z");
357 }
358
359 #[test]
360 fn attribution_joins_footnote_labels_to_source_ids() {
361 let body = "Per the recognition policy,[^rev-policy] corroborated by the \
362 dashboard.[^exec-rev-dash] And once more.[^rev-policy]\n\n\
363 [^rev-policy]: Revenue recognition policy\n\
364 [^exec-rev-dash]: Executive revenue dashboard\n\
365 [^ghost]: Not in sources\n";
366 let attributions = attributions(&sources(), body);
367 assert_eq!(attributions.len(), 3);
368
369 assert_eq!(attributions[0].label, "rev-policy");
370 assert_eq!(attributions[0].references, 2);
371 assert_eq!(attributions[0].definitions, 1);
372 assert!(attributions[0].is_resolved());
373 assert_eq!(
374 attributions[0].source.as_ref().unwrap().title.as_deref(),
375 Some("Revenue recognition policy")
376 );
377
378 assert_eq!(attributions[2].label, "ghost");
380 assert_eq!(attributions[2].references, 0);
381 assert!(!attributions[2].is_resolved());
382 }
383
384 #[test]
385 fn reordering_sources_does_not_change_attribution() {
386 let body = "Claim.[^exec-rev-dash]\n\n[^exec-rev-dash]: Executive revenue dashboard\n";
387 let mut reversed = sources();
388 reversed.reverse();
389 let a = attributions(&sources(), body);
390 let b = attributions(&reversed, body);
391 assert_eq!(a, b);
392 assert_eq!(
393 a[0].source.as_ref().unwrap().id.as_deref(),
394 Some("exec-rev-dash")
395 );
396 }
397}