Skip to main content

sc/cli/commands/
plan.rs

1//! Plan management commands.
2//!
3//! Commands for managing SaveContext plans (PRDs, specs, feature docs):
4//! - `sc plan create <title>` - Create a new plan
5//! - `sc plan list` - List plans
6//! - `sc plan show <id>` - Show plan details
7//! - `sc plan update <id>` - Update plan settings
8
9use crate::cli::{PlanCommands, PlanCreateArgs, PlanUpdateArgs};
10use crate::config::{current_project_path, default_actor, resolve_db_path};
11use crate::error::{Error, Result};
12use crate::model::{Plan, PlanStatus};
13use crate::storage::SqliteStorage;
14use serde::Serialize;
15use std::path::PathBuf;
16
17#[derive(Serialize)]
18struct PlanOutput {
19    id: String,
20    short_id: Option<String>,
21    project_path: String,
22    title: String,
23    status: String,
24    content_preview: Option<String>,
25    success_criteria: Option<String>,
26    created_at: String,
27    updated_at: String,
28    completed_at: Option<String>,
29}
30
31impl From<Plan> for PlanOutput {
32    fn from(p: Plan) -> Self {
33        // Create a content preview (first 200 chars)
34        let content_preview = p.content.as_ref().map(|c| {
35            if c.len() > 200 {
36                format!("{}...", &c[..200])
37            } else {
38                c.clone()
39            }
40        });
41
42        Self {
43            id: p.id,
44            short_id: p.short_id,
45            project_path: p.project_path,
46            title: p.title,
47            status: p.status.as_str().to_string(),
48            content_preview,
49            success_criteria: p.success_criteria,
50            created_at: format_timestamp(p.created_at),
51            updated_at: format_timestamp(p.updated_at),
52            completed_at: p.completed_at.map(format_timestamp),
53        }
54    }
55}
56
57#[derive(Serialize)]
58struct PlanDetailOutput {
59    id: String,
60    short_id: Option<String>,
61    project_path: String,
62    title: String,
63    status: String,
64    content: Option<String>,
65    success_criteria: Option<String>,
66    created_in_session: Option<String>,
67    completed_in_session: Option<String>,
68    created_at: String,
69    updated_at: String,
70    completed_at: Option<String>,
71}
72
73impl From<Plan> for PlanDetailOutput {
74    fn from(p: Plan) -> Self {
75        Self {
76            id: p.id,
77            short_id: p.short_id,
78            project_path: p.project_path,
79            title: p.title,
80            status: p.status.as_str().to_string(),
81            content: p.content,
82            success_criteria: p.success_criteria,
83            created_in_session: p.created_in_session,
84            completed_in_session: p.completed_in_session,
85            created_at: format_timestamp(p.created_at),
86            updated_at: format_timestamp(p.updated_at),
87            completed_at: p.completed_at.map(format_timestamp),
88        }
89    }
90}
91
92#[derive(Serialize)]
93struct PlanListOutput {
94    plans: Vec<PlanOutput>,
95    count: usize,
96}
97
98fn format_timestamp(ts: i64) -> String {
99    chrono::DateTime::from_timestamp_millis(ts)
100        .map(|dt| dt.to_rfc3339())
101        .unwrap_or_else(|| ts.to_string())
102}
103
104/// Execute a plan command.
105pub fn execute(
106    command: &PlanCommands,
107    db_path: Option<&PathBuf>,
108    actor: Option<&str>,
109    json_output: bool,
110) -> Result<()> {
111    let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
112        .ok_or(Error::NotInitialized)?;
113
114    if !db_path.exists() {
115        return Err(Error::NotInitialized);
116    }
117
118    let mut storage = SqliteStorage::open(&db_path)?;
119    let actor = actor.map(String::from).unwrap_or_else(default_actor);
120
121    match command {
122        PlanCommands::Create(args) => execute_create(&mut storage, args, json_output, &actor),
123        PlanCommands::List { status, limit } => execute_list(&storage, status, *limit, json_output),
124        PlanCommands::Show { id } => execute_show(&storage, id, json_output),
125        PlanCommands::Update(args) => execute_update(&mut storage, args, json_output, &actor),
126    }
127}
128
129fn execute_create(
130    storage: &mut SqliteStorage,
131    args: &PlanCreateArgs,
132    json_output: bool,
133    actor: &str,
134) -> Result<()> {
135    // Get current project
136    let project_path = current_project_path()
137        .map(|p| p.to_string_lossy().to_string())
138        .ok_or_else(|| Error::Other("Could not determine project path".to_string()))?;
139
140    // Canonicalize the path
141    let project_path = std::fs::canonicalize(&project_path)
142        .map(|p| p.to_string_lossy().to_string())
143        .unwrap_or(project_path);
144
145    // Get or create project
146    let project = storage.get_or_create_project(&project_path, actor)?;
147
148    // Create plan
149    let status = PlanStatus::from_str(&args.status);
150    let mut plan = Plan::new(project.id.clone(), project_path, args.title.clone())
151        .with_status(status);
152
153    if let Some(ref content) = args.content {
154        plan = plan.with_content(content);
155    }
156
157    if let Some(ref criteria) = args.success_criteria {
158        plan = plan.with_success_criteria(criteria);
159    }
160
161    storage.create_plan(&plan, actor)?;
162
163    if crate::is_silent() {
164        println!("{}", plan.id);
165        return Ok(());
166    }
167
168    if json_output {
169        let output = PlanOutput::from(plan);
170        println!("{}", serde_json::to_string_pretty(&output)?);
171    } else {
172        println!("Created plan: {}", plan.title);
173        println!("  ID:     {}", plan.id);
174        println!("  Status: {}", plan.status.as_str());
175    }
176
177    Ok(())
178}
179
180fn execute_list(
181    storage: &SqliteStorage,
182    status: &str,
183    limit: usize,
184    json_output: bool,
185) -> Result<()> {
186    // Get current project
187    let project_path = current_project_path()
188        .map(|p| p.to_string_lossy().to_string())
189        .ok_or_else(|| Error::Other("Could not determine project path".to_string()))?;
190
191    // Canonicalize the path
192    let project_path = std::fs::canonicalize(&project_path)
193        .map(|p| p.to_string_lossy().to_string())
194        .unwrap_or(project_path);
195
196    let status_filter = if status == "all" { Some("all") } else { Some(status) };
197    let plans = storage.list_plans(&project_path, status_filter, limit)?;
198
199    if crate::is_csv() {
200        println!("id,title,status");
201        for plan in &plans {
202            println!("{},{},{}", plan.id, crate::csv_escape(&plan.title), plan.status.as_str());
203        }
204    } else if json_output {
205        let output = PlanListOutput {
206            count: plans.len(),
207            plans: plans.into_iter().map(PlanOutput::from).collect(),
208        };
209        println!("{}", serde_json::to_string_pretty(&output)?);
210    } else if plans.is_empty() {
211        println!("No plans found.");
212        println!("\nCreate one with: sc plan create \"Plan Title\"");
213    } else {
214        println!("Plans ({}):\n", plans.len());
215        for plan in plans {
216            let status_icon = match plan.status {
217                PlanStatus::Draft => "📝",
218                PlanStatus::Active => "🔵",
219                PlanStatus::Completed => "✓",
220            };
221            println!("  {} {} [{}]", status_icon, plan.title, plan.status.as_str());
222            println!("    ID: {}", plan.id);
223            if let Some(criteria) = &plan.success_criteria {
224                let preview = if criteria.len() > 60 {
225                    format!("{}...", &criteria[..60])
226                } else {
227                    criteria.clone()
228                };
229                println!("    Success: {preview}");
230            }
231            println!();
232        }
233    }
234
235    Ok(())
236}
237
238fn execute_show(
239    storage: &SqliteStorage,
240    id: &str,
241    json_output: bool,
242) -> Result<()> {
243    let plan = storage.get_plan(id)?
244        .ok_or_else(|| Error::Other(format!("Plan not found: {id}")))?;
245
246    if json_output {
247        let output = PlanDetailOutput::from(plan);
248        println!("{}", serde_json::to_string_pretty(&output)?);
249    } else {
250        let status_icon = match plan.status {
251            PlanStatus::Draft => "📝",
252            PlanStatus::Active => "🔵",
253            PlanStatus::Completed => "✓",
254        };
255
256        println!("Plan: {} {}", status_icon, plan.title);
257        println!("  ID:     {}", plan.id);
258        println!("  Status: {}", plan.status.as_str());
259        println!("  Path:   {}", plan.project_path);
260
261        if let Some(criteria) = &plan.success_criteria {
262            println!();
263            println!("Success Criteria:");
264            println!("  {criteria}");
265        }
266
267        if let Some(content) = &plan.content {
268            println!();
269            println!("Content:");
270            println!("─────────────────────────────────────");
271            for line in content.lines() {
272                println!("  {line}");
273            }
274            println!("─────────────────────────────────────");
275        }
276
277        println!();
278        println!("Created: {}", format_timestamp(plan.created_at));
279        println!("Updated: {}", format_timestamp(plan.updated_at));
280        if let Some(completed_at) = plan.completed_at {
281            println!("Completed: {}", format_timestamp(completed_at));
282        }
283    }
284
285    Ok(())
286}
287
288fn execute_update(
289    storage: &mut SqliteStorage,
290    args: &PlanUpdateArgs,
291    json_output: bool,
292    actor: &str,
293) -> Result<()> {
294    // Verify plan exists
295    let plan = storage.get_plan(&args.id)?
296        .ok_or_else(|| Error::Other(format!("Plan not found: {}", args.id)))?;
297
298    // Update
299    storage.update_plan(
300        &plan.id,
301        args.title.as_deref(),
302        args.content.as_deref(),
303        args.status.as_deref(),
304        args.success_criteria.as_deref(),
305        actor,
306    )?;
307
308    // Fetch updated plan
309    let updated = storage.get_plan(&plan.id)?.unwrap();
310
311    if json_output {
312        let output = PlanOutput::from(updated);
313        println!("{}", serde_json::to_string_pretty(&output)?);
314    } else {
315        println!("Updated plan: {}", updated.title);
316        if args.title.is_some() {
317            println!("  Title: {}", updated.title);
318        }
319        if args.status.is_some() {
320            println!("  Status: {}", updated.status.as_str());
321        }
322        if args.success_criteria.is_some() {
323            println!("  Success criteria updated");
324        }
325        if args.content.is_some() {
326            println!("  Content updated");
327        }
328    }
329
330    Ok(())
331}