use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use crate::Engine;
use crate::workspace_store::{StoreError, WORKSPACE_STORE_DIR};
use super::cursor::{compute_source_cursor, enumerate_facet_files};
use super::resolve::{ResolvedIngest, ResolvedSource};
use super::slice::Slice;
const STATE_DIR: &str = "state";
const ADVANCE_DIR: &str = "advance";
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct AdvanceState {
pub binding: String,
pub frozen_slice: Slice,
pub dispositions: BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub exclusions: BTreeMap<String, String>,
}
pub const EXCLUDED_VERDICT: &str = "excluded";
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(untagged)]
pub enum DispositionInput {
Verdict(String),
Reasoned {
disposition: String,
rationale: String,
},
}
impl DispositionInput {
pub fn verdict(&self) -> &str {
match self {
DispositionInput::Verdict(v) => v,
DispositionInput::Reasoned { disposition, .. } => disposition,
}
}
pub fn rationale(&self) -> Option<&str> {
match self {
DispositionInput::Verdict(_) => None,
DispositionInput::Reasoned { rationale, .. } => Some(rationale),
}
}
}
impl AdvanceState {
pub fn disposed(&self) -> usize {
self.dispositions.len()
}
pub fn pending(&self) -> usize {
artifact_set(&self.frozen_slice)
.iter()
.filter(|a| !self.dispositions.contains_key(a.as_str()))
.count()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdvanceOutcome {
pub binding: String,
pub remainder: Slice,
pub disposed: usize,
pub pending: usize,
pub completed: bool,
pub tokens_written: Vec<String>,
pub warnings: Vec<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum AdvanceError {
#[error("malformed binding id '{0}': expected `<mem>/<stem>`")]
MalformedId(String),
#[error(
"disposition names {} artifact id(s) the engine did not present: {}; the advance gate \
accepts only ids from the presented slice, verbatim in their workspace-relative form \
({printed} presented){}",
artifacts.len(),
fmt_list(artifacts),
fmt_suggestions(suggestions)
)]
UnknownArtifact {
artifacts: Vec<String>,
printed: usize,
suggestions: Vec<(String, String)>,
},
#[error("advance store error: {0}")]
Store(#[source] StoreError),
#[error("could not advance baseline token: {0}")]
Engine(String),
}
fn fmt_list(names: &[String]) -> String {
if names.is_empty() {
"(none)".to_string()
} else {
names.join(", ")
}
}
fn fmt_suggestions(suggestions: &[(String, String)]) -> String {
if suggestions.is_empty() {
return String::new();
}
let pairs = suggestions
.iter()
.map(|(supplied, corrected)| format!("`{supplied}` → `{corrected}`"))
.collect::<Vec<_>>()
.join(", ");
format!(
". Some supplied ids look medium-relative; the slice presents them workspace-relative — \
retry with {pairs} (the medium-relative form is never accepted)"
)
}
fn derive_corrected_ids(
unknown: &[String],
resolved: &ResolvedIngest,
printed: &BTreeSet<String>,
) -> Vec<(String, String)> {
let medium_roots: Vec<&str> = resolved
.sources
.iter()
.filter_map(|s| match s {
ResolvedSource::Primary(p) if !p.pointer.is_empty() => Some(p.pointer.as_str()),
_ => None,
})
.collect();
unknown
.iter()
.filter_map(|id| {
medium_roots.iter().find_map(|root| {
let candidate = format!("{}/{id}", root.trim_end_matches('/'));
printed
.contains(candidate.as_str())
.then(|| (id.clone(), candidate))
})
})
.collect()
}
fn split_binding_id(binding_id: &str) -> Result<(String, String), AdvanceError> {
binding_id
.split_once('/')
.filter(|(m, n)| is_single_component(m) && is_single_component(n))
.map(|(m, n)| (m.to_string(), n.to_string()))
.ok_or_else(|| AdvanceError::MalformedId(binding_id.to_string()))
}
pub(crate) fn is_single_component(value: &str) -> bool {
!value.is_empty()
&& value != "."
&& value != ".."
&& !value.contains('/')
&& !value.contains('\\')
&& !value.contains(':')
&& !value.contains('\0')
}
pub fn advance_store_path(workspace_root: &Path, mem: &str, name: &str) -> PathBuf {
workspace_root
.join(WORKSPACE_STORE_DIR)
.join(STATE_DIR)
.join(ADVANCE_DIR)
.join(mem)
.join(format!("{name}.json"))
}
pub fn read_advance_store(
workspace_root: &Path,
mem: &str,
name: &str,
) -> Result<Option<AdvanceState>, StoreError> {
let path = advance_store_path(workspace_root, mem, name);
match std::fs::read(&path) {
Ok(bytes) => serde_json::from_slice(&bytes)
.map(Some)
.map_err(|e| StoreError::Parse {
path,
message: e.to_string(),
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(StoreError::Io { path, source: e }),
}
}
pub fn write_advance_store(
workspace_root: &Path,
mem: &str,
name: &str,
state: &AdvanceState,
) -> Result<(), StoreError> {
super::findings::ensure_selfignoring_store_dir(
&workspace_root
.join(WORKSPACE_STORE_DIR)
.join(STATE_DIR)
.join(ADVANCE_DIR),
)?;
let path = advance_store_path(workspace_root, mem, name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| StoreError::Io {
path: parent.to_path_buf(),
source: e,
})?;
}
let bytes = serde_json::to_vec_pretty(state).map_err(|e| StoreError::Parse {
path: path.clone(),
message: e.to_string(),
})?;
std::fs::write(&path, bytes).map_err(|e| StoreError::Io { path, source: e })
}
pub fn delete_advance_store(
workspace_root: &Path,
mem: &str,
name: &str,
) -> Result<(), StoreError> {
let path = advance_store_path(workspace_root, mem, name);
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(StoreError::Io { path, source: e }),
}
}
fn union_slice(into: &mut Slice, from: &Slice) {
into.added.extend(from.added.iter().cloned());
into.modified.extend(from.modified.iter().cloned());
into.deleted.extend(from.deleted.iter().cloned());
for v in [&mut into.added, &mut into.modified, &mut into.deleted] {
v.sort();
v.dedup();
}
}
fn artifact_set(slice: &Slice) -> BTreeSet<String> {
slice
.added
.iter()
.chain(slice.modified.iter())
.chain(slice.deleted.iter())
.cloned()
.collect()
}
fn subtract_disposed(frozen: &Slice, dispositions: &BTreeMap<String, String>) -> Slice {
let keep = |v: &[String]| -> Vec<String> {
v.iter()
.filter(|a| !dispositions.contains_key(*a))
.cloned()
.collect()
};
Slice {
added: keep(&frozen.added),
modified: keep(&frozen.modified),
deleted: keep(&frozen.deleted),
}
}
pub fn advance_baseline(
engine: &mut Engine,
workspace_root: &Path,
resolved: &ResolvedIngest,
dispositions: &BTreeMap<String, DispositionInput>,
) -> Result<AdvanceOutcome, AdvanceError> {
let binding_id = resolved.name.clone();
let (mem, name) = split_binding_id(&binding_id)?;
let cursor = compute_source_cursor(engine, resolved, workspace_root);
let mut state = read_advance_store(workspace_root, &mem, &name)
.map_err(AdvanceError::Store)?
.unwrap_or_else(|| AdvanceState {
binding: binding_id.clone(),
..Default::default()
});
union_slice(&mut state.frozen_slice, &cursor.union);
let printed = artifact_set(&state.frozen_slice);
let mut unknown: Vec<String> = dispositions
.keys()
.filter(|a| !printed.contains(a.as_str()))
.cloned()
.collect();
if !unknown.is_empty() {
unknown.sort();
unknown.dedup();
let suggestions = derive_corrected_ids(&unknown, resolved, &printed);
return Err(AdvanceError::UnknownArtifact {
artifacts: unknown,
printed: printed.len(),
suggestions,
});
}
for (artifact, input) in dispositions {
state
.dispositions
.insert(artifact.clone(), input.verdict().to_string());
if input.verdict() == EXCLUDED_VERDICT {
state.exclusions.insert(
artifact.clone(),
input.rationale().unwrap_or("").to_string(),
);
} else {
state.exclusions.remove(artifact);
}
}
let auto_worked: Vec<String> = printed
.iter()
.filter(|art| !state.dispositions.contains_key(art.as_str()))
.filter(|art| {
engine
.anchors_referencing_artifact(art)
.iter()
.any(|(eid, _)| eid.mem() == resolved.destination_mem.as_str())
})
.cloned()
.collect();
for art in auto_worked {
state.dispositions.insert(art, "worked".to_string());
}
let remainder = subtract_disposed(&state.frozen_slice, &state.dispositions);
let pending = remainder.added.len() + remainder.modified.len() + remainder.deleted.len();
let completed = pending == 0;
let mut warnings: Vec<String> = Vec::new();
let mut tokens_written: Vec<String> = Vec::new();
if completed {
let note = format!(
"projection advance {binding_id}: {} artifact(s) disposed, baseline advanced",
state.dispositions.len()
);
for c in cursor.write_commands.iter().chain(cursor.reseed.iter()) {
let outcome = engine
.set_mem_sync_state(&resolved.destination_mem, &c.key, &c.token, Some(¬e))
.map_err(|e| AdvanceError::Engine(e.to_string()))?;
warnings.extend(outcome.warnings.iter().map(ToString::to_string));
tokens_written.push(c.key.clone());
}
if state.exclusions.is_empty() {
delete_advance_store(workspace_root, &mem, &name).map_err(AdvanceError::Store)?;
} else {
let durable = AdvanceState {
binding: binding_id.clone(),
frozen_slice: Slice::default(),
dispositions: BTreeMap::new(),
exclusions: state.exclusions.clone(),
};
write_advance_store(workspace_root, &mem, &name, &durable)
.map_err(AdvanceError::Store)?;
}
} else {
write_advance_store(workspace_root, &mem, &name, &state).map_err(AdvanceError::Store)?;
}
Ok(AdvanceOutcome {
binding: binding_id,
remainder,
disposed: state.dispositions.len(),
pending,
completed,
tokens_written,
warnings,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExcludeOutcome {
pub binding: String,
pub excluded: usize,
pub added: usize,
}
#[derive(Debug, thiserror::Error)]
pub enum ExcludeError {
#[error("malformed binding id '{0}': expected `<mem>/<stem>`")]
MalformedId(String),
#[error(
"exclusion names {} artifact id(s) not in the binding's enumerable source S(D): {}; \
only an in-scope source member can be declared excluded ({printed} enumerated)",
artifacts.len(),
fmt_list(artifacts)
)]
NotSourceMember {
artifacts: Vec<String>,
printed: usize,
},
#[error("advance store error: {0}")]
Store(#[source] StoreError),
}
pub fn record_exclusions(
workspace_root: &Path,
resolved: &ResolvedIngest,
exclusions: &BTreeMap<String, String>,
) -> Result<ExcludeOutcome, ExcludeError> {
let binding_id = resolved.name.clone();
let (mem, name) =
split_binding_id(&binding_id).map_err(|_| ExcludeError::MalformedId(binding_id.clone()))?;
let mut s_d: BTreeSet<String> = BTreeSet::new();
for source in &resolved.sources {
if let ResolvedSource::Primary(p) = source {
for f in enumerate_facet_files(p, &resolved.deny_paths, workspace_root) {
s_d.insert(f);
}
}
}
let mut not_member: Vec<String> = exclusions
.keys()
.filter(|a| !s_d.contains(a.as_str()))
.cloned()
.collect();
if !not_member.is_empty() {
not_member.sort();
not_member.dedup();
return Err(ExcludeError::NotSourceMember {
artifacts: not_member,
printed: s_d.len(),
});
}
let mut state = read_advance_store(workspace_root, &mem, &name)
.map_err(ExcludeError::Store)?
.unwrap_or_else(|| AdvanceState {
binding: binding_id.clone(),
..Default::default()
});
let mut added = 0usize;
for (artifact, rationale) in exclusions {
if state
.exclusions
.insert(artifact.clone(), rationale.clone())
.is_none()
{
added += 1;
}
}
write_advance_store(workspace_root, &mem, &name, &state).map_err(ExcludeError::Store)?;
Ok(ExcludeOutcome {
binding: binding_id,
excluded: state.exclusions.len(),
added,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::binding::BuildMode;
use crate::pipeline::{IngestTrigger, MediumType, PatternEntry, PatternMode};
use crate::storage::FilesystemMemWriter;
use crate::workspace::{Mount, MountCapability, MountLifecycle, MountStorage};
use tempfile::TempDir;
fn slice(added: &[&str], modified: &[&str], deleted: &[&str]) -> Slice {
Slice {
added: added.iter().map(|s| s.to_string()).collect(),
modified: modified.iter().map(|s| s.to_string()).collect(),
deleted: deleted.iter().map(|s| s.to_string()).collect(),
}
}
fn disp(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(a, d)| (a.to_string(), d.to_string()))
.collect()
}
fn input(pairs: &[(&str, &str)]) -> BTreeMap<String, DispositionInput> {
pairs
.iter()
.map(|(a, d)| (a.to_string(), DispositionInput::Verdict(d.to_string())))
.collect()
}
#[test]
fn advance_store_round_trips_and_delete_is_idempotent() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
assert!(
read_advance_store(root, "engine", "graph")
.unwrap()
.is_none()
);
let state = AdvanceState {
binding: "engine/graph".to_string(),
frozen_slice: slice(&["c.rs"], &["a.rs"], &["b.rs"]),
dispositions: disp(&[("a.rs", "worked")]),
exclusions: BTreeMap::new(),
};
write_advance_store(root, "engine", "graph", &state).unwrap();
assert!(
advance_store_path(root, "engine", "graph")
.ends_with("state/advance/engine/graph.json")
);
let back = read_advance_store(root, "engine", "graph")
.unwrap()
.unwrap();
assert_eq!(back, state);
delete_advance_store(root, "engine", "graph").unwrap();
assert!(
read_advance_store(root, "engine", "graph")
.unwrap()
.is_none()
);
delete_advance_store(root, "engine", "graph").unwrap();
}
#[test]
fn subtract_disposed_removes_disposed_from_every_class() {
let frozen = slice(&["c.rs"], &["a.rs"], &["b.rs"]);
let out = subtract_disposed(&frozen, &disp(&[("a.rs", "worked"), ("c.rs", "skipped")]));
assert_eq!(out, slice(&[], &[], &["b.rs"]));
}
fn git(repo: &Path, args: &[&str]) {
let out = std::process::Command::new("git")
.args(args)
.current_dir(repo)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t")
.output()
.unwrap();
assert!(
out.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn head_sha(repo: &Path) -> String {
String::from_utf8(
std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.output()
.unwrap()
.stdout,
)
.unwrap()
.trim()
.to_string()
}
fn resolved_engine_graph() -> ResolvedIngest {
use super::super::resolve::{ResolvedSource, Source};
ResolvedIngest {
name: "engine/graph".to_string(),
mode: BuildMode::Discovery,
trigger: IngestTrigger::Loop,
batch_size: 20,
deny_paths: vec![],
projection_ref: "engine/graph".to_string(),
projection_mem: "engine".to_string(),
projection_name: "graph".to_string(),
intent: None,
sources: vec![ResolvedSource::Primary(Source {
name: "source-tree".to_string(),
medium_type: MediumType::Codebase,
pointer: String::new(),
change_detection: Some("git".to_string()),
scope: vec![PatternEntry {
path: "**/*.rs".to_string(),
mode: PatternMode::Allow,
}],
engagement: None,
preparation: None,
})],
destination_mem: "engine".to_string(),
rules: None,
post_actions: None,
}
}
fn engine_at(root: &Path) -> Engine {
let config_path = root.join(".memstead").join("config.json");
if !config_path.exists() {
std::fs::create_dir_all(root.join(".memstead")).unwrap();
std::fs::write(&config_path, br#"{"format":1,"schema":"default@1.0.0"}"#).unwrap();
}
let mount = Mount {
mem: "engine".to_string(),
schema: Some("default@1.0.0".parse().unwrap()),
storage: MountStorage::Folder {
path: root.to_path_buf(),
},
capability: MountCapability::Write,
lifecycle: MountLifecycle::Eager,
cross_linkable: false,
migration_target: None,
};
Engine::from_mounts(vec![(
mount,
Box::new(FilesystemMemWriter::new(root.to_path_buf()))
as Box<dyn crate::backend::MemBackend>,
)])
.unwrap()
}
fn synced_key() -> &'static str {
"engine/graph/source-tree#synced"
}
#[test]
fn advance_is_non_stalling_under_a_moving_head_with_gate_and_resumability() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
git(root, &["init", "-q"]);
std::fs::write(root.join("a.rs"), "one").unwrap();
std::fs::write(root.join("b.rs"), "bee").unwrap();
git(root, &["add", "a.rs", "b.rs"]);
git(root, &["commit", "-qm", "base"]);
let baseline = head_sha(root);
std::fs::write(root.join("a.rs"), "one-longer").unwrap();
std::fs::remove_file(root.join("b.rs")).unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "head1"]);
let resolved = resolved_engine_graph();
{
let mut engine = engine_at(root);
engine
.set_mem_sync_state("engine", synced_key(), &baseline, None)
.unwrap();
}
{
let mut engine = engine_at(root);
let out = advance_baseline(&mut engine, root, &resolved, &input(&[("a.rs", "worked")]))
.unwrap();
assert!(!out.completed, "one artifact still pending");
assert_eq!(out.remainder, slice(&[], &[], &["b.rs"]));
assert_eq!(out.pending, 1);
assert_eq!(out.disposed, 1);
}
let on_disk = read_advance_store(root, "engine", "graph")
.unwrap()
.unwrap();
assert_eq!(on_disk.dispositions, disp(&[("a.rs", "worked")]));
let before = std::fs::read(advance_store_path(root, "engine", "graph")).unwrap();
{
let mut engine = engine_at(root);
let err = advance_baseline(
&mut engine,
root,
&resolved,
&input(&[("never-presented.rs", "worked")]),
)
.unwrap_err();
assert!(
matches!(err, AdvanceError::UnknownArtifact { .. }),
"expected UnknownArtifact, got {err:?}"
);
}
let after = std::fs::read(advance_store_path(root, "engine", "graph")).unwrap();
assert_eq!(before, after, "refused call must not touch the store");
std::fs::write(root.join("c.rs"), "cee").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "head2"]);
{
let mut engine = engine_at(root);
let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
assert!(!out.completed);
assert_eq!(
out.remainder,
slice(&["c.rs"], &[], &["b.rs"]),
"re-present = old remainder (b.rs) + new delta (c.rs); disposed a.rs absent"
);
assert_eq!(out.disposed, 1, "no new disposition this call");
}
let head2 = head_sha(root);
{
let mut engine = engine_at(root);
let out = advance_baseline(
&mut engine,
root,
&resolved,
&input(&[("b.rs", "worked"), ("c.rs", "worked")]),
)
.unwrap();
assert!(out.completed, "every artifact disposed → complete");
assert_eq!(out.pending, 0);
assert_eq!(out.tokens_written, vec![synced_key().to_string()]);
let token = engine
.mem_config_for("engine")
.and_then(|c| c.sync_state.get(synced_key()).cloned());
assert_eq!(token.as_deref(), Some(head2.as_str()));
}
assert!(
read_advance_store(root, "engine", "graph")
.unwrap()
.is_none()
);
}
#[test]
fn advance_retains_authored_exclusions_past_completion_and_clears_on_rejudge() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
git(root, &["init", "-q"]);
std::fs::write(root.join("a.rs"), "one").unwrap();
git(root, &["add", "a.rs"]);
git(root, &["commit", "-qm", "base"]);
let baseline = head_sha(root);
std::fs::write(root.join("a.rs"), "one-longer").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "head1"]);
let resolved = resolved_engine_graph();
{
let mut engine = engine_at(root);
engine
.set_mem_sync_state("engine", synced_key(), &baseline, None)
.unwrap();
}
let excluded = {
let mut m = BTreeMap::new();
m.insert(
"a.rs".to_string(),
DispositionInput::Reasoned {
disposition: EXCLUDED_VERDICT.to_string(),
rationale: "mined; warrants no destination entity".to_string(),
},
);
m
};
{
let mut engine = engine_at(root);
let out = advance_baseline(&mut engine, root, &resolved, &excluded).unwrap();
assert!(out.completed, "the sole slice artifact was disposed");
}
let retained = read_advance_store(root, "engine", "graph")
.unwrap()
.expect("an authored exclusion keeps the store alive past completion");
assert!(
retained.frozen_slice == Slice::default() && retained.dispositions.is_empty(),
"transient progress is dropped on completion"
);
assert_eq!(
retained.exclusions.get("a.rs").map(String::as_str),
Some("mined; warrants no destination entity"),
"the durable exclusion + its rationale persist"
);
std::fs::write(root.join("a.rs"), "one-longer-still").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "head2"]);
{
let mut engine = engine_at(root);
let out = advance_baseline(&mut engine, root, &resolved, &input(&[("a.rs", "worked")]))
.unwrap();
assert!(out.completed);
}
assert!(
read_advance_store(root, "engine", "graph")
.unwrap()
.is_none(),
"re-judging the artifact cleared the exclusion; nothing durable remains"
);
}
#[test]
fn advance_unknown_artifact_names_dialect_and_suggests_corrected_id() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
git(root, &["init", "-q"]);
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::write(root.join("sub").join("a.rs"), "one").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "base"]);
let baseline = head_sha(root);
std::fs::write(root.join("sub").join("a.rs"), "one-longer").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "head1"]);
let mut resolved = resolved_engine_graph();
if let ResolvedSource::Primary(p) = &mut resolved.sources[0] {
p.pointer = "sub".to_string();
}
{
let mut engine = engine_at(root);
engine
.set_mem_sync_state("engine", synced_key(), &baseline, None)
.unwrap();
}
{
let mut engine = engine_at(root);
let err = advance_baseline(
&mut engine,
root,
&resolved,
&input(&[("a.rs", "worked"), ("zzz.rs", "worked")]),
)
.unwrap_err();
let AdvanceError::UnknownArtifact {
artifacts,
suggestions,
..
} = &err
else {
panic!("expected UnknownArtifact, got {err:?}");
};
assert_eq!(artifacts, &vec!["a.rs".to_string(), "zzz.rs".to_string()]);
assert_eq!(
suggestions,
&vec![("a.rs".to_string(), "sub/a.rs".to_string())],
"only the medium-relative id gets a corrected form; zzz.rs has none"
);
let msg = err.to_string();
assert!(
msg.contains("workspace-relative"),
"names the dialect: {msg}"
);
assert!(
msg.contains("`a.rs` → `sub/a.rs`"),
"carries the concrete corrected id: {msg}"
);
assert!(
msg.contains("never accepted"),
"states the dialect does not widen: {msg}"
);
}
assert!(
read_advance_store(root, "engine", "graph")
.unwrap()
.is_none(),
"a refused call must not create the advance store"
);
{
let mut engine = engine_at(root);
let out = advance_baseline(
&mut engine,
root,
&resolved,
&input(&[("sub/a.rs", "worked")]),
)
.unwrap();
assert!(out.completed, "the sole slice artifact was disposed");
}
}
#[test]
fn record_exclusions_gates_on_source_membership_and_merges() {
let tmp = TempDir::new().unwrap();
let root = tmp.path();
git(root, &["init", "-q"]);
std::fs::write(root.join("a.rs"), "one").unwrap();
std::fs::write(root.join("b.rs"), "two").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "base"]);
let resolved = resolved_engine_graph();
let out = record_exclusions(
root,
&resolved,
&BTreeMap::from([("a.rs".to_string(), "mined; no entity".to_string())]),
)
.unwrap();
assert_eq!((out.added, out.excluded), (1, 1));
let state = read_advance_store(root, "engine", "graph")
.unwrap()
.unwrap();
assert_eq!(
state.exclusions.get("a.rs").map(String::as_str),
Some("mined; no entity")
);
let err = record_exclusions(
root,
&resolved,
&BTreeMap::from([("does-not-exist.rs".to_string(), "x".to_string())]),
)
.unwrap_err();
assert!(
matches!(err, ExcludeError::NotSourceMember { .. }),
"got {err:?}"
);
assert_eq!(
read_advance_store(root, "engine", "graph")
.unwrap()
.unwrap()
.exclusions
.len(),
1,
"refused call left the ledger unchanged"
);
let out2 = record_exclusions(
root,
&resolved,
&BTreeMap::from([("b.rs".to_string(), "also mined".to_string())]),
)
.unwrap();
assert_eq!((out2.added, out2.excluded), (1, 2));
}
#[test]
fn disposition_input_parses_bare_and_reasoned_forms() {
let map: BTreeMap<String, DispositionInput> = serde_json::from_str(
r#"{"a.rs": "worked", "b.rs": {"disposition": "excluded", "rationale": "generated"}}"#,
)
.unwrap();
assert_eq!(map["a.rs"].verdict(), "worked");
assert_eq!(map["a.rs"].rationale(), None);
assert_eq!(map["b.rs"].verdict(), EXCLUDED_VERDICT);
assert_eq!(map["b.rs"].rationale(), Some("generated"));
}
#[test]
fn advance_auto_worked_from_anchors_subtracts_slice_and_never_fabricates() {
use crate::vcs::Actor;
use indexmap::IndexMap;
let tmp = TempDir::new().unwrap();
let root = tmp.path();
git(root, &["init", "-q"]);
std::fs::write(root.join(".keep"), "x").unwrap();
git(root, &["add", ".keep"]);
git(root, &["commit", "-qm", "base"]);
let baseline = head_sha(root);
let resolved = resolved_engine_graph();
{
let mut engine = engine_at(root);
engine
.set_mem_sync_state("engine", synced_key(), &baseline, None)
.unwrap();
}
std::fs::write(root.join("a.rs"), "one").unwrap();
std::fs::write(root.join("b.rs"), "bee").unwrap();
git(root, &["add", "a.rs", "b.rs"]);
git(root, &["commit", "-qm", "head1"]);
let make_anchor = |artifact: &str| crate::anchor::AnchorInput {
artifact: Some(artifact.to_string()),
grain: Some("file".to_string()),
class: Some("anchored".to_string()),
hash: Some("h".to_string()),
hash_stability: Some("stable".to_string()),
..Default::default()
};
let mut sections = IndexMap::new();
sections.insert("identity".to_string(), "Covers a.".to_string());
sections.insert("purpose".to_string(), "Track a.rs.".to_string());
{
let mut engine = engine_at(root);
engine
.create_entity(
crate::CreateEntityArgs {
mem: "engine".to_string(),
title: "Covers A".to_string(),
entity_type: "spec".to_string(),
sections,
metadata: IndexMap::new(),
relations: Vec::new(),
anchors: vec![make_anchor("a.rs"), make_anchor("zzz.rs")],
dry_run: false,
},
Actor::Agent,
None,
Some("anchored write"),
)
.unwrap();
}
{
let mut engine = engine_at(root);
let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
assert!(!out.completed, "b.rs still pending");
assert_eq!(
out.remainder,
slice(&["b.rs"], &[], &[]),
"a.rs auto-worked from its anchor; zzz.rs never became a slice member"
);
assert_eq!(out.disposed, 1, "only a.rs auto-worked");
assert_eq!(out.pending, 1);
}
std::fs::write(root.join("c.rs"), "cee").unwrap();
git(root, &["add", "-A"]);
git(root, &["commit", "-qm", "head2"]);
{
let mut engine = engine_at(root);
let out = advance_baseline(&mut engine, root, &resolved, &BTreeMap::new()).unwrap();
assert_eq!(
out.remainder,
slice(&["b.rs", "c.rs"], &[], &[]),
"auto-worked a.rs absent; unanchored b.rs + new c.rs pending"
);
assert_eq!(
out.disposed, 1,
"still only a.rs auto-worked; c.rs unanchored"
);
assert!(!out.completed);
}
}
}