Skip to main content

rec/session/
resolve.rs

1//! Session resolution by name-first, UUID-fallback.
2//!
3//! Provides a reusable helper to resolve a session identifier (which may be
4//! a human-readable name or a UUID) to a loaded `Session`. Used by all
5//! session-accepting commands (replay, show, delete, rename, edit, tag, export).
6
7use crate::error::RecError;
8use crate::models::Session;
9use crate::session::fuzzy;
10use crate::storage::{AliasStore, SessionStore};
11
12/// Error details when session resolution fails, including fuzzy suggestions.
13#[derive(Debug)]
14pub struct ResolveError {
15    /// The underlying error.
16    pub error: RecError,
17    /// Fuzzy suggestions for similar session names (empty if none found).
18    pub suggestions: Vec<fuzzy::Suggestion>,
19}
20
21/// Resolve a session identifier to a loaded `Session`.
22///
23/// Resolution order:
24/// 1. Scan all sessions for a name match (collecting names for fuzzy fallback)
25/// 2. If exactly one name match: return it
26/// 3. If no name match: try UUID lookup via `store.load(identifier)`
27/// 4. If no UUID match either: return `ResolveError` with fuzzy suggestions
28/// 5. If multiple name matches AND `interactive`: enhanced `dialoguer::Select`
29///    with date + command count
30/// 6. If multiple name matches AND NOT `interactive`: pick most recent by
31///    `started_at` (deterministic duplicate resolution)
32///
33/// # Errors
34///
35/// Returns `ResolveError` containing a `RecError` and optional fuzzy suggestions.
36///
37/// # Panics
38///
39/// Panics if internal iterator operations produce an empty collection when
40/// a non-empty result is expected (should not happen due to match guards).
41pub fn resolve_session(
42    store: &SessionStore,
43    identifier: &str,
44    interactive: bool,
45) -> std::result::Result<Session, ResolveError> {
46    let all_ids = store.list().map_err(|e| ResolveError {
47        error: e,
48        suggestions: Vec::new(),
49    })?;
50
51    // Search by name, collecting all (name, uuid) pairs for fuzzy fallback
52    let mut name_matches = Vec::new();
53    let mut all_names: Vec<(String, String)> = Vec::new();
54    for id in &all_ids {
55        if let Ok(s) = store.load(id) {
56            all_names.push((s.name().to_string(), id.clone()));
57            if s.name() == identifier {
58                name_matches.push(s);
59            }
60        }
61    }
62
63    match name_matches.len() {
64        1 => Ok(name_matches.into_iter().next().unwrap()),
65        0 => {
66            // Try UUID lookup
67            if let Ok(s) = store.load(identifier) {
68                Ok(s)
69            } else {
70                // UUID lookup also failed — compute fuzzy suggestions
71                let suggestions = fuzzy::suggest_sessions(identifier, &all_names, 3);
72                Err(ResolveError {
73                    error: RecError::SessionNotFound(identifier.to_string()),
74                    suggestions,
75                })
76            }
77        }
78        _ => {
79            // Multiple matches
80            if interactive {
81                let items: Vec<String> = name_matches
82                    .iter()
83                    .map(|s| {
84                        let date = chrono::DateTime::from_timestamp(s.header.started_at as i64, 0)
85                            .map_or_else(
86                                || "unknown".to_string(),
87                                |dt| {
88                                    let local: chrono::DateTime<chrono::Local> = dt.into();
89                                    local.format("%b %d").to_string()
90                                },
91                            );
92                        let cmd_count = s.commands.len();
93                        format!("{} ({}, {} commands)", s.name(), date, cmd_count)
94                    })
95                    .collect();
96
97                let selection = dialoguer::Select::new()
98                    .with_prompt("Multiple sessions found. Select one")
99                    .items(&items)
100                    .default(0)
101                    .interact()
102                    .map_err(|_| ResolveError {
103                        error: RecError::InvalidSession("Session selection cancelled".to_string()),
104                        suggestions: Vec::new(),
105                    })?;
106
107                Ok(name_matches.into_iter().nth(selection).unwrap())
108            } else {
109                // Non-interactive: pick most recent by started_at
110                name_matches.sort_by(|a, b| {
111                    b.header
112                        .started_at
113                        .partial_cmp(&a.header.started_at)
114                        .unwrap_or(std::cmp::Ordering::Equal)
115                });
116                Ok(name_matches.into_iter().next().unwrap())
117            }
118        }
119    }
120}
121
122/// Resolve a session identifier with alias lookup.
123///
124/// Resolution order:
125/// 1. Check alias store -- if alias exists, resolve the aliased target
126/// 2. Fall through to normal resolution (name -> UUID)
127///
128/// This is the preferred entry point for all commands that accept
129/// a session identifier. The non-alias version `resolve_session`
130/// remains available for internal use.
131///
132/// # Errors
133///
134/// Returns `ResolveError` if the session cannot be found by alias, name, or UUID.
135pub fn resolve_session_with_alias(
136    store: &SessionStore,
137    alias_store: &AliasStore,
138    identifier: &str,
139    interactive: bool,
140) -> std::result::Result<Session, ResolveError> {
141    // Step 1: Check aliases
142    if let Ok(Some(target)) = alias_store.get(identifier) {
143        // Resolve the aliased target through normal resolution
144        return resolve_session(store, &target, interactive);
145    }
146
147    // Step 2: Normal resolution (name -> UUID)
148    resolve_session(store, identifier, interactive)
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154    use crate::models::{Command, SessionStatus};
155    use crate::storage::Paths;
156    use std::path::PathBuf;
157    use tempfile::TempDir;
158
159    fn create_test_paths(temp_dir: &TempDir) -> Paths {
160        Paths {
161            data_dir: temp_dir.path().join("sessions"),
162            config_dir: temp_dir.path().join("config"),
163            config_file: temp_dir.path().join("config").join("config.toml"),
164            state_dir: temp_dir.path().join("state"),
165        }
166    }
167
168    fn create_test_session(name: &str) -> Session {
169        let mut session = Session::new(name);
170        session.commands.push(Command::new(
171            0,
172            "echo hello".to_string(),
173            PathBuf::from("/tmp"),
174        ));
175        session.complete(SessionStatus::Completed);
176        session
177    }
178
179    /// Create a test session with a specific `started_at` timestamp.
180    fn create_test_session_at(name: &str, started_at: f64) -> Session {
181        let mut session = create_test_session(name);
182        session.header.started_at = started_at;
183        session
184    }
185
186    #[test]
187    fn test_resolve_by_name() {
188        let temp_dir = TempDir::new().unwrap();
189        let paths = create_test_paths(&temp_dir);
190        let store = SessionStore::new(paths);
191
192        let session = create_test_session("my-session");
193        store.save(&session).unwrap();
194
195        let resolved = resolve_session(&store, "my-session", false).unwrap();
196        assert_eq!(resolved.name(), "my-session");
197        assert_eq!(resolved.id(), session.id());
198    }
199
200    #[test]
201    fn test_resolve_by_uuid() {
202        let temp_dir = TempDir::new().unwrap();
203        let paths = create_test_paths(&temp_dir);
204        let store = SessionStore::new(paths);
205
206        let session = create_test_session("my-session");
207        let id = session.id().to_string();
208        store.save(&session).unwrap();
209
210        let resolved = resolve_session(&store, &id, false).unwrap();
211        assert_eq!(resolved.name(), "my-session");
212        assert_eq!(resolved.id().to_string(), id);
213    }
214
215    #[test]
216    fn test_resolve_not_found() {
217        let temp_dir = TempDir::new().unwrap();
218        let paths = create_test_paths(&temp_dir);
219        let store = SessionStore::new(paths);
220
221        let result = resolve_session(&store, "nonexistent", false);
222        assert!(result.is_err());
223        let resolve_err = result.unwrap_err();
224        match &resolve_err.error {
225            RecError::SessionNotFound(name) => assert_eq!(name, "nonexistent"),
226            _ => panic!(
227                "Expected SessionNotFound error, got: {:?}",
228                resolve_err.error
229            ),
230        }
231    }
232
233    #[test]
234    fn test_resolve_multiple_matches_non_interactive_picks_most_recent() {
235        let temp_dir = TempDir::new().unwrap();
236        let paths = create_test_paths(&temp_dir);
237        let store = SessionStore::new(paths);
238
239        // Create two sessions with the same name at different times
240        let older = create_test_session_at("duplicate", 1000.0);
241        let newer = create_test_session_at("duplicate", 2000.0);
242        let newer_id = newer.id();
243        store.save(&older).unwrap();
244        store.save(&newer).unwrap();
245
246        // Non-interactive should pick the most recent (newer)
247        let result = resolve_session(&store, "duplicate", false);
248        assert!(result.is_ok(), "Expected Ok, got: {:?}", result.err());
249        let resolved = result.unwrap();
250        assert_eq!(resolved.id(), newer_id);
251        assert_eq!(resolved.name(), "duplicate");
252    }
253
254    #[test]
255    fn test_resolve_not_found_with_fuzzy_suggestions() {
256        let temp_dir = TempDir::new().unwrap();
257        let paths = create_test_paths(&temp_dir);
258        let store = SessionStore::new(paths);
259
260        // Create a session with a similar name
261        let session = create_test_session("deploy-v1");
262        store.save(&session).unwrap();
263
264        // Search for a typo
265        let result = resolve_session(&store, "deply-v1", false);
266        assert!(result.is_err());
267        let resolve_err = result.unwrap_err();
268        match &resolve_err.error {
269            RecError::SessionNotFound(name) => assert_eq!(name, "deply-v1"),
270            _ => panic!("Expected SessionNotFound error"),
271        }
272        assert!(
273            !resolve_err.suggestions.is_empty(),
274            "Expected fuzzy suggestions for typo"
275        );
276        assert_eq!(resolve_err.suggestions[0].name, "deploy-v1");
277    }
278
279    #[test]
280    fn test_resolve_not_found_no_suggestions() {
281        let temp_dir = TempDir::new().unwrap();
282        let paths = create_test_paths(&temp_dir);
283        let store = SessionStore::new(paths);
284
285        // Create a session with a very different name
286        let session = create_test_session("deploy-v1");
287        store.save(&session).unwrap();
288
289        // Search for something completely unrelated
290        let result = resolve_session(&store, "zzzzzzzzz", false);
291        assert!(result.is_err());
292        let resolve_err = result.unwrap_err();
293        match &resolve_err.error {
294            RecError::SessionNotFound(name) => assert_eq!(name, "zzzzzzzzz"),
295            _ => panic!("Expected SessionNotFound error"),
296        }
297        assert!(
298            resolve_err.suggestions.is_empty(),
299            "Expected no suggestions for completely unrelated query"
300        );
301    }
302
303    #[test]
304    fn test_resolve_with_alias() {
305        let temp_dir = TempDir::new().unwrap();
306        let paths = create_test_paths(&temp_dir);
307        let store = SessionStore::new(paths.clone());
308
309        let session = create_test_session("prod-deploy-v3");
310        let session_id = session.id();
311        store.save(&session).unwrap();
312
313        // Create alias pointing to the session name
314        let alias_store = AliasStore::new(&paths);
315        alias_store.set("deploy", "prod-deploy-v3").unwrap();
316
317        // Resolve by alias name
318        let resolved = resolve_session_with_alias(&store, &alias_store, "deploy", false).unwrap();
319        assert_eq!(resolved.name(), "prod-deploy-v3");
320        assert_eq!(resolved.id(), session_id);
321    }
322
323    #[test]
324    fn test_resolve_alias_not_found_falls_through() {
325        let temp_dir = TempDir::new().unwrap();
326        let paths = create_test_paths(&temp_dir);
327        let store = SessionStore::new(paths.clone());
328
329        let session = create_test_session("my-session");
330        store.save(&session).unwrap();
331
332        // No alias set — should fall through to normal name resolution
333        let alias_store = AliasStore::new(&paths);
334        let resolved =
335            resolve_session_with_alias(&store, &alias_store, "my-session", false).unwrap();
336        assert_eq!(resolved.name(), "my-session");
337    }
338
339    #[test]
340    fn test_resolve_alias_target_not_found() {
341        let temp_dir = TempDir::new().unwrap();
342        let paths = create_test_paths(&temp_dir);
343        let store = SessionStore::new(paths.clone());
344
345        // Create alias pointing to nonexistent session
346        let alias_store = AliasStore::new(&paths);
347        alias_store.set("broken", "nonexistent-session").unwrap();
348
349        let result = resolve_session_with_alias(&store, &alias_store, "broken", false);
350        assert!(result.is_err());
351        let resolve_err = result.unwrap_err();
352        match &resolve_err.error {
353            RecError::SessionNotFound(name) => assert_eq!(name, "nonexistent-session"),
354            _ => panic!(
355                "Expected SessionNotFound error, got: {:?}",
356                resolve_err.error
357            ),
358        }
359    }
360}