Skip to main content

pawan/config/
migration.rs

1//! Config schema migration helpers.
2//!
3//! Applies sequential version upgrades to a [`PawanConfig`] loaded from disk,
4//! creating a timestamped backup before modifying anything.
5
6use chrono::Utc;
7use tracing;
8use std::path::PathBuf;
9use super::{PawanConfig, default_tool_idle_timeout};
10
11/// Latest config version understood by this build.
12const LATEST_CONFIG_VERSION: u32 = 1;
13
14/// Outcome of a migration attempt.
15#[derive(Debug)]
16pub struct MigrationResult {
17    /// Whether any migration steps were applied.
18    pub migrated: bool,
19    /// Version the config was at on disk.
20    pub from_version: u32,
21    /// Version the config is at now.
22    pub to_version: u32,
23    /// Path to the pre-migration backup, if one was created.
24    pub backup_path: Option<PathBuf>,
25}
26
27impl MigrationResult {
28    pub fn new(from_version: u32, to_version: u32, backup_path: Option<PathBuf>) -> Self {
29        Self {
30            migrated: from_version != to_version,
31            from_version,
32            to_version,
33            backup_path,
34        }
35    }
36
37    pub fn no_migration(version: u32) -> Self {
38        Self {
39            migrated: false,
40            from_version: version,
41            to_version: version,
42            backup_path: None,
43        }
44    }
45}
46
47/// Migrate `config` to [`LATEST_CONFIG_VERSION`] in place.
48///
49/// Creates a timestamped backup at `config_path` (when provided) before
50/// applying any changes. Migration steps are applied sequentially; if any
51/// step fails the function returns early with a partial result and logs the
52/// error — the config is left in the partially-migrated state.
53pub fn migrate_to_latest(config: &mut PawanConfig, config_path: Option<&PathBuf>) -> MigrationResult {
54    let current_version = config.config_version;
55
56    if current_version >= LATEST_CONFIG_VERSION {
57        return MigrationResult::no_migration(current_version);
58    }
59
60    let backup_path = config_path.and_then(|path| create_backup(path).ok());
61
62    let mut version = current_version;
63    while version < LATEST_CONFIG_VERSION {
64        version = match apply_migration(config, version + 1) {
65            Ok(v) => v,
66            Err(e) => {
67                tracing::error!(
68                    from_version = version,
69                    to_version = LATEST_CONFIG_VERSION,
70                    error = %e,
71                    "Config migration failed"
72                );
73                return MigrationResult::new(current_version, version, backup_path);
74            }
75        };
76    }
77
78    config.config_version = LATEST_CONFIG_VERSION;
79    MigrationResult::new(current_version, LATEST_CONFIG_VERSION, backup_path)
80}
81
82/// Save `config` to `path` as pretty-printed TOML.
83pub fn save_config(config: &PawanConfig, path: &PathBuf) -> Result<(), String> {
84    let toml_string = toml::to_string_pretty(config)
85        .map_err(|e| format!("Failed to serialize config to TOML: {}", e))?;
86
87    std::fs::write(path, toml_string)
88        .map_err(|e| format!("Failed to write config to {}: {}", path.display(), e))?;
89
90    tracing::info!(path = %path.display(), "Config saved");
91    Ok(())
92}
93
94// ---------------------------------------------------------------------------
95// Internal migration steps
96// ---------------------------------------------------------------------------
97
98fn apply_migration(config: &mut PawanConfig, target_version: u32) -> Result<u32, String> {
99    match target_version {
100        1 => migrate_to_v1(config),
101        _ => Err(format!("Unknown target version: {}", target_version)),
102    }
103}
104
105/// v0 → v1: adds `config_version`, `tool_call_idle_timeout_secs`, and
106/// optional skill/local-inference fields (all handled by serde defaults).
107pub(super) fn migrate_to_v1(config: &mut PawanConfig) -> Result<u32, String> {
108    config.config_version = 1;
109    if config.tool_call_idle_timeout_secs == 0 {
110        config.tool_call_idle_timeout_secs = default_tool_idle_timeout();
111    }
112    tracing::info!("Config migrated to version 1");
113    Ok(1)
114}
115
116fn create_backup(config_path: &PathBuf) -> Result<PathBuf, String> {
117    let timestamp = Utc::now().format("%Y%m%d_%H%M%S");
118    let backup_path = config_path.with_extension(format!("toml.backup.{}", timestamp));
119
120    std::fs::copy(config_path, &backup_path)
121        .map_err(|e| format!("Failed to create backup at {}: {}", backup_path.display(), e))?;
122
123    tracing::info!(backup = %backup_path.display(), "Config backup created");
124    Ok(backup_path)
125}