use std::path::Path;
use crate::Engine;
use crate::binding::{Binding, BuildMode};
use crate::pipeline_store::{BindingConfigs, load_pipeline_configs};
use super::brief::{
ProcessMemInfo, assemble_discovery_brief, assemble_one_shot_brief, render_changed_slice,
render_sync_brief, render_verify_brief,
};
use super::cursor::{compute_source_cursor, write_active_deny_file};
use super::findings::{FindingClass, current_findings};
use super::guidance::{GuidanceDefaults, MemGuidance, ResolvedGuidance, resolve_writing_guidance};
use super::prune::prune_proposals;
use super::resolve::{ResolveError, ResolvedIngest, ResolvedSource, resolve_binding_run};
#[derive(Debug, thiserror::Error)]
pub enum RenderBriefError {
#[error("could not load pipeline config: {0}")]
ConfigLoad(String),
#[error(transparent)]
Resolve(#[from] ResolveError),
#[error(
"binding '{binding}' has no build operation — enable it with \
`memstead projection enable build {binding}`"
)]
BuildOperationAbsent {
binding: String,
},
#[error("could not read findings store for '{binding}': {detail}")]
FindingsRead {
binding: String,
detail: String,
},
}
fn preparation_refusal(resolved: &ResolvedIngest) -> Option<String> {
resolved.sources.iter().find_map(|s| match s {
ResolvedSource::Primary(p) => p.preparation.as_deref().map(|prep| {
format!(
"> **[ingest] Ingest \"{}\" is unsupported: facet \"{}\" declares preparation \
\"{}\", which has no implementation. Skipping.**\n",
resolved.name, p.name, prep
)
}),
ResolvedSource::Reference { .. } => None,
})
}
pub fn mode_name(mode: BuildMode) -> &'static str {
match mode {
BuildMode::Discovery => "discovery",
BuildMode::OneShot => "one-shot",
}
}
fn find_binding<'a>(
configs: &'a BindingConfigs,
arg: &str,
) -> Result<(String, &'a Binding), ResolveError> {
if let Some(r) = configs
.bindings
.iter()
.find(|r| format!("{}/{}", r.mem, r.name) == arg)
{
return Ok((format!("{}/{}", r.mem, r.name), &r.config));
}
if !arg.contains('/')
&& let Some(r) = configs
.bindings
.iter()
.find(|r| format!("{}-{}", r.mem, r.name) == arg)
{
return Ok((format!("{}/{}", r.mem, r.name), &r.config));
}
Err(ResolveError::BindingNotFound {
name: arg.to_string(),
available: configs
.bindings
.iter()
.map(|r| format!("{}/{}", r.mem, r.name))
.collect(),
})
}
pub fn render_ingest_brief(
engine: &Engine,
workspace_root: &Path,
ingest_name: &str,
) -> Result<String, RenderBriefError> {
let configs = load_pipeline_configs(workspace_root)
.map_err(|e| RenderBriefError::ConfigLoad(e.to_string()))?;
let (binding_id, binding) = find_binding(&configs, ingest_name)?;
if binding.operations.build.is_none() {
return Err(RenderBriefError::BuildOperationAbsent {
binding: binding_id,
});
}
let resolved = resolve_binding_run(&binding_id, binding)?;
write_active_deny_file(workspace_root, &resolved.name, &resolved.deny_paths);
if let Some(message) = preparation_refusal(&resolved) {
return Ok(message);
}
match resolved.mode {
BuildMode::Discovery => Ok(render_discovery(engine, &resolved, workspace_root)),
BuildMode::OneShot => Ok(render_one_shot(engine, &resolved)),
}
}
pub fn render_verify_brief_for(
engine: &Engine,
workspace_root: &Path,
binding_id: &str,
) -> Result<String, RenderBriefError> {
let configs = load_pipeline_configs(workspace_root)
.map_err(|e| RenderBriefError::ConfigLoad(e.to_string()))?;
let (binding_id, binding) = find_binding(&configs, binding_id)?;
let resolved = resolve_binding_run(&binding_id, binding)?;
let (_key, findings) =
current_findings(engine, workspace_root, binding, &resolved).map_err(|e| {
RenderBriefError::FindingsRead {
binding: binding_id.clone(),
detail: e.to_string(),
}
})?;
let backlog = findings
.iter()
.filter(|f| f.class == FindingClass::QueuedForAdjudication)
.count();
Ok(render_verify_brief(&resolved, backlog))
}
pub fn render_sync_brief_for(
engine: &Engine,
workspace_root: &Path,
binding_id: &str,
) -> Result<String, RenderBriefError> {
let configs = load_pipeline_configs(workspace_root)
.map_err(|e| RenderBriefError::ConfigLoad(e.to_string()))?;
let (binding_id, binding) = find_binding(&configs, binding_id)?;
let resolved = resolve_binding_run(&binding_id, binding)?;
let cursor = compute_source_cursor(engine, &resolved, workspace_root);
let (_key, findings) =
current_findings(engine, workspace_root, binding, &resolved).map_err(|e| {
RenderBriefError::FindingsRead {
binding: binding_id.clone(),
detail: e.to_string(),
}
})?;
let prune = prune_proposals(engine, workspace_root, binding, &resolved);
let adopt = mem_predates_binding(engine, &resolved);
Ok(render_sync_brief(
&resolved, &cursor, &findings, &prune, adopt,
))
}
pub fn mem_predates_binding(engine: &Engine, resolved: &ResolvedIngest) -> bool {
let no_anchors = engine
.mem_anchors_resolved(&resolved.destination_mem)
.is_empty();
let prefix = format!("{}/", resolved.name);
let never_synced = engine
.mem_config_for(&resolved.destination_mem)
.map(|c| {
!c.sync_state
.keys()
.any(|k| k.starts_with(&prefix) && k.ends_with("#synced"))
})
.unwrap_or(true);
no_anchors && never_synced
}
fn dest_guidance(engine: &Engine, dest: &str) -> ResolvedGuidance {
let defaults = engine
.schema_for(dest)
.and_then(|schema| schema.manifest.default_writing_guidance.clone())
.map(|d| GuidanceDefaults {
goal: d.goal,
avoid: d.avoid,
})
.unwrap_or_default();
let mem_guidance = engine
.mem_config_for(dest)
.map(|config| {
let get = |key: &str| {
config
.write_guidance
.get(key)
.and_then(|v| v.as_str())
.map(str::to_string)
};
MemGuidance {
goal_additions: get("goal_additions"),
avoid_additions: get("avoid_additions"),
legacy_goal: get("goal"),
legacy_avoid: get("avoid"),
}
})
.unwrap_or_default();
resolve_writing_guidance(&defaults, &mem_guidance)
}
fn render_discovery(engine: &Engine, resolved: &ResolvedIngest, workspace_root: &Path) -> String {
let dest = &resolved.destination_mem;
let guidance = dest_guidance(engine, dest);
let dest_schema = engine.schema_pin(dest).map(|r| r.as_display());
let process_mem = build_process_mem(engine, resolved);
let cursor = compute_source_cursor(engine, resolved, workspace_root);
let preface = render_changed_slice(&cursor);
assemble_discovery_brief(
resolved,
&guidance,
&process_mem,
dest_schema.as_deref(),
&preface,
)
}
fn render_one_shot(engine: &Engine, resolved: &ResolvedIngest) -> String {
let dest = &resolved.destination_mem;
let guidance = dest_guidance(engine, dest);
let dest_schema = engine.schema_pin(dest).map(|r| r.as_display());
let dest_purpose = engine
.mem_config_for(dest)
.and_then(|c| c.description.clone());
let process_mem = build_process_mem(engine, resolved);
assemble_one_shot_brief(
resolved,
&guidance,
&process_mem,
dest_schema.as_deref(),
dest_purpose.as_deref(),
)
}
fn build_process_mem(engine: &Engine, resolved: &ResolvedIngest) -> ProcessMemInfo {
let skipped = resolved.mode == BuildMode::OneShot;
let resolution = crate::ingest::resolve::resolve_process_mem(
engine,
&resolved.destination_mem,
&resolved.name,
);
let leaf = resolution.mem.clone();
let present = !skipped && resolution.mounted;
let notice = (!skipped && resolution.declared && !resolution.mounted).then(|| {
format!(
"destination `{}` declares process mem `{}`, which is not mounted",
resolved.destination_mem, resolution.mem
)
});
ProcessMemInfo {
present,
skipped,
notice,
mem_label: if resolution.declared {
leaf.clone()
} else {
format!("ingest/{leaf}")
},
leaf_name: leaf,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::binding::BuildMode;
use crate::ingest::resolve::Source;
use crate::pipeline::{IngestTrigger, MediumType};
fn ingest_with(sources: Vec<ResolvedSource>) -> ResolvedIngest {
ResolvedIngest {
name: "ing".to_string(),
mode: BuildMode::Discovery,
trigger: IngestTrigger::Loop,
batch_size: 20,
deny_paths: vec![],
projection_ref: "m/p".to_string(),
projection_mem: "m".to_string(),
projection_name: "p".to_string(),
intent: None,
sources,
destination_mem: "m".to_string(),
rules: None,
post_actions: None,
}
}
fn primary(facet: &str, preparation: Option<&str>) -> ResolvedSource {
ResolvedSource::Primary(Source {
name: facet.to_string(),
medium_type: MediumType::Codebase,
pointer: String::new(),
change_detection: None,
scope: vec![],
engagement: None,
preparation: preparation.map(str::to_string),
})
}
#[test]
fn preparation_step_is_refused() {
assert_eq!(
preparation_refusal(&ingest_with(vec![primary("f", None)])),
None
);
assert_eq!(
preparation_refusal(&ingest_with(vec![ResolvedSource::Reference {
mem: "e".to_string()
}])),
None
);
let msg = preparation_refusal(&ingest_with(vec![primary(
"manuals",
Some("pdf-to-markdown"),
)]))
.unwrap();
assert_eq!(
msg,
"> **[ingest] Ingest \"ing\" is unsupported: facet \"manuals\" declares preparation \"pdf-to-markdown\", which has no implementation. Skipping.**\n"
);
}
}