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::check_path::write_active_binding_file;
use super::cursor::compute_source_cursor;
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()
.filter(|prep| !crate::preparation::is_registered(prep))
.map(|prep| {
format!(
"> **[ingest] Ingest \"{}\" is unsupported: source \"{}\" declares \
preparation \"{}\", which is not in this engine's preparation registry \
(registered: {}). Skipping.**\n",
resolved.name,
p.name,
prep,
crate::preparation::registered_identifiers().join(", ")
)
}),
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,
consume: bool,
) -> 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)?;
if consume {
write_active_binding_file(workspace_root, &binding_id);
}
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 medium_type_wire(t: crate::pipeline::MediumType) -> &'static str {
use crate::pipeline::MediumType as M;
match t {
M::Codebase => "codebase",
M::Filesystem => "filesystem",
M::Git => "git",
M::Graph => "graph",
M::Web => "web",
}
}
fn absent_source_names(resolved: &ResolvedIngest, workspace_root: &Path) -> Vec<String> {
resolved
.sources
.iter()
.filter_map(|s| match s {
ResolvedSource::Primary(p) => Some(p),
ResolvedSource::Reference { .. } => None,
})
.filter(|p| {
matches!(
p.medium_type,
crate::pipeline::MediumType::Codebase
| crate::pipeline::MediumType::Filesystem
| crate::pipeline::MediumType::Git
) && !super::cursor::medium_base(&p.pointer, workspace_root).exists()
})
.map(|p| p.name.clone())
.collect()
}
fn suggested_schema_pin(engine: &Engine, writable: &[&str]) -> String {
writable
.iter()
.find_map(|m| engine.schema_pin(m))
.map(|r| r.as_display())
.or_else(|| {
memstead_schema::SchemaRegistry::builtin()
.available_versions("default")
.into_iter()
.max()
.map(|v| format!("default@{v}"))
})
.unwrap_or_else(|| "<name@version>".to_string())
}
fn absent_destination_note(
engine: &Engine,
resolved: &ResolvedIngest,
binding_id: &str,
workspace_root: &Path,
) -> Option<String> {
let dest = resolved.destination_mem.as_str();
if engine.schema_pin(dest).is_some() {
return None;
}
let mut writable: Vec<&str> = engine
.mem_router()
.writable_mems()
.iter()
.map(String::as_str)
.collect();
writable.sort_unstable();
let remedy = if crate::workspace_store::is_mem_repo_shaped(workspace_root) {
let admitted =
crate::mem_management::CreateRuleSet::new(engine.settings().mem_create_rules.clone())
.ok()
.is_some_and(|set| set.matches(std::path::Path::new(dest)));
let pin = suggested_schema_pin(engine, &writable);
if admitted {
format!("Create it before writing: `memstead mem init {dest} --schema {pin}`.")
} else {
format!(
"Creating it takes two steps — this workspace admits no mem name yet, \
so `memstead mem init` alone refuses: `memstead workspace allow-create \
'{dest}' --schema {pin}`, then `memstead mem init {dest} --schema {pin}`."
)
}
} else if writable.is_empty() {
"This workspace has no writable mem to point it at.".to_string()
} else {
let stem = binding_id.rsplit('/').next().unwrap_or(binding_id);
let redeclare = resolved
.sources
.iter()
.find_map(|s| match s {
crate::ingest::resolve::ResolvedSource::Primary(p) => Some(p),
crate::ingest::resolve::ResolvedSource::Reference { .. } => None,
})
.map(|p| {
format!(
" Re-declare it against that mem: `rm .memstead/projections/{binding_id}.json` \
then `memstead projection init --mem {} --source {} --medium-type {} \
--name {}`.",
writable.first().copied().unwrap_or("<mem>"),
p.pointer,
medium_type_wire(p.medium_type),
stem,
)
})
.unwrap_or_default();
format!(
"This is a filesystem-mem workspace, which holds one mem and cannot \
add another, so this binding names a mem that can never exist here.{redeclare} \
Editing `destination_mem` alone is not enough — the record's folder \
decides which mem's anchors resolve."
)
};
Some(format!(
"**This mem does not exist in this workspace yet.** {remedy} Until then, \
every mutation this brief asks for will refuse."
))
}
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);
let dest_note = absent_destination_note(engine, resolved, &resolved.name, workspace_root);
let absent = absent_source_names(resolved, workspace_root);
assemble_discovery_brief(
resolved,
&guidance,
&process_mem,
dest_schema.as_deref(),
dest_note.as_deref(),
&absent,
&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(),
None,
&[],
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 unregistered_preparation_is_refused_registered_passes() {
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
);
assert_eq!(
preparation_refusal(&ingest_with(vec![primary(
"claims",
Some(crate::preparation::ENTITY_LOAD_BEARING),
)])),
None,
"a registered preparation is not refused at render"
);
let msg = preparation_refusal(&ingest_with(vec![primary(
"manuals",
Some("pdf-to-markdown"),
)]))
.unwrap();
assert_eq!(
msg,
format!(
"> **[ingest] Ingest \"ing\" is unsupported: source \"manuals\" declares preparation \"pdf-to-markdown\", which is not in this engine's preparation registry (registered: {}). Skipping.**\n",
crate::preparation::registered_identifiers().join(", ")
)
);
assert!(msg.contains("entity-load-bearing, dated-entries, code-map"));
assert!(!msg.contains("facet"));
}
}