1use anyhow::{Context, Result};
7use rusqlite::{params, Connection, OptionalExtension};
8use std::collections::{HashMap, HashSet};
9use std::path::{Path, PathBuf};
10use std::sync::Mutex;
11use uuid::Uuid;
12
13use crate::models::{
14 Attachment, Comment, CustomTypeDefinition, FeatureDefinition, GitLabIssueLink, HistoryEntry,
15 IdConfiguration, ImplementationInfo, QueueEntry, ReactionDefinition, Relationship,
16 RelationshipDefinition, Requirement, RequirementPriority, RequirementStatus, RequirementType,
17 RequirementsStore, TraceLink, UrlLink, User,
18};
19
20use super::traits::{BackendType, DatabaseBackend};
21
22const SCHEMA_VERSION: i32 = 8;
24
25pub struct SqliteBackend {
27 path: PathBuf,
28 conn: Mutex<Connection>,
29}
30
31impl SqliteBackend {
32 pub fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
34 let path = path.as_ref().to_path_buf();
35
36 if let Some(parent) = path.parent() {
38 std::fs::create_dir_all(parent)?;
39 }
40
41 let conn = Connection::open(&path)?;
42
43 conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
45
46 let backend = Self {
47 path,
48 conn: Mutex::new(conn),
49 };
50
51 backend.init_schema()?;
52 Ok(backend)
53 }
54
55 fn init_schema(&self) -> Result<()> {
57 let conn = self.conn.lock().unwrap();
58
59 let current_version: i32 = conn
61 .query_row("SELECT version FROM schema_version LIMIT 1", [], |row| {
62 row.get(0)
63 })
64 .unwrap_or(0);
65
66 if current_version == 0 {
67 conn.execute_batch(include_str!("schema.sql"))?;
69 } else if current_version < SCHEMA_VERSION {
70 Self::migrate_schema(&conn, current_version)?;
72 }
73
74 Ok(())
75 }
76
77 fn migrate_schema(conn: &Connection, from_version: i32) -> Result<()> {
79 if from_version < 2 {
80 conn.execute_batch(
82 r#"
83 -- Add version column to requirements for optimistic locking
84 ALTER TABLE requirements ADD COLUMN version INTEGER NOT NULL DEFAULT 1;
85
86 -- Add custom_priority column if missing
87 ALTER TABLE requirements ADD COLUMN custom_priority TEXT;
88
89 -- Add ai_evaluation column if missing
90 ALTER TABLE requirements ADD COLUMN ai_evaluation TEXT;
91
92 -- Add version column to users
93 ALTER TABLE users ADD COLUMN version INTEGER NOT NULL DEFAULT 1;
94
95 -- Add new metadata columns
96 ALTER TABLE metadata ADD COLUMN ai_prompts TEXT NOT NULL DEFAULT '{}';
97 ALTER TABLE metadata ADD COLUMN baselines TEXT NOT NULL DEFAULT '[]';
98 ALTER TABLE metadata ADD COLUMN teams TEXT NOT NULL DEFAULT '[]';
99 ALTER TABLE metadata ADD COLUMN store_version INTEGER NOT NULL DEFAULT 1;
100
101 -- Update schema version
102 UPDATE schema_version SET version = 2;
103 "#,
104 )
105 .unwrap_or_else(|e| {
106 eprintln!("Note: Some migration columns may already exist: {}", e);
108 });
109
110 let _ = conn.execute("UPDATE schema_version SET version = 2", []);
112 }
113
114 if from_version < 3 {
115 conn.execute_batch(
118 r#"
119 -- Add trace_links column for code-to-requirement traceability
120 ALTER TABLE requirements ADD COLUMN trace_links TEXT NOT NULL DEFAULT '[]';
121
122 -- Add implementation_info column for implementation metadata
123 ALTER TABLE requirements ADD COLUMN implementation_info TEXT;
124
125 -- Update schema version
126 UPDATE schema_version SET version = 3;
127 "#,
128 )
129 .unwrap_or_else(|e| {
130 eprintln!("Note: Some v3 migration columns may already exist: {}", e);
132 });
133
134 let _ = conn.execute("UPDATE schema_version SET version = 3", []);
136 }
137
138 if from_version < 4 {
139 conn.execute_batch(
142 r#"
143 -- Add pin_hash column for simple user authentication
144 ALTER TABLE users ADD COLUMN pin_hash TEXT;
145
146 -- Update schema version
147 UPDATE schema_version SET version = 4;
148 "#,
149 )
150 .unwrap_or_else(|e| {
151 eprintln!("Note: Some v4 migration columns may already exist: {}", e);
153 });
154
155 let _ = conn.execute("UPDATE schema_version SET version = 4", []);
157 }
158
159 if from_version < 5 {
160 conn.execute_batch(
163 r#"
164 -- GitLab sync state table for tracking sync between AIDA and GitLab
165 CREATE TABLE IF NOT EXISTS gitlab_sync_state (
166 requirement_id TEXT NOT NULL,
167 spec_id TEXT NOT NULL,
168 gitlab_project_id INTEGER NOT NULL,
169 gitlab_issue_iid INTEGER NOT NULL,
170 gitlab_issue_id INTEGER NOT NULL,
171 linked_at TEXT NOT NULL,
172 last_sync TEXT NOT NULL,
173 aida_content_hash TEXT NOT NULL DEFAULT '',
174 gitlab_content_hash TEXT NOT NULL DEFAULT '',
175 link_origin TEXT NOT NULL DEFAULT 'ManualLink',
176 sync_status TEXT NOT NULL DEFAULT 'Untracked',
177 last_error TEXT,
178 PRIMARY KEY (requirement_id, gitlab_issue_iid)
179 );
180
181 -- Index for looking up sync state by requirement
182 CREATE INDEX IF NOT EXISTS idx_gitlab_sync_requirement ON gitlab_sync_state(requirement_id);
183
184 -- Index for looking up sync state by GitLab issue
185 CREATE INDEX IF NOT EXISTS idx_gitlab_sync_issue ON gitlab_sync_state(gitlab_project_id, gitlab_issue_iid);
186
187 -- Index for filtering by sync status
188 CREATE INDEX IF NOT EXISTS idx_gitlab_sync_status ON gitlab_sync_state(sync_status);
189
190 -- Update schema version
191 UPDATE schema_version SET version = 5;
192 "#,
193 ).unwrap_or_else(|e| {
194 eprintln!("Note: Some v5 migration may already exist: {}", e);
195 });
196
197 let _ = conn.execute("UPDATE schema_version SET version = 5", []);
199 }
200
201 if from_version < 6 {
203 conn.execute_batch(
204 r#"
205 -- Add meta_subtype column for Meta requirements
206 ALTER TABLE requirements ADD COLUMN meta_subtype TEXT;
207
208 -- Update schema version
209 UPDATE schema_version SET version = 6;
210 "#,
211 )
212 .unwrap_or_else(|e| {
213 eprintln!("Note: Some v6 migration may already exist: {}", e);
214 });
215
216 let _ = conn.execute("UPDATE schema_version SET version = 6", []);
218 }
219
220 if from_version < 7 {
223 conn.execute_batch(
224 r#"
225 -- Queue entries table for personal work queue per user
226 CREATE TABLE IF NOT EXISTS queue_entries (
227 user_id TEXT NOT NULL,
228 requirement_id TEXT NOT NULL,
229 position INTEGER NOT NULL,
230 added_by TEXT NOT NULL,
231 note TEXT,
232 added_at TEXT NOT NULL,
233 PRIMARY KEY (user_id, requirement_id)
234 );
235
236 -- Index for efficient queue listing ordered by position
237 CREATE INDEX IF NOT EXISTS idx_queue_user_position ON queue_entries(user_id, position);
238
239 -- Update schema version
240 UPDATE schema_version SET version = 7;
241 "#,
242 ).unwrap_or_else(|e| {
243 eprintln!("Note: Some v7 migration may already exist: {}", e);
244 });
245
246 let _ = conn.execute("UPDATE schema_version SET version = 7", []);
248 }
249
250 if from_version < 8 {
252 conn.execute_batch(
253 r#"
254 ALTER TABLE requirements ADD COLUMN weight REAL;
255 ALTER TABLE requirements ADD COLUMN attachments TEXT NOT NULL DEFAULT '[]';
256 ALTER TABLE requirements ADD COLUMN gitlab_issues TEXT NOT NULL DEFAULT '[]';
257
258 UPDATE schema_version SET version = 8;
259 "#,
260 )
261 .unwrap_or_else(|e| {
262 eprintln!("Note: Some v8 migration columns may already exist: {}", e);
263 });
264
265 let _ = conn.execute("UPDATE schema_version SET version = 8", []);
266 }
267
268 Ok(())
269 }
270
271 fn to_json<T: serde::Serialize>(value: &T) -> Result<String> {
273 serde_json::to_string(value).context("Failed to serialize to JSON")
274 }
275
276 fn from_json<T: serde::de::DeserializeOwned>(json: &str) -> Result<T> {
278 serde_json::from_str(json).context("Failed to deserialize from JSON")
279 }
280
281 fn status_to_str(status: &RequirementStatus) -> &'static str {
283 match status {
284 RequirementStatus::Draft => "Draft",
285 RequirementStatus::Approved => "Approved",
286 RequirementStatus::Planned => "Planned",
287 RequirementStatus::InProgress => "In Progress",
288 RequirementStatus::Completed => "Completed",
289 RequirementStatus::Rejected => "Rejected",
290 }
291 }
292
293 fn str_to_status(s: &str) -> RequirementStatus {
295 match s {
296 "Draft" => RequirementStatus::Draft,
297 "Approved" => RequirementStatus::Approved,
298 "Planned" => RequirementStatus::Planned,
299 "In Progress" => RequirementStatus::InProgress,
300 "Completed" => RequirementStatus::Completed,
301 "Rejected" => RequirementStatus::Rejected,
302 _ => RequirementStatus::Draft,
303 }
304 }
305
306 fn priority_to_str(priority: &RequirementPriority) -> &'static str {
308 match priority {
309 RequirementPriority::High => "High",
310 RequirementPriority::Medium => "Medium",
311 RequirementPriority::Low => "Low",
312 }
313 }
314
315 fn str_to_priority(s: &str) -> RequirementPriority {
317 match s {
318 "High" => RequirementPriority::High,
319 "Medium" => RequirementPriority::Medium,
320 "Low" => RequirementPriority::Low,
321 _ => RequirementPriority::Medium,
322 }
323 }
324
325 fn type_to_str(req_type: &RequirementType) -> &'static str {
327 match req_type {
328 RequirementType::Functional => "Functional",
329 RequirementType::NonFunctional => "NonFunctional",
330 RequirementType::System => "System",
331 RequirementType::User => "User",
332 RequirementType::ChangeRequest => "ChangeRequest",
333 RequirementType::Bug => "Bug",
334 RequirementType::Epic => "Epic",
335 RequirementType::Story => "Story",
336 RequirementType::Task => "Task",
337 RequirementType::Spike => "Spike",
338 RequirementType::Sprint => "Sprint",
339 RequirementType::Folder => "Folder",
340 RequirementType::Meta => "Meta",
341 }
342 }
343
344 fn str_to_type(s: &str) -> RequirementType {
346 match s {
347 "Functional" => RequirementType::Functional,
348 "NonFunctional" => RequirementType::NonFunctional,
349 "System" => RequirementType::System,
350 "User" => RequirementType::User,
351 "ChangeRequest" => RequirementType::ChangeRequest,
352 "Bug" => RequirementType::Bug,
353 "Epic" => RequirementType::Epic,
354 "Story" => RequirementType::Story,
355 "Task" => RequirementType::Task,
356 "Spike" => RequirementType::Spike,
357 "Sprint" => RequirementType::Sprint,
358 "Folder" => RequirementType::Folder,
359 "Meta" => RequirementType::Meta,
360 _ => RequirementType::Functional,
361 }
362 }
363
364 fn load_requirements(&self, conn: &Connection) -> Result<Vec<Requirement>> {
366 let has_trace_links = conn
368 .query_row(
369 "SELECT COUNT(*) FROM pragma_table_info('requirements') WHERE name='trace_links'",
370 [],
371 |row| row.get::<_, i32>(0),
372 )
373 .unwrap_or(0)
374 > 0;
375
376 let query = if has_trace_links {
377 "SELECT id, spec_id, prefix_override, title, description, status, priority,
378 owner, feature, created_at, created_by, modified_at, req_type,
379 dependencies, tags, relationships, comments, history, archived,
380 custom_status, custom_priority, custom_fields, urls, trace_links,
381 implementation_info, ai_evaluation, weight, attachments, gitlab_issues, version
382 FROM requirements ORDER BY created_at"
383 } else {
384 "SELECT id, spec_id, prefix_override, title, description, status, priority,
385 owner, feature, created_at, created_by, modified_at, req_type,
386 dependencies, tags, relationships, comments, history, archived,
387 custom_status, custom_priority, custom_fields, urls, NULL as trace_links,
388 NULL as implementation_info, ai_evaluation, NULL as weight, '[]' as attachments, '[]' as gitlab_issues, version
389 FROM requirements ORDER BY created_at"
390 };
391
392 let mut stmt = conn.prepare(query)?;
393
394 let rows = stmt.query_map([], |row| {
395 let id_str: String = row.get(0)?;
396 let spec_id: Option<String> = row.get(1)?;
397 let prefix_override: Option<String> = row.get(2)?;
398 let title: String = row.get(3)?;
399 let description: String = row.get(4)?;
400 let status_str: String = row.get(5)?;
401 let priority_str: String = row.get(6)?;
402 let owner: String = row.get(7)?;
403 let feature: String = row.get(8)?;
404 let created_at_str: String = row.get(9)?;
405 let created_by: Option<String> = row.get(10)?;
406 let modified_at_str: String = row.get(11)?;
407 let req_type_str: String = row.get(12)?;
408 let dependencies_json: String = row.get(13)?;
409 let tags_json: String = row.get(14)?;
410 let relationships_json: String = row.get(15)?;
411 let comments_json: String = row.get(16)?;
412 let history_json: String = row.get(17)?;
413 let archived: bool = row.get(18)?;
414 let custom_status: Option<String> = row.get(19)?;
415 let custom_priority: Option<String> = row.get(20)?;
416 let custom_fields_json: String = row.get(21)?;
417 let urls_json: String = row.get(22)?;
418 let trace_links_json: Option<String> = row.get(23)?;
419 let implementation_info_json: Option<String> = row.get(24)?;
420 let ai_evaluation_json: Option<String> = row.get(25)?;
421 let weight: Option<f32> = row.get(26)?;
422 let attachments_json: String = row.get(27)?;
423 let gitlab_issues_json: String = row.get(28)?;
424 let version: i64 = row.get(29)?;
425
426 Ok((
427 id_str,
428 spec_id,
429 prefix_override,
430 title,
431 description,
432 status_str,
433 priority_str,
434 owner,
435 feature,
436 created_at_str,
437 created_by,
438 modified_at_str,
439 req_type_str,
440 dependencies_json,
441 tags_json,
442 relationships_json,
443 comments_json,
444 history_json,
445 archived,
446 custom_status,
447 custom_priority,
448 custom_fields_json,
449 urls_json,
450 trace_links_json,
451 implementation_info_json,
452 ai_evaluation_json,
453 weight,
454 attachments_json,
455 gitlab_issues_json,
456 version,
457 ))
458 })?;
459
460 let mut requirements = Vec::new();
461 for row_result in rows {
462 let (
463 id_str,
464 spec_id,
465 prefix_override,
466 title,
467 description,
468 status_str,
469 priority_str,
470 owner,
471 feature,
472 created_at_str,
473 created_by,
474 modified_at_str,
475 req_type_str,
476 dependencies_json,
477 tags_json,
478 relationships_json,
479 comments_json,
480 history_json,
481 archived,
482 custom_status,
483 custom_priority,
484 custom_fields_json,
485 urls_json,
486 trace_links_json,
487 implementation_info_json,
488 ai_evaluation_json,
489 weight,
490 attachments_json,
491 gitlab_issues_json,
492 version,
493 ) = row_result?;
494
495 let id = Uuid::parse_str(&id_str).unwrap_or_else(|_| Uuid::now_v7());
496 let status = Self::str_to_status(&status_str);
497 let priority = Self::str_to_priority(&priority_str);
498 let req_type = Self::str_to_type(&req_type_str);
499 let created_at = chrono::DateTime::parse_from_rfc3339(&created_at_str)
500 .map(|dt| dt.with_timezone(&chrono::Utc))
501 .unwrap_or_else(|_| chrono::Utc::now());
502 let modified_at = chrono::DateTime::parse_from_rfc3339(&modified_at_str)
503 .map(|dt| dt.with_timezone(&chrono::Utc))
504 .unwrap_or_else(|_| chrono::Utc::now());
505 let dependencies: Vec<Uuid> = Self::from_json(&dependencies_json).unwrap_or_default();
506 let tags: HashSet<String> = Self::from_json(&tags_json).unwrap_or_default();
507 let relationships: Vec<Relationship> =
508 Self::from_json(&relationships_json).unwrap_or_default();
509 let comments: Vec<Comment> = Self::from_json(&comments_json).unwrap_or_default();
510 let history: Vec<HistoryEntry> = Self::from_json(&history_json).unwrap_or_default();
511 let custom_fields: HashMap<String, String> =
512 Self::from_json(&custom_fields_json).unwrap_or_default();
513 let urls: Vec<UrlLink> = Self::from_json(&urls_json).unwrap_or_default();
514 let trace_links: Vec<TraceLink> = trace_links_json
515 .and_then(|json| Self::from_json(&json).ok())
516 .unwrap_or_default();
517 let implementation_info: Option<ImplementationInfo> =
518 implementation_info_json.and_then(|json| Self::from_json(&json).ok());
519 let ai_evaluation = ai_evaluation_json.and_then(|json| Self::from_json(&json).ok());
520 let attachments: Vec<Attachment> =
521 Self::from_json(&attachments_json).unwrap_or_default();
522 let gitlab_issues: Vec<GitLabIssueLink> =
523 Self::from_json(&gitlab_issues_json).unwrap_or_default();
524
525 requirements.push(Requirement {
526 id,
527 spec_id,
528 agreed_id: None,
529 prefix_override,
530 title,
531 description,
532 status,
533 priority,
534 owner,
535 feature,
536 created_at,
537 created_by,
538 modified_at,
539 req_type,
540 meta_subtype: None, dependencies,
542 tags,
543 weight,
544 relationships,
545 comments,
546 history,
547 archived,
548 custom_status,
549 custom_priority,
550 custom_fields,
551 urls,
552 attachments,
553 trace_links,
554 gitlab_issues,
555 implementation_info,
556 ai_evaluation,
557 version,
558 });
559 }
560
561 Ok(requirements)
562 }
563
564 fn load_users(&self, conn: &Connection) -> Result<Vec<User>> {
566 let mut stmt = conn.prepare(
567 "SELECT id, spec_id, name, email, handle, pin_hash, created_at, archived, version FROM users"
568 )?;
569
570 let rows = stmt.query_map([], |row| {
571 let id_str: String = row.get(0)?;
572 let spec_id: Option<String> = row.get(1)?;
573 let name: String = row.get(2)?;
574 let email: String = row.get(3)?;
575 let handle: String = row.get(4)?;
576 let pin_hash: Option<String> = row.get(5)?;
577 let created_at_str: String = row.get(6)?;
578 let archived: bool = row.get(7)?;
579 let version: i64 = row.get(8)?;
580 Ok((
581 id_str,
582 spec_id,
583 name,
584 email,
585 handle,
586 pin_hash,
587 created_at_str,
588 archived,
589 version,
590 ))
591 })?;
592
593 let mut users = Vec::new();
594 for row_result in rows {
595 let (id_str, spec_id, name, email, handle, pin_hash, created_at_str, archived, version): (String, Option<String>, String, String, String, Option<String>, String, bool, i64) = row_result?;
596 let id = Uuid::parse_str(&id_str).unwrap_or_else(|_| Uuid::now_v7());
597 let created_at = chrono::DateTime::parse_from_rfc3339(&created_at_str)
598 .map(|dt| dt.with_timezone(&chrono::Utc))
599 .unwrap_or_else(|_| chrono::Utc::now());
600
601 users.push(User {
602 id,
603 spec_id,
604 name,
605 email,
606 handle,
607 pin_hash,
608 created_at,
609 archived,
610 version,
611 });
612 }
613
614 Ok(users)
615 }
616
617 fn load_metadata(
619 &self,
620 conn: &Connection,
621 ) -> Result<(
622 String,
623 String,
624 String,
625 IdConfiguration,
626 u32,
627 u32,
628 HashMap<String, u32>,
629 HashMap<String, u32>,
630 )> {
631 let row = conn.query_row(
632 "SELECT name, title, description, id_config, next_feature_number, next_spec_number, prefix_counters, meta_counters
633 FROM metadata WHERE id = 1",
634 [],
635 |row| {
636 let name: String = row.get(0)?;
637 let title: String = row.get(1)?;
638 let description: String = row.get(2)?;
639 let id_config_json: String = row.get(3)?;
640 let next_feature_number: u32 = row.get(4)?;
641 let next_spec_number: u32 = row.get(5)?;
642 let prefix_counters_json: String = row.get(6)?;
643 let meta_counters_json: String = row.get(7)?;
644 Ok((name, title, description, id_config_json, next_feature_number, next_spec_number, prefix_counters_json, meta_counters_json))
645 }
646 ).optional()?;
647
648 match row {
649 Some((
650 name,
651 title,
652 description,
653 id_config_json,
654 next_feature_number,
655 next_spec_number,
656 prefix_counters_json,
657 meta_counters_json,
658 )) => {
659 let id_config: IdConfiguration =
660 Self::from_json(&id_config_json).unwrap_or_default();
661 let prefix_counters: HashMap<String, u32> =
662 Self::from_json(&prefix_counters_json).unwrap_or_default();
663 let meta_counters: HashMap<String, u32> =
664 Self::from_json(&meta_counters_json).unwrap_or_default();
665 Ok((
666 name,
667 title,
668 description,
669 id_config,
670 next_feature_number,
671 next_spec_number,
672 prefix_counters,
673 meta_counters,
674 ))
675 }
676 None => Ok((
677 String::new(),
678 String::new(),
679 String::new(),
680 IdConfiguration::default(),
681 1,
682 1,
683 HashMap::new(),
684 HashMap::new(),
685 )),
686 }
687 }
688
689 fn load_features(&self, conn: &Connection) -> Result<Vec<FeatureDefinition>> {
691 let json: String = conn
692 .query_row("SELECT features FROM metadata WHERE id = 1", [], |row| {
693 row.get(0)
694 })
695 .unwrap_or_else(|_| "[]".to_string());
696 Self::from_json(&json)
697 }
698
699 fn load_type_definitions(&self, conn: &Connection) -> Result<Vec<CustomTypeDefinition>> {
701 let json: String = conn
702 .query_row(
703 "SELECT type_definitions FROM metadata WHERE id = 1",
704 [],
705 |row| row.get(0),
706 )
707 .unwrap_or_else(|_| "[]".to_string());
708 let defs: Vec<CustomTypeDefinition> = Self::from_json(&json)?;
709 if defs.is_empty() {
710 Ok(crate::models::default_type_definitions())
711 } else {
712 Ok(defs)
713 }
714 }
715
716 fn load_relationship_definitions(
718 &self,
719 conn: &Connection,
720 ) -> Result<Vec<RelationshipDefinition>> {
721 let json: String = conn
722 .query_row(
723 "SELECT relationship_definitions FROM metadata WHERE id = 1",
724 [],
725 |row| row.get(0),
726 )
727 .unwrap_or_else(|_| "[]".to_string());
728 let defs: Vec<RelationshipDefinition> = Self::from_json(&json)?;
729 if defs.is_empty() {
730 Ok(RelationshipDefinition::defaults())
731 } else {
732 Ok(defs)
733 }
734 }
735
736 fn load_reaction_definitions(&self, conn: &Connection) -> Result<Vec<ReactionDefinition>> {
738 let json: String = conn
739 .query_row(
740 "SELECT reaction_definitions FROM metadata WHERE id = 1",
741 [],
742 |row| row.get(0),
743 )
744 .unwrap_or_else(|_| "[]".to_string());
745 let defs: Vec<ReactionDefinition> = Self::from_json(&json)?;
746 if defs.is_empty() {
747 Ok(crate::models::default_reaction_definitions())
748 } else {
749 Ok(defs)
750 }
751 }
752
753 fn load_allowed_prefixes(&self, conn: &Connection) -> Result<(Vec<String>, bool)> {
755 let row = conn
756 .query_row(
757 "SELECT allowed_prefixes, restrict_prefixes FROM metadata WHERE id = 1",
758 [],
759 |row| {
760 let json: String = row.get(0)?;
761 let restrict: bool = row.get(1)?;
762 Ok((json, restrict))
763 },
764 )
765 .optional()?;
766
767 match row {
768 Some((json, restrict)) => {
769 let prefixes: Vec<String> = Self::from_json(&json).unwrap_or_default();
770 Ok((prefixes, restrict))
771 }
772 None => Ok((Vec::new(), false)),
773 }
774 }
775
776 fn save_requirement(&self, conn: &Connection, req: &Requirement) -> Result<()> {
778 let ai_eval_json = req
779 .ai_evaluation
780 .as_ref()
781 .map(|e| Self::to_json(e))
782 .transpose()?;
783 let impl_info_json = req
784 .implementation_info
785 .as_ref()
786 .map(|i| Self::to_json(i))
787 .transpose()?;
788 let attachments_json = Self::to_json(&req.attachments)?;
789 let gitlab_issues_json = Self::to_json(&req.gitlab_issues)?;
790
791 let has_trace_links = conn
793 .query_row(
794 "SELECT COUNT(*) FROM pragma_table_info('requirements') WHERE name='trace_links'",
795 [],
796 |row| row.get::<_, i32>(0),
797 )
798 .unwrap_or(0)
799 > 0;
800
801 if has_trace_links {
802 conn.execute(
803 "INSERT OR REPLACE INTO requirements
804 (id, spec_id, prefix_override, title, description, status, priority, owner, feature,
805 created_at, created_by, modified_at, req_type, dependencies, tags, relationships,
806 comments, history, archived, custom_status, custom_priority, custom_fields, urls,
807 trace_links, implementation_info, ai_evaluation, weight, attachments, gitlab_issues, version)
808 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25, ?26, ?27, ?28, ?29, ?30)",
809 params![
810 req.id.to_string(),
811 req.spec_id,
812 req.prefix_override,
813 req.title,
814 req.description,
815 Self::status_to_str(&req.status),
816 Self::priority_to_str(&req.priority),
817 req.owner,
818 req.feature,
819 req.created_at.to_rfc3339(),
820 req.created_by,
821 req.modified_at.to_rfc3339(),
822 Self::type_to_str(&req.req_type),
823 Self::to_json(&req.dependencies)?,
824 Self::to_json(&req.tags)?,
825 Self::to_json(&req.relationships)?,
826 Self::to_json(&req.comments)?,
827 Self::to_json(&req.history)?,
828 req.archived,
829 req.custom_status,
830 req.custom_priority,
831 Self::to_json(&req.custom_fields)?,
832 Self::to_json(&req.urls)?,
833 Self::to_json(&req.trace_links)?,
834 impl_info_json,
835 ai_eval_json,
836 req.weight,
837 attachments_json,
838 gitlab_issues_json,
839 req.version,
840 ],
841 )?;
842 } else {
843 conn.execute(
845 "INSERT OR REPLACE INTO requirements
846 (id, spec_id, prefix_override, title, description, status, priority, owner, feature,
847 created_at, created_by, modified_at, req_type, dependencies, tags, relationships,
848 comments, history, archived, custom_status, custom_priority, custom_fields, urls,
849 ai_evaluation, version)
850 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25)",
851 params![
852 req.id.to_string(),
853 req.spec_id,
854 req.prefix_override,
855 req.title,
856 req.description,
857 Self::status_to_str(&req.status),
858 Self::priority_to_str(&req.priority),
859 req.owner,
860 req.feature,
861 req.created_at.to_rfc3339(),
862 req.created_by,
863 req.modified_at.to_rfc3339(),
864 Self::type_to_str(&req.req_type),
865 Self::to_json(&req.dependencies)?,
866 Self::to_json(&req.tags)?,
867 Self::to_json(&req.relationships)?,
868 Self::to_json(&req.comments)?,
869 Self::to_json(&req.history)?,
870 req.archived,
871 req.custom_status,
872 req.custom_priority,
873 Self::to_json(&req.custom_fields)?,
874 Self::to_json(&req.urls)?,
875 ai_eval_json,
876 req.version,
877 ],
878 )?;
879 }
880 Ok(())
881 }
882
883 fn save_user(&self, conn: &Connection, user: &User) -> Result<()> {
885 conn.execute(
886 "INSERT OR REPLACE INTO users (id, spec_id, name, email, handle, pin_hash, created_at, archived, version)
887 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
888 params![
889 user.id.to_string(),
890 user.spec_id,
891 user.name,
892 user.email,
893 user.handle,
894 user.pin_hash,
895 user.created_at.to_rfc3339(),
896 user.archived,
897 user.version,
898 ],
899 )?;
900 Ok(())
901 }
902
903 fn save_metadata(&self, conn: &Connection, store: &RequirementsStore) -> Result<()> {
905 conn.execute(
906 "INSERT OR REPLACE INTO metadata
907 (id, name, title, description, id_config, features, next_feature_number, next_spec_number,
908 prefix_counters, relationship_definitions, reaction_definitions, meta_counters,
909 type_definitions, allowed_prefixes, restrict_prefixes, ai_prompts, baselines, teams, store_version)
910 VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18)",
911 params![
912 store.name,
913 store.title,
914 store.description,
915 Self::to_json(&store.id_config)?,
916 Self::to_json(&store.features)?,
917 store.next_feature_number,
918 store.next_spec_number,
919 Self::to_json(&store.prefix_counters)?,
920 Self::to_json(&store.relationship_definitions)?,
921 Self::to_json(&store.reaction_definitions)?,
922 Self::to_json(&store.meta_counters)?,
923 Self::to_json(&store.type_definitions)?,
924 Self::to_json(&store.allowed_prefixes)?,
925 store.restrict_prefixes,
926 Self::to_json(&store.ai_prompts)?,
927 Self::to_json(&store.baselines)?,
928 Self::to_json(&store.teams)?,
929 store.store_version,
930 ],
931 )?;
932 Ok(())
933 }
934
935 fn load_store_version(&self, conn: &Connection) -> Result<i64> {
937 let version: i64 = conn
938 .query_row(
939 "SELECT store_version FROM metadata WHERE id = 1",
940 [],
941 |row| row.get(0),
942 )
943 .unwrap_or(1);
944 Ok(version)
945 }
946
947 fn load_ai_prompts(&self, conn: &Connection) -> Result<crate::models::AiPromptConfig> {
949 let json: String = conn
950 .query_row("SELECT ai_prompts FROM metadata WHERE id = 1", [], |row| {
951 row.get(0)
952 })
953 .unwrap_or_else(|_| "{}".to_string());
954 Self::from_json(&json).or_else(|_| Ok(crate::models::AiPromptConfig::default()))
955 }
956
957 fn load_baselines(&self, conn: &Connection) -> Result<Vec<crate::models::Baseline>> {
959 let json: String = conn
960 .query_row("SELECT baselines FROM metadata WHERE id = 1", [], |row| {
961 row.get(0)
962 })
963 .unwrap_or_else(|_| "[]".to_string());
964 Self::from_json(&json)
965 }
966
967 fn load_teams(&self, conn: &Connection) -> Result<Vec<crate::models::Team>> {
969 let json: String = conn
970 .query_row("SELECT teams FROM metadata WHERE id = 1", [], |row| {
971 row.get(0)
972 })
973 .unwrap_or_else(|_| "[]".to_string());
974 Self::from_json(&json)
975 }
976
977 pub fn save_sync_state(&self, state: &crate::models::GitLabSyncState) -> Result<()> {
982 let conn = self.conn.lock().unwrap();
983 conn.execute(
984 r#"INSERT OR REPLACE INTO gitlab_sync_state
985 (requirement_id, spec_id, gitlab_project_id, gitlab_issue_iid, gitlab_issue_id,
986 linked_at, last_sync, aida_content_hash, gitlab_content_hash,
987 link_origin, sync_status, last_error)
988 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)"#,
989 rusqlite::params![
990 state.requirement_id.to_string(),
991 state.spec_id,
992 state.gitlab_project_id as i64,
993 state.gitlab_issue_iid as i64,
994 state.gitlab_issue_id as i64,
995 state.linked_at.to_rfc3339(),
996 state.last_sync.to_rfc3339(),
997 state.aida_content_hash,
998 state.gitlab_content_hash,
999 format!("{:?}", state.link_origin),
1000 format!("{:?}", state.sync_status),
1001 state.last_error,
1002 ],
1003 )?;
1004 Ok(())
1005 }
1006
1007 pub fn load_sync_state(
1010 &self,
1011 requirement_id: Uuid,
1012 issue_iid: u64,
1013 ) -> Result<Option<crate::models::GitLabSyncState>> {
1014 let conn = self.conn.lock().unwrap();
1015 let result = conn.query_row(
1016 r#"SELECT requirement_id, spec_id, gitlab_project_id, gitlab_issue_iid, gitlab_issue_id,
1017 linked_at, last_sync, aida_content_hash, gitlab_content_hash,
1018 link_origin, sync_status, last_error
1019 FROM gitlab_sync_state WHERE requirement_id = ?1 AND gitlab_issue_iid = ?2"#,
1020 rusqlite::params![requirement_id.to_string(), issue_iid as i64],
1021 |row| Self::row_to_sync_state(row),
1022 );
1023
1024 match result {
1025 Ok(state) => Ok(Some(state)),
1026 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1027 Err(e) => Err(e.into()),
1028 }
1029 }
1030
1031 pub fn load_sync_states_for_requirement(
1034 &self,
1035 requirement_id: Uuid,
1036 ) -> Result<Vec<crate::models::GitLabSyncState>> {
1037 let conn = self.conn.lock().unwrap();
1038 let mut stmt = conn.prepare(
1039 r#"SELECT requirement_id, spec_id, gitlab_project_id, gitlab_issue_iid, gitlab_issue_id,
1040 linked_at, last_sync, aida_content_hash, gitlab_content_hash,
1041 link_origin, sync_status, last_error
1042 FROM gitlab_sync_state WHERE requirement_id = ?1"#,
1043 )?;
1044
1045 let states = stmt
1046 .query_map([requirement_id.to_string()], |row| {
1047 Self::row_to_sync_state(row)
1048 })?
1049 .filter_map(|r| r.ok())
1050 .collect();
1051
1052 Ok(states)
1053 }
1054
1055 pub fn load_all_sync_states(&self) -> Result<Vec<crate::models::GitLabSyncState>> {
1058 let conn = self.conn.lock().unwrap();
1059 let mut stmt = conn.prepare(
1060 r#"SELECT requirement_id, spec_id, gitlab_project_id, gitlab_issue_iid, gitlab_issue_id,
1061 linked_at, last_sync, aida_content_hash, gitlab_content_hash,
1062 link_origin, sync_status, last_error
1063 FROM gitlab_sync_state ORDER BY last_sync DESC"#,
1064 )?;
1065
1066 let states = stmt
1067 .query_map([], |row| Self::row_to_sync_state(row))?
1068 .filter_map(|r| r.ok())
1069 .collect();
1070
1071 Ok(states)
1072 }
1073
1074 pub fn load_sync_states_by_status(
1077 &self,
1078 status: crate::models::SyncStatus,
1079 ) -> Result<Vec<crate::models::GitLabSyncState>> {
1080 let conn = self.conn.lock().unwrap();
1081 let mut stmt = conn.prepare(
1082 r#"SELECT requirement_id, spec_id, gitlab_project_id, gitlab_issue_iid, gitlab_issue_id,
1083 linked_at, last_sync, aida_content_hash, gitlab_content_hash,
1084 link_origin, sync_status, last_error
1085 FROM gitlab_sync_state WHERE sync_status = ?1 ORDER BY last_sync DESC"#,
1086 )?;
1087
1088 let states = stmt
1089 .query_map([format!("{:?}", status)], |row| {
1090 Self::row_to_sync_state(row)
1091 })?
1092 .filter_map(|r| r.ok())
1093 .collect();
1094
1095 Ok(states)
1096 }
1097
1098 pub fn delete_sync_state(&self, requirement_id: Uuid, issue_iid: u64) -> Result<bool> {
1101 let conn = self.conn.lock().unwrap();
1102 let rows_affected = conn.execute(
1103 "DELETE FROM gitlab_sync_state WHERE requirement_id = ?1 AND gitlab_issue_iid = ?2",
1104 rusqlite::params![requirement_id.to_string(), issue_iid as i64],
1105 )?;
1106 Ok(rows_affected > 0)
1107 }
1108
1109 fn row_to_sync_state(row: &rusqlite::Row) -> rusqlite::Result<crate::models::GitLabSyncState> {
1111 use crate::models::{GitLabSyncState, LinkOrigin, SyncStatus};
1112
1113 let req_id_str: String = row.get(0)?;
1114 let linked_at_str: String = row.get(5)?;
1115 let last_sync_str: String = row.get(6)?;
1116 let link_origin_str: String = row.get(9)?;
1117 let sync_status_str: String = row.get(10)?;
1118
1119 Ok(GitLabSyncState {
1120 requirement_id: Uuid::parse_str(&req_id_str).unwrap_or_default(),
1121 spec_id: row.get(1)?,
1122 gitlab_project_id: row.get::<_, i64>(2)? as u64,
1123 gitlab_issue_iid: row.get::<_, i64>(3)? as u64,
1124 gitlab_issue_id: row.get::<_, i64>(4)? as u64,
1125 linked_at: chrono::DateTime::parse_from_rfc3339(&linked_at_str)
1126 .map(|dt| dt.with_timezone(&chrono::Utc))
1127 .unwrap_or_else(|_| chrono::Utc::now()),
1128 last_sync: chrono::DateTime::parse_from_rfc3339(&last_sync_str)
1129 .map(|dt| dt.with_timezone(&chrono::Utc))
1130 .unwrap_or_else(|_| chrono::Utc::now()),
1131 aida_content_hash: row.get(7)?,
1132 gitlab_content_hash: row.get(8)?,
1133 link_origin: match link_origin_str.as_str() {
1134 "CreatedFromAida" => LinkOrigin::CreatedFromAida,
1135 "ImportedFromGitLab" => LinkOrigin::ImportedFromGitLab,
1136 _ => LinkOrigin::ManualLink,
1137 },
1138 sync_status: match sync_status_str.as_str() {
1139 "InSync" => SyncStatus::InSync,
1140 "AidaModified" => SyncStatus::AidaModified,
1141 "GitLabModified" => SyncStatus::GitLabModified,
1142 "Conflict" => SyncStatus::Conflict,
1143 "Error" => SyncStatus::Error,
1144 _ => SyncStatus::Untracked,
1145 },
1146 last_error: row.get(11)?,
1147 })
1148 }
1149}
1150
1151impl DatabaseBackend for SqliteBackend {
1152 fn backend_type(&self) -> BackendType {
1153 BackendType::Sqlite
1154 }
1155
1156 fn path(&self) -> &Path {
1157 &self.path
1158 }
1159
1160 fn load(&self) -> Result<RequirementsStore> {
1161 let conn = self.conn.lock().unwrap();
1162
1163 let requirements = self.load_requirements(&conn)?;
1164 let users = self.load_users(&conn)?;
1165 let (
1166 name,
1167 title,
1168 description,
1169 id_config,
1170 next_feature_number,
1171 next_spec_number,
1172 prefix_counters,
1173 meta_counters,
1174 ) = self.load_metadata(&conn)?;
1175 let features = self.load_features(&conn)?;
1176 let type_definitions = self.load_type_definitions(&conn)?;
1177 let relationship_definitions = self.load_relationship_definitions(&conn)?;
1178 let reaction_definitions = self.load_reaction_definitions(&conn)?;
1179 let (allowed_prefixes, restrict_prefixes) = self.load_allowed_prefixes(&conn)?;
1180 let ai_prompts = self.load_ai_prompts(&conn)?;
1181 let baselines = self.load_baselines(&conn)?;
1182 let teams = self.load_teams(&conn)?;
1183 let store_version = self.load_store_version(&conn)?;
1184
1185 Ok(RequirementsStore {
1186 name,
1187 title,
1188 description,
1189 requirements,
1190 users,
1191 teams,
1192 id_config,
1193 features,
1194 next_feature_number,
1195 next_spec_number,
1196 prefix_counters,
1197 relationship_definitions,
1198 reaction_definitions,
1199 meta_counters,
1200 type_definitions,
1201 allowed_prefixes,
1202 restrict_prefixes,
1203 ai_prompts,
1204 baselines,
1205 store_version,
1206 migrated_to: None, dispenser: None,
1208 })
1209 }
1210
1211 fn save(&self, store: &RequirementsStore) -> Result<()> {
1212 let conn = self.conn.lock().unwrap();
1213
1214 conn.execute("BEGIN TRANSACTION", [])?;
1216
1217 conn.execute("DELETE FROM requirements", [])?;
1219 conn.execute("DELETE FROM users", [])?;
1220
1221 for req in &store.requirements {
1223 self.save_requirement(&conn, req)?;
1224 }
1225
1226 for user in &store.users {
1228 self.save_user(&conn, user)?;
1229 }
1230
1231 self.save_metadata(&conn, store)?;
1233
1234 conn.execute("COMMIT", [])?;
1235 Ok(())
1236 }
1237
1238 fn update_atomically<F>(&self, update_fn: F) -> Result<RequirementsStore>
1239 where
1240 F: FnOnce(&mut RequirementsStore),
1241 {
1242 let conn = self.conn.lock().unwrap();
1243
1244 conn.execute("BEGIN EXCLUSIVE TRANSACTION", [])?;
1245
1246 drop(conn);
1248 let mut store = self.load()?;
1249
1250 update_fn(&mut store);
1252
1253 let conn = self.conn.lock().unwrap();
1255
1256 conn.execute("DELETE FROM requirements", [])?;
1258 conn.execute("DELETE FROM users", [])?;
1259
1260 for req in &store.requirements {
1262 self.save_requirement(&conn, req)?;
1263 }
1264
1265 for user in &store.users {
1267 self.save_user(&conn, user)?;
1268 }
1269
1270 self.save_metadata(&conn, &store)?;
1272
1273 conn.execute("COMMIT", [])?;
1274 Ok(store)
1275 }
1276
1277 fn get_requirement(&self, id: &Uuid) -> Result<Option<Requirement>> {
1280 let conn = self.conn.lock().unwrap();
1281
1282 let has_trace_links = conn
1284 .query_row(
1285 "SELECT COUNT(*) FROM pragma_table_info('requirements') WHERE name='trace_links'",
1286 [],
1287 |row| row.get::<_, i32>(0),
1288 )
1289 .unwrap_or(0)
1290 > 0;
1291
1292 let query = if has_trace_links {
1293 "SELECT id, spec_id, prefix_override, title, description, status, priority,
1294 owner, feature, created_at, created_by, modified_at, req_type,
1295 dependencies, tags, relationships, comments, history, archived,
1296 custom_status, custom_priority, custom_fields, urls, trace_links,
1297 implementation_info, ai_evaluation, weight, attachments, gitlab_issues, version
1298 FROM requirements WHERE id = ?1"
1299 } else {
1300 "SELECT id, spec_id, prefix_override, title, description, status, priority,
1301 owner, feature, created_at, created_by, modified_at, req_type,
1302 dependencies, tags, relationships, comments, history, archived,
1303 custom_status, custom_priority, custom_fields, urls, NULL as trace_links,
1304 NULL as implementation_info, ai_evaluation, NULL as weight, '[]' as attachments, '[]' as gitlab_issues, version
1305 FROM requirements WHERE id = ?1"
1306 };
1307
1308 let result = conn
1309 .query_row(query, [id.to_string()], |row| {
1310 let id_str: String = row.get(0)?;
1311 let spec_id: Option<String> = row.get(1)?;
1312 let prefix_override: Option<String> = row.get(2)?;
1313 let title: String = row.get(3)?;
1314 let description: String = row.get(4)?;
1315 let status_str: String = row.get(5)?;
1316 let priority_str: String = row.get(6)?;
1317 let owner: String = row.get(7)?;
1318 let feature: String = row.get(8)?;
1319 let created_at_str: String = row.get(9)?;
1320 let created_by: Option<String> = row.get(10)?;
1321 let modified_at_str: String = row.get(11)?;
1322 let req_type_str: String = row.get(12)?;
1323 let dependencies_json: String = row.get(13)?;
1324 let tags_json: String = row.get(14)?;
1325 let relationships_json: String = row.get(15)?;
1326 let comments_json: String = row.get(16)?;
1327 let history_json: String = row.get(17)?;
1328 let archived: bool = row.get(18)?;
1329 let custom_status: Option<String> = row.get(19)?;
1330 let custom_priority: Option<String> = row.get(20)?;
1331 let custom_fields_json: String = row.get(21)?;
1332 let urls_json: String = row.get(22)?;
1333 let trace_links_json: Option<String> = row.get(23)?;
1334 let implementation_info_json: Option<String> = row.get(24)?;
1335 let ai_evaluation_json: Option<String> = row.get(25)?;
1336 let weight: Option<f32> = row.get(26)?;
1337 let attachments_json: String = row.get(27)?;
1338 let gitlab_issues_json: String = row.get(28)?;
1339 let version: i64 = row.get(29)?;
1340
1341 Ok((
1342 id_str,
1343 spec_id,
1344 prefix_override,
1345 title,
1346 description,
1347 status_str,
1348 priority_str,
1349 owner,
1350 feature,
1351 created_at_str,
1352 created_by,
1353 modified_at_str,
1354 req_type_str,
1355 dependencies_json,
1356 tags_json,
1357 relationships_json,
1358 comments_json,
1359 history_json,
1360 archived,
1361 custom_status,
1362 custom_priority,
1363 custom_fields_json,
1364 urls_json,
1365 trace_links_json,
1366 implementation_info_json,
1367 ai_evaluation_json,
1368 weight,
1369 attachments_json,
1370 gitlab_issues_json,
1371 version,
1372 ))
1373 })
1374 .optional()?;
1375
1376 match result {
1377 Some((
1378 id_str,
1379 spec_id,
1380 prefix_override,
1381 title,
1382 description,
1383 status_str,
1384 priority_str,
1385 owner,
1386 feature,
1387 created_at_str,
1388 created_by,
1389 modified_at_str,
1390 req_type_str,
1391 dependencies_json,
1392 tags_json,
1393 relationships_json,
1394 comments_json,
1395 history_json,
1396 archived,
1397 custom_status,
1398 custom_priority,
1399 custom_fields_json,
1400 urls_json,
1401 trace_links_json,
1402 implementation_info_json,
1403 ai_evaluation_json,
1404 weight,
1405 attachments_json,
1406 gitlab_issues_json,
1407 version,
1408 )) => {
1409 let id = Uuid::parse_str(&id_str).unwrap_or_else(|_| Uuid::now_v7());
1410 let status = Self::str_to_status(&status_str);
1411 let priority = Self::str_to_priority(&priority_str);
1412 let req_type = Self::str_to_type(&req_type_str);
1413 let created_at = chrono::DateTime::parse_from_rfc3339(&created_at_str)
1414 .map(|dt| dt.with_timezone(&chrono::Utc))
1415 .unwrap_or_else(|_| chrono::Utc::now());
1416 let modified_at = chrono::DateTime::parse_from_rfc3339(&modified_at_str)
1417 .map(|dt| dt.with_timezone(&chrono::Utc))
1418 .unwrap_or_else(|_| chrono::Utc::now());
1419 let dependencies: Vec<Uuid> =
1420 Self::from_json(&dependencies_json).unwrap_or_default();
1421 let tags: HashSet<String> = Self::from_json(&tags_json).unwrap_or_default();
1422 let relationships: Vec<Relationship> =
1423 Self::from_json(&relationships_json).unwrap_or_default();
1424 let comments: Vec<Comment> = Self::from_json(&comments_json).unwrap_or_default();
1425 let history: Vec<HistoryEntry> = Self::from_json(&history_json).unwrap_or_default();
1426 let custom_fields: HashMap<String, String> =
1427 Self::from_json(&custom_fields_json).unwrap_or_default();
1428 let urls: Vec<UrlLink> = Self::from_json(&urls_json).unwrap_or_default();
1429 let trace_links: Vec<TraceLink> = trace_links_json
1430 .and_then(|json| Self::from_json(&json).ok())
1431 .unwrap_or_default();
1432 let implementation_info: Option<ImplementationInfo> =
1433 implementation_info_json.and_then(|json| Self::from_json(&json).ok());
1434 let ai_evaluation = ai_evaluation_json.and_then(|json| Self::from_json(&json).ok());
1435 let attachments: Vec<Attachment> =
1436 Self::from_json(&attachments_json).unwrap_or_default();
1437 let gitlab_issues: Vec<GitLabIssueLink> =
1438 Self::from_json(&gitlab_issues_json).unwrap_or_default();
1439
1440 Ok(Some(Requirement {
1441 id,
1442 spec_id,
1443 agreed_id: None,
1444 prefix_override,
1445 title,
1446 description,
1447 status,
1448 priority,
1449 owner,
1450 feature,
1451 created_at,
1452 created_by,
1453 modified_at,
1454 req_type,
1455 meta_subtype: None, dependencies,
1457 tags,
1458 weight,
1459 relationships,
1460 comments,
1461 history,
1462 archived,
1463 custom_status,
1464 custom_priority,
1465 custom_fields,
1466 urls,
1467 attachments,
1468 trace_links,
1469 gitlab_issues,
1470 implementation_info,
1471 ai_evaluation,
1472 version,
1473 }))
1474 }
1475 None => Ok(None),
1476 }
1477 }
1478
1479 fn update_requirement(&self, requirement: &Requirement) -> Result<()> {
1480 let conn = self.conn.lock().unwrap();
1481 self.save_requirement(&conn, requirement)
1482 }
1483
1484 fn update_requirement_versioned(
1485 &self,
1486 requirement: &Requirement,
1487 ) -> Result<super::traits::UpdateResult> {
1488 use super::traits::{UpdateResult, VersionConflict};
1489
1490 let conn = self.conn.lock().unwrap();
1491
1492 let current_version: Option<i64> = conn
1494 .query_row(
1495 "SELECT version FROM requirements WHERE id = ?1",
1496 [requirement.id.to_string()],
1497 |row| row.get(0),
1498 )
1499 .optional()?;
1500
1501 match current_version {
1502 Some(db_version) => {
1503 if db_version != requirement.version {
1505 return Ok(UpdateResult::Conflict(VersionConflict {
1506 id: requirement.id,
1507 expected_version: requirement.version,
1508 current_version: db_version,
1509 display_id: requirement
1510 .spec_id
1511 .clone()
1512 .unwrap_or_else(|| requirement.id.to_string()),
1513 }));
1514 }
1515
1516 let mut updated_req = requirement.clone();
1518 updated_req.version = db_version + 1;
1519 self.save_requirement(&conn, &updated_req)?;
1520
1521 Ok(UpdateResult::Success)
1522 }
1523 None => {
1524 anyhow::bail!("Requirement not found: {}", requirement.id)
1525 }
1526 }
1527 }
1528
1529 fn get_store_version(&self) -> Result<i64> {
1530 let conn = self.conn.lock().unwrap();
1531 self.load_store_version(&conn)
1532 }
1533
1534 fn delete_requirement(&self, id: &Uuid) -> Result<()> {
1535 let conn = self.conn.lock().unwrap();
1536 let rows_affected =
1537 conn.execute("DELETE FROM requirements WHERE id = ?1", [id.to_string()])?;
1538 if rows_affected == 0 {
1539 anyhow::bail!("Requirement not found: {}", id)
1540 }
1541 Ok(())
1542 }
1543
1544 fn get_user(&self, id: &Uuid) -> Result<Option<User>> {
1545 let conn = self.conn.lock().unwrap();
1546
1547 conn.query_row(
1548 "SELECT id, spec_id, name, email, handle, pin_hash, created_at, archived, version FROM users WHERE id = ?1",
1549 [id.to_string()],
1550 |row| {
1551 let id_str: String = row.get(0)?;
1552 let spec_id: Option<String> = row.get(1)?;
1553 let name: String = row.get(2)?;
1554 let email: String = row.get(3)?;
1555 let handle: String = row.get(4)?;
1556 let pin_hash: Option<String> = row.get(5)?;
1557 let created_at_str: String = row.get(6)?;
1558 let archived: bool = row.get(7)?;
1559 let version: i64 = row.get(8)?;
1560
1561 let id = Uuid::parse_str(&id_str).unwrap_or_else(|_| Uuid::now_v7());
1562 let created_at = chrono::DateTime::parse_from_rfc3339(&created_at_str)
1563 .map(|dt| dt.with_timezone(&chrono::Utc))
1564 .unwrap_or_else(|_| chrono::Utc::now());
1565
1566 Ok(User {
1567 id,
1568 spec_id,
1569 name,
1570 email,
1571 handle,
1572 pin_hash,
1573 created_at,
1574 archived,
1575 version,
1576 })
1577 }
1578 ).optional().map_err(|e| e.into())
1579 }
1580
1581 fn update_user(&self, user: &User) -> Result<()> {
1582 let conn = self.conn.lock().unwrap();
1583 self.save_user(&conn, user)
1584 }
1585
1586 fn delete_user(&self, id: &Uuid) -> Result<()> {
1587 let conn = self.conn.lock().unwrap();
1588 let rows_affected = conn.execute("DELETE FROM users WHERE id = ?1", [id.to_string()])?;
1589 if rows_affected == 0 {
1590 anyhow::bail!("User not found: {}", id)
1591 }
1592 Ok(())
1593 }
1594
1595 fn queue_list(&self, user_id: &str, include_completed: bool) -> Result<Vec<QueueEntry>> {
1601 let conn = self.conn.lock().unwrap();
1602 let sql = if include_completed {
1603 "SELECT q.user_id, q.requirement_id, q.position, q.added_by, q.note, q.added_at \
1604 FROM queue_entries q \
1605 WHERE q.user_id = ?1 \
1606 ORDER BY q.position ASC"
1607 } else {
1608 "SELECT q.user_id, q.requirement_id, q.position, q.added_by, q.note, q.added_at \
1609 FROM queue_entries q \
1610 LEFT JOIN requirements r ON q.requirement_id = r.id \
1611 WHERE q.user_id = ?1 AND (r.status IS NULL OR r.status != 'Completed') \
1612 ORDER BY q.position ASC"
1613 };
1614
1615 let mut stmt = conn.prepare(sql)?;
1616 let entries = stmt
1617 .query_map([user_id], |row| {
1618 let user_id: String = row.get(0)?;
1619 let req_id_str: String = row.get(1)?;
1620 let position: i64 = row.get(2)?;
1621 let added_by: String = row.get(3)?;
1622 let note: Option<String> = row.get(4)?;
1623 let added_at_str: String = row.get(5)?;
1624
1625 let requirement_id =
1626 Uuid::parse_str(&req_id_str).unwrap_or_else(|_| Uuid::now_v7());
1627 let added_at = chrono::DateTime::parse_from_rfc3339(&added_at_str)
1628 .map(|dt| dt.with_timezone(&chrono::Utc))
1629 .unwrap_or_else(|_| chrono::Utc::now());
1630
1631 Ok(QueueEntry {
1632 user_id,
1633 requirement_id,
1634 position,
1635 added_by,
1636 note,
1637 added_at,
1638 })
1639 })?
1640 .collect::<std::result::Result<Vec<_>, _>>()?;
1641
1642 Ok(entries)
1643 }
1644
1645 fn queue_add(&self, entry: QueueEntry) -> Result<()> {
1646 let conn = self.conn.lock().unwrap();
1647
1648 let position = if entry.position == i64::MAX {
1650 let max_pos: i64 = conn
1651 .query_row(
1652 "SELECT COALESCE(MAX(position), 0) FROM queue_entries WHERE user_id = ?1",
1653 [&entry.user_id],
1654 |row| row.get(0),
1655 )
1656 .unwrap_or(0);
1657 max_pos + 1000
1658 } else {
1659 entry.position
1660 };
1661
1662 conn.execute(
1663 "INSERT OR REPLACE INTO queue_entries (user_id, requirement_id, position, added_by, note, added_at) \
1664 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1665 params![
1666 entry.user_id,
1667 entry.requirement_id.to_string(),
1668 position,
1669 entry.added_by,
1670 entry.note,
1671 entry.added_at.to_rfc3339(),
1672 ],
1673 )?;
1674 Ok(())
1675 }
1676
1677 fn queue_remove(&self, user_id: &str, requirement_id: &Uuid) -> Result<()> {
1678 let conn = self.conn.lock().unwrap();
1679 conn.execute(
1680 "DELETE FROM queue_entries WHERE user_id = ?1 AND requirement_id = ?2",
1681 params![user_id, requirement_id.to_string()],
1682 )?;
1683 Ok(())
1684 }
1685
1686 fn queue_reorder(&self, user_id: &str, items: &[(Uuid, i64)]) -> Result<()> {
1687 let conn = self.conn.lock().unwrap();
1688 let tx = conn.unchecked_transaction()?;
1689 for (req_id, position) in items {
1690 tx.execute(
1691 "UPDATE queue_entries SET position = ?1 WHERE user_id = ?2 AND requirement_id = ?3",
1692 params![position, user_id, req_id.to_string()],
1693 )?;
1694 }
1695 tx.commit()?;
1696 Ok(())
1697 }
1698
1699 fn queue_clear(&self, user_id: &str, completed_only: bool) -> Result<()> {
1700 let conn = self.conn.lock().unwrap();
1701 if completed_only {
1702 conn.execute(
1703 "DELETE FROM queue_entries WHERE user_id = ?1 AND requirement_id IN \
1704 (SELECT id FROM requirements WHERE status = 'Completed')",
1705 [user_id],
1706 )?;
1707 } else {
1708 conn.execute("DELETE FROM queue_entries WHERE user_id = ?1", [user_id])?;
1709 }
1710 Ok(())
1711 }
1712}
1713
1714#[cfg(test)]
1715mod tests {
1716 use super::*;
1717 use tempfile::NamedTempFile;
1718
1719 #[test]
1720 fn test_sqlite_backend_create_and_load() {
1721 let temp_file = NamedTempFile::with_suffix(".db").unwrap();
1722 let backend = SqliteBackend::new(temp_file.path()).unwrap();
1723
1724 backend.create_if_not_exists().unwrap();
1725
1726 let store = backend.load().unwrap();
1727 assert!(store.requirements.is_empty());
1728 assert!(store.users.is_empty());
1729 }
1730
1731 #[test]
1732 fn test_sqlite_backend_save_and_load() {
1733 let temp_file = NamedTempFile::with_suffix(".db").unwrap();
1734 let backend = SqliteBackend::new(temp_file.path()).unwrap();
1735
1736 let mut store = RequirementsStore::new();
1737 store.name = "Test DB".to_string();
1738 store.title = "Test Database".to_string();
1739
1740 backend.save(&store).unwrap();
1741
1742 let loaded = backend.load().unwrap();
1743 assert_eq!(loaded.name, "Test DB");
1744 assert_eq!(loaded.title, "Test Database");
1745 }
1746
1747 #[test]
1748 fn test_sqlite_backend_requirement_crud() {
1749 let temp_file = NamedTempFile::with_suffix(".db").unwrap();
1750 let backend = SqliteBackend::new(temp_file.path()).unwrap();
1751
1752 backend.save(&RequirementsStore::new()).unwrap();
1754
1755 let req = Requirement::new("Test Req".to_string(), "Test Description".to_string());
1757 let req = backend.add_requirement(req).unwrap();
1758
1759 let loaded = backend.get_requirement(&req.id).unwrap();
1761 assert!(loaded.is_some());
1762 assert_eq!(loaded.unwrap().title, "Test Req");
1763
1764 backend.delete_requirement(&req.id).unwrap();
1766 let loaded = backend.get_requirement(&req.id).unwrap();
1767 assert!(loaded.is_none());
1768 }
1769}