database_replicator/serendb/
target.rs

1// ABOUTME: Persists SerenDB target selection for reuse across commands
2// ABOUTME: Stores project/branch/database selection in .seren-replicator/target.json
3
4use anyhow::{Context, Result};
5use chrono::Utc;
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8use std::path::PathBuf;
9
10const TARGET_FILE: &str = ".seren-replicator/target.json";
11const TARGET_FILE_ENV: &str = "SEREN_TARGET_STATE_PATH";
12const STATE_VERSION: u32 = 1;
13
14/// Persisted target selection state
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct TargetState {
17    /// Schema version for forward compatibility
18    pub version: u32,
19    /// Selected SerenDB project ID
20    pub project_id: String,
21    /// Human-readable project name
22    pub project_name: String,
23    /// Selected branch ID
24    pub branch_id: String,
25    /// Branch name
26    pub branch_name: String,
27    /// List of database names being replicated
28    pub databases: Vec<String>,
29    /// SHA256 hash of source URL (to detect mismatches)
30    pub source_url_hash: String,
31    /// When this target was configured
32    pub created_at: String,
33}
34
35impl TargetState {
36    /// Create a new target state snapshot
37    pub fn new(
38        project_id: String,
39        project_name: String,
40        branch_id: String,
41        branch_name: String,
42        databases: Vec<String>,
43        source_url: &str,
44    ) -> Self {
45        Self {
46            version: STATE_VERSION,
47            project_id,
48            project_name,
49            branch_id,
50            branch_name,
51            databases,
52            source_url_hash: hash_url(source_url),
53            created_at: Utc::now().to_rfc3339(),
54        }
55    }
56
57    /// Check if a source URL matches the stored configuration
58    pub fn source_matches(&self, source_url: &str) -> bool {
59        self.source_url_hash == hash_url(source_url)
60    }
61}
62
63/// Hash a URL for comparison (strips password for privacy)
64fn hash_url(url: &str) -> String {
65    let sanitized = crate::utils::strip_password_from_url(url).unwrap_or_else(|_| url.to_string());
66    let mut hasher = Sha256::new();
67    hasher.update(sanitized.as_bytes());
68    format!("sha256:{:x}", hasher.finalize())
69}
70
71/// Get the path to the target state file, allowing an env override for tests
72fn target_file_path() -> PathBuf {
73    if let Ok(custom) = std::env::var(TARGET_FILE_ENV) {
74        return PathBuf::from(custom);
75    }
76    PathBuf::from(TARGET_FILE)
77}
78
79/// Load target state from disk. Returns Ok(None) if the file does not exist.
80pub fn load_target_state() -> Result<Option<TargetState>> {
81    let path = target_file_path();
82
83    if !path.exists() {
84        return Ok(None);
85    }
86
87    let content = std::fs::read_to_string(&path)
88        .with_context(|| format!("Failed to read {}", path.display()))?;
89
90    let state: TargetState = serde_json::from_str(&content).with_context(|| {
91        format!(
92            "Failed to parse {}. Delete it and run init again.",
93            path.display()
94        )
95    })?;
96
97    if state.version > STATE_VERSION {
98        anyhow::bail!(
99            "Target state file was created by a newer database-replicator version. \
100             Upgrade this CLI or delete {}",
101            path.display()
102        );
103    }
104
105    Ok(Some(state))
106}
107
108/// Save target state to disk
109pub fn save_target_state(state: &TargetState) -> Result<()> {
110    let path = target_file_path();
111
112    if let Some(parent) = path.parent() {
113        std::fs::create_dir_all(parent)
114            .with_context(|| format!("Failed to create directory {}", parent.display()))?;
115    }
116
117    let content =
118        serde_json::to_string_pretty(state).context("Failed to serialize target state")?;
119
120    std::fs::write(&path, content)
121        .with_context(|| format!("Failed to write {}", path.display()))?;
122
123    tracing::info!("Saved SerenDB target configuration to {}", path.display());
124    Ok(())
125}
126
127/// Delete persisted target state (if present)
128pub fn clear_target_state() -> Result<()> {
129    let path = target_file_path();
130    if path.exists() {
131        std::fs::remove_file(&path)
132            .with_context(|| format!("Failed to remove {}", path.display()))?;
133    }
134    Ok(())
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use tempfile::tempdir;
141
142    fn with_temp_state_path<F: FnOnce()>(func: F) {
143        let _guard = crate::serendb::target_env_mutex().lock().unwrap();
144        let dir = tempdir().expect("tempdir");
145        let file_path = dir.path().join("target.json");
146        std::env::set_var(TARGET_FILE_ENV, &file_path);
147        func();
148        std::env::remove_var(TARGET_FILE_ENV);
149    }
150
151    #[test]
152    fn test_target_state_roundtrip() {
153        with_temp_state_path(|| {
154            let state = TargetState::new(
155                "proj-123".to_string(),
156                "my-project".to_string(),
157                "branch-456".to_string(),
158                "main".to_string(),
159                vec!["db1".to_string(), "db2".to_string()],
160                "postgresql://localhost/source",
161            );
162
163            save_target_state(&state).expect("save target state");
164            let loaded = load_target_state()
165                .expect("load state")
166                .expect("state present");
167
168            assert_eq!(loaded.project_id, "proj-123");
169            assert_eq!(loaded.databases.len(), 2);
170            assert!(loaded.source_matches("postgresql://localhost/source"));
171        });
172    }
173
174    #[test]
175    fn test_source_url_matching() {
176        let state = TargetState::new(
177            "p".to_string(),
178            "proj".to_string(),
179            "b".to_string(),
180            "main".to_string(),
181            vec![],
182            "postgresql://user:pass@host/db",
183        );
184
185        assert!(state.source_matches("postgresql://user:pass@host/db"));
186        assert!(state.source_matches("postgresql://user:other@host/db"));
187        assert!(!state.source_matches("postgresql://user:pass@other/db"));
188    }
189
190    #[test]
191    fn test_hash_url_strips_password() {
192        let hash1 = hash_url("postgresql://user:secret1@host/db");
193        let hash2 = hash_url("postgresql://user:secret2@host/db");
194        assert_eq!(hash1, hash2);
195    }
196}