#![cfg(feature = "advanced-queries")]
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::error::{Result, ThingsError};
use crate::models::TaskFilters;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SavedQuery {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub filters: TaskFilters,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub any_tags: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exclude_tags: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tag_count_min: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fuzzy_query: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fuzzy_threshold: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub where_expr: Option<crate::filter_expr::FilterExpr>,
pub saved_at: DateTime<Utc>,
}
impl SavedQuery {
#[must_use]
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
description: None,
filters: TaskFilters::default(),
any_tags: None,
exclude_tags: None,
tag_count_min: None,
fuzzy_query: None,
fuzzy_threshold: None,
where_expr: None,
saved_at: Utc::now(),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SavedQueryStore {
queries: HashMap<String, SavedQuery>,
}
impl SavedQueryStore {
#[must_use]
pub fn new() -> Self {
Self {
queries: HashMap::new(),
}
}
#[must_use]
pub fn default_path() -> PathBuf {
let dir = if let Ok(home) = std::env::var("HOME") {
PathBuf::from(home).join(".config").join("things3")
} else if let Ok(userprofile) = std::env::var("USERPROFILE") {
PathBuf::from(userprofile)
.join("AppData")
.join("Roaming")
.join("things3")
} else {
PathBuf::from(".")
};
dir.join("saved-queries.json")
}
pub fn load(path: &Path) -> Result<Self> {
if !path.exists() {
return Ok(Self::new());
}
let content = std::fs::read_to_string(path).map_err(|e| {
ThingsError::Io(std::io::Error::other(format!(
"Failed to read saved queries from {}: {}",
path.display(),
e
)))
})?;
serde_json::from_str(&content).map_err(|e| {
ThingsError::configuration(format!(
"Failed to parse saved queries at {}: {e}",
path.display()
))
})
}
pub fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|e| {
ThingsError::Io(std::io::Error::other(format!(
"Failed to create directory {}: {}",
parent.display(),
e
)))
})?;
}
}
let content = serde_json::to_string_pretty(self).map_err(|e| {
ThingsError::configuration(format!("Failed to serialize saved queries: {e}"))
})?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, content).map_err(|e| {
ThingsError::Io(std::io::Error::other(format!(
"Failed to write temp file {}: {}",
tmp.display(),
e
)))
})?;
std::fs::rename(&tmp, path).map_err(|e| {
ThingsError::Io(std::io::Error::other(format!(
"Failed to rename {} to {}: {}",
tmp.display(),
path.display(),
e
)))
})?;
Ok(())
}
pub fn insert(&mut self, query: SavedQuery) {
self.queries.insert(query.name.clone(), query);
}
#[must_use]
pub fn get(&self, name: &str) -> Option<&SavedQuery> {
self.queries.get(name)
}
pub fn remove(&mut self, name: &str) -> Option<SavedQuery> {
self.queries.remove(name)
}
pub fn list(&self) -> impl Iterator<Item = &SavedQuery> {
self.queries.values()
}
#[must_use]
pub fn len(&self) -> usize {
self.queries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.queries.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{TaskStatus, TaskType};
use chrono::NaiveDate;
use tempfile::TempDir;
use uuid::Uuid;
fn fully_populated_query(name: &str) -> SavedQuery {
SavedQuery {
name: name.to_string(),
description: Some("populated for tests".to_string()),
filters: TaskFilters {
status: Some(TaskStatus::Incomplete),
task_type: Some(TaskType::Todo),
project_uuid: Some(Uuid::nil()),
area_uuid: None,
tags: Some(vec!["work".to_string()]),
start_date_from: Some(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()),
start_date_to: Some(NaiveDate::from_ymd_opt(2026, 12, 31).unwrap()),
deadline_from: None,
deadline_to: Some(NaiveDate::from_ymd_opt(2026, 6, 30).unwrap()),
search_query: Some("budget".to_string()),
limit: Some(20),
offset: Some(5),
},
any_tags: Some(vec!["urgent".to_string(), "important".to_string()]),
exclude_tags: Some(vec!["archived".to_string()]),
tag_count_min: Some(2),
fuzzy_query: Some("agenda".to_string()),
fuzzy_threshold: Some(0.7),
where_expr: Some(
crate::filter_expr::FilterExpr::status(TaskStatus::Incomplete)
.and(crate::filter_expr::FilterExpr::task_type(TaskType::Project).not()),
),
saved_at: chrono::Utc::now(),
}
}
#[test]
fn test_saved_query_full_roundtrip() {
let original = fully_populated_query("everything");
let json = serde_json::to_string(&original).unwrap();
let parsed: SavedQuery = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.name, original.name);
assert_eq!(parsed.description, original.description);
assert_eq!(parsed.filters.status, original.filters.status);
assert_eq!(parsed.filters.task_type, original.filters.task_type);
assert_eq!(parsed.filters.project_uuid, original.filters.project_uuid);
assert_eq!(parsed.filters.area_uuid, original.filters.area_uuid);
assert_eq!(parsed.filters.tags, original.filters.tags);
assert_eq!(
parsed.filters.start_date_from,
original.filters.start_date_from
);
assert_eq!(parsed.filters.start_date_to, original.filters.start_date_to);
assert_eq!(parsed.filters.deadline_from, original.filters.deadline_from);
assert_eq!(parsed.filters.deadline_to, original.filters.deadline_to);
assert_eq!(parsed.filters.search_query, original.filters.search_query);
assert_eq!(parsed.filters.limit, original.filters.limit);
assert_eq!(parsed.filters.offset, original.filters.offset);
assert_eq!(parsed.any_tags, original.any_tags);
assert_eq!(parsed.exclude_tags, original.exclude_tags);
assert_eq!(parsed.tag_count_min, original.tag_count_min);
assert_eq!(parsed.fuzzy_query, original.fuzzy_query);
assert_eq!(parsed.fuzzy_threshold, original.fuzzy_threshold);
assert_eq!(parsed.where_expr, original.where_expr);
}
#[test]
fn test_saved_query_minimal_serialize_omits_empty_options() {
let q = SavedQuery::new("minimal");
let json = serde_json::to_string(&q).unwrap();
assert!(!json.contains("\"description\""));
assert!(!json.contains("\"any_tags\""));
assert!(!json.contains("\"exclude_tags\""));
assert!(!json.contains("\"tag_count_min\""));
assert!(!json.contains("\"fuzzy_query\""));
assert!(!json.contains("\"fuzzy_threshold\""));
assert!(!json.contains("\"where_expr\""));
assert!(json.contains("\"name\":\"minimal\""));
assert!(json.contains("\"filters\""));
assert!(json.contains("\"saved_at\""));
}
#[test]
fn test_saved_query_deserializes_with_missing_optional_fields() {
let json = r#"{
"name": "old-format",
"filters": {},
"saved_at": "2026-01-01T00:00:00Z"
}"#;
let q: SavedQuery = serde_json::from_str(json).unwrap();
assert_eq!(q.name, "old-format");
assert!(q.description.is_none());
assert!(q.any_tags.is_none());
assert!(q.fuzzy_query.is_none());
}
#[test]
fn test_store_insert_get_remove() {
let mut store = SavedQueryStore::new();
assert!(store.is_empty());
store.insert(SavedQuery::new("a"));
store.insert(SavedQuery::new("b"));
assert_eq!(store.len(), 2);
assert!(store.get("a").is_some());
assert!(store.get("missing").is_none());
let removed = store.remove("a").unwrap();
assert_eq!(removed.name, "a");
assert_eq!(store.len(), 1);
assert!(store.get("a").is_none());
}
#[test]
fn test_store_insert_replaces_same_name() {
let mut store = SavedQueryStore::new();
let mut q1 = SavedQuery::new("dup");
q1.description = Some("first".to_string());
store.insert(q1);
let mut q2 = SavedQuery::new("dup");
q2.description = Some("second".to_string());
store.insert(q2);
assert_eq!(store.len(), 1);
assert_eq!(
store.get("dup").unwrap().description.as_deref(),
Some("second")
);
}
#[test]
fn test_store_list_iteration() {
let mut store = SavedQueryStore::new();
store.insert(SavedQuery::new("a"));
store.insert(SavedQuery::new("b"));
store.insert(SavedQuery::new("c"));
let mut names: Vec<&str> = store.list().map(|q| q.name.as_str()).collect();
names.sort_unstable();
assert_eq!(names, vec!["a", "b", "c"]);
}
#[test]
fn test_store_load_missing_file_returns_empty() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("does-not-exist.json");
let store = SavedQueryStore::load(&path).unwrap();
assert!(store.is_empty());
}
#[test]
fn test_store_save_then_load_roundtrip() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("queries.json");
let mut original = SavedQueryStore::new();
original.insert(fully_populated_query("everything"));
original.insert(SavedQuery::new("simple"));
original.save(&path).unwrap();
let loaded = SavedQueryStore::load(&path).unwrap();
assert_eq!(loaded.len(), 2);
assert!(loaded.get("everything").is_some());
assert!(loaded.get("simple").is_some());
let everything = loaded.get("everything").unwrap();
assert_eq!(everything.fuzzy_threshold, Some(0.7));
assert_eq!(everything.tag_count_min, Some(2));
}
#[test]
fn test_store_save_replaces_existing() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("queries.json");
let mut store = SavedQueryStore::new();
store.insert(SavedQuery::new("first"));
store.save(&path).unwrap();
store.remove("first");
store.insert(SavedQuery::new("second"));
store.save(&path).unwrap();
let loaded = SavedQueryStore::load(&path).unwrap();
assert_eq!(loaded.len(), 1);
assert!(loaded.get("first").is_none());
assert!(loaded.get("second").is_some());
}
#[test]
fn test_store_save_creates_parent_dir() {
let dir = TempDir::new().unwrap();
let nested = dir.path().join("nested").join("more").join("queries.json");
let mut store = SavedQueryStore::new();
store.insert(SavedQuery::new("a"));
store.save(&nested).unwrap();
assert!(nested.exists());
let loaded = SavedQueryStore::load(&nested).unwrap();
assert_eq!(loaded.len(), 1);
}
#[test]
fn test_store_load_invalid_json_errors() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("bad.json");
std::fs::write(&path, "{ not valid json").unwrap();
let result = SavedQueryStore::load(&path);
assert!(result.is_err(), "expected parse error");
}
#[test]
fn test_default_path_ends_with_saved_queries_json() {
let path = SavedQueryStore::default_path();
assert_eq!(
path.file_name().and_then(|s| s.to_str()),
Some("saved-queries.json")
);
assert!(
path.parent()
.and_then(|p| p.file_name())
.and_then(|s| s.to_str())
== Some("things3"),
"expected parent dir to be 'things3', got {path:?}"
);
}
}