Skip to main content

claude_wrapper/
plans.rs

1//! Read-side access to Claude Code's saved **plan** documents.
2//!
3//! Plan mode writes each accepted plan to
4//! `~/.claude/plans/<slugged-name>.md` as plain markdown, one file
5//! per plan, with a human-readable slug for a name (e.g.
6//! `review-draft-pr-129-zany-lightning.md`). This module lists and
7//! reads them; it is read-only on purpose, like the other
8//! introspection modules. The layout is undocumented Claude Code
9//! internal state (observed against CLI 2.1.219) and can change
10//! across CLI versions.
11//!
12//! - [`PlansRoot::list`] -- every plan with summary metadata
13//!   (first-heading title, size, modified time), most recently
14//!   modified first.
15//! - [`PlansRoot::get`] -- one plan's full markdown content.
16//!
17//! # Example
18//!
19//! ```no_run
20//! use claude_wrapper::plans::PlansRoot;
21//!
22//! # fn example() -> claude_wrapper::Result<()> {
23//! let root = PlansRoot::home()?;
24//! for plan in root.list()? {
25//!     println!("{}: {}", plan.file_stem, plan.title.as_deref().unwrap_or("(untitled)"));
26//! }
27//! # Ok(()) }
28//! ```
29
30use std::fs;
31use std::path::{Path, PathBuf};
32use std::time::SystemTime;
33
34use serde::Serialize;
35
36use crate::error::{Error, Result};
37
38/// Root directory of Claude Code's saved plan documents. Defaults
39/// to `~/.claude/plans`; override with [`PlansRoot::at`] for tests
40/// or non-default installs.
41#[derive(Debug, Clone)]
42pub struct PlansRoot {
43    path: PathBuf,
44}
45
46impl PlansRoot {
47    /// Resolve the default `~/.claude/plans`. Errors if `$HOME`
48    /// (or the platform-specific user home) cannot be determined.
49    pub fn home() -> Result<Self> {
50        let home = home_dir().ok_or_else(|| Error::Artifacts {
51            message: "could not determine user home directory".to_string(),
52        })?;
53        Ok(Self {
54            path: home.join(".claude").join("plans"),
55        })
56    }
57
58    /// Use a specific path as the plans root. Useful for tests
59    /// (point at a tempdir) and for non-default installs.
60    pub fn at(path: impl Into<PathBuf>) -> Self {
61        Self { path: path.into() }
62    }
63
64    /// The configured root directory.
65    pub fn path(&self) -> &Path {
66        &self.path
67    }
68
69    /// List every plan at the root, most recently modified first
70    /// (ties broken by file stem). A missing root returns an empty
71    /// vec. Files that fail to read contribute a tracing warning
72    /// and are skipped.
73    pub fn list(&self) -> Result<Vec<PlanSummary>> {
74        let entries = match fs::read_dir(&self.path) {
75            Ok(it) => it,
76            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
77            Err(e) => return Err(e.into()),
78        };
79        let mut out = Vec::new();
80        for entry in entries.flatten() {
81            let path = entry.path();
82            if !path.is_file() || path.extension().and_then(|s| s.to_str()) != Some("md") {
83                continue;
84            }
85            let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
86                continue;
87            };
88            match summarize_plan(&path, stem) {
89                Ok(summary) => out.push(summary),
90                Err(e) => tracing::warn!(?path, "skipping plan: {e}"),
91            }
92        }
93        out.sort_by(|a, b| {
94            b.modified
95                .cmp(&a.modified)
96                .then_with(|| a.file_stem.cmp(&b.file_stem))
97        });
98        Ok(out)
99    }
100
101    /// Read one plan's full markdown content by file stem (the
102    /// basename of `<stem>.md` under the root). Errors if no such
103    /// file exists.
104    pub fn get(&self, file_stem: &str) -> Result<Plan> {
105        let path = self.path.join(format!("{file_stem}.md"));
106        if !path.is_file() {
107            return Err(Error::Artifacts {
108                message: format!("no plan at {}", path.display()),
109            });
110        }
111        let content = fs::read_to_string(&path)?;
112        Ok(Plan {
113            file_stem: file_stem.to_string(),
114            title: first_heading(&content),
115            file_path: path,
116            content,
117        })
118    }
119}
120
121/// Lightweight metadata for one plan, returned by
122/// [`PlansRoot::list`]. Strips the content to keep listings cheap.
123#[derive(Debug, Clone, Serialize)]
124pub struct PlanSummary {
125    /// File stem (the basename of `<stem>.md`). The canonical
126    /// handle for [`PlansRoot::get`].
127    pub file_stem: String,
128    /// The first `#` heading in the document, when present.
129    pub title: Option<String>,
130    /// Absolute path to the source `.md`.
131    pub file_path: PathBuf,
132    /// File size in bytes.
133    pub size_bytes: u64,
134    /// Last-modified time, when the filesystem reports one.
135    pub modified: Option<SystemTime>,
136}
137
138/// Full plan record returned by [`PlansRoot::get`].
139#[derive(Debug, Clone, Serialize)]
140pub struct Plan {
141    /// File stem (the basename of `<stem>.md`).
142    pub file_stem: String,
143    /// The first `#` heading in the document, when present.
144    pub title: Option<String>,
145    /// Absolute path to the source `.md`.
146    pub file_path: PathBuf,
147    /// The full markdown content.
148    pub content: String,
149}
150
151fn summarize_plan(path: &Path, stem: &str) -> Result<PlanSummary> {
152    let meta = fs::metadata(path)?;
153    // Only the head of the file is needed for the title; plans are
154    // small, so a full read keeps this simple.
155    let content = fs::read_to_string(path)?;
156    Ok(PlanSummary {
157        file_stem: stem.to_string(),
158        title: first_heading(&content),
159        file_path: path.to_path_buf(),
160        size_bytes: meta.len(),
161        modified: meta.modified().ok(),
162    })
163}
164
165/// The text of the first markdown `#` heading (any level), trimmed.
166fn first_heading(content: &str) -> Option<String> {
167    for line in content.lines() {
168        let trimmed = line.trim_start();
169        if let Some(rest) = trimmed.strip_prefix('#') {
170            let title = rest.trim_start_matches('#').trim();
171            if !title.is_empty() {
172                return Some(title.to_string());
173            }
174        }
175    }
176    None
177}
178
179fn home_dir() -> Option<PathBuf> {
180    if let Ok(h) = std::env::var("HOME")
181        && !h.is_empty()
182    {
183        return Some(PathBuf::from(h));
184    }
185    if let Ok(h) = std::env::var("USERPROFILE")
186        && !h.is_empty()
187    {
188        return Some(PathBuf::from(h));
189    }
190    None
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    fn write_plan(root: &Path, stem: &str, contents: &str) {
198        fs::create_dir_all(root).unwrap();
199        fs::write(root.join(format!("{stem}.md")), contents).unwrap();
200    }
201
202    fn set_mtime(root: &Path, stem: &str, secs: u64) {
203        let f = fs::OpenOptions::new()
204            .write(true)
205            .open(root.join(format!("{stem}.md")))
206            .unwrap();
207        f.set_modified(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs))
208            .unwrap();
209    }
210
211    fn fixture_root() -> tempfile::TempDir {
212        let tmp = tempfile::tempdir().expect("tempdir");
213        write_plan(
214            tmp.path(),
215            "older-plan",
216            "# The older plan\n\n## Context\n\nDetails.\n",
217        );
218        write_plan(tmp.path(), "newer-plan", "No heading here, just prose.\n");
219        set_mtime(tmp.path(), "older-plan", 1_000);
220        set_mtime(tmp.path(), "newer-plan", 2_000);
221        fs::write(tmp.path().join("not-a-plan.txt"), "ignored").unwrap();
222        tmp
223    }
224
225    #[test]
226    fn list_sorts_recent_first_and_extracts_titles() {
227        let tmp = fixture_root();
228        let root = PlansRoot::at(tmp.path());
229        let plans = root.list().expect("list");
230        let stems: Vec<&str> = plans.iter().map(|p| p.file_stem.as_str()).collect();
231        assert_eq!(stems, ["newer-plan", "older-plan"]);
232        assert_eq!(plans[0].title, None);
233        assert_eq!(plans[1].title.as_deref(), Some("The older plan"));
234        assert!(plans[1].size_bytes > 0);
235        assert!(plans[1].modified.is_some());
236    }
237
238    #[test]
239    fn list_missing_root_returns_empty() {
240        let tmp = tempfile::tempdir().unwrap();
241        let root = PlansRoot::at(tmp.path().join("does-not-exist"));
242        assert!(root.list().expect("ok").is_empty());
243    }
244
245    #[test]
246    fn get_returns_full_content() {
247        let tmp = fixture_root();
248        let root = PlansRoot::at(tmp.path());
249        let plan = root.get("older-plan").expect("get");
250        assert_eq!(plan.title.as_deref(), Some("The older plan"));
251        assert!(plan.content.contains("## Context"));
252    }
253
254    #[test]
255    fn get_unknown_stem_errors() {
256        let tmp = fixture_root();
257        let root = PlansRoot::at(tmp.path());
258        let err = root.get("nope").unwrap_err();
259        assert!(err.to_string().contains("no plan at"));
260    }
261
262    #[test]
263    fn first_heading_skips_deeper_levels_only_when_empty() {
264        assert_eq!(first_heading("## Sub only\n"), Some("Sub only".to_string()));
265        assert_eq!(first_heading("#\n# Real\n"), Some("Real".to_string()));
266        assert_eq!(first_heading("plain text\n"), None);
267    }
268}