1use anyhow::{Context, Result};
11use rusqlite::{Connection, OptionalExtension, params};
12use serde::{Deserialize, Serialize};
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, Mutex};
16use std::time::{SystemTime, UNIX_EPOCH};
17
18pub const MAX_PLANS: usize = 20;
20pub const MAX_STEPS: usize = 50;
22
23pub fn plans_dir(data_dir: &Path) -> PathBuf {
25 data_dir.join("plans")
26}
27
28pub fn sanitize_plan_slug(raw: &str) -> String {
30 let mut out = String::with_capacity(raw.len());
31 for ch in raw.chars() {
32 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
33 out.push(ch);
34 } else {
35 out.push('_');
36 }
37 }
38 let trimmed = out.trim_matches('_');
39 if trimmed.is_empty() {
40 "session".to_string()
41 } else {
42 trimmed.chars().take(80).collect()
43 }
44}
45
46pub fn session_plan_file_path(data_dir: &Path, session_id: &str) -> PathBuf {
48 plans_dir(data_dir).join(format!("{}.md", sanitize_plan_slug(session_id)))
49}
50
51pub fn project_plan_file_path(data_dir: &Path, project_id: &str) -> PathBuf {
53 plans_dir(data_dir)
54 .join(sanitize_plan_slug(project_id))
55 .join("plan.md")
56}
57
58pub fn is_under_plans_dir(data_dir: &Path, path: &Path) -> bool {
60 let plans = plans_dir(data_dir);
61 path.starts_with(&plans)
62}
63
64pub fn read_plan_file(path: &Path) -> Option<String> {
66 let content = fs::read_to_string(path).ok()?;
67 if content.trim().is_empty() {
68 None
69 } else {
70 Some(content)
71 }
72}
73
74pub fn write_plan_file(path: &Path, markdown: &str) -> Result<()> {
76 if let Some(parent) = path.parent() {
77 fs::create_dir_all(parent)
78 .with_context(|| format!("create plan file dir {}", parent.display()))?;
79 }
80 fs::write(path, markdown).with_context(|| format!("write plan file {}", path.display()))?;
81 Ok(())
82}
83
84pub fn title_from_markdown(body: &str) -> String {
86 for line in body.lines() {
87 let t = line.trim();
88 if t.is_empty() {
89 continue;
90 }
91 if let Some(rest) = t.strip_prefix("# ") {
92 let title = rest.trim();
93 if !title.is_empty() {
94 return truncate_plan_title(title);
95 }
96 }
97 return truncate_plan_title(t.trim_start_matches('#').trim());
98 }
99 "Plan".to_string()
100}
101
102fn truncate_plan_title(s: &str) -> String {
103 let t = s.trim();
104 if t.chars().count() <= 80 {
105 t.to_string()
106 } else {
107 let mut out: String = t.chars().take(79).collect();
108 out.push('…');
109 out
110 }
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct Plan {
116 pub id: String,
117 pub title: String,
118 #[serde(default)]
119 pub description: String,
120 pub steps: Vec<PlanStep>,
121 pub status: PlanStatus,
122 pub created_at: u64,
123 pub updated_at: u64,
124 #[serde(default)]
126 pub body_markdown: String,
127 #[serde(default)]
129 pub comments: Vec<PlanLineComment>,
130 #[serde(default)]
132 pub project_id: String,
133 #[serde(default)]
135 pub session_id: String,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct PlanStep {
141 pub description: String,
142 pub completed: bool,
143 #[serde(default)]
144 pub notes: String,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
149#[serde(rename_all = "kebab-case")]
150pub enum PlanStatus {
151 Active,
152 Completed,
153 Abandoned,
154 Proposed,
156}
157
158impl std::fmt::Display for PlanStatus {
159 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
160 match self {
161 PlanStatus::Active => write!(f, "active"),
162 PlanStatus::Completed => write!(f, "completed"),
163 PlanStatus::Abandoned => write!(f, "abandoned"),
164 PlanStatus::Proposed => write!(f, "proposed"),
165 }
166 }
167}
168
169impl PlanStatus {
170 pub fn parse(s: &str) -> Result<Self> {
171 match s {
172 "active" => Ok(Self::Active),
173 "completed" => Ok(Self::Completed),
174 "abandoned" => Ok(Self::Abandoned),
175 "proposed" => Ok(Self::Proposed),
176 _ => Err(anyhow::anyhow!(
177 "invalid status '{s}', use active/completed/abandoned/proposed"
178 )),
179 }
180 }
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
185pub struct PlanLineComment {
186 pub start_line: usize,
188 pub end_line: usize,
190 pub text: String,
191}
192
193#[derive(Clone)]
195pub struct PlanStore {
196 conn: Arc<Mutex<Connection>>,
197 pub db_path: PathBuf,
198}
199
200impl std::fmt::Debug for PlanStore {
201 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
202 f.debug_struct("PlanStore")
203 .field("db_path", &self.db_path)
204 .finish()
205 }
206}
207
208impl PlanStore {
209 pub fn open(db_path: &Path) -> Result<Self> {
211 if let Some(parent) = db_path.parent() {
212 fs::create_dir_all(parent)
213 .with_context(|| format!("create plan store dir {}", parent.display()))?;
214 }
215 let conn = Connection::open(db_path)
216 .with_context(|| format!("open plans db {}", db_path.display()))?;
217 crate::memory::auto_memory::configure_connection(&conn)?;
218 let store = Self {
219 conn: Arc::new(Mutex::new(conn)),
220 db_path: db_path.to_path_buf(),
221 };
222 store.init_schema()?;
223 Ok(store)
224 }
225
226 pub fn open_default(data_dir: &Path) -> Result<Self> {
228 Self::open(&data_dir.join("plans.sqlite"))
229 }
230
231 fn init_schema(&self) -> Result<()> {
232 let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
234 conn.execute_batch(
235 r#"
236 CREATE TABLE IF NOT EXISTS plans (
237 id TEXT PRIMARY KEY,
238 project_id TEXT NOT NULL,
239 session_id TEXT NOT NULL DEFAULT '',
240 title TEXT NOT NULL,
241 description TEXT NOT NULL DEFAULT '',
242 body_markdown TEXT NOT NULL DEFAULT '',
243 status TEXT NOT NULL,
244 steps_json TEXT NOT NULL,
245 comments_json TEXT NOT NULL DEFAULT '[]',
246 created_at INTEGER NOT NULL,
247 updated_at INTEGER NOT NULL
248 );
249 CREATE INDEX IF NOT EXISTS idx_plans_project_status
250 ON plans(project_id, status, updated_at DESC);
251 "#,
252 )?;
253 Ok(())
254 }
255
256 pub fn migrate_json_dir(&self, plans_root: &Path) -> Result<usize> {
258 if !plans_root.exists() {
259 return Ok(0);
260 }
261 let mut imported = 0usize;
262 for project_entry in fs::read_dir(plans_root)? {
263 let project_entry = project_entry?;
264 if !project_entry.file_type()?.is_dir() {
265 continue;
266 }
267 let project_id = project_entry.file_name().to_string_lossy().to_string();
268 for entry in fs::read_dir(project_entry.path())? {
269 let entry = entry?;
270 let path = entry.path();
271 if path.extension().and_then(|e| e.to_str()) != Some("json") {
272 continue;
273 }
274 let Ok(content) = fs::read_to_string(&path) else {
275 continue;
276 };
277 let Ok(mut plan) = serde_json::from_str::<Plan>(&content) else {
278 continue;
279 };
280 if plan.project_id.is_empty() {
281 plan.project_id = project_id.clone();
282 }
283 if self.get(&plan.id)?.is_none() {
284 self.upsert(&plan)?;
285 imported += 1;
286 }
287 }
288 }
289 Ok(imported)
290 }
291
292 pub fn upsert(&self, plan: &Plan) -> Result<()> {
293 let steps_json = serde_json::to_string(&plan.steps)?;
294 let comments_json = serde_json::to_string(&plan.comments)?;
295 let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
296 conn.execute(
297 r#"
298 INSERT INTO plans (
299 id, project_id, session_id, title, description, body_markdown,
300 status, steps_json, comments_json, created_at, updated_at
301 ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)
302 ON CONFLICT(id) DO UPDATE SET
303 project_id=excluded.project_id,
304 session_id=excluded.session_id,
305 title=excluded.title,
306 description=excluded.description,
307 body_markdown=excluded.body_markdown,
308 status=excluded.status,
309 steps_json=excluded.steps_json,
310 comments_json=excluded.comments_json,
311 updated_at=excluded.updated_at
312 "#,
313 params![
314 plan.id,
315 plan.project_id,
316 plan.session_id,
317 plan.title,
318 plan.description,
319 plan.body_markdown,
320 plan.status.to_string(),
321 steps_json,
322 comments_json,
323 plan.created_at as i64,
324 plan.updated_at as i64,
325 ],
326 )?;
327 Ok(())
328 }
329
330 pub fn get(&self, plan_id: &str) -> Result<Option<Plan>> {
331 let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
332 let mut stmt = conn.prepare(
333 r#"
334 SELECT id, project_id, session_id, title, description, body_markdown,
335 status, steps_json, comments_json, created_at, updated_at
336 FROM plans WHERE id = ?1
337 "#,
338 )?;
339 let plan = stmt.query_row(params![plan_id], row_to_plan).optional()?;
340 Ok(plan)
341 }
342
343 pub fn list(
344 &self,
345 project_id: &str,
346 filter_status: Option<&str>,
347 limit: usize,
348 ) -> Result<Vec<Plan>> {
349 let conn = self.conn.lock().unwrap_or_else(|e| e.into_inner());
350 let limit = limit.min(MAX_PLANS) as i64;
351 let mut plans = Vec::new();
352 if let Some(status) = filter_status {
353 let mut stmt = conn.prepare(
354 r#"
355 SELECT id, project_id, session_id, title, description, body_markdown,
356 status, steps_json, comments_json, created_at, updated_at
357 FROM plans
358 WHERE project_id = ?1 AND status = ?2
359 ORDER BY updated_at DESC
360 LIMIT ?3
361 "#,
362 )?;
363 let rows = stmt.query_map(params![project_id, status, limit], row_to_plan)?;
364 for row in rows {
365 plans.push(row?);
366 }
367 } else {
368 let mut stmt = conn.prepare(
369 r#"
370 SELECT id, project_id, session_id, title, description, body_markdown,
371 status, steps_json, comments_json, created_at, updated_at
372 FROM plans
373 WHERE project_id = ?1
374 ORDER BY updated_at DESC
375 LIMIT ?2
376 "#,
377 )?;
378 let rows = stmt.query_map(params![project_id, limit], row_to_plan)?;
379 for row in rows {
380 plans.push(row?);
381 }
382 }
383 Ok(plans)
384 }
385
386 pub fn active(&self, project_id: &str) -> Result<Option<Plan>> {
387 let mut plans = self.list(project_id, Some("active"), 1)?;
388 Ok(plans.pop())
389 }
390
391 pub fn set_status(&self, plan_id: &str, status: PlanStatus) -> Result<()> {
392 let mut plan = self
393 .get(plan_id)?
394 .ok_or_else(|| anyhow::anyhow!("plan '{plan_id}' not found"))?;
395 plan.status = status;
396 plan.updated_at = now_ms();
397 self.upsert(&plan)
398 }
399
400 pub fn save_comments(&self, plan_id: &str, comments: Vec<PlanLineComment>) -> Result<()> {
401 let mut plan = self
402 .get(plan_id)?
403 .ok_or_else(|| anyhow::anyhow!("plan '{plan_id}' not found"))?;
404 plan.comments = comments;
405 plan.updated_at = now_ms();
406 self.upsert(&plan)
407 }
408}
409
410fn row_to_plan(row: &rusqlite::Row<'_>) -> rusqlite::Result<Plan> {
411 let steps_json: String = row.get(7)?;
412 let comments_json: String = row.get(8)?;
413 let steps: Vec<PlanStep> = serde_json::from_str(&steps_json).unwrap_or_default();
414 let comments: Vec<PlanLineComment> = serde_json::from_str(&comments_json).unwrap_or_default();
415 let status_str: String = row.get(6)?;
416 let status = PlanStatus::parse(&status_str).unwrap_or(PlanStatus::Active);
417 Ok(Plan {
418 id: row.get(0)?,
419 project_id: row.get(1)?,
420 session_id: row.get(2)?,
421 title: row.get(3)?,
422 description: row.get(4)?,
423 body_markdown: row.get(5)?,
424 status,
425 steps,
426 comments,
427 created_at: row.get::<_, i64>(9)? as u64,
428 updated_at: row.get::<_, i64>(10)? as u64,
429 })
430}
431
432pub fn now_ms() -> u64 {
433 SystemTime::now()
434 .duration_since(UNIX_EPOCH)
435 .map(|d| d.as_millis() as u64)
436 .unwrap_or(0)
437}
438
439pub fn plan_view_lines(plan: &Plan) -> Vec<String> {
444 let mut lines = Vec::new();
445 if !plan.body_markdown.trim().is_empty() {
446 for line in plan.body_markdown.lines() {
447 lines.push(line.to_string());
448 }
449 return lines;
450 }
451 if !plan.title.is_empty() {
452 lines.push(plan.title.clone());
453 lines.push(String::new());
454 }
455 if !plan.description.trim().is_empty() {
456 for para in plan.description.lines() {
457 lines.push(para.to_string());
458 }
459 lines.push(String::new());
460 }
461 for (i, step) in plan.steps.iter().enumerate() {
462 let mark = if step.completed { "✓" } else { "•" };
463 lines.push(format!("{mark} {}. {}", i + 1, step.description));
464 if !step.notes.trim().is_empty() {
465 lines.push(format!(" ↳ {}", step.notes.trim()));
466 }
467 }
468 if lines.is_empty() {
469 lines.push("(empty plan)".to_string());
470 }
471 lines
472}
473
474pub fn format_plan_feedback(
476 plan: &Plan,
477 comments: &[PlanLineComment],
478 freeform: &str,
479 decision: &str,
480) -> String {
481 let view = plan_view_lines(plan);
482 let mut out = String::new();
483 out.push_str(&format!("## Plan review feedback (plan_id={})\n", plan.id));
484 out.push_str(&format!("### Decision: {decision}\n"));
485 if !comments.is_empty() {
486 out.push_str("### Line comments\n");
487 for c in comments {
488 let start = c.start_line.min(view.len().saturating_sub(1));
489 let end = c.end_line.min(view.len().saturating_sub(1)).max(start);
490 let snippet: Vec<&str> = view[start..=end]
491 .iter()
492 .map(|s| s.as_str())
493 .filter(|s| !s.is_empty())
494 .collect();
495 let range = if start == end {
496 format!("L{}", start + 1)
497 } else {
498 format!("L{}–{}", start + 1, end + 1)
499 };
500 out.push_str(&format!("- **{range}**"));
501 if !snippet.is_empty() {
502 out.push_str(&format!(" (`{}`)", snippet.join(" / ")));
503 }
504 out.push_str(&format!(": {}\n", c.text.trim()));
505 }
506 }
507 let free = freeform.trim();
508 if !free.is_empty() {
509 out.push_str("### Freeform notes\n");
510 out.push_str(free);
511 out.push('\n');
512 }
513 out
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519 use tempfile::tempdir;
520
521 #[test]
522 fn upsert_get_list_roundtrip() {
523 let dir = tempdir().unwrap();
524 let store = PlanStore::open(&dir.path().join("plans.sqlite")).unwrap();
525 let plan = Plan {
526 id: "plan-1".into(),
527 title: "Ship feature".into(),
528 description: "Do the thing".into(),
529 steps: vec![PlanStep {
530 description: "Write code".into(),
531 completed: false,
532 notes: String::new(),
533 }],
534 status: PlanStatus::Active,
535 created_at: 1,
536 updated_at: 2,
537 body_markdown: String::new(),
538 comments: Vec::new(),
539 project_id: "proj".into(),
540 session_id: "sess".into(),
541 };
542 store.upsert(&plan).unwrap();
543 let loaded = store.get("plan-1").unwrap().expect("plan");
544 assert_eq!(loaded.title, "Ship feature");
545 assert_eq!(loaded.steps.len(), 1);
546 let list = store.list("proj", Some("active"), 10).unwrap();
547 assert_eq!(list.len(), 1);
548 }
549
550 #[test]
551 fn view_lines_and_feedback() {
552 let plan = Plan {
553 id: "p".into(),
554 title: "T".into(),
555 description: "D".into(),
556 steps: vec![PlanStep {
557 description: "step one".into(),
558 completed: false,
559 notes: String::new(),
560 }],
561 status: PlanStatus::Proposed,
562 created_at: 0,
563 updated_at: 0,
564 body_markdown: String::new(),
565 comments: Vec::new(),
566 project_id: String::new(),
567 session_id: String::new(),
568 };
569 let lines = plan_view_lines(&plan);
570 assert!(lines.iter().any(|l| l.contains("step one")));
571 let fb = format_plan_feedback(
572 &plan,
573 &[PlanLineComment {
574 start_line: 0,
575 end_line: 0,
576 text: "rename".into(),
577 }],
578 "more detail",
579 "request_changes",
580 );
581 assert!(fb.contains("rename"));
582 assert!(fb.contains("request_changes"));
583 }
584
585 #[test]
586 fn view_lines_prefer_markdown_body() {
587 let plan = Plan {
588 id: "p".into(),
589 title: "ignored title when body present".into(),
590 description: "ignored".into(),
591 steps: vec![PlanStep {
592 description: "should not appear".into(),
593 completed: false,
594 notes: String::new(),
595 }],
596 status: PlanStatus::Proposed,
597 created_at: 0,
598 updated_at: 0,
599 body_markdown: "# Real Plan\n\n## Context\n\nDo the thing.\n".into(),
600 comments: Vec::new(),
601 project_id: String::new(),
602 session_id: String::new(),
603 };
604 let lines = plan_view_lines(&plan);
605 assert_eq!(lines[0], "# Real Plan");
606 assert!(lines.iter().any(|l| l.contains("Context")));
607 assert!(!lines.iter().any(|l| l.contains("should not appear")));
608 }
609
610 #[test]
611 fn plan_file_roundtrip() {
612 let dir = tempdir().unwrap();
613 let path = session_plan_file_path(dir.path(), "sess/with:weird");
614 write_plan_file(&path, "# Hello\n\nBody\n").unwrap();
615 let read = read_plan_file(&path).unwrap();
616 assert!(read.contains("# Hello"));
617 assert!(is_under_plans_dir(dir.path(), &path));
618 assert_eq!(title_from_markdown(&read), "Hello");
619 }
620}