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