Skip to main content

navi_core/
plan_store.rs

1//! SQLite-backed plan persistence (reviewable work plans).
2//!
3//! Replaces the per-plan JSON files under `data_dir/plans/<project>/` with a
4//! single SQLite database so plans survive restarts and support line comments.
5
6use 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
14/// Maximum plans returned by list.
15pub const MAX_PLANS: usize = 20;
16/// Maximum steps per plan.
17pub const MAX_STEPS: usize = 50;
18
19/// A work plan with checklist steps.
20#[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    /// Optional freeform body used for line-oriented review (markdown).
31    #[serde(default)]
32    pub body_markdown: String,
33    /// User line comments from the review modal.
34    #[serde(default)]
35    pub comments: Vec<PlanLineComment>,
36    /// Project scope key (hash of project root).
37    #[serde(default)]
38    pub project_id: String,
39    /// Session that created/last reviewed the plan.
40    #[serde(default)]
41    pub session_id: String,
42}
43
44/// A single step in a plan.
45#[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/// Plan lifecycle status.
54#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
55#[serde(rename_all = "kebab-case")]
56pub enum PlanStatus {
57    Active,
58    Completed,
59    Abandoned,
60    /// Awaiting user review in the TUI modal.
61    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/// Inline comment on a line range of the plan preview.
90#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
91pub struct PlanLineComment {
92    /// Inclusive start line (0-based) in the rendered plan view.
93    pub start_line: usize,
94    /// Inclusive end line (0-based).
95    pub end_line: usize,
96    pub text: String,
97}
98
99/// Thread-safe SQLite plan store.
100#[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    /// Open (or create) the plans database at `db_path`.
116    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    /// Default path: `{data_dir}/plans.sqlite`.
133    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    /// Import legacy JSON plan files from `data_dir/plans/<project_hash>/*.json`.
162    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
344/// Build display lines for the review modal (stable line indices for comments).
345pub 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
378/// Format review feedback for the agent.
379pub 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}