use std::collections::HashMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use crate::brain::tools::brain_file_safety;
const TEMPLATE_BASE_URL: &str =
"https://raw.githubusercontent.com/adolfousier/opencrabs/main/src/docs/reference/templates";
const TRACKED_FILES: &[&str] = &[
"SOUL.md",
"USER.md",
"AGENTS.md",
"TOOLS.md",
"CODE.md",
"SECURITY.md",
"MEMORY.md",
"BOOT.md",
"BOOTSTRAP.md",
"IDENTITY.md",
"HEARTBEAT.md",
"VOICE.md",
];
#[derive(Debug, Clone, Default, PartialEq)]
pub struct SyncState {
pub last_synced_version: String,
pub last_sync_date: String,
pub file_dates: HashMap<String, String>,
}
impl SyncState {
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!("RSI sync: failed to read state.toml: {e}");
return Self::default();
}
};
let mut state = Self::default();
let mut in_files_section = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed == "[files]" {
in_files_section = true;
continue;
}
if trimmed.starts_with('[') {
in_files_section = false;
continue;
}
if let Some((key, value)) = trimmed.split_once('=') {
let key = key.trim();
let value = value.trim().trim_matches('"');
if in_files_section {
state.file_dates.insert(key.to_string(), value.to_string());
} else if key == "last_synced_version" {
state.last_synced_version = value.to_string();
} else if key == "last_sync_date" {
state.last_sync_date = value.to_string();
}
}
}
state
}
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!(
"last_synced_version = \"{}\"\nlast_sync_date = \"{}\"\n\n[files]\n",
self.last_synced_version, self.last_sync_date
);
for (file, date) in &self.file_dates {
content.push_str(&format!("{file} = \"{date}\"\n"));
}
std::fs::write(&path, content)
}
fn state_path() -> PathBuf {
crate::config::opencrabs_home().join("rsi/state.toml")
}
}
#[derive(Debug, Clone)]
pub struct FileSyncResult {
pub filename: String,
pub synced: bool,
pub sections_added: usize,
pub error: Option<String>,
}
pub fn needs_sync(state: &SyncState) -> bool {
state.last_synced_version != crate::VERSION
}
pub async fn fetch_template(filename: &str) -> Result<String, String> {
let url = format!("{TEMPLATE_BASE_URL}/{filename}");
let response = reqwest::get(&url)
.await
.map_err(|e| format!("Failed to fetch {filename}: {e}"))?;
if !response.status().is_success() {
return Err(format!(
"Failed to fetch {filename}: HTTP {}",
response.status()
));
}
response
.text()
.await
.map_err(|e| format!("Failed to read {filename} body: {e}"))
}
pub fn extract_new_sections(local: &str, upstream: &str) -> String {
let local_headers: std::collections::HashSet<String> =
extract_section_headers(local).into_iter().collect();
let mut blocks: Vec<(usize, String, Vec<String>)> = Vec::new();
let mut current_level = 0;
let mut current_header = String::new();
let mut current_content = Vec::new();
for line in upstream.lines() {
let level = if line.starts_with("## ") {
2
} else if line.starts_with("### ") {
3
} else {
0
};
if level >= 2 {
if !current_header.is_empty() {
blocks.push((
current_level,
current_header.clone(),
current_content.clone(),
));
}
current_level = level;
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_level, current_header, current_content));
}
let mut new_sections = Vec::new();
for (level, header, content) in &blocks {
if *level == 2 {
if !local_headers.contains(header) {
new_sections.push(content.join("\n"));
}
} else if *level == 3 {
if !local_headers.contains(header) {
new_sections.push(content.join("\n"));
}
}
}
if new_sections.is_empty() {
String::new()
} else {
format!("\n{}\n", new_sections.join("\n\n"))
}
}
pub(crate) fn extract_section_headers(content: &str) -> Vec<String> {
content
.lines()
.filter(|line| line.starts_with("## ") || line.starts_with("### "))
.map(|line| line.to_string())
.collect()
}
fn backups_dir() -> PathBuf {
crate::config::opencrabs_home().join("rsi/backups")
}
fn ensure_backups_dir() -> std::io::Result<()> {
std::fs::create_dir_all(backups_dir())
}
pub async fn sync_templates() -> Vec<FileSyncResult> {
let home = crate::config::opencrabs_home();
let mut state = SyncState::load();
if !needs_sync(&state) {
tracing::info!(
"RSI sync: no new release since last sync (v{}). Skipping.",
state.last_synced_version
);
return vec![];
}
tracing::info!(
"RSI sync: version changed from {} to {}. Starting template sync.",
state.last_synced_version,
crate::VERSION
);
if let Err(e) = ensure_backups_dir() {
tracing::warn!("RSI sync: failed to create backups dir: {e}");
return vec![FileSyncResult {
filename: "_setup".to_string(),
synced: false,
sections_added: 0,
error: Some(format!("Failed to create backups dir: {e}")),
}];
}
let mut results = Vec::new();
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
for filename in TRACKED_FILES {
let local_path = home.join(filename);
if !local_path.exists() {
tracing::debug!("RSI sync: {filename} does not exist locally, skipping");
continue;
}
let result = sync_single_file(&local_path, filename, &now).await;
if result.synced {
state.file_dates.insert(filename.to_string(), now.clone());
}
results.push(result);
}
state.last_synced_version = crate::VERSION.to_string();
state.last_sync_date = now;
if let Err(e) = state.save() {
tracing::warn!("RSI sync: failed to save state.toml: {e}");
}
results
}
async fn sync_single_file(local_path: &Path, filename: &str, _timestamp: &str) -> FileSyncResult {
let local_content = match std::fs::read_to_string(local_path) {
Ok(c) => c,
Err(e) => {
return FileSyncResult {
filename: filename.to_string(),
synced: false,
sections_added: 0,
error: Some(format!("Failed to read local {filename}: {e}")),
};
}
};
let upstream_content = match fetch_template(filename).await {
Ok(c) => c,
Err(e) => {
return FileSyncResult {
filename: filename.to_string(),
synced: false,
sections_added: 0,
error: Some(e),
};
}
};
let new_sections = extract_new_sections(&local_content, &upstream_content);
if new_sections.trim().is_empty() {
tracing::info!("RSI sync: {filename} has no new sections, skipping");
return FileSyncResult {
filename: filename.to_string(),
synced: true,
sections_added: 0,
error: None,
};
}
let sections_count = new_sections
.lines()
.filter(|l| l.starts_with("## "))
.count();
match brain_file_safety::backup_before_write(local_path) {
Ok(Some(backup_path)) => {
tracing::info!(
"RSI sync: backed up {filename} to {}",
backup_path.display()
);
}
Ok(None) => {
tracing::debug!("RSI sync: {filename} has no existing backup (file is new)");
}
Err(e) => {
tracing::warn!("RSI sync: failed to backup {filename}: {e}");
}
}
let updated = format!("{}{}", local_content, new_sections);
if updated.trim().is_empty() {
return FileSyncResult {
filename: filename.to_string(),
synced: false,
sections_added: 0,
error: Some("Sanity check failed: merged content is empty".to_string()),
};
}
if let Err(e) = std::fs::write(local_path, &updated) {
return FileSyncResult {
filename: filename.to_string(),
synced: false,
sections_added: 0,
error: Some(format!("Failed to write {filename}: {e}")),
};
}
let home = crate::config::opencrabs_home();
let improvements_path = home.join("rsi/improvements.md");
let entry = format!(
"\n## [Synced] Upstream template sync for {filename}\n\n\
**Date:** {}\n\
**Version:** {}\n\
**Sections added:** {sections_count}\n\
**Status:** Applied (upstream sync)\n",
chrono::Utc::now().format("%Y-%m-%d %H:%M UTC"),
crate::VERSION,
);
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&improvements_path)
{
let _ = f.write_all(entry.as_bytes());
}
tracing::info!(
"RSI sync: synced {filename} (+{sections_count} sections from upstream v{})",
crate::VERSION
);
FileSyncResult {
filename: filename.to_string(),
synced: true,
sections_added: sections_count,
error: None,
}
}