1use std::path::Path;
13
14use crate::Engine;
15use crate::binding::{BindingV1, 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#[derive(Debug, thiserror::Error)]
30pub enum RenderBriefError {
31 #[error("could not load pipeline config: {0}")]
33 ConfigLoad(String),
34 #[error(transparent)]
36 Resolve(#[from] ResolveError),
37 #[error(
42 "binding '{binding}' has no build operation — enable it with \
43 `memstead projection enable build {binding}`"
44 )]
45 BuildOperationAbsent {
46 binding: String,
48 },
49 #[error("could not read findings store for '{binding}': {detail}")]
53 FindingsRead {
54 binding: String,
56 detail: String,
58 },
59}
60
61fn 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.facet_ref, prep
72 )
73 }),
74 ResolvedSource::Reference { .. } => None,
75 })
76}
77
78pub fn mode_name(mode: BuildMode) -> &'static str {
80 match mode {
81 BuildMode::Discovery => "discovery",
82 BuildMode::OneShot => "one-shot",
83 }
84}
85
86fn find_binding<'a>(
94 configs: &'a BindingConfigs,
95 arg: &str,
96) -> Result<(String, &'a BindingV1), ResolveError> {
97 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 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
124pub 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 if binding.operations.build.is_none() {
140 return Err(RenderBriefError::BuildOperationAbsent {
141 binding: binding_id,
142 });
143 }
144
145 let resolved = resolve_binding_run(&configs, &binding_id, binding)?;
146
147 write_active_deny_file(workspace_root, &resolved.name, &resolved.deny_paths);
152
153 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
167pub 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(&configs, &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
198pub 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(&configs, &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 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
233pub 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
261fn 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
295fn 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 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
318fn 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); assemble_one_shot_brief(
330 resolved,
331 &guidance,
332 &process_mem,
333 dest_schema.as_deref(),
334 dest_purpose.as_deref(),
335 )
336}
337
338fn build_process_mem(engine: &Engine, resolved: &ResolvedIngest) -> ProcessMemInfo {
342 let leaf = resolved.name.clone();
343 let skipped = resolved.mode == BuildMode::OneShot;
344 let present = !skipped && engine.mem_names().iter().any(|m| *m == leaf);
345 ProcessMemInfo {
346 present,
347 skipped,
348 notice: None,
349 mem_label: format!("ingest/{leaf}"),
350 leaf_name: leaf,
351 }
352}
353
354#[cfg(test)]
355mod tests {
356 use super::*;
357 use crate::binding::BuildMode;
358 use crate::ingest::resolve::ResolvedPrimarySource;
359 use crate::pipeline::{IngestTrigger, MediumType};
360
361 fn ingest_with(sources: Vec<ResolvedSource>) -> ResolvedIngest {
362 ResolvedIngest {
363 name: "ing".to_string(),
364 mode: BuildMode::Discovery,
365 trigger: IngestTrigger::Loop,
366 batch_size: 20,
367 deny_paths: vec![],
368 projection_ref: "m/p".to_string(),
369 projection_mem: "m".to_string(),
370 projection_name: "p".to_string(),
371 intent: None,
372 sources,
373 destination_mem: "m".to_string(),
374 rules: None,
375 post_actions: None,
376 }
377 }
378
379 fn primary(facet: &str, preparation: Option<&str>) -> ResolvedSource {
380 ResolvedSource::Primary(ResolvedPrimarySource {
381 facet_ref: facet.to_string(),
382 medium: "m".to_string(),
383 medium_type: MediumType::Codebase,
384 medium_pointer: String::new(),
385 declared_change_detection: None,
386 scope: vec![],
387 preparation: preparation.map(str::to_string),
388 })
389 }
390
391 #[test]
395 fn preparation_step_is_refused() {
396 assert_eq!(
397 preparation_refusal(&ingest_with(vec![primary("f", None)])),
398 None
399 );
400 assert_eq!(
401 preparation_refusal(&ingest_with(vec![ResolvedSource::Reference {
402 mem: "e".to_string()
403 }])),
404 None
405 );
406 let msg = preparation_refusal(&ingest_with(vec![primary(
407 "manuals",
408 Some("pdf-to-markdown"),
409 )]))
410 .unwrap();
411 assert_eq!(
412 msg,
413 "> **[ingest] Ingest \"ing\" is unsupported: facet \"manuals\" declares preparation \"pdf-to-markdown\", which has no implementation. Skipping.**\n"
414 );
415 }
416}