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