use memstead_base::mem_management::{CreateRuleSet, DeleteRuleSet};
use crate::FullEngineError;
pub const NOTE_MAX_LEN: usize = 280;
fn now_iso_utc() -> String {
let dur = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
let total_secs = dur.as_secs();
let days = total_secs / 86_400;
let rem = total_secs - days * 86_400;
let hours = rem / 3_600;
let mins = (rem % 3_600) / 60;
let secs = rem % 60;
let (year, month, day) = days_to_ymd(days);
format!("{year:04}-{month:02}-{day:02}T{hours:02}:{mins:02}:{secs:02}Z")
}
enum ResidueProbe {
None,
Present {
branch_ref: String,
config_blob: Option<String>,
existing_config: Option<Box<memstead_schema::config::MemConfig>>,
},
}
fn residue_probe_for_workspace(
engine: &memstead_base::Engine,
workspace_root: Option<&std::path::Path>,
branch_full_path: &str,
mem_name: &str,
canonical_schema_ref: &memstead_schema::SchemaRef,
) -> ResidueProbe {
let Some(root) = workspace_root else {
return ResidueProbe::None;
};
let gitdir = root.join("mem-repo").join(".git");
if !gitdir.is_dir() {
return ResidueProbe::None;
}
let canonical_gitdir = gitdir.canonicalize().unwrap_or(gitdir);
let probe_mount = memstead_base::workspace::Mount {
migration_target: None,
mem: mem_name.to_string(),
schema: Some(canonical_schema_ref.clone()),
storage: memstead_base::workspace::MountStorage::GitBranch {
gitdir: canonical_gitdir,
branch: format!("refs/heads/{branch_full_path}"),
},
capability: memstead_base::workspace::MountCapability::Write,
lifecycle: memstead_base::workspace::MountLifecycle::Eager,
cross_linkable: true,
};
let factory = engine.backend_factory();
let backend = match factory(&probe_mount) {
Ok(b) => b,
Err(_) => return ResidueProbe::None,
};
let bytes = match backend.read_mem_config() {
Ok(Some(b)) => b,
Ok(None) | Err(_) => return ResidueProbe::None,
};
let existing_config = serde_json::from_slice::<memstead_schema::config::MemConfig>(&bytes)
.ok()
.map(Box::new);
ResidueProbe::Present {
branch_ref: format!("refs/heads/{branch_full_path}"),
config_blob: Some(format!("__MEMSTEAD:mems/{branch_full_path}/config.json")),
existing_config,
}
}
fn days_to_ymd(days: u64) -> (u64, u64, u64) {
let z = days + 719_468;
let era = z / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
#[derive(Debug, Clone)]
pub struct MemDeleteParams {
pub name: String,
pub delete_files: bool,
pub note: Option<String>,
pub operator_mode: bool,
pub detach_incoming: bool,
}
#[derive(Debug, Clone)]
pub struct MemDeleteResponse {
pub name: String,
pub deleted_from_router: bool,
pub files_deleted: bool,
pub warnings: Vec<memstead_base::ops::WarningHint>,
pub allowlist_entries_removed: Vec<AllowlistEntryRemoved>,
pub detached_referrers: Vec<memstead_base::ReferrerInfo>,
}
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
pub struct AllowlistEntryRemoved {
pub table: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub pattern: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub from: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub to: Option<String>,
}
pub fn delete_mem(
engine: &mut memstead_base::Engine,
params: MemDeleteParams,
) -> Result<MemDeleteResponse, FullEngineError> {
if let Some(note) = params.note.as_deref()
&& note.chars().count() > NOTE_MAX_LEN
{
return Err(memstead_base::EngineError::InvalidInput(format!(
"note exceeds {NOTE_MAX_LEN} characters"
))
.into());
}
let mut detached_referrers: Vec<memstead_base::ReferrerInfo> = Vec::new();
if !engine.mem_router().is_writable(¶ms.name) {
return Err(memstead_base::EngineError::UnknownMem(params.name.clone()).into());
}
let mem_dir: Option<std::path::PathBuf> = engine
.mem_router()
.dir_for_mem(¶ms.name)
.map(|p| p.to_path_buf());
if !params.operator_mode {
let attempted = mem_dir
.clone()
.unwrap_or_else(|| std::path::PathBuf::from(format!("(mem: {})", params.name)));
let candidate: String = params.name.clone();
let delete_rule_set = DeleteRuleSet::new(engine.settings().mem_delete_rules.clone())
.map_err(|e| {
memstead_base::EngineError::InvalidInput(format!("mem_delete_rules: {e}"))
})?;
let patterns_for_errors: Vec<String> = delete_rule_set.patterns();
if delete_rule_set.is_empty() {
return Err(FullEngineError::MemPathNotAllowed {
attempted,
candidate,
patterns: patterns_for_errors,
reason: "no_allowlist_configured",
policy_table: "mem_management.delete",
});
}
if delete_rule_set
.first_match(std::path::Path::new(&candidate))
.is_none()
{
return Err(FullEngineError::MemPathNotAllowed {
attempted,
candidate,
patterns: patterns_for_errors,
reason: "no_match",
policy_table: "mem_management.delete",
});
}
}
if params.delete_files {
use memstead_schema::workspace_config::CrossLinkValue;
let mut referring_mems: Vec<String> = engine
.settings()
.cross_mem_links
.iter()
.filter_map(|(referring, policy)| {
if referring == ¶ms.name {
return None;
}
match policy {
CrossLinkValue::List(targets) => {
if targets.iter().any(|t| t == ¶ms.name) {
Some(referring.clone())
} else {
None
}
}
_ => None,
}
})
.collect();
referring_mems.sort();
referring_mems.dedup();
if !referring_mems.is_empty() {
return Err(FullEngineError::MemReferencedByPolicy {
name: params.name,
referring_mems,
});
}
}
{
use std::collections::BTreeSet;
let store = engine.store();
let mut by_source: std::collections::BTreeMap<
String,
(memstead_base::EntityId, BTreeSet<String>),
> = std::collections::BTreeMap::new();
let doomed_mem = params.name.as_str();
for entity in store.all_entities() {
if entity.mem != doomed_mem {
continue;
}
for in_edge in store.incoming(&entity.id) {
if in_edge.from.mem() == doomed_mem {
continue;
}
let is_writable = engine.mem_router().is_writable(in_edge.from.mem());
if !is_writable {
continue;
}
by_source
.entry(in_edge.from.to_string())
.or_insert_with(|| (in_edge.from.clone(), BTreeSet::new()))
.1
.insert(in_edge.rel_type.clone());
}
}
if !by_source.is_empty() {
let referrers: Vec<memstead_base::ReferrerInfo> = by_source
.into_values()
.map(|(from, rel_types)| memstead_base::ReferrerInfo {
from_id: from.to_string(),
rel_types: rel_types.into_iter().collect(),
mem: from.mem().to_string(),
})
.collect();
if params.detach_incoming {
detached_referrers = referrers;
} else {
return Err(memstead_base::EngineError::MemHasIncomingRefs {
mem: params.name,
referrers,
}
.into());
}
}
}
let removed_backend = engine.unregister_writable_mem(¶ms.name)?;
let backend =
removed_backend.expect("mem_router().is_writable check above guarantees a present mount");
if !params.delete_files {
match backend.read_mem_config() {
Ok(Some(bytes)) => {
match serde_json::from_slice::<memstead_schema::config::MemConfig>(&bytes) {
Ok(mut cfg) => {
cfg.unregistered_at = Some(now_iso_utc());
match serde_json::to_vec_pretty(&cfg) {
Ok(mut new_bytes) => {
new_bytes.push(b'\n');
if let Err(e) = backend.write_mem_config(&new_bytes) {
tracing::warn!(
mem = %params.name,
error = %e,
"delete_mem: unregister succeeded but tombstone \
write failed — re-init will require an explicit \
--reattach flag"
);
}
}
Err(e) => tracing::warn!(
mem = %params.name,
error = %e,
"delete_mem: tombstone serialize failed",
),
}
}
Err(e) => tracing::warn!(
mem = %params.name,
error = %e,
"delete_mem: tombstone-write skipped — config blob did not \
parse as MemConfig",
),
}
}
Ok(None) => {
}
Err(e) => tracing::warn!(
mem = %params.name,
error = %e,
"delete_mem: tombstone-read skipped — backend read_mem_config errored",
),
}
}
let mut warnings: Vec<memstead_base::ops::WarningHint> = Vec::new();
let files_deleted = if params.delete_files {
let backend_ok = match backend.delete_artifacts() {
Ok(()) => true,
Err(e) => {
tracing::warn!(
mem = %params.name,
error = %e,
"delete_mem: unregister succeeded but backend artifact \
cleanup failed — leaving leftover refs / tree entries \
for explicit cleanup"
);
warnings.push(memstead_base::ops::WarningHint::MemFilesNotDeleted {
mem: params.name.clone(),
reason: "backend_prune_failed".into(),
path: None,
error: Some(e.to_string()),
});
false
}
};
let dir_ok = match mem_dir.as_ref() {
Some(dir) => match std::fs::remove_dir_all(dir) {
Ok(()) => true,
Err(e) => {
tracing::warn!(
mem = %params.name,
path = %dir.display(),
error = %e,
"delete_mem: unregister succeeded but rmdir failed — \
leaving leftover files for explicit cleanup"
);
warnings.push(memstead_base::ops::WarningHint::MemFilesNotDeleted {
mem: params.name.clone(),
reason: "rmdir_failed".into(),
path: Some(dir.display().to_string()),
error: Some(e.to_string()),
});
false
}
},
None => true,
};
backend_ok && dir_ok
} else {
false
};
engine.persist_state()?;
let mut allowlist_entries_removed: Vec<AllowlistEntryRemoved> = Vec::new();
if params.delete_files
&& let Some(root) = engine.workspace_root().map(|p| p.to_path_buf())
{
match crate::workspace_config_edit::scrub_policy_for_deleted_mem(&root, ¶ms.name) {
Err(e) => {
tracing::warn!(
mem = %params.name,
error = %e,
"delete_mem: destructive delete committed but policy \
scrub failed — `.memstead/workspace.toml` may still \
reference the deleted mem"
);
}
Ok(scrubbed) => {
allowlist_entries_removed = scrubbed
.into_iter()
.map(|e| match e {
crate::workspace_config_edit::ScrubbedEntry::CrossLink { from, to } => {
AllowlistEntryRemoved {
table: "cross_mem_links".to_string(),
pattern: None,
from: Some(from),
to: Some(to),
}
}
})
.collect();
let store = memstead_base::workspace_store::FileWorkspaceStore::new();
if let Ok(ws) = <memstead_base::workspace_store::FileWorkspaceStore as memstead_base::workspace_store::WorkspaceStoreAdapter>::load(
&store,
&root,
) {
engine.set_settings(ws.settings);
}
}
}
}
if let Some(w) = engine.note_missing_warning("delete_mem", params.note.as_deref()) {
warnings.push(w);
}
Ok(MemDeleteResponse {
name: params.name,
deleted_from_router: true,
files_deleted,
warnings,
allowlist_entries_removed,
detached_referrers,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StorageKind {
Folder,
GitBranch,
}
#[derive(Debug, Clone)]
pub struct MemCreateParams {
pub name: String,
pub location: std::path::PathBuf,
pub schema_ref: memstead_schema::SchemaRef,
pub vcs: Option<memstead_schema::VcsConfig>,
pub note: Option<String>,
pub operator_mode: bool,
pub recovery: Option<crate::RecoveryAction>,
pub write_guidance: std::collections::HashMap<String, serde_json::Value>,
pub storage: Option<StorageKind>,
pub actor: memstead_base::vcs::Actor,
pub client: Option<memstead_base::vcs::ClientId>,
}
#[derive(Debug, Clone)]
pub struct MemCreateResponse {
pub name: String,
pub location: std::path::PathBuf,
pub schema_ref: memstead_schema::SchemaRef,
pub seed_commit_sha: String,
pub warnings: Vec<memstead_base::ops::WarningHint>,
}
fn classify_invalid_mem_name(name: &str) -> Option<&'static str> {
if name.is_empty() {
return Some("empty");
}
if name.chars().any(char::is_whitespace) {
return Some("whitespace");
}
if name.split('/').any(|seg| seg.starts_with("__")) {
return Some("reserved_prefix");
}
None
}
pub fn create_mem(
engine: &mut memstead_base::Engine,
mut params: MemCreateParams,
) -> Result<MemCreateResponse, FullEngineError> {
use std::path::Path;
if let Some(note) = params.note.as_deref()
&& note.chars().count() > NOTE_MAX_LEN
{
return Err(memstead_base::EngineError::InvalidInput(format!(
"note exceeds {NOTE_MAX_LEN} characters"
))
.into());
}
if let Some(reason) = classify_invalid_mem_name(¶ms.name) {
return Err(FullEngineError::InvalidMemName {
name: params.name.clone(),
reason,
});
}
if memstead_base::entity::id::validate_mem_name_grammar(¶ms.name).is_err() {
return Err(FullEngineError::InvalidMemName {
name: params.name.clone(),
reason: "invalid_char",
});
}
let mut builtin_schemas: Vec<std::sync::Arc<memstead_schema::Schema>> =
engine.workspace_schemas().to_vec();
builtin_schemas.extend_from_slice(engine.builtin_schemas());
let resolved_schema = memstead_base::engine::SchemaResolver::new(&builtin_schemas)
.resolve(¶ms.schema_ref)
.map_err(|sources| {
memstead_base::EngineError::SchemaNotFound {
mem: params.name.clone(),
pin: params.schema_ref.to_string(),
sources,
install_hint: None,
}
.with_schema_install_probe(engine.workspace_root())
})?;
let canonical_schema_ref = memstead_schema::SchemaRef::new(
resolved_schema.manifest.name.clone(),
resolved_schema.version.clone(),
);
params.schema_ref = canonical_schema_ref.clone();
let workspace_root = engine.workspace_root().map(|p| p.to_path_buf());
let absolute = if params.location.is_absolute() {
params.location.clone()
} else if let Some(root) = workspace_root.as_ref() {
root.join(¶ms.location)
} else {
params.location.clone()
};
let canonical = canonicalize_maybe_missing(&absolute);
let mount_path = if workspace_root.is_some() {
absolute.clone()
} else {
canonical.clone()
};
let candidate: String = params.name.clone();
if !params.operator_mode {
let create_rule_set = CreateRuleSet::new(engine.settings().mem_create_rules.clone())
.map_err(|e| {
memstead_base::EngineError::InvalidInput(format!("mem_create_rules: {e}"))
})?;
let patterns_for_errors: Vec<String> = create_rule_set.patterns();
if create_rule_set.is_empty() {
return Err(FullEngineError::MemPathNotAllowed {
attempted: canonical.clone(),
candidate,
patterns: patterns_for_errors,
reason: "no_allowlist_configured",
policy_table: "mem_management.create",
});
}
let matched_rule = match create_rule_set.first_match(Path::new(&candidate)) {
Some(r) => r.clone(),
None => {
return Err(FullEngineError::MemPathNotAllowed {
attempted: canonical.clone(),
candidate,
patterns: patterns_for_errors,
reason: "no_match",
policy_table: "mem_management.create",
});
}
};
if let Some(root) = workspace_root.as_ref()
&& canonical.strip_prefix(root).is_err()
{
return Err(FullEngineError::MemPathNotAllowed {
attempted: canonical.clone(),
candidate,
patterns: patterns_for_errors,
reason: "outside_workspace",
policy_table: "mem_management.create",
});
}
let schema_wildcard = matched_rule
.schemas
.iter()
.any(|s| s == memstead_base::SCHEMA_WILDCARD);
if !schema_wildcard {
let requested_canonical = canonical_schema_ref.to_string();
let mut allowed_canonical: Vec<String> = Vec::with_capacity(matched_rule.schemas.len());
let mut allowed = false;
for raw in &matched_rule.schemas {
let parsed: memstead_schema::SchemaRef = match raw.parse() {
Ok(r) => r,
Err(_) => {
return Err(memstead_base::EngineError::InvalidInput(format!(
"[mem_management] rule {:?}: schema entry {:?} is not a valid `name@version` pin",
matched_rule.pattern, raw,
))
.into());
}
};
let resolved = memstead_base::engine::SchemaResolver::new(&builtin_schemas)
.resolve(&parsed)
.map_err(|sources| {
memstead_base::EngineError::SchemaNotFound {
mem: params.name.clone(),
pin: parsed.to_string(),
sources,
install_hint: None,
}
.with_schema_install_probe(engine.workspace_root())
})?;
let canon_str = memstead_schema::SchemaRef::new(
resolved.manifest.name.clone(),
resolved.version.clone(),
)
.to_string();
if canon_str == requested_canonical {
allowed = true;
}
allowed_canonical.push(canon_str);
}
if !allowed {
return Err(FullEngineError::MemSchemaNotAllowed {
candidate,
matched_pattern: matched_rule.pattern.clone(),
requested_schema: requested_canonical,
allowed_schemas: allowed_canonical,
});
}
}
}
let workspace_has_mem_repo = workspace_root
.as_ref()
.map(|root| root.join("mem-repo").join(".git").is_dir())
.unwrap_or(false);
let storage_kind = match params.storage {
Some(k) => k,
None => {
if workspace_has_mem_repo {
StorageKind::GitBranch
} else {
StorageKind::Folder
}
}
};
if storage_kind == StorageKind::GitBranch && !workspace_has_mem_repo {
return Err(memstead_base::EngineError::InvalidInput(
"storage: git-branch requires a mem-repo workspace \
(<workspace_root>/mem-repo/.git/ not found) — omit the \
override or pass storage: folder"
.to_string(),
)
.into());
}
if storage_kind == StorageKind::Folder {
let target_basename = canonical
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let name_leaf = params
.name
.rsplit('/')
.next()
.unwrap_or(params.name.as_str());
if target_basename != name_leaf {
return Err(memstead_base::EngineError::InvalidInput(format!(
"mem name '{}' (leaf '{}') does not match the basename '{}' of the canonical location '{}' \
— rename either side so the registered identity's leaf matches the on-disk basename",
params.name,
name_leaf,
target_basename,
canonical.display()
))
.into());
}
}
if let Some(existing) = engine.mem_router().origin_for_mem(¶ms.name) {
return Err(memstead_base::EngineError::MemNameCollision {
name: params.name,
source_origin: existing.render_source(),
}
.into());
}
if engine
.mem_router()
.archive_path_for_mem(¶ms.name)
.is_some()
{
return Err(memstead_base::EngineError::MemNameCollision {
name: params.name,
source_origin: "attached read mem".to_string(),
}
.into());
}
let composed_branch_leaf = params.name.clone();
let residue_probe = if storage_kind == StorageKind::Folder {
ResidueProbe::None
} else {
residue_probe_for_workspace(
engine,
workspace_root.as_deref(),
&composed_branch_leaf,
¶ms.name,
&canonical_schema_ref,
)
};
let _: Option<memstead_base::ops::WarningHint> = match residue_probe {
ResidueProbe::None => None,
ResidueProbe::Present {
branch_ref,
config_blob,
existing_config,
} => {
let tombstone = existing_config
.as_ref()
.and_then(|c| c.unregistered_at.clone());
let effective_action = params
.recovery
.or_else(|| tombstone.as_ref().map(|_| crate::RecoveryAction::Reattach));
match effective_action {
None => {
return Err(FullEngineError::MemStorageResidueDetected {
branch_ref,
config_blob,
entity_count: 0,
});
}
Some(crate::RecoveryAction::HardCleanupFirst) => {
return Err(FullEngineError::MemStorageResidueDetected {
branch_ref,
config_blob,
entity_count: 0,
});
}
Some(crate::RecoveryAction::ForceOverwrite) => {
let workspace_root_ref = workspace_root.as_ref().ok_or_else(|| {
memstead_base::EngineError::InvalidInput(
"force_overwrite requires a workspace_root \
to locate mem-repo/.git/"
.to_string(),
)
})?;
let gitdir = workspace_root_ref.join("mem-repo").join(".git");
let canonical_gitdir = gitdir.canonicalize().unwrap_or(gitdir);
let ops = engine.git_branch_ops().ok_or_else(|| {
memstead_base::EngineError::InvalidInput(
"force_overwrite requires the git-branch ops \
bundle (full boot only) — folder workspaces \
have no branch residue to prune"
.to_string(),
)
})?;
(ops.prune_residue)(&canonical_gitdir, &composed_branch_leaf).map_err(|e| {
memstead_base::EngineError::Mem(format!("force_overwrite prune: {e}"))
})?;
None
}
Some(crate::RecoveryAction::Reattach) => {
let workspace_root_ref = workspace_root.as_ref().ok_or_else(|| {
memstead_base::EngineError::InvalidInput(
"reattach requires a workspace_root \
to locate mem-repo/.git/"
.to_string(),
)
})?;
let gitdir = workspace_root_ref.join("mem-repo").join(".git");
let canonical_gitdir = gitdir.canonicalize().unwrap_or(gitdir);
let mount = memstead_base::workspace::Mount {
migration_target: None,
mem: params.name.clone(),
schema: Some(canonical_schema_ref.clone()),
storage: memstead_base::workspace::MountStorage::GitBranch {
gitdir: canonical_gitdir,
branch: format!("refs/heads/{composed_branch_leaf}"),
},
capability: memstead_base::workspace::MountCapability::Write,
lifecycle: memstead_base::workspace::MountLifecycle::Eager,
cross_linkable: true,
};
let factory = engine.backend_factory();
let backend = factory(&mount).map_err(|e| {
memstead_base::EngineError::Mem(format!(
"reattach backend instantiate: {e}"
))
})?;
if let Some(cfg) = existing_config.as_ref()
&& cfg.unregistered_at.is_some()
{
let mut updated = cfg.clone();
updated.unregistered_at = None;
if let Ok(mut bytes) = serde_json::to_vec_pretty(&updated) {
bytes.push(b'\n');
if let Err(e) = backend.write_mem_config(&bytes) {
tracing::warn!(
mem = %params.name,
error = %e,
"reattach: tombstone clear failed — \
the marker survives; a re-unregister will \
overwrite it",
);
}
}
}
let origin = memstead_base::MemOrigin::RuntimeCreated {
at: std::time::SystemTime::now(),
by_tool: "memstead_mem_create (reattach)",
};
engine.register_writable_mem(mount, backend, origin)?;
engine.persist_state()?;
let _ = engine.reload_each_writable_mem_reports()?;
let mut warnings: Vec<memstead_base::ops::WarningHint> = Vec::new();
if let Some(ts) = tombstone {
warnings.push(
memstead_base::ops::WarningHint::MemReattachedAfterUnregister {
mem: params.name.clone(),
unregistered_at: ts,
},
);
}
return Ok(MemCreateResponse {
name: params.name,
location: canonical,
schema_ref: canonical_schema_ref,
seed_commit_sha: String::new(),
warnings,
});
}
}
}
};
let mem_config = memstead_schema::config::MemConfig {
review_mark: None,
mutation_stamp: None,
name: None,
title: None,
subject: None,
version: Some(semver::Version::new(0, 1, 0)),
description: None,
authors: None,
schema: Some(canonical_schema_ref.clone()),
write_guidance: params.write_guidance.clone(),
process_mem: None,
rules: None,
publish: None,
language: None,
read_mems: Default::default(),
community: None,
vcs: params.vcs.clone(),
unregistered_at: None,
sync_state: Default::default(),
extra: Default::default(),
};
let config_bytes = serde_json::to_vec_pretty(&mem_config).map_err(|e| {
memstead_base::EngineError::InvalidInput(format!("could not serialize mem config: {e}"))
})?;
let branch_leaf = params.name.clone();
let storage = match storage_kind {
StorageKind::GitBranch => {
let root = workspace_root.as_ref().ok_or_else(|| {
memstead_base::EngineError::InvalidInput(
"storage: git-branch requires a workspace_root \
to locate mem-repo/.git/"
.to_string(),
)
})?;
let probe = root.join("mem-repo").join(".git");
let gitdir = probe.canonicalize().unwrap_or(probe);
memstead_base::workspace::MountStorage::GitBranch {
gitdir,
branch: format!("refs/heads/{branch_leaf}"),
}
}
StorageKind::Folder => memstead_base::workspace::MountStorage::Folder {
path: mount_path.clone(),
},
};
let is_git_branch = matches!(
storage,
memstead_base::workspace::MountStorage::GitBranch { .. }
);
if !is_git_branch {
std::fs::create_dir_all(&canonical).map_err(|e| {
memstead_base::EngineError::Mem(format!("create_dir_all {}: {e}", canonical.display()))
})?;
let memstead_dir = canonical.join(memstead_base::MEM_META_DIR);
std::fs::create_dir_all(&memstead_dir).map_err(|e| {
memstead_base::EngineError::Mem(format!(
"create_dir_all {}: {e}",
memstead_dir.display()
))
})?;
let config_path = memstead_dir.join("config.json");
if config_path.exists() {
return Err(FullEngineError::ConfigAlreadyExists { path: config_path });
}
std::fs::write(&config_path, &config_bytes).map_err(|e| {
memstead_base::EngineError::Mem(format!("write {}: {e}", config_path.display()))
})?;
}
let mount = memstead_base::workspace::Mount {
mem: params.name.clone(),
schema: Some(canonical_schema_ref.clone()),
storage,
capability: memstead_base::workspace::MountCapability::Write,
lifecycle: memstead_base::workspace::MountLifecycle::Eager,
cross_linkable: true,
migration_target: None,
};
let factory = engine.backend_factory();
let backend = factory(&mount)
.map_err(|e| memstead_base::EngineError::Mem(format!("instantiate backend: {e}")))?;
let seed_ctx = memstead_base::vcs::CommitContext {
actor: params.actor,
client: params.client.clone(),
tool: Some("memstead_mem_create"),
note: params.note.clone(),
role: Default::default(),
logical_operation_id: None,
entity_ids: None,
};
if is_git_branch {
backend
.write_mem_config(&config_bytes)
.map_err(|e| memstead_base::EngineError::Mem(format!("write mem config: {e}")))?;
}
let seed_commit_sha = backend
.commit(&format!("memstead: create mem {}", params.name), &seed_ctx)
.map_err(|e| memstead_base::EngineError::Mem(format!("seed commit: {e}")))?;
let origin = memstead_base::MemOrigin::RuntimeCreated {
at: std::time::SystemTime::now(),
by_tool: "memstead_mem_create",
};
engine.register_writable_mem(mount, backend, origin)?;
engine.persist_state()?;
let note_warning = engine.note_missing_warning("create_mem", params.note.as_deref());
let mut warnings: Vec<memstead_base::ops::WarningHint> = note_warning.into_iter().collect();
if storage_kind == StorageKind::Folder {
warnings.push(memstead_base::ops::WarningHint::FolderMemProvenance {
mem: params.name.clone(),
});
}
Ok(MemCreateResponse {
name: params.name,
location: canonical,
schema_ref: canonical_schema_ref,
seed_commit_sha,
warnings,
})
}
fn canonicalize_maybe_missing(path: &std::path::Path) -> std::path::PathBuf {
if let Ok(c) = path.canonicalize() {
return c;
}
let mut tail: Vec<std::ffi::OsString> = Vec::new();
let mut cursor: &std::path::Path = path;
loop {
if let Ok(c) = cursor.canonicalize() {
let mut out = c;
for seg in tail.iter().rev() {
out.push(seg);
}
return out;
}
match cursor.file_name() {
Some(name) => {
tail.push(name.to_os_string());
match cursor.parent() {
Some(parent) => cursor = parent,
None => return path.to_path_buf(),
}
}
None => return path.to_path_buf(),
}
}
}
#[derive(Debug, Clone)]
pub struct MemRenameParams {
pub old: String,
pub new: String,
pub operator_mode: bool,
pub note: Option<String>,
}
#[derive(Debug, Clone)]
pub struct MemRenameResponse {
pub old: String,
pub new: String,
pub rewritten_mems: Vec<String>,
pub resumed: bool,
pub warnings: Vec<memstead_base::ops::WarningHint>,
}
pub fn rename_mem(
engine: &mut memstead_base::Engine,
params: MemRenameParams,
) -> Result<MemRenameResponse, FullEngineError> {
if let Some(note) = params.note.as_deref()
&& note.chars().count() > NOTE_MAX_LEN
{
return Err(memstead_base::EngineError::InvalidInput(format!(
"note exceeds {NOTE_MAX_LEN} characters"
))
.into());
}
if params.old == params.new {
return Err(memstead_base::EngineError::InvalidInput(
"rename source and target are the same name".to_string(),
)
.into());
}
if let Some(reason) = classify_invalid_mem_name(¶ms.new) {
return Err(FullEngineError::InvalidMemName {
name: params.new.clone(),
reason,
});
}
if memstead_base::entity::id::validate_mem_name_grammar(¶ms.new).is_err() {
return Err(FullEngineError::InvalidMemName {
name: params.new.clone(),
reason: "invalid_char",
});
}
let old_mount = engine.mount(¶ms.old).cloned();
let new_mount_present = engine.mount(¶ms.new).is_some();
let resumed = match (&old_mount, new_mount_present) {
(Some(_), true) => {
return Err(memstead_base::EngineError::MemNameCollision {
name: params.new.clone(),
source_origin: "registered mount".to_string(),
}
.into());
}
(Some(m), false) => {
if m.capability != memstead_base::MountCapability::Write {
return Err(memstead_base::EngineError::ReadOnlyMount(params.old.clone()).into());
}
false
}
(None, true) => {
if !engine.mem_router().is_writable(¶ms.new) {
return Err(memstead_base::EngineError::UnknownMem(params.old.clone()).into());
}
true
}
(None, false) => {
return Err(memstead_base::EngineError::UnknownMem(params.old.clone()).into());
}
};
if !resumed && !params.operator_mode {
let attempted = std::path::PathBuf::from(format!("(mem: {})", params.old));
let delete_rule_set = DeleteRuleSet::new(engine.settings().mem_delete_rules.clone())
.map_err(|e| {
memstead_base::EngineError::InvalidInput(format!("mem_delete_rules: {e}"))
})?;
let delete_patterns: Vec<String> = delete_rule_set.patterns();
if delete_rule_set.is_empty()
|| delete_rule_set
.first_match(std::path::Path::new(¶ms.old))
.is_none()
{
let reason = if delete_rule_set.is_empty() {
"no_allowlist_configured"
} else {
"no_match"
};
return Err(FullEngineError::MemPathNotAllowed {
attempted,
candidate: params.old.clone(),
patterns: delete_patterns,
reason,
policy_table: "mem_management.delete",
});
}
let create_rule_set = CreateRuleSet::new(engine.settings().mem_create_rules.clone())
.map_err(|e| {
memstead_base::EngineError::InvalidInput(format!("mem_create_rules: {e}"))
})?;
let create_patterns: Vec<String> = create_rule_set.patterns();
let matched_rule = if create_rule_set.is_empty() {
None
} else {
create_rule_set
.first_match(std::path::Path::new(¶ms.new))
.cloned()
};
let Some(matched_rule) = matched_rule else {
let reason = if create_rule_set.is_empty() {
"no_allowlist_configured"
} else {
"no_match"
};
return Err(FullEngineError::MemPathNotAllowed {
attempted: std::path::PathBuf::from(format!("(mem: {})", params.new)),
candidate: params.new.clone(),
patterns: create_patterns,
reason,
policy_table: "mem_management.create",
});
};
let schema_wildcard = matched_rule
.schemas
.iter()
.any(|s| s == memstead_base::SCHEMA_WILDCARD);
if !schema_wildcard {
let existing_pin: Option<String> = engine
.mem_configs_named()
.find(|(name, _)| *name == params.old)
.and_then(|(_, c)| c.schema.as_ref().map(|s| s.to_string()))
.or_else(|| {
old_mount
.as_ref()
.and_then(|m| m.schema.as_ref().map(|s| s.to_string()))
});
let allowed = existing_pin
.as_deref()
.is_some_and(|pin| matched_rule.schemas.iter().any(|s| s == pin));
if !allowed {
return Err(FullEngineError::MemSchemaNotAllowed {
candidate: params.new.clone(),
matched_pattern: matched_rule.pattern.clone(),
requested_schema: existing_pin.unwrap_or_else(|| "(no pin)".to_string()),
allowed_schemas: matched_rule.schemas.clone(),
});
}
}
}
let sweep = engine
.rewrite_mem_references(¶ms.old, ¶ms.new, params.note.as_deref())
.map_err(FullEngineError::from)?;
if !resumed {
let mount = old_mount.expect("normal mode implies the old mount is present");
{
let factory = engine.backend_factory();
let backend_ref = factory(&mount).map_err(|e| {
memstead_base::EngineError::Mem(format!("instantiate backend: {e}"))
})?;
let config_bytes = backend_ref
.read_mem_config()
.map_err(|e| memstead_base::EngineError::Mem(format!("read mem config: {e}")))?;
if let Some(bytes) = config_bytes
&& let Ok(mut cfg) =
serde_json::from_slice::<memstead_schema::config::MemConfig>(&bytes)
{
let old_prefix = format!("{}/", params.old);
let new_prefix = format!("{}/", params.new);
let mut changed = false;
let rewritten: std::collections::BTreeMap<String, String> = cfg
.sync_state
.into_iter()
.map(|(k, v)| match k.strip_prefix(&old_prefix) {
Some(rest) => {
changed = true;
(format!("{new_prefix}{rest}"), v)
}
None => (k, v),
})
.collect();
cfg.sync_state = rewritten;
if changed {
let mut out = serde_json::to_vec_pretty(&cfg).map_err(|e| {
memstead_base::EngineError::Mem(format!("serialise mem config: {e}"))
})?;
out.push(b'\n');
backend_ref.write_mem_config(&out).map_err(|e| {
memstead_base::EngineError::Mem(format!("write mem config: {e}"))
})?;
}
}
}
let new_storage = match &mount.storage {
memstead_base::MountStorage::GitBranch { gitdir, branch } => {
let ops = engine.git_branch_ops().ok_or_else(|| {
memstead_base::EngineError::InvalidInput(
"mem rename on a git-branch mount requires the git-branch ops \
bundle (full boot only)"
.to_string(),
)
})?;
let canonical_gitdir = gitdir.canonicalize().unwrap_or_else(|_| gitdir.clone());
let had_prefix = branch.starts_with("refs/heads/");
let old_leaf = branch.strip_prefix("refs/heads/").unwrap_or(branch);
(ops.rename_mem_storage)(&canonical_gitdir, old_leaf, ¶ms.new)
.map_err(|e| memstead_base::EngineError::Mem(format!("storage rename: {e}")))?;
let new_branch = if had_prefix {
format!("refs/heads/{}", params.new)
} else {
params.new.clone()
};
memstead_base::MountStorage::GitBranch {
gitdir: gitdir.clone(),
branch: new_branch,
}
}
other => other.clone(),
};
engine
.unregister_writable_mem(¶ms.old)
.map_err(FullEngineError::from)?;
let new_mount = memstead_base::Mount {
mem: params.new.clone(),
schema: mount.schema.clone(),
storage: new_storage,
capability: mount.capability,
lifecycle: mount.lifecycle,
cross_linkable: mount.cross_linkable,
migration_target: mount.migration_target.clone(),
};
let factory = engine.backend_factory();
let backend = factory(&new_mount)
.map_err(|e| memstead_base::EngineError::Mem(format!("instantiate backend: {e}")))?;
let origin = memstead_base::MemOrigin::RuntimeCreated {
at: std::time::SystemTime::now(),
by_tool: "memstead mem rename",
};
engine
.register_writable_mem(new_mount, backend, origin)
.map_err(FullEngineError::from)?;
engine.persist_state().map_err(FullEngineError::from)?;
}
if let Some(root) = engine.workspace_root().map(|p| p.to_path_buf()) {
crate::workspace_config_edit::rename_mem_in_cross_links(&root, ¶ms.old, ¶ms.new)
.map_err(|e| memstead_base::EngineError::Mem(format!("grants rewrite: {e}")))?;
let store_dir = root.join(memstead_base::WORKSPACE_STORE_DIR);
let projections_old = store_dir.join("projections").join(¶ms.old);
let projections_new = store_dir.join("projections").join(¶ms.new);
if projections_old.is_dir() && !projections_new.exists() {
std::fs::rename(&projections_old, &projections_new).map_err(|e| {
memstead_base::EngineError::Mem(format!("move projections dir: {e}"))
})?;
}
if projections_new.is_dir() {
for entry in std::fs::read_dir(&projections_new)
.map_err(|e| memstead_base::EngineError::Mem(format!("read projections: {e}")))?
{
let path = entry
.map_err(|e| memstead_base::EngineError::Mem(format!("read projections: {e}")))?
.path();
if path.extension().and_then(|e| e.to_str()) != Some("json") {
continue;
}
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let Ok(mut doc) = serde_json::from_str::<serde_json::Value>(&text) else {
continue;
};
if doc.get("destination_mem").and_then(|v| v.as_str()) == Some(params.old.as_str())
{
doc["destination_mem"] = serde_json::Value::String(params.new.clone());
let mut out = serde_json::to_string_pretty(&doc).unwrap_or(text);
out.push('\n');
std::fs::write(&path, out).map_err(|e| {
memstead_base::EngineError::Mem(format!("rewrite binding: {e}"))
})?;
}
}
}
let findings_old = store_dir.join("state").join("findings").join(¶ms.old);
let findings_new = store_dir.join("state").join("findings").join(¶ms.new);
if findings_old.is_dir() && !findings_new.exists() {
std::fs::rename(&findings_old, &findings_new)
.map_err(|e| memstead_base::EngineError::Mem(format!("move findings dir: {e}")))?;
}
}
let note_warning = engine.note_missing_warning("rename_mem", params.note.as_deref());
Ok(MemRenameResponse {
old: params.old,
new: params.new,
rewritten_mems: sweep.rewritten_mems,
resumed,
warnings: note_warning.into_iter().collect(),
})
}