Skip to main content

doing_taskpaper/
tags.rs

1use std::{
2  collections::HashSet,
3  fmt::{Display, Formatter, Result as FmtResult},
4};
5
6use regex::Regex;
7
8/// A TaskPaper tag with an optional value.
9///
10/// Tags appear in entry titles as `@name` or `@name(value)`. Tag names are
11/// case-insensitive for matching but preserve their original case in output.
12#[derive(Clone, Debug, Eq)]
13pub struct Tag {
14  name: String,
15  value: Option<String>,
16}
17
18impl Tag {
19  /// Create a new tag with the given name and optional value.
20  ///
21  /// The name is stored as-is (preserving case). Any leading `@` is stripped.
22  pub fn new(name: impl Into<String>, value: Option<impl Into<String>>) -> Self {
23    let name = name.into();
24    let name = name.strip_prefix('@').map(String::from).unwrap_or(name);
25    Self {
26      name,
27      value: value.map(Into::into),
28    }
29  }
30
31  /// Return the tag name (without `@` prefix).
32  pub fn name(&self) -> &str {
33    &self.name
34  }
35
36  /// Return the tag value, if any.
37  pub fn value(&self) -> Option<&str> {
38    self.value.as_deref()
39  }
40}
41
42impl Display for Tag {
43  fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
44    match &self.value {
45      Some(v) => write!(f, "@{}({})", self.name, v),
46      None => write!(f, "@{}", self.name),
47    }
48  }
49}
50
51impl PartialEq for Tag {
52  /// Two tags are equal when their names match case-insensitively and their
53  /// values are identical.
54  fn eq(&self, other: &Self) -> bool {
55    self.name.eq_ignore_ascii_case(&other.name) && self.value == other.value
56  }
57}
58
59/// A collection of tags with operations for add, remove, rename, query, and dedup.
60#[derive(Clone, Debug, Default, Eq, PartialEq)]
61pub struct Tags {
62  inner: Vec<Tag>,
63}
64
65impl Tags {
66  /// Build a tag collection from an iterator of tags.
67  #[allow(clippy::should_implement_trait)]
68  pub fn from_iter(iter: impl IntoIterator<Item = Tag>) -> Self {
69    Self {
70      inner: iter.into_iter().collect(),
71    }
72  }
73
74  /// Create an empty tag collection.
75  pub fn new() -> Self {
76    Self::default()
77  }
78
79  /// Add a tag. If a tag with the same name already exists, it is replaced.
80  pub fn add(&mut self, tag: Tag) {
81    if let Some(pos) = self.position(&tag.name) {
82      self.inner[pos] = tag;
83    } else {
84      self.inner.push(tag);
85    }
86  }
87
88  /// Remove duplicate tags, keeping the first occurrence of each name
89  /// (compared case-insensitively).
90  pub fn dedup(&mut self) {
91    let mut seen = HashSet::new();
92    self.inner.retain(|tag| seen.insert(tag.name.to_ascii_lowercase()));
93  }
94
95  /// Check whether a tag with the given name exists (case-insensitive).
96  pub fn has(&self, name: &str) -> bool {
97    let name = name.strip_prefix('@').unwrap_or(name);
98    self.inner.iter().any(|t| t.name.eq_ignore_ascii_case(name))
99  }
100
101  /// Return whether the collection is empty.
102  pub fn is_empty(&self) -> bool {
103    self.inner.is_empty()
104  }
105
106  /// Return an iterator over the tags.
107  pub fn iter(&self) -> impl Iterator<Item = &Tag> {
108    self.inner.iter()
109  }
110
111  /// Return the number of tags.
112  #[allow(dead_code)]
113  pub fn len(&self) -> usize {
114    self.inner.len()
115  }
116
117  /// Check whether any tag name matches a wildcard pattern.
118  ///
119  /// Wildcards: `*` matches zero or more characters, `?` matches exactly one.
120  /// Matching is case-insensitive.
121  pub fn matches_wildcard(&self, pattern: &str) -> bool {
122    let pattern = pattern.strip_prefix('@').unwrap_or(pattern);
123    let rx_str = wildcard_to_regex(pattern);
124    let Ok(rx) = Regex::new(&rx_str) else {
125      return false;
126    };
127    self.inner.iter().any(|t| rx.is_match(&t.name))
128  }
129
130  /// Remove all tags whose names match case-insensitively. Returns the number
131  /// of tags removed.
132  pub fn remove(&mut self, name: &str) -> usize {
133    let name = name.strip_prefix('@').unwrap_or(name);
134    let before = self.inner.len();
135    self.inner.retain(|t| !t.name.eq_ignore_ascii_case(name));
136    before - self.inner.len()
137  }
138
139  /// Remove all tags whose names match a regex pattern (case-insensitive).
140  /// Returns the number of tags removed.
141  pub fn remove_by_regex(&mut self, pattern: &str) -> usize {
142    let ci_pattern = format!("(?i){pattern}");
143    let Ok(rx) = Regex::new(&ci_pattern) else {
144      return 0;
145    };
146    let before = self.inner.len();
147    self.inner.retain(|t| !rx.is_match(&t.name));
148    before - self.inner.len()
149  }
150
151  /// Remove all tags whose names match a wildcard pattern. Returns the number
152  /// of tags removed.
153  pub fn remove_by_wildcard(&mut self, pattern: &str) -> usize {
154    let pattern = pattern.strip_prefix('@').unwrap_or(pattern);
155    let rx_str = wildcard_to_regex(pattern);
156    let Ok(rx) = Regex::new(&rx_str) else {
157      return 0;
158    };
159    let before = self.inner.len();
160    self.inner.retain(|t| !rx.is_match(&t.name));
161    before - self.inner.len()
162  }
163
164  /// Rename all tags matching `old_name` to `new_name`, preserving values.
165  /// Returns the number of tags renamed.
166  pub fn rename(&mut self, old_name: &str, new_name: &str) -> usize {
167    let old = old_name.strip_prefix('@').unwrap_or(old_name);
168    let new = new_name.strip_prefix('@').unwrap_or(new_name);
169    let mut count = 0;
170    for tag in &mut self.inner {
171      if tag.name.eq_ignore_ascii_case(old) {
172        tag.name = new.to_string();
173        count += 1;
174      }
175    }
176    count
177  }
178
179  /// Rename all tags matching a wildcard pattern to `new_name`, preserving
180  /// values. Returns the number of tags renamed.
181  pub fn rename_by_wildcard(&mut self, pattern: &str, new_name: &str) -> usize {
182    let pattern = pattern.strip_prefix('@').unwrap_or(pattern);
183    let new = new_name.strip_prefix('@').unwrap_or(new_name);
184    let rx_str = wildcard_to_regex(pattern);
185    let Ok(rx) = Regex::new(&rx_str) else {
186      return 0;
187    };
188    let mut count = 0;
189    for tag in &mut self.inner {
190      if rx.is_match(&tag.name) {
191        tag.name = new.to_string();
192        count += 1;
193      }
194    }
195    count
196  }
197
198  /// Return the index of the first tag matching `name` (case-insensitive).
199  fn position(&self, name: &str) -> Option<usize> {
200    let name = name.strip_prefix('@').unwrap_or(name);
201    self.inner.iter().position(|t| t.name.eq_ignore_ascii_case(name))
202  }
203}
204
205impl Display for Tags {
206  fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
207    let parts: Vec<String> = self.inner.iter().map(|t| t.to_string()).collect();
208    write!(f, "{}", parts.join(" "))
209  }
210}
211
212/// Convert a wildcard pattern to a case-insensitive regex string.
213///
214/// `*` becomes `\S*` (zero or more non-whitespace), `?` becomes `\S` (one
215/// non-whitespace character). All other characters are regex-escaped.
216fn wildcard_to_regex(pattern: &str) -> String {
217  let mut rx = String::from("(?i)^");
218  for ch in pattern.chars() {
219    match ch {
220      '*' => rx.push_str(r"\S*"),
221      '?' => rx.push_str(r"\S"),
222      _ => {
223        for escaped in regex::escape(&ch.to_string()).chars() {
224          rx.push(escaped);
225        }
226      }
227    }
228  }
229  rx.push('$');
230  rx
231}
232
233#[cfg(test)]
234mod test {
235  use super::*;
236
237  mod tag {
238    mod display {
239      use pretty_assertions::assert_eq;
240
241      use super::super::super::*;
242
243      #[test]
244      fn it_formats_tag_without_value() {
245        let tag = Tag::new("coding", None::<String>);
246
247        assert_eq!(tag.to_string(), "@coding");
248      }
249
250      #[test]
251      fn it_formats_tag_with_value() {
252        let tag = Tag::new("done", Some("2024-03-17 14:00"));
253
254        assert_eq!(tag.to_string(), "@done(2024-03-17 14:00)");
255      }
256    }
257
258    mod eq {
259      use super::super::super::*;
260
261      #[test]
262      fn it_matches_case_insensitively() {
263        let a = Tag::new("Done", Some("value"));
264        let b = Tag::new("done", Some("value"));
265
266        assert_eq!(a, b);
267      }
268
269      #[test]
270      fn it_does_not_match_different_values() {
271        let a = Tag::new("done", Some("a"));
272        let b = Tag::new("done", Some("b"));
273
274        assert_ne!(a, b);
275      }
276    }
277
278    mod new {
279      use pretty_assertions::assert_eq;
280
281      use super::super::super::*;
282
283      #[test]
284      fn it_strips_at_prefix() {
285        let tag = Tag::new("@coding", None::<String>);
286
287        assert_eq!(tag.name(), "coding");
288      }
289
290      #[test]
291      fn it_preserves_original_case() {
292        let tag = Tag::new("MyTag", None::<String>);
293
294        assert_eq!(tag.name(), "MyTag");
295      }
296    }
297  }
298
299  mod tags {
300    mod add {
301      use pretty_assertions::assert_eq;
302
303      use super::super::super::*;
304
305      #[test]
306      fn it_adds_a_new_tag() {
307        let mut tags = Tags::new();
308        tags.add(Tag::new("coding", None::<String>));
309
310        assert_eq!(tags.len(), 1);
311        assert!(tags.has("coding"));
312      }
313
314      #[test]
315      fn it_replaces_existing_tag_with_same_name() {
316        let mut tags = Tags::new();
317        tags.add(Tag::new("done", None::<String>));
318        tags.add(Tag::new("done", Some("2024-03-17")));
319
320        assert_eq!(tags.len(), 1);
321        assert_eq!(tags.iter().next().unwrap().value(), Some("2024-03-17"));
322      }
323    }
324
325    mod dedup {
326      use pretty_assertions::assert_eq;
327
328      use super::super::super::*;
329
330      #[test]
331      fn it_removes_case_insensitive_duplicates() {
332        let mut tags = Tags::from_iter(vec![
333          Tag::new("coding", None::<String>),
334          Tag::new("Coding", None::<String>),
335          Tag::new("CODING", None::<String>),
336        ]);
337
338        tags.dedup();
339
340        assert_eq!(tags.len(), 1);
341        assert_eq!(tags.iter().next().unwrap().name(), "coding");
342      }
343    }
344
345    mod display {
346      use pretty_assertions::assert_eq;
347
348      use super::super::super::*;
349
350      #[test]
351      fn it_joins_tags_with_spaces() {
352        let tags = Tags::from_iter(vec![
353          Tag::new("coding", None::<String>),
354          Tag::new("done", Some("2024-03-17")),
355        ]);
356
357        assert_eq!(tags.to_string(), "@coding @done(2024-03-17)");
358      }
359    }
360
361    mod has {
362      use super::super::super::*;
363
364      #[test]
365      fn it_finds_tag_case_insensitively() {
366        let mut tags = Tags::new();
367        tags.add(Tag::new("Coding", None::<String>));
368
369        assert!(tags.has("coding"));
370        assert!(tags.has("CODING"));
371        assert!(tags.has("Coding"));
372      }
373
374      #[test]
375      fn it_handles_at_prefix() {
376        let mut tags = Tags::new();
377        tags.add(Tag::new("coding", None::<String>));
378
379        assert!(tags.has("@coding"));
380      }
381    }
382
383    mod matches_wildcard {
384      use super::super::super::*;
385
386      #[test]
387      fn it_matches_star_wildcard() {
388        let tags = Tags::from_iter(vec![Tag::new("coding", None::<String>)]);
389
390        assert!(tags.matches_wildcard("cod*"));
391        assert!(tags.matches_wildcard("*ing"));
392        assert!(tags.matches_wildcard("*"));
393      }
394
395      #[test]
396      fn it_matches_question_mark_wildcard() {
397        let tags = Tags::from_iter(vec![Tag::new("done", None::<String>)]);
398
399        assert!(tags.matches_wildcard("d?ne"));
400        assert!(!tags.matches_wildcard("d?e"));
401      }
402
403      #[test]
404      fn it_matches_case_insensitively() {
405        let tags = Tags::from_iter(vec![Tag::new("Coding", None::<String>)]);
406
407        assert!(tags.matches_wildcard("coding"));
408        assert!(tags.matches_wildcard("CODING"));
409      }
410
411      #[test]
412      fn it_strips_at_prefix_from_pattern() {
413        let tags = Tags::from_iter(vec![Tag::new("coding", None::<String>)]);
414
415        assert!(tags.matches_wildcard("@coding"));
416      }
417    }
418
419    mod remove {
420      use pretty_assertions::assert_eq;
421
422      use super::super::super::*;
423
424      #[test]
425      fn it_removes_tag_by_name() {
426        let mut tags = Tags::from_iter(vec![
427          Tag::new("coding", None::<String>),
428          Tag::new("done", None::<String>),
429        ]);
430
431        let removed = tags.remove("coding");
432
433        assert_eq!(removed, 1);
434        assert_eq!(tags.len(), 1);
435        assert!(!tags.has("coding"));
436      }
437
438      #[test]
439      fn it_removes_case_insensitively() {
440        let mut tags = Tags::from_iter(vec![Tag::new("Coding", None::<String>)]);
441
442        let removed = tags.remove("coding");
443
444        assert_eq!(removed, 1);
445        assert!(tags.is_empty());
446      }
447    }
448
449    mod remove_by_regex {
450      use pretty_assertions::assert_eq;
451
452      use super::super::super::*;
453
454      #[test]
455      fn it_removes_tags_matching_regex() {
456        let mut tags = Tags::from_iter(vec![
457          Tag::new("project-123", None::<String>),
458          Tag::new("project-456", None::<String>),
459          Tag::new("coding", None::<String>),
460        ]);
461
462        let removed = tags.remove_by_regex("^project-\\d+$");
463
464        assert_eq!(removed, 2);
465        assert_eq!(tags.len(), 1);
466        assert!(tags.has("coding"));
467      }
468
469      #[test]
470      fn it_matches_case_insensitively() {
471        let mut tags = Tags::from_iter(vec![Tag::new("Coding", None::<String>)]);
472
473        let removed = tags.remove_by_regex("^coding$");
474
475        assert_eq!(removed, 1);
476        assert!(tags.is_empty());
477      }
478
479      #[test]
480      fn it_returns_zero_for_invalid_regex() {
481        let mut tags = Tags::from_iter(vec![Tag::new("coding", None::<String>)]);
482
483        let removed = tags.remove_by_regex("[invalid");
484
485        assert_eq!(removed, 0);
486        assert_eq!(tags.len(), 1);
487      }
488    }
489
490    mod remove_by_wildcard {
491      use pretty_assertions::assert_eq;
492
493      use super::super::super::*;
494
495      #[test]
496      fn it_removes_tags_matching_wildcard() {
497        let mut tags = Tags::from_iter(vec![
498          Tag::new("project-a", None::<String>),
499          Tag::new("project-b", None::<String>),
500          Tag::new("coding", None::<String>),
501        ]);
502
503        let removed = tags.remove_by_wildcard("project-*");
504
505        assert_eq!(removed, 2);
506        assert_eq!(tags.len(), 1);
507        assert!(tags.has("coding"));
508      }
509
510      #[test]
511      fn it_matches_case_insensitively() {
512        let mut tags = Tags::from_iter(vec![Tag::new("Coding", None::<String>)]);
513
514        let removed = tags.remove_by_wildcard("cod*");
515
516        assert_eq!(removed, 1);
517        assert!(tags.is_empty());
518      }
519
520      #[test]
521      fn it_strips_at_prefix_from_pattern() {
522        let mut tags = Tags::from_iter(vec![Tag::new("coding", None::<String>)]);
523
524        let removed = tags.remove_by_wildcard("@coding");
525
526        assert_eq!(removed, 1);
527        assert!(tags.is_empty());
528      }
529    }
530
531    mod rename {
532      use pretty_assertions::assert_eq;
533
534      use super::super::super::*;
535
536      #[test]
537      fn it_renames_matching_tags() {
538        let mut tags = Tags::from_iter(vec![
539          Tag::new("old_tag", Some("value")),
540          Tag::new("other", None::<String>),
541        ]);
542
543        let renamed = tags.rename("old_tag", "new_tag");
544
545        assert_eq!(renamed, 1);
546        assert!(tags.has("new_tag"));
547        assert!(!tags.has("old_tag"));
548        assert_eq!(tags.iter().next().unwrap().value(), Some("value"));
549      }
550
551      #[test]
552      fn it_renames_case_insensitively() {
553        let mut tags = Tags::from_iter(vec![Tag::new("OldTag", None::<String>)]);
554
555        let renamed = tags.rename("oldtag", "newtag");
556
557        assert_eq!(renamed, 1);
558        assert!(tags.has("newtag"));
559      }
560    }
561
562    mod rename_by_wildcard {
563      use pretty_assertions::assert_eq;
564
565      use super::super::super::*;
566
567      #[test]
568      fn it_renames_tags_matching_wildcard() {
569        let mut tags = Tags::from_iter(vec![
570          Tag::new("proj-a", Some("value")),
571          Tag::new("proj-b", None::<String>),
572          Tag::new("coding", None::<String>),
573        ]);
574
575        let renamed = tags.rename_by_wildcard("proj-*", "project");
576
577        assert_eq!(renamed, 2);
578        assert!(tags.has("project"));
579        assert!(!tags.has("proj-a"));
580        assert!(!tags.has("proj-b"));
581      }
582
583      #[test]
584      fn it_preserves_values() {
585        let mut tags = Tags::from_iter(vec![Tag::new("old", Some("val"))]);
586
587        tags.rename_by_wildcard("ol?", "new");
588
589        assert!(tags.has("new"));
590        assert_eq!(tags.iter().next().unwrap().value(), Some("val"));
591      }
592
593      #[test]
594      fn it_returns_zero_for_no_matches() {
595        let mut tags = Tags::from_iter(vec![Tag::new("coding", None::<String>)]);
596
597        let renamed = tags.rename_by_wildcard("proj-*", "project");
598
599        assert_eq!(renamed, 0);
600        assert!(tags.has("coding"));
601      }
602    }
603  }
604
605  mod wildcard_to_regex {
606    use super::*;
607
608    #[test]
609    fn it_converts_star_to_non_whitespace_pattern() {
610      let result = wildcard_to_regex("do*");
611
612      assert!(result.contains(r"\S*"));
613    }
614
615    #[test]
616    fn it_converts_question_mark_to_single_non_whitespace() {
617      let result = wildcard_to_regex("d?ne");
618
619      assert!(result.contains(r"\S"));
620    }
621
622    #[test]
623    fn it_escapes_regex_special_characters() {
624      let result = wildcard_to_regex("tag.name");
625
626      assert!(result.contains(r"\."));
627    }
628  }
629}