Skip to main content

skilllite_executor/
plan.rs

1//! Plan store: append-only jsonl, similar to transcript.
2//!
3//! Each plan is appended as a JSON line. Supports reading latest plan.
4//! Backward compatible: can still read legacy single .json files.
5
6use anyhow::{Context, Result};
7use serde_json::Value;
8use std::fs::OpenOptions;
9use std::io::{BufRead, BufReader, Write};
10use std::path::{Path, PathBuf};
11
12fn date_today() -> String {
13    chrono::Local::now().format("%Y-%m-%d").to_string()
14}
15
16/// Path for plan jsonl file: plans/{session_key}-{date}.jsonl
17pub fn plan_path_jsonl(plans_dir: &Path, session_key: &str, date: Option<&str>) -> PathBuf {
18    let date_str = date.map(|s| s.to_string()).unwrap_or_else(date_today);
19    plans_dir.join(format!("{}-{}.jsonl", session_key, date_str))
20}
21
22/// Legacy path: plans/{session_key}-{date}.json (single file, overwrite)
23pub fn plan_path_legacy(plans_dir: &Path, session_key: &str, date: Option<&str>) -> PathBuf {
24    let date_str = date.map(|s| s.to_string()).unwrap_or_else(date_today);
25    plans_dir.join(format!("{}-{}.json", session_key, date_str))
26}
27
28/// Append a plan entry to the jsonl file.
29pub fn append_plan(plans_dir: &Path, session_key: &str, plan_json: &Value) -> Result<()> {
30    let path = plan_path_jsonl(plans_dir, session_key, None);
31    if let Some(parent) = path.parent() {
32        std::fs::create_dir_all(parent)?;
33    }
34    let mut file = OpenOptions::new()
35        .create(true)
36        .append(true)
37        .open(&path)
38        .with_context(|| format!("Failed to open plan: {}", path.display()))?;
39    let line = serde_json::to_string(plan_json)?;
40    writeln!(file, "{}", line)?;
41    Ok(())
42}
43
44/// Read plan entries from jsonl. Returns all entries in order.
45fn read_plan_entries(
46    plans_dir: &Path,
47    session_key: &str,
48    date: Option<&str>,
49) -> Result<Vec<Value>> {
50    let path = plan_path_jsonl(plans_dir, session_key, date);
51    if !path.exists() {
52        return Ok(Vec::new());
53    }
54    let file = std::fs::File::open(&path)
55        .with_context(|| format!("Failed to open plan: {}", path.display()))?;
56    let reader = BufReader::new(file);
57    let mut entries = Vec::new();
58    for line in reader.lines() {
59        let line = line?;
60        let line = line.trim();
61        if line.is_empty() {
62            continue;
63        }
64        let v: Value = serde_json::from_str(line)?;
65        entries.push(v);
66    }
67    Ok(entries)
68}
69
70/// Read the latest plan. Tries jsonl first (last entry), then legacy .json.
71pub fn read_latest_plan(
72    plans_dir: &Path,
73    session_key: &str,
74    date: Option<&str>,
75) -> Result<Option<Value>> {
76    let entries = read_plan_entries(plans_dir, session_key, date)?;
77    if let Some(last) = entries.last() {
78        return Ok(Some(last.clone()));
79    }
80    // Fallback: legacy single .json file
81    let legacy_path = plan_path_legacy(plans_dir, session_key, date);
82    if legacy_path.exists() {
83        let content = skilllite_fs::read_file(&legacy_path)?;
84        let plan: Value = serde_json::from_str(&content)?;
85        return Ok(Some(plan));
86    }
87    Ok(None)
88}
89
90/// List all plan files for a session (for UI / history browsing).
91pub fn list_plan_files(plans_dir: &Path, session_key: &str) -> Result<Vec<PathBuf>> {
92    if !plans_dir.exists() {
93        return Ok(Vec::new());
94    }
95    let mut files: Vec<PathBuf> = skilllite_fs::read_dir(plans_dir)
96        .with_context(|| format!("Failed to read plans dir: {}", plans_dir.display()))?
97        .into_iter()
98        .map(|(p, _)| p)
99        .filter(|p| p.is_file())
100        .filter(|p| {
101            p.extension().is_some_and(|e| e == "jsonl" || e == "json")
102                && p.file_stem()
103                    .and_then(|s| s.to_str())
104                    .is_some_and(|n| n.starts_with(session_key))
105        })
106        .collect();
107    files.sort_by(|a, b| {
108        std::fs::metadata(a)
109            .and_then(|m| m.modified())
110            .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
111            .cmp(
112                &std::fs::metadata(b)
113                    .and_then(|m| m.modified())
114                    .unwrap_or(std::time::SystemTime::UNIX_EPOCH),
115            )
116    });
117    Ok(files)
118}