difflore-core 0.3.0

Core library for the difflore CLI — rule store, retrieval, MCP server, hooks, cloud sync. Not intended for direct use; depend on `difflore-cli` instead.
use uuid::Uuid;

use crate::domain::models::{AddProjectInput, ProjectRecord, RemoveProjectInput};
use crate::error::CoreError;

#[derive(sqlx::FromRow)]
struct ProjectRow {
    id: String,
    name: String,
    path: String,
    git_branch: Option<String>,
    active_sessions: i64,
    created_at: String,
}

impl From<ProjectRow> for ProjectRecord {
    fn from(r: ProjectRow) -> Self {
        Self {
            id: r.id,
            name: r.name,
            path: r.path,
            git_branch: r.git_branch,
            active_sessions: count_i64_to_i32(r.active_sessions),
            total_sessions: None,
            created_at: r.created_at,
        }
    }
}

fn count_i64_to_i32(value: i64) -> i32 {
    i32::try_from(value.max(0)).unwrap_or(i32::MAX)
}

pub async fn list(db: &sqlx::SqlitePool) -> crate::Result<Vec<ProjectRecord>> {
    let rows = sqlx::query_as!(
        ProjectRow,
        "SELECT id, name, path, git_branch, active_sessions, created_at
         FROM projects ORDER BY created_at DESC"
    )
    .fetch_all(db)
    .await?;

    Ok(rows.into_iter().map(ProjectRecord::from).collect())
}

pub async fn get(
    db: &sqlx::SqlitePool,
    input: RemoveProjectInput,
) -> crate::Result<Option<ProjectRecord>> {
    if input.id.trim().is_empty() {
        return Err(CoreError::Validation("id is required".into()));
    }
    let row = sqlx::query_as!(
        ProjectRow,
        "SELECT id, name, path, git_branch, active_sessions, created_at
         FROM projects WHERE id = ?1",
        input.id
    )
    .fetch_optional(db)
    .await?;

    Ok(row.map(ProjectRecord::from))
}

pub async fn add(db: &sqlx::SqlitePool, input: AddProjectInput) -> crate::Result<ProjectRecord> {
    let path = normalize_project_path(&input.path)?;
    let path_str = path.to_string_lossy().to_string();

    if path_str.trim().is_empty() {
        return Err(CoreError::Validation("path is required".into()));
    }
    let existing = sqlx::query_as!(
        ProjectRow,
        "SELECT id, name, path, git_branch, active_sessions, created_at
         FROM projects WHERE path = ?1",
        path_str
    )
    .fetch_optional(db)
    .await?;

    if let Some(p) = existing {
        return Ok(ProjectRecord::from(p));
    }

    let name = path
        .file_name()
        .and_then(|v| v.to_str())
        .filter(|v| !v.is_empty())
        .unwrap_or("project")
        .to_owned();

    let id = format!("project-{}", Uuid::new_v4());
    let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();

    sqlx::query!(
        "INSERT INTO projects (id, name, path, active_sessions, created_at) VALUES (?1, ?2, ?3, 0, ?4)",
        id,
        name,
        path_str,
        now
    )
    .execute(db)
    .await?;

    Ok(ProjectRecord {
        id,
        name,
        path: path_str,
        git_branch: None,
        active_sessions: 0,
        total_sessions: Some(0),
        created_at: now,
    })
}

fn normalize_project_path(raw: &str) -> crate::Result<std::path::PathBuf> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return Err(CoreError::Validation("path is required".into()));
    }

    let path = std::path::Path::new(trimmed);
    let canonical = path.canonicalize().map_err(|e| {
        CoreError::Validation(format!("project path must be an existing directory: {e}"))
    })?;
    if !canonical.is_dir() {
        return Err(CoreError::Validation(format!(
            "project path must be a directory: {}",
            canonical.display()
        )));
    }
    if canonical.parent().is_none() {
        return Err(CoreError::Validation(
            "refusing to register a filesystem root as a project".into(),
        ));
    }
    Ok(canonical)
}

pub async fn remove(db: &sqlx::SqlitePool, input: RemoveProjectInput) -> crate::Result<()> {
    if input.id.trim().is_empty() {
        return Err(CoreError::Validation("id is required".into()));
    }
    let result = sqlx::query!("DELETE FROM projects WHERE id = ?1", input.id)
        .execute(db)
        .await?;
    if result.rows_affected() == 0 {
        return Err(CoreError::NotFound(format!(
            "project '{}' not found.",
            input.id
        )));
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_row(
        id: &str,
        name: &str,
        path: &str,
        branch: Option<&str>,
        sessions: i64,
    ) -> ProjectRow {
        ProjectRow {
            id: id.into(),
            name: name.into(),
            path: path.into(),
            git_branch: branch.map(String::from),
            active_sessions: sessions,
            created_at: "2026-04-10 12:00:00".into(),
        }
    }

    #[test]
    fn project_row_into_record_copies_all_fields() {
        let row = make_row("p-1", "my-proj", "/home/me/code", Some("main"), 3);
        let rec = ProjectRecord::from(row);
        assert_eq!(rec.id, "p-1");
        assert_eq!(rec.name, "my-proj");
        assert_eq!(rec.path, "/home/me/code");
        assert_eq!(rec.git_branch.as_deref(), Some("main"));
        assert_eq!(rec.active_sessions, 3);
        assert_eq!(rec.total_sessions, None);
        assert_eq!(rec.created_at, "2026-04-10 12:00:00");
    }

    #[test]
    fn project_row_count_conversion_clamps_to_display_range() {
        assert_eq!(count_i64_to_i32(-1), 0);
        assert_eq!(count_i64_to_i32(i64::MAX), i32::MAX);
    }

    #[test]
    fn normalize_project_path_rejects_missing_file_and_root() {
        let err = normalize_project_path("").unwrap_err().to_string();
        assert!(err.contains("path is required"), "unexpected: {err}");

        let missing = normalize_project_path("/definitely/not/difflore")
            .unwrap_err()
            .to_string();
        assert!(
            missing.contains("existing directory"),
            "unexpected: {missing}"
        );

        let root = std::path::Path::new(std::path::MAIN_SEPARATOR_STR);
        if root.exists() {
            let err = normalize_project_path(root.to_string_lossy().as_ref())
                .unwrap_err()
                .to_string();
            assert!(
                err.contains("filesystem root"),
                "root should be rejected: {err}"
            );
        }
    }
}