Skip to main content

sc/model/
project.rs

1//! Project model for SaveContext.
2//!
3//! Projects represent distinct codebases/directories that can have their own
4//! issue prefixes, plans, and memory.
5
6use serde::{Deserialize, Serialize};
7
8/// A project in SaveContext.
9///
10/// Projects provide:
11/// - Issue ID prefixes (e.g., "SC" -> SC-1, SC-2)
12/// - Plan tracking
13/// - Project-level memory
14/// - Session grouping
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Project {
17    /// Unique identifier (UUID format)
18    pub id: String,
19
20    /// Absolute path to the project directory
21    pub project_path: String,
22
23    /// Display name for the project
24    pub name: String,
25
26    /// Optional description
27    pub description: Option<String>,
28
29    /// Prefix for issue short IDs (e.g., "SC" creates SC-1, SC-2)
30    pub issue_prefix: Option<String>,
31
32    /// Next issue number to assign
33    #[serde(default = "default_one")]
34    pub next_issue_number: i32,
35
36    /// Prefix for plan short IDs
37    pub plan_prefix: Option<String>,
38
39    /// Next plan number to assign
40    #[serde(default = "default_one")]
41    pub next_plan_number: i32,
42
43    /// Creation timestamp (Unix milliseconds)
44    pub created_at: i64,
45
46    /// Last update timestamp (Unix milliseconds)
47    pub updated_at: i64,
48}
49
50fn default_one() -> i32 {
51    1
52}
53
54impl Project {
55    /// Create a new project with default values.
56    pub fn new(project_path: String, name: String) -> Self {
57        let now = chrono::Utc::now().timestamp_millis();
58        let id = format!("proj_{}", &uuid::Uuid::new_v4().to_string()[..12]);
59
60        // Generate default issue prefix from first 2-4 chars of name
61        let issue_prefix = name
62            .chars()
63            .filter(|c| c.is_alphanumeric())
64            .take(4)
65            .collect::<String>()
66            .to_uppercase();
67
68        Self {
69            id,
70            project_path,
71            name,
72            description: None,
73            issue_prefix: Some(issue_prefix),
74            next_issue_number: 1,
75            plan_prefix: None,
76            next_plan_number: 1,
77            created_at: now,
78            updated_at: now,
79        }
80    }
81
82    /// Generate the next issue short ID.
83    pub fn next_issue_short_id(&self) -> String {
84        let prefix = self.issue_prefix.as_deref().unwrap_or("SC");
85        format!("{}-{}", prefix, self.next_issue_number)
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn test_new_project() {
95        let project = Project::new(
96            "/home/user/myproject".to_string(),
97            "My Project".to_string(),
98        );
99
100        assert!(project.id.starts_with("proj_"));
101        assert_eq!(project.project_path, "/home/user/myproject");
102        assert_eq!(project.name, "My Project");
103        assert_eq!(project.issue_prefix, Some("MYPR".to_string()));
104        assert_eq!(project.next_issue_number, 1);
105    }
106
107    #[test]
108    fn test_next_issue_short_id() {
109        let mut project = Project::new(
110            "/test".to_string(),
111            "Test".to_string(),
112        );
113        project.issue_prefix = Some("TEST".to_string());
114        project.next_issue_number = 42;
115
116        assert_eq!(project.next_issue_short_id(), "TEST-42");
117    }
118}