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 current_project_path()
101 .map(|p| p.to_string_lossy().to_string())
102 .unwrap_or_else(|| ".".to_string())
103 });
104
105 let project_path = std::fs::canonicalize(&project_path)
107 .map(|p| p.to_string_lossy().to_string())
108 .unwrap_or(project_path);
109
110 if let Some(existing) = storage.get_project_by_path(&project_path)? {
112 if json_output {
113 let output = ProjectOutput::from(existing);
114 println!("{}", serde_json::to_string_pretty(&output)?);
115 } else {
116 println!("Project already exists at this path");
117 println!(" ID: {}", existing.id);
118 println!(" Name: {}", existing.name);
119 }
120 return Ok(());
121 }
122
123 let name = args.name.clone().unwrap_or_else(|| {
125 std::path::Path::new(&project_path)
126 .file_name()
127 .and_then(|n| n.to_str())
128 .unwrap_or("Unknown Project")
129 .to_string()
130 });
131
132 let mut project = Project::new(project_path, name);
134
135 if let Some(ref desc) = args.description {
136 project.description = Some(desc.clone());
137 }
138
139 if let Some(ref prefix) = args.issue_prefix {
140 project.issue_prefix = Some(prefix.to_uppercase());
141 }
142
143 storage.create_project(&project, actor)?;
144
145 if json_output {
146 let output = ProjectOutput::from(project);
147 println!("{}", serde_json::to_string_pretty(&output)?);
148 } else {
149 println!("Created project: {}", project.name);
150 println!(" ID: {}", project.id);
151 println!(" Path: {}", project.project_path);
152 println!(" Issue prefix: {}", project.issue_prefix.unwrap_or_default());
153 }
154
155 Ok(())
156}
157
158fn execute_list(
159 storage: &SqliteStorage,
160 limit: usize,
161 include_session_count: bool,
162 json_output: bool,
163) -> Result<()> {
164 let projects = storage.list_projects(limit)?;
165
166 if json_output {
167 if include_session_count {
168 let projects_with_counts: Vec<serde_json::Value> = projects
170 .iter()
171 .map(|p| {
172 let counts = storage.get_project_counts(&p.project_path).ok();
173 let mut obj = serde_json::to_value(ProjectOutput::from(p.clone())).unwrap();
174 if let (Some(counts), Some(obj_map)) = (counts, obj.as_object_mut()) {
175 obj_map.insert("session_count".to_string(), serde_json::json!(counts.sessions));
176 }
177 obj
178 })
179 .collect();
180 let output = serde_json::json!({
181 "count": projects_with_counts.len(),
182 "projects": projects_with_counts
183 });
184 println!("{}", serde_json::to_string_pretty(&output)?);
185 } else {
186 let output = ProjectListOutput {
187 count: projects.len(),
188 projects: projects.into_iter().map(ProjectOutput::from).collect(),
189 };
190 println!("{}", serde_json::to_string_pretty(&output)?);
191 }
192 } else if projects.is_empty() {
193 println!("No projects found.");
194 println!("\nCreate one with: sc project create [--name <name>]");
195 } else {
196 println!("Projects ({}):\n", projects.len());
197 for project in &projects {
198 let prefix = project.issue_prefix.as_deref().unwrap_or("-");
199 if include_session_count {
200 let session_count = storage
201 .get_project_counts(&project.project_path)
202 .map(|c| c.sessions)
203 .unwrap_or(0);
204 println!(" {} [{}] ({} sessions)", project.name, prefix, session_count);
205 } else {
206 println!(" {} [{}]", project.name, prefix);
207 }
208 println!(" ID: {}", project.id);
209 println!(" Path: {}", project.project_path);
210 if let Some(desc) = &project.description {
211 println!(" Desc: {}", desc);
212 }
213 println!();
214 }
215 }
216
217 Ok(())
218}
219
220fn execute_show(
221 storage: &SqliteStorage,
222 id: &str,
223 json_output: bool,
224) -> Result<()> {
225 let project = storage.get_project(id)?
227 .or_else(|| storage.get_project_by_path(id).ok().flatten());
228
229 let project = project.ok_or_else(|| {
230 Error::ProjectNotFound { id: id.to_string() }
231 })?;
232
233 let counts = storage.get_project_counts(&project.project_path)?;
235
236 if json_output {
237 let output = ProjectWithCounts {
238 project: ProjectOutput::from(project),
239 session_count: counts.sessions,
240 issue_count: counts.issues,
241 memory_count: counts.memories,
242 };
243 println!("{}", serde_json::to_string_pretty(&output)?);
244 } else {
245 println!("Project: {}", project.name);
246 println!(" ID: {}", project.id);
247 println!(" Path: {}", project.project_path);
248 println!(" Issue prefix: {}", project.issue_prefix.as_deref().unwrap_or("-"));
249 println!(" Description: {}", project.description.as_deref().unwrap_or("-"));
250 println!();
251 println!("Statistics:");
252 println!(" Sessions: {}", counts.sessions);
253 println!(" Issues: {}", counts.issues);
254 println!(" Memory items: {}", counts.memories);
255 println!(" Checkpoints: {}", counts.checkpoints);
256 println!();
257 println!("Created: {}", format_timestamp(project.created_at));
258 println!("Updated: {}", format_timestamp(project.updated_at));
259 }
260
261 Ok(())
262}
263
264fn execute_update(
265 storage: &mut SqliteStorage,
266 args: &ProjectUpdateArgs,
267 json_output: bool,
268 actor: &str,
269) -> Result<()> {
270 let project = storage.get_project(&args.id)?
272 .or_else(|| storage.get_project_by_path(&args.id).ok().flatten())
273 .ok_or_else(|| {
274 Error::ProjectNotFound { id: args.id.clone() }
275 })?;
276
277 storage.update_project(
279 &project.id,
280 args.name.as_deref(),
281 args.description.as_deref(),
282 args.issue_prefix.as_deref(),
283 actor,
284 )?;
285
286 let updated = storage.get_project(&project.id)?.unwrap();
288
289 if json_output {
290 let output = ProjectOutput::from(updated);
291 println!("{}", serde_json::to_string_pretty(&output)?);
292 } else {
293 println!("Updated project: {}", updated.name);
294 if args.name.is_some() {
295 println!(" Name: {}", updated.name);
296 }
297 if args.description.is_some() {
298 println!(" Description: {}", updated.description.as_deref().unwrap_or("-"));
299 }
300 if args.issue_prefix.is_some() {
301 println!(" Issue prefix: {}", updated.issue_prefix.as_deref().unwrap_or("-"));
302 }
303 }
304
305 Ok(())
306}
307
308fn execute_delete(
309 storage: &mut SqliteStorage,
310 id: &str,
311 force: bool,
312 json_output: bool,
313 actor: &str,
314) -> Result<()> {
315 let project = storage.get_project(id)?
317 .or_else(|| storage.get_project_by_path(id).ok().flatten())
318 .ok_or_else(|| {
319 Error::ProjectNotFound { id: id.to_string() }
320 })?;
321
322 let counts = storage.get_project_counts(&project.project_path)?;
324 let total_items = counts.sessions + counts.issues + counts.memories + counts.checkpoints;
325
326 if !force && total_items > 0 && !json_output {
327 println!("Warning: This will delete:");
328 println!(" {} sessions", counts.sessions);
329 println!(" {} issues", counts.issues);
330 println!(" {} memory items", counts.memories);
331 println!(" {} checkpoints", counts.checkpoints);
332 println!();
333 println!("Use --force to confirm deletion.");
334 return Ok(());
335 }
336
337 storage.delete_project(&project.id, actor)?;
339
340 if json_output {
341 let output = serde_json::json!({
342 "deleted": true,
343 "id": project.id,
344 "name": project.name,
345 "items_deleted": total_items
346 });
347 println!("{}", serde_json::to_string_pretty(&output)?);
348 } else {
349 println!("Deleted project: {} ({})", project.name, project.id);
350 if total_items > 0 {
351 println!(" Deleted {} associated items", total_items);
352 }
353 }
354
355 Ok(())
356}