1use anyhow::{Context, Result};
7use rusqlite::{Connection, OptionalExtension, params};
8use serde::{Deserialize, Serialize};
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, Mutex};
12use std::time::{SystemTime, UNIX_EPOCH};
13
14pub const MAX_PLANS: usize = 20;
16pub const MAX_STEPS: usize = 50;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct Plan {
22 pub id: String,
23 pub title: String,
24 #[serde(default)]
25 pub description: String,
26 pub steps: Vec<PlanStep>,
27 pub status: PlanStatus,
28 pub created_at: u64,
29 pub updated_at: u64,
30 #[serde(default)]
32 pub body_markdown: String,
33 #[serde(default)]
35 pub comments: Vec<PlanLineComment>,
36 #[serde(default)]
38 pub project_id: String,
39 #[serde(default)]
41 pub session_id: String,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct PlanStep {
47 pub description: String,
48 pub completed: bool,
49 #[serde(default)]
50 pub notes: String,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55#[serde(rename_all = "kebab-case")]
56pub enum PlanStatus {
57 Active,
58 Completed,
59 Abandoned,
60 Proposed,
62}
63
64impl std::fmt::Display for PlanStatus {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 match self {
67 PlanStatus::Active => write!(f, "active"),
68 PlanStatus::Completed => write!(f, "completed"),
69 PlanStatus::Abandoned => write!(f, "abandoned"),
70 PlanStatus::Proposed => write!(f, "proposed"),
71 }
72 }
73}
74
75impl PlanStatus {
76 pub fn parse(s: &str) -> Result<Self> {
77 match s {
78 "active" => Ok(Self::Active),
79 "completed" => Ok(Self::Completed),
80 "abandoned" => Ok(Self::Abandoned),
81 "proposed" => Ok(Self::Proposed),
82 _ => Err(anyhow::anyhow!(
83 "invalid status '{s}', use active/completed/abandoned/proposed"
84 )),
85 }
86 }
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91pub struct PlanLineComment {
92 pub start_line: usize,
94 pub end_line: usize,
96 pub text: String,
97}
98
99#[derive(Clone)]
101pub struct PlanStore {
102 conn: Arc<Mutex<Connection>>,
103 pub db_path: PathBuf,
104}
105
106impl std::fmt::Debug for PlanStore {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 f.debug_struct("PlanStore")
109 .field("db_path", &self.db_path)
110 .finish()
111 }
112}
113
114impl PlanStore {
115 pub fn open(db_path: &Path) -> Result<Self> {
117 if let Some(parent) = db_path.parent() {
118 fs::create_dir_all(parent)
119 .with_context(|| format!("create plan store dir {}", parent.display()))?;
120 }
121 let conn = Connection::open(db_path)
122 .with_context(|| format!("open plans db {}", db_path.display()))?;
123 crate::memory::auto_memory::configure_connection(&conn)?;
124 let store = Self {
125 conn: Arc::new(Mutex::new(conn)),
126 db_path: db_path.to_path_buf(),
127 };
128 store.init_schema()?;
129 Ok(store)
130 }
131
132 pub fn open_default(data_dir: &Path) -> Result<Self> {
134 Self::open(&data_dir.join("plans.sqlite"))
135 }
136
137 fn init_schema(&self) -> Result<()> {
138 let conn = self.conn.lock().unwrap();
139 conn.execute_batch(
140 r#"
141 CREATE TABLE IF NOT EXISTS plans (
142 id TEXT PRIMARY KEY,
143 project_id TEXT NOT NULL,
144 session_id TEXT NOT NULL DEFAULT '',
145 title TEXT NOT NULL,
146 description TEXT NOT NULL DEFAULT '',
147 body_markdown TEXT NOT NULL DEFAULT '',
148 status TEXT NOT NULL,
149 steps_json TEXT NOT NULL,
150 comments_json TEXT NOT NULL DEFAULT '[]',
151 created_at INTEGER NOT NULL,
152 updated_at INTEGER NOT NULL
153 );
154 CREATE INDEX IF NOT EXISTS idx_plans_project_status
155 ON plans(project_id, status, updated_at DESC);
156 "#,
157 )?;
158 Ok(())
159 }
160
161 pub fn migrate_json_dir(&self, plans_root: &Path) -> Result<usize> {
163 if !plans_root.exists() {
164 return Ok(0);
165 }
166 let mut imported = 0usize;
167 for project_entry in fs::read_dir(plans_root)? {
168 let project_entry = project_entry?;
169 if !project_entry.file_type()?.is_dir() {
170 continue;
171 }
172 let project_id = project_entry.file_name().to_string_lossy().to_string();
173 for entry in fs::read_dir(project_entry.path())? {
174 let entry = entry?;
175 let path = entry.path();
176 if path.extension().and_then(|e| e.to_str()) != Some("json") {
177 continue;
178 }
179 let Ok(content) = fs::read_to_string(&path) else {
180 continue;
181 };
182 let Ok(mut plan) = serde_json::from_str::<Plan>(&content) else {
183 continue;
184 };
185 if plan.project_id.is_empty() {
186 plan.project_id = project_id.clone();
187 }
188 if self.get(&plan.id)?.is_none() {
189 self.upsert(&plan)?;
190 imported += 1;
191 }
192 }
193 }
194 Ok(imported)
195 }
196
197 pub fn upsert(&self, plan: &Plan) -> Result<()> {
198 let steps_json = serde_json::to_string(&plan.steps)?;
199 let comments_json = serde_json::to_string(&plan.comments)?;
200 let conn = self.conn.lock().unwrap();
201 conn.execute(
202 r#"
203 INSERT INTO plans (
204 id, project_id, session_id, title, description, body_markdown,
205 status, steps_json, comments_json, created_at, updated_at
206 ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)
207 ON CONFLICT(id) DO UPDATE SET
208 project_id=excluded.project_id,
209 session_id=excluded.session_id,
210 title=excluded.title,
211 description=excluded.description,
212 body_markdown=excluded.body_markdown,
213 status=excluded.status,
214 steps_json=excluded.steps_json,
215 comments_json=excluded.comments_json,
216 updated_at=excluded.updated_at
217 "#,
218 params![
219 plan.id,
220 plan.project_id,
221 plan.session_id,
222 plan.title,
223 plan.description,
224 plan.body_markdown,
225 plan.status.to_string(),
226 steps_json,
227 comments_json,
228 plan.created_at as i64,
229 plan.updated_at as i64,
230 ],
231 )?;
232 Ok(())
233 }
234
235 pub fn get(&self, plan_id: &str) -> Result<Option<Plan>> {
236 let conn = self.conn.lock().unwrap();
237 let mut stmt = conn.prepare(
238 r#"
239 SELECT id, project_id, session_id, title, description, body_markdown,
240 status, steps_json, comments_json, created_at, updated_at
241 FROM plans WHERE id = ?1
242 "#,
243 )?;
244 let plan = stmt.query_row(params![plan_id], row_to_plan).optional()?;
245 Ok(plan)
246 }
247
248 pub fn list(
249 &self,
250 project_id: &str,
251 filter_status: Option<&str>,
252 limit: usize,
253 ) -> Result<Vec<Plan>> {
254 let conn = self.conn.lock().unwrap();
255 let limit = limit.min(MAX_PLANS) as i64;
256 let mut plans = Vec::new();
257 if let Some(status) = filter_status {
258 let mut stmt = conn.prepare(
259 r#"
260 SELECT id, project_id, session_id, title, description, body_markdown,
261 status, steps_json, comments_json, created_at, updated_at
262 FROM plans
263 WHERE project_id = ?1 AND status = ?2
264 ORDER BY updated_at DESC
265 LIMIT ?3
266 "#,
267 )?;
268 let rows = stmt.query_map(params![project_id, status, limit], row_to_plan)?;
269 for row in rows {
270 plans.push(row?);
271 }
272 } else {
273 let mut stmt = conn.prepare(
274 r#"
275 SELECT id, project_id, session_id, title, description, body_markdown,
276 status, steps_json, comments_json, created_at, updated_at
277 FROM plans
278 WHERE project_id = ?1
279 ORDER BY updated_at DESC
280 LIMIT ?2
281 "#,
282 )?;
283 let rows = stmt.query_map(params![project_id, limit], row_to_plan)?;
284 for row in rows {
285 plans.push(row?);
286 }
287 }
288 Ok(plans)
289 }
290
291 pub fn active(&self, project_id: &str) -> Result<Option<Plan>> {
292 let mut plans = self.list(project_id, Some("active"), 1)?;
293 Ok(plans.pop())
294 }
295
296 pub fn set_status(&self, plan_id: &str, status: PlanStatus) -> Result<()> {
297 let mut plan = self
298 .get(plan_id)?
299 .ok_or_else(|| anyhow::anyhow!("plan '{plan_id}' not found"))?;
300 plan.status = status;
301 plan.updated_at = now_ms();
302 self.upsert(&plan)
303 }
304
305 pub fn save_comments(&self, plan_id: &str, comments: Vec<PlanLineComment>) -> Result<()> {
306 let mut plan = self
307 .get(plan_id)?
308 .ok_or_else(|| anyhow::anyhow!("plan '{plan_id}' not found"))?;
309 plan.comments = comments;
310 plan.updated_at = now_ms();
311 self.upsert(&plan)
312 }
313}
314
315fn row_to_plan(row: &rusqlite::Row<'_>) -> rusqlite::Result<Plan> {
316 let steps_json: String = row.get(7)?;
317 let comments_json: String = row.get(8)?;
318 let steps: Vec<PlanStep> = serde_json::from_str(&steps_json).unwrap_or_default();
319 let comments: Vec<PlanLineComment> = serde_json::from_str(&comments_json).unwrap_or_default();
320 let status_str: String = row.get(6)?;
321 let status = PlanStatus::parse(&status_str).unwrap_or(PlanStatus::Active);
322 Ok(Plan {
323 id: row.get(0)?,
324 project_id: row.get(1)?,
325 session_id: row.get(2)?,
326 title: row.get(3)?,
327 description: row.get(4)?,
328 body_markdown: row.get(5)?,
329 status,
330 steps,
331 comments,
332 created_at: row.get::<_, i64>(9)? as u64,
333 updated_at: row.get::<_, i64>(10)? as u64,
334 })
335}
336
337pub fn now_ms() -> u64 {
338 SystemTime::now()
339 .duration_since(UNIX_EPOCH)
340 .map(|d| d.as_millis() as u64)
341 .unwrap_or(0)
342}
343
344pub fn plan_view_lines(plan: &Plan) -> Vec<String> {
346 let mut lines = Vec::new();
347 if !plan.title.is_empty() {
348 lines.push(plan.title.clone());
349 lines.push(String::new());
350 }
351 if !plan.description.trim().is_empty() {
352 for para in plan.description.lines() {
353 lines.push(para.to_string());
354 }
355 lines.push(String::new());
356 }
357 if !plan.body_markdown.trim().is_empty() {
358 for line in plan.body_markdown.lines() {
359 lines.push(line.to_string());
360 }
361 if !plan.steps.is_empty() {
362 lines.push(String::new());
363 }
364 }
365 for (i, step) in plan.steps.iter().enumerate() {
366 let mark = if step.completed { "✓" } else { "•" };
367 lines.push(format!("{mark} {}. {}", i + 1, step.description));
368 if !step.notes.trim().is_empty() {
369 lines.push(format!(" ↳ {}", step.notes.trim()));
370 }
371 }
372 if lines.is_empty() {
373 lines.push("(empty plan)".to_string());
374 }
375 lines
376}
377
378pub fn format_plan_feedback(
380 plan: &Plan,
381 comments: &[PlanLineComment],
382 freeform: &str,
383 decision: &str,
384) -> String {
385 let view = plan_view_lines(plan);
386 let mut out = String::new();
387 out.push_str(&format!("## Plan review feedback (plan_id={})\n", plan.id));
388 out.push_str(&format!("### Decision: {decision}\n"));
389 if !comments.is_empty() {
390 out.push_str("### Line comments\n");
391 for c in comments {
392 let start = c.start_line.min(view.len().saturating_sub(1));
393 let end = c.end_line.min(view.len().saturating_sub(1)).max(start);
394 let snippet: Vec<&str> = view[start..=end]
395 .iter()
396 .map(|s| s.as_str())
397 .filter(|s| !s.is_empty())
398 .collect();
399 let range = if start == end {
400 format!("L{}", start + 1)
401 } else {
402 format!("L{}–{}", start + 1, end + 1)
403 };
404 out.push_str(&format!("- **{range}**"));
405 if !snippet.is_empty() {
406 out.push_str(&format!(" (`{}`)", snippet.join(" / ")));
407 }
408 out.push_str(&format!(": {}\n", c.text.trim()));
409 }
410 }
411 let free = freeform.trim();
412 if !free.is_empty() {
413 out.push_str("### Freeform notes\n");
414 out.push_str(free);
415 out.push('\n');
416 }
417 out
418}
419
420#[cfg(test)]
421mod tests {
422 use super::*;
423 use tempfile::tempdir;
424
425 #[test]
426 fn upsert_get_list_roundtrip() {
427 let dir = tempdir().unwrap();
428 let store = PlanStore::open(&dir.path().join("plans.sqlite")).unwrap();
429 let plan = Plan {
430 id: "plan-1".into(),
431 title: "Ship feature".into(),
432 description: "Do the thing".into(),
433 steps: vec![PlanStep {
434 description: "Write code".into(),
435 completed: false,
436 notes: String::new(),
437 }],
438 status: PlanStatus::Active,
439 created_at: 1,
440 updated_at: 2,
441 body_markdown: String::new(),
442 comments: Vec::new(),
443 project_id: "proj".into(),
444 session_id: "sess".into(),
445 };
446 store.upsert(&plan).unwrap();
447 let loaded = store.get("plan-1").unwrap().expect("plan");
448 assert_eq!(loaded.title, "Ship feature");
449 assert_eq!(loaded.steps.len(), 1);
450 let list = store.list("proj", Some("active"), 10).unwrap();
451 assert_eq!(list.len(), 1);
452 }
453
454 #[test]
455 fn view_lines_and_feedback() {
456 let plan = Plan {
457 id: "p".into(),
458 title: "T".into(),
459 description: "D".into(),
460 steps: vec![PlanStep {
461 description: "step one".into(),
462 completed: false,
463 notes: String::new(),
464 }],
465 status: PlanStatus::Proposed,
466 created_at: 0,
467 updated_at: 0,
468 body_markdown: String::new(),
469 comments: Vec::new(),
470 project_id: String::new(),
471 session_id: String::new(),
472 };
473 let lines = plan_view_lines(&plan);
474 assert!(lines.iter().any(|l| l.contains("step one")));
475 let fb = format_plan_feedback(
476 &plan,
477 &[PlanLineComment {
478 start_line: 0,
479 end_line: 0,
480 text: "rename".into(),
481 }],
482 "more detail",
483 "request_changes",
484 );
485 assert!(fb.contains("rename"));
486 assert!(fb.contains("request_changes"));
487 }
488}