Skip to main content

doing_taskpaper/
tags.rs

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