1use 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
12fn 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
24fn parse_timestamp(s: &str) -> DateTime<Utc> {
26 s.parse().unwrap_or_else(|_| Utc::now())
27}
28
29impl SqliteBackend {
30 pub fn log_param(&self, run_id: &str, key: &str, value: ParameterValue) -> Result<()> {
32 let conn = self.lock_conn()?;
33
34 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 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 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 ¶ms_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 pub fn get_params(&self, run_id: &str) -> Result<HashMap<String, ParameterValue>> {
92 let conn = self.lock_conn()?;
93
94 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 pub fn search_runs_by_params(&self, filters: &[ParamFilter]) -> Result<Vec<Run>> {
131 if filters.is_empty() {
132 return self.list_all_runs();
134 }
135
136 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 fn param_matches(value: &ParameterValue, op: &FilterOp, filter_value: &ParameterValue) -> bool {
163 match (value, filter_value, op) {
164 (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 (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 (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 (ParameterValue::Bool(v), ParameterValue::Bool(fv), FilterOp::Eq) => v == fv,
196 (ParameterValue::Bool(v), ParameterValue::Bool(fv), FilterOp::Ne) => v != fv,
197
198 _ => false,
200 }
201 }
202
203 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 pub fn get_run(&self, run_id: &str) -> Result<Run> {
247 self.query_run_by_id(run_id)
248 }
249
250 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 pub fn list_runs(&self, experiment_id: &str) -> Result<Vec<Run>> {
294 let conn = self.lock_conn()?;
295
296 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 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 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 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 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 #[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 let now = chrono::Utc::now();
526 let diff = (now - ts).num_seconds().abs();
527 assert!(diff < 5); }
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 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 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 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 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]
723 fn test_cov4_str_to_status_all_case_sensitive() {
724 assert_eq!(str_to_status("Pending"), crate::storage::RunStatus::Failed); 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 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 let ts2 = parse_timestamp("2020-01-01T00:00:00Z");
748 assert_eq!(ts2.year(), 2020);
749
750 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 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 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 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 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 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 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 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 assert!(!SqliteBackend::param_matches(&float, &FilterOp::Eq, &int));
945 assert!(!SqliteBackend::param_matches(&float, &FilterOp::Eq, &string));
947 assert!(!SqliteBackend::param_matches(&float, &FilterOp::Eq, &bool_val));
949 assert!(!SqliteBackend::param_matches(&int, &FilterOp::Eq, &string));
951 assert!(!SqliteBackend::param_matches(&int, &FilterOp::Eq, &bool_val));
953 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 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, 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}