1use uuid::Uuid;
2
3use crate::errors::CoreError;
4use crate::models::{AddProjectInput, ProjectRecord, RemoveProjectInput};
5
6#[derive(sqlx::FromRow)]
7struct ProjectRow {
8 id: String,
9 name: String,
10 path: String,
11 git_branch: Option<String>,
12 active_sessions: i64,
13 created_at: String,
14}
15
16impl From<ProjectRow> for ProjectRecord {
17 fn from(r: ProjectRow) -> Self {
18 Self {
19 id: r.id,
20 name: r.name,
21 path: r.path,
22 git_branch: r.git_branch,
23 active_sessions: i32::try_from(r.active_sessions).unwrap_or(i32::MAX),
24 total_sessions: None,
25 created_at: r.created_at,
26 }
27 }
28}
29
30pub async fn list(db: &sqlx::SqlitePool) -> crate::Result<Vec<ProjectRecord>> {
31 let rows = sqlx::query_as!(
32 ProjectRow,
33 "SELECT id, name, path, git_branch, active_sessions, created_at
34 FROM projects ORDER BY created_at DESC"
35 )
36 .fetch_all(db)
37 .await?;
38
39 Ok(rows.into_iter().map(ProjectRecord::from).collect())
40}
41
42pub async fn get(
43 db: &sqlx::SqlitePool,
44 input: RemoveProjectInput,
45) -> crate::Result<Option<ProjectRecord>> {
46 if input.id.trim().is_empty() {
47 return Err(CoreError::Validation("id is required".into()));
48 }
49 let row = sqlx::query_as!(
50 ProjectRow,
51 "SELECT id, name, path, git_branch, active_sessions, created_at
52 FROM projects WHERE id = ?1",
53 input.id
54 )
55 .fetch_optional(db)
56 .await?;
57
58 Ok(row.map(ProjectRecord::from))
59}
60
61pub async fn add(db: &sqlx::SqlitePool, input: AddProjectInput) -> crate::Result<ProjectRecord> {
62 let path = normalize_project_path(&input.path)?;
63 let path_str = path.to_string_lossy().to_string();
64
65 if path_str.trim().is_empty() {
66 return Err(CoreError::Validation("path is required".into()));
67 }
68 let existing = sqlx::query_as!(
69 ProjectRow,
70 "SELECT id, name, path, git_branch, active_sessions, created_at
71 FROM projects WHERE path = ?1",
72 path_str
73 )
74 .fetch_optional(db)
75 .await?;
76
77 if let Some(p) = existing {
78 return Ok(ProjectRecord::from(p));
79 }
80
81 let name = path
82 .file_name()
83 .and_then(|v| v.to_str())
84 .filter(|v| !v.is_empty())
85 .unwrap_or("project")
86 .to_owned();
87
88 let id = format!("project-{}", Uuid::new_v4());
89 let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
90
91 sqlx::query!(
92 "INSERT INTO projects (id, name, path, active_sessions, created_at) VALUES (?1, ?2, ?3, 0, ?4)",
93 id,
94 name,
95 path_str,
96 now
97 )
98 .execute(db)
99 .await?;
100
101 Ok(ProjectRecord {
102 id,
103 name,
104 path: path_str,
105 git_branch: None,
106 active_sessions: 0,
107 total_sessions: Some(0),
108 created_at: now,
109 })
110}
111
112fn normalize_project_path(raw: &str) -> crate::Result<std::path::PathBuf> {
113 let trimmed = raw.trim();
114 if trimmed.is_empty() {
115 return Err(CoreError::Validation("path is required".into()));
116 }
117
118 let path = std::path::Path::new(trimmed);
119 let canonical = path.canonicalize().map_err(|e| {
120 CoreError::Validation(format!("project path must be an existing directory: {e}"))
121 })?;
122 if !canonical.is_dir() {
123 return Err(CoreError::Validation(format!(
124 "project path must be a directory: {}",
125 canonical.display()
126 )));
127 }
128 if canonical.parent().is_none() {
129 return Err(CoreError::Validation(
130 "refusing to register a filesystem root as a project".into(),
131 ));
132 }
133 Ok(canonical)
134}
135
136pub async fn remove(db: &sqlx::SqlitePool, input: RemoveProjectInput) -> crate::Result<()> {
137 if input.id.trim().is_empty() {
138 return Err(CoreError::Validation("id is required".into()));
139 }
140 let result = sqlx::query!("DELETE FROM projects WHERE id = ?1", input.id)
141 .execute(db)
142 .await?;
143 if result.rows_affected() == 0 {
144 return Err(CoreError::NotFound(format!(
145 "project '{}' not found.",
146 input.id
147 )));
148 }
149
150 Ok(())
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 fn make_row(
158 id: &str,
159 name: &str,
160 path: &str,
161 branch: Option<&str>,
162 sessions: i64,
163 ) -> ProjectRow {
164 ProjectRow {
165 id: id.into(),
166 name: name.into(),
167 path: path.into(),
168 git_branch: branch.map(String::from),
169 active_sessions: sessions,
170 created_at: "2026-04-10 12:00:00".into(),
171 }
172 }
173
174 #[test]
175 fn project_row_into_record_copies_all_fields() {
176 let row = make_row("p-1", "my-proj", "/home/me/code", Some("main"), 3);
177 let rec = ProjectRecord::from(row);
178 assert_eq!(rec.id, "p-1");
179 assert_eq!(rec.name, "my-proj");
180 assert_eq!(rec.path, "/home/me/code");
181 assert_eq!(rec.git_branch.as_deref(), Some("main"));
182 assert_eq!(rec.active_sessions, 3);
183 assert_eq!(rec.total_sessions, None);
184 assert_eq!(rec.created_at, "2026-04-10 12:00:00");
185 }
186
187 #[test]
188 fn normalize_project_path_rejects_missing_file_and_root() {
189 let err = normalize_project_path("").unwrap_err().to_string();
190 assert!(err.contains("path is required"), "unexpected: {err}");
191
192 let missing = normalize_project_path("/definitely/not/difflore")
193 .unwrap_err()
194 .to_string();
195 assert!(
196 missing.contains("existing directory"),
197 "unexpected: {missing}"
198 );
199
200 let root = std::path::Path::new(std::path::MAIN_SEPARATOR_STR);
201 if root.exists() {
202 let err = normalize_project_path(root.to_string_lossy().as_ref())
203 .unwrap_err()
204 .to_string();
205 assert!(
206 err.contains("filesystem root"),
207 "root should be rejected: {err}"
208 );
209 }
210 }
211}