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