⎇ e044d475167b0b210a79df049541c22d61edd4e2
crates/modules/tahuantinsuyu/tahuantinsuyu-store/src/lib.rs
History
Who touched what
raw
feat(tahuantinsuyu): persistir flex de los splitters entre sesiones
1//! `tahuantinsuyu-store` — persistencia SQLite del estudio astrológico.
2//!
3//! Una sola conexión `rusqlite` envuelta en `Arc<Mutex>` para que la app
4//! GPUI la comparta entre threads sin pelearse con el ownership. La
5//! migración inicial corre la primera vez que se abre un archivo nuevo
6//! (idempotente vía `CREATE TABLE IF NOT EXISTS`).
7//!
8//! Patrón inspirado en `yahweh_provider_sqlite::SqliteDataProvider` pero
9//! con dominio propio (no extiende el `DataProvider` agnóstico — esa
10//! integración viene en `tahuantinsuyu-tree` que envuelve este store
11//! detrás del trait de yahweh).
12
13#![forbid(unsafe_code)]
14#![warn(rust_2018_idioms)]
15
16use std::path::Path;
17use std::sync::{Arc, Mutex};
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use rusqlite::{Connection, OptionalExtension, params};
21use thiserror::Error;
22
23use tahuantinsuyu_model::{
24 Chart, ChartId, ChartKind, Contact, ContactId, Group, GroupId, ModuleState, StoredBirthData,
25 StoredChartConfig,
26};
27
28const SCHEMA_VERSION: i32 = 1;
29
30#[derive(Debug, Error)]
31pub enum StoreError {
32 #[error("sqlite: {0}")]
33 Sqlite(#[from] rusqlite::Error),
34 #[error("json: {0}")]
35 Json(#[from] serde_json::Error),
36 #[error("schema downgrade: db is at v{found}, code expects v{expected}")]
37 SchemaDowngrade { found: i32, expected: i32 },
38 #[error("ulid decode: {0}")]
39 UlidDecode(#[from] ulid::DecodeError),
40 #[error("model invariant: {0}")]
41 Model(#[from] tahuantinsuyu_model::ModelError),
42 #[error("not found: {0}")]
43 NotFound(String),
44}
45
46pub type StoreResult<T> = Result<T, StoreError>;
47
48/// Store backed by a single SQLite file.
49///
50/// Clone-able: comparte la misma conexión bajo el mutex. Útil para que
51/// distintos widgets (tree, panel, canvas) compartan una vista
52/// consistente sin pasar `&mut` por todos lados.
53#[derive(Clone)]
54pub struct Store {
55 conn: Arc<Mutex<Connection>>,
56}
57
58impl Store {
59 /// Abre (o crea) un archivo SQLite y corre las migraciones.
60 pub fn open(path: impl AsRef<Path>) -> StoreResult<Self> {
61 let conn = Connection::open(path)?;
62 let store = Self {
63 conn: Arc::new(Mutex::new(conn)),
64 };
65 store.migrate()?;
66 Ok(store)
67 }
68
69 /// Variante in-memory para tests.
70 pub fn in_memory() -> StoreResult<Self> {
71 let conn = Connection::open_in_memory()?;
72 let store = Self {
73 conn: Arc::new(Mutex::new(conn)),
74 };
75 store.migrate()?;
76 Ok(store)
77 }
78
79 fn migrate(&self) -> StoreResult<()> {
80 let conn = self.conn.lock().unwrap();
81 conn.execute_batch(MIGRATION_V1)?;
82
83 let found: i32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?;
84 if found > SCHEMA_VERSION {
85 return Err(StoreError::SchemaDowngrade {
86 found,
87 expected: SCHEMA_VERSION,
88 });
89 }
90 if found < SCHEMA_VERSION {
91 conn.execute(&format!("PRAGMA user_version = {}", SCHEMA_VERSION), [])?;
92 }
93 Ok(())
94 }
95
96 // -----------------------------------------------------------------
97 // Groups
98 // -----------------------------------------------------------------
99
100 pub fn create_group(
101 &self,
102 parent_id: Option<GroupId>,
103 name: &str,
104 description: Option<&str>,
105 ) -> StoreResult<Group> {
106 let group = Group {
107 id: GroupId::new(),
108 parent_id,
109 name: name.into(),
110 description: description.map(String::from),
111 created_at_ms: now_ms(),
112 sort_order: 0,
113 };
114 let conn = self.conn.lock().unwrap();
115 conn.execute(
116 "INSERT INTO groups (id, parent_id, name, description, created_at_ms, sort_order) \
117 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
118 params![
119 group.id.to_string(),
120 group.parent_id.map(|g| g.to_string()),
121 group.name,
122 group.description,
123 group.created_at_ms,
124 group.sort_order,
125 ],
126 )?;
127 Ok(group)
128 }
129
130 pub fn list_groups(&self, parent_id: Option<GroupId>) -> StoreResult<Vec<Group>> {
131 let conn = self.conn.lock().unwrap();
132 let mut stmt = conn.prepare(
133 "SELECT id, parent_id, name, description, created_at_ms, sort_order \
134 FROM groups WHERE parent_id IS ?1 \
135 ORDER BY sort_order ASC, name COLLATE NOCASE ASC",
136 )?;
137 let parent_str = parent_id.map(|g| g.to_string());
138 let rows = stmt.query_map(params![parent_str], row_to_group)?;
139 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
140 }
141
142 pub fn delete_group(&self, id: GroupId) -> StoreResult<()> {
143 let conn = self.conn.lock().unwrap();
144 conn.execute("DELETE FROM groups WHERE id = ?1", params![id.to_string()])?;
145 Ok(())
146 }
147
148 pub fn rename_group(&self, id: GroupId, name: &str) -> StoreResult<()> {
149 let conn = self.conn.lock().unwrap();
150 conn.execute(
151 "UPDATE groups SET name = ?2 WHERE id = ?1",
152 params![id.to_string(), name],
153 )?;
154 Ok(())
155 }
156
157 /// Cambia el `parent_id` de un Group. Pasar `None` para mover a raíz.
158 /// **No** valida ciclos — el caller debe garantizar que el nuevo
159 /// padre no sea descendiente del que mueve (sino la DB queda con un
160 /// ciclo que el list_groups no rompe pero hace al CTE infinito).
161 pub fn move_group(&self, id: GroupId, new_parent: Option<GroupId>) -> StoreResult<()> {
162 let conn = self.conn.lock().unwrap();
163 conn.execute(
164 "UPDATE groups SET parent_id = ?2 WHERE id = ?1",
165 params![id.to_string(), new_parent.map(|g| g.to_string())],
166 )?;
167 Ok(())
168 }
169
170 // -----------------------------------------------------------------
171 // Contacts
172 // -----------------------------------------------------------------
173
174 pub fn create_contact(
175 &self,
176 group_id: Option<GroupId>,
177 name: &str,
178 notes: Option<&str>,
179 ) -> StoreResult<Contact> {
180 let c = Contact {
181 id: ContactId::new(),
182 group_id,
183 name: name.into(),
184 notes: notes.map(String::from),
185 created_at_ms: now_ms(),
186 };
187 let conn = self.conn.lock().unwrap();
188 conn.execute(
189 "INSERT INTO contacts (id, group_id, name, notes, created_at_ms) \
190 VALUES (?1, ?2, ?3, ?4, ?5)",
191 params![
192 c.id.to_string(),
193 c.group_id.map(|g| g.to_string()),
194 c.name,
195 c.notes,
196 c.created_at_ms,
197 ],
198 )?;
199 Ok(c)
200 }
201
202 pub fn list_contacts(&self, group_id: Option<GroupId>) -> StoreResult<Vec<Contact>> {
203 let conn = self.conn.lock().unwrap();
204 let mut stmt = conn.prepare(
205 "SELECT id, group_id, name, notes, created_at_ms \
206 FROM contacts WHERE group_id IS ?1 \
207 ORDER BY name COLLATE NOCASE ASC",
208 )?;
209 let g = group_id.map(|g| g.to_string());
210 let rows = stmt.query_map(params![g], row_to_contact)?;
211 rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
212 }
213
214 pub fn delete_contact(&self, id: ContactId) -> StoreResult<()> {
215 let conn = self.conn.lock().unwrap();
216 conn.execute("DELETE FROM contacts WHERE id = ?1", params![id.to_string()])?;
217 Ok(())
218 }
219
220 pub fn rename_contact(&self, id: ContactId, name: &str) -> StoreResult<()> {
221 let conn = self.conn.lock().unwrap();
222 conn.execute(
223 "UPDATE contacts SET name = ?2 WHERE id = ?1",
224 params![id.to_string(), name],
225 )?;
226 Ok(())
227 }
228
229 pub fn move_contact(&self, id: ContactId, new_group: Option<GroupId>) -> StoreResult<()> {
230 let conn = self.conn.lock().unwrap();
231 conn.execute(
232 "UPDATE contacts SET group_id = ?2 WHERE id = ?1",
233 params![id.to_string(), new_group.map(|g| g.to_string())],
234 )?;
235 Ok(())
236 }
237
238 // -----------------------------------------------------------------
239 // Charts
240 // -----------------------------------------------------------------
241
242 pub fn create_chart(
243 &self,
244 contact_id: ContactId,
245 kind: ChartKind,
246 label: &str,
247 birth: &StoredBirthData,
248 config: &StoredChartConfig,
249 related_chart_id: Option<ChartId>,
250 ) -> StoreResult<Chart> {
251 let chart = Chart {
252 id: ChartId::new(),
253 contact_id,
254 kind,
255 label: label.into(),
256 birth_data: birth.clone(),
257 config: config.clone(),
258 related_chart_id,
259 created_at_ms: now_ms(),
260 };
261 chart.validate()?;
262 let conn = self.conn.lock().unwrap();
263 conn.execute(
264 "INSERT INTO charts \
265 (id, contact_id, kind, label, birth_data_json, config_json, \
266 related_chart_id, created_at_ms) \
267 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
268 params![
269 chart.id.to_string(),
270 chart.contact_id.to_string(),
271 serde_json::to_string(&chart.kind)?,
272 chart.label,
273 serde_json::to_string(&chart.birth_data)?,
274 serde_json::to_string(&chart.config)?,
275 chart.related_chart_id.map(|c| c.to_string()),
276 chart.created_at_ms,
277 ],
278 )?;
279 Ok(chart)
280 }
281
282 pub fn list_charts(&self, contact_id: ContactId) -> StoreResult<Vec<Chart>> {
283 let conn = self.conn.lock().unwrap();
284 let mut stmt = conn.prepare(
285 "SELECT id, contact_id, kind, label, birth_data_json, config_json, \
286 related_chart_id, created_at_ms \
287 FROM charts WHERE contact_id = ?1 \
288 ORDER BY created_at_ms ASC",
289 )?;
290 let rows = stmt.query_map(params![contact_id.to_string()], row_to_chart)?;
291 rows.collect::<Result<Vec<_>, _>>()
292 .map_err(StoreError::from)
293 .and_then(|v| v.into_iter().collect::<StoreResult<Vec<_>>>())
294 }
295
296 /// Lista todas las cartas del DB ordenadas por label (case-insensitive).
297 /// Pensado para pickers / selectores cross-contact (ej. elegir un
298 /// partner de sinastría desde cualquier contacto).
299 pub fn list_all_charts(&self) -> StoreResult<Vec<Chart>> {
300 let conn = self.conn.lock().unwrap();
301 let mut stmt = conn.prepare(
302 "SELECT id, contact_id, kind, label, birth_data_json, config_json, \
303 related_chart_id, created_at_ms \
304 FROM charts ORDER BY label COLLATE NOCASE ASC",
305 )?;
306 let rows = stmt.query_map([], row_to_chart)?;
307 let mut out = Vec::new();
308 for r in rows {
309 out.push(r??);
310 }
311 Ok(out)
312 }
313
314 pub fn get_chart(&self, id: ChartId) -> StoreResult<Chart> {
315 let conn = self.conn.lock().unwrap();
316 let mut stmt = conn.prepare(
317 "SELECT id, contact_id, kind, label, birth_data_json, config_json, \
318 related_chart_id, created_at_ms \
319 FROM charts WHERE id = ?1",
320 )?;
321 let chart = stmt
322 .query_row(params![id.to_string()], row_to_chart)
323 .optional()?;
324 match chart {
325 Some(Ok(c)) => Ok(c),
326 Some(Err(e)) => Err(e),
327 None => Err(StoreError::NotFound(format!("chart {}", id))),
328 }
329 }
330
331 pub fn delete_chart(&self, id: ChartId) -> StoreResult<()> {
332 let conn = self.conn.lock().unwrap();
333 conn.execute("DELETE FROM charts WHERE id = ?1", params![id.to_string()])?;
334 Ok(())
335 }
336
337 pub fn rename_chart(&self, id: ChartId, label: &str) -> StoreResult<()> {
338 let conn = self.conn.lock().unwrap();
339 conn.execute(
340 "UPDATE charts SET label = ?2 WHERE id = ?1",
341 params![id.to_string(), label],
342 )?;
343 Ok(())
344 }
345
346 /// Reemplaza label + birth_data + config de una carta existente,
347 /// preservando id / contact_id / related_chart_id / created_at_ms y
348 /// el `module_state` asociado (no se borra). Usado por el editor de
349 /// rectificación natal.
350 pub fn update_chart(
351 &self,
352 id: ChartId,
353 label: &str,
354 birth: &StoredBirthData,
355 config: &StoredChartConfig,
356 ) -> StoreResult<()> {
357 let conn = self.conn.lock().unwrap();
358 conn.execute(
359 "UPDATE charts SET label = ?2, birth_data_json = ?3, config_json = ?4 \
360 WHERE id = ?1",
361 params![
362 id.to_string(),
363 label,
364 serde_json::to_string(birth)?,
365 serde_json::to_string(config)?,
366 ],
367 )?;
368 Ok(())
369 }
370
371 // -----------------------------------------------------------------
372 // Module state
373 // -----------------------------------------------------------------
374
375 pub fn upsert_module_state(&self, state: &ModuleState) -> StoreResult<()> {
376 let conn = self.conn.lock().unwrap();
377 conn.execute(
378 "INSERT INTO module_state (chart_id, module_id, enabled, config_json) \
379 VALUES (?1, ?2, ?3, ?4) \
380 ON CONFLICT(chart_id, module_id) DO UPDATE SET \
381 enabled = excluded.enabled, \
382 config_json = excluded.config_json",
383 params![
384 state.chart_id.to_string(),
385 state.module_id,
386 state.enabled as i32,
387 state.config.to_string(),
388 ],
389 )?;
390 Ok(())
391 }
392
393 pub fn list_module_states(&self, chart_id: ChartId) -> StoreResult<Vec<ModuleState>> {
394 let conn = self.conn.lock().unwrap();
395 let mut stmt = conn.prepare(
396 "SELECT chart_id, module_id, enabled, config_json \
397 FROM module_state WHERE chart_id = ?1",
398 )?;
399 let rows = stmt.query_map(params![chart_id.to_string()], |row| {
400 Ok((
401 row.get::<_, String>(0)?,
402 row.get::<_, String>(1)?,
403 row.get::<_, i32>(2)?,
404 row.get::<_, String>(3)?,
405 ))
406 })?;
407 let mut out = Vec::new();
408 for r in rows {
409 let (chart_str, module_id, enabled, config_str) = r?;
410 out.push(ModuleState {
411 chart_id: chart_str
412 .parse()
413 .map_err(|e: ulid::DecodeError| StoreError::UlidDecode(e))?,
414 module_id,
415 enabled: enabled != 0,
416 config: serde_json::from_str(&config_str).unwrap_or(serde_json::Value::Null),
417 });
418 }
419 Ok(out)
420 }
421
422 // -----------------------------------------------------------------
423 // Settings (key/value libre — layout, last-opened chart, etc.)
424 // -----------------------------------------------------------------
425
426 /// Lee un valor de la tabla `settings`. `None` si no existe.
427 pub fn get_setting(&self, key: &str) -> StoreResult<Option<String>> {
428 let conn = self.conn.lock().unwrap();
429 let val = conn
430 .query_row(
431 "SELECT value FROM settings WHERE key = ?1",
432 params![key],
433 |row| row.get::<_, String>(0),
434 )
435 .optional()?;
436 Ok(val)
437 }
438
439 /// Upsert un setting. El valor es texto libre — para JSON, el caller
440 /// serializa antes de llamar.
441 pub fn set_setting(&self, key: &str, value: &str) -> StoreResult<()> {
442 let conn = self.conn.lock().unwrap();
443 conn.execute(
444 "INSERT INTO settings (key, value) VALUES (?1, ?2) \
445 ON CONFLICT(key) DO UPDATE SET value = excluded.value",
446 params![key, value],
447 )?;
448 Ok(())
449 }
450
451 // -----------------------------------------------------------------
452 // Recursive descent: charts under a group/contact (para thumbnails)
453 // -----------------------------------------------------------------
454
455 /// Devuelve todas las cartas que descienden de un Group (incluyendo
456 /// los Contacts de sub-groups recursivamente).
457 pub fn charts_under_group(&self, root: GroupId) -> StoreResult<Vec<Chart>> {
458 let conn = self.conn.lock().unwrap();
459 // CTE recursivo para listar todos los descendientes del group.
460 let mut stmt = conn.prepare(
461 "WITH RECURSIVE descendants(id) AS ( \
462 SELECT ?1 \
463 UNION ALL \
464 SELECT g.id FROM groups g JOIN descendants d ON g.parent_id = d.id \
465 ) \
466 SELECT c.id, c.contact_id, c.kind, c.label, c.birth_data_json, c.config_json, \
467 c.related_chart_id, c.created_at_ms \
468 FROM charts c \
469 JOIN contacts ct ON ct.id = c.contact_id \
470 WHERE ct.group_id IN descendants \
471 ORDER BY c.created_at_ms ASC",
472 )?;
473 let rows = stmt.query_map(params![root.to_string()], row_to_chart)?;
474 let mut out = Vec::new();
475 for r in rows {
476 out.push(r??);
477 }
478 Ok(out)
479 }
480}
481
482// =====================================================================
483// SQL schema
484// =====================================================================
485
486const MIGRATION_V1: &str = r#"
487PRAGMA foreign_keys = ON;
488
489CREATE TABLE IF NOT EXISTS groups (
490 id TEXT PRIMARY KEY,
491 parent_id TEXT,
492 name TEXT NOT NULL,
493 description TEXT,
494 created_at_ms INTEGER NOT NULL,
495 sort_order INTEGER NOT NULL DEFAULT 0,
496 FOREIGN KEY(parent_id) REFERENCES groups(id) ON DELETE CASCADE
497);
498CREATE INDEX IF NOT EXISTS idx_groups_parent ON groups(parent_id);
499
500CREATE TABLE IF NOT EXISTS contacts (
501 id TEXT PRIMARY KEY,
502 group_id TEXT,
503 name TEXT NOT NULL,
504 notes TEXT,
505 created_at_ms INTEGER NOT NULL,
506 FOREIGN KEY(group_id) REFERENCES groups(id) ON DELETE SET NULL
507);
508CREATE INDEX IF NOT EXISTS idx_contacts_group ON contacts(group_id);
509
510CREATE TABLE IF NOT EXISTS charts (
511 id TEXT PRIMARY KEY,
512 contact_id TEXT NOT NULL,
513 kind TEXT NOT NULL,
514 label TEXT NOT NULL,
515 birth_data_json TEXT NOT NULL,
516 config_json TEXT NOT NULL,
517 related_chart_id TEXT,
518 created_at_ms INTEGER NOT NULL,
519 FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE CASCADE,
520 FOREIGN KEY(related_chart_id) REFERENCES charts(id) ON DELETE SET NULL
521);
522CREATE INDEX IF NOT EXISTS idx_charts_contact ON charts(contact_id);
523
524CREATE TABLE IF NOT EXISTS module_state (
525 chart_id TEXT NOT NULL,
526 module_id TEXT NOT NULL,
527 enabled INTEGER NOT NULL DEFAULT 0,
528 config_json TEXT NOT NULL DEFAULT '{}',
529 PRIMARY KEY(chart_id, module_id),
530 FOREIGN KEY(chart_id) REFERENCES charts(id) ON DELETE CASCADE
531);
532
533CREATE TABLE IF NOT EXISTS settings (
534 key TEXT PRIMARY KEY,
535 value TEXT NOT NULL
536);
537"#;
538
539// =====================================================================
540// Row decoders
541// =====================================================================
542
543fn row_to_group(row: &rusqlite::Row<'_>) -> rusqlite::Result<Group> {
544 let id_str: String = row.get(0)?;
545 let parent_id_str: Option<String> = row.get(1)?;
546 Ok(Group {
547 id: id_str
548 .parse()
549 .map_err(|e: ulid::DecodeError| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
550 parent_id: match parent_id_str {
551 Some(s) => Some(s.parse().map_err(|e: ulid::DecodeError| {
552 rusqlite::Error::ToSqlConversionFailure(Box::new(e))
553 })?),
554 None => None,
555 },
556 name: row.get(2)?,
557 description: row.get(3)?,
558 created_at_ms: row.get(4)?,
559 sort_order: row.get(5)?,
560 })
561}
562
563fn row_to_contact(row: &rusqlite::Row<'_>) -> rusqlite::Result<Contact> {
564 let id_str: String = row.get(0)?;
565 let group_str: Option<String> = row.get(1)?;
566 Ok(Contact {
567 id: id_str
568 .parse()
569 .map_err(|e: ulid::DecodeError| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?,
570 group_id: match group_str {
571 Some(s) => Some(s.parse().map_err(|e: ulid::DecodeError| {
572 rusqlite::Error::ToSqlConversionFailure(Box::new(e))
573 })?),
574 None => None,
575 },
576 name: row.get(2)?,
577 notes: row.get(3)?,
578 created_at_ms: row.get(4)?,
579 })
580}
581
582fn row_to_chart(row: &rusqlite::Row<'_>) -> rusqlite::Result<StoreResult<Chart>> {
583 // Doble-Result porque hay deserialización JSON adentro que rusqlite no
584 // sabe modelar. El caller la aplana.
585 let id_str: String = row.get(0)?;
586 let contact_str: String = row.get(1)?;
587 let kind_json: String = row.get(2)?;
588 let label: String = row.get(3)?;
589 let bd_json: String = row.get(4)?;
590 let cfg_json: String = row.get(5)?;
591 let related_str: Option<String> = row.get(6)?;
592 let created_at_ms: i64 = row.get(7)?;
593
594 Ok((|| -> StoreResult<Chart> {
595 Ok(Chart {
596 id: id_str.parse()?,
597 contact_id: contact_str.parse()?,
598 kind: serde_json::from_str(&kind_json)?,
599 label,
600 birth_data: serde_json::from_str(&bd_json)?,
601 config: serde_json::from_str(&cfg_json)?,
602 related_chart_id: match related_str {
603 Some(s) => Some(s.parse()?),
604 None => None,
605 },
606 created_at_ms,
607 })
608 })())
609}
610
611fn now_ms() -> i64 {
612 SystemTime::now()
613 .duration_since(UNIX_EPOCH)
614 .map(|d| d.as_millis() as i64)
615 .unwrap_or(0)
616}
617
618// =====================================================================
619// Tests
620// =====================================================================
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625 use tahuantinsuyu_model::{ModuleState, StoredBirthData, StoredChartConfig};
626
627 #[test]
628 fn open_and_migrate() {
629 let s = Store::in_memory().unwrap();
630 let groups = s.list_groups(None).unwrap();
631 assert!(groups.is_empty());
632 }
633
634 #[test]
635 fn module_state_roundtrip() {
636 let s = Store::in_memory().unwrap();
637 let g = s.create_group(None, "Familia", None).unwrap();
638 let c = s.create_contact(Some(g.id), "Sergio", None).unwrap();
639 let chart = s
640 .create_chart(
641 c.id,
642 ChartKind::Natal,
643 "Natal",
644 &StoredBirthData {
645 year: 1987,
646 month: 3,
647 day: 14,
648 hour: 5,
649 minute: 22,
650 second: 0.0,
651 tz_offset_minutes: -240,
652 latitude_deg: 10.4806,
653 longitude_deg: -66.9036,
654 altitude_m: 900.0,
655 time_certainty: Default::default(),
656 subject_name: None,
657 birthplace_label: None,
658 },
659 &StoredChartConfig::default(),
660 None,
661 )
662 .unwrap();
663
664 // Persistir dos módulos con configs distintos.
665 let state1 = ModuleState {
666 chart_id: chart.id,
667 module_id: "transit".into(),
668 enabled: true,
669 config: serde_json::json!({}),
670 };
671 let state2 = ModuleState {
672 chart_id: chart.id,
673 module_id: "progression".into(),
674 enabled: false,
675 config: serde_json::json!({ "target_age_years": 42.5 }),
676 };
677 s.upsert_module_state(&state1).unwrap();
678 s.upsert_module_state(&state2).unwrap();
679
680 let loaded = s.list_module_states(chart.id).unwrap();
681 assert_eq!(loaded.len(), 2);
682 let by_id: std::collections::HashMap<_, _> =
683 loaded.into_iter().map(|m| (m.module_id.clone(), m)).collect();
684 assert_eq!(by_id["transit"].enabled, true);
685 assert_eq!(by_id["progression"].enabled, false);
686 assert_eq!(
687 by_id["progression"]
688 .config
689 .get("target_age_years")
690 .and_then(|v| v.as_f64()),
691 Some(42.5)
692 );
693
694 // Upsert: cambiar enabled de transit a false.
695 let state1_off = ModuleState {
696 chart_id: chart.id,
697 module_id: "transit".into(),
698 enabled: false,
699 config: serde_json::json!({}),
700 };
701 s.upsert_module_state(&state1_off).unwrap();
702 let loaded = s.list_module_states(chart.id).unwrap();
703 let by_id: std::collections::HashMap<_, _> =
704 loaded.into_iter().map(|m| (m.module_id.clone(), m)).collect();
705 assert_eq!(by_id["transit"].enabled, false);
706 }
707
708 #[test]
709 fn settings_upsert_and_read() {
710 let s = Store::in_memory().unwrap();
711 assert_eq!(s.get_setting("layout.outer").unwrap(), None);
712 s.set_setting("layout.outer", "4.0,1.0").unwrap();
713 assert_eq!(
714 s.get_setting("layout.outer").unwrap().as_deref(),
715 Some("4.0,1.0")
716 );
717 // Upsert — el segundo set sobreescribe.
718 s.set_setting("layout.outer", "3.5,1.5").unwrap();
719 assert_eq!(
720 s.get_setting("layout.outer").unwrap().as_deref(),
721 Some("3.5,1.5")
722 );
723 }
724
725 #[test]
726 fn full_hierarchy_roundtrip() {
727 let s = Store::in_memory().unwrap();
728 let g = s.create_group(None, "Familia", None).unwrap();
729 let c = s.create_contact(Some(g.id), "Sergio", None).unwrap();
730 let chart = s
731 .create_chart(
732 c.id,
733 ChartKind::Natal,
734 "Natal",
735 &StoredBirthData {
736 year: 1987,
737 month: 3,
738 day: 14,
739 hour: 5,
740 minute: 22,
741 second: 0.0,
742 tz_offset_minutes: -240,
743 latitude_deg: 10.4806,
744 longitude_deg: -66.9036,
745 altitude_m: 900.0,
746 time_certainty: Default::default(),
747 subject_name: Some("Sergio".into()),
748 birthplace_label: Some("Caracas".into()),
749 },
750 &StoredChartConfig::default(),
751 None,
752 )
753 .unwrap();
754 assert_eq!(s.get_chart(chart.id).unwrap().label, "Natal");
755
756 let under = s.charts_under_group(g.id).unwrap();
757 assert_eq!(under.len(), 1);
758 assert_eq!(under[0].id, chart.id);
759 }
760}