gestura_core_sessions/agent_sessions/
legacy_gui_migration.rs

1//! Legacy GUI session migration + sanitization.
2//!
3//! The GUI previously persisted all sessions in a single JSON file at:
4//! `~/.gestura/gui_sessions.json`.
5//!
6//! As part of the Core-First migration, session persistence is unified under
7//! [`super::FileAgentSessionStore`], which stores one JSON file per session in
8//! `~/.gestura/agent_sessions/`.
9//!
10//! This module keeps the **business logic** for:
11//! - locating the legacy file
12//! - migrating legacy sessions into the core store
13//! - sanitizing persisted session overrides
14
15use chrono::{DateTime, Utc};
16use serde::{Deserialize, Serialize};
17use std::path::{Path, PathBuf};
18
19use super::{AgentSession, AgentSessionStore, SessionState};
20
21/// Returns the Gestura data directory (`~/.gestura/`).
22fn gestura_data_dir() -> PathBuf {
23    super::gestura_home_dir().join(".gestura")
24}
25
26/// Returns the path for the legacy GUI session history file: `~/.gestura/gui_sessions.json`.
27pub fn legacy_gui_sessions_file_path() -> PathBuf {
28    gestura_data_dir().join("gui_sessions.json")
29}
30
31/// Sanitize potentially invalid persisted (provider, model) overrides.
32///
33/// This is defensive against historic bug states such as `provider=openai` with a non-OpenAI
34/// model value.
35///
36/// The `is_model_compatible` function is injected by the caller to avoid coupling this
37/// crate to a specific validation implementation.
38///
39/// Returns `true` if any repair was applied.
40pub fn sanitize_session_llm_override(
41    session_id: &str,
42    state: &mut SessionState,
43    global_llm_provider: &str,
44    is_model_compatible: impl Fn(&str, &str) -> bool,
45) -> bool {
46    let Some(ref mut llm_cfg) = state.llm_config else {
47        return false;
48    };
49
50    let mut repaired = false;
51    if let Some(ref model) = llm_cfg.model {
52        let effective_provider = llm_cfg.provider.as_deref().unwrap_or(global_llm_provider);
53        if !is_model_compatible(effective_provider, model) {
54            tracing::warn!(
55                session_id = %session_id,
56                provider = %effective_provider,
57                model = %model,
58                "Clearing incompatible persisted session LLM model override"
59            );
60            llm_cfg.model = None;
61            repaired = true;
62        }
63    }
64
65    // If both fields are now empty, clear the override container.
66    if llm_cfg.provider.is_none() && llm_cfg.model.is_none() {
67        state.llm_config = None;
68    }
69
70    repaired
71}
72
73/// One-time migration from the legacy `gui_sessions.json` file into the unified core store.
74///
75/// Uses [`legacy_gui_sessions_file_path`] and delegates to
76/// [`migrate_legacy_gui_sessions_to_core_at_path`].
77///
78/// Returns the migrated core sessions if migration succeeded (even partially).
79pub fn migrate_legacy_gui_sessions_to_core<S: AgentSessionStore>(
80    store: &S,
81    global_llm_provider: &str,
82    is_model_compatible: impl Fn(&str, &str) -> bool,
83) -> Vec<AgentSession> {
84    migrate_legacy_gui_sessions_to_core_at_path(
85        store,
86        global_llm_provider,
87        &legacy_gui_sessions_file_path(),
88        &is_model_compatible,
89    )
90}
91
92/// One-time migration from a legacy GUI sessions file into the unified core store.
93///
94/// Returns the migrated core sessions if migration succeeded (even partially).
95pub fn migrate_legacy_gui_sessions_to_core_at_path<S: AgentSessionStore>(
96    store: &S,
97    global_llm_provider: &str,
98    path: &Path,
99    is_model_compatible: &dyn Fn(&str, &str) -> bool,
100) -> Vec<AgentSession> {
101    if !path.exists() {
102        return Vec::new();
103    }
104
105    let mut migrated = Vec::new();
106    match std::fs::read_to_string(path) {
107        Ok(json) => match serde_json::from_str::<LegacyPersistedSessions>(&json) {
108            Ok(persisted) => {
109                for mut session in persisted.sessions {
110                    // Windows don't survive app restart.
111                    session.is_open = false;
112                    session.window_label = None;
113                    session.message_count = session.state.messages.len();
114
115                    // Sanitize LLM overrides before persisting.
116                    let _ = sanitize_session_llm_override(
117                        &session.id,
118                        &mut session.state,
119                        global_llm_provider,
120                        is_model_compatible,
121                    );
122
123                    let model = session
124                        .state
125                        .llm_config
126                        .as_ref()
127                        .and_then(|cfg| cfg.model.clone());
128                    let core_session = AgentSession {
129                        id: session.id,
130                        title: session.title,
131                        created_at: session.created_at,
132                        last_active: session.last_active,
133                        model,
134                        state: session.state,
135                    };
136
137                    match store.save(&core_session) {
138                        Ok(()) => migrated.push(core_session),
139                        Err(e) => tracing::warn!(
140                            session_id = %core_session.id,
141                            error = %e,
142                            "Failed to migrate legacy GUI session to core store"
143                        ),
144                    }
145                }
146
147                // Best-effort cleanup: remove legacy file after migration.
148                if let Err(e) = std::fs::remove_file(path) {
149                    tracing::debug!(
150                        error = %e,
151                        path = %path.display(),
152                        "Failed to remove legacy sessions file"
153                    );
154                }
155            }
156            Err(e) => {
157                tracing::warn!(
158                    error = %e,
159                    path = %path.display(),
160                    "Failed to parse legacy GUI sessions file"
161                );
162            }
163        },
164        Err(e) => {
165            tracing::warn!(
166                error = %e,
167                path = %path.display(),
168                "Failed to read legacy GUI sessions file"
169            );
170        }
171    }
172
173    migrated
174}
175
176/// Legacy persisted session data container (single JSON file).
177#[derive(Debug, Clone, Serialize, Deserialize, Default)]
178#[serde(default)]
179struct LegacyPersistedSessions {
180    sessions: Vec<LegacyGuiAgentSession>,
181    version: u32,
182}
183
184/// Legacy GUI session view-model as persisted historically by the desktop app.
185///
186/// Note: only used for reading the legacy file; new persistence uses core [`AgentSession`].
187#[derive(Debug, Clone, Serialize, Deserialize, Default)]
188#[serde(default)]
189struct LegacyGuiAgentSession {
190    id: String,
191    title: String,
192    created_at: DateTime<Utc>,
193    last_active: DateTime<Utc>,
194    is_open: bool,
195    window_label: Option<String>,
196    message_count: usize,
197    #[serde(default)]
198    state: SessionState,
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::agent_sessions::FileAgentSessionStore;
205
206    /// Stub validation function for tests.
207    fn always_incompatible(_provider: &str, _model: &str) -> bool {
208        false
209    }
210
211    /// Stub validation function that always reports compatible.
212    fn always_compatible(_provider: &str, _model: &str) -> bool {
213        true
214    }
215
216    #[test]
217    fn sanitize_clears_incompatible_model_override_and_prunes_empty_container() {
218        let mut state = SessionState {
219            llm_config: Some(crate::agent_sessions::SessionLlmConfig {
220                provider: Some("openai".to_string()),
221                model: Some("claude-3-5-sonnet-20241022".to_string()),
222            }),
223            ..Default::default()
224        };
225
226        let repaired =
227            sanitize_session_llm_override("s1", &mut state, "openai", always_incompatible);
228        assert!(repaired);
229        assert_eq!(
230            state.llm_config.as_ref().unwrap().provider.as_deref(),
231            Some("openai")
232        );
233        assert_eq!(state.llm_config.as_ref().unwrap().model, None);
234
235        // Now clear provider too; container should be pruned.
236        state.llm_config = Some(crate::agent_sessions::SessionLlmConfig {
237            provider: None,
238            model: Some("gpt-4o".to_string()),
239        });
240        let repaired =
241            sanitize_session_llm_override("s2", &mut state, "anthropic", always_incompatible);
242        assert!(repaired);
243        assert!(state.llm_config.is_none());
244    }
245
246    #[test]
247    fn migrate_returns_empty_when_legacy_file_missing() {
248        let temp = tempfile::tempdir().unwrap();
249        let store = FileAgentSessionStore::new(temp.path().join("store"));
250        let missing = temp.path().join("gui_sessions.json");
251
252        let migrated = migrate_legacy_gui_sessions_to_core_at_path(
253            &store,
254            "openai",
255            &missing,
256            &always_compatible,
257        );
258        assert!(migrated.is_empty());
259    }
260
261    #[test]
262    fn migrate_saves_sessions_and_removes_legacy_file() {
263        let temp = tempfile::tempdir().unwrap();
264        let store_dir = temp.path().join("store");
265        let store = FileAgentSessionStore::new(store_dir);
266        let legacy_path = temp.path().join("gui_sessions.json");
267
268        let legacy = LegacyPersistedSessions {
269            sessions: vec![LegacyGuiAgentSession {
270                id: "abc".to_string(),
271                title: "Hello".to_string(),
272                created_at: Utc::now(),
273                last_active: Utc::now(),
274                is_open: true,
275                window_label: Some("agent-abc".to_string()),
276                message_count: 123,
277                state: SessionState::default(),
278            }],
279            version: 1,
280        };
281
282        std::fs::write(&legacy_path, serde_json::to_string(&legacy).unwrap()).unwrap();
283
284        let migrated = migrate_legacy_gui_sessions_to_core_at_path(
285            &store,
286            "openai",
287            &legacy_path,
288            &always_compatible,
289        );
290        assert_eq!(migrated.len(), 1);
291        assert_eq!(migrated[0].id, "abc");
292        assert!(!legacy_path.exists());
293
294        // Confirm it was persisted into the store.
295        let loaded = store.load("abc").unwrap();
296        assert_eq!(loaded.title, "Hello");
297    }
298}