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 pub fn new() -> Self {
78 Self::default()
79 }
80
81 pub fn add(&mut self, tag: Tag) {
83 if let Some(pos) = self.position(&tag.name) {
84 self.inner[pos] = tag;
85 } else {
86 self.inner.push(tag);
87 }
88 }
89
90 pub fn dedup(&mut self) {
93 let mut seen = HashSet::new();
94 self.inner.retain(|tag| seen.insert(tag.name.to_ascii_lowercase()));
95 }
96
97 pub fn has(&self, name: &str) -> bool {
99 let name = name.strip_prefix('@').unwrap_or(name);
100 self.inner.iter().any(|t| t.name.eq_ignore_ascii_case(name))
101 }
102
103 pub fn is_empty(&self) -> bool {
105 self.inner.is_empty()
106 }
107
108 pub fn iter(&self) -> impl Iterator<Item = &Tag> {
110 self.inner.iter()
111 }
112
113 pub fn len(&self) -> usize {
115 self.inner.len()
116 }
117
118 pub fn matches_wildcard(&self, pattern: &str) -> bool {
123 let pattern = pattern.strip_prefix('@').unwrap_or(pattern);
124 let rx_str = wildcard_to_regex(pattern);
125 let Ok(rx) = Regex::new(&rx_str) else {
126 return false;
127 };
128 self.inner.iter().any(|t| rx.is_match(&t.name))
129 }
130
131 pub fn remove(&mut self, name: &str) -> usize {
134 let name = name.strip_prefix('@').unwrap_or(name);
135 let before = self.inner.len();
136 self.inner.retain(|t| !t.name.eq_ignore_ascii_case(name));
137 before - self.inner.len()
138 }
139
140 pub fn remove_by_regex(&mut self, pattern: &str) -> usize {
143 let ci_pattern = format!("(?i){pattern}");
144 let Ok(rx) = Regex::new(&ci_pattern) else {
145 return 0;
146 };
147 let before = self.inner.len();
148 self.inner.retain(|t| !rx.is_match(&t.name));
149 before - self.inner.len()
150 }
151
152 pub fn remove_by_wildcard(&mut self, pattern: &str) -> usize {
155 let pattern = pattern.strip_prefix('@').unwrap_or(pattern);
156 let rx_str = wildcard_to_regex(pattern);
157 let Ok(rx) = Regex::new(&rx_str) else {
158 return 0;
159 };
160 let before = self.inner.len();
161 self.inner.retain(|t| !rx.is_match(&t.name));
162 before - self.inner.len()
163 }
164
165 pub fn rename(&mut self, old_name: &str, new_name: &str) -> usize {
168 let old = old_name.strip_prefix('@').unwrap_or(old_name);
169 let new = new_name.strip_prefix('@').unwrap_or(new_name);
170 let mut count = 0;
171 for tag in &mut self.inner {
172 if tag.name.eq_ignore_ascii_case(old) {
173 tag.name = new.to_string();
174 count += 1;
175 }
176 }
177 self.dedup();
178 count
179 }
180
181 pub fn rename_by_wildcard(&mut self, pattern: &str, new_name: &str) -> usize {
184 let pattern = pattern.strip_prefix('@').unwrap_or(pattern);
185 let new = new_name.strip_prefix('@').unwrap_or(new_name);
186 let rx_str = wildcard_to_regex(pattern);
187 let Ok(rx) = Regex::new(&rx_str) else {
188 return 0;
189 };
190 let mut count = 0;
191 for tag in &mut self.inner {
192 if rx.is_match(&tag.name) {
193 tag.name = new.to_string();
194 count += 1;
195 }
196 }
197 self.dedup();
198 count
199 }
200
201 fn position(&self, name: &str) -> Option<usize> {
203 let name = name.strip_prefix('@').unwrap_or(name);
204 self.inner.iter().position(|t| t.name.eq_ignore_ascii_case(name))
205 }
206}
207
208impl Display for Tags {
209 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
210 let parts: Vec<String> = self.inner.iter().map(|t| t.to_string()).collect();
211 write!(f, "{}", parts.join(" "))
212 }
213}
214
215impl FromIterator<Tag> for Tags {
216 fn from_iter<I: IntoIterator<Item = Tag>>(iter: I) -> Self {
217 Self {
218 inner: iter.into_iter().collect(),
219 }
220 }
221}
222
223fn 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}