pub mod error;
pub mod rate_limiter;
use std::sync::Arc;
use async_trait::async_trait;
use redb::ReadableTable as _;
use serde::{Deserialize, Serialize};
use tracing::instrument;
pub use error::MemoryError;
pub use rate_limiter::QuestRateLimiter;
#[derive(Debug, Deserialize)]
#[serde(tag = "command", rename_all = "snake_case")]
pub enum MemoryCommand {
View {
path: String,
view_range: Option<[u32; 2]>,
},
Create { path: String, file_text: String },
StrReplace {
path: String,
old_str: String,
new_str: String,
},
Insert {
path: String,
insert_line: u32,
insert_text: String,
},
Delete { path: String },
Rename { old_path: String, new_path: String },
}
#[derive(Debug, Serialize)]
pub struct MemoryResult {
pub content: String,
pub is_error: bool,
}
impl MemoryResult {
fn ok(content: impl Into<String>) -> Self {
Self {
content: content.into(),
is_error: false,
}
}
fn err(msg: impl Into<String>) -> Self {
Self {
content: msg.into(),
is_error: true,
}
}
}
#[async_trait]
pub trait MemoryBackend: Send + Sync {
async fn view(&self, path: &str, range: Option<[u32; 2]>) -> Result<String, MemoryError>;
async fn create(&self, path: &str, file_text: &str) -> Result<(), MemoryError>;
async fn str_replace(&self, path: &str, old: &str, new_text: &str) -> Result<(), MemoryError>;
async fn insert(&self, path: &str, line: u32, text: &str) -> Result<(), MemoryError>;
async fn delete(&self, path: &str) -> Result<(), MemoryError>;
async fn rename(&self, old_path: &str, new_path: &str) -> Result<(), MemoryError>;
}
fn validate_path(path: &str) -> Result<(), MemoryError> {
if path.contains("..") || path.starts_with('/') || path.starts_with('\\') {
return Err(MemoryError::PathInvalid(path.to_string()));
}
if path.is_empty() {
return Err(MemoryError::PathInvalid("<empty>".to_string()));
}
Ok(())
}
const MEMORY_FILES_TABLE: redb::TableDefinition<&str, &str> =
redb::TableDefinition::new("memory_files");
pub struct RedbMemoryBackend {
db: Arc<redb::Database>,
}
impl RedbMemoryBackend {
pub fn new(db_path: &std::path::Path) -> Result<Self, MemoryError> {
let db = redb::Database::create(db_path).map_err(|e| MemoryError::Redb(e.to_string()))?;
Ok(Self { db: Arc::new(db) })
}
}
#[async_trait]
impl MemoryBackend for RedbMemoryBackend {
#[instrument(skip(self), fields(path))]
async fn view(&self, path: &str, range: Option<[u32; 2]>) -> Result<String, MemoryError> {
validate_path(path)?;
let read_txn = self.db.begin_read()?;
let table = read_txn.open_table(MEMORY_FILES_TABLE).map_err(|e| {
if matches!(e, redb::TableError::TableDoesNotExist(_)) {
MemoryError::PathNotFound(path.to_string())
} else {
MemoryError::Redb(e.to_string())
}
})?;
match table.get(path)? {
None => Err(MemoryError::PathNotFound(path.to_string())),
Some(guard) => {
let content: &str = guard.value();
if let Some([start, end]) = range {
let lines: Vec<&str> = content.lines().collect();
let start_idx = (start as usize).saturating_sub(1);
let end_idx = end as usize;
let len = lines.len();
if start_idx >= len || start_idx > end_idx.min(len) {
return Err(MemoryError::RangeInvalid {
start: start as usize,
end: end as usize,
len,
});
}
let end_clamped = end_idx.min(len);
Ok(lines[start_idx..end_clamped].join("\n"))
} else {
Ok(content.to_string())
}
}
}
}
#[instrument(skip(self, file_text), fields(path))]
async fn create(&self, path: &str, file_text: &str) -> Result<(), MemoryError> {
validate_path(path)?;
{
let read_txn = self.db.begin_read()?;
let table = match read_txn.open_table(MEMORY_FILES_TABLE) {
Ok(t) => t,
Err(redb::TableError::TableDoesNotExist(_)) => {
drop(read_txn);
let write_txn = self.db.begin_write()?;
{
let mut table = write_txn.open_table(MEMORY_FILES_TABLE)?;
table.insert(path, file_text)?;
}
write_txn.commit()?;
return Ok(());
}
Err(e) => return Err(MemoryError::from(e)),
};
if table.get(path)?.is_some() {
return Err(MemoryError::PathExists {
path: path.to_string(),
});
}
}
let write_txn = self.db.begin_write()?;
{
let mut table = write_txn.open_table(MEMORY_FILES_TABLE)?;
table.insert(path, file_text)?;
}
write_txn.commit()?;
Ok(())
}
#[instrument(skip(self, old_str, new_text), fields(path))]
async fn str_replace(
&self,
path: &str,
old_str: &str,
new_text: &str,
) -> Result<(), MemoryError> {
validate_path(path)?;
let write_txn = self.db.begin_write()?;
{
let mut table = write_txn.open_table(MEMORY_FILES_TABLE)?;
let existing = table
.get(path)?
.ok_or_else(|| MemoryError::PathNotFound(path.to_string()))?;
let content = existing.value().to_string();
drop(existing);
if !content.contains(old_str) {
return Err(MemoryError::OldStrNotFound {
path: path.to_string(),
});
}
let updated = content.replacen(old_str, new_text, 1);
table.insert(path, updated.as_str())?;
}
write_txn.commit()?;
Ok(())
}
#[instrument(skip(self, text), fields(path, insert_line))]
async fn insert(&self, path: &str, insert_line: u32, text: &str) -> Result<(), MemoryError> {
validate_path(path)?;
let write_txn = self.db.begin_write()?;
{
let mut table = write_txn.open_table(MEMORY_FILES_TABLE)?;
let existing = table
.get(path)?
.ok_or_else(|| MemoryError::PathNotFound(path.to_string()))?;
let content = existing.value().to_string();
drop(existing);
let mut lines: Vec<String> = content.lines().map(str::to_string).collect();
let idx = insert_line as usize;
if idx > lines.len() {
return Err(MemoryError::InsertOutOfRange {
path: path.to_string(),
line: insert_line,
});
}
lines.insert(idx, text.to_string());
let updated = lines.join("\n");
table.insert(path, updated.as_str())?;
}
write_txn.commit()?;
Ok(())
}
#[instrument(skip(self), fields(path))]
async fn delete(&self, path: &str) -> Result<(), MemoryError> {
validate_path(path)?;
let write_txn = self.db.begin_write()?;
{
let mut table = write_txn.open_table(MEMORY_FILES_TABLE)?;
table.remove(path)?;
}
write_txn.commit()?;
Ok(())
}
#[instrument(skip(self), fields(old_path, new_path))]
async fn rename(&self, old_path: &str, new_path: &str) -> Result<(), MemoryError> {
validate_path(old_path)?;
validate_path(new_path)?;
let write_txn = self.db.begin_write()?;
{
let mut table = write_txn.open_table(MEMORY_FILES_TABLE)?;
let existing = table
.get(old_path)?
.ok_or_else(|| MemoryError::PathNotFound(old_path.to_string()))?;
let content = existing.value().to_string();
drop(existing);
table.remove(old_path)?;
table.insert(new_path, content.as_str())?;
}
write_txn.commit()?;
Ok(())
}
}
pub struct MemoryDispatcher<B: MemoryBackend> {
backend: Arc<B>,
rate_limiter: QuestRateLimiter,
}
impl<B: MemoryBackend> MemoryDispatcher<B> {
pub fn new(backend: B, rate_limiter: QuestRateLimiter) -> Self {
Self {
backend: Arc::new(backend),
rate_limiter,
}
}
pub async fn dispatch(&self, cmd: MemoryCommand) -> MemoryResult {
if let Err(e) = self.rate_limiter.try_consume() {
return MemoryResult::err(e.to_string());
}
match self.dispatch_inner(cmd).await {
Ok(result) => result,
Err(e) => MemoryResult::err(e.to_string()),
}
}
async fn dispatch_inner(&self, cmd: MemoryCommand) -> Result<MemoryResult, MemoryError> {
match cmd {
MemoryCommand::View { path, view_range } => {
let content = self.backend.view(&path, view_range).await?;
Ok(MemoryResult::ok(content))
}
MemoryCommand::Create { path, file_text } => {
self.backend.create(&path, &file_text).await?;
Ok(MemoryResult::ok(format!("Created: {path}")))
}
MemoryCommand::StrReplace {
path,
old_str,
new_str,
} => {
self.backend.str_replace(&path, &old_str, &new_str).await?;
Ok(MemoryResult::ok(format!("Updated: {path}")))
}
MemoryCommand::Insert {
path,
insert_line,
insert_text,
} => {
self.backend
.insert(&path, insert_line, &insert_text)
.await?;
Ok(MemoryResult::ok(format!(
"Inserted at line {insert_line} in {path}"
)))
}
MemoryCommand::Delete { path } => {
self.backend.delete(&path).await?;
Ok(MemoryResult::ok(format!("Deleted: {path}")))
}
MemoryCommand::Rename { old_path, new_path } => {
self.backend.rename(&old_path, &new_path).await?;
Ok(MemoryResult::ok(format!(
"Renamed: {old_path} → {new_path}"
)))
}
}
}
pub fn reset_for_new_quest(&self) {
self.rate_limiter.reset_for_new_quest();
}
}
const _: fn() = || {
fn _assert_object_safe(_b: &dyn MemoryBackend) {}
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn memory_command_deserializes_view() {
let json = r#"{"command":"view","path":"user/foo.md","view_range":null}"#;
let cmd: MemoryCommand = serde_json::from_str(json).unwrap();
assert!(
matches!(cmd, MemoryCommand::View { path, view_range: None } if path == "user/foo.md")
);
}
#[test]
fn memory_command_deserializes_create() {
let json = r#"{"command":"create","path":"project/bar.md","file_text":"content here"}"#;
let cmd: MemoryCommand = serde_json::from_str(json).unwrap();
assert!(
matches!(cmd, MemoryCommand::Create { path, file_text } if path == "project/bar.md" && file_text == "content here")
);
}
#[test]
fn memory_command_deserializes_str_replace() {
let json = r#"{"command":"str_replace","path":"p.md","old_str":"old","new_str":"new"}"#;
let cmd: MemoryCommand = serde_json::from_str(json).unwrap();
assert!(matches!(cmd, MemoryCommand::StrReplace { .. }));
}
#[test]
fn path_validation_rejects_traversal() {
assert!(validate_path("../etc/passwd").is_err());
assert!(validate_path("/absolute/path").is_err());
assert!(validate_path("\\windows\\path").is_err());
assert!(validate_path("").is_err());
}
#[test]
fn path_validation_accepts_valid() {
assert!(validate_path("user/decisions.md").is_ok());
assert!(validate_path("project/arch.md").is_ok());
assert!(validate_path("some-file.md").is_ok());
}
#[tokio::test]
async fn t_fp_02_create_path_exists_returns_error() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.redb");
let backend = RedbMemoryBackend::new(&db_path).unwrap();
backend
.create("user/test.md", "initial content")
.await
.unwrap();
let result = backend.create("user/test.md", "overwrite attempt").await;
assert!(
matches!(result, Err(MemoryError::PathExists { ref path }) if path == "user/test.md"),
"expected PathExists, got: {result:?}"
);
let content = backend.view("user/test.md", None).await.unwrap();
assert_eq!(
content, "initial content",
"original content must not be overwritten"
);
}
#[test]
fn t_fp_03_license_file_present() {
let crate_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let license_path = crate_dir.join("LICENSE");
assert!(
license_path.exists(),
"P0-3: LICENSE file missing at {license_path:?}. cargo publish requires LICENSE for MIT crates."
);
}
#[tokio::test]
async fn t_fp_25a_view_returns_range_invalid_for_start_greater_than_end() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.redb");
let backend = RedbMemoryBackend::new(&db_path).unwrap();
backend
.create("user/range_test.md", "line1\nline2\nline3\nline4\nline5")
.await
.unwrap();
let result = backend.view("user/range_test.md", Some([10, 2])).await;
assert!(
matches!(result, Err(MemoryError::RangeInvalid { .. })),
"P1-8: view([10,2]) on 5-line content must return RangeInvalid, got: {result:?}"
);
}
#[tokio::test]
async fn t_fp_25b_view_with_end_beyond_content_clamps_silently() {
let dir = tempfile::tempdir().unwrap();
let db_path = dir.path().join("test.redb");
let backend = RedbMemoryBackend::new(&db_path).unwrap();
backend
.create("user/clamp_test.md", "alpha\nbeta\ngamma")
.await
.unwrap();
let result = backend.view("user/clamp_test.md", Some([1, 9999])).await;
assert!(
result.is_ok(),
"view([1,9999]) on 3-line content should clamp, not error: {result:?}"
);
let content = result.unwrap();
assert_eq!(content, "alpha\nbeta\ngamma");
}
}