Skip to main content

sc/config/
plan_discovery.rs

1//! Multi-agent plan file discovery.
2//!
3//! Discovers plan files written by AI coding agents:
4//! - Claude Code: `~/.claude/plans/*.md`
5//! - Gemini CLI: `~/.gemini/tmp/<sha256(project)>/plans/*.md`
6//! - OpenCode: `<project>/.opencode/plans/*.md`
7//! - Cursor: `<project>/.cursor/plans/*.md`
8//!
9//! Used by `sc plan capture` to import plans into SaveContext.
10
11use sha2::{Digest, Sha256};
12use std::path::{Path, PathBuf};
13use std::time::{Duration, SystemTime};
14
15/// A plan file discovered from an AI coding agent.
16#[derive(Debug, Clone)]
17pub struct DiscoveredPlan {
18    /// Path to the plan file.
19    pub path: PathBuf,
20    /// Which agent created this plan.
21    pub agent: AgentKind,
22    /// Extracted title (from first heading or filename).
23    pub title: String,
24    /// Full markdown content.
25    pub content: String,
26    /// Last modification time.
27    pub modified_at: SystemTime,
28}
29
30/// Supported AI coding agents.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum AgentKind {
33    ClaudeCode,
34    GeminiCli,
35    OpenCode,
36    Cursor,
37}
38
39impl AgentKind {
40    /// Parse from CLI argument string.
41    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    /// Display name for the agent.
52    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
62/// Discover plan files from all supported agents.
63///
64/// Returns plans sorted by modification time (most recent first),
65/// filtered to those modified within `max_age` duration.
66pub 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    // Sort by modification time, most recent first
106    plans.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
107    plans
108}
109
110/// Get the plan directories for a specific agent.
111fn plan_directories(agent: AgentKind, project_path: &Path) -> Vec<PathBuf> {
112    match agent {
113        AgentKind::ClaudeCode => {
114            // Check settings.json for custom plansDirectory
115            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            // ~/.gemini/tmp/<sha256(project_path)>/plans/
130            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                // Compute SHA-256 of canonicalized project path
140                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
161/// Try to read a custom plansDirectory from Claude Code settings.
162fn 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
180/// Read and validate a plan file.
181fn 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    // Skip files older than cutoff
190    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
211/// Extract a title from plan content.
212///
213/// Tries the first markdown heading, falls back to humanized filename.
214pub 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    // Fallback: humanize filename (hyphens/underscores → spaces, title case first word)
223    filename.replace('-', " ").replace('_', " ")
224}
225
226/// Compute SHA-256 hash of file content (for deduplication).
227pub 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); // SHA-256 hex
266    }
267}