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 pub fn new() -> Self {
31 Self::default()
32 }
33
34 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 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 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
105pub 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 if query.cmd.is_empty() && slice_contains(ALL_CMDS, &lc_item.as_str()) {
124 query.cmd = lc_item;
125 continue;
126 }
127
128 if !ids_exhausted && let Ok(id) = item.parse::<i32>() {
130 query.ids.push(id);
131 continue;
132 }
133
134 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 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}