Skip to main content

doing_taskpaper/
tags.rs

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