Skip to main content

sqry_cli/persistence/
types.rs

1//! Core types for query persistence.
2//!
3//! This module defines the data structures used for storing saved queries (aliases)
4//! and query history. All types are designed for serialization with both postcard
5//! (primary index storage) and `serde_json` (export/import).
6
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::PathBuf;
11
12/// Current version of the user metadata format.
13/// Increment when making breaking changes to the schema.
14pub const USER_METADATA_VERSION: u32 = 1;
15
16/// Storage scope for aliases.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
18pub enum StorageScope {
19    /// Global storage (~/.config/sqry/global.index.user)
20    Global,
21    /// Project-local storage (.sqry-index.user in project root)
22    Local,
23}
24
25impl std::fmt::Display for StorageScope {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        match self {
28            StorageScope::Global => write!(f, "global"),
29            StorageScope::Local => write!(f, "local"),
30        }
31    }
32}
33
34/// A saved query alias.
35///
36/// Aliases allow users to save frequently used queries for quick reuse.
37/// Each alias maps a short name to a full command with arguments.
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct SavedAlias {
40    /// The base command (e.g., "search", "query")
41    pub command: String,
42
43    /// Command arguments (e.g., `["kind:function", "--lang", "rust"]`)
44    pub args: Vec<String>,
45
46    /// When this alias was created
47    pub created: DateTime<Utc>,
48
49    /// Optional description for the alias
50    pub description: Option<String>,
51}
52
53/// A single entry in the query history.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct HistoryEntry {
56    /// Unique ID assigned when the entry is recorded
57    pub id: u64,
58
59    /// When the query was executed
60    pub timestamp: DateTime<Utc>,
61
62    /// The command that was run (e.g., "search", "query")
63    pub command: String,
64
65    /// Command arguments
66    pub args: Vec<String>,
67
68    /// Working directory when the query was executed
69    pub working_dir: PathBuf,
70
71    /// Whether the query succeeded
72    pub success: bool,
73
74    /// Query execution time in milliseconds
75    pub duration_ms: Option<u64>,
76}
77
78/// History state stored in the user metadata index.
79#[derive(Debug, Clone, Default, Serialize, Deserialize)]
80pub struct HistoryState {
81    /// History entries (most recent last for efficient appending)
82    pub entries: Vec<HistoryEntry>,
83
84    /// Next ID to assign
85    pub next_id: u64,
86}
87
88/// Root structure for user metadata stored in the index.
89///
90/// This is the top-level structure that gets serialized to `.sqry-index.user`
91/// (local) or `global.index.user` (global).
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct UserMetadata {
94    /// Schema version for forward compatibility
95    pub version: u32,
96
97    /// Saved query aliases by name
98    pub aliases: HashMap<String, SavedAlias>,
99
100    /// Query history
101    pub history: HistoryState,
102}
103
104impl Default for UserMetadata {
105    fn default() -> Self {
106        Self {
107            version: USER_METADATA_VERSION,
108            aliases: HashMap::new(),
109            history: HistoryState::default(),
110        }
111    }
112}
113
114/// JSON export format for aliases (for backup/restore via ).
115#[derive(Debug, Clone, Serialize, Deserialize)]
116pub struct AliasExportFile {
117    /// Export format version
118    pub version: u32,
119
120    /// When this export was created
121    pub exported_at: DateTime<Utc>,
122
123    /// The exported aliases
124    pub aliases: HashMap<String, SavedAlias>,
125}
126
127impl AliasExportFile {
128    /// Create an export file from a list of aliases.
129    #[must_use]
130    pub fn from_aliases(aliases: &[AliasWithScope]) -> Self {
131        let mut map = HashMap::new();
132        for aws in aliases {
133            map.insert(aws.name.clone(), aws.alias.clone());
134        }
135        Self {
136            version: 1,
137            exported_at: Utc::now(),
138            aliases: map,
139        }
140    }
141}
142
143/// Import conflict resolution strategies.
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
145pub enum ImportConflictStrategy {
146    /// Skip aliases that already exist
147    Skip,
148    /// Overwrite existing aliases
149    Overwrite,
150    /// Fail if any conflict is detected
151    Fail,
152}
153
154/// Result of an import operation.
155#[derive(Debug, Clone)]
156pub struct ImportResult {
157    /// Number of aliases successfully imported
158    pub imported: usize,
159    /// Number of aliases skipped due to conflicts
160    pub skipped: usize,
161    /// Number of aliases that failed to import
162    pub failed: usize,
163    /// Number of aliases overwritten
164    pub overwritten: usize,
165    /// Names of aliases that were skipped
166    pub skipped_names: Vec<String>,
167}
168
169/// Alias with its storage scope for listing.
170#[derive(Debug, Clone)]
171pub struct AliasWithScope {
172    /// The alias name
173    pub name: String,
174    /// The alias data
175    pub alias: SavedAlias,
176    /// Where the alias is stored
177    pub scope: StorageScope,
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn test_saved_alias_serialization() {
186        let alias = SavedAlias {
187            command: "query".to_string(),
188            args: vec![
189                "kind:function".to_string(),
190                "--lang".to_string(),
191                "rust".to_string(),
192            ],
193            created: Utc::now(),
194            description: Some("Find Rust functions".to_string()),
195        };
196
197        // Test postcard serialization
198        let bytes = postcard::to_allocvec(&alias).expect("postcard serialize");
199        let deserialized: SavedAlias = postcard::from_bytes(&bytes).expect("postcard deserialize");
200
201        assert_eq!(alias.command, deserialized.command);
202        assert_eq!(alias.args, deserialized.args);
203        assert_eq!(alias.description, deserialized.description);
204    }
205
206    #[test]
207    fn test_history_entry_serialization() {
208        let entry = HistoryEntry {
209            id: 42,
210            timestamp: Utc::now(),
211            command: "search".to_string(),
212            args: vec!["main".to_string()],
213            working_dir: PathBuf::from("/home/user/project"),
214            success: true,
215            duration_ms: Some(150),
216        };
217
218        let bytes = postcard::to_allocvec(&entry).expect("postcard serialize");
219        let deserialized: HistoryEntry =
220            postcard::from_bytes(&bytes).expect("postcard deserialize");
221
222        assert_eq!(entry.id, deserialized.id);
223        assert_eq!(entry.command, deserialized.command);
224        assert_eq!(entry.args, deserialized.args);
225        assert_eq!(entry.success, deserialized.success);
226    }
227
228    #[test]
229    fn test_user_metadata_default() {
230        let metadata = UserMetadata::default();
231
232        assert_eq!(metadata.version, USER_METADATA_VERSION);
233        assert!(metadata.aliases.is_empty());
234        assert!(metadata.history.entries.is_empty());
235        assert_eq!(metadata.history.next_id, 0);
236    }
237
238    #[test]
239    fn test_user_metadata_serialization() {
240        let mut metadata = UserMetadata::default();
241        metadata.aliases.insert(
242            "test".to_string(),
243            SavedAlias {
244                command: "search".to_string(),
245                args: vec!["pattern".to_string()],
246                created: Utc::now(),
247                description: None,
248            },
249        );
250        metadata.history.entries.push(HistoryEntry {
251            id: 1,
252            timestamp: Utc::now(),
253            command: "query".to_string(),
254            args: vec!["kind:function".to_string()],
255            working_dir: PathBuf::from("/tmp"),
256            success: true,
257            duration_ms: Some(50),
258        });
259        metadata.history.next_id = 2;
260
261        let bytes = postcard::to_allocvec(&metadata).expect("postcard serialize");
262        let deserialized: UserMetadata =
263            postcard::from_bytes(&bytes).expect("postcard deserialize");
264
265        assert_eq!(deserialized.version, USER_METADATA_VERSION);
266        assert!(deserialized.aliases.contains_key("test"));
267        assert_eq!(deserialized.history.entries.len(), 1);
268        assert_eq!(deserialized.history.next_id, 2);
269    }
270
271    #[test]
272    fn test_storage_scope_display() {
273        assert_eq!(format!("{}", StorageScope::Global), "global");
274        assert_eq!(format!("{}", StorageScope::Local), "local");
275    }
276
277    #[test]
278    fn test_alias_export_file_json() {
279        let mut aliases = HashMap::new();
280        aliases.insert(
281            "test".to_string(),
282            SavedAlias {
283                command: "search".to_string(),
284                args: vec!["main".to_string()],
285                created: Utc::now(),
286                description: None,
287            },
288        );
289
290        let export = AliasExportFile {
291            version: 1,
292            exported_at: Utc::now(),
293            aliases,
294        };
295
296        let json = serde_json::to_string(&export).expect("json serialize");
297        let deserialized: AliasExportFile = serde_json::from_str(&json).expect("json deserialize");
298
299        assert_eq!(deserialized.version, 1);
300        assert!(deserialized.aliases.contains_key("test"));
301    }
302}