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 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, Default, Serialize, Deserialize)]
struct ToolProposalsFile {
#[serde(default)]
proposals: Vec<ToolProposal>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct CommandProposalsFile {
#[serde(default)]
proposals: Vec<CommandProposal>,
}
#[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 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_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(())
}
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 list_tool_proposals(&self) -> Vec<ToolProposal> {
self.read_tools().proposals
}
pub fn list_command_proposals(&self) -> Vec<CommandProposal> {
self.read_commands().proposals
}
pub fn pending_count(&self) -> usize {
self.read_tools().proposals.len() + self.read_commands().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 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_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)
}