Skip to main content

bamboo_memory/
plan_store.rs

1//! Plan file persistence for plan mode sessions.
2//!
3//! Stores plan markdown files in `${BAMBOO_DATA_DIR}/plans/{session_slug}.md`.
4
5use std::path::{Path, PathBuf};
6
7/// Error type for plan store operations.
8#[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/// Stores and retrieves plan files for sessions.
17#[derive(Debug, Clone)]
18pub struct PlanStore {
19    plans_dir: PathBuf,
20}
21
22impl PlanStore {
23    /// Create a new plan store with the given base data directory.
24    ///
25    /// Plans are stored in `{data_dir}/plans/`.
26    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    /// Generate a slug from a session ID suitable for use as a filename.
39    ///
40    /// Uses the first 8 characters of the session ID plus a hash of the remainder
41    /// to produce a short, deterministic, filesystem-safe identifier.
42    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    /// Path to the plan file for a given session.
63    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    /// Write a plan for the given session.
69    ///
70    /// Overwrites any existing plan for this session.
71    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    /// Read the plan for the given session, if it exists.
82    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    /// Check whether a plan exists for the given session.
88    pub fn plan_exists(&self, session_id: &str) -> bool {
89        self.plan_file_path(session_id).exists()
90    }
91
92    /// Delete the plan for the given session.
93    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    /// Get the storage directory path.
102    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        // Should not error
175        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}