Skip to main content

majordomus_cli/
why.rs

1//! The Why catalogue: the operational moments this tool is a response to, the audiences
2//! that recognise them and the areas they fall under, read from the index and answered as
3//! one immutable typed model.
4//!
5//! Nothing here discovers or parses a file. The moments, audiences and areas are ordinary
6//! declarative objects of the layer — kinds `moment`, `audience` and `area` in
7//! `share/kinds.yaml`, discovered by the source classes in the repository's
8//! `sources.yaml`, and already validated against their JSON Schemas by the time the index
9//! holds them. This module projects that validated metadata into typed records, resolves
10//! every reference the records make, derives every relation nobody authored, and answers
11//! queries over the result.
12//!
13//! Built once, at the moment a [`crate::capability::Context`] is composed, and shared by
14//! every projection: the command line, the HTTP routes, the MCP tools, the derived graph
15//! and the site's dataset are readers of this one value. A request never rebuilds it.
16//!
17//! Two directions are kept apart on purpose. **Authored** is what a file says: its hook,
18//! its summary, the audiences and areas it belongs to, its signals, its examples, and the
19//! things of this repository it names. **Derived** is everything else — the route, the
20//! members of an audience, the moments that answer a claim, the responsibilities a moment
21//! touches, the reverse of `related`, every count and every facet — and none of it may be
22//! written into a source file. The schemas refuse the derived keys, and this module is
23//! where they come from instead.
24
25use 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
36/// The kind of an operational moment.
37pub const MOMENT: &str = "moment";
38/// The kind of an audience.
39pub const AUDIENCE: &str = "audience";
40/// The kind of an operational area.
41pub const AREA: &str = "area";
42/// The section the catalogue is published under.
43pub const ROUTE: &str = "/why/";
44
45/// The identities the section's own routes use. A moment called one of these would claim a
46/// route the section already owns — `/why/audiences/` is the taxonomy, not a moment — and
47/// the collision would show up as a page quietly replaced rather than as an error.
48pub const RESERVED: &[&str] = &["audiences", "areas", "graph", "index"];
49
50/// A moment that is complete and public. Draft records are listed as drafts and counted
51/// nowhere; deprecated ones keep their route and are listed nowhere.
52pub const STABLE: &str = "stable";
53
54// ---------------------------------------------------------------- authored records
55
56/// One observable symptom of a moment: a question a reader can answer about their own
57/// week. The questionnaire on the site and the input of `why diagnose` are these and
58/// nothing else.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
60pub struct Signal {
61    /// Unique within the moment; the identity a diagnosis selects by.
62    pub id: String,
63    /// The symptom, phrased so a reader can say whether it happened to them.
64    pub text: String,
65}
66
67/// One concrete situation, in one audience, with what happens today and what happens
68/// instead.
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
70pub struct Example {
71    /// Unique within the moment.
72    pub id: String,
73    /// The audience this situation belongs to; must name an audience of the catalogue.
74    pub audience: String,
75    /// One line naming the situation.
76    pub title: String,
77    /// What happens without the tool.
78    pub before: String,
79    /// What happens with it.
80    pub after: String,
81}
82
83/// One operational moment, as its file declares it plus the two facts the file cannot
84/// hold: where it came from and where it is published.
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
86pub struct Moment {
87    /// The identity, the slug and the file name.
88    pub id: String,
89    /// The moment as a heading.
90    pub title: String,
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    /// Two or three words for a narrow column, when the record carries one.
93    pub short_title: Option<String>,
94    /// The first-person line an index shows.
95    pub hook: String,
96    /// One line: what is actually wrong.
97    pub summary: String,
98    /// `stable`, `draft` or `deprecated`.
99    pub status: String,
100    /// `low`, `medium` or `high`.
101    pub severity: String,
102    /// `rare`, `occasional`, `common` or `constant`.
103    pub frequency: String,
104    #[serde(default)]
105    /// Presentation order, lowest first.
106    pub weight: u32,
107    #[serde(default)]
108    /// Whether the homepage shows it.
109    pub featured: bool,
110    /// The audiences that recognise it.
111    pub audiences: Vec<String>,
112    /// The operational areas it falls under.
113    pub areas: Vec<String>,
114    #[serde(default)]
115    /// The stages of work at which it shows up.
116    pub lifecycle: Vec<String>,
117    #[serde(default)]
118    /// Free tags.
119    pub tags: Vec<String>,
120    /// The observable symptoms.
121    pub signals: Vec<Signal>,
122    /// The concrete situations.
123    pub examples: Vec<Example>,
124    #[serde(default)]
125    /// Commands of the tool that answer it.
126    pub commands: Vec<String>,
127    #[serde(default)]
128    /// Capability ids of the executable that answer it.
129    pub capabilities: Vec<String>,
130    #[serde(default)]
131    /// Claims that say what is guaranteed here.
132    pub claims: Vec<String>,
133    #[serde(default)]
134    /// Rules of the effective set that govern it.
135    pub doctrines: Vec<String>,
136    #[serde(default)]
137    /// Use cases that show the way out.
138    pub use_cases: Vec<String>,
139    #[serde(default)]
140    /// Moments explicitly related to this one. The reverse is derived.
141    pub related: Vec<String>,
142    #[serde(default)]
143    /// Other words a reader might search for.
144    pub aliases: Vec<String>,
145
146    #[serde(default)]
147    /// Derived: `/why/<id>/`. Never authored; the schema refuses a `route` key.
148    pub route: String,
149    #[serde(default)]
150    /// Derived: the repository-relative file the record came from.
151    pub source: String,
152    #[serde(default, skip_serializing_if = "String::is_empty")]
153    /// The Markdown body, without its front matter.
154    pub body: String,
155    #[serde(skip)]
156    /// Derived once, when the catalogue is built: everything a reader might remember about
157    /// this moment, lower-cased. A query matches against this rather than rebuilding it,
158    /// which is what keeps a search over the catalogue independent of how the records are
159    /// shaped (`project.derived-once`).
160    search: String,
161}
162
163/// One audience. Membership is never listed here: it is each moment's `audiences`.
164#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
165pub struct Audience {
166    /// The identity, the slug and the file name.
167    pub id: String,
168    /// Who they are.
169    pub title: String,
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    /// Two or three words for a filter chip.
172    pub short_title: Option<String>,
173    /// One line: the working situation.
174    pub summary: String,
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    /// One line: how work flows for them.
177    pub workflow: Option<String>,
178    /// `stable`, `draft` or `deprecated`.
179    pub status: String,
180    #[serde(default)]
181    /// Presentation order.
182    pub weight: u32,
183    #[serde(default)]
184    /// Free tags.
185    pub tags: Vec<String>,
186    #[serde(default)]
187    /// Derived: `/why/audiences/<id>/`.
188    pub route: String,
189    #[serde(default)]
190    /// Derived: the file it came from.
191    pub source: String,
192    #[serde(default, skip_serializing_if = "String::is_empty")]
193    /// The Markdown body.
194    pub body: String,
195}
196
197/// One operational area. Membership is never listed here either.
198#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
199pub struct Area {
200    /// The identity, the slug and the file name.
201    pub id: String,
202    /// The area.
203    pub title: String,
204    /// One line: what falls under it.
205    pub summary: String,
206    /// `stable`, `draft` or `deprecated`.
207    pub status: String,
208    #[serde(default)]
209    /// Presentation order.
210    pub weight: u32,
211    #[serde(default)]
212    /// Free tags.
213    pub tags: Vec<String>,
214    #[serde(default)]
215    /// Derived: `/why/areas/<id>/`.
216    pub route: String,
217    #[serde(default)]
218    /// Derived: the file it came from.
219    pub source: String,
220    #[serde(default, skip_serializing_if = "String::is_empty")]
221    /// The Markdown body.
222    pub body: String,
223}
224
225// The three catalogues are ordered the same way, once. `weight` is the ranking each record
226// declares; the canonical order takes it as the rank and ends on the identity, so two
227// records that declare the same weight cannot swap places between runs or between machines.
228
229impl 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// ---------------------------------------------------------------- findings
248
249// How much a finding matters is the layer's own [`Severity`]: `error` is a broken
250// catalogue — a reference that resolves to nothing, or two records claiming one identity —
251// and `warning` is a record that is valid and does not meet the floor its status promises.
252// A second severity vocabulary would be a second noun for one idea.
253
254/// One thing wrong with the catalogue, named where it is, with the nearest candidate when
255/// there is one. A finding always carries enough to fix it without searching.
256#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
257pub struct Finding {
258    /// `error` or `warning`.
259    pub severity: Severity,
260    /// A short category: `unknown_reference`, `duplicate_identity`, `missing_content`, ...
261    pub code: String,
262    /// The repository-relative file the finding is in.
263    pub path: String,
264    /// The record's identity, when one record owns the finding.
265    #[serde(skip_serializing_if = "Option::is_none")]
266    pub id: Option<String>,
267    /// The front-matter key the finding is about, when one key owns it.
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub field: Option<String>,
270    /// What is wrong, in one sentence.
271    pub message: String,
272    /// The nearest existing name, when the value looks like a typo of one.
273    #[serde(skip_serializing_if = "Option::is_none")]
274    pub did_you_mean: Option<String>,
275}
276
277// ---------------------------------------------------------------- the catalogue
278
279/// The validated catalogue and every index derived from it. Immutable: built once from an
280/// index and a registry, read many times.
281#[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
303/// Parse one object's validated metadata into a typed record, and attach the two derived
304/// facts the file cannot hold.
305fn record<T: for<'de> Deserialize<'de>>(o: &Object) -> Option<T> {
306    serde_json::from_value::<T>(o.metadata.clone()).ok()
307}
308
309/// Levenshtein distance, for the nearest-candidate hint on an unresolved reference.
310fn 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
325/// The nearest candidate to `value`, when one is close enough to be a plausible typo.
326fn 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    /// Build the catalogue from an index and the registry its capability references
337    /// resolve against. Deterministic: the same objects produce the same value, and no
338    /// derivation reads a clock or the filesystem.
339    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            // The index refuses two objects with one URI, so a duplicate identity here is
346            // a duplicate *file name stem* against the id, which the index cannot see.
347            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        // Weight is the catalogue's own ranking; the canonical order takes it as the rank
396        // and breaks its ties by identity, the way every other collection is ordered.
397        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    /// Every diagnostic the index raised about a file of this section. An object the index
410    /// refused — two files claiming one identity, malformed front matter, a schema
411    /// violation — never reaches the catalogue at all, so without this the catalogue would
412    /// report itself valid while the thing it is a catalogue of had been excluded.
413    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    /// Every reference every record makes, resolved against the thing it names. A name
499    /// that resolves to nothing is an error with the nearest candidate offered.
500    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        // a rule's identity is `<id>@<version>`; a moment names the id, which is what a
516        // use case and an application name too
517        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    /// The quality floors a public record promises. A draft is exempt and says so by
606    /// being a draft.
607    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    /// A public audience nobody recognises is a filter that answers nothing.
701    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    /// A moment's responsibilities are those of the claims it stands on. Every claim
728    /// declares the responsibility it belongs to, so this relation is derived and a
729    /// moment never authors it.
730    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    /// A hash of the catalogue's own sources, in identity order. It depends on nothing
755    /// but these files, so two runs over one tree agree even when the rest of the index
756    /// has moved between them.
757    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// ---------------------------------------------------------------- reading it
784
785/// What a listing is narrowed by. Every field is optional and they compose as a
786/// conjunction; the values a caller may pass are the facets the catalogue reports, never a
787/// list written anywhere.
788#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
789#[serde(deny_unknown_fields)]
790pub struct Query {
791    #[serde(default)]
792    /// Only moments this audience recognises.
793    pub audience: Option<String>,
794    #[serde(default)]
795    /// Only moments in this operational area.
796    pub area: Option<String>,
797    #[serde(default)]
798    /// Only moments carrying this tag.
799    pub tag: Option<String>,
800    #[serde(default)]
801    /// Only moments of this severity.
802    pub severity: Option<String>,
803    #[serde(default)]
804    /// Only moments of this frequency.
805    pub frequency: Option<String>,
806    #[serde(default)]
807    /// Only moments at this stage of work.
808    pub lifecycle: Option<String>,
809    #[serde(default)]
810    /// Only moments naming this capability of the executable.
811    pub capability: Option<String>,
812    #[serde(default)]
813    /// Only moments naming this command.
814    pub command: Option<String>,
815    #[serde(default)]
816    /// Only moments the homepage features.
817    pub featured: Option<bool>,
818    #[serde(default)]
819    /// Only moments of this status. Absent means the public ones (`stable`); pass `any`
820    /// for everything the catalogue holds.
821    pub status: Option<String>,
822    #[serde(default)]
823    /// Case-insensitive text, matched against the identity, the titles, the hook, the
824    /// summary, the tags, the aliases, the signals, the examples and the body.
825    pub q: Option<String>,
826}
827
828impl Catalogue {
829    /// Every moment the catalogue holds, in presentation order, whatever its status.
830    pub fn all(&self) -> &[Moment] {
831        &self.moments
832    }
833    /// Every audience, in presentation order.
834    pub fn audiences(&self) -> &[Audience] {
835        &self.audiences
836    }
837    /// Every area, in presentation order.
838    pub fn areas(&self) -> &[Area] {
839        &self.areas
840    }
841    /// One moment by id.
842    pub fn moment(&self, id: &str) -> Option<&Moment> {
843        self.by_id.get(id).map(|i| &self.moments[*i])
844    }
845    /// One audience by id.
846    pub fn audience(&self, id: &str) -> Option<&Audience> {
847        self.audiences.iter().find(|a| a.id == id)
848    }
849    /// One area by id.
850    pub fn area(&self, id: &str) -> Option<&Area> {
851        self.areas.iter().find(|a| a.id == id)
852    }
853    /// Every finding: unresolved references, duplicate identities, and records that do
854    /// not meet the floor their status promises.
855    pub fn findings(&self) -> &[Finding] {
856        &self.findings
857    }
858    /// How many findings are errors.
859    pub fn errors(&self) -> usize {
860        self.findings
861            .iter()
862            .filter(|f| f.severity == Severity::Error)
863            .count()
864    }
865    /// The hash of the catalogue's own sources.
866    pub fn fingerprint(&self) -> &str {
867        &self.fingerprint
868    }
869    /// The responsibilities a moment touches, derived from the claims it names.
870    pub fn responsibilities(&self, id: &str) -> &[String] {
871        self.responsibilities
872            .get(id)
873            .map(Vec::as_slice)
874            .unwrap_or(&[])
875    }
876    /// The moments that name this one in their `related`. Never authored.
877    pub fn backlinks(&self, id: &str) -> &[String] {
878        self.backlinks.get(id).map(Vec::as_slice).unwrap_or(&[])
879    }
880    /// The moments that name this thing, whatever kind of thing it is. This is the one
881    /// reverse index every "the moments this answers" list is read from.
882    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    /// The moment a signal belongs to.
900    pub fn signal_owner(&self, signal: &str) -> Option<&Moment> {
901        self.by_signal.get(signal).map(|i| &self.moments[*i])
902    }
903
904    /// The moments a query selects, in presentation order.
905    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    /// The text a search reads: everything a reader might remember about this moment.
931    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    /// Does the lower-cased needle occur anywhere in the search text? The text was built
964    /// once, when the catalogue was; this is a substring test and nothing else.
965    pub fn matches(&self, needle: &str) -> bool {
966        self.search.contains(needle)
967    }
968
969    /// The name a narrow column shows.
970    pub fn label(&self) -> &str {
971        self.short_title.as_deref().unwrap_or(&self.title)
972    }
973}
974
975// ---------------------------------------------------------------- facets
976
977/// One selectable value of one facet, with how many public moments carry it. Every filter
978/// a reader is offered is one of these; nothing anywhere lists the values by hand.
979#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
980pub struct FacetValue {
981    /// The value as a moment declares it.
982    pub value: String,
983    /// The name to show; the value itself when the taxonomy holds no title for it.
984    pub label: String,
985    /// How many public moments carry it.
986    pub count: usize,
987}
988
989/// The filters the catalogue offers, derived from the records rather than declared.
990#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
991pub struct Facets {
992    /// By audience.
993    pub audiences: Vec<FacetValue>,
994    /// By operational area.
995    pub areas: Vec<FacetValue>,
996    /// By tag.
997    pub tags: Vec<FacetValue>,
998    /// By severity.
999    pub severities: Vec<FacetValue>,
1000    /// By frequency.
1001    pub frequencies: Vec<FacetValue>,
1002    /// By stage of work.
1003    pub lifecycle: Vec<FacetValue>,
1004    /// By capability of the executable.
1005    pub capabilities: Vec<FacetValue>,
1006    /// By command.
1007    pub commands: Vec<FacetValue>,
1008}
1009
1010impl Catalogue {
1011    /// The facets, counted over the public moments. A value with no public moment is not
1012    /// offered: a filter that answers nothing is not a filter.
1013    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        // audiences and areas keep the taxonomy's own order, so the filter row reads the
1042        // way the catalogue is written; the open-ended facets are ordered by weight of use
1043        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// ---------------------------------------------------------------- diagnosis
1084
1085/// One recommendation, with the moments that produced it. A recommendation with no
1086/// `matched_because` is a recommendation nobody can check.
1087#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1088pub struct Recommendation {
1089    /// The thing recommended: a capability id, a command, a claim, a rule, a use case.
1090    pub id: String,
1091    /// How many of the selected moments name it.
1092    pub count: usize,
1093    /// The selected moments that named it, in presentation order.
1094    pub matched_because: Vec<String>,
1095}
1096
1097/// What a reader's selection implies, computed by counting rather than by inference.
1098///
1099/// The arithmetic is the whole model and is stated so a reader can check it: a selection
1100/// resolves to a set of moments; an area or an audience scores the number of selected
1101/// moments that name it; a recommendation scores the number that name it and carries
1102/// their ids. There is no weighting and no percentage, because there is no model behind
1103/// one.
1104#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1105pub struct Diagnosis {
1106    /// The moments the selection resolved to, in presentation order.
1107    pub moments: Vec<String>,
1108    /// Selected names that resolved to nothing.
1109    pub unresolved: Vec<String>,
1110    /// Operational areas by how many selected moments fall under them, heaviest first.
1111    pub areas: Vec<Recommendation>,
1112    /// Audiences by how many selected moments they recognise, heaviest first.
1113    pub audiences: Vec<Recommendation>,
1114    /// Capabilities of the executable that answer the selected moments.
1115    pub capabilities: Vec<Recommendation>,
1116    /// Commands that answer them.
1117    pub commands: Vec<Recommendation>,
1118    /// Claims that say what is guaranteed.
1119    pub claims: Vec<Recommendation>,
1120    /// Rules of the effective set that govern them.
1121    pub doctrines: Vec<Recommendation>,
1122    /// Use cases that show the way out.
1123    pub use_cases: Vec<Recommendation>,
1124    /// Moments the selection did not include that share an area with one that it did.
1125    pub also_worth_reading: Vec<String>,
1126}
1127
1128impl Catalogue {
1129    /// Diagnose a selection. Each name may be a signal id or a moment id; a signal
1130    /// resolves to the moment that owns it, so a reader ticking symptoms and a script
1131    /// naming moments reach the same answer.
1132    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}