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