Skip to main content

oxios_kernel/project/
project_db.rs

1//! Project-related SQLite operations.
2//!
3//! Extracted from `memory/database.rs` — project tables (`projects`,
4//! `project_memory`) are a kernel concern, not a memory concern.
5//! Uses `MemoryDatabase::conn()` for SQL execution.
6
7use anyhow::Result;
8
9use super::{Project, ProjectSource};
10
11/// Schema DDL for project tables.
12pub const PROJECT_SCHEMA: &str = r#"
13-- ─────────────────────────────────────────────
14-- Projects (RFC-011)
15-- ─────────────────────────────────────────────
16CREATE TABLE IF NOT EXISTS projects (
17    id              TEXT PRIMARY KEY,
18    name            TEXT NOT NULL UNIQUE,
19    description     TEXT,
20    paths           TEXT,            -- JSON array of PathBuf strings
21    tags            TEXT,            -- JSON array of strings
22    emoji           TEXT NOT NULL DEFAULT '📦',
23    source          TEXT NOT NULL DEFAULT 'manual',
24    memory_visible  INTEGER NOT NULL DEFAULT 1,
25    created_at      TEXT NOT NULL,
26    updated_at      TEXT NOT NULL,
27    last_active_at  TEXT NOT NULL
28);
29
30CREATE INDEX IF NOT EXISTS idx_projects_name ON projects(name);
31
32-- ─────────────────────────────────────────────
33-- Project-Memory junction (RFC-011)
34-- ─────────────────────────────────────────────
35CREATE TABLE IF NOT EXISTS project_memory (
36    project_id  TEXT NOT NULL,
37    memory_id   TEXT NOT NULL,
38    created_at  TEXT NOT NULL DEFAULT (datetime('now')),
39    PRIMARY KEY (project_id, memory_id)
40);
41
42CREATE INDEX IF NOT EXISTS idx_pm_project ON project_memory(project_id);
43CREATE INDEX IF NOT EXISTS idx_pm_memory ON project_memory(memory_id);
44"#;
45
46/// Ensure project tables exist in the database.
47pub fn ensure_project_schema(conn: &rusqlite::Connection) -> Result<()> {
48    conn.execute_batch(PROJECT_SCHEMA)?;
49    Ok(())
50}
51
52/// Save a project (insert or replace).
53pub fn save_project(conn: &rusqlite::Connection, project: &Project) -> Result<()> {
54    conn.execute(
55        "INSERT OR REPLACE INTO projects
56         (id, name, description, paths, tags, emoji, source, memory_visible, created_at, updated_at, last_active_at)
57         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
58        rusqlite::params![
59            project.id.to_string(),
60            project.name,
61            project.description,
62            serde_json::to_string(&project.paths)?,
63            serde_json::to_string(&project.tags)?,
64            project.emoji,
65            project.source.to_string(),
66            project.memory_visible as i32,
67            project.created_at.to_rfc3339(),
68            project.updated_at.to_rfc3339(),
69            project.last_active_at.to_rfc3339(),
70        ],
71    )?;
72    Ok(())
73}
74
75/// List all projects.
76pub fn list_projects(conn: &rusqlite::Connection) -> Result<Vec<Project>> {
77    let mut stmt = conn.prepare(
78        "SELECT id, name, description, paths, tags, emoji, source, memory_visible,
79                created_at, updated_at, last_active_at
80         FROM projects ORDER BY name",
81    )?;
82    let rows = stmt.query_map([], row_to_project)?;
83    rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
84}
85
86/// Delete a project by ID.
87pub fn delete_project(conn: &rusqlite::Connection, id: &str) -> Result<()> {
88    // Delete junction entries first
89    conn.execute(
90        "DELETE FROM project_memory WHERE project_id = ?1",
91        rusqlite::params![id],
92    )?;
93    conn.execute("DELETE FROM projects WHERE id = ?1", rusqlite::params![id])?;
94    Ok(())
95}
96
97/// Link a memory to a project.
98pub fn link_project_memory(
99    conn: &rusqlite::Connection,
100    project_id: &str,
101    memory_id: &str,
102) -> Result<()> {
103    conn.execute(
104        "INSERT OR IGNORE INTO project_memory (project_id, memory_id, created_at) VALUES (?1, ?2, datetime('now'))",
105        rusqlite::params![project_id, memory_id],
106    )?;
107    Ok(())
108}
109
110/// Unlink a memory from a project.
111pub fn unlink_project_memory(
112    conn: &rusqlite::Connection,
113    project_id: &str,
114    memory_id: &str,
115) -> Result<()> {
116    conn.execute(
117        "DELETE FROM project_memory WHERE project_id = ?1 AND memory_id = ?2",
118        rusqlite::params![project_id, memory_id],
119    )?;
120    Ok(())
121}
122
123/// Get all memory IDs associated with a project.
124pub fn get_project_memory_ids(
125    conn: &rusqlite::Connection,
126    project_id: &str,
127) -> Result<Vec<String>> {
128    let mut stmt = conn.prepare(
129        "SELECT memory_id FROM project_memory WHERE project_id = ?1 ORDER BY created_at DESC",
130    )?;
131    let rows = stmt.query_map(rusqlite::params![project_id], |row| row.get(0))?;
132    rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
133}
134
135/// Convert a SQLite row into a Project struct.
136fn row_to_project(row: &rusqlite::Row<'_>) -> rusqlite::Result<Project> {
137    use chrono::{DateTime, Utc};
138    use std::path::PathBuf;
139
140    let id_str: String = row.get(0)?;
141    let name: String = row.get(1)?;
142    let description: String = row.get::<_, Option<String>>(2)?.unwrap_or_default();
143    let paths_str: String = row
144        .get::<_, Option<String>>(3)?
145        .unwrap_or_else(|| "[]".to_string());
146    let tags_str: String = row
147        .get::<_, Option<String>>(4)?
148        .unwrap_or_else(|| "[]".to_string());
149    let emoji: String = row
150        .get::<_, Option<String>>(5)?
151        .unwrap_or_else(|| "📦".to_string());
152    let source_str: String = row
153        .get::<_, Option<String>>(6)?
154        .unwrap_or_else(|| "manual".to_string());
155    let memory_visible: bool = row.get::<_, Option<i32>>(7)?.unwrap_or(1) != 0;
156    let created_at: String = row.get(8)?;
157    let updated_at: String = row.get(9)?;
158    let last_active_at: String = row.get(10)?;
159
160    let id = uuid::Uuid::parse_str(&id_str).map_err(|e| {
161        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
162    })?;
163    let paths: Vec<PathBuf> = serde_json::from_str(&paths_str).unwrap_or_default();
164    let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
165    let source = match source_str.as_str() {
166        "auto_detected" => ProjectSource::AutoDetected,
167        _ => ProjectSource::Manual,
168    };
169
170    Ok(Project {
171        id,
172        name,
173        description,
174        paths,
175        tags,
176        emoji,
177        source,
178        memory_visible,
179        created_at: created_at
180            .parse::<DateTime<Utc>>()
181            .unwrap_or_else(|_| Utc::now()),
182        updated_at: updated_at
183            .parse::<DateTime<Utc>>()
184            .unwrap_or_else(|_| Utc::now()),
185        last_active_at: last_active_at
186            .parse::<DateTime<Utc>>()
187            .unwrap_or_else(|_| Utc::now()),
188    })
189}