Skip to main content

pharos_native/runtime/
backup.rs

1//! Native adapter for app backup and restore.
2//!
3//! Generic backup bundle assembly lives in `pharos_runtime_ops`. This module
4//! resolves Pharos runtime configuration and keeps the existing root API stable.
5
6use std::path::{Path, PathBuf};
7
8use crate::config::{self, AppRuntime, ServeConfig};
9
10pub use pharos_runtime_ops::BackupRestoreError;
11
12fn non_empty<'a>(
13    value: Option<&'a str>,
14    error: &'static str,
15) -> Result<&'a str, BackupRestoreError> {
16    value
17        .map(str::trim)
18        .filter(|value| !value.is_empty())
19        .ok_or(BackupRestoreError::InvalidInput(error))
20}
21
22fn app_root(app: &AppRuntime) -> Result<PathBuf, BackupRestoreError> {
23    let value = non_empty(app.app_root.as_deref(), "backup requires an app root")?;
24    let path = PathBuf::from(value);
25    if !path.is_dir() {
26        return Err(BackupRestoreError::InvalidPath(
27            "app root is not a directory",
28        ));
29    }
30    Ok(path)
31}
32
33fn configured_value(app: &AppRuntime, key: &'static str) -> Result<String, BackupRestoreError> {
34    let root = app_root(app)?;
35    config::dynamic_config_effective_value(
36        &root,
37        app.runtime_conf_path.as_deref().map(Path::new),
38        key,
39    )
40    .filter(|value| !value.trim().is_empty())
41    .ok_or(BackupRestoreError::InvalidInput(match key {
42        "db_backend" => "db_backend is required for backup or restore",
43        "db_path" => "db_path is required for backup or restore",
44        "backup_dir" => "backup_dir is required for backup",
45        _ => "required runtime configuration is missing",
46    }))
47}
48
49fn backup_context(
50    config: &ServeConfig,
51    app: &AppRuntime,
52) -> Result<pharos_runtime_ops::BackupBundleContext, BackupRestoreError> {
53    pharos_runtime_ops::backup_bundle_context_from_core_config(config, app)
54        .map_err(|_| BackupRestoreError::InvalidInput("backup runtime configuration is invalid"))
55}
56
57pub fn json_top_level_long(text: &str, key: &str, fallback_value: i64) -> i64 {
58    pharos_runtime_ops::json_top_level_long(text, key, fallback_value)
59}
60
61pub fn write_text_file(path: &Path, content: &str) -> Result<(), BackupRestoreError> {
62    pharos_runtime_ops::write_text_file(path, content)
63}
64
65pub fn default_backend_local(app: &AppRuntime) -> Result<String, BackupRestoreError> {
66    configured_value(app, "db_backend")
67}
68
69pub fn backup_base_dir(app: &AppRuntime) -> Result<PathBuf, BackupRestoreError> {
70    Ok(PathBuf::from(configured_value(app, "backup_dir")?))
71}
72
73pub fn backup_timestamp_utc() -> Result<String, BackupRestoreError> {
74    pharos_runtime_ops::backup_timestamp_utc()
75}
76
77pub fn migration_fixture_path(app: &AppRuntime) -> Result<PathBuf, BackupRestoreError> {
78    pharos_runtime_ops::migration_contract_path_from_manifest(&app_root(app)?)
79}
80
81pub fn migration_schema_version(migration_fixture_path: &Path) -> Result<i64, BackupRestoreError> {
82    pharos_runtime_ops::migration_schema_version(migration_fixture_path)
83}
84
85pub fn write_backup_state_snapshot(
86    app: &AppRuntime,
87    output_path: &Path,
88) -> Result<(), BackupRestoreError> {
89    let state_path = non_empty(app.state_path.as_deref(), "app state_path is required")?;
90    pharos_runtime_ops::write_backup_state_snapshot(Path::new(state_path), output_path)
91}
92
93pub fn metadata_artifact_value(
94    metadata_path: &Path,
95    key: &str,
96) -> Result<String, BackupRestoreError> {
97    pharos_runtime_ops::metadata_artifact_value(metadata_path, key)
98}
99
100pub fn build_backup_report_local_json(
101    config: &ServeConfig,
102    app: &AppRuntime,
103    backend_override: Option<&str>,
104    output_dir_override: Option<&Path>,
105) -> Result<String, BackupRestoreError> {
106    let context = backup_context(config, app)?;
107    let adapter = super::database::operations::resolve(&context.database)
108        .map_err(BackupRestoreError::Database)?;
109    pharos_runtime_ops::build_backup_report_from_context(
110        &context,
111        adapter.as_ref(),
112        backend_override,
113        output_dir_override,
114    )
115}
116
117pub fn build_restore_report_local_json(
118    config: &ServeConfig,
119    app: &AppRuntime,
120    backend_override: Option<&str>,
121    input_dir: &Path,
122) -> Result<String, BackupRestoreError> {
123    let context = backup_context(config, app)?;
124    let adapter = super::database::operations::resolve(&context.database)
125        .map_err(BackupRestoreError::Database)?;
126    pharos_runtime_ops::build_restore_report_from_context(
127        &context,
128        adapter.as_ref(),
129        backend_override,
130        input_dir,
131    )
132}
133
134#[cfg(test)]
135mod tests {
136    use super::migration_fixture_path;
137    use crate::config::AppRuntime;
138    use std::fs;
139    use std::path::{Path, PathBuf};
140    use std::time::{SystemTime, UNIX_EPOCH};
141
142    fn unique_temp_dir(label: &str) -> PathBuf {
143        let nonce = SystemTime::now()
144            .duration_since(UNIX_EPOCH)
145            .expect("clock should be after epoch")
146            .as_nanos();
147        let path = std::env::temp_dir().join(format!(
148            "pharos-backup-test-{label}-{}-{nonce}",
149            std::process::id()
150        ));
151        fs::create_dir_all(&path).expect("temp dir should be created");
152        path
153    }
154
155    fn write_manifest(root: &Path, manifest_text: &str) {
156        fs::write(root.join("pharos.app.json"), manifest_text).expect("manifest should be written");
157        let contracts_dir = root.join("data/contracts");
158        fs::create_dir_all(&contracts_dir).expect("contracts dir should be created");
159        fs::write(
160            contracts_dir.join("migrations.json"),
161            "{\"schema_version\":1}\n",
162        )
163        .expect("migration contract should be written");
164    }
165
166    #[test]
167    fn migration_fixture_path_reads_database_contract_from_built_manifest() {
168        let root = unique_temp_dir("database-contract");
169        write_manifest(
170            &root,
171            r#"{
172  "database": {
173    "migration_contract": "data/contracts/migrations.json"
174  }
175}
176"#,
177        );
178        let app = AppRuntime {
179            app_root: Some(root.to_string_lossy().into_owned()),
180            ..AppRuntime::default()
181        };
182        let fixture =
183            migration_fixture_path(&app).expect("database migration contract should resolve");
184        assert_eq!(
185            fixture,
186            root.join("data/contracts/migrations.json")
187                .canonicalize()
188                .expect("fixture path should canonicalize")
189        );
190        fs::remove_dir_all(&root).expect("temp dir should be removed");
191    }
192
193    #[test]
194    fn migration_fixture_path_rejects_undeclared_legacy_contract() {
195        let root = unique_temp_dir("legacy-contract");
196        write_manifest(
197            &root,
198            r#"{
199  "contracts": {
200    "migration_contract": "data/contracts/migrations.json"
201  }
202}
203"#,
204        );
205        let app = AppRuntime {
206            app_root: Some(root.to_string_lossy().into_owned()),
207            ..AppRuntime::default()
208        };
209        assert!(migration_fixture_path(&app).is_err());
210        fs::remove_dir_all(&root).expect("temp dir should be removed");
211    }
212}