Skip to main content

rstask_core/
query.rs

1use crate::Result;
2use crate::constants::*;
3use crate::date_util::parse_due_date_arg;
4use crate::util::slice_contains;
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
10pub struct Query {
11    pub cmd: String,
12    pub ids: Vec<i32>,
13    pub tags: Vec<String>,
14    pub anti_tags: Vec<String>,
15    pub project: String,
16    pub anti_projects: Vec<String>,
17    #[serde(with = "chrono::serde::ts_seconds_option")]
18    #[serde(default)]
19    pub due: Option<DateTime<Utc>>,
20    pub date_filter: String,
21    pub priority: String,
22    pub template: i32,
23    pub text: String,
24    pub ignore_context: bool,
25    pub note: String,
26}
27
28impl Query {
29    /// Creates an empty query
30    pub fn new() -> Self {
31        Self::default()
32    }
33
34    /// Prints context description with color
35    pub fn print_context_description(&self) {
36        let env_var_notification = if std::env::var("RSTASK_CONTEXT").is_ok() {
37            " (set by RSTASK_CONTEXT)"
38        } else {
39            ""
40        };
41
42        let query_str = self.to_string();
43        if !query_str.is_empty() {
44            println!(
45                "\x1b[33mActive context{}: {}\x1b[0m",
46                env_var_notification, query_str
47            );
48        }
49    }
50
51    /// Returns true if the query has filter operators
52    pub fn has_operators(&self) -> bool {
53        !self.tags.is_empty()
54            || !self.anti_tags.is_empty()
55            || !self.project.is_empty()
56            || !self.anti_projects.is_empty()
57            || self.due.is_some()
58            || !self.date_filter.is_empty()
59            || !self.priority.is_empty()
60            || self.template > 0
61    }
62
63    /// Merges another query into this one, used for applying context
64    pub fn merge(&self, q2: &Query) -> Query {
65        let mut q = self.clone();
66
67        for tag in &q2.tags {
68            if !q.tags.contains(tag) {
69                q.tags.push(tag.clone());
70            }
71        }
72
73        for tag in &q2.anti_tags {
74            if !q.anti_tags.contains(tag) {
75                q.anti_tags.push(tag.clone());
76            }
77        }
78
79        if !q2.project.is_empty() {
80            if !q.project.is_empty() && q.project != q2.project {
81                panic!("Could not apply context, project conflict");
82            }
83            q.project = q2.project.clone();
84        }
85
86        if q2.due.is_some() {
87            if q.due.is_some() && q.due != q2.due {
88                panic!("Could not apply context, date filter conflict");
89            }
90            q.due = q2.due;
91            q.date_filter = q2.date_filter.clone();
92        }
93
94        if !q2.priority.is_empty() {
95            if !q.priority.is_empty() {
96                panic!("Could not apply context, priority conflict");
97            }
98            q.priority = q2.priority.clone();
99        }
100
101        q
102    }
103}
104
105/// Parses command line arguments into a Query
106pub fn parse_query(args: &[String]) -> Result<Query> {
107    let mut query = Query::new();
108    let mut words = Vec::new();
109    let mut notes_mode_activated = false;
110    let mut notes = Vec::new();
111    let mut ids_exhausted = false;
112    let mut due_date_set = false;
113
114    for item in args {
115        let lc_item = item.to_lowercase();
116
117        if notes_mode_activated {
118            notes.push(item.clone());
119            continue;
120        }
121
122        // Check for command
123        if query.cmd.is_empty() && slice_contains(ALL_CMDS, &lc_item.as_str()) {
124            query.cmd = lc_item;
125            continue;
126        }
127
128        // Check for ID (only before any other token)
129        if !ids_exhausted && let Ok(id) = item.parse::<i32>() {
130            query.ids.push(id);
131            continue;
132        }
133
134        // Check for special keywords
135        if item == IGNORE_CONTEXT_KEYWORD {
136            query.ignore_context = true;
137        } else if item == NOTE_MODE_KEYWORD {
138            notes_mode_activated = true;
139        } else if let Some(proj) = lc_item.strip_prefix("project:") {
140            if query.project.is_empty() {
141                query.project = proj.to_string();
142            }
143        } else if let Some(proj) = lc_item.strip_prefix("+project:") {
144            if query.project.is_empty() {
145                query.project = proj.to_string();
146            }
147        } else if let Some(proj) = lc_item.strip_prefix("-project:") {
148            query.anti_projects.push(proj.to_string());
149        } else if lc_item.starts_with("due.") || lc_item.starts_with("due:") {
150            if due_date_set {
151                return Err(crate::RstaskError::Parse(
152                    "Query should only have one due date".to_string(),
153                ));
154            }
155            let (date_filter, due_date) = parse_due_date_arg(&lc_item)?;
156            query.date_filter = date_filter;
157            query.due = Some(due_date.with_timezone(&Utc));
158            due_date_set = true;
159        } else if let Some(template_str) = lc_item.strip_prefix("template:") {
160            if let Ok(template_id) = template_str.parse::<i32>() {
161                query.template = template_id;
162            }
163        } else if let Some(tag) = lc_item.strip_prefix('+') {
164            if !tag.is_empty() {
165                query.tags.push(tag.to_string());
166            }
167        } else if let Some(tag) = lc_item.strip_prefix('-') {
168            if !tag.is_empty() {
169                query.anti_tags.push(tag.to_string());
170            }
171        } else if query.priority.is_empty() && is_valid_priority(item) {
172            query.priority = item.clone();
173        } else {
174            words.push(item.clone());
175        }
176
177        ids_exhausted = true;
178    }
179
180    query.text = words.join(" ");
181    query.note = notes.join(" ");
182
183    Ok(query)
184}
185
186impl fmt::Display for Query {
187    /// Reconstructs the query as a string
188    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189        let mut args = Vec::new();
190
191        for id in &self.ids {
192            args.push(id.to_string());
193        }
194
195        for tag in &self.tags {
196            args.push(format!("+{}", tag));
197        }
198
199        for tag in &self.anti_tags {
200            args.push(format!("-{}", tag));
201        }
202
203        if !self.project.is_empty() {
204            args.push(format!("project:{}", self.project));
205        }
206
207        for project in &self.anti_projects {
208            args.push(format!("-project:{}", project));
209        }
210
211        if let Some(due) = &self.due {
212            let mut due_arg = "due".to_string();
213            if !self.date_filter.is_empty() {
214                due_arg.push('.');
215                due_arg.push_str(&self.date_filter);
216            }
217            due_arg.push(':');
218            due_arg.push_str(&due.format("%Y-%m-%d").to_string());
219            args.push(due_arg);
220        }
221
222        if !self.priority.is_empty() {
223            args.push(self.priority.clone());
224        }
225
226        if self.template > 0 {
227            args.push(format!("template:{}", self.template));
228        }
229
230        if !self.text.is_empty() {
231            args.push(format!("\"{}\"", self.text));
232        }
233
234        write!(f, "{}", args.join(" "))
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn test_parse_query_basic() {
244        let args = vec![
245            "add".to_string(),
246            "have".to_string(),
247            "an".to_string(),
248            "adventure".to_string(),
249        ];
250        let query = parse_query(&args).unwrap();
251
252        assert_eq!(query.cmd, "add");
253        assert_eq!(query.text, "have an adventure");
254        assert!(query.tags.is_empty());
255        assert!(query.anti_tags.is_empty());
256    }
257
258    #[test]
259    fn test_parse_query_with_tags() {
260        let args = vec![
261            "add".to_string(),
262            "+x".to_string(),
263            "-y".to_string(),
264            "have".to_string(),
265            "an".to_string(),
266            "adventure".to_string(),
267        ];
268        let query = parse_query(&args).unwrap();
269
270        assert_eq!(query.cmd, "add");
271        assert_eq!(query.tags, vec!["x".to_string()]);
272        assert_eq!(query.anti_tags, vec!["y".to_string()]);
273        assert_eq!(query.text, "have an adventure");
274    }
275
276    #[test]
277    fn test_parse_query_with_note() {
278        let args = vec![
279            "add".to_string(),
280            "floss".to_string(),
281            "project:p".to_string(),
282            "+health".to_string(),
283            "/".to_string(),
284            "every".to_string(),
285            " day".to_string(),
286        ];
287        let query = parse_query(&args).unwrap();
288
289        assert_eq!(query.cmd, "add");
290        assert_eq!(query.project, "p");
291        assert_eq!(query.tags, vec!["health".to_string()]);
292        assert_eq!(query.text, "floss");
293        assert_eq!(query.note, "every  day");
294    }
295
296    #[test]
297    fn test_parse_query_with_id_and_modify() {
298        let args = vec![
299            "16".to_string(),
300            "modify".to_string(),
301            "+project:p".to_string(),
302            "-project:x".to_string(),
303            "-fun".to_string(),
304        ];
305        let query = parse_query(&args).unwrap();
306
307        assert_eq!(query.cmd, "modify");
308        assert_eq!(query.ids, vec![16]);
309        assert_eq!(query.project, "p");
310        assert_eq!(query.anti_projects, vec!["x".to_string()]);
311        assert_eq!(query.anti_tags, vec!["fun".to_string()]);
312    }
313
314    #[test]
315    fn test_parse_query_ignore_context() {
316        let args = vec!["--".to_string(), "show-resolved".to_string()];
317        let query = parse_query(&args).unwrap();
318
319        assert_eq!(query.cmd, "show-resolved");
320        assert!(query.ignore_context);
321    }
322
323    #[test]
324    fn test_parse_query_priority() {
325        let args = vec![
326            "add".to_string(),
327            "P1".to_string(),
328            "P2".to_string(),
329            "P3".to_string(),
330        ];
331        let query = parse_query(&args).unwrap();
332
333        assert_eq!(query.cmd, "add");
334        assert_eq!(query.priority, "P1");
335        assert_eq!(query.text, "P2 P3");
336    }
337
338    #[test]
339    fn test_parse_query_template() {
340        let args = vec![
341            "add".to_string(),
342            "My".to_string(),
343            "Task".to_string(),
344            "template:1".to_string(),
345            "/".to_string(),
346            "Test".to_string(),
347            "Note".to_string(),
348        ];
349        let query = parse_query(&args).unwrap();
350
351        assert_eq!(query.cmd, "add");
352        assert_eq!(query.template, 1);
353        assert_eq!(query.text, "My Task");
354        assert_eq!(query.note, "Test Note");
355    }
356}