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}