Skip to main content

memstead_base/ingest/
render.rs

1//! Top-level run-brief rendering — the one engine entry point that both the
2//! CLI (`memstead ingest brief`) and UniFFI (macOS app) call, so the brief a
3//! client emits is byte-identical to the CLI's **by construction** (a single
4//! 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::cursor::{compute_source_cursor, write_active_deny_file};
23use super::findings::{FindingClass, current_findings};
24use super::guidance::{GuidanceDefaults, MemGuidance, ResolvedGuidance, resolve_writing_guidance};
25use super::prune::prune_proposals;
26use super::resolve::{ResolveError, ResolvedIngest, ResolvedSource, resolve_binding_run};
27
28/// Why [`render_ingest_brief`] could not produce a brief.
29#[derive(Debug, thiserror::Error)]
30pub enum RenderBriefError {
31    /// The four-primitive pipeline config could not be loaded.
32    #[error("could not load pipeline config: {0}")]
33    ConfigLoad(String),
34    /// The ingest (or a reference it names) could not be resolved.
35    #[error(transparent)]
36    Resolve(#[from] ResolveError),
37    /// The binding declares no `build` operation, so the build path (brief) is
38    /// refused (D6/AC4). The message carries the one-command remedy
39    /// `memstead projection enable build <binding>`, which — run verbatim —
40    /// makes the same brief succeed.
41    #[error(
42        "binding '{binding}' has no build operation — enable it with \
43         `memstead projection enable build {binding}`"
44    )]
45    BuildOperationAbsent {
46        /// The binding id whose build block is absent.
47        binding: String,
48    },
49    /// The durable findings store could not be read while rendering a verify /
50    /// sync brief (group C). The brief needs the open findings; a malformed
51    /// store surfaces here rather than silently rendering an empty findings set.
52    #[error("could not read findings store for '{binding}': {detail}")]
53    FindingsRead {
54        /// The binding id whose findings store failed to read.
55        binding: String,
56        /// The underlying store error, stringified.
57        detail: String,
58    },
59}
60
61/// If any source facet declares an (unimplemented) preparation step, return the
62/// unsupported-and-skipped message the plugin's preparation guard emits; `None`
63/// when every source is directly ingestable. No preparation implementation
64/// exists, so *any* declared preparation is unsupported.
65fn preparation_refusal(resolved: &ResolvedIngest) -> Option<String> {
66    resolved.sources.iter().find_map(|s| match s {
67        ResolvedSource::Primary(p) => p.preparation.as_deref().map(|prep| {
68            format!(
69                "> **[ingest] Ingest \"{}\" is unsupported: facet \"{}\" declares preparation \
70                 \"{}\", which has no implementation. Skipping.**\n",
71                resolved.name, p.name, prep
72            )
73        }),
74        ResolvedSource::Reference { .. } => None,
75    })
76}
77
78/// The mode string used in messages (`discovery` / `one-shot`).
79pub fn mode_name(mode: BuildMode) -> &'static str {
80    match mode {
81        BuildMode::Discovery => "discovery",
82        BuildMode::OneShot => "one-shot",
83    }
84}
85
86/// Locate a binding by the CLI/UniFFI argument. The canonical form is the
87/// binding id `<mem>/<stem>` (D3) — the shape `projection brief` / `--all`
88/// selection use. As a transition bridge, a slash-free legacy argument (the
89/// old flat ingest stem, e.g. `engine-graph`) is also matched against each
90/// binding's `<mem>-<stem>` dashed form, so `memstead ingest brief engine-graph`
91/// keeps rendering the migrated `engine/graph` binding without a router change.
92/// Returns the canonical binding id and the binding.
93fn find_binding<'a>(
94    configs: &'a BindingConfigs,
95    arg: &str,
96) -> Result<(String, &'a Binding), ResolveError> {
97    // Exact canonical id: `<mem>/<stem>`.
98    if let Some(r) = configs
99        .bindings
100        .iter()
101        .find(|r| format!("{}/{}", r.mem, r.name) == arg)
102    {
103        return Ok((format!("{}/{}", r.mem, r.name), &r.config));
104    }
105    // Transition bridge: a slash-free legacy stem → `<mem>-<stem>` dashed form.
106    if !arg.contains('/')
107        && let Some(r) = configs
108            .bindings
109            .iter()
110            .find(|r| format!("{}-{}", r.mem, r.name) == arg)
111    {
112        return Ok((format!("{}/{}", r.mem, r.name), &r.config));
113    }
114    Err(ResolveError::BindingNotFound {
115        name: arg.to_string(),
116        available: configs
117            .bindings
118            .iter()
119            .map(|r| format!("{}/{}", r.mem, r.name))
120            .collect(),
121    })
122}
123
124/// Render the run-brief for a binding — the Markdown prompt an agent consumes.
125/// The single engine entry point shared by the CLI and UniFFI. `ingest_name` is
126/// the canonical binding id (or a legacy flat-ingest stem — see [`find_binding`]).
127pub fn render_ingest_brief(
128    engine: &Engine,
129    workspace_root: &Path,
130    ingest_name: &str,
131) -> Result<String, RenderBriefError> {
132    let configs = load_pipeline_configs(workspace_root)
133        .map_err(|e| RenderBriefError::ConfigLoad(e.to_string()))?;
134    let (binding_id, binding) = find_binding(&configs, ingest_name)?;
135
136    // D6/AC4: the build path (brief) refuses when the binding declares no build
137    // operation, carrying the one-command `projection enable build` remedy —
138    // rather than fabricating a default build the operator never declared.
139    if binding.operations.build.is_none() {
140        return Err(RenderBriefError::BuildOperationAbsent {
141            binding: binding_id,
142        });
143    }
144
145    let resolved = resolve_binding_run(&binding_id, binding)?;
146
147    // Publish this ingest's deny list for the plugin's PreToolUse deny hook —
148    // stale-safe (remove-then-write), overwrite-always, before any mode branch
149    // so the channel is live for every rendered brief and never pins a
150    // previous ingest's list. Best-effort engine cache, not a tracked mutation.
151    write_active_deny_file(workspace_root, &resolved.name, &resolved.deny_paths);
152
153    // Refuse an ingest whose source facet declares a deterministic preparation
154    // step (e.g. `pdf-to-markdown`) — no preparation implementation exists, so
155    // the ingest is reported unsupported and skipped rather than run against
156    // raw, unprepared content. Mirrors the plugin's preparation guard.
157    if let Some(message) = preparation_refusal(&resolved) {
158        return Ok(message);
159    }
160
161    match resolved.mode {
162        BuildMode::Discovery => Ok(render_discovery(engine, &resolved, workspace_root)),
163        BuildMode::OneShot => Ok(render_one_shot(engine, &resolved)),
164    }
165}
166
167/// Render the **verify brief** (C1) for a binding — the measurement +
168/// capped-adjudication prompt an agent consumes. The one engine entry point the
169/// CLI (`projection brief --verify`) and UniFFI share, mirroring
170/// [`render_ingest_brief`]. Read-only on the destination mem: it borrows
171/// `&Engine` (shared), reads the durable findings store for the backlog count,
172/// and renders. It emits **no** destination-mutation instruction (C1) — the
173/// refusal is carried by [`render_verify_brief`] itself.
174pub fn render_verify_brief_for(
175    engine: &Engine,
176    workspace_root: &Path,
177    binding_id: &str,
178) -> Result<String, RenderBriefError> {
179    let configs = load_pipeline_configs(workspace_root)
180        .map_err(|e| RenderBriefError::ConfigLoad(e.to_string()))?;
181    let (binding_id, binding) = find_binding(&configs, binding_id)?;
182    let resolved = resolve_binding_run(&binding_id, binding)?;
183
184    let (_key, findings) =
185        current_findings(engine, workspace_root, binding, &resolved).map_err(|e| {
186            RenderBriefError::FindingsRead {
187                binding: binding_id.clone(),
188                detail: e.to_string(),
189            }
190        })?;
191    let backlog = findings
192        .iter()
193        .filter(|f| f.class == FindingClass::QueuedForAdjudication)
194        .count();
195    Ok(render_verify_brief(&resolved, backlog))
196}
197
198/// Render the **sync brief** (C2/C3) for a binding — the *single* channel
199/// through which maintenance-writing work reaches an agent. The one engine entry
200/// point the CLI (`projection brief --sync`) and UniFFI share. It assembles both
201/// inputs in one render: the live cursor slice ([`compute_source_cursor`]) and
202/// the open findings the verify pass recorded (`current(key)`), plus the adopt
203/// framing when the mem predates its binding (E1). Read-only on the destination
204/// mem (shared `&Engine`) — every repair happens only when an agent acts on this
205/// brief through the normal MCP mutation surface.
206pub fn render_sync_brief_for(
207    engine: &Engine,
208    workspace_root: &Path,
209    binding_id: &str,
210) -> Result<String, RenderBriefError> {
211    let configs = load_pipeline_configs(workspace_root)
212        .map_err(|e| RenderBriefError::ConfigLoad(e.to_string()))?;
213    let (binding_id, binding) = find_binding(&configs, binding_id)?;
214    let resolved = resolve_binding_run(&binding_id, binding)?;
215
216    let cursor = compute_source_cursor(engine, &resolved, workspace_root);
217    let (_key, findings) =
218        current_findings(engine, workspace_root, binding, &resolved).map_err(|e| {
219            RenderBriefError::FindingsRead {
220                binding: binding_id.clone(),
221                detail: e.to_string(),
222            }
223        })?;
224    // Prune proposals (group F) ride the sync brief — the sole channel through
225    // which a prune removal reaches the mem (F3/A5). Read-only gather.
226    let prune = prune_proposals(engine, workspace_root, binding, &resolved);
227    let adopt = mem_predates_binding(engine, &resolved);
228    Ok(render_sync_brief(
229        &resolved, &cursor, &findings, &prune, adopt,
230    ))
231}
232
233/// Whether the destination mem predates its binding — the adopt / onboarding
234/// signal (E1). True when the mem carries **no** anchors and the binding has
235/// **no** recorded `#synced` baseline for any facet: there is nothing to diff
236/// against and nothing anchored yet, so 0% anchored is expected (a first sync),
237/// not drift. A genuinely-fresh mem legitimately gets the same first-sync
238/// framing — the signal is deliberately generic.
239///
240/// The single canonical adopt predicate: the sync brief ([`render_sync_brief_for`]),
241/// the tier-1 fidelity report ([`super::report::compute_fidelity_report`]), and the
242/// status rollup ([`super::status::projection_rollup`]) all read it, so onboarding
243/// framing and the no-red-verdict-from-pre-binding-history refusal stay in lockstep
244/// across every surface.
245pub fn mem_predates_binding(engine: &Engine, resolved: &ResolvedIngest) -> bool {
246    let no_anchors = engine
247        .mem_anchors_resolved(&resolved.destination_mem)
248        .is_empty();
249    let prefix = format!("{}/", resolved.name);
250    let never_synced = engine
251        .mem_config_for(&resolved.destination_mem)
252        .map(|c| {
253            !c.sync_state
254                .keys()
255                .any(|k| k.starts_with(&prefix) && k.ends_with("#synced"))
256        })
257        .unwrap_or(true);
258    no_anchors && never_synced
259}
260
261/// Resolve the destination mem's writing guidance (schema defaults + per-mem
262/// additions / legacy) — shared by the discovery and one-shot briefs.
263fn dest_guidance(engine: &Engine, dest: &str) -> ResolvedGuidance {
264    let defaults = engine
265        .schema_for(dest)
266        .and_then(|schema| schema.manifest.default_writing_guidance.clone())
267        .map(|d| GuidanceDefaults {
268            goal: d.goal,
269            avoid: d.avoid,
270        })
271        .unwrap_or_default();
272
273    let mem_guidance = engine
274        .mem_config_for(dest)
275        .map(|config| {
276            let get = |key: &str| {
277                config
278                    .write_guidance
279                    .get(key)
280                    .and_then(|v| v.as_str())
281                    .map(str::to_string)
282            };
283            MemGuidance {
284                goal_additions: get("goal_additions"),
285                avoid_additions: get("avoid_additions"),
286                legacy_goal: get("goal"),
287                legacy_avoid: get("avoid"),
288            }
289        })
290        .unwrap_or_default();
291
292    resolve_writing_guidance(&defaults, &mem_guidance)
293}
294
295/// Assemble the discovery brief from the engine's live view of the
296/// destination mem: its schema defaults, per-mem writing-guidance additions,
297/// pinned schema ref, paired-process-mem existence, and the source cursor.
298fn render_discovery(engine: &Engine, resolved: &ResolvedIngest, workspace_root: &Path) -> String {
299    let dest = &resolved.destination_mem;
300    let guidance = dest_guidance(engine, dest);
301    let dest_schema = engine.schema_pin(dest).map(|r| r.as_display());
302    let process_mem = build_process_mem(engine, resolved);
303
304    // Changed-slice preface from live source state (empty when nothing has
305    // moved → the brief is byte-identical to a plain roam).
306    let cursor = compute_source_cursor(engine, resolved, workspace_root);
307    let preface = render_changed_slice(&cursor);
308
309    assemble_discovery_brief(
310        resolved,
311        &guidance,
312        &process_mem,
313        dest_schema.as_deref(),
314        &preface,
315    )
316}
317
318/// Assemble the one-shot lens brief — no changed-slice, no paired process mem;
319/// the destination-set / routing / idempotency / report lens block instead.
320fn render_one_shot(engine: &Engine, resolved: &ResolvedIngest) -> String {
321    let dest = &resolved.destination_mem;
322    let guidance = dest_guidance(engine, dest);
323    let dest_schema = engine.schema_pin(dest).map(|r| r.as_display());
324    let dest_purpose = engine
325        .mem_config_for(dest)
326        .and_then(|c| c.description.clone());
327    let process_mem = build_process_mem(engine, resolved); // skipped = true for one-shot
328
329    assemble_one_shot_brief(
330        resolved,
331        &guidance,
332        &process_mem,
333        dest_schema.as_deref(),
334        dest_purpose.as_deref(),
335    )
336}
337
338/// Resolve the paired-process-mem view from live workspace state. Read-only:
339/// a missing process mem is reported absent rather than auto-created (mutation
340/// belongs to the orchestration layer, not brief rendering).
341fn build_process_mem(engine: &Engine, resolved: &ResolvedIngest) -> ProcessMemInfo {
342    let skipped = resolved.mode == BuildMode::OneShot;
343    // One resolution mechanism (agent-trust plan 14): the
344    // destination's declaration wins, the ingest-name convention is
345    // the fallback. A declared-but-unmounted process mem is a stated
346    // notice, never a silent fallback to derivation.
347    let resolution = crate::ingest::resolve::resolve_process_mem(
348        engine,
349        &resolved.destination_mem,
350        &resolved.name,
351    );
352    let leaf = resolution.mem.clone();
353    let present = !skipped && resolution.mounted;
354    let notice = (!skipped && resolution.declared && !resolution.mounted).then(|| {
355        format!(
356            "destination `{}` declares process mem `{}`, which is not mounted",
357            resolved.destination_mem, resolution.mem
358        )
359    });
360    ProcessMemInfo {
361        present,
362        skipped,
363        notice,
364        mem_label: if resolution.declared {
365            leaf.clone()
366        } else {
367            format!("ingest/{leaf}")
368        },
369        leaf_name: leaf,
370    }
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use crate::binding::BuildMode;
377    use crate::ingest::resolve::Source;
378    use crate::pipeline::{IngestTrigger, MediumType};
379
380    fn ingest_with(sources: Vec<ResolvedSource>) -> ResolvedIngest {
381        ResolvedIngest {
382            name: "ing".to_string(),
383            mode: BuildMode::Discovery,
384            trigger: IngestTrigger::Loop,
385            batch_size: 20,
386            deny_paths: vec![],
387            projection_ref: "m/p".to_string(),
388            projection_mem: "m".to_string(),
389            projection_name: "p".to_string(),
390            intent: None,
391            sources,
392            destination_mem: "m".to_string(),
393            rules: None,
394            post_actions: None,
395        }
396    }
397
398    fn primary(facet: &str, preparation: Option<&str>) -> ResolvedSource {
399        ResolvedSource::Primary(Source {
400            name: facet.to_string(),
401            medium_type: MediumType::Codebase,
402            pointer: String::new(),
403            change_detection: None,
404            scope: vec![],
405            engagement: None,
406            preparation: preparation.map(str::to_string),
407        })
408    }
409
410    /// An ingest whose source facet declares an unimplemented preparation step
411    /// is refused (unsupported / skip) rather than rendered — the plugin's
412    /// preparation guard, ported.
413    #[test]
414    fn preparation_step_is_refused() {
415        assert_eq!(
416            preparation_refusal(&ingest_with(vec![primary("f", None)])),
417            None
418        );
419        assert_eq!(
420            preparation_refusal(&ingest_with(vec![ResolvedSource::Reference {
421                mem: "e".to_string()
422            }])),
423            None
424        );
425        let msg = preparation_refusal(&ingest_with(vec![primary(
426            "manuals",
427            Some("pdf-to-markdown"),
428        )]))
429        .unwrap();
430        assert_eq!(
431            msg,
432            "> **[ingest] Ingest \"ing\" is unsupported: facet \"manuals\" declares preparation \"pdf-to-markdown\", which has no implementation. Skipping.**\n"
433        );
434    }
435}