sc/config/
plan_discovery.rs1use sha2::{Digest, Sha256};
12use std::path::{Path, PathBuf};
13use std::time::{Duration, SystemTime};
14
15#[derive(Debug, Clone)]
17pub struct DiscoveredPlan {
18 pub path: PathBuf,
20 pub agent: AgentKind,
22 pub title: String,
24 pub content: String,
26 pub modified_at: SystemTime,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum AgentKind {
33 ClaudeCode,
34 GeminiCli,
35 OpenCode,
36 Cursor,
37}
38
39impl AgentKind {
40 pub fn from_arg(s: &str) -> Option<Self> {
42 match s.to_lowercase().as_str() {
43 "claude" | "claude-code" => Some(Self::ClaudeCode),
44 "gemini" | "gemini-cli" => Some(Self::GeminiCli),
45 "opencode" | "open-code" => Some(Self::OpenCode),
46 "cursor" => Some(Self::Cursor),
47 _ => None,
48 }
49 }
50
51 pub const fn display_name(&self) -> &'static str {
53 match self {
54 Self::ClaudeCode => "Claude Code",
55 Self::GeminiCli => "Gemini CLI",
56 Self::OpenCode => "OpenCode",
57 Self::Cursor => "Cursor",
58 }
59 }
60}
61
62pub fn discover_plans(
67 project_path: &Path,
68 agent_filter: Option<AgentKind>,
69 max_age: Duration,
70) -> Vec<DiscoveredPlan> {
71 let cutoff = SystemTime::now()
72 .checked_sub(max_age)
73 .unwrap_or(SystemTime::UNIX_EPOCH);
74
75 let agents: Vec<AgentKind> = match agent_filter {
76 Some(agent) => vec![agent],
77 None => vec![
78 AgentKind::ClaudeCode,
79 AgentKind::GeminiCli,
80 AgentKind::OpenCode,
81 AgentKind::Cursor,
82 ],
83 };
84
85 let mut plans: Vec<DiscoveredPlan> = Vec::new();
86
87 for agent in agents {
88 let dirs = plan_directories(agent, project_path);
89 for dir in dirs {
90 if dir.is_dir() {
91 if let Ok(entries) = std::fs::read_dir(&dir) {
92 for entry in entries.flatten() {
93 let path = entry.path();
94 if path.extension().map_or(false, |e| e == "md") {
95 if let Some(plan) = read_plan_file(&path, agent, cutoff) {
96 plans.push(plan);
97 }
98 }
99 }
100 }
101 }
102 }
103 }
104
105 plans.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
107 plans
108}
109
110fn plan_directories(agent: AgentKind, project_path: &Path) -> Vec<PathBuf> {
112 match agent {
113 AgentKind::ClaudeCode => {
114 let home = directories::BaseDirs::new()
116 .map(|b| b.home_dir().to_path_buf());
117
118 if let Some(home) = home {
119 let custom_dir = claude_plans_directory(&home);
120 if let Some(dir) = custom_dir {
121 return vec![dir];
122 }
123 vec![home.join(".claude").join("plans")]
124 } else {
125 vec![]
126 }
127 }
128 AgentKind::GeminiCli => {
129 let gemini_home = std::env::var("GEMINI_CLI_HOME")
131 .map(PathBuf::from)
132 .ok()
133 .or_else(|| {
134 directories::BaseDirs::new()
135 .map(|b| b.home_dir().join(".gemini"))
136 });
137
138 if let Some(gemini_home) = gemini_home {
139 let canonical = std::fs::canonicalize(project_path)
141 .unwrap_or_else(|_| project_path.to_path_buf());
142 let path_str = canonical.to_string_lossy();
143 let mut hasher = Sha256::new();
144 hasher.update(path_str.as_bytes());
145 let hash = format!("{:x}", hasher.finalize());
146
147 vec![gemini_home.join("tmp").join(hash).join("plans")]
148 } else {
149 vec![]
150 }
151 }
152 AgentKind::OpenCode => {
153 vec![project_path.join(".opencode").join("plans")]
154 }
155 AgentKind::Cursor => {
156 vec![project_path.join(".cursor").join("plans")]
157 }
158 }
159}
160
161fn claude_plans_directory(home: &Path) -> Option<PathBuf> {
163 let settings_path = home.join(".claude").join("settings.json");
164 if !settings_path.exists() {
165 return None;
166 }
167
168 let content = std::fs::read_to_string(&settings_path).ok()?;
169 let settings: serde_json::Value = serde_json::from_str(&content).ok()?;
170 let plans_dir = settings.get("plansDirectory")?.as_str()?;
171
172 let path = PathBuf::from(plans_dir);
173 if path.is_absolute() {
174 Some(path)
175 } else {
176 Some(home.join(".claude").join(path))
177 }
178}
179
180fn read_plan_file(
182 path: &Path,
183 agent: AgentKind,
184 cutoff: SystemTime,
185) -> Option<DiscoveredPlan> {
186 let metadata = std::fs::metadata(path).ok()?;
187 let modified = metadata.modified().ok()?;
188
189 if modified < cutoff {
191 return None;
192 }
193
194 let content = std::fs::read_to_string(path).ok()?;
195 if content.trim().is_empty() {
196 return None;
197 }
198
199 let filename = path.file_stem()?.to_string_lossy().to_string();
200 let title = extract_title(&content, &filename);
201
202 Some(DiscoveredPlan {
203 path: path.to_path_buf(),
204 agent,
205 title,
206 content,
207 modified_at: modified,
208 })
209}
210
211pub fn extract_title(content: &str, filename: &str) -> String {
215 for line in content.lines() {
216 let trimmed = line.trim();
217 if trimmed.starts_with("# ") {
218 return trimmed[2..].trim().to_string();
219 }
220 }
221
222 filename.replace('-', " ").replace('_', " ")
224}
225
226pub fn compute_content_hash(content: &str) -> String {
228 let mut hasher = Sha256::new();
229 hasher.update(content.as_bytes());
230 format!("{:x}", hasher.finalize())
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236
237 #[test]
238 fn test_extract_title_from_heading() {
239 assert_eq!(
240 extract_title("# My Plan\n\nSome content", "fallback"),
241 "My Plan"
242 );
243 }
244
245 #[test]
246 fn test_extract_title_fallback() {
247 assert_eq!(
248 extract_title("No heading here\nJust text", "my-plan-name"),
249 "my plan name"
250 );
251 }
252
253 #[test]
254 fn test_agent_kind_from_arg() {
255 assert_eq!(AgentKind::from_arg("claude"), Some(AgentKind::ClaudeCode));
256 assert_eq!(AgentKind::from_arg("gemini"), Some(AgentKind::GeminiCli));
257 assert_eq!(AgentKind::from_arg("opencode"), Some(AgentKind::OpenCode));
258 assert_eq!(AgentKind::from_arg("cursor"), Some(AgentKind::Cursor));
259 assert_eq!(AgentKind::from_arg("unknown"), None);
260 }
261
262 #[test]
263 fn test_compute_content_hash() {
264 let hash = compute_content_hash("test content");
265 assert_eq!(hash.len(), 64); }
267}