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 current directory
99    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    // Canonicalize the path
106    let project_path = std::fs::canonicalize(&project_path)
107        .map(|p| p.to_string_lossy().to_string())
108        .unwrap_or(project_path);
109
110    // Check if project already exists
111    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    // Derive name from path if not provided
124    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    // Create project
133    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            // Include session counts for each project
169            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    // Try to find by ID first, then by path
226    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    // Get counts
234    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    // Find the project
271    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    // Update
278    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    // Fetch updated project
287    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    // Find the project
316    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    // Get counts for warning
323    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    // Delete
338    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}