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#[derive(Clone, Debug, Eq)]
17pub struct Tag {
18 name: String,
19 value: Option<String>,
20}
21
22impl Tag {
23 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 pub fn name(&self) -> &str {
37 &self.name
38 }
39
40 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 fn eq(&self, other: &Self) -> bool {
68 self.name.eq_ignore_ascii_case(&other.name) && self.value == other.value
69 }
70}
71
72#[derive(Clone, Debug, Default, Eq, PartialEq)]
74pub struct Tags {
75 inner: Vec<Tag>,
76}
77
78impl Tags {
79 pub fn new() -> Self {
81 Self::default()
82 }
83
84 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 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 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 pub fn is_empty(&self) -> bool {
108 self.inner.is_empty()
109 }
110
111 pub fn iter(&self) -> impl Iterator<Item = &Tag> {
113 self.inner.iter()
114 }
115
116 pub fn len(&self) -> usize {
118 self.inner.len()
119 }
120
121 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 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 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 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 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 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 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
229fn 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
242fn 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}