- Replace .unwrap() on Mutex lock with proper error propagation - Add worktree_path and branch_name columns to processed_tickets - Add style-src to CSP for Tailwind compatibility - Implement std::error::Error for AppError - Check affected row count in update/delete operations - Handle non-UTF-8 paths in git clone Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
190 lines
5.8 KiB
Rust
190 lines
5.8 KiB
Rust
use rusqlite::{params, Connection, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct Project {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub path: String,
|
|
pub cloned_from: Option<String>,
|
|
pub base_branch: String,
|
|
pub created_at: String,
|
|
}
|
|
|
|
impl Project {
|
|
pub fn insert(
|
|
conn: &Connection,
|
|
name: &str,
|
|
path: &str,
|
|
cloned_from: Option<&str>,
|
|
base_branch: &str,
|
|
) -> Result<Project> {
|
|
let id = Uuid::new_v4().to_string();
|
|
let now = chrono::Utc::now().to_rfc3339();
|
|
|
|
conn.execute(
|
|
"INSERT INTO projects (id, name, path, cloned_from, base_branch, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
params![id, name, path, cloned_from, base_branch, now],
|
|
)?;
|
|
|
|
Ok(Project {
|
|
id,
|
|
name: name.to_string(),
|
|
path: path.to_string(),
|
|
cloned_from: cloned_from.map(String::from),
|
|
base_branch: base_branch.to_string(),
|
|
created_at: now,
|
|
})
|
|
}
|
|
|
|
pub fn list(conn: &Connection) -> Result<Vec<Project>> {
|
|
let mut stmt = conn.prepare(
|
|
"SELECT id, name, path, cloned_from, base_branch, created_at FROM projects ORDER BY created_at DESC",
|
|
)?;
|
|
let rows = stmt.query_map([], |row| {
|
|
Ok(Project {
|
|
id: row.get(0)?,
|
|
name: row.get(1)?,
|
|
path: row.get(2)?,
|
|
cloned_from: row.get(3)?,
|
|
base_branch: row.get(4)?,
|
|
created_at: row.get(5)?,
|
|
})
|
|
})?;
|
|
rows.collect()
|
|
}
|
|
|
|
pub fn get_by_id(conn: &Connection, id: &str) -> Result<Project> {
|
|
conn.query_row(
|
|
"SELECT id, name, path, cloned_from, base_branch, created_at FROM projects WHERE id = ?1",
|
|
params![id],
|
|
|row| {
|
|
Ok(Project {
|
|
id: row.get(0)?,
|
|
name: row.get(1)?,
|
|
path: row.get(2)?,
|
|
cloned_from: row.get(3)?,
|
|
base_branch: row.get(4)?,
|
|
created_at: row.get(5)?,
|
|
})
|
|
},
|
|
)
|
|
}
|
|
|
|
pub fn update(conn: &Connection, id: &str, name: &str, base_branch: &str) -> Result<()> {
|
|
let affected = conn.execute(
|
|
"UPDATE projects SET name = ?1, base_branch = ?2 WHERE id = ?3",
|
|
params![name, base_branch, id],
|
|
)?;
|
|
if affected == 0 {
|
|
return Err(rusqlite::Error::QueryReturnedNoRows);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn delete(conn: &Connection, id: &str) -> Result<()> {
|
|
let affected = conn.execute("DELETE FROM projects WHERE id = ?1", params![id])?;
|
|
if affected == 0 {
|
|
return Err(rusqlite::Error::QueryReturnedNoRows);
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::db;
|
|
|
|
fn setup() -> Connection {
|
|
db::init_in_memory().expect("db init should succeed")
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_project_local_path() {
|
|
let conn = setup();
|
|
let project = Project::insert(&conn, "My Project", "/home/user/code/myproject", None, "main")
|
|
.expect("insert should succeed");
|
|
|
|
assert_eq!(project.name, "My Project");
|
|
assert_eq!(project.path, "/home/user/code/myproject");
|
|
assert!(project.cloned_from.is_none());
|
|
assert_eq!(project.base_branch, "main");
|
|
assert!(!project.id.is_empty());
|
|
assert!(!project.created_at.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_insert_project_cloned() {
|
|
let conn = setup();
|
|
let project = Project::insert(
|
|
&conn,
|
|
"Cloned Project",
|
|
"/home/user/code/cloned",
|
|
Some("https://github.com/org/repo.git"),
|
|
"stable",
|
|
)
|
|
.expect("insert should succeed");
|
|
|
|
assert_eq!(project.cloned_from.as_deref(), Some("https://github.com/org/repo.git"));
|
|
assert_eq!(project.base_branch, "stable");
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_projects_empty() {
|
|
let conn = setup();
|
|
let projects = Project::list(&conn).expect("list should succeed");
|
|
assert!(projects.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_list_projects_returns_all() {
|
|
let conn = setup();
|
|
Project::insert(&conn, "A", "/path/a", None, "main").unwrap();
|
|
Project::insert(&conn, "B", "/path/b", None, "main").unwrap();
|
|
|
|
let projects = Project::list(&conn).expect("list should succeed");
|
|
assert_eq!(projects.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_by_id() {
|
|
let conn = setup();
|
|
let created = Project::insert(&conn, "Test", "/path/test", None, "main").unwrap();
|
|
let found = Project::get_by_id(&conn, &created.id).expect("get should succeed");
|
|
|
|
assert_eq!(found.id, created.id);
|
|
assert_eq!(found.name, "Test");
|
|
}
|
|
|
|
#[test]
|
|
fn test_get_by_id_not_found() {
|
|
let conn = setup();
|
|
let result = Project::get_by_id(&conn, "nonexistent");
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_update_project() {
|
|
let conn = setup();
|
|
let created = Project::insert(&conn, "Old Name", "/path", None, "main").unwrap();
|
|
|
|
Project::update(&conn, &created.id, "New Name", "develop").expect("update should succeed");
|
|
|
|
let updated = Project::get_by_id(&conn, &created.id).unwrap();
|
|
assert_eq!(updated.name, "New Name");
|
|
assert_eq!(updated.base_branch, "develop");
|
|
}
|
|
|
|
#[test]
|
|
fn test_delete_project() {
|
|
let conn = setup();
|
|
let created = Project::insert(&conn, "ToDelete", "/path", None, "main").unwrap();
|
|
|
|
Project::delete(&conn, &created.id).expect("delete should succeed");
|
|
|
|
let result = Project::get_by_id(&conn, &created.id);
|
|
assert!(result.is_err());
|
|
}
|
|
}
|