Skip to main content

navi_core/
plan_store.rs

1//! SQLite-backed plan persistence (reviewable work plans).
2//!
3//! The **source of truth for plan content** is a markdown design-doc file under
4//! `{data_dir}/plans/`. SQLite holds metadata,
5//! checklist steps derived from the markdown, review comments, and status.
6//!
7//! Legacy per-plan JSON under `data_dir/plans/<project>/*.json` is still
8//! imported once via [`PlanStore::migrate_json_dir`].
9
10use 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
18/// Maximum plans returned by list.
19pub const MAX_PLANS: usize = 20;
20/// Maximum steps per plan.
21pub const MAX_STEPS: usize = 50;
22
23/// Root directory for on-disk markdown plan files: `{data_dir}/plans`.
24pub fn plans_dir(data_dir: &Path) -> PathBuf {
25    data_dir.join("plans")
26}
27
28/// Sanitize a session id for use as a filename stem.
29pub 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
46/// Session-scoped plan markdown path: `{data_dir}/plans/{session}.md`.
47pub 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
51/// Project-scoped fallback plan path: `{data_dir}/plans/{project_id}/plan.md`.
52pub 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
58/// Whether `path` is under the agent-writable plans directory.
59pub 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
64/// Read plan markdown from disk. Returns `None` if missing or empty.
65pub 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
74/// Write plan markdown to disk (creates parent directories).
75pub 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
84/// Extract a short title from markdown (`# Heading` or first non-empty line).
85pub 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/// A work plan with checklist steps.
114#[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    /// Optional freeform body used for line-oriented review (markdown).
125    #[serde(default)]
126    pub body_markdown: String,
127    /// User line comments from the review modal.
128    #[serde(default)]
129    pub comments: Vec<PlanLineComment>,
130    /// Project scope key (hash of project root).
131    #[serde(default)]
132    pub project_id: String,
133    /// Session that created/last reviewed the plan.
134    #[serde(default)]
135    pub session_id: String,
136}
137
138/// A single step in a plan.
139#[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/// Plan lifecycle status.
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
149#[serde(rename_all = "kebab-case")]
150pub enum PlanStatus {
151    Active,
152    Completed,
153    Abandoned,
154    /// Awaiting user review in the TUI modal.
155    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/// Inline comment on a line range of the plan preview.
184#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
185pub struct PlanLineComment {
186    /// Inclusive start line (0-based) in the rendered plan view.
187    pub start_line: usize,
188    /// Inclusive end line (0-based).
189    pub end_line: usize,
190    pub text: String,
191}
192
193/// Thread-safe SQLite plan store.
194#[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    /// Open (or create) the plans database at `db_path`.
210    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    /// Default path: `{data_dir}/plans.sqlite`.
227    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        // Recover from poisoned mutex so a prior panic does not brick the store.
233        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    /// Import legacy JSON plan files from `data_dir/plans/<project_hash>/*.json`.
257    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
439/// Build display lines for the review modal (stable line indices for comments).
440///
441/// Markdown body is the primary view (design-doc style). Checklist steps are
442/// only rendered when there is no markdown body.
443pub 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
474/// Format review feedback for the agent.
475pub 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}