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_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
242fn 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(®ex::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}