1use anyhow::Result;
8
9use super::{Project, ProjectSource};
10
11pub const PROJECT_SCHEMA: &str = r#"
13-- ─────────────────────────────────────────────
14-- Projects (RFC-011 / RFC-025)
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 migrate_rfc025(conn: &rusqlite::Connection) -> Result<()> {
49 let mut stmt = conn.prepare("PRAGMA table_info(projects)")?;
51 let cols: Vec<String> = stmt
52 .query_map([], |row| row.get::<_, String>(1))?
53 .filter_map(|r| r.ok())
54 .collect();
55
56 if !cols.iter().any(|c| c == "mount_ids") {
57 conn.execute_batch(
58 "ALTER TABLE projects ADD COLUMN mount_ids TEXT NOT NULL DEFAULT '[]';",
59 )?;
60 }
61 if !cols.iter().any(|c| c == "instructions") {
62 conn.execute_batch(
63 "ALTER TABLE projects ADD COLUMN instructions TEXT NOT NULL DEFAULT '';",
64 )?;
65 }
66 Ok(())
67}
68
69pub fn ensure_project_schema(conn: &rusqlite::Connection) -> Result<()> {
71 conn.execute_batch(PROJECT_SCHEMA)?;
72 migrate_rfc025(conn)?;
73 Ok(())
74}
75
76pub fn save_project(conn: &rusqlite::Connection, project: &Project) -> Result<()> {
78 conn.execute(
79 "INSERT OR REPLACE INTO projects
80 (id, name, description, paths, tags, emoji, source, memory_visible,
81 mount_ids, instructions, created_at, updated_at, last_active_at)
82 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)
83 ON CONFLICT(id) DO UPDATE SET
84 name=excluded.name, description=excluded.description, paths=excluded.paths,
85 tags=excluded.tags, emoji=excluded.emoji, source=excluded.source,
86 memory_visible=excluded.memory_visible, mount_ids=excluded.mount_ids,
87 instructions=excluded.instructions, updated_at=excluded.updated_at,
88 last_active_at=excluded.last_active_at",
89 rusqlite::params![
90 project.id.to_string(),
91 project.name,
92 project.description,
93 serde_json::to_string(&project.paths)?,
94 serde_json::to_string(&project.tags)?,
95 project.emoji,
96 project.source.to_string(),
97 project.memory_visible as i32,
98 serde_json::to_string(&project.mount_ids)?,
99 project.instructions,
100 project.created_at.to_rfc3339(),
101 project.updated_at.to_rfc3339(),
102 project.last_active_at.to_rfc3339(),
103 ],
104 )?;
105 Ok(())
106}
107
108pub fn list_projects(conn: &rusqlite::Connection) -> Result<Vec<Project>> {
110 let mut stmt = conn.prepare(
111 "SELECT id, name, description, paths, tags, emoji, source, memory_visible,
112 mount_ids, instructions, created_at, updated_at, last_active_at
113 FROM projects ORDER BY name",
114 )?;
115 let rows = stmt.query_map([], row_to_project)?;
116 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
117}
118
119pub fn delete_project(conn: &rusqlite::Connection, id: &str) -> Result<()> {
121 conn.execute(
123 "DELETE FROM project_memory WHERE project_id = ?1",
124 rusqlite::params![id],
125 )?;
126 conn.execute("DELETE FROM projects WHERE id = ?1", rusqlite::params![id])?;
127 Ok(())
128}
129
130pub fn link_project_memory(
132 conn: &rusqlite::Connection,
133 project_id: &str,
134 memory_id: &str,
135) -> Result<()> {
136 conn.execute(
137 "INSERT OR IGNORE INTO project_memory (project_id, memory_id, created_at) VALUES (?1, ?2, datetime('now'))",
138 rusqlite::params![project_id, memory_id],
139 )?;
140 Ok(())
141}
142
143pub fn unlink_project_memory(
145 conn: &rusqlite::Connection,
146 project_id: &str,
147 memory_id: &str,
148) -> Result<()> {
149 conn.execute(
150 "DELETE FROM project_memory WHERE project_id = ?1 AND memory_id = ?2",
151 rusqlite::params![project_id, memory_id],
152 )?;
153 Ok(())
154}
155
156pub fn get_project_memory_ids(
158 conn: &rusqlite::Connection,
159 project_id: &str,
160) -> Result<Vec<String>> {
161 let mut stmt = conn.prepare(
162 "SELECT memory_id FROM project_memory WHERE project_id = ?1 ORDER BY created_at DESC",
163 )?;
164 let rows = stmt.query_map(rusqlite::params![project_id], |row| row.get(0))?;
165 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
166}
167
168fn row_to_project(row: &rusqlite::Row<'_>) -> rusqlite::Result<Project> {
170 use chrono::{DateTime, Utc};
171 use std::path::PathBuf;
172
173 let id_str: String = row.get(0)?;
174 let name: String = row.get(1)?;
175 let description: String = row.get::<_, Option<String>>(2)?.unwrap_or_default();
176 let paths_str: String = row
177 .get::<_, Option<String>>(3)?
178 .unwrap_or_else(|| "[]".to_string());
179 let tags_str: String = row
180 .get::<_, Option<String>>(4)?
181 .unwrap_or_else(|| "[]".to_string());
182 let emoji: String = row
183 .get::<_, Option<String>>(5)?
184 .unwrap_or_else(|| "📦".to_string());
185 let source_str: String = row
186 .get::<_, Option<String>>(6)?
187 .unwrap_or_else(|| "manual".to_string());
188 let memory_visible: bool = row.get::<_, Option<i32>>(7)?.unwrap_or(1) != 0;
189 let mount_ids_str: String = row
190 .get::<_, Option<String>>(8)?
191 .unwrap_or_else(|| "[]".to_string());
192 let instructions: String = row.get::<_, Option<String>>(9)?.unwrap_or_default();
193 let created_at: String = row.get(10)?;
194 let updated_at: String = row.get(11)?;
195 let last_active_at: String = row.get(12)?;
196
197 let id = uuid::Uuid::parse_str(&id_str).map_err(|e| {
198 rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Text, Box::new(e))
199 })?;
200 let paths: Vec<PathBuf> = serde_json::from_str(&paths_str).unwrap_or_default();
201 let tags: Vec<String> = serde_json::from_str(&tags_str).unwrap_or_default();
202 let mount_ids: Vec<crate::mount::MountId> =
203 serde_json::from_str(&mount_ids_str).unwrap_or_default();
204 let source = match source_str.as_str() {
205 "auto_detected" => ProjectSource::AutoDetected,
206 _ => ProjectSource::Manual,
207 };
208
209 Ok(Project {
210 id,
211 name,
212 description,
213 paths,
214 tags,
215 emoji,
216 source,
217 memory_visible,
218 mount_ids,
219 instructions,
220 created_at: created_at
221 .parse::<DateTime<Utc>>()
222 .unwrap_or_else(|_| Utc::now()),
223 updated_at: updated_at
224 .parse::<DateTime<Utc>>()
225 .unwrap_or_else(|_| Utc::now()),
226 last_active_at: last_active_at
227 .parse::<DateTime<Utc>>()
228 .unwrap_or_else(|_| Utc::now()),
229 })
230}