database_replicator/serendb/
target.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct TargetState {
17 pub version: u32,
19 pub project_id: String,
21 pub project_name: String,
23 pub branch_id: String,
25 pub branch_name: String,
27 pub databases: Vec<String>,
29 pub source_url_hash: String,
31 pub created_at: String,
33}
34
35impl TargetState {
36 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 pub fn source_matches(&self, source_url: &str) -> bool {
59 self.source_url_hash == hash_url(source_url)
60 }
61}
62
63fn 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
71fn 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
79pub 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
108pub 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
127pub 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}