use std::path::Path;
const PROTECTED_BRAIN_FILES: &[&str] = &[
"SOUL.md",
"USER.md",
"AGENTS.md",
"TOOLS.md",
"CODE.md",
"SECURITY.md",
"MEMORY.md",
"BOOT.md",
"IDENTITY.md",
];
pub fn is_protected_brain_file(name: &str) -> bool {
PROTECTED_BRAIN_FILES
.iter()
.any(|p| p.eq_ignore_ascii_case(name))
}
pub fn is_protected_path(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.map(is_protected_brain_file)
.unwrap_or(false)
}
pub fn backup_before_write(path: &Path) -> std::io::Result<Option<std::path::PathBuf>> {
if !path.exists() {
return Ok(None);
}
let stamp = chrono::Utc::now().format("%Y-%m-%dT%H%M%S");
let mut backup = path.as_os_str().to_owned();
backup.push(format!(".{stamp}.bak"));
let backup = std::path::PathBuf::from(backup);
std::fs::copy(path, &backup)?;
Ok(Some(backup))
}
#[derive(Debug, PartialEq, Eq)]
pub enum ShrinkCheck {
Allowed,
Rejected { message: String },
}
pub fn check_no_shrink(
path: &Path,
existing: &str,
updated: &str,
dedup_intent: bool,
) -> ShrinkCheck {
if !is_protected_path(path) {
return ShrinkCheck::Allowed;
}
if updated.len() >= existing.len() {
return ShrinkCheck::Allowed;
}
let removed_bytes = existing.len().saturating_sub(updated.len());
let label = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("brain file");
if dedup_intent && shrink_only_drops_duplicates(existing, updated) {
return ShrinkCheck::Allowed;
}
let hint = if dedup_intent {
" (dedup_intent was set, but the bytes removed do not all reappear in the result \
— that's not deduplication, that's deletion)"
} else {
""
};
ShrinkCheck::Rejected {
message: format!(
"Refusing to shrink protected brain file {label} by {removed_bytes} bytes. \
Brain files are append-only — use action='apply' / operation='append' to \
add new content. Removals are only allowed for genuine deduplication, and \
must opt in via dedup_intent=true with a result that still contains every \
unique line of the original.{hint}"
),
}
}
fn shrink_only_drops_duplicates(existing: &str, updated: &str) -> bool {
let updated_lines: std::collections::HashSet<&str> =
updated.lines().map(str::trim_end).collect();
for line in existing.lines() {
let trimmed = line.trim_end();
if trimmed.is_empty() {
continue;
}
if !updated_lines.contains(trimmed) {
return false;
}
}
true
}