use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
pub const SCHEMA_VERSION: u32 = 2;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MovedEntry {
pub header: String,
pub dest: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct PrunedState {
pub schema_version: u32,
pub pruned: HashMap<String, Vec<String>>,
pub pruned_at: HashMap<String, String>,
pub moved: HashMap<String, Vec<MovedEntry>>,
}
impl Default for PrunedState {
fn default() -> Self {
Self {
schema_version: SCHEMA_VERSION,
pruned: HashMap::new(),
pruned_at: HashMap::new(),
moved: HashMap::new(),
}
}
}
impl PrunedState {
pub fn load() -> Self {
let path = Self::state_path();
if !path.exists() {
return Self::default();
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(e) => {
tracing::warn!("pruned: failed to read pruned.toml: {e}");
return Self::default();
}
};
Self::parse(&content)
}
pub(crate) fn parse(content: &str) -> Self {
let mut state = Self {
schema_version: 1,
pruned: HashMap::new(),
pruned_at: HashMap::new(),
moved: HashMap::new(),
};
let mut current_file: Option<String> = None;
let mut moved_buffer: Option<String> = None;
for line in content.lines() {
let raw = line;
let trimmed = raw.trim();
if let Some(ref mut buf) = moved_buffer {
buf.push_str(raw);
buf.push('\n');
if trimmed.ends_with(']') {
let buf_taken = std::mem::take(buf);
moved_buffer = None;
if let Some(ref file) = current_file {
let pairs = Self::parse_moved_array(&buf_taken);
if !pairs.is_empty() {
state.moved.insert(file.clone(), pairs);
}
}
}
continue;
}
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if current_file.is_none()
&& let Some((key, value)) = trimmed.split_once('=')
&& key.trim() == "schema_version"
&& let Ok(v) = value.trim().parse::<u32>()
{
state.schema_version = v;
continue;
}
if trimmed.starts_with('[') && trimmed.ends_with(']') && !trimmed.contains('=') {
let name = trimmed[1..trimmed.len() - 1].trim().to_string();
current_file = Some(name);
continue;
}
if let Some(ref file) = current_file
&& let Some((key, value)) = trimmed.split_once('=')
{
let key = key.trim();
let value = value.trim();
if key == "pruned_at" {
let ts = value.trim_matches('"').to_string();
state.pruned_at.insert(file.clone(), ts);
} else if key == "pruned" {
let headers = Self::parse_string_array(value);
if !headers.is_empty() {
state.pruned.insert(file.clone(), headers);
}
} else if key == "moved" {
if value.starts_with('[') && value.ends_with(']') {
let pairs = Self::parse_moved_array(value);
if !pairs.is_empty() {
state.moved.insert(file.clone(), pairs);
}
} else if value.starts_with('[') {
moved_buffer = Some(format!("{value}\n"));
}
}
}
}
state
}
fn parse_moved_array(value: &str) -> Vec<MovedEntry> {
let mut depth = 0i32;
let mut current = String::new();
let mut pairs_raw: Vec<String> = Vec::new();
for ch in value.chars() {
match ch {
'[' => {
depth += 1;
if depth >= 2 {
current.push(ch);
}
}
']' => {
if depth == 2 {
current.push(ch);
pairs_raw.push(std::mem::take(&mut current));
} else if depth >= 2 {
current.push(ch);
}
depth -= 1;
}
_ if depth >= 2 => current.push(ch),
_ => {}
}
}
pairs_raw
.into_iter()
.filter_map(|raw| {
let strings = Self::parse_string_array(&raw);
if strings.len() == 2 {
Some(MovedEntry {
header: strings[0].clone(),
dest: strings[1].clone(),
})
} else {
None
}
})
.collect()
}
fn parse_string_array(value: &str) -> Vec<String> {
let trimmed = value.trim();
if !trimmed.starts_with('[') || !trimmed.ends_with(']') {
return Vec::new();
}
let inner = &trimmed[1..trimmed.len() - 1];
let mut result = Vec::new();
let mut current = String::new();
let mut in_string = false;
let mut escape = false;
for ch in inner.chars() {
if escape {
current.push(ch);
escape = false;
continue;
}
if ch == '\\' {
escape = true;
continue;
}
if ch == '"' {
if in_string {
result.push(current.clone());
current.clear();
in_string = false;
} else {
in_string = true;
}
continue;
}
if in_string {
current.push(ch);
}
}
result
}
pub fn save(&self) -> std::io::Result<()> {
let path = Self::state_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut content = format!(
"# Pruned brain-file sections.\n\
# Sections listed here will NOT be re-added by sync_templates().\n\
# Edit manually or use `opencrabs pruned clear` to reset.\n\n\
schema_version = {SCHEMA_VERSION}\n\n",
);
let mut files: HashSet<&String> = HashSet::new();
files.extend(self.pruned.keys());
files.extend(self.pruned_at.keys());
files.extend(self.moved.keys());
let mut files: Vec<&String> = files.into_iter().collect();
files.sort();
for file in files {
let pruned_headers = self.pruned.get(file);
let moved_pairs = self.moved.get(file);
let has_any = pruned_headers.map(|h| !h.is_empty()).unwrap_or(false)
|| moved_pairs.map(|m| !m.is_empty()).unwrap_or(false);
if !has_any {
continue;
}
content.push_str(&format!("[{file}]\n"));
if let Some(headers) = pruned_headers
&& !headers.is_empty()
{
content.push_str("pruned = [");
let escaped: Vec<String> = headers
.iter()
.map(|h| format!("\"{}\"", h.replace('\\', "\\\\").replace('"', "\\\"")))
.collect();
content.push_str(&escaped.join(", "));
content.push_str("]\n");
}
if let Some(ts) = self.pruned_at.get(file) {
content.push_str(&format!("pruned_at = \"{ts}\"\n"));
}
if let Some(pairs) = moved_pairs
&& !pairs.is_empty()
{
content.push_str("moved = [\n");
for entry in pairs {
let h_esc = entry.header.replace('\\', "\\\\").replace('"', "\\\"");
let d_esc = entry.dest.replace('\\', "\\\\").replace('"', "\\\"");
content.push_str(&format!(" [\"{h_esc}\", \"{d_esc}\"],\n"));
}
content.push_str("]\n");
}
content.push('\n');
}
std::fs::write(&path, content)
}
fn state_path() -> PathBuf {
crate::config::opencrabs_home().join("rsi/pruned.toml")
}
pub fn is_pruned(&self, filename: &str, header: &str) -> bool {
self.pruned
.get(filename)
.map(|headers| headers.iter().any(|h| h == header))
.unwrap_or(false)
}
pub fn pruned_headers(&self, filename: &str) -> HashSet<String> {
self.pruned
.get(filename)
.cloned()
.unwrap_or_default()
.into_iter()
.collect()
}
pub fn record_pruned(&mut self, filename: &str, headers: Vec<String>) {
if headers.is_empty() {
return;
}
let entry = self.pruned.entry(filename.to_string()).or_default();
for h in headers {
if !entry.contains(&h) {
entry.push(h);
}
}
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
self.pruned_at.insert(filename.to_string(), now);
}
pub fn clear(&mut self, filename: Option<&str>) {
if let Some(f) = filename {
self.pruned.remove(f);
self.pruned_at.remove(f);
self.moved.remove(f);
} else {
self.pruned.clear();
self.pruned_at.clear();
self.moved.clear();
}
}
pub fn record_moved(&mut self, source_file: &str, entries: Vec<MovedEntry>) {
if entries.is_empty() {
return;
}
let bucket = self.moved.entry(source_file.to_string()).or_default();
for new_entry in entries {
if !bucket.iter().any(|e| e.header == new_entry.header) {
bucket.push(new_entry);
}
}
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
self.pruned_at.insert(source_file.to_string(), now);
}
pub fn moved_destination(&self, source_file: &str, header: &str) -> Option<&str> {
self.moved
.get(source_file)
.and_then(|entries| entries.iter().find(|e| e.header == header))
.map(|e| e.dest.as_str())
}
}
pub fn detect_removed_sections(old: &str, new: &str) -> Vec<String> {
let old_headers: HashSet<String> = crate::brain::rsi_sync::extract_section_headers(old)
.into_iter()
.collect();
let new_headers: HashSet<String> = crate::brain::rsi_sync::extract_section_headers(new)
.into_iter()
.collect();
let mut removed: Vec<String> = old_headers.difference(&new_headers).cloned().collect();
removed.sort();
removed
}
pub fn filter_pruned_sections(new_sections: &str, state: &PrunedState, filename: &str) -> String {
let pruned = state.pruned_headers(filename);
let moved_sources: HashSet<String> = state
.moved
.get(filename)
.map(|entries| entries.iter().map(|e| e.header.clone()).collect())
.unwrap_or_default();
if pruned.is_empty() && moved_sources.is_empty() {
return new_sections.to_string();
}
let mut blocks: Vec<(String, Vec<String>)> = Vec::new();
let mut current_header = String::new();
let mut current_content: Vec<String> = Vec::new();
for line in new_sections.lines() {
if line.starts_with("## ") || line.starts_with("### ") {
if !current_header.is_empty() {
blocks.push((current_header.clone(), current_content.clone()));
}
current_header = line.to_string();
current_content = vec![line.to_string()];
} else if !current_header.is_empty() {
current_content.push(line.to_string());
}
}
if !current_header.is_empty() {
blocks.push((current_header, current_content));
}
let kept: Vec<String> = blocks
.into_iter()
.filter_map(|(header, content)| {
if pruned.contains(&header) || moved_sources.contains(&header) {
None
} else {
Some(content.join("\n"))
}
})
.collect();
if kept.is_empty() {
String::new()
} else {
format!("\n{}\n", kept.join("\n\n"))
}
}