1use std::collections::{BTreeMap, BTreeSet};
26
27use schemars::JsonSchema;
28use serde::{Deserialize, Serialize};
29use serde_json::Value;
30use sha2::{Digest, Sha256};
31
32use crate::capability::CapabilityRegistry;
33use crate::index::Index;
34use crate::model::{Object, Severity};
35
36pub const MOMENT: &str = "moment";
38pub const AUDIENCE: &str = "audience";
40pub const AREA: &str = "area";
42pub const ROUTE: &str = "/why/";
44
45pub const RESERVED: &[&str] = &["audiences", "areas", "graph", "index"];
49
50pub const STABLE: &str = "stable";
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
60pub struct Signal {
61 pub id: String,
63 pub text: String,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
70pub struct Example {
71 pub id: String,
73 pub audience: String,
75 pub title: String,
77 pub before: String,
79 pub after: String,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
86pub struct Moment {
87 pub id: String,
89 pub title: String,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub short_title: Option<String>,
94 pub hook: String,
96 pub summary: String,
98 pub status: String,
100 pub severity: String,
102 pub frequency: String,
104 #[serde(default)]
105 pub weight: u32,
107 #[serde(default)]
108 pub featured: bool,
110 pub audiences: Vec<String>,
112 pub areas: Vec<String>,
114 #[serde(default)]
115 pub lifecycle: Vec<String>,
117 #[serde(default)]
118 pub tags: Vec<String>,
120 pub signals: Vec<Signal>,
122 pub examples: Vec<Example>,
124 #[serde(default)]
125 pub commands: Vec<String>,
127 #[serde(default)]
128 pub capabilities: Vec<String>,
130 #[serde(default)]
131 pub claims: Vec<String>,
133 #[serde(default)]
134 pub doctrines: Vec<String>,
136 #[serde(default)]
137 pub use_cases: Vec<String>,
139 #[serde(default)]
140 pub related: Vec<String>,
142 #[serde(default)]
143 pub aliases: Vec<String>,
145
146 #[serde(default)]
147 pub route: String,
149 #[serde(default)]
150 pub source: String,
152 #[serde(default, skip_serializing_if = "String::is_empty")]
153 pub body: String,
155 #[serde(skip)]
156 search: String,
161}
162
163#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
165pub struct Audience {
166 pub id: String,
168 pub title: String,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub short_title: Option<String>,
173 pub summary: String,
175 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub workflow: Option<String>,
178 pub status: String,
180 #[serde(default)]
181 pub weight: u32,
183 #[serde(default)]
184 pub tags: Vec<String>,
186 #[serde(default)]
187 pub route: String,
189 #[serde(default)]
190 pub source: String,
192 #[serde(default, skip_serializing_if = "String::is_empty")]
193 pub body: String,
195}
196
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
199pub struct Area {
200 pub id: String,
202 pub title: String,
204 pub summary: String,
206 pub status: String,
208 #[serde(default)]
209 pub weight: u32,
211 #[serde(default)]
212 pub tags: Vec<String>,
214 #[serde(default)]
215 pub route: String,
217 #[serde(default)]
218 pub source: String,
220 #[serde(default, skip_serializing_if = "String::is_empty")]
221 pub body: String,
223}
224
225impl crate::order::Ordered for Moment {
230 fn order_key(&self) -> crate::order::OrderKey<'_> {
231 crate::order::OrderKey::plain(&self.id, &self.id).ranked(i64::from(self.weight))
232 }
233}
234
235impl crate::order::Ordered for Audience {
236 fn order_key(&self) -> crate::order::OrderKey<'_> {
237 crate::order::OrderKey::plain(&self.id, &self.id).ranked(i64::from(self.weight))
238 }
239}
240
241impl crate::order::Ordered for Area {
242 fn order_key(&self) -> crate::order::OrderKey<'_> {
243 crate::order::OrderKey::plain(&self.id, &self.id).ranked(i64::from(self.weight))
244 }
245}
246
247#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
257pub struct Finding {
258 pub severity: Severity,
260 pub code: String,
262 pub path: String,
264 #[serde(skip_serializing_if = "Option::is_none")]
266 pub id: Option<String>,
267 #[serde(skip_serializing_if = "Option::is_none")]
269 pub field: Option<String>,
270 pub message: String,
272 #[serde(skip_serializing_if = "Option::is_none")]
274 pub did_you_mean: Option<String>,
275}
276
277#[derive(Debug, Clone, Default)]
282pub struct Catalogue {
283 moments: Vec<Moment>,
284 audiences: Vec<Audience>,
285 areas: Vec<Area>,
286 findings: Vec<Finding>,
287 fingerprint: String,
288 by_id: BTreeMap<String, usize>,
289 by_audience: BTreeMap<String, Vec<usize>>,
290 by_area: BTreeMap<String, Vec<usize>>,
291 by_tag: BTreeMap<String, Vec<usize>>,
292 by_lifecycle: BTreeMap<String, Vec<usize>>,
293 by_command: BTreeMap<String, Vec<usize>>,
294 by_capability: BTreeMap<String, Vec<usize>>,
295 by_claim: BTreeMap<String, Vec<usize>>,
296 by_doctrine: BTreeMap<String, Vec<usize>>,
297 by_use_case: BTreeMap<String, Vec<usize>>,
298 by_signal: BTreeMap<String, usize>,
299 backlinks: BTreeMap<String, Vec<String>>,
300 responsibilities: BTreeMap<String, Vec<String>>,
301}
302
303fn record<T: for<'de> Deserialize<'de>>(o: &Object) -> Option<T> {
306 serde_json::from_value::<T>(o.metadata.clone()).ok()
307}
308
309fn distance(a: &str, b: &str) -> usize {
311 let (a, b): (Vec<char>, Vec<char>) = (a.chars().collect(), b.chars().collect());
312 let mut prev: Vec<usize> = (0..=b.len()).collect();
313 let mut cur = vec![0usize; b.len() + 1];
314 for (i, ca) in a.iter().enumerate() {
315 cur[0] = i + 1;
316 for (j, cb) in b.iter().enumerate() {
317 let cost = usize::from(ca != cb);
318 cur[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(cur[j] + 1);
319 }
320 std::mem::swap(&mut prev, &mut cur);
321 }
322 prev[b.len()]
323}
324
325fn nearest<'a>(value: &str, candidates: impl Iterator<Item = &'a str>) -> Option<String> {
327 let limit = (value.chars().count() / 3).max(2);
328 candidates
329 .map(|c| (distance(value, c), c))
330 .filter(|(d, _)| *d <= limit)
331 .min_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(b.1)))
332 .map(|(_, c)| c.to_string())
333}
334
335impl Catalogue {
336 pub fn build(index: &Index, registry: &CapabilityRegistry) -> Self {
340 let mut c = Catalogue::default();
341 let mut seen: BTreeMap<(String, String), String> = BTreeMap::new();
342
343 for o in index.objects.iter() {
344 let path = o.provenance.path.clone();
345 if matches!(o.kind.as_str(), MOMENT | AUDIENCE | AREA) {
348 if let Some(first) = seen.insert((o.kind.clone(), o.identity.clone()), path.clone())
349 {
350 c.findings.push(Finding {
351 severity: Severity::Error,
352 code: "duplicate_identity".into(),
353 path: path.clone(),
354 id: Some(o.identity.clone()),
355 field: Some("id".into()),
356 message: format!(
357 "a second {} claims the identity '{}' (first: {first})",
358 o.kind, o.identity
359 ),
360 did_you_mean: None,
361 });
362 }
363 c.check_file_name(o);
364 }
365 match o.kind.as_str() {
366 MOMENT => {
367 if let Some(mut m) = record::<Moment>(o) {
368 m.route = format!("{ROUTE}{}/", m.id);
369 m.source = path;
370 m.body = o.body.clone();
371 m.search = m.search_text();
372 c.moments.push(m);
373 }
374 }
375 AUDIENCE => {
376 if let Some(mut a) = record::<Audience>(o) {
377 a.route = format!("{ROUTE}audiences/{}/", a.id);
378 a.source = path;
379 a.body = o.body.clone();
380 c.audiences.push(a);
381 }
382 }
383 AREA => {
384 if let Some(mut a) = record::<Area>(o) {
385 a.route = format!("{ROUTE}areas/{}/", a.id);
386 a.source = path;
387 a.body = o.body.clone();
388 c.areas.push(a);
389 }
390 }
391 _ => {}
392 }
393 }
394
395 crate::order::canonical(&mut c.moments);
398 crate::order::canonical(&mut c.audiences);
399 crate::order::canonical(&mut c.areas);
400
401 c.adopt_diagnostics(index);
402 c.index_moments();
403 c.resolve(index, registry);
404 c.derive_responsibilities(index);
405 c.fingerprint = c.compute_fingerprint();
406 c
407 }
408
409 fn adopt_diagnostics(&mut self, index: &Index) {
414 let Some(section) = index.repository.sections.get("why") else {
415 return;
416 };
417 for d in index
418 .diagnostics
419 .iter()
420 .filter(|d| d.severity == Severity::Error)
421 {
422 let Some(path) = d.path.as_deref() else {
423 continue;
424 };
425 if !path.starts_with(section.as_str()) {
426 continue;
427 }
428 self.findings.push(Finding {
429 severity: Severity::Error,
430 code: d.code.clone(),
431 path: path.to_string(),
432 id: None,
433 field: None,
434 message: d.message.clone(),
435 did_you_mean: None,
436 });
437 }
438 }
439
440 fn check_file_name(&mut self, o: &Object) {
441 let stem = o
442 .provenance
443 .path
444 .rsplit('/')
445 .next()
446 .and_then(|f| f.strip_suffix(".md"))
447 .unwrap_or_default();
448 if stem != o.identity {
449 self.findings.push(Finding {
450 severity: Severity::Error,
451 code: "filename_mismatch".into(),
452 path: o.provenance.path.clone(),
453 id: Some(o.identity.clone()),
454 field: Some("id".into()),
455 message: format!(
456 "the file is named '{stem}.md' and the record's id is '{}'; the id is the file name and the route",
457 o.identity
458 ),
459 did_you_mean: Some(format!("{}.md", o.identity)),
460 });
461 }
462 }
463
464 fn index_moments(&mut self) {
465 for (i, m) in self.moments.iter().enumerate() {
466 self.by_id.insert(m.id.clone(), i);
467 for (map, values) in [
468 (&mut self.by_audience, &m.audiences),
469 (&mut self.by_area, &m.areas),
470 (&mut self.by_tag, &m.tags),
471 (&mut self.by_lifecycle, &m.lifecycle),
472 (&mut self.by_command, &m.commands),
473 (&mut self.by_capability, &m.capabilities),
474 (&mut self.by_claim, &m.claims),
475 (&mut self.by_doctrine, &m.doctrines),
476 (&mut self.by_use_case, &m.use_cases),
477 ] {
478 for v in values {
479 map.entry(v.clone()).or_default().push(i);
480 }
481 }
482 for s in &m.signals {
483 self.by_signal.insert(s.id.clone(), i);
484 }
485 for r in &m.related {
486 self.backlinks
487 .entry(r.clone())
488 .or_default()
489 .push(m.id.clone());
490 }
491 }
492 for v in self.backlinks.values_mut() {
493 v.sort();
494 v.dedup();
495 }
496 }
497
498 fn resolve(&mut self, index: &Index, registry: &CapabilityRegistry) {
501 let audiences: BTreeSet<String> = self.audiences.iter().map(|a| a.id.clone()).collect();
502 let areas: BTreeSet<String> = self.areas.iter().map(|a| a.id.clone()).collect();
503 let moments: BTreeSet<String> = self.moments.iter().map(|m| m.id.clone()).collect();
504 let of_kind = |k: &str| -> BTreeSet<String> {
505 index
506 .objects
507 .iter()
508 .filter(|o| o.kind == k)
509 .map(|o| o.identity.clone())
510 .collect()
511 };
512 let commands = of_kind("command");
513 let claims = of_kind("claim");
514 let use_cases = of_kind("use-case");
515 let rules: BTreeSet<String> = index
518 .objects
519 .iter()
520 .filter(|o| o.kind == "rule")
521 .filter_map(|o| o.metadata.get("id").and_then(Value::as_str))
522 .map(str::to_string)
523 .collect();
524 let capabilities: BTreeSet<String> = registry.iter().map(|c| c.id.to_string()).collect();
525
526 let mut findings = Vec::new();
527 for m in &self.moments {
528 let mut check =
529 |field: &str, values: &[String], known: &BTreeSet<String>, what: &str| {
530 for v in values {
531 if !known.contains(v) {
532 findings.push(Finding {
533 severity: Severity::Error,
534 code: "unknown_reference".into(),
535 path: m.source.clone(),
536 id: Some(m.id.clone()),
537 field: Some(field.into()),
538 message: format!("unknown {what} reference: \"{v}\""),
539 did_you_mean: nearest(v, known.iter().map(String::as_str)),
540 });
541 }
542 }
543 };
544 check("audiences", &m.audiences, &audiences, "audience");
545 check("areas", &m.areas, &areas, "area");
546 check("related", &m.related, &moments, "moment");
547 check("commands", &m.commands, &commands, "command");
548 check("capabilities", &m.capabilities, &capabilities, "capability");
549 check("claims", &m.claims, &claims, "claim");
550 check("doctrines", &m.doctrines, &rules, "rule");
551 check("use_cases", &m.use_cases, &use_cases, "use case");
552
553 for e in &m.examples {
554 if !audiences.contains(&e.audience) {
555 findings.push(Finding {
556 severity: Severity::Error,
557 code: "unknown_reference".into(),
558 path: m.source.clone(),
559 id: Some(m.id.clone()),
560 field: Some(format!("examples.{}.audience", e.id)),
561 message: format!("unknown audience reference: \"{}\"", e.audience),
562 did_you_mean: nearest(&e.audience, audiences.iter().map(String::as_str)),
563 });
564 }
565 }
566 if RESERVED.contains(&m.id.as_str()) {
567 findings.push(Finding {
568 severity: Severity::Error,
569 code: "reserved_identity".into(),
570 path: m.source.clone(),
571 id: Some(m.id.clone()),
572 field: Some("id".into()),
573 message: format!(
574 "'{}' is a route this section owns; a moment may not claim it (reserved: {})",
575 m.id,
576 RESERVED.join(", ")
577 ),
578 did_you_mean: None,
579 });
580 }
581 if m.related.contains(&m.id) {
582 findings.push(Finding {
583 severity: Severity::Error,
584 code: "self_reference".into(),
585 path: m.source.clone(),
586 id: Some(m.id.clone()),
587 field: Some("related".into()),
588 message: "a moment may not be related to itself".into(),
589 did_you_mean: None,
590 });
591 }
592 findings.extend(self.quality(m));
593 }
594 findings.extend(self.audience_coverage());
595 findings.sort_by(|a, b| {
596 b.severity
597 .cmp(&a.severity)
598 .then(a.path.cmp(&b.path))
599 .then(a.code.cmp(&b.code))
600 .then(a.message.cmp(&b.message))
601 });
602 self.findings.extend(findings);
603 }
604
605 fn quality(&self, m: &Moment) -> Vec<Finding> {
608 if m.status != STABLE {
609 return Vec::new();
610 }
611 let mut out = Vec::new();
612 fn fail(out: &mut Vec<Finding>, m: &Moment, field: &str, message: String) {
613 out.push(Finding {
614 severity: Severity::Warning,
615 code: "missing_content".into(),
616 path: m.source.clone(),
617 id: Some(m.id.clone()),
618 field: Some(field.into()),
619 message,
620 did_you_mean: None,
621 });
622 }
623 if m.signals.is_empty() {
624 fail(
625 &mut out,
626 m,
627 "signals",
628 "a stable moment carries at least one signal".into(),
629 );
630 }
631 if m.examples.len() < 3 {
632 fail(
633 &mut out,
634 m,
635 "examples",
636 format!(
637 "a stable moment carries at least three concrete examples; this one has {}",
638 m.examples.len()
639 ),
640 );
641 }
642 let audiences: BTreeSet<&str> = m.examples.iter().map(|e| e.audience.as_str()).collect();
643 if m.examples.len() >= 3 && audiences.len() < 2 {
644 fail(
645 &mut out,
646 m,
647 "examples",
648 "the examples of a stable moment cover more than one audience".into(),
649 );
650 }
651 let ids: BTreeSet<&str> = m.examples.iter().map(|e| e.id.as_str()).collect();
652 if ids.len() != m.examples.len() {
653 out.push(Finding {
654 severity: Severity::Error,
655 code: "duplicate_identity".into(),
656 path: m.source.clone(),
657 id: Some(m.id.clone()),
658 field: Some("examples".into()),
659 message: "two examples of one moment share an id".into(),
660 did_you_mean: None,
661 });
662 }
663 let signal_ids: BTreeSet<&str> = m.signals.iter().map(|s| s.id.as_str()).collect();
664 if signal_ids.len() != m.signals.len() {
665 out.push(Finding {
666 severity: Severity::Error,
667 code: "duplicate_identity".into(),
668 path: m.source.clone(),
669 id: Some(m.id.clone()),
670 field: Some("signals".into()),
671 message: "two signals of one moment share an id".into(),
672 did_you_mean: None,
673 });
674 }
675 if m.capabilities.is_empty() && m.commands.is_empty() && m.claims.is_empty() {
676 fail(
677 &mut out,
678 m,
679 "capabilities",
680 "a stable moment names the mechanism that answers it: a capability, a command or a claim".into(),
681 );
682 }
683 for heading in [
684 "## The moment",
685 "## Why it happens",
686 "## What it does not do",
687 ] {
688 if !m.body.lines().any(|l| l.trim_end() == heading) {
689 fail(
690 &mut out,
691 m,
692 "body",
693 format!("a stable moment's body carries `{heading}`"),
694 );
695 }
696 }
697 out
698 }
699
700 fn audience_coverage(&self) -> Vec<Finding> {
702 const FLOOR: usize = 3;
703 self.audiences
704 .iter()
705 .filter(|a| a.status == STABLE)
706 .filter_map(|a| {
707 let n = self
708 .by_audience
709 .get(&a.id)
710 .map(|v| v.iter().filter(|i| self.moments[**i].status == STABLE).count())
711 .unwrap_or(0);
712 (n < FLOOR).then(|| Finding {
713 severity: Severity::Warning,
714 code: "thin_audience".into(),
715 path: a.source.clone(),
716 id: Some(a.id.clone()),
717 field: None,
718 message: format!(
719 "a stable audience is named by at least {FLOOR} stable moments; this one is named by {n}"
720 ),
721 did_you_mean: None,
722 })
723 })
724 .collect()
725 }
726
727 fn derive_responsibilities(&mut self, index: &Index) {
731 let of_claim: BTreeMap<&str, &str> = index
732 .objects
733 .iter()
734 .filter(|o| o.kind == "claim")
735 .filter_map(|o| {
736 let r = o.metadata.get("responsibility").and_then(Value::as_str)?;
737 (r != "none").then_some((o.identity.as_str(), r))
738 })
739 .collect();
740 for m in &self.moments {
741 let mut r: Vec<String> = m
742 .claims
743 .iter()
744 .filter_map(|c| of_claim.get(c.as_str()).map(|s| s.to_string()))
745 .collect();
746 crate::order::canonical(&mut r);
747 r.dedup();
748 if !r.is_empty() {
749 self.responsibilities.insert(m.id.clone(), r);
750 }
751 }
752 }
753
754 fn compute_fingerprint(&self) -> String {
758 let mut h = Sha256::new();
759 for (kind, id, source, body) in self
760 .moments
761 .iter()
762 .map(|m| (MOMENT, &m.id, &m.source, &m.body))
763 .chain(
764 self.audiences
765 .iter()
766 .map(|a| (AUDIENCE, &a.id, &a.source, &a.body)),
767 )
768 .chain(self.areas.iter().map(|a| (AREA, &a.id, &a.source, &a.body)))
769 {
770 h.update(kind.as_bytes());
771 h.update([0]);
772 h.update(id.as_bytes());
773 h.update([0]);
774 h.update(source.as_bytes());
775 h.update([0]);
776 h.update(body.as_bytes());
777 h.update(*b"\n");
778 }
779 format!("{:x}", h.finalize())
780 }
781}
782
783#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
789#[serde(deny_unknown_fields)]
790pub struct Query {
791 #[serde(default)]
792 pub audience: Option<String>,
794 #[serde(default)]
795 pub area: Option<String>,
797 #[serde(default)]
798 pub tag: Option<String>,
800 #[serde(default)]
801 pub severity: Option<String>,
803 #[serde(default)]
804 pub frequency: Option<String>,
806 #[serde(default)]
807 pub lifecycle: Option<String>,
809 #[serde(default)]
810 pub capability: Option<String>,
812 #[serde(default)]
813 pub command: Option<String>,
815 #[serde(default)]
816 pub featured: Option<bool>,
818 #[serde(default)]
819 pub status: Option<String>,
822 #[serde(default)]
823 pub q: Option<String>,
826}
827
828impl Catalogue {
829 pub fn all(&self) -> &[Moment] {
831 &self.moments
832 }
833 pub fn audiences(&self) -> &[Audience] {
835 &self.audiences
836 }
837 pub fn areas(&self) -> &[Area] {
839 &self.areas
840 }
841 pub fn moment(&self, id: &str) -> Option<&Moment> {
843 self.by_id.get(id).map(|i| &self.moments[*i])
844 }
845 pub fn audience(&self, id: &str) -> Option<&Audience> {
847 self.audiences.iter().find(|a| a.id == id)
848 }
849 pub fn area(&self, id: &str) -> Option<&Area> {
851 self.areas.iter().find(|a| a.id == id)
852 }
853 pub fn findings(&self) -> &[Finding] {
856 &self.findings
857 }
858 pub fn errors(&self) -> usize {
860 self.findings
861 .iter()
862 .filter(|f| f.severity == Severity::Error)
863 .count()
864 }
865 pub fn fingerprint(&self) -> &str {
867 &self.fingerprint
868 }
869 pub fn responsibilities(&self, id: &str) -> &[String] {
871 self.responsibilities
872 .get(id)
873 .map(Vec::as_slice)
874 .unwrap_or(&[])
875 }
876 pub fn backlinks(&self, id: &str) -> &[String] {
878 self.backlinks.get(id).map(Vec::as_slice).unwrap_or(&[])
879 }
880 pub fn naming(&self, field: &str, value: &str) -> Vec<&Moment> {
883 let map = match field {
884 "audience" => &self.by_audience,
885 "area" => &self.by_area,
886 "tag" => &self.by_tag,
887 "lifecycle" => &self.by_lifecycle,
888 "command" => &self.by_command,
889 "capability" => &self.by_capability,
890 "claim" => &self.by_claim,
891 "doctrine" => &self.by_doctrine,
892 "use_case" => &self.by_use_case,
893 _ => return Vec::new(),
894 };
895 map.get(value)
896 .map(|v| v.iter().map(|i| &self.moments[*i]).collect())
897 .unwrap_or_default()
898 }
899 pub fn signal_owner(&self, signal: &str) -> Option<&Moment> {
901 self.by_signal.get(signal).map(|i| &self.moments[*i])
902 }
903
904 pub fn select(&self, q: &Query) -> Vec<&Moment> {
906 let status = q.status.as_deref().unwrap_or(STABLE);
907 let text = q.q.as_ref().map(|s| s.to_lowercase());
908 self.moments
909 .iter()
910 .filter(|m| status == "any" || m.status == status)
911 .filter(|m| q.audience.as_ref().is_none_or(|v| m.audiences.contains(v)))
912 .filter(|m| q.area.as_ref().is_none_or(|v| m.areas.contains(v)))
913 .filter(|m| q.tag.as_ref().is_none_or(|v| m.tags.contains(v)))
914 .filter(|m| q.severity.as_deref().is_none_or(|v| m.severity == v))
915 .filter(|m| q.frequency.as_deref().is_none_or(|v| m.frequency == v))
916 .filter(|m| q.lifecycle.as_ref().is_none_or(|v| m.lifecycle.contains(v)))
917 .filter(|m| {
918 q.capability
919 .as_ref()
920 .is_none_or(|v| m.capabilities.contains(v))
921 })
922 .filter(|m| q.command.as_ref().is_none_or(|v| m.commands.contains(v)))
923 .filter(|m| q.featured.is_none_or(|v| m.featured == v))
924 .filter(|m| text.as_deref().is_none_or(|t| m.matches(t)))
925 .collect()
926 }
927}
928
929impl Moment {
930 pub fn search_text(&self) -> String {
932 let mut s = String::new();
933 for part in [
934 self.id.as_str(),
935 self.title.as_str(),
936 self.short_title.as_deref().unwrap_or(""),
937 self.hook.as_str(),
938 self.summary.as_str(),
939 self.body.as_str(),
940 ] {
941 s.push_str(part);
942 s.push('\n');
943 }
944 for list in [&self.tags, &self.aliases, &self.audiences, &self.areas] {
945 for v in list {
946 s.push_str(v);
947 s.push('\n');
948 }
949 }
950 for sig in &self.signals {
951 s.push_str(&sig.text);
952 s.push('\n');
953 }
954 for e in &self.examples {
955 for part in [&e.title, &e.before, &e.after] {
956 s.push_str(part);
957 s.push('\n');
958 }
959 }
960 s.to_lowercase()
961 }
962
963 pub fn matches(&self, needle: &str) -> bool {
966 self.search.contains(needle)
967 }
968
969 pub fn label(&self) -> &str {
971 self.short_title.as_deref().unwrap_or(&self.title)
972 }
973}
974
975#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
980pub struct FacetValue {
981 pub value: String,
983 pub label: String,
985 pub count: usize,
987}
988
989#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
991pub struct Facets {
992 pub audiences: Vec<FacetValue>,
994 pub areas: Vec<FacetValue>,
996 pub tags: Vec<FacetValue>,
998 pub severities: Vec<FacetValue>,
1000 pub frequencies: Vec<FacetValue>,
1002 pub lifecycle: Vec<FacetValue>,
1004 pub capabilities: Vec<FacetValue>,
1006 pub commands: Vec<FacetValue>,
1008}
1009
1010impl Catalogue {
1011 pub fn facets(&self) -> Facets {
1014 let public: Vec<&Moment> = self.moments.iter().filter(|m| m.status == STABLE).collect();
1015 let titles: BTreeMap<&str, &str> = self
1016 .audiences
1017 .iter()
1018 .map(|a| (a.id.as_str(), a.short_title.as_deref().unwrap_or(&a.title)))
1019 .chain(self.areas.iter().map(|a| (a.id.as_str(), a.title.as_str())))
1020 .collect();
1021 let tally = |f: &dyn Fn(&Moment) -> Vec<String>| -> Vec<FacetValue> {
1022 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
1023 for m in &public {
1024 for v in f(m) {
1025 *counts.entry(v).or_default() += 1;
1026 }
1027 }
1028 let mut out: Vec<FacetValue> = counts
1029 .into_iter()
1030 .map(|(value, count)| FacetValue {
1031 label: titles
1032 .get(value.as_str())
1033 .map_or(value.clone(), |t| t.to_string()),
1034 value,
1035 count,
1036 })
1037 .collect();
1038 out.sort_by(|a, b| b.count.cmp(&a.count).then(a.value.cmp(&b.value)));
1039 out
1040 };
1041 let ordered =
1044 |ids: Vec<(&str, &str)>, f: &dyn Fn(&Moment) -> Vec<String>| -> Vec<FacetValue> {
1045 let counts = tally(f);
1046 ids.into_iter()
1047 .filter_map(|(id, label)| {
1048 counts.iter().find(|c| c.value == id).map(|c| FacetValue {
1049 value: id.to_string(),
1050 label: label.to_string(),
1051 count: c.count,
1052 })
1053 })
1054 .collect()
1055 };
1056 Facets {
1057 audiences: ordered(
1058 self.audiences
1059 .iter()
1060 .filter(|a| a.status != "deprecated")
1061 .map(|a| (a.id.as_str(), a.short_title.as_deref().unwrap_or(&a.title)))
1062 .collect(),
1063 &|m| m.audiences.clone(),
1064 ),
1065 areas: ordered(
1066 self.areas
1067 .iter()
1068 .filter(|a| a.status != "deprecated")
1069 .map(|a| (a.id.as_str(), a.title.as_str()))
1070 .collect(),
1071 &|m| m.areas.clone(),
1072 ),
1073 tags: tally(&|m| m.tags.clone()),
1074 severities: tally(&|m| vec![m.severity.clone()]),
1075 frequencies: tally(&|m| vec![m.frequency.clone()]),
1076 lifecycle: tally(&|m| m.lifecycle.clone()),
1077 capabilities: tally(&|m| m.capabilities.clone()),
1078 commands: tally(&|m| m.commands.clone()),
1079 }
1080 }
1081}
1082
1083#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1088pub struct Recommendation {
1089 pub id: String,
1091 pub count: usize,
1093 pub matched_because: Vec<String>,
1095}
1096
1097#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1105pub struct Diagnosis {
1106 pub moments: Vec<String>,
1108 pub unresolved: Vec<String>,
1110 pub areas: Vec<Recommendation>,
1112 pub audiences: Vec<Recommendation>,
1114 pub capabilities: Vec<Recommendation>,
1116 pub commands: Vec<Recommendation>,
1118 pub claims: Vec<Recommendation>,
1120 pub doctrines: Vec<Recommendation>,
1122 pub use_cases: Vec<Recommendation>,
1124 pub also_worth_reading: Vec<String>,
1126}
1127
1128impl Catalogue {
1129 pub fn diagnose(&self, selected: &[String]) -> Diagnosis {
1133 let mut ids: Vec<&str> = Vec::new();
1134 let mut unresolved: Vec<String> = Vec::new();
1135 for name in selected {
1136 if let Some(m) = self.signal_owner(name).or_else(|| self.moment(name)) {
1137 if !ids.contains(&m.id.as_str()) {
1138 ids.push(&m.id);
1139 }
1140 } else {
1141 unresolved.push(name.clone());
1142 }
1143 }
1144 let chosen: Vec<&Moment> = self
1145 .moments
1146 .iter()
1147 .filter(|m| ids.contains(&m.id.as_str()))
1148 .collect();
1149
1150 let rank = |f: &dyn Fn(&Moment) -> Vec<String>| -> Vec<Recommendation> {
1151 let mut by: BTreeMap<String, Vec<String>> = BTreeMap::new();
1152 for m in &chosen {
1153 for v in f(m) {
1154 by.entry(v).or_default().push(m.id.clone());
1155 }
1156 }
1157 let mut out: Vec<Recommendation> = by
1158 .into_iter()
1159 .map(|(id, matched_because)| Recommendation {
1160 id,
1161 count: matched_because.len(),
1162 matched_because,
1163 })
1164 .collect();
1165 out.sort_by(|a, b| b.count.cmp(&a.count).then(a.id.cmp(&b.id)));
1166 out
1167 };
1168
1169 let areas = rank(&|m| m.areas.clone());
1170 let mut also: Vec<String> = Vec::new();
1171 for m in self.moments.iter().filter(|m| m.status == STABLE) {
1172 if ids.contains(&m.id.as_str()) {
1173 continue;
1174 }
1175 let shares_area = m.areas.iter().any(|a| areas.iter().any(|r| &r.id == a));
1176 let is_related = chosen.iter().any(|c| c.related.contains(&m.id));
1177 if shares_area || is_related {
1178 also.push(m.id.clone());
1179 }
1180 }
1181
1182 Diagnosis {
1183 moments: chosen.iter().map(|m| m.id.clone()).collect(),
1184 unresolved,
1185 audiences: rank(&|m| m.audiences.clone()),
1186 areas,
1187 capabilities: rank(&|m| m.capabilities.clone()),
1188 commands: rank(&|m| m.commands.clone()),
1189 claims: rank(&|m| m.claims.clone()),
1190 doctrines: rank(&|m| m.doctrines.clone()),
1191 use_cases: rank(&|m| m.use_cases.clone()),
1192 also_worth_reading: also,
1193 }
1194 }
1195}