1use crate::cli::{ProjectCommands, ProjectCreateArgs, ProjectUpdateArgs};
11use crate::config::{current_project_path, default_actor, resolve_db_path};
12use crate::error::{Error, Result};
13use crate::model::Project;
14use crate::storage::SqliteStorage;
15use serde::Serialize;
16use std::path::PathBuf;
17
18#[derive(Serialize)]
19struct ProjectOutput {
20 id: String,
21 project_path: String,
22 name: String,
23 description: Option<String>,
24 issue_prefix: Option<String>,
25 next_issue_number: i32,
26 created_at: String,
27 updated_at: String,
28}
29
30impl From<Project> for ProjectOutput {
31 fn from(p: Project) -> Self {
32 Self {
33 id: p.id,
34 project_path: p.project_path,
35 name: p.name,
36 description: p.description,
37 issue_prefix: p.issue_prefix,
38 next_issue_number: p.next_issue_number,
39 created_at: format_timestamp(p.created_at),
40 updated_at: format_timestamp(p.updated_at),
41 }
42 }
43}
44
45#[derive(Serialize)]
46struct ProjectListOutput {
47 projects: Vec<ProjectOutput>,
48 count: usize,
49}
50
51#[derive(Serialize)]
52struct ProjectWithCounts {
53 #[serde(flatten)]
54 project: ProjectOutput,
55 session_count: usize,
56 issue_count: usize,
57 memory_count: usize,
58}
59
60fn format_timestamp(ts: i64) -> String {
61 chrono::DateTime::from_timestamp_millis(ts)
62 .map(|dt| dt.to_rfc3339())
63 .unwrap_or_else(|| ts.to_string())
64}
65
66pub fn execute(
68 command: &ProjectCommands,
69 db_path: Option<&PathBuf>,
70 actor: Option<&str>,
71 json_output: bool,
72) -> Result<()> {
73 let db_path = resolve_db_path(db_path.map(|p| p.as_path()))
74 .ok_or(Error::NotInitialized)?;
75
76 if !db_path.exists() {
77 return Err(Error::NotInitialized);
78 }
79
80 let mut storage = SqliteStorage::open(&db_path)?;
81 let actor = actor.map(String::from).unwrap_or_else(default_actor);
82
83 match command {
84 ProjectCommands::Create(args) => execute_create(&mut storage, args, json_output, &actor),
85 ProjectCommands::List { limit, session_count } => execute_list(&storage, *limit, *session_count, json_output),
86 ProjectCommands::Show { id } => execute_show(&storage, id, json_output),
87 ProjectCommands::Update(args) => execute_update(&mut storage, args, json_output, &actor),
88 ProjectCommands::Delete { id, force } => execute_delete(&mut storage, id, *force, json_output, &actor),
89 }
90}
91
92fn execute_create(
93 storage: &mut SqliteStorage,
94 args: &ProjectCreateArgs,
95 json_output: bool,
96 actor: &str,
97) -> Result<()> {
98 let project_path = args.path.clone().unwrap_or_else(|| {
100 std::env::current_dir()
101 .ok()
102 .and_then(|p| p.canonicalize().ok())
103 .map(|p| p.to_string_lossy().to_string())
104 .unwrap_or_else(|| {
105 current_project_path()
106 .map(|p| p.to_string_lossy().to_string())
107 .unwrap_or_else(|| ".".to_string())
108 })
109 });
110
111 let project_path = std::fs::canonicalize(&project_path)
113 .map(|p| p.to_string_lossy().to_string())
114 .unwrap_or(project_path);
115
116 if let Some(existing) = storage.get_project_by_path(&project_path)? {
118 if json_output {
119 let output = ProjectOutput::from(existing);
120 println!("{}", serde_json::to_string_pretty(&output)?);
121 } else {
122 println!("Project already exists at this path");
123 println!(" ID: {}", existing.id);
124 println!(" Name: {}", existing.name);
125 }
126 return Ok(());
127 }
128
129 let name = args.name.clone().unwrap_or_else(|| {
131 std::path::Path::new(&project_path)
132 .file_name()
133 .and_then(|n| n.to_str())
134 .unwrap_or("Unknown Project")
135 .to_string()
136 });
137
138 let mut project = Project::new(project_path, name);
140
141 if let Some(ref desc) = args.description {
142 project.description = Some(desc.clone());
143 }
144
145 if let Some(ref prefix) = args.issue_prefix {
146 project.issue_prefix = Some(prefix.to_uppercase());
147 }
148
149 storage.create_project(&project, actor)?;
150
151 if json_output {
152 let output = ProjectOutput::from(project);
153 println!("{}", serde_json::to_string_pretty(&output)?);
154 } else {
155 println!("Created project: {}", project.name);
156 println!(" ID: {}", project.id);
157 println!(" Path: {}", project.project_path);
158 println!(" Issue prefix: {}", project.issue_prefix.unwrap_or_default());
159 }
160
161 Ok(())
162}
163
164fn execute_list(
165 storage: &SqliteStorage,
166 limit: usize,
167 include_session_count: bool,
168 json_output: bool,
169) -> Result<()> {
170 let projects = storage.list_projects(limit)?;
171
172 if json_output {
173 if include_session_count {
174 let projects_with_counts: Vec<serde_json::Value> = projects
176 .iter()
177 .map(|p| {
178 let counts = storage.get_project_counts(&p.project_path).ok();
179 let mut obj = serde_json::to_value(ProjectOutput::from(p.clone())).unwrap();
180 if let (Some(counts), Some(obj_map)) = (counts, obj.as_object_mut()) {
181 obj_map.insert("session_count".to_string(), serde_json::json!(counts.sessions));
182 }
183 obj
184 })
185 .collect();
186 let output = serde_json::json!({
187 "count": projects_with_counts.len(),
188 "projects": projects_with_counts
189 });
190 println!("{}", serde_json::to_string_pretty(&output)?);
191 } else {
192 let output = ProjectListOutput {
193 count: projects.len(),
194 projects: projects.into_iter().map(ProjectOutput::from).collect(),
195 };
196 println!("{}", serde_json::to_string_pretty(&output)?);
197 }
198 } else if projects.is_empty() {
199 println!("No projects found.");
200 println!("\nCreate one with: sc project create [--name <name>]");
201 } else {
202 println!("Projects ({}):\n", projects.len());
203 for project in &projects {
204 let prefix = project.issue_prefix.as_deref().unwrap_or("-");
205 if include_session_count {
206 let session_count = storage
207 .get_project_counts(&project.project_path)
208 .map(|c| c.sessions)
209 .unwrap_or(0);
210 println!(" {} [{}] ({} sessions)", project.name, prefix, session_count);
211 } else {
212 println!(" {} [{}]", project.name, prefix);
213 }
214 println!(" ID: {}", project.id);
215 println!(" Path: {}", project.project_path);
216 if let Some(desc) = &project.description {
217 println!(" Desc: {}", desc);
218 }
219 println!();
220 }
221 }
222
223 Ok(())
224}
225
226fn execute_show(
227 storage: &SqliteStorage,
228 id: &str,
229 json_output: bool,
230) -> Result<()> {
231 let project = storage.get_project(id)?
233 .or_else(|| storage.get_project_by_path(id).ok().flatten());
234
235 let project = project.ok_or_else(|| {
236 Error::ProjectNotFound { id: id.to_string() }
237 })?;
238
239 let counts = storage.get_project_counts(&project.project_path)?;
241
242 if json_output {
243 let output = ProjectWithCounts {
244 project: ProjectOutput::from(project),
245 session_count: counts.sessions,
246 issue_count: counts.issues,
247 memory_count: counts.memories,
248 };
249 println!("{}", serde_json::to_string_pretty(&output)?);
250 } else {
251 println!("Project: {}", project.name);
252 println!(" ID: {}", project.id);
253 println!(" Path: {}", project.project_path);
254 println!(" Issue prefix: {}", project.issue_prefix.as_deref().unwrap_or("-"));
255 println!(" Description: {}", project.description.as_deref().unwrap_or("-"));
256 println!();
257 println!("Statistics:");
258 println!(" Sessions: {}", counts.sessions);
259 println!(" Issues: {}", counts.issues);
260 println!(" Memory items: {}", counts.memories);
261 println!(" Checkpoints: {}", counts.checkpoints);
262 println!();
263 println!("Created: {}", format_timestamp(project.created_at));
264 println!("Updated: {}", format_timestamp(project.updated_at));
265 }
266
267 Ok(())
268}
269
270fn execute_update(
271 storage: &mut SqliteStorage,
272 args: &ProjectUpdateArgs,
273 json_output: bool,
274 actor: &str,
275) -> Result<()> {
276 let project = storage.get_project(&args.id)?
278 .or_else(|| storage.get_project_by_path(&args.id).ok().flatten())
279 .ok_or_else(|| {
280 Error::ProjectNotFound { id: args.id.clone() }
281 })?;
282
283 storage.update_project(
285 &project.id,
286 args.name.as_deref(),
287 args.description.as_deref(),
288 args.issue_prefix.as_deref(),
289 actor,
290 )?;
291
292 let updated = storage.get_project(&project.id)?.unwrap();
294
295 if json_output {
296 let output = ProjectOutput::from(updated);
297 println!("{}", serde_json::to_string_pretty(&output)?);
298 } else {
299 println!("Updated project: {}", updated.name);
300 if args.name.is_some() {
301 println!(" Name: {}", updated.name);
302 }
303 if args.description.is_some() {
304 println!(" Description: {}", updated.description.as_deref().unwrap_or("-"));
305 }
306 if args.issue_prefix.is_some() {
307 println!(" Issue prefix: {}", updated.issue_prefix.as_deref().unwrap_or("-"));
308 }
309 }
310
311 Ok(())
312}
313
314fn execute_delete(
315 storage: &mut SqliteStorage,
316 id: &str,
317 force: bool,
318 json_output: bool,
319 actor: &str,
320) -> Result<()> {
321 let project = storage.get_project(id)?
323 .or_else(|| storage.get_project_by_path(id).ok().flatten())
324 .ok_or_else(|| {
325 Error::ProjectNotFound { id: id.to_string() }
326 })?;
327
328 let counts = storage.get_project_counts(&project.project_path)?;
330 let total_items = counts.sessions + counts.issues + counts.memories + counts.checkpoints;
331
332 if !force && total_items > 0 && !json_output {
333 println!("Warning: This will delete:");
334 println!(" {} sessions", counts.sessions);
335 println!(" {} issues", counts.issues);
336 println!(" {} memory items", counts.memories);
337 println!(" {} checkpoints", counts.checkpoints);
338 println!();
339 println!("Use --force to confirm deletion.");
340 return Ok(());
341 }
342
343 storage.delete_project(&project.id, actor)?;
345
346 if json_output {
347 let output = serde_json::json!({
348 "deleted": true,
349 "id": project.id,
350 "name": project.name,
351 "items_deleted": total_items
352 });
353 println!("{}", serde_json::to_string_pretty(&output)?);
354 } else {
355 println!("Deleted project: {} ({})", project.name, project.id);
356 if total_items > 0 {
357 println!(" Deleted {} associated items", total_items);
358 }
359 }
360
361 Ok(())
362}