database_replicator/serendb/
mod.rs1mod client;
5mod picker;
6mod target;
7
8pub use client::{Branch, ConsoleClient, Database, Project};
9pub use picker::{create_missing_databases, select_target, TargetSelection};
10pub use target::{clear_target_state, load_target_state, save_target_state, TargetState};
11
12use anyhow::Result;
13
14#[cfg(test)]
15pub(crate) fn target_env_mutex() -> &'static std::sync::Mutex<()> {
16 use std::sync::{Mutex, OnceLock};
17 static ENV_MUTEX: OnceLock<Mutex<()>> = OnceLock::new();
18 ENV_MUTEX.get_or_init(|| Mutex::new(()))
19}
20
21#[derive(Debug, Clone)]
23pub enum TargetMode {
24 ConnectionString(String),
26 ApiKey(String),
28 SavedState(TargetState),
30}
31
32pub fn resolve_target_mode(target: Option<String>, api_key: Option<String>) -> Result<TargetMode> {
34 match (target, api_key) {
35 (Some(url), _) => Ok(TargetMode::ConnectionString(url)),
36 (None, Some(key)) => {
37 if let Some(state) = load_target_state()? {
38 tracing::info!(
39 "Using saved target configuration: {}/{}",
40 state.project_name,
41 state.branch_name
42 );
43 Ok(TargetMode::SavedState(state))
44 } else {
45 Ok(TargetMode::ApiKey(key))
46 }
47 }
48 (None, None) => {
49 anyhow::bail!(
50 "Target database required.\n\n\
51 Option 1: Provide --target with a PostgreSQL connection string\n\
52 Option 2: Set SEREN_API_KEY or pass --api-key for interactive SerenDB selection\n\n\
53 Get your API key at: https://console.serendb.com/api-keys"
54 )
55 }
56 }
57}
58
59#[cfg(test)]
60mod tests {
61 use super::*;
62 use crate::serendb::target::{clear_target_state, save_target_state, TargetState};
63 use tempfile::tempdir;
64
65 fn with_temp_state_path<F: FnOnce()>(func: F) {
66 let _guard = crate::serendb::target_env_mutex().lock().unwrap();
67 let dir = tempdir().expect("tempdir");
68 let path = dir.path().join("target.json");
69 std::env::set_var("SEREN_TARGET_STATE_PATH", &path);
70 func();
71 std::env::remove_var("SEREN_TARGET_STATE_PATH");
72 }
73
74 #[test]
75 fn test_resolve_target_mode_connection_string() {
76 let mode =
77 resolve_target_mode(Some("postgresql://localhost/db".to_string()), None).unwrap();
78 match mode {
79 TargetMode::ConnectionString(url) => assert!(url.contains("localhost")),
80 _ => panic!("Expected ConnectionString mode"),
81 }
82 }
83
84 #[test]
85 fn test_resolve_target_mode_prefers_explicit_target() {
86 let mode = resolve_target_mode(
87 Some("postgresql://localhost/db".to_string()),
88 Some("seren_key".to_string()),
89 )
90 .unwrap();
91
92 if !matches!(mode, TargetMode::ConnectionString(_)) {
93 panic!("Expected ConnectionString mode");
94 }
95 }
96
97 #[test]
98 fn test_resolve_target_mode_uses_saved_state() {
99 with_temp_state_path(|| {
100 let state = TargetState::new(
101 "proj".into(),
102 "Project".into(),
103 "branch".into(),
104 "main".into(),
105 vec!["db1".into()],
106 "postgresql://localhost/source",
107 );
108 save_target_state(&state).expect("save state");
109
110 let mode = resolve_target_mode(None, Some("seren_key".into())).unwrap();
111 match mode {
112 TargetMode::SavedState(saved) => assert_eq!(saved.project_id, "proj"),
113 _ => panic!("Expected SavedState mode"),
114 }
115
116 clear_target_state().expect("clear state");
117 });
118 }
119
120 #[test]
121 fn test_resolve_target_mode_neither_fails() {
122 let result = resolve_target_mode(None, None);
123 assert!(result.is_err());
124 }
125}