use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use crate::brain::commands::UserCommand;
use crate::brain::tools::dynamic::tool::DynamicToolDef;
const PROPOSED_TOOLS_FILE: &str = "proposed_tools.toml";
const PROPOSED_COMMANDS_FILE: &str = "proposed_commands.toml";
const PROPOSED_SKILLS_FILE: &str = "proposed_skills.toml";
const PROPOSED_BRAIN_DEDUP_FILE: &str = "proposed_brain_dedup.toml";
const APPLIED_DIR: &str = "applied";
const REJECTED_DIR: &str = "rejected";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolProposal {
pub id: String,
pub created_at: DateTime<Utc>,
pub proposer: String,
pub rationale: String,
pub def: DynamicToolDef,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CommandProposal {
pub id: String,
pub created_at: DateTime<Utc>,
pub proposer: String,
pub rationale: String,
pub command: UserCommand,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillProposal {
pub id: String,
pub created_at: DateTime<Utc>,
pub proposer: String,
pub rationale: String,
pub skill: ProposedSkill,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProposedSkill {
pub name: String,
pub description: String,
pub body: String,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct ToolProposalsFile {
#[serde(default)]
proposals: Vec<ToolProposal>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct CommandProposalsFile {
#[serde(default)]
proposals: Vec<CommandProposal>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct SkillProposalsFile {
#[serde(default)]
proposals: Vec<SkillProposal>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BrainDedupProposal {
pub id: String,
pub created_at: DateTime<Utc>,
pub proposer: String,
pub rationale: String,
pub dedup: ProposedBrainDedup,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProposedBrainDedup {
pub target_file: String,
pub duplicate_text: String,
pub line_range: String,
pub duplicate_of: String,
pub count: usize,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct BrainDedupProposalsFile {
#[serde(default)]
proposals: Vec<BrainDedupProposal>,
}
#[derive(Debug, Clone)]
pub struct ProposalsStore {
rsi_dir: PathBuf,
}
impl ProposalsStore {
pub fn new() -> Self {
Self {
rsi_dir: crate::config::opencrabs_home().join("rsi"),
}
}
pub fn with_dir(rsi_dir: PathBuf) -> Self {
Self { rsi_dir }
}
fn ensure_dirs(&self) -> Result<()> {
fs::create_dir_all(&self.rsi_dir)
.with_context(|| format!("creating {}", self.rsi_dir.display()))?;
fs::create_dir_all(self.rsi_dir.join(APPLIED_DIR))?;
fs::create_dir_all(self.rsi_dir.join(REJECTED_DIR))?;
Ok(())
}
fn tools_path(&self) -> PathBuf {
self.rsi_dir.join(PROPOSED_TOOLS_FILE)
}
fn commands_path(&self) -> PathBuf {
self.rsi_dir.join(PROPOSED_COMMANDS_FILE)
}
fn skills_path(&self) -> PathBuf {
self.rsi_dir.join(PROPOSED_SKILLS_FILE)
}
fn brain_dedup_path(&self) -> PathBuf {
self.rsi_dir.join(PROPOSED_BRAIN_DEDUP_FILE)
}
fn read_tools(&self) -> ToolProposalsFile {
Self::read_file(&self.tools_path()).unwrap_or_default()
}
fn read_commands(&self) -> CommandProposalsFile {
Self::read_file(&self.commands_path()).unwrap_or_default()
}
fn read_skills(&self) -> SkillProposalsFile {
Self::read_file(&self.skills_path()).unwrap_or_default()
}
fn read_brain_dedup(&self) -> BrainDedupProposalsFile {
Self::read_file(&self.brain_dedup_path()).unwrap_or_default()
}
fn read_file<T: serde::de::DeserializeOwned>(path: &Path) -> Option<T> {
let contents = fs::read_to_string(path).ok()?;
match toml::from_str(&contents) {
Ok(parsed) => Some(parsed),
Err(e) => {
tracing::warn!("ProposalsStore: failed to parse {}: {}", path.display(), e);
None
}
}
}
fn write_tools(&self, file: &ToolProposalsFile) -> Result<()> {
self.ensure_dirs()?;
let toml_str = toml::to_string_pretty(file)?;
fs::write(self.tools_path(), toml_str)?;
Ok(())
}
fn write_commands(&self, file: &CommandProposalsFile) -> Result<()> {
self.ensure_dirs()?;
let toml_str = toml::to_string_pretty(file)?;
fs::write(self.commands_path(), toml_str)?;
Ok(())
}
fn write_skills(&self, file: &SkillProposalsFile) -> Result<()> {
self.ensure_dirs()?;
let toml_str = toml::to_string_pretty(file)?;
fs::write(self.skills_path(), toml_str)?;
Ok(())
}
fn write_brain_dedup(&self, file: &BrainDedupProposalsFile) -> Result<()> {
self.ensure_dirs()?;
let toml_str = toml::to_string_pretty(file)?;
fs::write(self.brain_dedup_path(), toml_str)?;
Ok(())
}
pub fn add_tool_proposal(
&self,
proposer: impl Into<String>,
rationale: impl Into<String>,
def: DynamicToolDef,
) -> Result<String> {
let mut file = self.read_tools();
let id = generate_id("tool", &def.name);
file.proposals.retain(|p| p.def.name != def.name);
file.proposals.push(ToolProposal {
id: id.clone(),
created_at: Utc::now(),
proposer: proposer.into(),
rationale: rationale.into(),
def,
});
self.write_tools(&file)?;
Ok(id)
}
pub fn add_command_proposal(
&self,
proposer: impl Into<String>,
rationale: impl Into<String>,
command: UserCommand,
) -> Result<String> {
let mut file = self.read_commands();
let id = generate_id("cmd", &command.name);
file.proposals.retain(|p| p.command.name != command.name);
file.proposals.push(CommandProposal {
id: id.clone(),
created_at: Utc::now(),
proposer: proposer.into(),
rationale: rationale.into(),
command,
});
self.write_commands(&file)?;
Ok(id)
}
pub fn add_skill_proposal(
&self,
proposer: impl Into<String>,
rationale: impl Into<String>,
skill: ProposedSkill,
) -> Result<String> {
let mut file = self.read_skills();
let id = generate_id("skill", &skill.name);
file.proposals.retain(|p| p.skill.name != skill.name);
file.proposals.push(SkillProposal {
id: id.clone(),
created_at: Utc::now(),
proposer: proposer.into(),
rationale: rationale.into(),
skill,
});
self.write_skills(&file)?;
Ok(id)
}
pub fn add_brain_dedup_proposal(
&self,
proposer: impl Into<String>,
rationale: impl Into<String>,
dedup: ProposedBrainDedup,
) -> Result<String> {
let mut file = self.read_brain_dedup();
let id = generate_id("dedup", &dedup.target_file);
file.proposals.retain(|p| {
!(p.dedup.target_file == dedup.target_file
&& p.dedup.duplicate_text == dedup.duplicate_text)
});
file.proposals.push(BrainDedupProposal {
id: id.clone(),
created_at: Utc::now(),
proposer: proposer.into(),
rationale: rationale.into(),
dedup,
});
self.write_brain_dedup(&file)?;
Ok(id)
}
pub fn list_tool_proposals(&self) -> Vec<ToolProposal> {
self.read_tools().proposals
}
pub fn list_command_proposals(&self) -> Vec<CommandProposal> {
self.read_commands().proposals
}
pub fn list_skill_proposals(&self) -> Vec<SkillProposal> {
self.read_skills().proposals
}
pub fn list_brain_dedup_proposals(&self) -> Vec<BrainDedupProposal> {
self.read_brain_dedup().proposals
}
pub fn pending_count(&self) -> usize {
self.read_tools().proposals.len()
+ self.read_commands().proposals.len()
+ self.read_skills().proposals.len()
+ self.read_brain_dedup().proposals.len()
}
pub fn take_tool_proposal(&self, id: &str) -> Result<Option<ToolProposal>> {
let mut file = self.read_tools();
let pos = file.proposals.iter().position(|p| p.id == id);
let Some(idx) = pos else {
return Ok(None);
};
let taken = file.proposals.remove(idx);
self.write_tools(&file)?;
Ok(Some(taken))
}
pub fn take_command_proposal(&self, id: &str) -> Result<Option<CommandProposal>> {
let mut file = self.read_commands();
let pos = file.proposals.iter().position(|p| p.id == id);
let Some(idx) = pos else {
return Ok(None);
};
let taken = file.proposals.remove(idx);
self.write_commands(&file)?;
Ok(Some(taken))
}
pub fn take_skill_proposal(&self, id: &str) -> Result<Option<SkillProposal>> {
let mut file = self.read_skills();
let pos = file.proposals.iter().position(|p| p.id == id);
let Some(idx) = pos else {
return Ok(None);
};
let taken = file.proposals.remove(idx);
self.write_skills(&file)?;
Ok(Some(taken))
}
pub fn take_brain_dedup_proposal(&self, id: &str) -> Result<Option<BrainDedupProposal>> {
let mut file = self.read_brain_dedup();
let pos = file.proposals.iter().position(|p| p.id == id);
let Some(idx) = pos else {
return Ok(None);
};
let taken = file.proposals.remove(idx);
self.write_brain_dedup(&file)?;
Ok(Some(taken))
}
pub fn archive_applied_tool(&self, proposal: &ToolProposal) -> Result<()> {
self.archive(APPLIED_DIR, "tools", proposal)
}
pub fn archive_applied_command(&self, proposal: &CommandProposal) -> Result<()> {
self.archive(APPLIED_DIR, "commands", proposal)
}
pub fn archive_applied_skill(&self, proposal: &SkillProposal) -> Result<()> {
self.archive(APPLIED_DIR, "skills", proposal)
}
pub fn archive_applied_brain_dedup(&self, proposal: &BrainDedupProposal) -> Result<()> {
self.archive(APPLIED_DIR, "brain_dedup", proposal)
}
pub fn archive_rejected_brain_dedup(
&self,
proposal: &BrainDedupProposal,
reason: Option<&str>,
) -> Result<()> {
let mut wrapped = ArchivedProposal {
inner: proposal.clone(),
reason: reason.map(str::to_string),
};
wrapped.reason = reason.map(str::to_string);
self.archive(REJECTED_DIR, "brain_dedup", &wrapped)
}
pub fn archive_rejected_skill(
&self,
proposal: &SkillProposal,
reason: Option<&str>,
) -> Result<()> {
let mut wrapped = ArchivedProposal {
inner: proposal.clone(),
reason: reason.map(str::to_string),
};
wrapped.reason = reason.map(str::to_string);
self.archive(REJECTED_DIR, "skills", &wrapped)
}
pub fn archive_rejected_tool(
&self,
proposal: &ToolProposal,
reason: Option<&str>,
) -> Result<()> {
let mut wrapped = ArchivedProposal {
inner: proposal.clone(),
reason: reason.map(str::to_string),
};
wrapped.reason = reason.map(str::to_string);
self.archive(REJECTED_DIR, "tools", &wrapped)
}
pub fn archive_rejected_command(
&self,
proposal: &CommandProposal,
reason: Option<&str>,
) -> Result<()> {
let mut wrapped = ArchivedProposal {
inner: proposal.clone(),
reason: reason.map(str::to_string),
};
wrapped.reason = reason.map(str::to_string);
self.archive(REJECTED_DIR, "commands", &wrapped)
}
fn archive<T: Serialize>(&self, dir_name: &str, kind: &str, proposal: &T) -> Result<()> {
self.ensure_dirs()?;
let date = Utc::now().format("%Y-%m-%d").to_string();
let path = self
.rsi_dir
.join(dir_name)
.join(format!("{}-{}.toml", date, kind));
let mut existing = String::new();
if path.exists() {
existing = fs::read_to_string(&path).unwrap_or_default();
if !existing.is_empty() && !existing.ends_with('\n') {
existing.push('\n');
}
}
let mut wrapper = toml::map::Map::new();
wrapper.insert(
"proposals".to_string(),
toml::Value::Array(vec![toml::Value::try_from(proposal)?]),
);
let chunk = toml::to_string_pretty(&toml::Value::Table(wrapper))?;
existing.push_str(&chunk);
existing.push('\n');
fs::write(&path, existing)?;
Ok(())
}
}
impl Default for ProposalsStore {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Serialize)]
struct ArchivedProposal<T: Serialize> {
#[serde(flatten)]
inner: T,
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<String>,
}
fn generate_id(kind: &str, name: &str) -> String {
let date = Utc::now().format("%Y-%m-%d").to_string();
let suffix = uuid::Uuid::new_v4().simple().to_string();
let suffix_short: String = suffix.chars().take(6).collect();
let safe_name: String = name
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
format!("prop_{}_{}_{}_{}", kind, date, safe_name, suffix_short)
}