Skip to main content

entrenar/storage/sqlite/
queries.rs

1//! Query operations for SQLite Backend.
2//!
3//! Contains search, list, and parameter filtering methods.
4
5use super::backend::SqliteBackend;
6use super::types::{Experiment, FilterOp, ParamFilter, ParameterValue, Run};
7use crate::storage::{Result, RunStatus, StorageError};
8use chrono::{DateTime, Utc};
9use rusqlite::params;
10use std::collections::HashMap;
11
12/// Parse a RunStatus from its stored string
13fn str_to_status(s: &str) -> RunStatus {
14    match s {
15        "pending" => RunStatus::Pending,
16        "running" => RunStatus::Running,
17        "completed" => RunStatus::Success,
18        "failed" => RunStatus::Failed,
19        "cancelled" => RunStatus::Cancelled,
20        _ => RunStatus::Failed,
21    }
22}
23
24/// Parse an RFC3339 timestamp string, falling back to now
25fn parse_timestamp(s: &str) -> DateTime<Utc> {
26    s.parse().unwrap_or_else(|_| Utc::now())
27}
28
29impl SqliteBackend {
30    /// Log a parameter for a run
31    pub fn log_param(&self, run_id: &str, key: &str, value: ParameterValue) -> Result<()> {
32        let conn = self.lock_conn()?;
33
34        // Verify run exists
35        let exists: bool = conn
36            .query_row("SELECT EXISTS(SELECT 1 FROM runs WHERE id = ?1)", [run_id], |row| {
37                row.get(0)
38            })
39            .map_err(|e| StorageError::Backend(format!("Failed to check run: {e}")))?;
40
41        if !exists {
42            return Err(StorageError::RunNotFound(run_id.to_string()));
43        }
44
45        let value_json = value.to_json();
46        let type_name = value.type_name();
47
48        conn.execute(
49            "INSERT OR REPLACE INTO params (run_id, key, value, type) VALUES (?1, ?2, ?3, ?4)",
50            params![run_id, key, value_json, type_name],
51        )
52        .map_err(|e| StorageError::Backend(format!("Failed to log param: {e}")))?;
53
54        Ok(())
55    }
56
57    /// Log multiple parameters for a run
58    pub fn log_params(
59        &self,
60        run_id: &str,
61        params_map: HashMap<String, ParameterValue>,
62    ) -> Result<()> {
63        let conn = self.lock_conn()?;
64
65        // Verify run exists
66        let exists: bool = conn
67            .query_row("SELECT EXISTS(SELECT 1 FROM runs WHERE id = ?1)", [run_id], |row| {
68                row.get(0)
69            })
70            .map_err(|e| StorageError::Backend(format!("Failed to check run: {e}")))?;
71
72        if !exists {
73            return Err(StorageError::RunNotFound(run_id.to_string()));
74        }
75
76        for (key, value) in &params_map {
77            let value_json = value.to_json();
78            let type_name = value.type_name();
79
80            conn.execute(
81                "INSERT OR REPLACE INTO params (run_id, key, value, type) VALUES (?1, ?2, ?3, ?4)",
82                rusqlite::params![run_id, key, value_json, type_name],
83            )
84            .map_err(|e| StorageError::Backend(format!("Failed to log param: {e}")))?;
85        }
86
87        Ok(())
88    }
89
90    /// Get parameters for a run
91    pub fn get_params(&self, run_id: &str) -> Result<HashMap<String, ParameterValue>> {
92        let conn = self.lock_conn()?;
93
94        // Verify run exists
95        let exists: bool = conn
96            .query_row("SELECT EXISTS(SELECT 1 FROM runs WHERE id = ?1)", [run_id], |row| {
97                row.get(0)
98            })
99            .map_err(|e| StorageError::Backend(format!("Failed to check run: {e}")))?;
100
101        if !exists {
102            return Err(StorageError::RunNotFound(run_id.to_string()));
103        }
104
105        let mut stmt = conn
106            .prepare("SELECT key, value FROM params WHERE run_id = ?1")
107            .map_err(|e| StorageError::Backend(format!("Failed to prepare params query: {e}")))?;
108
109        let rows = stmt
110            .query_map([run_id], |row| {
111                let key: String = row.get(0)?;
112                let value_json: String = row.get(1)?;
113                Ok((key, value_json))
114            })
115            .map_err(|e| StorageError::Backend(format!("Failed to query params: {e}")))?;
116
117        let mut result = HashMap::new();
118        for row in rows {
119            let (key, value_json) =
120                row.map_err(|e| StorageError::Backend(format!("Failed to read param row: {e}")))?;
121            if let Some(value) = ParameterValue::from_json(&value_json) {
122                result.insert(key, value);
123            }
124        }
125
126        Ok(result)
127    }
128
129    /// Search runs by parameter filters
130    pub fn search_runs_by_params(&self, filters: &[ParamFilter]) -> Result<Vec<Run>> {
131        if filters.is_empty() {
132            // No filters — return all runs
133            return self.list_all_runs();
134        }
135
136        // Get all runs, then filter in Rust (same semantics as the HashMap version).
137        // The filter logic requires cross-type matching rules that are complex to
138        // express in SQL, so we fetch candidate runs and apply filters in memory.
139        let all_runs = self.list_all_runs()?;
140        let mut results = Vec::new();
141
142        for run in all_runs {
143            let run_params = self.get_params(&run.id)?;
144
145            let matches = filters.iter().all(|filter| {
146                if let Some(value) = run_params.get(&filter.key) {
147                    Self::param_matches(value, &filter.op, &filter.value)
148                } else {
149                    false
150                }
151            });
152
153            if matches {
154                results.push(run);
155            }
156        }
157
158        Ok(results)
159    }
160
161    /// Check if a parameter value matches a filter
162    fn param_matches(value: &ParameterValue, op: &FilterOp, filter_value: &ParameterValue) -> bool {
163        match (value, filter_value, op) {
164            // Float comparisons
165            (ParameterValue::Float(v), ParameterValue::Float(fv), FilterOp::Eq) => {
166                (v - fv).abs() < f64::EPSILON
167            }
168            (ParameterValue::Float(v), ParameterValue::Float(fv), FilterOp::Ne) => {
169                (v - fv).abs() >= f64::EPSILON
170            }
171            (ParameterValue::Float(v), ParameterValue::Float(fv), FilterOp::Gt) => v > fv,
172            (ParameterValue::Float(v), ParameterValue::Float(fv), FilterOp::Lt) => v < fv,
173            (ParameterValue::Float(v), ParameterValue::Float(fv), FilterOp::Gte) => v >= fv,
174            (ParameterValue::Float(v), ParameterValue::Float(fv), FilterOp::Lte) => v <= fv,
175
176            // Int comparisons
177            (ParameterValue::Int(v), ParameterValue::Int(fv), FilterOp::Eq) => v == fv,
178            (ParameterValue::Int(v), ParameterValue::Int(fv), FilterOp::Ne) => v != fv,
179            (ParameterValue::Int(v), ParameterValue::Int(fv), FilterOp::Gt) => v > fv,
180            (ParameterValue::Int(v), ParameterValue::Int(fv), FilterOp::Lt) => v < fv,
181            (ParameterValue::Int(v), ParameterValue::Int(fv), FilterOp::Gte) => v >= fv,
182            (ParameterValue::Int(v), ParameterValue::Int(fv), FilterOp::Lte) => v <= fv,
183
184            // String comparisons
185            (ParameterValue::String(v), ParameterValue::String(fv), FilterOp::Eq) => v == fv,
186            (ParameterValue::String(v), ParameterValue::String(fv), FilterOp::Ne) => v != fv,
187            (ParameterValue::String(v), ParameterValue::String(fv), FilterOp::Contains) => {
188                v.contains(fv.as_str())
189            }
190            (ParameterValue::String(v), ParameterValue::String(fv), FilterOp::StartsWith) => {
191                v.starts_with(fv.as_str())
192            }
193
194            // Bool comparisons
195            (ParameterValue::Bool(v), ParameterValue::Bool(fv), FilterOp::Eq) => v == fv,
196            (ParameterValue::Bool(v), ParameterValue::Bool(fv), FilterOp::Ne) => v != fv,
197
198            // Everything else (unsupported ops, cross-type, List/Dict) → false
199            _ => false,
200        }
201    }
202
203    /// Get an experiment by ID
204    pub fn get_experiment(&self, experiment_id: &str) -> Result<Experiment> {
205        let conn = self.lock_conn()?;
206
207        let row = conn
208            .query_row(
209                "SELECT id, name, description, config, tags, created_at, updated_at FROM experiments WHERE id = ?1",
210                [experiment_id],
211                |row| {
212                    let id: String = row.get(0)?;
213                    let name: String = row.get(1)?;
214                    let description: Option<String> = row.get(2)?;
215                    let config_str: Option<String> = row.get(3)?;
216                    let tags_str: Option<String> = row.get(4)?;
217                    let created_str: String = row.get(5)?;
218                    let updated_str: String = row.get(6)?;
219                    Ok((id, name, description, config_str, tags_str, created_str, updated_str))
220                },
221            )
222            .map_err(|e| match e {
223                rusqlite::Error::QueryReturnedNoRows => {
224                    StorageError::ExperimentNotFound(experiment_id.to_string())
225                }
226                _ => StorageError::Backend(format!("Failed to get experiment: {e}")),
227            })?;
228
229        let (id, name, description, config_str, tags_str, created_str, updated_str) = row;
230        let config = config_str.and_then(|s| serde_json::from_str(&s).ok());
231        let tags: HashMap<String, String> =
232            tags_str.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
233
234        Ok(Experiment {
235            id,
236            name,
237            description,
238            config,
239            tags,
240            created_at: parse_timestamp(&created_str),
241            updated_at: parse_timestamp(&updated_str),
242        })
243    }
244
245    /// Get a run by ID
246    pub fn get_run(&self, run_id: &str) -> Result<Run> {
247        self.query_run_by_id(run_id)
248    }
249
250    /// List all experiments
251    pub fn list_experiments(&self) -> Result<Vec<Experiment>> {
252        let conn = self.lock_conn()?;
253
254        let mut stmt = conn
255            .prepare("SELECT id, name, description, config, tags, created_at, updated_at FROM experiments ORDER BY created_at DESC")
256            .map_err(|e| StorageError::Backend(format!("Failed to prepare query: {e}")))?;
257
258        let rows = stmt
259            .query_map([], |row| {
260                let id: String = row.get(0)?;
261                let name: String = row.get(1)?;
262                let description: Option<String> = row.get(2)?;
263                let config_str: Option<String> = row.get(3)?;
264                let tags_str: Option<String> = row.get(4)?;
265                let created_str: String = row.get(5)?;
266                let updated_str: String = row.get(6)?;
267                Ok((id, name, description, config_str, tags_str, created_str, updated_str))
268            })
269            .map_err(|e| StorageError::Backend(format!("Failed to list experiments: {e}")))?;
270
271        let mut result = Vec::new();
272        for row in rows {
273            let (id, name, description, config_str, tags_str, created_str, updated_str) =
274                row.map_err(|e| StorageError::Backend(format!("Failed to read row: {e}")))?;
275            let config = config_str.and_then(|s| serde_json::from_str(&s).ok());
276            let tags: HashMap<String, String> =
277                tags_str.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
278            result.push(Experiment {
279                id,
280                name,
281                description,
282                config,
283                tags,
284                created_at: parse_timestamp(&created_str),
285                updated_at: parse_timestamp(&updated_str),
286            });
287        }
288
289        Ok(result)
290    }
291
292    /// List runs for an experiment
293    pub fn list_runs(&self, experiment_id: &str) -> Result<Vec<Run>> {
294        let conn = self.lock_conn()?;
295
296        // Verify experiment exists
297        let exists: bool = conn
298            .query_row(
299                "SELECT EXISTS(SELECT 1 FROM experiments WHERE id = ?1)",
300                [experiment_id],
301                |row| row.get(0),
302            )
303            .map_err(|e| StorageError::Backend(format!("Failed to check experiment: {e}")))?;
304
305        if !exists {
306            return Err(StorageError::ExperimentNotFound(experiment_id.to_string()));
307        }
308
309        let mut stmt = conn
310            .prepare("SELECT id, experiment_id, status, start_time, end_time, tags FROM runs WHERE experiment_id = ?1 ORDER BY start_time")
311            .map_err(|e| StorageError::Backend(format!("Failed to prepare query: {e}")))?;
312
313        Self::collect_runs_from_stmt(&mut stmt, params![experiment_id])
314    }
315
316    // ── Internal helpers ──────────────────────────────────────────────────
317
318    fn query_run_by_id(&self, run_id: &str) -> Result<Run> {
319        let conn = self.lock_conn()?;
320
321        let row = conn
322            .query_row(
323                "SELECT id, experiment_id, status, start_time, end_time, tags FROM runs WHERE id = ?1",
324                [run_id],
325                |row| {
326                    let id: String = row.get(0)?;
327                    let experiment_id: String = row.get(1)?;
328                    let status_str: String = row.get(2)?;
329                    let start_str: Option<String> = row.get(3)?;
330                    let end_str: Option<String> = row.get(4)?;
331                    let tags_str: Option<String> = row.get(5)?;
332                    Ok((id, experiment_id, status_str, start_str, end_str, tags_str))
333                },
334            )
335            .map_err(|e| match e {
336                rusqlite::Error::QueryReturnedNoRows => {
337                    StorageError::RunNotFound(run_id.to_string())
338                }
339                _ => StorageError::Backend(format!("Failed to get run: {e}")),
340            })?;
341
342        Ok(Self::row_to_run(row))
343    }
344
345    fn list_all_runs(&self) -> Result<Vec<Run>> {
346        let conn = self.lock_conn()?;
347
348        let mut stmt = conn
349            .prepare("SELECT id, experiment_id, status, start_time, end_time, tags FROM runs ORDER BY start_time")
350            .map_err(|e| StorageError::Backend(format!("Failed to prepare query: {e}")))?;
351
352        Self::collect_runs_from_stmt(&mut stmt, [])
353    }
354
355    fn collect_runs_from_stmt<P: rusqlite::Params>(
356        stmt: &mut rusqlite::Statement<'_>,
357        params: P,
358    ) -> Result<Vec<Run>> {
359        let rows = stmt
360            .query_map(params, |row| {
361                let id: String = row.get(0)?;
362                let experiment_id: String = row.get(1)?;
363                let status_str: String = row.get(2)?;
364                let start_str: Option<String> = row.get(3)?;
365                let end_str: Option<String> = row.get(4)?;
366                let tags_str: Option<String> = row.get(5)?;
367                Ok((id, experiment_id, status_str, start_str, end_str, tags_str))
368            })
369            .map_err(|e| StorageError::Backend(format!("Failed to query runs: {e}")))?;
370
371        let mut result = Vec::new();
372        for row in rows {
373            let tuple =
374                row.map_err(|e| StorageError::Backend(format!("Failed to read run row: {e}")))?;
375            result.push(Self::row_to_run(tuple));
376        }
377        Ok(result)
378    }
379
380    fn row_to_run(
381        row: (String, String, String, Option<String>, Option<String>, Option<String>),
382    ) -> Run {
383        let (id, experiment_id, status_str, start_str, end_str, tags_str) = row;
384        let start_time = start_str.map_or_else(Utc::now, |s| parse_timestamp(&s));
385        let end_time = end_str.map(|s| parse_timestamp(&s));
386        let tags: HashMap<String, String> =
387            tags_str.and_then(|s| serde_json::from_str(&s).ok()).unwrap_or_default();
388
389        Run {
390            id,
391            experiment_id,
392            status: str_to_status(&status_str),
393            start_time,
394            end_time,
395            params: HashMap::new(),
396            tags,
397        }
398    }
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn test_param_matches_all_filter_ops() {
407        let float_val = ParameterValue::Float(5.0);
408        let float_filter = ParameterValue::Float(5.0);
409        let string_val = ParameterValue::String("hello world".to_string());
410        let string_filter = ParameterValue::String("hello".to_string());
411
412        for op in &[
413            FilterOp::Eq,
414            FilterOp::Ne,
415            FilterOp::Gt,
416            FilterOp::Lt,
417            FilterOp::Gte,
418            FilterOp::Lte,
419            FilterOp::Contains,
420            FilterOp::StartsWith,
421        ] {
422            match op {
423                FilterOp::Eq => {
424                    assert!(SqliteBackend::param_matches(&float_val, op, &float_filter));
425                }
426                FilterOp::Ne => {
427                    assert!(!SqliteBackend::param_matches(&float_val, op, &float_filter));
428                }
429                FilterOp::Gt => {
430                    assert!(!SqliteBackend::param_matches(&float_val, op, &float_filter));
431                }
432                FilterOp::Lt => {
433                    assert!(!SqliteBackend::param_matches(&float_val, op, &float_filter));
434                }
435                FilterOp::Gte => {
436                    assert!(SqliteBackend::param_matches(&float_val, op, &float_filter));
437                }
438                FilterOp::Lte => {
439                    assert!(SqliteBackend::param_matches(&float_val, op, &float_filter));
440                }
441                FilterOp::Contains => {
442                    // Unsupported for Float, but supported for String
443                    assert!(!SqliteBackend::param_matches(&float_val, op, &float_filter));
444                    assert!(SqliteBackend::param_matches(&string_val, op, &string_filter));
445                }
446                FilterOp::StartsWith => {
447                    assert!(!SqliteBackend::param_matches(&float_val, op, &float_filter));
448                    assert!(SqliteBackend::param_matches(&string_val, op, &string_filter));
449                }
450            }
451        }
452    }
453
454    #[test]
455    fn test_param_matches_cross_type_returns_false() {
456        let float_val = ParameterValue::Float(42.0);
457        let int_filter = ParameterValue::Int(42);
458        // Cross-type comparisons always return false
459        assert!(!SqliteBackend::param_matches(&float_val, &FilterOp::Eq, &int_filter));
460        assert!(!SqliteBackend::param_matches(&float_val, &FilterOp::Ne, &int_filter));
461        assert!(!SqliteBackend::param_matches(&float_val, &FilterOp::Gt, &int_filter));
462        assert!(!SqliteBackend::param_matches(&float_val, &FilterOp::Lt, &int_filter));
463    }
464
465    #[test]
466    fn test_param_matches_list_and_dict_always_false() {
467        let list_val = ParameterValue::List(vec![ParameterValue::Int(1)]);
468        let list_filter = ParameterValue::List(vec![ParameterValue::Int(1)]);
469        let dict_val =
470            ParameterValue::Dict(HashMap::from([("k".to_string(), ParameterValue::Int(1))]));
471        let dict_filter =
472            ParameterValue::Dict(HashMap::from([("k".to_string(), ParameterValue::Int(1))]));
473
474        // List and Dict do not support any filter operations - verify with match
475        let test_cases: Vec<(&ParameterValue, &ParameterValue, &FilterOp)> = vec![
476            (&list_val, &list_filter, &FilterOp::Eq),
477            (&list_val, &list_filter, &FilterOp::Ne),
478            (&dict_val, &dict_filter, &FilterOp::Eq),
479            (&dict_val, &dict_filter, &FilterOp::Ne),
480        ];
481
482        for (val, filt, op) in &test_cases {
483            let result = SqliteBackend::param_matches(val, op, filt);
484            match (val, filt, op) {
485                (ParameterValue::List(..), ParameterValue::List(..), _) => {
486                    assert!(!result, "List should not match with any op");
487                }
488                (ParameterValue::Dict(..), ParameterValue::Dict(..), _) => {
489                    assert!(!result, "Dict should not match with any op");
490                }
491                _ => unreachable!(),
492            }
493        }
494    }
495
496    // ── Additional coverage tests ──
497
498    #[test]
499    fn test_str_to_status_all_variants() {
500        assert_eq!(str_to_status("pending"), crate::storage::RunStatus::Pending);
501        assert_eq!(str_to_status("running"), crate::storage::RunStatus::Running);
502        assert_eq!(str_to_status("completed"), crate::storage::RunStatus::Success);
503        assert_eq!(str_to_status("failed"), crate::storage::RunStatus::Failed);
504        assert_eq!(str_to_status("cancelled"), crate::storage::RunStatus::Cancelled);
505    }
506
507    #[test]
508    fn test_str_to_status_unknown_defaults_to_failed() {
509        assert_eq!(str_to_status("unknown"), crate::storage::RunStatus::Failed);
510        assert_eq!(str_to_status(""), crate::storage::RunStatus::Failed);
511        assert_eq!(str_to_status("RUNNING"), crate::storage::RunStatus::Failed);
512    }
513
514    #[test]
515    fn test_parse_timestamp_valid() {
516        let ts = parse_timestamp("2026-03-08T12:00:00Z");
517        assert_eq!(ts.year(), 2026);
518        assert_eq!(ts.month(), 3);
519    }
520
521    #[test]
522    fn test_parse_timestamp_invalid_falls_back() {
523        let ts = parse_timestamp("not-a-date");
524        // Should fall back to now
525        let now = chrono::Utc::now();
526        let diff = (now - ts).num_seconds().abs();
527        assert!(diff < 5); // Within 5 seconds of now
528    }
529
530    #[test]
531    fn test_param_matches_int_all_ops() {
532        let v5 = ParameterValue::Int(5);
533        let v3 = ParameterValue::Int(3);
534        let v5_dup = ParameterValue::Int(5);
535        let v7 = ParameterValue::Int(7);
536
537        assert!(SqliteBackend::param_matches(&v5, &FilterOp::Eq, &v5_dup));
538        assert!(!SqliteBackend::param_matches(&v5, &FilterOp::Eq, &v3));
539
540        assert!(!SqliteBackend::param_matches(&v5, &FilterOp::Ne, &v5_dup));
541        assert!(SqliteBackend::param_matches(&v5, &FilterOp::Ne, &v3));
542
543        assert!(SqliteBackend::param_matches(&v5, &FilterOp::Gt, &v3));
544        assert!(!SqliteBackend::param_matches(&v3, &FilterOp::Gt, &v5));
545        assert!(!SqliteBackend::param_matches(&v5, &FilterOp::Gt, &v5_dup));
546
547        assert!(SqliteBackend::param_matches(&v3, &FilterOp::Lt, &v5));
548        assert!(!SqliteBackend::param_matches(&v5, &FilterOp::Lt, &v3));
549
550        assert!(SqliteBackend::param_matches(&v5, &FilterOp::Gte, &v5_dup));
551        assert!(SqliteBackend::param_matches(&v7, &FilterOp::Gte, &v5));
552        assert!(!SqliteBackend::param_matches(&v3, &FilterOp::Gte, &v5));
553
554        assert!(SqliteBackend::param_matches(&v5, &FilterOp::Lte, &v5_dup));
555        assert!(SqliteBackend::param_matches(&v3, &FilterOp::Lte, &v5));
556        assert!(!SqliteBackend::param_matches(&v7, &FilterOp::Lte, &v5));
557    }
558
559    #[test]
560    fn test_param_matches_string_eq_ne() {
561        let hello = ParameterValue::String("hello".to_string());
562        let world = ParameterValue::String("world".to_string());
563        let hello_dup = ParameterValue::String("hello".to_string());
564
565        assert!(SqliteBackend::param_matches(&hello, &FilterOp::Eq, &hello_dup));
566        assert!(!SqliteBackend::param_matches(&hello, &FilterOp::Eq, &world));
567
568        assert!(!SqliteBackend::param_matches(&hello, &FilterOp::Ne, &hello_dup));
569        assert!(SqliteBackend::param_matches(&hello, &FilterOp::Ne, &world));
570    }
571
572    #[test]
573    fn test_param_matches_string_contains() {
574        let full = ParameterValue::String("hello world".to_string());
575        let sub = ParameterValue::String("world".to_string());
576        let missing = ParameterValue::String("xyz".to_string());
577
578        assert!(SqliteBackend::param_matches(&full, &FilterOp::Contains, &sub));
579        assert!(!SqliteBackend::param_matches(&full, &FilterOp::Contains, &missing));
580    }
581
582    #[test]
583    fn test_param_matches_string_starts_with() {
584        let full = ParameterValue::String("hello world".to_string());
585        let prefix = ParameterValue::String("hello".to_string());
586        let wrong = ParameterValue::String("world".to_string());
587
588        assert!(SqliteBackend::param_matches(&full, &FilterOp::StartsWith, &prefix));
589        assert!(!SqliteBackend::param_matches(&full, &FilterOp::StartsWith, &wrong));
590    }
591
592    #[test]
593    fn test_param_matches_bool_eq_ne() {
594        let t = ParameterValue::Bool(true);
595        let f = ParameterValue::Bool(false);
596        let t_dup = ParameterValue::Bool(true);
597
598        assert!(SqliteBackend::param_matches(&t, &FilterOp::Eq, &t_dup));
599        assert!(!SqliteBackend::param_matches(&t, &FilterOp::Eq, &f));
600
601        assert!(!SqliteBackend::param_matches(&t, &FilterOp::Ne, &t_dup));
602        assert!(SqliteBackend::param_matches(&t, &FilterOp::Ne, &f));
603    }
604
605    #[test]
606    fn test_param_matches_float_gt_lt() {
607        let f5 = ParameterValue::Float(5.0);
608        let f3 = ParameterValue::Float(3.0);
609
610        assert!(SqliteBackend::param_matches(&f5, &FilterOp::Gt, &f3));
611        assert!(!SqliteBackend::param_matches(&f3, &FilterOp::Gt, &f5));
612
613        assert!(SqliteBackend::param_matches(&f3, &FilterOp::Lt, &f5));
614        assert!(!SqliteBackend::param_matches(&f5, &FilterOp::Lt, &f3));
615    }
616
617    #[test]
618    fn test_param_matches_float_gte_lte() {
619        let f5 = ParameterValue::Float(5.0);
620        let f5_dup = ParameterValue::Float(5.0);
621        let f3 = ParameterValue::Float(3.0);
622
623        assert!(SqliteBackend::param_matches(&f5, &FilterOp::Gte, &f5_dup));
624        assert!(SqliteBackend::param_matches(&f5, &FilterOp::Gte, &f3));
625        assert!(!SqliteBackend::param_matches(&f3, &FilterOp::Gte, &f5));
626
627        assert!(SqliteBackend::param_matches(&f5, &FilterOp::Lte, &f5_dup));
628        assert!(SqliteBackend::param_matches(&f3, &FilterOp::Lte, &f5));
629        assert!(!SqliteBackend::param_matches(&f5, &FilterOp::Lte, &f3));
630    }
631
632    #[test]
633    fn test_param_matches_unsupported_ops_return_false() {
634        // String with Gt/Lt/Gte/Lte should return false
635        let s = ParameterValue::String("hello".to_string());
636        assert!(!SqliteBackend::param_matches(&s, &FilterOp::Gt, &s));
637        assert!(!SqliteBackend::param_matches(&s, &FilterOp::Lt, &s));
638        assert!(!SqliteBackend::param_matches(&s, &FilterOp::Gte, &s));
639        assert!(!SqliteBackend::param_matches(&s, &FilterOp::Lte, &s));
640
641        // Bool with Gt/Lt/Gte/Lte/Contains/StartsWith should return false
642        let b = ParameterValue::Bool(true);
643        assert!(!SqliteBackend::param_matches(&b, &FilterOp::Gt, &b));
644        assert!(!SqliteBackend::param_matches(&b, &FilterOp::Lt, &b));
645        assert!(!SqliteBackend::param_matches(&b, &FilterOp::Contains, &b));
646        assert!(!SqliteBackend::param_matches(&b, &FilterOp::StartsWith, &b));
647
648        // Int with Contains/StartsWith should return false
649        let i = ParameterValue::Int(42);
650        assert!(!SqliteBackend::param_matches(&i, &FilterOp::Contains, &i));
651        assert!(!SqliteBackend::param_matches(&i, &FilterOp::StartsWith, &i));
652    }
653
654    #[test]
655    fn test_row_to_run_with_all_fields() {
656        let now = chrono::Utc::now().to_rfc3339();
657        let tags_json = serde_json::json!({"env": "prod"}).to_string();
658        let row = (
659            "run-123".to_string(),
660            "exp-456".to_string(),
661            "running".to_string(),
662            Some(now.clone()),
663            Some(now.clone()),
664            Some(tags_json),
665        );
666        let run = SqliteBackend::row_to_run(row);
667        assert_eq!(run.id, "run-123");
668        assert_eq!(run.experiment_id, "exp-456");
669        assert_eq!(run.status, crate::storage::RunStatus::Running);
670        assert!(run.end_time.is_some());
671        assert_eq!(run.tags.get("env").map(String::as_str), Some("prod"));
672    }
673
674    #[test]
675    fn test_row_to_run_with_none_fields() {
676        let row =
677            ("run-1".to_string(), "exp-1".to_string(), "pending".to_string(), None, None, None);
678        let run = SqliteBackend::row_to_run(row);
679        assert_eq!(run.id, "run-1");
680        assert_eq!(run.status, crate::storage::RunStatus::Pending);
681        assert!(run.end_time.is_none());
682        assert!(run.tags.is_empty());
683    }
684
685    #[test]
686    fn test_row_to_run_invalid_tags_json() {
687        let row = (
688            "run-1".to_string(),
689            "exp-1".to_string(),
690            "completed".to_string(),
691            Some(chrono::Utc::now().to_rfc3339()),
692            None,
693            Some("not-valid-json".to_string()),
694        );
695        let run = SqliteBackend::row_to_run(row);
696        // Invalid JSON for tags should result in empty HashMap
697        assert!(run.tags.is_empty());
698    }
699
700    #[test]
701    fn test_param_matches_float_ne() {
702        let f5 = ParameterValue::Float(5.0);
703        let f3 = ParameterValue::Float(3.0);
704        let f5_dup = ParameterValue::Float(5.0);
705
706        assert!(SqliteBackend::param_matches(&f5, &FilterOp::Ne, &f3));
707        assert!(!SqliteBackend::param_matches(&f5, &FilterOp::Ne, &f5_dup));
708    }
709
710    use chrono::Datelike;
711
712    #[test]
713    fn test_parse_timestamp_rfc3339() {
714        let ts = parse_timestamp("2025-06-15T10:30:00+00:00");
715        assert_eq!(ts.year(), 2025);
716        assert_eq!(ts.month(), 6);
717        assert_eq!(ts.day(), 15);
718    }
719
720    // ── test_cov4 additional coverage tests ────────────────────────
721
722    #[test]
723    fn test_cov4_str_to_status_all_case_sensitive() {
724        // Verify case sensitivity
725        assert_eq!(str_to_status("Pending"), crate::storage::RunStatus::Failed); // capital P
726        assert_eq!(str_to_status("COMPLETED"), crate::storage::RunStatus::Failed);
727        assert_eq!(str_to_status("Running"), crate::storage::RunStatus::Failed);
728        assert_eq!(str_to_status("Cancelled"), crate::storage::RunStatus::Failed);
729        assert_eq!(str_to_status("FAILED"), crate::storage::RunStatus::Failed);
730    }
731
732    #[test]
733    fn test_cov4_str_to_status_whitespace() {
734        assert_eq!(str_to_status(" pending"), crate::storage::RunStatus::Failed);
735        assert_eq!(str_to_status("pending "), crate::storage::RunStatus::Failed);
736        assert_eq!(str_to_status("  "), crate::storage::RunStatus::Failed);
737    }
738
739    #[test]
740    fn test_cov4_parse_timestamp_various_formats() {
741        // With timezone offset
742        let ts = parse_timestamp("2024-12-25T00:00:00+05:30");
743        assert_eq!(ts.year(), 2024);
744        assert_eq!(ts.month(), 12);
745
746        // UTC with Z
747        let ts2 = parse_timestamp("2020-01-01T00:00:00Z");
748        assert_eq!(ts2.year(), 2020);
749
750        // Negative offset
751        let ts3 = parse_timestamp("2023-06-15T12:00:00-07:00");
752        assert_eq!(ts3.year(), 2023);
753    }
754
755    #[test]
756    fn test_cov4_parse_timestamp_empty_string() {
757        let ts = parse_timestamp("");
758        let now = chrono::Utc::now();
759        let diff = (now - ts).num_seconds().abs();
760        assert!(diff < 5);
761    }
762
763    #[test]
764    fn test_cov4_parse_timestamp_partial_date() {
765        // Partial date should fail and fall back to now
766        let ts = parse_timestamp("2025-01");
767        let now = chrono::Utc::now();
768        let diff = (now - ts).num_seconds().abs();
769        assert!(diff < 5);
770    }
771
772    #[test]
773    fn test_cov4_row_to_run_all_statuses() {
774        for (status_str, expected) in &[
775            ("pending", crate::storage::RunStatus::Pending),
776            ("running", crate::storage::RunStatus::Running),
777            ("completed", crate::storage::RunStatus::Success),
778            ("failed", crate::storage::RunStatus::Failed),
779            ("cancelled", crate::storage::RunStatus::Cancelled),
780            ("unknown", crate::storage::RunStatus::Failed),
781        ] {
782            let row = (
783                "run-x".to_string(),
784                "exp-x".to_string(),
785                status_str.to_string(),
786                Some("2026-01-01T00:00:00Z".to_string()),
787                None,
788                None,
789            );
790            let run = SqliteBackend::row_to_run(row);
791            assert_eq!(run.status, *expected, "Status for '{status_str}'");
792        }
793    }
794
795    #[test]
796    fn test_cov4_row_to_run_with_end_time() {
797        let row = (
798            "r1".to_string(),
799            "e1".to_string(),
800            "completed".to_string(),
801            Some("2026-01-01T00:00:00Z".to_string()),
802            Some("2026-01-01T01:00:00Z".to_string()),
803            None,
804        );
805        let run = SqliteBackend::row_to_run(row);
806        assert!(run.end_time.is_some());
807        let end = run.end_time.unwrap();
808        assert_eq!(end.hour(), 1);
809    }
810
811    #[test]
812    fn test_cov4_row_to_run_with_complex_tags() {
813        let tags =
814            serde_json::json!({"env": "staging", "model": "qwen2", "version": "1.0"}).to_string();
815        let row = (
816            "r1".to_string(),
817            "e1".to_string(),
818            "running".to_string(),
819            Some("2026-01-01T00:00:00Z".to_string()),
820            None,
821            Some(tags),
822        );
823        let run = SqliteBackend::row_to_run(row);
824        assert_eq!(run.tags.len(), 3);
825        assert_eq!(run.tags.get("env").map(String::as_str), Some("staging"));
826        assert_eq!(run.tags.get("model").map(String::as_str), Some("qwen2"));
827    }
828
829    #[test]
830    fn test_cov4_row_to_run_empty_tags_json() {
831        let row = (
832            "r1".to_string(),
833            "e1".to_string(),
834            "pending".to_string(),
835            None,
836            None,
837            Some("{}".to_string()),
838        );
839        let run = SqliteBackend::row_to_run(row);
840        assert!(run.tags.is_empty());
841    }
842
843    #[test]
844    fn test_cov4_row_to_run_invalid_start_time() {
845        let row = (
846            "r1".to_string(),
847            "e1".to_string(),
848            "completed".to_string(),
849            Some("not-a-date".to_string()),
850            Some("also-not-a-date".to_string()),
851            None,
852        );
853        let run = SqliteBackend::row_to_run(row);
854        // Invalid timestamps fall back to now
855        let now = chrono::Utc::now();
856        let diff = (now - run.start_time).num_seconds().abs();
857        assert!(diff < 5);
858    }
859
860    #[test]
861    fn test_cov4_param_matches_int_contains_false() {
862        let v = ParameterValue::Int(42);
863        assert!(!SqliteBackend::param_matches(&v, &FilterOp::Contains, &v));
864    }
865
866    #[test]
867    fn test_cov4_param_matches_int_starts_with_false() {
868        let v = ParameterValue::Int(42);
869        assert!(!SqliteBackend::param_matches(&v, &FilterOp::StartsWith, &v));
870    }
871
872    #[test]
873    fn test_cov4_param_matches_bool_all_unsupported_ops() {
874        let t = ParameterValue::Bool(true);
875        let f = ParameterValue::Bool(false);
876
877        // Bool only supports Eq and Ne
878        assert!(SqliteBackend::param_matches(&t, &FilterOp::Eq, &t));
879        assert!(!SqliteBackend::param_matches(&t, &FilterOp::Eq, &f));
880        assert!(!SqliteBackend::param_matches(&t, &FilterOp::Ne, &t));
881        assert!(SqliteBackend::param_matches(&t, &FilterOp::Ne, &f));
882
883        // All others unsupported
884        assert!(!SqliteBackend::param_matches(&t, &FilterOp::Gte, &t));
885        assert!(!SqliteBackend::param_matches(&t, &FilterOp::Lte, &t));
886    }
887
888    #[test]
889    fn test_cov4_param_matches_string_all_unsupported_ops() {
890        let s = ParameterValue::String("test".to_string());
891        // String does not support Gt, Lt, Gte, Lte
892        assert!(!SqliteBackend::param_matches(&s, &FilterOp::Gt, &s));
893        assert!(!SqliteBackend::param_matches(&s, &FilterOp::Lt, &s));
894        assert!(!SqliteBackend::param_matches(&s, &FilterOp::Gte, &s));
895        assert!(!SqliteBackend::param_matches(&s, &FilterOp::Lte, &s));
896    }
897
898    #[test]
899    fn test_cov4_param_matches_float_epsilon_boundary() {
900        let a = ParameterValue::Float(1.0);
901        let b = ParameterValue::Float(1.0 + f64::EPSILON * 0.5);
902        // Should be Eq because diff < EPSILON
903        assert!(SqliteBackend::param_matches(&a, &FilterOp::Eq, &b));
904    }
905
906    #[test]
907    fn test_cov4_param_matches_float_ne_different() {
908        let a = ParameterValue::Float(1.0);
909        let b = ParameterValue::Float(1.1);
910        assert!(SqliteBackend::param_matches(&a, &FilterOp::Ne, &b));
911        assert!(!SqliteBackend::param_matches(&a, &FilterOp::Eq, &b));
912    }
913
914    #[test]
915    fn test_cov4_param_matches_string_contains_empty() {
916        let full = ParameterValue::String("hello".to_string());
917        let empty = ParameterValue::String(String::new());
918        // Every string contains empty string
919        assert!(SqliteBackend::param_matches(&full, &FilterOp::Contains, &empty));
920    }
921
922    #[test]
923    fn test_cov4_param_matches_string_starts_with_empty() {
924        let full = ParameterValue::String("hello".to_string());
925        let empty = ParameterValue::String(String::new());
926        assert!(SqliteBackend::param_matches(&full, &FilterOp::StartsWith, &empty));
927    }
928
929    #[test]
930    fn test_cov4_param_matches_string_contains_full() {
931        let s = ParameterValue::String("abcdef".to_string());
932        let s2 = ParameterValue::String("abcdef".to_string());
933        assert!(SqliteBackend::param_matches(&s, &FilterOp::Contains, &s2));
934    }
935
936    #[test]
937    fn test_cov4_param_matches_cross_type_all_combinations() {
938        let float = ParameterValue::Float(1.0);
939        let int = ParameterValue::Int(1);
940        let string = ParameterValue::String("1".to_string());
941        let bool_val = ParameterValue::Bool(true);
942
943        // Float vs Int
944        assert!(!SqliteBackend::param_matches(&float, &FilterOp::Eq, &int));
945        // Float vs String
946        assert!(!SqliteBackend::param_matches(&float, &FilterOp::Eq, &string));
947        // Float vs Bool
948        assert!(!SqliteBackend::param_matches(&float, &FilterOp::Eq, &bool_val));
949        // Int vs String
950        assert!(!SqliteBackend::param_matches(&int, &FilterOp::Eq, &string));
951        // Int vs Bool
952        assert!(!SqliteBackend::param_matches(&int, &FilterOp::Eq, &bool_val));
953        // String vs Bool
954        assert!(!SqliteBackend::param_matches(&string, &FilterOp::Eq, &bool_val));
955    }
956
957    #[test]
958    fn test_cov4_param_matches_list_all_ops() {
959        let list = ParameterValue::List(vec![ParameterValue::Int(1), ParameterValue::Int(2)]);
960        for op in &[
961            FilterOp::Eq,
962            FilterOp::Ne,
963            FilterOp::Gt,
964            FilterOp::Lt,
965            FilterOp::Gte,
966            FilterOp::Lte,
967            FilterOp::Contains,
968            FilterOp::StartsWith,
969        ] {
970            assert!(
971                !SqliteBackend::param_matches(&list, op, &list),
972                "List should not match with {op:?}"
973            );
974        }
975    }
976
977    #[test]
978    fn test_cov4_param_matches_dict_all_ops() {
979        let dict = ParameterValue::Dict(HashMap::from([("k".to_string(), ParameterValue::Int(1))]));
980        for op in &[
981            FilterOp::Eq,
982            FilterOp::Ne,
983            FilterOp::Gt,
984            FilterOp::Lt,
985            FilterOp::Gte,
986            FilterOp::Lte,
987            FilterOp::Contains,
988            FilterOp::StartsWith,
989        ] {
990            assert!(
991                !SqliteBackend::param_matches(&dict, op, &dict),
992                "Dict should not match with {op:?}"
993            );
994        }
995    }
996
997    use chrono::Timelike;
998
999    #[test]
1000    fn test_cov4_row_to_run_params_always_empty() {
1001        // row_to_run always initializes params as empty HashMap
1002        let row = ("r1".to_string(), "e1".to_string(), "running".to_string(), None, None, None);
1003        let run = SqliteBackend::row_to_run(row);
1004        assert!(run.params.is_empty());
1005    }
1006
1007    #[test]
1008    fn test_cov4_row_to_run_no_start_time_uses_now() {
1009        let row = (
1010            "r1".to_string(),
1011            "e1".to_string(),
1012            "pending".to_string(),
1013            None, // no start_time → uses now
1014            None,
1015            None,
1016        );
1017        let run = SqliteBackend::row_to_run(row);
1018        let now = chrono::Utc::now();
1019        let diff = (now - run.start_time).num_seconds().abs();
1020        assert!(diff < 5, "start_time should be near now when None, diff={diff}");
1021    }
1022}