Skip to main content

difflore_core/domain/
projects.rs

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}