1use crate::cli::{PlanCommands, PlanCreateArgs, PlanUpdateArgs};
10use crate::config::plan_discovery::{self, AgentKind};
11use crate::config::{default_actor, resolve_db_path, resolve_project_path, resolve_session_id};
12use crate::error::{Error, Result};
13use crate::model::{Plan, PlanStatus};
14use crate::storage::SqliteStorage;
15use serde::Serialize;
16use std::path::{Path, PathBuf};
17use std::time::Duration;
18
19#[derive(Serialize)]
20struct PlanOutput {
21 id: String,
22 short_id: Option<String>,
23 project_path: String,
24 title: String,
25 status: String,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 session_id: Option<String>,
28 content_preview: Option<String>,
29 success_criteria: Option<String>,
30 created_at: String,
31 updated_at: String,
32 completed_at: Option<String>,
33}
34
35impl From<Plan> for PlanOutput {
36 fn from(p: Plan) -> Self {
37 let content_preview = p.content.as_ref().map(|c| {
39 if c.len() > 200 {
40 format!("{}...", &c[..200])
41 } else {
42 c.clone()
43 }
44 });
45
46 Self {
47 id: p.id,
48 short_id: p.short_id,
49 project_path: p.project_path,
50 title: p.title,
51 status: p.status.as_str().to_string(),
52 session_id: p.session_id,
53 content_preview,
54 success_criteria: p.success_criteria,
55 created_at: format_timestamp(p.created_at),
56 updated_at: format_timestamp(p.updated_at),
57 completed_at: p.completed_at.map(format_timestamp),
58 }
59 }
60}
61
62#[derive(Serialize)]
63struct PlanDetailOutput {
64 id: String,
65 short_id: Option<String>,
66 project_path: String,
67 title: String,
68 status: String,
69 #[serde(skip_serializing_if = "Option::is_none")]
70 session_id: Option<String>,
71 content: Option<String>,
72 success_criteria: Option<String>,
73 created_in_session: Option<String>,
74 completed_in_session: Option<String>,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 source_path: Option<String>,
77 created_at: String,
78 updated_at: String,
79 completed_at: Option<String>,
80}
81
82impl From<Plan> for PlanDetailOutput {
83 fn from(p: Plan) -> Self {
84 Self {
85 id: p.id,
86 short_id: p.short_id,
87 project_path: p.project_path,
88 title: p.title,
89 status: p.status.as_str().to_string(),
90 session_id: p.session_id,
91 content: p.content,
92 success_criteria: p.success_criteria,
93 created_in_session: p.created_in_session,
94 completed_in_session: p.completed_in_session,
95 source_path: p.source_path,
96 created_at: format_timestamp(p.created_at),
97 updated_at: format_timestamp(p.updated_at),
98 completed_at: p.completed_at.map(format_timestamp),
99 }
100 }
101}
102
103#[derive(Serialize)]
104struct PlanListOutput {
105 plans: Vec<PlanOutput>,
106 count: usize,
107}
108
109fn format_timestamp(ts: i64) -> String {
110 chrono::DateTime::from_timestamp_millis(ts)
111 .map(|dt| dt.to_rfc3339())
112 .unwrap_or_else(|| ts.to_string())
113}
114
115pub fn execute(
117 command: &PlanCommands,
118 db_path: Option<&PathBuf>,
119 actor: Option<&str>,
120 json_output: bool,
121) -> Result<()> {
122 let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
123 .ok_or(Error::NotInitialized)?;
124
125 if !db_path.exists() {
126 return Err(Error::NotInitialized);
127 }
128
129 let mut storage = SqliteStorage::open(&db_path)?;
130 let actor = actor.map(String::from).unwrap_or_else(default_actor);
131
132 match command {
133 PlanCommands::Create(args) => execute_create(&mut storage, args, json_output, &actor),
134 PlanCommands::List { status, limit, session } => execute_list(&storage, status, *limit, session.as_deref(), json_output),
135 PlanCommands::Show { id } => execute_show(&storage, id, json_output),
136 PlanCommands::Update(args) => execute_update(&mut storage, args, json_output, &actor),
137 PlanCommands::Capture { agent, max_age, file } => {
138 execute_capture(&mut storage, agent.as_deref(), *max_age, file.as_deref(), json_output, &actor)
139 }
140 }
141}
142
143fn execute_create(
144 storage: &mut SqliteStorage,
145 args: &PlanCreateArgs,
146 json_output: bool,
147 actor: &str,
148) -> Result<()> {
149 let project_path = resolve_project_path(storage, None)?;
151
152 let project = storage.get_or_create_project(&project_path, actor)?;
154
155 let status = PlanStatus::from_str(&args.status);
157 let mut plan = Plan::new(project.id.clone(), project_path, args.title.clone())
158 .with_status(status);
159
160 if let Some(ref content) = args.content {
161 plan = plan.with_content(content);
162 }
163
164 if let Some(ref criteria) = args.success_criteria {
165 plan = plan.with_success_criteria(criteria);
166 }
167
168 if let Ok(session_id) = resolve_session_id(args.session.as_deref()) {
170 plan = plan.with_session(&session_id);
171 }
172
173 storage.create_plan(&plan, actor)?;
174
175 if crate::is_silent() {
176 println!("{}", plan.id);
177 return Ok(());
178 }
179
180 if json_output {
181 let output = PlanOutput::from(plan);
182 println!("{}", serde_json::to_string_pretty(&output)?);
183 } else {
184 println!("Created plan: {}", plan.title);
185 println!(" ID: {}", plan.id);
186 println!(" Status: {}", plan.status.as_str());
187 }
188
189 Ok(())
190}
191
192fn execute_list(
193 storage: &SqliteStorage,
194 status: &str,
195 limit: usize,
196 session: Option<&str>,
197 json_output: bool,
198) -> Result<()> {
199 let project_path = resolve_project_path(storage, None)?;
201
202 let session_id = session.and_then(|s| {
204 if s == "current" {
205 resolve_session_id(None).ok()
206 } else {
207 Some(s.to_string())
208 }
209 });
210
211 let status_filter = if status == "all" { Some("all") } else { Some(status) };
212 let mut plans = storage.list_plans(&project_path, status_filter, limit)?;
213
214 if let Some(ref sid) = session_id {
216 plans.retain(|p| p.session_id.as_deref() == Some(sid.as_str()));
217 }
218
219 if crate::is_csv() {
220 println!("id,title,status");
221 for plan in &plans {
222 println!("{},{},{}", plan.id, crate::csv_escape(&plan.title), plan.status.as_str());
223 }
224 } else if json_output {
225 let output = PlanListOutput {
226 count: plans.len(),
227 plans: plans.into_iter().map(PlanOutput::from).collect(),
228 };
229 println!("{}", serde_json::to_string_pretty(&output)?);
230 } else if plans.is_empty() {
231 println!("No plans found.");
232 println!("\nCreate one with: sc plan create \"Plan Title\"");
233 } else {
234 println!("Plans ({}):\n", plans.len());
235 for plan in plans {
236 let status_icon = match plan.status {
237 PlanStatus::Draft => "📝",
238 PlanStatus::Active => "🔵",
239 PlanStatus::Completed => "✓",
240 };
241 println!(" {} {} [{}]", status_icon, plan.title, plan.status.as_str());
242 println!(" ID: {}", plan.id);
243 if let Some(criteria) = &plan.success_criteria {
244 let preview = if criteria.len() > 60 {
245 format!("{}...", &criteria[..60])
246 } else {
247 criteria.clone()
248 };
249 println!(" Success: {preview}");
250 }
251 println!();
252 }
253 }
254
255 Ok(())
256}
257
258fn execute_show(
259 storage: &SqliteStorage,
260 id: &str,
261 json_output: bool,
262) -> Result<()> {
263 let plan = storage.get_plan(id)?
264 .ok_or_else(|| Error::Other(format!("Plan not found: {id}")))?;
265
266 if json_output {
267 let output = PlanDetailOutput::from(plan);
268 println!("{}", serde_json::to_string_pretty(&output)?);
269 } else {
270 let status_icon = match plan.status {
271 PlanStatus::Draft => "📝",
272 PlanStatus::Active => "🔵",
273 PlanStatus::Completed => "✓",
274 };
275
276 println!("Plan: {} {}", status_icon, plan.title);
277 println!(" ID: {}", plan.id);
278 println!(" Status: {}", plan.status.as_str());
279 println!(" Path: {}", plan.project_path);
280
281 if let Some(criteria) = &plan.success_criteria {
282 println!();
283 println!("Success Criteria:");
284 println!(" {criteria}");
285 }
286
287 if let Some(content) = &plan.content {
288 println!();
289 println!("Content:");
290 println!("─────────────────────────────────────");
291 for line in content.lines() {
292 println!(" {line}");
293 }
294 println!("─────────────────────────────────────");
295 }
296
297 println!();
298 println!("Created: {}", format_timestamp(plan.created_at));
299 println!("Updated: {}", format_timestamp(plan.updated_at));
300 if let Some(completed_at) = plan.completed_at {
301 println!("Completed: {}", format_timestamp(completed_at));
302 }
303 }
304
305 Ok(())
306}
307
308fn execute_update(
309 storage: &mut SqliteStorage,
310 args: &PlanUpdateArgs,
311 json_output: bool,
312 actor: &str,
313) -> Result<()> {
314 let plan = storage.get_plan(&args.id)?
316 .ok_or_else(|| Error::Other(format!("Plan not found: {}", args.id)))?;
317
318 storage.update_plan(
320 &plan.id,
321 args.title.as_deref(),
322 args.content.as_deref(),
323 args.status.as_deref(),
324 args.success_criteria.as_deref(),
325 actor,
326 )?;
327
328 let updated = storage.get_plan(&plan.id)?.unwrap();
330
331 if json_output {
332 let output = PlanOutput::from(updated);
333 println!("{}", serde_json::to_string_pretty(&output)?);
334 } else {
335 println!("Updated plan: {}", updated.title);
336 if args.title.is_some() {
337 println!(" Title: {}", updated.title);
338 }
339 if args.status.is_some() {
340 println!(" Status: {}", updated.status.as_str());
341 }
342 if args.success_criteria.is_some() {
343 println!(" Success criteria updated");
344 }
345 if args.content.is_some() {
346 println!(" Content updated");
347 }
348 }
349
350 Ok(())
351}
352
353fn execute_capture(
354 storage: &mut SqliteStorage,
355 agent: Option<&str>,
356 max_age_minutes: u64,
357 file: Option<&Path>,
358 json_output: bool,
359 actor: &str,
360) -> Result<()> {
361 let project_path = resolve_project_path(storage, None)?;
363
364 let project = storage.get_or_create_project(&project_path, actor)?;
366
367 let discovered = if let Some(file_path) = file {
369 let content = std::fs::read_to_string(file_path)
371 .map_err(|e| Error::Other(format!("Failed to read plan file: {e}")))?;
372 let filename = file_path
373 .file_stem()
374 .map(|s| s.to_string_lossy().to_string())
375 .unwrap_or_else(|| "unnamed".to_string());
376 let title = plan_discovery::extract_title(&content, &filename);
377 let modified = std::fs::metadata(file_path)
378 .and_then(|m| m.modified())
379 .unwrap_or_else(|_| std::time::SystemTime::now());
380
381 vec![plan_discovery::DiscoveredPlan {
382 path: file_path.to_path_buf(),
383 agent: AgentKind::ClaudeCode, title,
385 content,
386 modified_at: modified,
387 }]
388 } else {
389 let agent_filter = agent
391 .map(|a| AgentKind::from_arg(a)
392 .ok_or_else(|| Error::Other(format!(
393 "Unknown agent: {a}. Use: claude, gemini, opencode, cursor, factory"
394 ))))
395 .transpose()?;
396
397 let max_age = Duration::from_secs(max_age_minutes * 60);
398 plan_discovery::discover_plans(Path::new(&project_path), agent_filter, max_age)
399 };
400
401 if discovered.is_empty() {
402 if !crate::is_silent() {
403 if json_output {
404 println!(r#"{{"captured":false,"reason":"no_plans_found"}}"#);
405 } else {
406 println!("No recent plan files found.");
407 if let Some(agent_name) = agent {
408 println!(" Searched agent: {agent_name}");
409 } else {
410 println!(" Searched: Claude Code, Gemini CLI, OpenCode, Cursor, Factory AI");
411 }
412 println!(" Max age: {max_age_minutes} minutes");
413 }
414 }
415 return Ok(());
416 }
417
418 let plan_file = &discovered[0];
420 let source_hash = plan_discovery::compute_content_hash(&plan_file.content);
421 let source_path = plan_file.path.to_string_lossy().to_string();
422
423 if let Some(existing) = storage.find_plan_by_source_hash(&source_hash)? {
425 storage.update_plan(
427 &existing.id,
428 Some(&plan_file.title),
429 Some(&plan_file.content),
430 None, None, actor,
433 )?;
434
435 if crate::is_silent() {
436 println!("{}", existing.id);
437 } else if json_output {
438 let updated = storage.get_plan(&existing.id)?.unwrap();
439 let output = PlanOutput::from(updated);
440 println!("{}", serde_json::to_string_pretty(&serde_json::json!({
441 "captured": true,
442 "action": "updated",
443 "agent": plan_file.agent.display_name(),
444 "plan": output,
445 }))?);
446 } else {
447 println!("Updated existing plan: {}", existing.title);
448 println!(" ID: {}", existing.id);
449 println!(" Agent: {}", plan_file.agent.display_name());
450 println!(" Source: {source_path}");
451 }
452
453 return Ok(());
454 }
455
456 let mut plan = Plan::new(project.id.clone(), project_path, plan_file.title.clone())
458 .with_content(&plan_file.content)
459 .with_status(PlanStatus::Active)
460 .with_source(&source_path, &source_hash);
461
462 if let Ok(session_id) = resolve_session_id(None) {
464 plan = plan.with_session(&session_id);
465 }
466
467 storage.create_plan(&plan, actor)?;
468
469 if crate::is_silent() {
470 println!("{}", plan.id);
471 } else if json_output {
472 let output = PlanOutput::from(plan.clone());
473 println!("{}", serde_json::to_string_pretty(&serde_json::json!({
474 "captured": true,
475 "action": "created",
476 "agent": plan_file.agent.display_name(),
477 "plan": output,
478 }))?);
479 } else {
480 println!("Captured plan: {}", plan.title);
481 println!(" ID: {}", plan.id);
482 println!(" Agent: {}", plan_file.agent.display_name());
483 println!(" Source: {source_path}");
484 if let Some(ref sid) = plan.session_id {
485 println!(" Session: {sid}");
486 }
487 }
488
489 Ok(())
490}