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