1use crate::aggregator::{AliasAggregator, SourceSpec};
34use crate::error::MiniAppError;
35use rusqlite::OptionalExtension;
36use std::path::{Path, PathBuf};
37use std::sync::{Arc, Mutex};
38
39#[derive(
48 Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize, schemars::JsonSchema,
49)]
50#[serde(rename_all = "lowercase")]
51pub enum AliasScope {
52 Project,
55 User,
59}
60
61#[derive(Debug, Clone)]
68pub struct AliasRecord {
69 pub name: String,
71 pub sources: SourceSpec,
73 pub aggregator: Option<AliasAggregator>,
75 pub filter: String,
77 pub default_limit: Option<u32>,
79 pub description: Option<String>,
81 pub params_schema: Option<String>,
84 pub fields: Option<String>,
89 pub order_by: Option<String>,
94 pub scope: Option<AliasScope>,
98}
99
100impl AliasRecord {
101 #[allow(clippy::too_many_arguments)]
117 pub fn new(
118 name: impl Into<String>,
119 sources: SourceSpec,
120 aggregator: Option<AliasAggregator>,
121 filter: impl Into<String>,
122 default_limit: Option<u32>,
123 description: Option<String>,
124 params_schema: Option<String>,
125 fields: Option<String>,
126 order_by: Option<String>,
127 ) -> Self {
128 Self {
129 name: name.into(),
130 sources,
131 aggregator,
132 filter: filter.into(),
133 default_limit,
134 description,
135 params_schema,
136 fields,
137 order_by,
138 scope: None,
139 }
140 }
141}
142
143const CREATE_GLOBAL_ALIASES_SQL: &str = "
146 CREATE TABLE IF NOT EXISTS _global_aliases (
147 name TEXT PRIMARY KEY,
148 sources_json TEXT NOT NULL,
149 aggregator_json TEXT,
150 filter TEXT NOT NULL,
151 default_limit INTEGER,
152 description TEXT,
153 params_schema TEXT,
154 fields TEXT,
155 order_by TEXT
156 )
157";
158
159pub const LEGACY_PER_TABLE_ALIASES_SQL: &str = "
162 CREATE TABLE IF NOT EXISTS _aliases (
163 name TEXT PRIMARY KEY,
164 filter TEXT NOT NULL,
165 default_limit INTEGER,
166 description TEXT,
167 params_schema TEXT
168 )
169";
170
171type LegacyAliasRow = (String, String, Option<u32>, Option<String>, Option<String>);
176
177pub struct GlobalAliasStorage {
182 project_conn: Option<Arc<Mutex<rusqlite::Connection>>>,
183 user_conn: Option<Arc<Mutex<rusqlite::Connection>>>,
184 project_path: Option<PathBuf>,
185 user_path: Option<PathBuf>,
186}
187
188impl std::fmt::Debug for GlobalAliasStorage {
189 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
190 f.debug_struct("GlobalAliasStorage")
194 .field("project_path", &self.project_path)
195 .field("user_path", &self.user_path)
196 .field("project_mounted", &self.project_conn.is_some())
197 .field("user_mounted", &self.user_conn.is_some())
198 .finish()
199 }
200}
201
202impl GlobalAliasStorage {
203 pub fn open(project_dir: Option<&Path>, user_dir: Option<&Path>) -> Result<Self, MiniAppError> {
216 if project_dir.is_none() && user_dir.is_none() {
217 return Err(MiniAppError::Config(
218 "GlobalAliasStorage::open requires at least one of project_dir / user_dir".into(),
219 ));
220 }
221 let project = project_dir.map(open_scope_db).transpose()?;
222 let user = user_dir.map(open_scope_db).transpose()?;
223 Ok(Self {
224 project_conn: project.as_ref().map(|(c, _)| Arc::clone(c)),
225 user_conn: user.as_ref().map(|(c, _)| Arc::clone(c)),
226 project_path: project.map(|(_, p)| p),
227 user_path: user.map(|(_, p)| p),
228 })
229 }
230
231 #[cfg(test)]
233 pub fn open_in_memory() -> Result<Self, MiniAppError> {
234 let conn = rusqlite::Connection::open_in_memory()?;
235 conn.execute_batch(CREATE_GLOBAL_ALIASES_SQL)?;
236 Ok(Self {
237 project_conn: Some(Arc::new(Mutex::new(conn))),
238 user_conn: None,
239 project_path: None,
240 user_path: None,
241 })
242 }
243
244 pub fn path_for_scope(&self, scope: AliasScope) -> Option<&Path> {
247 match scope {
248 AliasScope::Project => self.project_path.as_deref(),
249 AliasScope::User => self.user_path.as_deref(),
250 }
251 }
252
253 fn conn_for_scope(
254 &self,
255 scope: AliasScope,
256 ) -> Result<Arc<Mutex<rusqlite::Connection>>, MiniAppError> {
257 let opt = match scope {
258 AliasScope::Project => self.project_conn.as_ref(),
259 AliasScope::User => self.user_conn.as_ref(),
260 };
261 opt.map(Arc::clone).ok_or_else(|| {
262 MiniAppError::Config(format!("GlobalAliasStorage scope {scope:?} is not mounted"))
263 })
264 }
265
266 pub async fn alias_create(
275 &self,
276 scope: AliasScope,
277 record: AliasRecord,
278 ) -> Result<(), MiniAppError> {
279 let conn = self.conn_for_scope(scope)?;
280 let sources_json = serde_json::to_string(&record.sources).map_err(|e| {
281 MiniAppError::Schema(format!(
282 "serialise sources for alias '{}': {e}",
283 record.name
284 ))
285 })?;
286 let aggregator_json = match &record.aggregator {
287 Some(agg) => Some(serde_json::to_string(agg).map_err(|e| {
288 MiniAppError::Schema(format!(
289 "serialise aggregator for alias '{}': {e}",
290 record.name
291 ))
292 })?),
293 None => None,
294 };
295 let name = record.name.clone();
296 let filter = record.filter.clone();
297 let default_limit = record.default_limit;
298 let description = record.description.clone();
299 let params_schema = record.params_schema.clone();
300 let fields = record.fields.clone();
301 let order_by = record.order_by.clone();
302 tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
303 let conn = conn
304 .lock()
305 .map_err(|_| MiniAppError::Schema("mutex poisoned".into()))?;
306 conn.execute(
307 "INSERT OR IGNORE INTO _global_aliases \
308 (name, sources_json, aggregator_json, filter, default_limit, description, params_schema, fields, order_by) \
309 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
310 rusqlite::params![
311 name,
312 sources_json,
313 aggregator_json,
314 filter,
315 default_limit,
316 description,
317 params_schema,
318 fields,
319 order_by,
320 ],
321 )?;
322 if conn.changes() == 0 {
323 return Err(MiniAppError::AliasAlreadyExists { name });
324 }
325 Ok(())
326 })
327 .await
328 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
329 }
330
331 pub async fn alias_get(&self, name: &str) -> Result<AliasRecord, MiniAppError> {
335 if let Some(rec) = self.alias_get_scope(AliasScope::Project, name).await? {
336 return Ok(rec);
337 }
338 if let Some(rec) = self.alias_get_scope(AliasScope::User, name).await? {
339 return Ok(rec);
340 }
341 Err(MiniAppError::AliasNotFound {
342 name: name.to_string(),
343 })
344 }
345
346 pub async fn alias_get_scope(
351 &self,
352 scope: AliasScope,
353 name: &str,
354 ) -> Result<Option<AliasRecord>, MiniAppError> {
355 let conn = match scope {
356 AliasScope::Project => self.project_conn.as_ref(),
357 AliasScope::User => self.user_conn.as_ref(),
358 };
359 let Some(conn) = conn.map(Arc::clone) else {
360 return Ok(None);
361 };
362 let name_owned = name.to_string();
363 tokio::task::spawn_blocking(move || -> Result<Option<AliasRecord>, MiniAppError> {
364 let conn = conn
365 .lock()
366 .map_err(|_| MiniAppError::Schema("mutex poisoned".into()))?;
367 let mut stmt = conn.prepare(
368 "SELECT name, sources_json, aggregator_json, filter, default_limit, description, params_schema, fields, order_by \
369 FROM _global_aliases WHERE name = ?1",
370 )?;
371 let row = stmt
372 .query_row(rusqlite::params![name_owned], extract_row)
373 .optional()?;
374 match row {
375 Some(mut rec) => {
376 rec.scope = Some(scope);
377 Ok(Some(rec))
378 }
379 None => Ok(None),
380 }
381 })
382 .await
383 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
384 }
385
386 pub async fn alias_list(&self) -> Result<Vec<AliasRecord>, MiniAppError> {
390 let project = match self.project_conn.as_ref() {
391 Some(c) => list_scope(Arc::clone(c), AliasScope::Project).await?,
392 None => Vec::new(),
393 };
394 let user = match self.user_conn.as_ref() {
395 Some(c) => list_scope(Arc::clone(c), AliasScope::User).await?,
396 None => Vec::new(),
397 };
398 let mut merged: std::collections::BTreeMap<String, AliasRecord> =
399 std::collections::BTreeMap::new();
400 for rec in user {
403 merged.insert(rec.name.clone(), rec);
404 }
405 for rec in project {
406 merged.insert(rec.name.clone(), rec);
407 }
408 Ok(merged.into_values().collect())
409 }
410
411 pub async fn alias_delete(&self, scope: AliasScope, name: &str) -> Result<(), MiniAppError> {
419 let conn = self.conn_for_scope(scope)?;
420 let name_owned = name.to_string();
421 tokio::task::spawn_blocking(move || -> Result<(), MiniAppError> {
422 let conn = conn
423 .lock()
424 .map_err(|_| MiniAppError::Schema("mutex poisoned".into()))?;
425 let affected = conn.execute(
426 "DELETE FROM _global_aliases WHERE name = ?1",
427 rusqlite::params![name_owned],
428 )?;
429 if affected == 0 {
430 return Err(MiniAppError::AliasNotFound { name: name_owned });
431 }
432 Ok(())
433 })
434 .await
435 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
436 }
437
438 pub async fn migrate_from_per_table(
456 &self,
457 target_scope: AliasScope,
458 per_table: Vec<(String, Arc<Mutex<rusqlite::Connection>>)>,
459 ) -> Result<usize, MiniAppError> {
460 let dest = self.conn_for_scope(target_scope).map_err(|_| {
461 MiniAppError::Config(format!(
462 "GlobalAliasStorage::migrate_from_per_table requires {target_scope:?} scope to be mounted"
463 ))
464 })?;
465 tokio::task::spawn_blocking(move || -> Result<usize, MiniAppError> {
466 let mut migrated = 0usize;
467 for (table_name, src_conn) in per_table {
468 let rows: Vec<LegacyAliasRow> = {
469 let src = src_conn
470 .lock()
471 .map_err(|_| MiniAppError::Schema("source mutex poisoned".into()))?;
472 let mut stmt = src.prepare(
473 "SELECT name, filter, default_limit, description, params_schema \
474 FROM _aliases ORDER BY name ASC",
475 )?;
476 stmt.query_map([], |row| {
477 Ok((
478 row.get::<_, String>(0)?,
479 row.get::<_, String>(1)?,
480 row.get::<_, Option<u32>>(2)?,
481 row.get::<_, Option<String>>(3)?,
482 row.get::<_, Option<String>>(4)?,
483 ))
484 })?
485 .collect::<Result<Vec<_>, _>>()?
486 };
487 if rows.is_empty() {
488 continue;
489 }
490 let sources_json = serde_json::to_string(&SourceSpec::Single(table_name.clone()))
491 .map_err(|e| {
492 MiniAppError::Schema(format!(
493 "serialise Single source for table '{table_name}' during migration: {e}"
494 ))
495 })?;
496 let dst = dest
497 .lock()
498 .map_err(|_| MiniAppError::Schema("dest mutex poisoned".into()))?;
499 for (name, filter, default_limit, description, params_schema) in rows {
500 dst.execute(
501 "INSERT OR IGNORE INTO _global_aliases \
502 (name, sources_json, aggregator_json, filter, default_limit, description, params_schema, fields, order_by) \
503 VALUES (?1, ?2, NULL, ?3, ?4, ?5, ?6, NULL, NULL)",
504 rusqlite::params![
505 name,
506 sources_json,
507 filter,
508 default_limit,
509 description,
510 params_schema,
511 ],
512 )?;
513 if dst.changes() > 0 {
514 migrated += 1;
515 }
516 }
517 }
518 Ok(migrated)
519 })
520 .await
521 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
522 }
523}
524
525fn open_scope_db(dir: &Path) -> Result<(Arc<Mutex<rusqlite::Connection>>, PathBuf), MiniAppError> {
526 std::fs::create_dir_all(dir)?;
527 let db_path = dir.join("_global.db");
528 let conn = rusqlite::Connection::open(&db_path)?;
529 conn.pragma_update(None, "journal_mode", "WAL")?;
536 conn.execute_batch(CREATE_GLOBAL_ALIASES_SQL)?;
537 let has_fields: bool = {
544 let mut stmt = conn.prepare(
545 "SELECT COUNT(*) FROM pragma_table_info('_global_aliases') WHERE name = 'fields'",
546 )?;
547 stmt.query_row([], |row| row.get::<_, i64>(0))
548 .map(|n| n > 0)?
549 };
550 if !has_fields {
551 conn.execute_batch("ALTER TABLE _global_aliases ADD COLUMN fields TEXT")?;
552 }
553 let has_order_by: bool = {
557 let mut stmt = conn.prepare(
558 "SELECT COUNT(*) FROM pragma_table_info('_global_aliases') WHERE name = 'order_by'",
559 )?;
560 stmt.query_row([], |row| row.get::<_, i64>(0))
561 .map(|n| n > 0)?
562 };
563 if !has_order_by {
564 conn.execute_batch("ALTER TABLE _global_aliases ADD COLUMN order_by TEXT")?;
565 }
566 Ok((Arc::new(Mutex::new(conn)), db_path))
567}
568
569fn extract_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<AliasRecord> {
570 let name: String = row.get(0)?;
571 let sources_json: String = row.get(1)?;
572 let aggregator_json: Option<String> = row.get(2)?;
573 let filter: String = row.get(3)?;
574 let default_limit: Option<u32> = row.get(4)?;
575 let description: Option<String> = row.get(5)?;
576 let params_schema: Option<String> = row.get(6)?;
577 let fields: Option<String> = row.get(7)?;
578 let order_by: Option<String> = row.get(8)?;
579 let sources: SourceSpec = serde_json::from_str(&sources_json).map_err(|e| {
580 rusqlite::Error::FromSqlConversionFailure(
581 1,
582 rusqlite::types::Type::Text,
583 Box::new(std::io::Error::other(format!(
584 "deserialise sources_json: {e}"
585 ))),
586 )
587 })?;
588 let aggregator: Option<AliasAggregator> = match aggregator_json {
589 Some(s) => Some(serde_json::from_str(&s).map_err(|e| {
590 rusqlite::Error::FromSqlConversionFailure(
591 2,
592 rusqlite::types::Type::Text,
593 Box::new(std::io::Error::other(format!(
594 "deserialise aggregator_json: {e}"
595 ))),
596 )
597 })?),
598 None => None,
599 };
600 Ok(AliasRecord {
601 name,
602 sources,
603 aggregator,
604 filter,
605 default_limit,
606 description,
607 params_schema,
608 fields,
609 order_by,
610 scope: None,
611 })
612}
613
614async fn list_scope(
615 conn: Arc<Mutex<rusqlite::Connection>>,
616 scope: AliasScope,
617) -> Result<Vec<AliasRecord>, MiniAppError> {
618 tokio::task::spawn_blocking(move || -> Result<Vec<AliasRecord>, MiniAppError> {
619 let conn = conn
620 .lock()
621 .map_err(|_| MiniAppError::Schema("mutex poisoned".into()))?;
622 let mut stmt = conn.prepare(
623 "SELECT name, sources_json, aggregator_json, filter, default_limit, description, params_schema, fields, order_by \
624 FROM _global_aliases ORDER BY name ASC",
625 )?;
626 let rows = stmt
627 .query_map([], extract_row)?
628 .collect::<Result<Vec<_>, _>>()?;
629 Ok(rows
630 .into_iter()
631 .map(|mut r| {
632 r.scope = Some(scope);
633 r
634 })
635 .collect())
636 })
637 .await
638 .map_err(|e| MiniAppError::Schema(format!("blocking task panic: {e}")))?
639}
640
641#[cfg(test)]
646mod tests {
647 use super::*;
648 use crate::aggregator::AliasAggregator;
649 use tempfile::TempDir;
650
651 fn sample_record(name: &str) -> AliasRecord {
652 AliasRecord::new(
653 name,
654 SourceSpec::Single("rows".into()),
655 None,
656 r#"{"type":"eq","field":"status","value":"open"}"#,
657 Some(20),
658 Some("sample".into()),
659 None,
660 None,
661 None,
662 )
663 }
664
665 #[tokio::test]
666 async fn create_get_roundtrip_in_memory() {
667 let storage = GlobalAliasStorage::open_in_memory().unwrap();
668 storage
669 .alias_create(AliasScope::Project, sample_record("foo"))
670 .await
671 .unwrap();
672 let got = storage.alias_get("foo").await.unwrap();
673 assert_eq!(got.name, "foo");
674 assert!(matches!(got.sources, SourceSpec::Single(ref t) if t == "rows"));
675 assert!(got.aggregator.is_none());
676 assert_eq!(got.default_limit, Some(20));
677 assert_eq!(got.description.as_deref(), Some("sample"));
678 assert_eq!(got.scope, Some(AliasScope::Project));
679 }
680
681 #[tokio::test]
682 async fn create_persists_sources_multi_and_aggregator_groupby() {
683 let storage = GlobalAliasStorage::open_in_memory().unwrap();
684 let rec = AliasRecord::new(
685 "by_tag",
686 SourceSpec::Multi(vec!["a".into(), "b".into()]),
687 Some(AliasAggregator::GroupBy {
688 by_field: "tag".into(),
689 having: None,
690 inner: Some(Box::new(AliasAggregator::Sum {
691 field: "value".into(),
692 })),
693 }),
694 "{}".to_string(),
695 None,
696 None,
697 None,
698 None,
699 None,
700 );
701 storage
702 .alias_create(AliasScope::Project, rec)
703 .await
704 .unwrap();
705 let got = storage.alias_get("by_tag").await.unwrap();
706 match got.sources {
707 SourceSpec::Multi(v) => assert_eq!(v, vec!["a".to_string(), "b".to_string()]),
708 other => panic!("expected Multi, got {other:?}"),
709 }
710 match got.aggregator {
711 Some(AliasAggregator::GroupBy {
712 by_field,
713 inner: Some(inner),
714 ..
715 }) => {
716 assert_eq!(by_field, "tag");
717 assert!(matches!(*inner, AliasAggregator::Sum { ref field } if field == "value"));
718 }
719 other => panic!("expected GroupBy+Sum, got {other:?}"),
720 }
721 }
722
723 #[tokio::test]
724 async fn create_persists_pattern_source() {
725 let storage = GlobalAliasStorage::open_in_memory().unwrap();
726 let rec = AliasRecord::new(
727 "shi_all",
728 SourceSpec::Pattern("shi_*".into()),
729 Some(AliasAggregator::Count),
730 "{}".to_string(),
731 None,
732 None,
733 None,
734 None,
735 None,
736 );
737 storage
738 .alias_create(AliasScope::Project, rec)
739 .await
740 .unwrap();
741 let got = storage.alias_get("shi_all").await.unwrap();
742 match got.sources {
743 SourceSpec::Pattern(p) => assert_eq!(p, "shi_*"),
744 other => panic!("expected Pattern, got {other:?}"),
745 }
746 assert!(matches!(got.aggregator, Some(AliasAggregator::Count)));
747 }
748
749 #[tokio::test]
750 async fn create_duplicate_returns_already_exists() {
751 let storage = GlobalAliasStorage::open_in_memory().unwrap();
752 storage
753 .alias_create(AliasScope::Project, sample_record("dup"))
754 .await
755 .unwrap();
756 let err = storage
757 .alias_create(AliasScope::Project, sample_record("dup"))
758 .await
759 .expect_err("expected AliasAlreadyExists");
760 assert_eq!(err.code(), crate::error::codes::ALIAS_ALREADY_EXISTS);
761 }
762
763 #[tokio::test]
764 async fn get_unknown_returns_not_found() {
765 let storage = GlobalAliasStorage::open_in_memory().unwrap();
766 let err = storage
767 .alias_get("nope")
768 .await
769 .expect_err("expected AliasNotFound");
770 assert_eq!(err.code(), crate::error::codes::ALIAS_NOT_FOUND);
771 }
772
773 #[tokio::test]
774 async fn delete_round_trip_then_not_found() {
775 let storage = GlobalAliasStorage::open_in_memory().unwrap();
776 storage
777 .alias_create(AliasScope::Project, sample_record("to_delete"))
778 .await
779 .unwrap();
780 storage
781 .alias_delete(AliasScope::Project, "to_delete")
782 .await
783 .unwrap();
784 let err = storage
785 .alias_delete(AliasScope::Project, "to_delete")
786 .await
787 .expect_err("second delete should fail");
788 assert_eq!(err.code(), crate::error::codes::ALIAS_NOT_FOUND);
789 }
790
791 #[tokio::test]
792 async fn list_returns_sorted_ascending_by_name() {
793 let storage = GlobalAliasStorage::open_in_memory().unwrap();
794 for n in ["c", "a", "b"] {
795 storage
796 .alias_create(AliasScope::Project, sample_record(n))
797 .await
798 .unwrap();
799 }
800 let names: Vec<String> = storage
801 .alias_list()
802 .await
803 .unwrap()
804 .into_iter()
805 .map(|r| r.name)
806 .collect();
807 assert_eq!(names, vec!["a", "b", "c"]);
808 }
809
810 #[tokio::test]
811 async fn project_overrides_user_on_name_collision() {
812 let project_dir = TempDir::new().unwrap();
813 let user_dir = TempDir::new().unwrap();
814 let storage =
815 GlobalAliasStorage::open(Some(project_dir.path()), Some(user_dir.path())).unwrap();
816 let mut user_rec = sample_record("shared");
817 user_rec.description = Some("user-version".into());
818 storage
819 .alias_create(AliasScope::User, user_rec)
820 .await
821 .unwrap();
822 let mut project_rec = sample_record("shared");
823 project_rec.description = Some("project-version".into());
824 storage
825 .alias_create(AliasScope::Project, project_rec)
826 .await
827 .unwrap();
828
829 let got = storage.alias_get("shared").await.unwrap();
831 assert_eq!(got.description.as_deref(), Some("project-version"));
832 assert_eq!(got.scope, Some(AliasScope::Project));
833
834 let all = storage.alias_list().await.unwrap();
836 assert_eq!(all.len(), 1);
837 assert_eq!(all[0].description.as_deref(), Some("project-version"));
838 assert_eq!(all[0].scope, Some(AliasScope::Project));
839 }
840
841 #[tokio::test]
842 async fn user_only_alias_returned_when_no_project_collision() {
843 let project_dir = TempDir::new().unwrap();
844 let user_dir = TempDir::new().unwrap();
845 let storage =
846 GlobalAliasStorage::open(Some(project_dir.path()), Some(user_dir.path())).unwrap();
847 let user_only = sample_record("user_only");
848 storage
849 .alias_create(AliasScope::User, user_only)
850 .await
851 .unwrap();
852 let got = storage.alias_get("user_only").await.unwrap();
853 assert_eq!(got.scope, Some(AliasScope::User));
854 }
855
856 #[tokio::test]
857 async fn open_persists_across_reopen() {
858 let project_dir = TempDir::new().unwrap();
859 {
860 let storage = GlobalAliasStorage::open(Some(project_dir.path()), None).unwrap();
861 storage
862 .alias_create(AliasScope::Project, sample_record("persisted"))
863 .await
864 .unwrap();
865 }
866 let reopened = GlobalAliasStorage::open(Some(project_dir.path()), None).unwrap();
867 let got = reopened.alias_get("persisted").await.unwrap();
868 assert_eq!(got.name, "persisted");
869 }
870
871 #[tokio::test]
872 async fn open_requires_at_least_one_scope() {
873 let err = GlobalAliasStorage::open(None, None)
874 .expect_err("expected Config error when both dirs are None");
875 assert_eq!(err.code(), crate::error::codes::CONFIG_ERROR);
876 }
877
878 #[tokio::test]
879 async fn migrate_from_per_table_lossless_roundtrip() {
880 let conn_a = rusqlite::Connection::open_in_memory().unwrap();
882 conn_a.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
883 conn_a
884 .execute(
885 "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
886 VALUES (?1, ?2, ?3, ?4, ?5)",
887 rusqlite::params!["a_open", "{}", 50i64, "alpha", Option::<String>::None],
888 )
889 .unwrap();
890 conn_a
891 .execute(
892 "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
893 VALUES (?1, ?2, ?3, ?4, ?5)",
894 rusqlite::params![
895 "a_closed",
896 "{}",
897 Option::<i64>::None,
898 Option::<String>::None,
899 Some("[\"x\"]".to_string())
900 ],
901 )
902 .unwrap();
903
904 let conn_b = rusqlite::Connection::open_in_memory().unwrap();
905 conn_b.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
906 conn_b
907 .execute(
908 "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
909 VALUES (?1, ?2, ?3, ?4, ?5)",
910 rusqlite::params!["b_recent", "{}", 10i64, "bravo", Option::<String>::None],
911 )
912 .unwrap();
913
914 let storage = GlobalAliasStorage::open_in_memory().unwrap();
915 let migrated = storage
916 .migrate_from_per_table(
917 AliasScope::Project,
918 vec![
919 ("table_a".to_string(), Arc::new(Mutex::new(conn_a))),
920 ("table_b".to_string(), Arc::new(Mutex::new(conn_b))),
921 ],
922 )
923 .await
924 .unwrap();
925 assert_eq!(migrated, 3);
926
927 let all = storage.alias_list().await.unwrap();
930 assert_eq!(all.len(), 3);
931 let a_open = all.iter().find(|r| r.name == "a_open").unwrap();
932 assert!(matches!(a_open.sources, SourceSpec::Single(ref t) if t == "table_a"));
933 assert!(a_open.aggregator.is_none());
934 assert_eq!(a_open.default_limit, Some(50));
935 assert_eq!(a_open.description.as_deref(), Some("alpha"));
936 assert_eq!(a_open.params_schema, None);
937
938 let a_closed = all.iter().find(|r| r.name == "a_closed").unwrap();
939 assert!(matches!(a_closed.sources, SourceSpec::Single(ref t) if t == "table_a"));
940 assert_eq!(a_closed.params_schema.as_deref(), Some("[\"x\"]"));
941
942 let b_recent = all.iter().find(|r| r.name == "b_recent").unwrap();
943 assert!(matches!(b_recent.sources, SourceSpec::Single(ref t) if t == "table_b"));
944 assert_eq!(b_recent.default_limit, Some(10));
945 }
946
947 #[tokio::test]
948 async fn migrate_from_per_table_idempotent_on_second_run() {
949 let conn = rusqlite::Connection::open_in_memory().unwrap();
950 conn.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
951 conn.execute(
952 "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
953 VALUES (?1, ?2, ?3, ?4, ?5)",
954 rusqlite::params![
955 "x",
956 "{}",
957 Option::<i64>::None,
958 Option::<String>::None,
959 Option::<String>::None
960 ],
961 )
962 .unwrap();
963 let conn_arc = Arc::new(Mutex::new(conn));
964
965 let storage = GlobalAliasStorage::open_in_memory().unwrap();
966 let first = storage
967 .migrate_from_per_table(
968 AliasScope::Project,
969 vec![("t".to_string(), Arc::clone(&conn_arc))],
970 )
971 .await
972 .unwrap();
973 let second = storage
974 .migrate_from_per_table(
975 AliasScope::Project,
976 vec![("t".to_string(), Arc::clone(&conn_arc))],
977 )
978 .await
979 .unwrap();
980 assert_eq!(first, 1);
981 assert_eq!(second, 0);
982 let all = storage.alias_list().await.unwrap();
983 assert_eq!(all.len(), 1);
984 }
985
986 #[tokio::test]
987 async fn migrate_from_per_table_skips_collision_with_existing_global() {
988 let storage = GlobalAliasStorage::open_in_memory().unwrap();
992 let mut existing = sample_record("shared");
993 existing.description = Some("existing-global".into());
994 storage
995 .alias_create(AliasScope::Project, existing)
996 .await
997 .unwrap();
998
999 let conn = rusqlite::Connection::open_in_memory().unwrap();
1000 conn.execute_batch(LEGACY_PER_TABLE_ALIASES_SQL).unwrap();
1001 conn.execute(
1002 "INSERT INTO _aliases (name, filter, default_limit, description, params_schema) \
1003 VALUES (?1, ?2, ?3, ?4, ?5)",
1004 rusqlite::params![
1005 "shared",
1006 "{}",
1007 Option::<i64>::None,
1008 Some("legacy-per-table".to_string()),
1009 Option::<String>::None
1010 ],
1011 )
1012 .unwrap();
1013 let migrated = storage
1014 .migrate_from_per_table(
1015 AliasScope::Project,
1016 vec![("ignored_table".to_string(), Arc::new(Mutex::new(conn)))],
1017 )
1018 .await
1019 .unwrap();
1020 assert_eq!(migrated, 0);
1021 let got = storage.alias_get("shared").await.unwrap();
1022 assert_eq!(got.description.as_deref(), Some("existing-global"));
1023 }
1024
1025 #[tokio::test]
1026 async fn order_by_roundtrip() {
1027 let storage = GlobalAliasStorage::open_in_memory().unwrap();
1028 let order_by_json =
1029 r#"[{"field":"priority","direction":"asc"},{"field":"due","direction":"asc"}]"#;
1030 let rec = AliasRecord::new(
1031 "sorted_alias",
1032 SourceSpec::Single("todo".into()),
1033 None,
1034 "{}",
1035 None,
1036 None,
1037 None,
1038 None,
1039 Some(order_by_json.to_string()),
1040 );
1041 storage
1042 .alias_create(AliasScope::Project, rec)
1043 .await
1044 .unwrap();
1045 let got = storage.alias_get("sorted_alias").await.unwrap();
1046 assert_eq!(got.order_by.as_deref(), Some(order_by_json));
1047 }
1048
1049 #[tokio::test]
1050 async fn order_by_none_roundtrip() {
1051 let storage = GlobalAliasStorage::open_in_memory().unwrap();
1052 let rec = AliasRecord::new(
1053 "unsorted_alias",
1054 SourceSpec::Single("todo".into()),
1055 None,
1056 "{}",
1057 None,
1058 None,
1059 None,
1060 None,
1061 None,
1062 );
1063 storage
1064 .alias_create(AliasScope::Project, rec)
1065 .await
1066 .unwrap();
1067 let got = storage.alias_get("unsorted_alias").await.unwrap();
1068 assert!(got.order_by.is_none());
1069 }
1070}