use std::fs;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use serde::Serialize;
use crate::error::{Error, Result};
#[derive(Debug, Clone)]
pub struct PlansRoot {
path: PathBuf,
}
impl PlansRoot {
pub fn home() -> Result<Self> {
let home = home_dir().ok_or_else(|| Error::Artifacts {
message: "could not determine user home directory".to_string(),
})?;
Ok(Self {
path: home.join(".claude").join("plans"),
})
}
pub fn at(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn list(&self) -> Result<Vec<PlanSummary>> {
let entries = match fs::read_dir(&self.path) {
Ok(it) => it,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e.into()),
};
let mut out = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() || path.extension().and_then(|s| s.to_str()) != Some("md") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
match summarize_plan(&path, stem) {
Ok(summary) => out.push(summary),
Err(e) => tracing::warn!(?path, "skipping plan: {e}"),
}
}
out.sort_by(|a, b| {
b.modified
.cmp(&a.modified)
.then_with(|| a.file_stem.cmp(&b.file_stem))
});
Ok(out)
}
pub fn get(&self, file_stem: &str) -> Result<Plan> {
let path = self.path.join(format!("{file_stem}.md"));
if !path.is_file() {
return Err(Error::Artifacts {
message: format!("no plan at {}", path.display()),
});
}
let content = fs::read_to_string(&path)?;
Ok(Plan {
file_stem: file_stem.to_string(),
title: first_heading(&content),
file_path: path,
content,
})
}
}
#[derive(Debug, Clone, Serialize)]
pub struct PlanSummary {
pub file_stem: String,
pub title: Option<String>,
pub file_path: PathBuf,
pub size_bytes: u64,
pub modified: Option<SystemTime>,
}
#[derive(Debug, Clone, Serialize)]
pub struct Plan {
pub file_stem: String,
pub title: Option<String>,
pub file_path: PathBuf,
pub content: String,
}
fn summarize_plan(path: &Path, stem: &str) -> Result<PlanSummary> {
let meta = fs::metadata(path)?;
let content = fs::read_to_string(path)?;
Ok(PlanSummary {
file_stem: stem.to_string(),
title: first_heading(&content),
file_path: path.to_path_buf(),
size_bytes: meta.len(),
modified: meta.modified().ok(),
})
}
fn first_heading(content: &str) -> Option<String> {
for line in content.lines() {
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix('#') {
let title = rest.trim_start_matches('#').trim();
if !title.is_empty() {
return Some(title.to_string());
}
}
}
None
}
fn home_dir() -> Option<PathBuf> {
if let Ok(h) = std::env::var("HOME")
&& !h.is_empty()
{
return Some(PathBuf::from(h));
}
if let Ok(h) = std::env::var("USERPROFILE")
&& !h.is_empty()
{
return Some(PathBuf::from(h));
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn write_plan(root: &Path, stem: &str, contents: &str) {
fs::create_dir_all(root).unwrap();
fs::write(root.join(format!("{stem}.md")), contents).unwrap();
}
fn set_mtime(root: &Path, stem: &str, secs: u64) {
let f = fs::OpenOptions::new()
.write(true)
.open(root.join(format!("{stem}.md")))
.unwrap();
f.set_modified(SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(secs))
.unwrap();
}
fn fixture_root() -> tempfile::TempDir {
let tmp = tempfile::tempdir().expect("tempdir");
write_plan(
tmp.path(),
"older-plan",
"# The older plan\n\n## Context\n\nDetails.\n",
);
write_plan(tmp.path(), "newer-plan", "No heading here, just prose.\n");
set_mtime(tmp.path(), "older-plan", 1_000);
set_mtime(tmp.path(), "newer-plan", 2_000);
fs::write(tmp.path().join("not-a-plan.txt"), "ignored").unwrap();
tmp
}
#[test]
fn list_sorts_recent_first_and_extracts_titles() {
let tmp = fixture_root();
let root = PlansRoot::at(tmp.path());
let plans = root.list().expect("list");
let stems: Vec<&str> = plans.iter().map(|p| p.file_stem.as_str()).collect();
assert_eq!(stems, ["newer-plan", "older-plan"]);
assert_eq!(plans[0].title, None);
assert_eq!(plans[1].title.as_deref(), Some("The older plan"));
assert!(plans[1].size_bytes > 0);
assert!(plans[1].modified.is_some());
}
#[test]
fn list_missing_root_returns_empty() {
let tmp = tempfile::tempdir().unwrap();
let root = PlansRoot::at(tmp.path().join("does-not-exist"));
assert!(root.list().expect("ok").is_empty());
}
#[test]
fn get_returns_full_content() {
let tmp = fixture_root();
let root = PlansRoot::at(tmp.path());
let plan = root.get("older-plan").expect("get");
assert_eq!(plan.title.as_deref(), Some("The older plan"));
assert!(plan.content.contains("## Context"));
}
#[test]
fn get_unknown_stem_errors() {
let tmp = fixture_root();
let root = PlansRoot::at(tmp.path());
let err = root.get("nope").unwrap_err();
assert!(err.to_string().contains("no plan at"));
}
#[test]
fn first_heading_skips_deeper_levels_only_when_empty() {
assert_eq!(first_heading("## Sub only\n"), Some("Sub only".to_string()));
assert_eq!(first_heading("#\n# Real\n"), Some("Real".to_string()));
assert_eq!(first_heading("plain text\n"), None);
}
}