bamboo_memory/
plan_store.rs1use std::path::{Path, PathBuf};
6
7#[derive(Debug, thiserror::Error)]
9pub enum PlanStoreError {
10 #[error("IO error: {0}")]
11 Io(#[from] std::io::Error),
12 #[error("Plan directory not accessible: {0}")]
13 DirectoryNotAccessible(String),
14}
15
16#[derive(Debug, Clone)]
18pub struct PlanStore {
19 plans_dir: PathBuf,
20}
21
22impl PlanStore {
23 pub fn new(data_dir: impl AsRef<Path>) -> Result<Self, PlanStoreError> {
27 let plans_dir = data_dir.as_ref().join("plans");
28 std::fs::create_dir_all(&plans_dir).map_err(|e| {
29 PlanStoreError::DirectoryNotAccessible(format!(
30 "Failed to create plans directory at {}: {}",
31 plans_dir.display(),
32 e
33 ))
34 })?;
35 Ok(Self { plans_dir })
36 }
37
38 fn session_slug(session_id: &str) -> String {
43 let clean = session_id
44 .chars()
45 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
46 .collect::<String>();
47
48 if clean.len() <= 16 {
49 return clean;
50 }
51
52 let prefix = &clean[..8];
53 let suffix = &clean[clean.len() - 8..];
54 format!(
55 "{}-{}-{:x}",
56 prefix,
57 suffix,
58 seahash::hash(clean.as_bytes())
59 )
60 }
61
62 pub fn plan_file_path(&self, session_id: &str) -> PathBuf {
64 let slug = Self::session_slug(session_id);
65 self.plans_dir.join(format!("{}.md", slug))
66 }
67
68 pub fn write_plan(
72 &self,
73 session_id: &str,
74 content: impl AsRef<str>,
75 ) -> Result<PathBuf, PlanStoreError> {
76 let path = self.plan_file_path(session_id);
77 std::fs::write(&path, content.as_ref())?;
78 Ok(path)
79 }
80
81 pub fn read_plan(&self, session_id: &str) -> Option<String> {
83 let path = self.plan_file_path(session_id);
84 std::fs::read_to_string(&path).ok()
85 }
86
87 pub fn plan_exists(&self, session_id: &str) -> bool {
89 self.plan_file_path(session_id).exists()
90 }
91
92 pub fn delete_plan(&self, session_id: &str) -> Result<(), PlanStoreError> {
94 let path = self.plan_file_path(session_id);
95 if path.exists() {
96 std::fs::remove_file(&path)?;
97 }
98 Ok(())
99 }
100
101 pub fn plans_dir(&self) -> &Path {
103 &self.plans_dir
104 }
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110
111 fn temp_store() -> PlanStore {
112 let temp_dir = std::env::temp_dir().join(format!("plan_store_test_{}", std::process::id()));
113 let _ = std::fs::remove_dir_all(&temp_dir);
114 PlanStore::new(&temp_dir).unwrap()
115 }
116
117 #[test]
118 fn session_slug_produces_short_identifier() {
119 let id = "sess-abc123-def456-ghi789";
120 let slug = PlanStore::session_slug(id);
121 assert!(!slug.is_empty());
122 assert!(!slug.contains('/'));
123 assert!(!slug.contains('\\'));
124 }
125
126 #[test]
127 fn write_and_read_plan() {
128 let store = temp_store();
129 let session_id = "test-session-001";
130 let content = "# Implementation Plan\n\n1. Step one\n2. Step two\n";
131
132 let path = store.write_plan(session_id, content).unwrap();
133 assert!(path.exists());
134 assert!(store.plan_exists(session_id));
135
136 let read = store.read_plan(session_id).unwrap();
137 assert_eq!(read, content);
138 }
139
140 #[test]
141 fn read_nonexistent_plan_returns_none() {
142 let store = temp_store();
143 assert!(store.read_plan("nonexistent-session").is_none());
144 assert!(!store.plan_exists("nonexistent-session"));
145 }
146
147 #[test]
148 fn write_plan_overwrites_existing() {
149 let store = temp_store();
150 let session_id = "test-session-002";
151
152 store.write_plan(session_id, "Plan v1").unwrap();
153 store.write_plan(session_id, "Plan v2").unwrap();
154
155 let read = store.read_plan(session_id).unwrap();
156 assert_eq!(read, "Plan v2");
157 }
158
159 #[test]
160 fn delete_plan_removes_file() {
161 let store = temp_store();
162 let session_id = "test-session-003";
163
164 store.write_plan(session_id, "Plan to delete").unwrap();
165 assert!(store.plan_exists(session_id));
166
167 store.delete_plan(session_id).unwrap();
168 assert!(!store.plan_exists(session_id));
169 }
170
171 #[test]
172 fn delete_nonexistent_plan_is_noop() {
173 let store = temp_store();
174 store.delete_plan("never-created").unwrap();
176 }
177
178 #[test]
179 fn plan_file_path_is_under_plans_dir() {
180 let store = temp_store();
181 let path = store.plan_file_path("some-session");
182 assert!(path.starts_with(&store.plans_dir));
183 assert_eq!(path.extension().unwrap(), "md");
184 }
185
186 #[test]
187 fn session_slug_handles_short_id() {
188 let id = "short";
189 let slug = PlanStore::session_slug(id);
190 assert_eq!(slug, "short");
191 }
192
193 #[test]
194 fn session_slug_strips_special_chars() {
195 let id = "sess/abc\\def:ghi";
196 let slug = PlanStore::session_slug(id);
197 assert!(!slug.contains('/'));
198 assert!(!slug.contains('\\'));
199 assert!(!slug.contains(':'));
200 }
201}