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",
];
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)?;
if let Err(e) = prune_backups(path, 5, 7) {
eprintln!(
"Warning: failed to prune backups for {}: {}",
path.display(),
e
);
}
Ok(Some(backup))
}
fn prune_backups(path: &Path, max_count: usize, max_age_days: u64) -> std::io::Result<()> {
let Some(parent) = path.parent() else {
return Ok(());
};
let Some(file_name) = path.file_name().and_then(|n| n.to_str()) else {
return Ok(());
};
let mut backups: Vec<(std::path::PathBuf, chrono::DateTime<chrono::Utc>)> = Vec::new();
for entry in std::fs::read_dir(parent)? {
let entry = entry?;
let entry_name = entry.file_name();
let Some(entry_str) = entry_name.to_str() else {
continue;
};
if !entry_str.starts_with(file_name) || !entry_str.ends_with(".bak") {
continue;
}
let without_base = &entry_str[file_name.len()..];
let without_ext = without_base
.trim_start_matches('.')
.trim_end_matches(".bak");
if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(without_ext, "%Y-%m-%dT%H%M%S") {
let utc = dt.and_utc();
backups.push((entry.path(), utc));
}
}
backups.sort_by_key(|b| std::cmp::Reverse(b.1));
let cutoff = chrono::Utc::now() - chrono::Duration::days(max_age_days as i64);
for (i, (backup_path, timestamp)) in backups.iter().enumerate() {
if !(i >= max_count || *timestamp < cutoff) {
continue;
}
if let Err(e) = std::fs::remove_file(backup_path) {
eprintln!(
"Warning: failed to delete old backup {}: {}",
backup_path.display(),
e
);
}
}
Ok(())
}
#[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,
cleanup_intent: bool,
) -> ShrinkCheck {
if !is_protected_path(path) {
return ShrinkCheck::Allowed;
}
if updated.len() >= existing.len() {
return ShrinkCheck::Allowed;
}
if cleanup_intent {
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}"
),
}
}
#[derive(Debug)]
pub enum AppendDedup {
AllNew,
Filtered {
filtered_content: String,
skipped_paragraphs: usize,
},
AllDuplicate,
}
fn split_paragraphs(text: &str) -> Vec<String> {
let mut paragraphs = Vec::new();
let mut current = String::new();
for line in text.lines() {
if line.trim().is_empty() {
if !current.is_empty() {
paragraphs.push(current.trim_end().to_string());
current.clear();
}
} else {
if !current.is_empty() {
current.push('\n');
}
current.push_str(line);
}
}
if !current.is_empty() {
paragraphs.push(current.trim_end().to_string());
}
paragraphs
}
fn is_incident_line(line: &str) -> bool {
let trimmed = line.trim();
let lower = trimmed.to_ascii_lowercase();
(lower.starts_with("added ") || lower.starts_with("repeat "))
&& lower.contains("session ")
&& trimmed.chars().any(|c| c.is_ascii_digit())
}
fn strip_incident_lines(text: &str) -> String {
text.lines()
.filter(|l| !is_incident_line(l))
.collect::<Vec<_>>()
.join("\n")
}
fn paragraph_exists(paragraph: &str, existing: &str) -> bool {
let trimmed = paragraph.trim();
if trimmed.is_empty() {
return true;
}
if existing.contains(trimmed) {
return true;
}
if let Some(first_line) = trimmed.lines().next() {
let header = first_line.trim();
if (header.starts_with("## ") || header.starts_with("### "))
&& existing.lines().any(|l| l.trim() == header)
{
return true;
}
}
let existing_lines: std::collections::HashSet<&str> = existing.lines().map(str::trim).collect();
let para_lines: Vec<&str> = trimmed.lines().filter(|l| !l.trim().is_empty()).collect();
if para_lines.len() >= 3 {
let overlap = para_lines
.iter()
.filter(|l| existing_lines.contains(l.trim()))
.count();
let ratio = overlap as f64 / para_lines.len() as f64;
if ratio > 0.7 {
return true;
}
}
let has_incident_lines = trimmed.lines().any(is_incident_line);
if has_incident_lines && para_lines.len() >= 2 {
let stripped_para = strip_incident_lines(trimmed);
let stripped_existing = strip_incident_lines(existing);
let stripped_lines: Vec<&str> = stripped_para
.lines()
.filter(|l| !l.trim().is_empty())
.collect();
if !stripped_lines.is_empty() {
let stripped_existing_set: std::collections::HashSet<&str> =
stripped_existing.lines().map(str::trim).collect();
let overlap = stripped_lines
.iter()
.filter(|l| stripped_existing_set.contains(l.trim()))
.count();
let ratio = overlap as f64 / stripped_lines.len() as f64;
if ratio > 0.7 {
return true;
}
}
}
false
}
pub fn filter_duplicate_append(existing: &str, new_content: &str) -> AppendDedup {
let new_trimmed = new_content.trim();
if new_trimmed.is_empty() {
return AppendDedup::AllDuplicate;
}
if existing.contains(new_trimmed) {
return AppendDedup::AllDuplicate;
}
let paragraphs = split_paragraphs(new_trimmed);
if paragraphs.is_empty() {
return AppendDedup::AllDuplicate;
}
let mut new_paragraphs = Vec::new();
let mut skipped = 0;
for para in ¶graphs {
if paragraph_exists(para, existing) {
skipped += 1;
} else {
new_paragraphs.push(para.clone());
}
}
if new_paragraphs.is_empty() {
return AppendDedup::AllDuplicate;
}
if skipped == 0 {
return AppendDedup::AllNew;
}
AppendDedup::Filtered {
filtered_content: new_paragraphs.join("\n\n"),
skipped_paragraphs: skipped,
}
}
pub fn is_duplicate_append(existing: &str, new_content: &str) -> bool {
matches!(
filter_duplicate_append(existing, new_content),
AppendDedup::AllDuplicate
)
}
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
}