database_replicator/serendb/
mod.rs

1// ABOUTME: SerenDB Console API client for managing project settings
2// ABOUTME: Enables checking and enabling logical replication on SerenDB projects
3
4mod 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/// How the target database is specified
22#[derive(Debug, Clone)]
23pub enum TargetMode {
24    /// User provided --target connection string directly
25    ConnectionString(String),
26    /// User provided API key, will use interactive selection
27    ApiKey(String),
28    /// Using saved target from previous init
29    SavedState(TargetState),
30}
31
32/// Resolve which target mode to use based on CLI args and environment
33pub 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}