1use 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 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
104pub 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 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 let project_path = std::fs::canonicalize(&project_path)
142 .map(|p| p.to_string_lossy().to_string())
143 .unwrap_or(project_path);
144
145 let project = storage.get_or_create_project(&project_path, actor)?;
147
148 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 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 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 let plan = storage.get_plan(&args.id)?
296 .ok_or_else(|| Error::Other(format!("Plan not found: {}", args.id)))?;
297
298 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 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}