1use anyhow::Result;
8
9use super::{Project, ProjectSource};
10
11pub 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
46pub fn ensure_project_schema(conn: &rusqlite::Connection) -> Result<()> {
48 conn.execute_batch(PROJECT_SCHEMA)?;
49 Ok(())
50}
51
52pub 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
75pub 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
86pub fn delete_project(conn: &rusqlite::Connection, id: &str) -> Result<()> {
88 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
97pub 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
110pub 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
123pub 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
135fn 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}