Skip to main content

majordomus_cli/
index.rs

1//! The index: every declarative object the repository declares, read, validated and
2//! identified, plus every diagnostic about what could not be read. Built once per
3//! invocation from a discovery pass; the MCP surface is a projection of it.
4//!
5//! Failure policy: a file that cannot become an object is excluded and reported with an
6//! error diagnostic, and the index is `Degraded`. The manifest and `sources.yaml` are
7//! different: without them nothing can be discovered, so they are errors, not diagnostics.
8
9use std::collections::BTreeMap;
10use std::path::{Path, PathBuf};
11
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use serde_json::{Map, Value};
15
16use crate::discovery::{self, DiscoveredFile, DiscoverySource, Sources};
17use crate::error::Result;
18use crate::git::GitState;
19use crate::metadata::{frontmatter, yaml, Format, FrontMatterRule, KindSchema, KindSpec};
20use crate::model::{uri_for, Diagnostic, Object, Provenance, Severity};
21use crate::repository::Repository;
22use crate::scope::{Scope, Scoped, Verdict};
23
24/// A file larger than this is refused rather than read.
25pub const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024;
26
27/// Whether every discovered file became an object.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
29#[serde(rename_all = "lowercase")]
30pub enum State {
31    /// Every discovered file became an object.
32    Ok,
33    /// At least one file could not be read; the diagnostics say which.
34    Degraded,
35}
36
37/// How long a picture of the repository is trusted as current when nothing announced a change.
38///
39/// [`crate::live::Live`] re-reads the repository whenever a git control file moves — a
40/// commit, a checkout, a `git add` — and cannot see an edit to a tracked file that is never
41/// staged. So an index, and a "working tree clean" read with it, can be wrong without
42/// anything having moved. Past this window a surface says so instead of presenting the
43/// picture as current. One window for both pictures, because they share the one blind spot.
44pub const STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(120);
45
46/// When the state behind an answer was read: the freshness contract of a capability answer.
47///
48/// Declared once and carried in the output type of every answer that is a picture of
49/// state, so that the output schema, OpenAPI, the MCP tool schemas and the command line's
50/// JSON all carry it without a transport of their own. It is set where the state is read —
51/// the index build, the git read — never by the executor, which knows when a handler ran
52/// and not when the state it read was taken.
53///
54/// ```
55/// use std::time::{Duration, UNIX_EPOCH};
56/// use majordomus_cli::index::AnswerObservation;
57/// let at = UNIX_EPOCH + Duration::from_secs(1_788_000_000);
58/// let o = AnswerObservation::taken("git read", at, Some(Duration::from_secs(120)));
59/// assert_eq!(o.observed_at, "2026-08-29T10:40:00Z");
60/// assert_eq!(o.stale_after.as_deref(), Some("2026-08-29T10:42:00Z"));
61/// assert!(!o.is_stale_at(at + Duration::from_secs(120)));
62/// assert!(o.is_stale_at(at + Duration::from_secs(121)));
63/// ```
64#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
65pub struct AnswerObservation {
66    /// RFC 3339 UTC, whole seconds: when the state behind the answer was read. Empty only
67    /// in an answer from an index that was never built (a hand-made one in a test).
68    pub observed_at: String,
69    /// What was read at that time: `index build` or `git read`.
70    pub source: String,
71    /// RFC 3339 UTC: after this the answer is not to be trusted as current even though
72    /// nothing announced a change. Absent when there is no known blind spot.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub stale_after: Option<String>,
75}
76
77impl AnswerObservation {
78    /// An observation of `source` taken at `at`, trusted for `window` when there is one.
79    ///
80    /// ```
81    /// use std::time::UNIX_EPOCH;
82    /// use majordomus_cli::index::AnswerObservation;
83    /// let o = AnswerObservation::taken("index build", UNIX_EPOCH, None);
84    /// assert_eq!((o.observed_at.as_str(), o.stale_after), ("1970-01-01T00:00:00Z", None));
85    /// ```
86    pub fn taken(
87        source: &str,
88        at: std::time::SystemTime,
89        window: Option<std::time::Duration>,
90    ) -> Self {
91        AnswerObservation {
92            observed_at: crate::peers::rfc3339(at),
93            source: source.to_string(),
94            stale_after: window.map(|w| crate::peers::rfc3339(at + w)),
95        }
96    }
97
98    /// Whether `now` is past [`Self::stale_after`]. RFC 3339 UTC in whole seconds is fixed
99    /// width, so the comparison is of the strings.
100    ///
101    /// ```
102    /// use std::time::{Duration, UNIX_EPOCH};
103    /// use majordomus_cli::index::AnswerObservation;
104    /// let o = AnswerObservation::taken("git read", UNIX_EPOCH, None);
105    /// assert!(!o.is_stale_at(UNIX_EPOCH + Duration::from_secs(86_400)), "no window, never stale");
106    /// ```
107    pub fn is_stale_at(&self, now: std::time::SystemTime) -> bool {
108        self.stale_after
109            .as_deref()
110            .is_some_and(|limit| crate::peers::rfc3339(now).as_str() > limit)
111    }
112}
113
114/// The two pictures a process takes of the repository, each with when it was taken.
115///
116/// A picture without a window is never stale, and its window is not serialized.
117///
118/// ```
119/// use std::time::{Duration, UNIX_EPOCH};
120/// use majordomus_cli::index::{AnswerObservation, RepositoryObservations};
121/// let now = RepositoryObservations {
122///     index: AnswerObservation::taken("index build", UNIX_EPOCH, None),
123///     git: AnswerObservation::taken("git read", UNIX_EPOCH, Some(Duration::from_secs(120))),
124/// };
125/// let json = serde_json::to_value(&now).unwrap();
126/// assert_eq!(json["git"]["stale_after"], "1970-01-01T00:02:00Z");
127/// assert!(json["index"].get("stale_after").is_none());
128/// let back: RepositoryObservations = serde_json::from_value(json).unwrap();
129/// assert_eq!(back, now);
130/// ```
131#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
132pub struct RepositoryObservations {
133    /// When the index was built: the objects, the kinds, the diagnostics.
134    pub index: AnswerObservation,
135    /// When git was asked: the branch, the head, the working tree.
136    pub git: AnswerObservation,
137}
138
139/// What the index knows about the repository it was built from.
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
141pub struct RepositoryInfo {
142    /// The repository root, absolute.
143    pub root: String,
144    /// The manifest's `schema`, `ai-repository/v1`.
145    pub layer_schema: String,
146    /// Manifest section name to repository-relative path.
147    pub sections: BTreeMap<String, String>,
148    /// What `git` said, or why it could not be asked.
149    pub git: GitState,
150    /// `vcs` or `filesystem`.
151    pub discovery: String,
152    /// Source class id to kind, in declared order.
153    pub source_classes: Vec<(String, String)>,
154    /// The kinds files the reader was configured from: the distribution's, then the
155    /// repository's own when it has one.
156    pub kind_sources: Vec<String>,
157    /// Where the scope was read from: `repository` or `distribution`.
158    pub scope_origin: crate::scope::Origin,
159    /// The scope file: repository-relative for the repository's own, the share path for
160    /// the distribution's default.
161    pub scope_path: String,
162    /// When the index and the git state it carries were read. Defaults to empty so that a
163    /// record written before the freshness contract still reads.
164    #[serde(default)]
165    pub observed: RepositoryObservations,
166}
167
168#[derive(Debug)]
169/// The objects of the repository, their diagnostics, and the repository they came from.
170pub struct Index {
171    /// The repository the index was built from.
172    pub repository: RepositoryInfo,
173    /// Sorted by URI; unique by URI.
174    pub objects: Vec<Object>,
175    /// Every finding, in discovery order.
176    pub diagnostics: Vec<Diagnostic>,
177    /// Ok, or degraded when a diagnostic is an error.
178    pub state: State,
179    /// A hash of every object's path and content, in URI order: the repository state this
180    /// index is a picture of, stable across processes.
181    pub fingerprint: String,
182    /// The scope the index was built under, with every tracked file tallied against it.
183    pub scoped: Scoped,
184    /// The distribution model the tool was run with, when it carries one: how this project
185    /// is packaged, published and installed. Read from the tool's data directory beside the
186    /// kinds and the schemas, not discovered in the repository, so that an installed copy
187    /// answers the same questions a checkout does.
188    pub distribution: Option<crate::distribution::Model>,
189    /// The providers the tool distribution ships an adapter for: one per template under
190    /// `share/providers/`, joined with what `share/providers.yaml` declares about it. Read
191    /// from the tool's data directory beside the kinds and the schemas, like the
192    /// distribution model, so that the product model can say which providers exist without
193    /// a second discovery.
194    pub providers: crate::share::ProviderDeclarations,
195    /// The distribution directory this process located, absolute, when one was located.
196    ///
197    /// The share is a fact about the invocation, not about the repository: it is what
198    /// `--share`, `MAJORDOMUS_SHARE`, the repository or the installation resolved to, and
199    /// [`crate::app::App`] records it here so that a handler needing a file beside the
200    /// kinds — the obligation vocabulary, the shell tool's command registry — reads the
201    /// distribution this process was run with instead of resolving one of its own. A
202    /// handler that resolved its own would answer from a different distribution than the
203    /// one whose kinds it was configured with, and, for an executable that lives outside
204    /// the repository, from none at all. `None` for an index built directly, which is what
205    /// makes the resolution fall back to the conventions.
206    pub share: Option<PathBuf>,
207}
208
209impl Index {
210    /// Discover, read, validate. `source` decides how files are enumerated; `git` is
211    /// whatever `git::inspect` said and is carried, never consulted here.
212    pub fn build(
213        repo: &Repository,
214        sources: &Sources,
215        schema: &KindSchema,
216        source: &dyn DiscoverySource,
217        git: GitState,
218        scope: Scope,
219    ) -> Result<Self> {
220        let started = std::time::SystemTime::now();
221        let _phase = crate::perf::phase(crate::perf::Phase::IndexBuild);
222        crate::perf::Counters::bump(&crate::perf::COUNTERS.index_builds);
223        let (files, mut diagnostics) = discovery::discover(repo, sources, source)?;
224        let (tally, mut scope_diagnostics) = scope.tally_repository(repo, source)?;
225        diagnostics.append(&mut scope_diagnostics);
226        let mut objects = Vec::with_capacity(files.len());
227        for file in &files {
228            // the scope decides before anything is read: a source outside it is not an
229            // object, and the diagnostic names the rule so that the declaration is fixed
230            // at its source (the scope, or the class that claims the file)
231            let judged = scope.classify(repo.root(), &file.rel_path);
232            if judged.verdict == Verdict::Out {
233                diagnostics.push(Diagnostic::warning(
234                    "out_of_scope",
235                    Some(file.rel_path.clone()),
236                    format!(
237                        "discovered by class '{}' but out of the repository scope ({}{}); not read",
238                        file.class,
239                        judged.reason.map(|r| r.as_str()).unwrap_or("out"),
240                        judged
241                            .rule
242                            .as_deref()
243                            .map(|r| format!(": {r}"))
244                            .unwrap_or_default()
245                    ),
246                ));
247                continue;
248            }
249            match read_objects(repo, schema, file) {
250                Ok((mut read, mut member_diagnostics)) => {
251                    objects.append(&mut read);
252                    diagnostics.append(&mut member_diagnostics);
253                }
254                Err(d) => diagnostics.push(d),
255            }
256        }
257        dedupe(&mut objects, &mut diagnostics);
258        // Byte order of the URI, and deliberately not the canonical order: `Index::get`
259        // binary-searches this vector with the same comparison, so the sequence here is a
260        // search invariant rather than anything a reader is shown. A surface that presents
261        // objects to a person asks `crate::order::canonical` for the order instead.
262        objects.sort_by(|a, b| a.uri.cmp(&b.uri));
263        let state = if diagnostics.iter().any(|d| d.severity == Severity::Error) {
264            State::Degraded
265        } else {
266            State::Ok
267        };
268        let repository = RepositoryInfo {
269            root: repo.root().display().to_string(),
270            layer_schema: repo.manifest().schema.clone(),
271            sections: repo
272                .manifest()
273                .sections
274                .keys()
275                .filter_map(|k| repo.section_path(k).map(|p| (k.clone(), p)))
276                .collect(),
277            git,
278            discovery: source.name().to_string(),
279            source_classes: sources
280                .sources
281                .iter()
282                .map(|c| (c.id.clone(), c.kind.clone()))
283                .collect(),
284            kind_sources: schema.sources().to_vec(),
285            scope_origin: scope.origin(),
286            scope_path: scope.path().to_string(),
287            // The git state was handed in, read just before; a caller that knows exactly
288            // when (App::load) overwrites `git` with its own reading of the clock.
289            observed: RepositoryObservations {
290                index: AnswerObservation::taken("index build", started, Some(STALE_AFTER)),
291                git: AnswerObservation::taken("git read", started, Some(STALE_AFTER)),
292            },
293        };
294        tracing::info!(
295            objects = objects.len(),
296            diagnostics = diagnostics.len(),
297            state = ?state,
298            "index built"
299        );
300        let fingerprint = {
301            use sha2::{Digest, Sha256};
302            let mut h = Sha256::new();
303            for o in &objects {
304                h.update(o.provenance.path.as_bytes());
305                h.update(b"\0");
306                h.update(o.content.as_bytes());
307                h.update(b"\n");
308            }
309            format!("{:x}", h.finalize())
310        };
311        Ok(Index {
312            repository,
313            objects,
314            diagnostics,
315            state,
316            fingerprint,
317            scoped: Scoped { scope, tally },
318            distribution: None,
319            providers: Default::default(),
320            share: None,
321        })
322    }
323
324    /// An object by URI.
325    pub fn get(&self, uri: &str) -> Option<&Object> {
326        self.objects
327            .binary_search_by(|o| o.uri.as_str().cmp(uri))
328            .ok()
329            .map(|i| &self.objects[i])
330    }
331
332    /// How many diagnostics are errors.
333    pub fn errors(&self) -> usize {
334        self.diagnostics
335            .iter()
336            .filter(|d| d.severity == Severity::Error)
337            .count()
338    }
339
340    /// The kinds present, with how many objects each has.
341    pub fn kinds(&self) -> BTreeMap<&str, usize> {
342        let mut m = BTreeMap::new();
343        for o in &self.objects {
344            *m.entry(o.kind.as_str()).or_insert(0) += 1;
345        }
346        m
347    }
348}
349
350/// Two objects of one kind claiming one identity are both excluded.
351fn dedupe(objects: &mut Vec<Object>, diagnostics: &mut Vec<Diagnostic>) {
352    let mut by_uri: BTreeMap<String, Vec<String>> = BTreeMap::new();
353    for o in objects.iter() {
354        by_uri
355            .entry(o.uri.clone())
356            .or_default()
357            .push(o.provenance.path.clone());
358    }
359    let duplicates: BTreeMap<&String, &Vec<String>> =
360        by_uri.iter().filter(|(_, paths)| paths.len() > 1).collect();
361    if duplicates.is_empty() {
362        return;
363    }
364    for (uri, paths) in &duplicates {
365        for path in paths.iter() {
366            diagnostics.push(Diagnostic::error(
367                "duplicate_identity",
368                Some(path.clone()),
369                format!(
370                    "{uri} is claimed by {}; every claimant is excluded",
371                    paths.join(" and ")
372                ),
373            ));
374        }
375    }
376    objects.retain(|o| !duplicates.contains_key(&o.uri));
377}
378
379/// Read one discovered file into its objects: one for most kinds, one per member for a
380/// collection file. A failure of the whole file is the `Err`; a failure of one member is a
381/// diagnostic beside the members that were read.
382fn read_objects(
383    repo: &Repository,
384    schema: &KindSchema,
385    file: &DiscoveredFile,
386) -> std::result::Result<(Vec<Object>, Vec<Diagnostic>), Diagnostic> {
387    let rel = file.rel_path.as_str();
388    let d = |code, msg: String| Diagnostic::error(code, Some(rel.to_string()), msg);
389    let Some(mut spec) = schema.kind(&file.kind) else {
390        return Err(d(
391            "unknown_kind",
392            format!(
393                "class '{}' declares kind '{}', which this executable does not read",
394                file.class, file.kind
395            ),
396        ));
397    };
398    let mut kind = file.kind.as_str();
399    let abs = repo.root().join(rel);
400    let content = read_text(&abs).map_err(|(code, msg)| d(code, msg))?;
401    let bytes = content.len() as u64;
402    let directory = match rel.rsplit_once('/') {
403        Some((dir, _)) => dir.to_string(),
404        None => ".".to_string(),
405    };
406    let provenance = |member: Option<String>| Provenance {
407        path: rel.to_string(),
408        directory: directory.clone(),
409        source_class: file.class.clone(),
410        section: repo.section_of(rel).map(str::to_string),
411        bytes,
412        member,
413    };
414
415    let (metadata, body, media_type): (Map<String, Value>, String, &'static str) = match spec.format
416    {
417        Format::Text => (Map::new(), content.clone(), "text/plain"),
418        Format::Yaml => {
419            let map = yaml::parse_mapping(&content).map_err(|e| d("malformed_yaml", e))?;
420            (map, content.clone(), "application/yaml")
421        }
422        Format::Markdown => {
423            let split = frontmatter::split(&content)
424                .map_err(|e| d("malformed_front_matter", e.to_string()))?;
425            let map = match split.front {
426                Some(front) => {
427                    frontmatter::parse(front).map_err(|e| d("malformed_front_matter", e))?
428                }
429                None => Map::new(),
430            };
431            if split.front.is_none() {
432                if let Some(fallback) = spec.without_front_matter.as_deref() {
433                    // Validated at schema load: the fallback exists and accepts the file.
434                    if let Some((name, f)) = schema.kinds().find(|(k, _)| k.as_str() == fallback) {
435                        spec = f;
436                        kind = name.as_str();
437                    }
438                }
439            } else if let Some(declared) = map.get("kind").and_then(Value::as_str) {
440                // A file that declares a kind the schema lets files declare is that kind,
441                // whatever class found it.
442                if declared != kind && schema.is_declared_kind(declared) {
443                    if let Some((name, f)) = schema.kinds().find(|(k, _)| k.as_str() == declared) {
444                        spec = f;
445                        kind = name.as_str();
446                    }
447                }
448            }
449            if split.front.is_none() && spec.front_matter == FrontMatterRule::Required {
450                return Err(d(
451                    "missing_front_matter",
452                    "no front matter, and the kind requires it".into(),
453                ));
454            }
455            (map, split.body.to_string(), "text/markdown")
456        }
457    };
458
459    // the manifest names the file names that must carry the context contract wherever they
460    // appear under the layer; one that does not is a broken context document, not a plain one
461    if let Some(conventions) = &repo.manifest().context {
462        let name = rel.rsplit('/').next().unwrap_or(rel);
463        let under_layer = rel.starts_with(".ai/");
464        if under_layer && conventions.documents.iter().any(|d| d == name) && kind != "context" {
465            return Err(d(
466                "missing_context_contract",
467                format!("the manifest lists {name} among the context documents, and this one declares no schema: context/v1"),
468            ));
469        }
470    }
471
472    if let Some(sv) = &spec.schema_version {
473        check_version(sv, &metadata).map_err(|(code, msg)| d(code, msg))?;
474    }
475
476    // a collection file: one object per member of the named list
477    if let Some(list) = &spec.members {
478        let Some(items) = metadata.get(list).and_then(Value::as_array) else {
479            return Err(d(
480                "missing_field",
481                format!("no list '{list}' holding the members"),
482            ));
483        };
484        let mut objects = Vec::new();
485        let mut diagnostics = Vec::new();
486        for (i, item) in items.iter().enumerate() {
487            let member = format!("{list}.{i}");
488            let Value::Object(item) = item else {
489                diagnostics.push(d(
490                    "malformed_yaml",
491                    format!("member {member} is not a mapping"),
492                ));
493                continue;
494            };
495            match object_of(
496                spec,
497                kind,
498                item,
499                "",
500                &member,
501                provenance(Some(member.clone())),
502            ) {
503                Ok(o) => objects.push(o),
504                Err(msg) => diagnostics.push(d("missing_field", format!("member {member}: {msg}"))),
505            }
506        }
507        return Ok((objects, diagnostics));
508    }
509
510    if let Some(sch) = schema.schema_for(spec) {
511        conforms(sch, &Value::Object(metadata.clone()), &d, "")?;
512    }
513    if let Some(declared) = metadata.get("kind").and_then(Value::as_str) {
514        if declared != kind {
515            return Err(d(
516                "kind_mismatch",
517                format!(
518                    "declares kind '{declared}' but was discovered by class '{}' as '{kind}'",
519                    file.class
520                ),
521            ));
522        }
523    }
524    let object = object_of(spec, kind, &metadata, &body, rel, provenance(None))
525        .map_err(|msg| d("missing_field", msg))?;
526    Ok((
527        vec![Object {
528            content,
529            media_type,
530            ..object
531        }],
532        Vec::new(),
533    ))
534}
535
536/// Validate a document against its kind's schema: keys the schema does not allow are one
537/// `unknown_key` diagnostic naming them all; any other violation is `schema_violation`
538/// naming every failed constraint with its path.
539fn conforms(
540    sch: &crate::metadata::Schema,
541    instance: &Value,
542    d: &dyn Fn(&'static str, String) -> Diagnostic,
543    prefix: &str,
544) -> std::result::Result<(), Diagnostic> {
545    let violations = sch.validate(instance);
546    if violations.is_empty() {
547        return Ok(());
548    }
549    let unknown: Vec<String> = violations
550        .iter()
551        .flat_map(|v| v.unknown_keys.clone())
552        .collect();
553    if !unknown.is_empty() {
554        return Err(d(
555            "unknown_key",
556            format!(
557                "{prefix}key(s) not in schema '{}': {}",
558                sch.name,
559                unknown.join(", ")
560            ),
561        ));
562    }
563    let list: Vec<String> = violations
564        .iter()
565        .map(|v| {
566            if v.path.is_empty() {
567                v.message.clone()
568            } else {
569                format!("{}: {}", v.path, v.message)
570            }
571        })
572        .collect();
573    Err(d(
574        "schema_violation",
575        format!("{prefix}schema '{}': {}", sch.name, list.join("; ")),
576    ))
577}
578
579fn check_version(
580    sv: &crate::metadata::SchemaVersion,
581    metadata: &Map<String, Value>,
582) -> std::result::Result<(), (&'static str, String)> {
583    match metadata.get(&sv.field) {
584        Some(v) if sv.supported.contains(v) => Ok(()),
585        Some(v) => Err((
586            "unsupported_version",
587            format!(
588                "{} {v} is not among the supported {}",
589                sv.field,
590                serde_json::to_string(&sv.supported).unwrap_or_default()
591            ),
592        )),
593        None => Err(("missing_field", format!("no '{}'", sv.field))),
594    }
595}
596
597/// Build the object of a metadata mapping: identity, title, description, URI. Content and
598/// media type for a whole file are filled by the caller; a member carries itself as JSON.
599fn object_of(
600    spec: &KindSpec,
601    kind: &str,
602    metadata: &Map<String, Value>,
603    body: &str,
604    fallback_identity: &str,
605    provenance: Provenance,
606) -> std::result::Result<Object, String> {
607    let identity = identity_of(spec, metadata, fallback_identity)?;
608    let title = title_of(spec, metadata, body);
609    let description = spec
610        .description
611        .as_deref()
612        .and_then(|f| metadata.get(f))
613        .and_then(yaml::scalar_string);
614    let is_member = provenance.member.is_some();
615    let text = if is_member {
616        serde_json::to_string_pretty(&Value::Object(metadata.clone())).unwrap_or_default()
617    } else {
618        String::new()
619    };
620    Ok(Object {
621        uri: uri_for(kind, &identity),
622        kind: kind.to_string(),
623        identity,
624        title,
625        description,
626        metadata: Value::Object(metadata.clone()),
627        body: if is_member {
628            text.clone()
629        } else {
630            body.to_string()
631        },
632        content: text,
633        media_type: if is_member {
634            "application/json"
635        } else {
636            "text/plain"
637        },
638        provenance,
639    })
640}
641
642fn identity_of(
643    spec: &KindSpec,
644    metadata: &Map<String, Value>,
645    rel: &str,
646) -> std::result::Result<String, String> {
647    if spec.identity.is_empty() {
648        return Ok(rel.to_string());
649    }
650    let mut parts = Vec::with_capacity(spec.identity.len());
651    for field in &spec.identity {
652        let value = metadata
653            .get(field)
654            .and_then(yaml::scalar_string)
655            .filter(|s| !s.is_empty())
656            .ok_or_else(|| format!("identity field '{field}' is absent or empty"))?;
657        if value.contains('/') || value.contains('@') || value.contains(char::is_whitespace) {
658            return Err(format!(
659                "identity field '{field}' carries '/', '@' or whitespace: '{value}'"
660            ));
661        }
662        parts.push(value);
663    }
664    Ok(parts.join("@"))
665}
666
667fn title_of(spec: &KindSpec, metadata: &Map<String, Value>, body: &str) -> Option<String> {
668    match spec.title.as_deref()? {
669        "@heading" => body
670            .lines()
671            .find_map(|l| l.strip_prefix("# "))
672            .map(|t| t.trim().to_string())
673            .filter(|t| !t.is_empty()),
674        field => metadata.get(field).and_then(yaml::scalar_string),
675    }
676}
677
678/// Read a file as UTF-8 text, refusing symlinks, oversized files and invalid encodings.
679fn read_text(abs: &Path) -> std::result::Result<String, (&'static str, String)> {
680    let meta = std::fs::symlink_metadata(abs).map_err(|e| ("unreadable", e.to_string()))?;
681    if meta.file_type().is_symlink() {
682        return Err((
683            "symlink",
684            "a symlink is not read; sources are regular files".into(),
685        ));
686    }
687    if !meta.is_file() {
688        return Err(("not_a_file", "not a regular file".into()));
689    }
690    if meta.len() > MAX_FILE_BYTES {
691        return Err((
692            "oversized",
693            format!("{} bytes, over the {MAX_FILE_BYTES} byte limit", meta.len()),
694        ));
695    }
696    let bytes = std::fs::read(abs).map_err(|e| ("unreadable", e.to_string()))?;
697    String::from_utf8(bytes).map_err(|e| ("invalid_utf8", e.to_string()))
698}