Skip to main content

sc/cli/commands/
project.rs

1//! Project management commands.
2//!
3//! Commands for managing SaveContext projects:
4//! - `sc project create <path>` - Create a new project
5//! - `sc project list` - List all projects
6//! - `sc project show <id>` - Show project details
7//! - `sc project update <id>` - Update project settings
8//! - `sc project delete <id>` - Delete a project
9
10use 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
66/// Execute a project command.
67pub 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    // Use provided path or canonicalized CWD
99    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    // Canonicalize the path
112    let project_path = std::fs::canonicalize(&project_path)
113        .map(|p| p.to_string_lossy().to_string())
114        .unwrap_or(project_path);
115
116    // Check if project already exists
117    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    // Derive name from path if not provided
130    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    // Create project
139    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            // Include session counts for each project
175            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    // Try to find by ID first, then by path
232    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    // Get counts
240    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    // Find the project
277    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    // Update
284    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    // Fetch updated project
293    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    // Find the project
322    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    // Get counts for warning
329    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    // Delete
344    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}