1use std::fs;
31use std::path::{Path, PathBuf};
32use std::time::SystemTime;
33
34use serde::Serialize;
35
36use crate::error::{Error, Result};
37
38#[derive(Debug, Clone)]
42pub struct PlansRoot {
43 path: PathBuf,
44}
45
46impl PlansRoot {
47 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 pub fn at(path: impl Into<PathBuf>) -> Self {
61 Self { path: path.into() }
62 }
63
64 pub fn path(&self) -> &Path {
66 &self.path
67 }
68
69 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 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#[derive(Debug, Clone, Serialize)]
124pub struct PlanSummary {
125 pub file_stem: String,
128 pub title: Option<String>,
130 pub file_path: PathBuf,
132 pub size_bytes: u64,
134 pub modified: Option<SystemTime>,
136}
137
138#[derive(Debug, Clone, Serialize)]
140pub struct Plan {
141 pub file_stem: String,
143 pub title: Option<String>,
145 pub file_path: PathBuf,
147 pub content: String,
149}
150
151fn summarize_plan(path: &Path, stem: &str) -> Result<PlanSummary> {
152 let meta = fs::metadata(path)?;
153 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
165fn 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}