Skip to main content

memstead_base/ingest/
render.rs

1//! Top-level run-brief rendering — the one engine entry point every
2//! consuming surface calls (the CLI via `memstead projection brief`), so the
3//! brief a client emits is byte-identical to the CLI's **by construction**
4//! (a single code path), not by parallel re-implementation.
5//!
6//! Given a loaded [`Engine`], the workspace root, and an ingest name, it
7//! loads the four-primitive config, resolves the ingest, and — for discovery
8//! mode — assembles the full brief: writing guidance from the destination
9//! mem's schema + config, the paired-process-mem view, and the changed-slice
10//! preface from live source state.
11
12use std::path::Path;
13
14use crate::Engine;
15use crate::binding::{Binding, BuildMode};
16use crate::pipeline_store::{BindingConfigs, load_pipeline_configs};
17
18use super::brief::{
19    ProcessMemInfo, assemble_discovery_brief, assemble_one_shot_brief, render_changed_slice,
20    render_sync_brief, render_verify_brief,
21};
22use super::check_path::write_active_binding_file;
23use super::cursor::compute_source_cursor;
24use super::findings::{FindingClass, current_findings};
25use super::guidance::{GuidanceDefaults, MemGuidance, ResolvedGuidance, resolve_writing_guidance};
26use super::prune::prune_proposals;
27use super::resolve::{ResolveError, ResolvedIngest, ResolvedSource, resolve_binding_run};
28
29/// Why [`render_ingest_brief`] could not produce a brief.
30#[derive(Debug, thiserror::Error)]
31pub enum RenderBriefError {
32    /// The four-primitive pipeline config could not be loaded.
33    #[error("could not load pipeline config: {0}")]
34    ConfigLoad(String),
35    /// The ingest (or a reference it names) could not be resolved.
36    #[error(transparent)]
37    Resolve(#[from] ResolveError),
38    /// The binding declares no `build` operation, so the build path (brief) is
39    /// refused (D6/AC4). The message carries the one-command remedy
40    /// `memstead projection enable build <binding>`, which — run verbatim —
41    /// makes the same brief succeed.
42    #[error(
43        "binding '{binding}' has no build operation — enable it with \
44         `memstead projection enable build {binding}`"
45    )]
46    BuildOperationAbsent {
47        /// The binding id whose build block is absent.
48        binding: String,
49    },
50    /// The durable findings store could not be read while rendering a verify /
51    /// sync brief (group C). The brief needs the open findings; a malformed
52    /// store surfaces here rather than silently rendering an empty findings set.
53    #[error("could not read findings store for '{binding}': {detail}")]
54    FindingsRead {
55        /// The binding id whose findings store failed to read.
56        binding: String,
57        /// The underlying store error, stringified.
58        detail: String,
59    },
60}
61
62/// If any primary source declares a preparation the engine's registry
63/// ([`crate::preparation`]) does not know, return the unsupported-and-skipped
64/// message; `None` when every declared preparation is registered (or none
65/// is declared). Mirrors [`crate::binding::validate_binding`]'s registry
66/// rule for a record that acquired an unknown identifier by hand — accepted
67/// at rest, refused here rather than run over content the engine cannot
68/// prepare — so the two refusal paths carry one semantics.
69fn preparation_refusal(resolved: &ResolvedIngest) -> Option<String> {
70    resolved.sources.iter().find_map(|s| match s {
71        ResolvedSource::Primary(p) => p
72            .preparation
73            .as_deref()
74            .filter(|prep| !crate::preparation::is_registered(prep))
75            .map(|prep| {
76                format!(
77                    "> **[ingest] Ingest \"{}\" is unsupported: source \"{}\" declares \
78                     preparation \"{}\", which is not in this engine's preparation registry \
79                     (registered: {}). Skipping.**\n",
80                    resolved.name,
81                    p.name,
82                    prep,
83                    crate::preparation::registered_identifiers().join(", ")
84                )
85            }),
86        ResolvedSource::Reference { .. } => None,
87    })
88}
89
90/// The mode string used in messages (`discovery` / `one-shot`).
91pub fn mode_name(mode: BuildMode) -> &'static str {
92    match mode {
93        BuildMode::Discovery => "discovery",
94        BuildMode::OneShot => "one-shot",
95    }
96}
97
98/// Locate a binding by the CLI argument. The canonical form is the
99/// binding id `<mem>/<stem>` (D3) — the shape `projection brief` / `--all`
100/// selection use. As a transition bridge, a slash-free legacy argument (the
101/// old flat ingest stem, e.g. `engine-graph`) is also matched against each
102/// binding's `<mem>-<stem>` dashed form, so `memstead projection brief engine-graph`
103/// keeps rendering the migrated `engine/graph` binding without a router change.
104/// Returns the canonical binding id and the binding.
105fn find_binding<'a>(
106    configs: &'a BindingConfigs,
107    arg: &str,
108) -> Result<(String, &'a Binding), ResolveError> {
109    // Exact canonical id: `<mem>/<stem>`.
110    if let Some(r) = configs
111        .bindings
112        .iter()
113        .find(|r| format!("{}/{}", r.mem, r.name) == arg)
114    {
115        return Ok((format!("{}/{}", r.mem, r.name), &r.config));
116    }
117    // Transition bridge: a slash-free legacy stem → `<mem>-<stem>` dashed form.
118    if !arg.contains('/')
119        && let Some(r) = configs
120            .bindings
121            .iter()
122            .find(|r| format!("{}-{}", r.mem, r.name) == arg)
123    {
124        return Ok((format!("{}/{}", r.mem, r.name), &r.config));
125    }
126    Err(ResolveError::BindingNotFound {
127        name: arg.to_string(),
128        available: configs
129            .bindings
130            .iter()
131            .map(|r| format!("{}/{}", r.mem, r.name))
132            .collect(),
133    })
134}
135
136/// Render the run-brief for a binding — the Markdown prompt an agent consumes.
137/// The single engine entry point behind every consuming surface. `ingest_name` is
138/// the canonical binding id (or a legacy flat-ingest stem — see [`find_binding`]).
139///
140/// `consume` mirrors the scheduler's peek/consume split (decision 12,
141/// backlog-sweep plan 03) onto derived caches: a peek (`false`) is a
142/// pure read that leaves every cache byte-identical, while a consuming
143/// render (`true`) additionally publishes this binding as the ACTIVE
144/// one for deny enforcement (`projection check-path`). Without this, a
145/// peek of binding A repointed enforcement so a later consuming run of
146/// binding B was briefly guarded by A's denies.
147pub fn render_ingest_brief(
148    engine: &Engine,
149    workspace_root: &Path,
150    ingest_name: &str,
151    consume: bool,
152) -> Result<String, RenderBriefError> {
153    let configs = load_pipeline_configs(workspace_root)
154        .map_err(|e| RenderBriefError::ConfigLoad(e.to_string()))?;
155    let (binding_id, binding) = find_binding(&configs, ingest_name)?;
156
157    // D6/AC4: the build path (brief) refuses when the binding declares no build
158    // operation, carrying the one-command `projection enable build` remedy —
159    // rather than fabricating a default build the operator never declared.
160    if binding.operations.build.is_none() {
161        return Err(RenderBriefError::BuildOperationAbsent {
162            binding: binding_id,
163        });
164    }
165
166    let resolved = resolve_binding_run(&binding_id, binding)?;
167
168    // Publish this binding as the ACTIVE one for the deny enforcement path
169    // (`projection check-path` resolves "active" through this pointer) —
170    // stale-safe (remove-then-write), overwrite-always, before any mode branch
171    // so the channel is live for every consumed brief and never pins a
172    // previous binding. Only the id is published; the deny list itself is
173    // read fresh from the binding record on every check. Best-effort engine
174    // cache, not a tracked mutation. Consuming renders only: a peek changes
175    // no state a later actor depends on — derived caches included.
176    if consume {
177        write_active_binding_file(workspace_root, &binding_id);
178    }
179
180    // Refuse an ingest whose source declares a preparation the registry does
181    // not know (a hand-edited record — every edit path refuses it earlier):
182    // reported unsupported and skipped rather than run against content the
183    // engine cannot prepare. A registered preparation passes.
184    if let Some(message) = preparation_refusal(&resolved) {
185        return Ok(message);
186    }
187
188    match resolved.mode {
189        BuildMode::Discovery => Ok(render_discovery(engine, &resolved, workspace_root)),
190        BuildMode::OneShot => Ok(render_one_shot(engine, &resolved)),
191    }
192}
193
194/// Render the **verify brief** (C1) for a binding — the measurement +
195/// capped-adjudication prompt an agent consumes. The one engine entry point
196/// behind the CLI (`projection brief --verify`), mirroring
197/// [`render_ingest_brief`]. Read-only on the destination mem: it borrows
198/// `&Engine` (shared), reads the durable findings store for the backlog count,
199/// and renders. It emits **no** destination-mutation instruction (C1) — the
200/// refusal is carried by [`render_verify_brief`] itself.
201pub fn render_verify_brief_for(
202    engine: &Engine,
203    workspace_root: &Path,
204    binding_id: &str,
205) -> Result<String, RenderBriefError> {
206    let configs = load_pipeline_configs(workspace_root)
207        .map_err(|e| RenderBriefError::ConfigLoad(e.to_string()))?;
208    let (binding_id, binding) = find_binding(&configs, binding_id)?;
209    let resolved = resolve_binding_run(&binding_id, binding)?;
210
211    let (_key, findings) =
212        current_findings(engine, workspace_root, binding, &resolved).map_err(|e| {
213            RenderBriefError::FindingsRead {
214                binding: binding_id.clone(),
215                detail: e.to_string(),
216            }
217        })?;
218    let backlog = findings
219        .iter()
220        .filter(|f| f.class == FindingClass::QueuedForAdjudication)
221        .count();
222    Ok(render_verify_brief(&resolved, backlog))
223}
224
225/// Render the **sync brief** (C2/C3) for a binding — the *single* channel
226/// through which maintenance-writing work reaches an agent. The one engine entry
227/// point behind the CLI (`projection brief --sync`). It assembles both
228/// inputs in one render: the live cursor slice ([`compute_source_cursor`]) and
229/// the open findings the verify pass recorded (`current(key)`), plus the adopt
230/// framing when the mem predates its binding (E1). Read-only on the destination
231/// mem (shared `&Engine`) — every repair happens only when an agent acts on this
232/// brief through the normal MCP mutation surface.
233pub fn render_sync_brief_for(
234    engine: &Engine,
235    workspace_root: &Path,
236    binding_id: &str,
237) -> Result<String, RenderBriefError> {
238    let configs = load_pipeline_configs(workspace_root)
239        .map_err(|e| RenderBriefError::ConfigLoad(e.to_string()))?;
240    let (binding_id, binding) = find_binding(&configs, binding_id)?;
241    let resolved = resolve_binding_run(&binding_id, binding)?;
242
243    let cursor = compute_source_cursor(engine, &resolved, workspace_root);
244    let (_key, findings) =
245        current_findings(engine, workspace_root, binding, &resolved).map_err(|e| {
246            RenderBriefError::FindingsRead {
247                binding: binding_id.clone(),
248                detail: e.to_string(),
249            }
250        })?;
251    // Prune proposals (group F) ride the sync brief — the sole channel through
252    // which a prune removal reaches the mem (F3/A5). Read-only gather.
253    let prune = prune_proposals(engine, workspace_root, binding, &resolved);
254    let adopt = mem_predates_binding(engine, &resolved);
255    // The exclusion ledger against the binding as declared now: in force,
256    // and dropped (a removed source), reported once here.
257    let exclusions =
258        crate::ingest::advance::reconcile_exclusions(engine, workspace_root, &resolved).map_err(
259            |e| RenderBriefError::FindingsRead {
260                binding: binding_id.clone(),
261                detail: format!("exclusion ledger: {e}"),
262            },
263        )?;
264    Ok(render_sync_brief(
265        &resolved,
266        &cursor,
267        &findings,
268        &prune,
269        adopt,
270        &exclusions,
271    ))
272}
273
274/// Whether the destination mem predates its binding — the adopt / onboarding
275/// signal (E1). True when the mem carries **no** anchors and the binding has
276/// **no** recorded `#synced` baseline for any facet: there is nothing to diff
277/// against and nothing anchored yet, so 0% anchored is expected (a first sync),
278/// not drift. A genuinely-fresh mem legitimately gets the same first-sync
279/// framing — the signal is deliberately generic.
280///
281/// The single canonical adopt predicate: the sync brief ([`render_sync_brief_for`]),
282/// the tier-1 fidelity report ([`super::report::compute_fidelity_report`]), and the
283/// status rollup ([`super::status::projection_rollup`]) all read it, so onboarding
284/// framing and the no-red-verdict-from-pre-binding-history refusal stay in lockstep
285/// across every surface.
286/// DELIBERATELY MEM-WIDE, unlike the three reporting consumers that
287/// consistency-sweep 03/01 scoped to one binding's population. This predicate
288/// gates whether a verdict may be red at all, so narrowing it would change a
289/// REFUSAL rather than a figure: a mem carrying another binding's anchors would
290/// begin counting as pre-binding for this one, and a red verdict it should
291/// have produced would go quiet. That is the exit-code contract, which 03/01's
292/// scope excludes.
293pub fn mem_predates_binding(engine: &Engine, resolved: &ResolvedIngest) -> bool {
294    // Existence only — `mem_anchors_resolved` would OBSERVE every anchor
295    // (hash live sources, enumerate file scopes) to answer a question the
296    // sidecar parse alone answers. On the status path this ran per binding
297    // per request and dominated the cost (2026-09-01 profile).
298    let no_anchors = !engine.mem_has_anchors(&resolved.destination_mem);
299    let prefix = format!("{}/", resolved.name);
300    let never_synced = engine
301        .mem_config_for(&resolved.destination_mem)
302        .map(|c| {
303            !c.sync_state
304                .keys()
305                .any(|k| k.starts_with(&prefix) && k.ends_with("#synced"))
306        })
307        .unwrap_or(true);
308    no_anchors && never_synced
309}
310
311/// Resolve the destination mem's writing guidance (schema defaults + per-mem
312/// additions / legacy) — shared by the discovery and one-shot briefs.
313fn dest_guidance(engine: &Engine, dest: &str) -> ResolvedGuidance {
314    let defaults = engine
315        .schema_for(dest)
316        .and_then(|schema| schema.manifest.default_writing_guidance.clone())
317        .map(|d| GuidanceDefaults {
318            goal: d.goal,
319            avoid: d.avoid,
320        })
321        .unwrap_or_default();
322
323    let mem_guidance = engine
324        .mem_config_for(dest)
325        .map(|config| {
326            let get = |key: &str| {
327                config
328                    .write_guidance
329                    .get(key)
330                    .and_then(|v| v.as_str())
331                    .map(str::to_string)
332            };
333            MemGuidance {
334                goal_additions: get("goal_additions"),
335                avoid_additions: get("avoid_additions"),
336                legacy_goal: get("goal"),
337                legacy_avoid: get("avoid"),
338            }
339        })
340        .unwrap_or_default();
341
342    resolve_writing_guidance(&defaults, &mem_guidance)
343}
344
345/// The `--medium-type` flag value for a medium — the wire spelling a
346/// caller can paste back into `projection init`.
347fn medium_type_wire(t: crate::pipeline::MediumType) -> &'static str {
348    use crate::pipeline::MediumType as M;
349    match t {
350        M::Codebase => "codebase",
351        M::Filesystem => "filesystem",
352        M::Git => "git",
353        M::Graph => "graph",
354        M::Web => "web",
355    }
356}
357
358/// Primary source names whose medium base does not exist on disk. Only
359/// path-namespace media can be checked this way; a `web` or `graph`
360/// pointer is out of scope and never reported absent.
361fn absent_source_names(resolved: &ResolvedIngest, workspace_root: &Path) -> Vec<String> {
362    resolved
363        .sources
364        .iter()
365        .filter_map(|s| match s {
366            ResolvedSource::Primary(p) => Some(p),
367            ResolvedSource::Reference { .. } => None,
368        })
369        .filter(|p| {
370            matches!(
371                p.medium_type,
372                crate::pipeline::MediumType::Codebase
373                    | crate::pipeline::MediumType::Filesystem
374                    | crate::pipeline::MediumType::Git
375            ) && !super::cursor::medium_base(&p.pointer, workspace_root).exists()
376        })
377        .map(|p| p.name.clone())
378        .collect()
379}
380
381/// A schema pin the reader can copy verbatim into `allow-create` and
382/// `mem init`. Prefers one already in use in this workspace, so a mem
383/// created by following a remedy speaks its neighbours' vocabulary; falls
384/// back to the newest builtin `default` generation when the workspace has
385/// no mem yet (the shape a fresh `mem-repo init` leaves behind). The
386/// version is resolved from the registry rather than written literally, so
387/// a schema generation bump cannot leave this remedy naming a stale pin.
388fn suggested_schema_pin(engine: &Engine, writable: &[&str]) -> String {
389    writable
390        .iter()
391        .find_map(|m| engine.schema_pin(m))
392        .map(|r| r.as_display())
393        .or_else(|| {
394            memstead_schema::SchemaRegistry::builtin()
395                .available_versions("default")
396                .into_iter()
397                .max()
398                .map(|v| format!("default@{v}"))
399        })
400        // Unreachable with a sane binary: the builtin catalogue always
401        // carries `default`. A placeholder is still better than a pin that
402        // does not exist.
403        .unwrap_or_else(|| "<name@version>".to_string())
404}
405
406/// The note the Destination block carries when the destination mem is not
407/// in this workspace — and the remedy that actually works in the shape the
408/// reader is standing in. `memstead mem init` is mem-repo-only, so naming
409/// it unconditionally hands a filesystem-mem reader (the shape `memstead
410/// quickstart` produces) a command that refuses; there, the binding is
411/// simply pointed at the wrong mem and repointing it is the whole fix.
412fn absent_destination_note(
413    engine: &Engine,
414    resolved: &ResolvedIngest,
415    binding_id: &str,
416    workspace_root: &Path,
417) -> Option<String> {
418    let dest = resolved.destination_mem.as_str();
419    if engine.schema_pin(dest).is_some() {
420        return None;
421    }
422    let mut writable: Vec<&str> = engine
423        .mem_router()
424        .writable_mems()
425        .iter()
426        .map(String::as_str)
427        .collect();
428    writable.sort_unstable();
429    let remedy = if crate::workspace_store::is_mem_repo_shaped(workspace_root) {
430        // `mem init` is refused by default: a mem-repo workspace creates
431        // nothing until a `[[mem_management.create]]` rule admits the name.
432        // Naming the second step only would hand the reader a command that
433        // refuses `MEM_PATH_NOT_ALLOWED` on a workspace fresh from
434        // `mem-repo init` — which is the workspace this brief most often
435        // renders against.
436        let admitted =
437            crate::mem_management::CreateRuleSet::new(engine.settings().mem_create_rules.clone())
438                .ok()
439                .is_some_and(|set| set.matches(std::path::Path::new(dest)));
440        // Both steps name the SAME concrete pin. A placeholder here would be
441        // the one point on the first-session path where the reader must fetch
442        // vocabulary from somewhere else; and naming a pin on the rule while
443        // letting `mem init` fall back to its own default would refuse when
444        // the two disagree.
445        let pin = suggested_schema_pin(engine, &writable);
446        if admitted {
447            format!("Create it before writing: `memstead mem init {dest} --schema {pin}`.")
448        } else {
449            format!(
450                "Creating it takes two steps — this workspace admits no mem name yet, \
451                 so `memstead mem init` alone refuses: `memstead workspace allow-create \
452                 '{dest}' --schema {pin}`, then `memstead mem init {dest} --schema {pin}`."
453            )
454        }
455    } else if writable.is_empty() {
456        "This workspace has no writable mem to point it at.".to_string()
457    } else {
458        // Re-declare rather than hand-edit. The record's LOCATION decides
459        // the binding id and the mem whose anchors resolve — editing
460        // `destination_mem` in place leaves the record under the wrong mem
461        // folder, and every anchored write the brief mandates still refuses
462        // with INVALID_ANCHOR. Naming the field alone would be a remedy the
463        // reader could follow exactly and still be stuck.
464        let stem = binding_id.rsplit('/').next().unwrap_or(binding_id);
465        let redeclare = resolved
466            .sources
467            .iter()
468            .find_map(|s| match s {
469                crate::ingest::resolve::ResolvedSource::Primary(p) => Some(p),
470                crate::ingest::resolve::ResolvedSource::Reference { .. } => None,
471            })
472            .map(|p| {
473                format!(
474                    " Re-declare it against that mem: `rm .memstead/projections/{binding_id}.json` \
475                     then `memstead projection init --mem {} --source {} --medium-type {} \
476                     --name {}`.",
477                    writable.first().copied().unwrap_or("<mem>"),
478                    p.pointer,
479                    medium_type_wire(p.medium_type),
480                    // `--name` is not optional here: a `.` pointer (the
481                    // `quickstart --repo .` layout) derives no stem and
482                    // refuses PROJECTION_INVALID_NAME without it.
483                    stem,
484                )
485            })
486            .unwrap_or_default();
487        format!(
488            "This is a filesystem-mem workspace, which holds one mem and cannot \
489             add another, so this binding names a mem that can never exist here.{redeclare} \
490             Editing `destination_mem` alone is not enough — the record's folder \
491             decides which mem's anchors resolve."
492        )
493    };
494    Some(format!(
495        "**This mem does not exist in this workspace yet.** {remedy} Until then, \
496         every mutation this brief asks for will refuse."
497    ))
498}
499
500/// Assemble the discovery brief from the engine's live view of the
501/// destination mem: its schema defaults, per-mem writing-guidance additions,
502/// pinned schema ref, paired-process-mem existence, and the source cursor.
503fn render_discovery(engine: &Engine, resolved: &ResolvedIngest, workspace_root: &Path) -> String {
504    let dest = &resolved.destination_mem;
505    let guidance = dest_guidance(engine, dest);
506    let dest_schema = engine.schema_pin(dest).map(|r| r.as_display());
507    let process_mem = build_process_mem(engine, resolved);
508
509    // Changed-slice preface from live source state (empty when nothing has
510    // moved → the brief is byte-identical to a plain roam).
511    let cursor = compute_source_cursor(engine, resolved, workspace_root);
512    let preface = render_changed_slice(&cursor);
513
514    let dest_note = absent_destination_note(engine, resolved, &resolved.name, workspace_root);
515    let absent = absent_source_names(resolved, workspace_root);
516    assemble_discovery_brief(
517        resolved,
518        &guidance,
519        &process_mem,
520        dest_schema.as_deref(),
521        dest_note.as_deref(),
522        &absent,
523        &preface,
524    )
525}
526
527/// Assemble the one-shot lens brief — no changed-slice, no paired process mem;
528/// the destination-set / routing / idempotency / report lens block instead.
529fn render_one_shot(engine: &Engine, resolved: &ResolvedIngest) -> String {
530    let dest = &resolved.destination_mem;
531    let guidance = dest_guidance(engine, dest);
532    let dest_schema = engine.schema_pin(dest).map(|r| r.as_display());
533    let dest_purpose = engine
534        .mem_config_for(dest)
535        .and_then(|c| c.description.clone());
536    let process_mem = build_process_mem(engine, resolved); // skipped = true for one-shot
537
538    assemble_one_shot_brief(
539        resolved,
540        &guidance,
541        &process_mem,
542        dest_schema.as_deref(),
543        // The one-shot lens has no workspace root in hand; its destination
544        // set is validated by the lens block itself.
545        None,
546        &[],
547        dest_purpose.as_deref(),
548    )
549}
550
551/// Resolve the paired-process-mem view from live workspace state. Read-only:
552/// a missing process mem is reported absent rather than auto-created (mutation
553/// belongs to the orchestration layer, not brief rendering).
554fn build_process_mem(engine: &Engine, resolved: &ResolvedIngest) -> ProcessMemInfo {
555    let skipped = resolved.mode == BuildMode::OneShot;
556    // One resolution mechanism (agent-trust plan 14): the
557    // destination's declaration wins, the ingest-name convention is
558    // the fallback. A declared-but-unmounted process mem is a stated
559    // notice, never a silent fallback to derivation.
560    let resolution = crate::ingest::resolve::resolve_process_mem(
561        engine,
562        &resolved.destination_mem,
563        &resolved.name,
564    );
565    let leaf = resolution.mem.clone();
566    let present = !skipped && resolution.mounted;
567    let notice = (!skipped && resolution.declared && !resolution.mounted).then(|| {
568        format!(
569            "destination `{}` declares process mem `{}`, which is not mounted",
570            resolved.destination_mem, resolution.mem
571        )
572    });
573    ProcessMemInfo {
574        present,
575        skipped,
576        notice,
577        mem_label: if resolution.declared {
578            leaf.clone()
579        } else {
580            format!("ingest/{leaf}")
581        },
582        leaf_name: leaf,
583    }
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589    use crate::binding::BuildMode;
590    use crate::ingest::resolve::Source;
591    use crate::pipeline::{IngestTrigger, MediumType};
592
593    fn ingest_with(sources: Vec<ResolvedSource>) -> ResolvedIngest {
594        ResolvedIngest {
595            name: "ing".to_string(),
596            mode: BuildMode::Discovery,
597            trigger: IngestTrigger::Loop,
598            batch_size: 20,
599            deny_paths: vec![],
600            projection_ref: "m/p".to_string(),
601            projection_mem: "m".to_string(),
602            projection_name: "p".to_string(),
603            intent: None,
604            sources,
605            destination_mem: "m".to_string(),
606            rules: None,
607            post_actions: None,
608        }
609    }
610
611    fn primary(facet: &str, preparation: Option<&str>) -> ResolvedSource {
612        ResolvedSource::Primary(Source {
613            name: facet.to_string(),
614            medium_type: MediumType::Codebase,
615            pointer: String::new(),
616            change_detection: None,
617            scope: vec![],
618            engagement: None,
619            preparation: preparation.map(str::to_string),
620        })
621    }
622
623    /// An ingest whose source declares a preparation the registry does not
624    /// know is refused (unsupported / skip) rather than rendered — the same
625    /// rule `validate_binding` applies, mirrored for a hand-edited record.
626    /// A registered preparation passes, and the message speaks of sources,
627    /// never of the retired facet noun.
628    #[test]
629    fn unregistered_preparation_is_refused_registered_passes() {
630        assert_eq!(
631            preparation_refusal(&ingest_with(vec![primary("f", None)])),
632            None
633        );
634        assert_eq!(
635            preparation_refusal(&ingest_with(vec![ResolvedSource::Reference {
636                mem: "e".to_string()
637            }])),
638            None
639        );
640        assert_eq!(
641            preparation_refusal(&ingest_with(vec![primary(
642                "claims",
643                Some(crate::preparation::ENTITY_LOAD_BEARING),
644            )])),
645            None,
646            "a registered preparation is not refused at render"
647        );
648        let msg = preparation_refusal(&ingest_with(vec![primary(
649            "manuals",
650            Some("pdf-to-markdown"),
651        )]))
652        .unwrap();
653        assert_eq!(
654            msg,
655            format!(
656                "> **[ingest] Ingest \"ing\" is unsupported: source \"manuals\" declares preparation \"pdf-to-markdown\", which is not in this engine's preparation registry (registered: {}). Skipping.**\n",
657                crate::preparation::registered_identifiers().join(", ")
658            )
659        );
660        assert!(msg.contains("entity-load-bearing, dated-entries, code-map"));
661        assert!(!msg.contains("facet"));
662    }
663}