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