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    self.dedup();
187    count
188  }
189
190  /// Rename all tags matching a wildcard pattern to `new_name`, preserving
191  /// values. Returns the number of tags renamed.
192  pub fn rename_by_wildcard(&mut self, pattern: &str, new_name: &str) -> usize {
193    let pattern = pattern.strip_prefix('@').unwrap_or(pattern);
194    let new = new_name.strip_prefix('@').unwrap_or(new_name);
195    let rx_str = wildcard_to_regex(pattern);
196    let Ok(rx) = Regex::new(&rx_str) else {
197      return 0;
198    };
199    let mut count = 0;
200    for tag in &mut self.inner {
201      if rx.is_match(&tag.name) {
202        tag.name = new.to_string();
203        count += 1;
204      }
205    }
206    self.dedup();
207    count
208  }
209
210  /// Return the index of the first tag matching `name` (case-insensitive).
211  fn position(&self, name: &str) -> Option<usize> {
212    let name = name.strip_prefix('@').unwrap_or(name);
213    self.inner.iter().position(|t| t.name.eq_ignore_ascii_case(name))
214  }
215}
216
217impl Display for Tags {
218  fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
219    let parts: Vec<String> = self.inner.iter().map(|t| t.to_string()).collect();
220    write!(f, "{}", parts.join(" "))
221  }
222}
223
224/// Convert a wildcard pattern to a case-insensitive regex string.
225///
226/// `*` becomes `\S*` (zero or more non-whitespace), `?` becomes `\S` (one
227/// non-whitespace character). All other characters are regex-escaped.
228fn wildcard_to_regex(pattern: &str) -> String {
229  let mut rx = String::from("(?i)^");
230  for ch in pattern.chars() {
231    match ch {
232      '*' => rx.push_str(r"\S*"),
233      '?' => rx.push_str(r"\S"),
234      _ => {
235        for escaped in regex::escape(&ch.to_string()).chars() {
236          rx.push(escaped);
237        }
238      }
239    }
240  }
241  rx.push('$');
242  rx
243}
244
245#[cfg(test)]
246mod test {
247  use super::*;
248
249  mod tag {
250    mod display {
251      use pretty_assertions::assert_eq;
252
253      use super::super::super::*;
254
255      #[test]
256      fn it_formats_tag_without_value() {
257        let tag = Tag::new("coding", None::<String>);
258
259        assert_eq!(tag.to_string(), "@coding");
260      }
261
262      #[test]
263      fn it_formats_tag_with_value() {
264        let tag = Tag::new("done", Some("2024-03-17 14:00"));
265
266        assert_eq!(tag.to_string(), "@done(2024-03-17 14:00)");
267      }
268    }
269
270    mod eq {
271      use super::super::super::*;
272
273      #[test]
274      fn it_matches_case_insensitively() {
275        let a = Tag::new("Done", Some("value"));
276        let b = Tag::new("done", Some("value"));
277
278        assert_eq!(a, b);
279      }
280
281      #[test]
282      fn it_does_not_match_different_values() {
283        let a = Tag::new("done", Some("a"));
284        let b = Tag::new("done", Some("b"));
285
286        assert_ne!(a, b);
287      }
288    }
289
290    mod hash {
291      use std::hash::{DefaultHasher, Hash, Hasher};
292
293      use super::super::super::*;
294
295      fn compute_hash(tag: &Tag) -> u64 {
296        let mut hasher = DefaultHasher::new();
297        tag.hash(&mut hasher);
298        hasher.finish()
299      }
300
301      #[test]
302      fn it_produces_same_hash_for_case_insensitive_names() {
303        let a = Tag::new("Done", Some("value"));
304        let b = Tag::new("done", Some("value"));
305
306        assert_eq!(compute_hash(&a), compute_hash(&b));
307      }
308
309      #[test]
310      fn it_deduplicates_case_insensitive_names_in_hashset() {
311        let mut set = HashSet::new();
312        set.insert(Tag::new("Done", None::<String>));
313        set.insert(Tag::new("done", None::<String>));
314        set.insert(Tag::new("DONE", None::<String>));
315
316        assert_eq!(set.len(), 1);
317      }
318    }
319
320    mod new {
321      use pretty_assertions::assert_eq;
322
323      use super::super::super::*;
324
325      #[test]
326      fn it_strips_at_prefix() {
327        let tag = Tag::new("@coding", None::<String>);
328
329        assert_eq!(tag.name(), "coding");
330      }
331
332      #[test]
333      fn it_preserves_original_case() {
334        let tag = Tag::new("MyTag", None::<String>);
335
336        assert_eq!(tag.name(), "MyTag");
337      }
338    }
339  }
340
341  mod tags {
342    mod add {
343      use pretty_assertions::assert_eq;
344
345      use super::super::super::*;
346
347      #[test]
348      fn it_adds_a_new_tag() {
349        let mut tags = Tags::new();
350        tags.add(Tag::new("coding", None::<String>));
351
352        assert_eq!(tags.len(), 1);
353        assert!(tags.has("coding"));
354      }
355
356      #[test]
357      fn it_replaces_existing_tag_with_same_name() {
358        let mut tags = Tags::new();
359        tags.add(Tag::new("done", None::<String>));
360        tags.add(Tag::new("done", Some("2024-03-17")));
361
362        assert_eq!(tags.len(), 1);
363        assert_eq!(tags.iter().next().unwrap().value(), Some("2024-03-17"));
364      }
365    }
366
367    mod dedup {
368      use pretty_assertions::assert_eq;
369
370      use super::super::super::*;
371
372      #[test]
373      fn it_removes_case_insensitive_duplicates() {
374        let mut tags = Tags::from_iter(vec![
375          Tag::new("coding", None::<String>),
376          Tag::new("Coding", None::<String>),
377          Tag::new("CODING", None::<String>),
378        ]);
379
380        tags.dedup();
381
382        assert_eq!(tags.len(), 1);
383        assert_eq!(tags.iter().next().unwrap().name(), "coding");
384      }
385    }
386
387    mod display {
388      use pretty_assertions::assert_eq;
389
390      use super::super::super::*;
391
392      #[test]
393      fn it_joins_tags_with_spaces() {
394        let tags = Tags::from_iter(vec![
395          Tag::new("coding", None::<String>),
396          Tag::new("done", Some("2024-03-17")),
397        ]);
398
399        assert_eq!(tags.to_string(), "@coding @done(2024-03-17)");
400      }
401    }
402
403    mod has {
404      use super::super::super::*;
405
406      #[test]
407      fn it_finds_tag_case_insensitively() {
408        let mut tags = Tags::new();
409        tags.add(Tag::new("Coding", None::<String>));
410
411        assert!(tags.has("coding"));
412        assert!(tags.has("CODING"));
413        assert!(tags.has("Coding"));
414      }
415
416      #[test]
417      fn it_handles_at_prefix() {
418        let mut tags = Tags::new();
419        tags.add(Tag::new("coding", None::<String>));
420
421        assert!(tags.has("@coding"));
422      }
423    }
424
425    mod matches_wildcard {
426      use super::super::super::*;
427
428      #[test]
429      fn it_matches_star_wildcard() {
430        let tags = Tags::from_iter(vec![Tag::new("coding", None::<String>)]);
431
432        assert!(tags.matches_wildcard("cod*"));
433        assert!(tags.matches_wildcard("*ing"));
434        assert!(tags.matches_wildcard("*"));
435      }
436
437      #[test]
438      fn it_matches_question_mark_wildcard() {
439        let tags = Tags::from_iter(vec![Tag::new("done", None::<String>)]);
440
441        assert!(tags.matches_wildcard("d?ne"));
442        assert!(!tags.matches_wildcard("d?e"));
443      }
444
445      #[test]
446      fn it_matches_case_insensitively() {
447        let tags = Tags::from_iter(vec![Tag::new("Coding", None::<String>)]);
448
449        assert!(tags.matches_wildcard("coding"));
450        assert!(tags.matches_wildcard("CODING"));
451      }
452
453      #[test]
454      fn it_strips_at_prefix_from_pattern() {
455        let tags = Tags::from_iter(vec![Tag::new("coding", None::<String>)]);
456
457        assert!(tags.matches_wildcard("@coding"));
458      }
459    }
460
461    mod remove {
462      use pretty_assertions::assert_eq;
463
464      use super::super::super::*;
465
466      #[test]
467      fn it_removes_tag_by_name() {
468        let mut tags = Tags::from_iter(vec![
469          Tag::new("coding", None::<String>),
470          Tag::new("done", None::<String>),
471        ]);
472
473        let removed = tags.remove("coding");
474
475        assert_eq!(removed, 1);
476        assert_eq!(tags.len(), 1);
477        assert!(!tags.has("coding"));
478      }
479
480      #[test]
481      fn it_removes_case_insensitively() {
482        let mut tags = Tags::from_iter(vec![Tag::new("Coding", None::<String>)]);
483
484        let removed = tags.remove("coding");
485
486        assert_eq!(removed, 1);
487        assert!(tags.is_empty());
488      }
489    }
490
491    mod remove_by_regex {
492      use pretty_assertions::assert_eq;
493
494      use super::super::super::*;
495
496      #[test]
497      fn it_removes_tags_matching_regex() {
498        let mut tags = Tags::from_iter(vec![
499          Tag::new("project-123", None::<String>),
500          Tag::new("project-456", None::<String>),
501          Tag::new("coding", None::<String>),
502        ]);
503
504        let removed = tags.remove_by_regex("^project-\\d+$");
505
506        assert_eq!(removed, 2);
507        assert_eq!(tags.len(), 1);
508        assert!(tags.has("coding"));
509      }
510
511      #[test]
512      fn it_matches_case_insensitively() {
513        let mut tags = Tags::from_iter(vec![Tag::new("Coding", None::<String>)]);
514
515        let removed = tags.remove_by_regex("^coding$");
516
517        assert_eq!(removed, 1);
518        assert!(tags.is_empty());
519      }
520
521      #[test]
522      fn it_returns_zero_for_invalid_regex() {
523        let mut tags = Tags::from_iter(vec![Tag::new("coding", None::<String>)]);
524
525        let removed = tags.remove_by_regex("[invalid");
526
527        assert_eq!(removed, 0);
528        assert_eq!(tags.len(), 1);
529      }
530    }
531
532    mod remove_by_wildcard {
533      use pretty_assertions::assert_eq;
534
535      use super::super::super::*;
536
537      #[test]
538      fn it_removes_tags_matching_wildcard() {
539        let mut tags = Tags::from_iter(vec![
540          Tag::new("project-a", None::<String>),
541          Tag::new("project-b", None::<String>),
542          Tag::new("coding", None::<String>),
543        ]);
544
545        let removed = tags.remove_by_wildcard("project-*");
546
547        assert_eq!(removed, 2);
548        assert_eq!(tags.len(), 1);
549        assert!(tags.has("coding"));
550      }
551
552      #[test]
553      fn it_matches_case_insensitively() {
554        let mut tags = Tags::from_iter(vec![Tag::new("Coding", None::<String>)]);
555
556        let removed = tags.remove_by_wildcard("cod*");
557
558        assert_eq!(removed, 1);
559        assert!(tags.is_empty());
560      }
561
562      #[test]
563      fn it_strips_at_prefix_from_pattern() {
564        let mut tags = Tags::from_iter(vec![Tag::new("coding", None::<String>)]);
565
566        let removed = tags.remove_by_wildcard("@coding");
567
568        assert_eq!(removed, 1);
569        assert!(tags.is_empty());
570      }
571    }
572
573    mod rename {
574      use pretty_assertions::assert_eq;
575
576      use super::super::super::*;
577
578      #[test]
579      fn it_renames_matching_tags() {
580        let mut tags = Tags::from_iter(vec![
581          Tag::new("old_tag", Some("value")),
582          Tag::new("other", None::<String>),
583        ]);
584
585        let renamed = tags.rename("old_tag", "new_tag");
586
587        assert_eq!(renamed, 1);
588        assert!(tags.has("new_tag"));
589        assert!(!tags.has("old_tag"));
590        assert_eq!(tags.iter().next().unwrap().value(), Some("value"));
591      }
592
593      #[test]
594      fn it_renames_case_insensitively() {
595        let mut tags = Tags::from_iter(vec![Tag::new("OldTag", None::<String>)]);
596
597        let renamed = tags.rename("oldtag", "newtag");
598
599        assert_eq!(renamed, 1);
600        assert!(tags.has("newtag"));
601      }
602
603      #[test]
604      fn it_deduplicates_when_target_already_exists() {
605        let mut tags = Tags::from_iter(vec![
606          Tag::new("alpha", None::<String>),
607          Tag::new("beta", None::<String>),
608        ]);
609
610        tags.rename("alpha", "beta");
611
612        assert_eq!(tags.len(), 1);
613        assert!(tags.has("beta"));
614      }
615    }
616
617    mod rename_by_wildcard {
618      use pretty_assertions::assert_eq;
619
620      use super::super::super::*;
621
622      #[test]
623      fn it_renames_tags_matching_wildcard() {
624        let mut tags = Tags::from_iter(vec![
625          Tag::new("proj-a", Some("value")),
626          Tag::new("proj-b", None::<String>),
627          Tag::new("coding", None::<String>),
628        ]);
629
630        let renamed = tags.rename_by_wildcard("proj-*", "project");
631
632        assert_eq!(renamed, 2);
633        assert!(tags.has("project"));
634        assert!(!tags.has("proj-a"));
635        assert!(!tags.has("proj-b"));
636      }
637
638      #[test]
639      fn it_preserves_values() {
640        let mut tags = Tags::from_iter(vec![Tag::new("old", Some("val"))]);
641
642        tags.rename_by_wildcard("ol?", "new");
643
644        assert!(tags.has("new"));
645        assert_eq!(tags.iter().next().unwrap().value(), Some("val"));
646      }
647
648      #[test]
649      fn it_deduplicates_when_wildcard_matches_multiple_to_same_name() {
650        let mut tags = Tags::from_iter(vec![
651          Tag::new("proj-a", None::<String>),
652          Tag::new("proj-b", None::<String>),
653          Tag::new("project", None::<String>),
654        ]);
655
656        tags.rename_by_wildcard("proj-*", "project");
657
658        assert_eq!(tags.len(), 1);
659        assert!(tags.has("project"));
660      }
661
662      #[test]
663      fn it_returns_zero_for_no_matches() {
664        let mut tags = Tags::from_iter(vec![Tag::new("coding", None::<String>)]);
665
666        let renamed = tags.rename_by_wildcard("proj-*", "project");
667
668        assert_eq!(renamed, 0);
669        assert!(tags.has("coding"));
670      }
671    }
672  }
673
674  mod wildcard_to_regex {
675    use super::*;
676
677    #[test]
678    fn it_converts_star_to_non_whitespace_pattern() {
679      let result = wildcard_to_regex("do*");
680
681      assert!(result.contains(r"\S*"));
682    }
683
684    #[test]
685    fn it_converts_question_mark_to_single_non_whitespace() {
686      let result = wildcard_to_regex("d?ne");
687
688      assert!(result.contains(r"\S"));
689    }
690
691    #[test]
692    fn it_escapes_regex_special_characters() {
693      let result = wildcard_to_regex("tag.name");
694
695      assert!(result.contains(r"\."));
696    }
697  }
698}