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//! - Factory AI: `~/.factory/specs/*.md`
9//!
10//! Used by `sc plan capture` to import plans into SaveContext.
11
12use sha2::{Digest, Sha256};
13use std::path::{Path, PathBuf};
14use std::time::{Duration, SystemTime};
15
16/// A plan file discovered from an AI coding agent.
17#[derive(Debug, Clone)]
18pub struct DiscoveredPlan {
19    /// Path to the plan file.
20    pub path: PathBuf,
21    /// Which agent created this plan.
22    pub agent: AgentKind,
23    /// Extracted title (from first heading or filename).
24    pub title: String,
25    /// Full markdown content.
26    pub content: String,
27    /// Last modification time.
28    pub modified_at: SystemTime,
29}
30
31/// Supported AI coding agents.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum AgentKind {
34    ClaudeCode,
35    GeminiCli,
36    OpenCode,
37    Cursor,
38    FactoryAi,
39}
40
41impl AgentKind {
42    /// Parse from CLI argument string.
43    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    /// Display name for the agent.
55    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
66/// Discover plan files from all supported agents.
67///
68/// Returns plans sorted by modification time (most recent first),
69/// filtered to those modified within `max_age` duration.
70pub 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    // Sort by modification time, most recent first
111    plans.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
112    plans
113}
114
115/// Get the plan directories for a specific agent.
116fn plan_directories(agent: AgentKind, project_path: &Path) -> Vec<PathBuf> {
117    match agent {
118        AgentKind::ClaudeCode => {
119            // Check settings.json for custom plansDirectory
120            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            // ~/.gemini/tmp/<sha256(project_path)>/plans/
135            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                // Compute SHA-256 of canonicalized project path
145                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            // Check FACTORY_HOME env var first, then fallback to ~/.factory
165            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
181/// Try to read a custom plansDirectory from Claude Code settings.
182fn 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
200/// Read and validate a plan file.
201fn 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    // Skip files older than cutoff
210    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
231/// Extract a title from plan content.
232///
233/// Tries the first markdown heading, falls back to humanized filename.
234pub 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    // Fallback: humanize filename (hyphens/underscores → spaces, title case first word)
243    filename.replace('-', " ").replace('_', " ")
244}
245
246/// Compute SHA-256 hash of file content (for deduplication).
247pub 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); // SHA-256 hex
289    }
290}