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 count
187 }
188
189 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 count
206 }
207
208 fn position(&self, name: &str) -> Option<usize> {
210 let name = name.strip_prefix('@').unwrap_or(name);
211 self.inner.iter().position(|t| t.name.eq_ignore_ascii_case(name))
212 }
213}
214
215impl Display for Tags {
216 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
217 let parts: Vec<String> = self.inner.iter().map(|t| t.to_string()).collect();
218 write!(f, "{}", parts.join(" "))
219 }
220}
221
222fn wildcard_to_regex(pattern: &str) -> String {
227 let mut rx = String::from("(?i)^");
228 for ch in pattern.chars() {
229 match ch {
230 '*' => rx.push_str(r"\S*"),
231 '?' => rx.push_str(r"\S"),
232 _ => {
233 for escaped in regex::escape(&ch.to_string()).chars() {
234 rx.push(escaped);
235 }
236 }
237 }
238 }
239 rx.push('$');
240 rx
241}
242
243#[cfg(test)]
244mod test {
245 use super::*;
246
247 mod tag {
248 mod display {
249 use pretty_assertions::assert_eq;
250
251 use super::super::super::*;
252
253 #[test]
254 fn it_formats_tag_without_value() {
255 let tag = Tag::new("coding", None::<String>);
256
257 assert_eq!(tag.to_string(), "@coding");
258 }
259
260 #[test]
261 fn it_formats_tag_with_value() {
262 let tag = Tag::new("done", Some("2024-03-17 14:00"));
263
264 assert_eq!(tag.to_string(), "@done(2024-03-17 14:00)");
265 }
266 }
267
268 mod eq {
269 use super::super::super::*;
270
271 #[test]
272 fn it_matches_case_insensitively() {
273 let a = Tag::new("Done", Some("value"));
274 let b = Tag::new("done", Some("value"));
275
276 assert_eq!(a, b);
277 }
278
279 #[test]
280 fn it_does_not_match_different_values() {
281 let a = Tag::new("done", Some("a"));
282 let b = Tag::new("done", Some("b"));
283
284 assert_ne!(a, b);
285 }
286 }
287
288 mod hash {
289 use std::hash::{DefaultHasher, Hash, Hasher};
290
291 use super::super::super::*;
292
293 fn compute_hash(tag: &Tag) -> u64 {
294 let mut hasher = DefaultHasher::new();
295 tag.hash(&mut hasher);
296 hasher.finish()
297 }
298
299 #[test]
300 fn it_produces_same_hash_for_case_insensitive_names() {
301 let a = Tag::new("Done", Some("value"));
302 let b = Tag::new("done", Some("value"));
303
304 assert_eq!(compute_hash(&a), compute_hash(&b));
305 }
306
307 #[test]
308 fn it_deduplicates_case_insensitive_names_in_hashset() {
309 let mut set = HashSet::new();
310 set.insert(Tag::new("Done", None::<String>));
311 set.insert(Tag::new("done", None::<String>));
312 set.insert(Tag::new("DONE", None::<String>));
313
314 assert_eq!(set.len(), 1);
315 }
316 }
317
318 mod new {
319 use pretty_assertions::assert_eq;
320
321 use super::super::super::*;
322
323 #[test]
324 fn it_strips_at_prefix() {
325 let tag = Tag::new("@coding", None::<String>);
326
327 assert_eq!(tag.name(), "coding");
328 }
329
330 #[test]
331 fn it_preserves_original_case() {
332 let tag = Tag::new("MyTag", None::<String>);
333
334 assert_eq!(tag.name(), "MyTag");
335 }
336 }
337 }
338
339 mod tags {
340 mod add {
341 use pretty_assertions::assert_eq;
342
343 use super::super::super::*;
344
345 #[test]
346 fn it_adds_a_new_tag() {
347 let mut tags = Tags::new();
348 tags.add(Tag::new("coding", None::<String>));
349
350 assert_eq!(tags.len(), 1);
351 assert!(tags.has("coding"));
352 }
353
354 #[test]
355 fn it_replaces_existing_tag_with_same_name() {
356 let mut tags = Tags::new();
357 tags.add(Tag::new("done", None::<String>));
358 tags.add(Tag::new("done", Some("2024-03-17")));
359
360 assert_eq!(tags.len(), 1);
361 assert_eq!(tags.iter().next().unwrap().value(), Some("2024-03-17"));
362 }
363 }
364
365 mod dedup {
366 use pretty_assertions::assert_eq;
367
368 use super::super::super::*;
369
370 #[test]
371 fn it_removes_case_insensitive_duplicates() {
372 let mut tags = Tags::from_iter(vec![
373 Tag::new("coding", None::<String>),
374 Tag::new("Coding", None::<String>),
375 Tag::new("CODING", None::<String>),
376 ]);
377
378 tags.dedup();
379
380 assert_eq!(tags.len(), 1);
381 assert_eq!(tags.iter().next().unwrap().name(), "coding");
382 }
383 }
384
385 mod display {
386 use pretty_assertions::assert_eq;
387
388 use super::super::super::*;
389
390 #[test]
391 fn it_joins_tags_with_spaces() {
392 let tags = Tags::from_iter(vec![
393 Tag::new("coding", None::<String>),
394 Tag::new("done", Some("2024-03-17")),
395 ]);
396
397 assert_eq!(tags.to_string(), "@coding @done(2024-03-17)");
398 }
399 }
400
401 mod has {
402 use super::super::super::*;
403
404 #[test]
405 fn it_finds_tag_case_insensitively() {
406 let mut tags = Tags::new();
407 tags.add(Tag::new("Coding", None::<String>));
408
409 assert!(tags.has("coding"));
410 assert!(tags.has("CODING"));
411 assert!(tags.has("Coding"));
412 }
413
414 #[test]
415 fn it_handles_at_prefix() {
416 let mut tags = Tags::new();
417 tags.add(Tag::new("coding", None::<String>));
418
419 assert!(tags.has("@coding"));
420 }
421 }
422
423 mod matches_wildcard {
424 use super::super::super::*;
425
426 #[test]
427 fn it_matches_star_wildcard() {
428 let tags = Tags::from_iter(vec![Tag::new("coding", None::<String>)]);
429
430 assert!(tags.matches_wildcard("cod*"));
431 assert!(tags.matches_wildcard("*ing"));
432 assert!(tags.matches_wildcard("*"));
433 }
434
435 #[test]
436 fn it_matches_question_mark_wildcard() {
437 let tags = Tags::from_iter(vec![Tag::new("done", None::<String>)]);
438
439 assert!(tags.matches_wildcard("d?ne"));
440 assert!(!tags.matches_wildcard("d?e"));
441 }
442
443 #[test]
444 fn it_matches_case_insensitively() {
445 let tags = Tags::from_iter(vec![Tag::new("Coding", None::<String>)]);
446
447 assert!(tags.matches_wildcard("coding"));
448 assert!(tags.matches_wildcard("CODING"));
449 }
450
451 #[test]
452 fn it_strips_at_prefix_from_pattern() {
453 let tags = Tags::from_iter(vec![Tag::new("coding", None::<String>)]);
454
455 assert!(tags.matches_wildcard("@coding"));
456 }
457 }
458
459 mod remove {
460 use pretty_assertions::assert_eq;
461
462 use super::super::super::*;
463
464 #[test]
465 fn it_removes_tag_by_name() {
466 let mut tags = Tags::from_iter(vec![
467 Tag::new("coding", None::<String>),
468 Tag::new("done", None::<String>),
469 ]);
470
471 let removed = tags.remove("coding");
472
473 assert_eq!(removed, 1);
474 assert_eq!(tags.len(), 1);
475 assert!(!tags.has("coding"));
476 }
477
478 #[test]
479 fn it_removes_case_insensitively() {
480 let mut tags = Tags::from_iter(vec![Tag::new("Coding", None::<String>)]);
481
482 let removed = tags.remove("coding");
483
484 assert_eq!(removed, 1);
485 assert!(tags.is_empty());
486 }
487 }
488
489 mod remove_by_regex {
490 use pretty_assertions::assert_eq;
491
492 use super::super::super::*;
493
494 #[test]
495 fn it_removes_tags_matching_regex() {
496 let mut tags = Tags::from_iter(vec![
497 Tag::new("project-123", None::<String>),
498 Tag::new("project-456", None::<String>),
499 Tag::new("coding", None::<String>),
500 ]);
501
502 let removed = tags.remove_by_regex("^project-\\d+$");
503
504 assert_eq!(removed, 2);
505 assert_eq!(tags.len(), 1);
506 assert!(tags.has("coding"));
507 }
508
509 #[test]
510 fn it_matches_case_insensitively() {
511 let mut tags = Tags::from_iter(vec![Tag::new("Coding", None::<String>)]);
512
513 let removed = tags.remove_by_regex("^coding$");
514
515 assert_eq!(removed, 1);
516 assert!(tags.is_empty());
517 }
518
519 #[test]
520 fn it_returns_zero_for_invalid_regex() {
521 let mut tags = Tags::from_iter(vec![Tag::new("coding", None::<String>)]);
522
523 let removed = tags.remove_by_regex("[invalid");
524
525 assert_eq!(removed, 0);
526 assert_eq!(tags.len(), 1);
527 }
528 }
529
530 mod remove_by_wildcard {
531 use pretty_assertions::assert_eq;
532
533 use super::super::super::*;
534
535 #[test]
536 fn it_removes_tags_matching_wildcard() {
537 let mut tags = Tags::from_iter(vec![
538 Tag::new("project-a", None::<String>),
539 Tag::new("project-b", None::<String>),
540 Tag::new("coding", None::<String>),
541 ]);
542
543 let removed = tags.remove_by_wildcard("project-*");
544
545 assert_eq!(removed, 2);
546 assert_eq!(tags.len(), 1);
547 assert!(tags.has("coding"));
548 }
549
550 #[test]
551 fn it_matches_case_insensitively() {
552 let mut tags = Tags::from_iter(vec![Tag::new("Coding", None::<String>)]);
553
554 let removed = tags.remove_by_wildcard("cod*");
555
556 assert_eq!(removed, 1);
557 assert!(tags.is_empty());
558 }
559
560 #[test]
561 fn it_strips_at_prefix_from_pattern() {
562 let mut tags = Tags::from_iter(vec![Tag::new("coding", None::<String>)]);
563
564 let removed = tags.remove_by_wildcard("@coding");
565
566 assert_eq!(removed, 1);
567 assert!(tags.is_empty());
568 }
569 }
570
571 mod rename {
572 use pretty_assertions::assert_eq;
573
574 use super::super::super::*;
575
576 #[test]
577 fn it_renames_matching_tags() {
578 let mut tags = Tags::from_iter(vec![
579 Tag::new("old_tag", Some("value")),
580 Tag::new("other", None::<String>),
581 ]);
582
583 let renamed = tags.rename("old_tag", "new_tag");
584
585 assert_eq!(renamed, 1);
586 assert!(tags.has("new_tag"));
587 assert!(!tags.has("old_tag"));
588 assert_eq!(tags.iter().next().unwrap().value(), Some("value"));
589 }
590
591 #[test]
592 fn it_renames_case_insensitively() {
593 let mut tags = Tags::from_iter(vec![Tag::new("OldTag", None::<String>)]);
594
595 let renamed = tags.rename("oldtag", "newtag");
596
597 assert_eq!(renamed, 1);
598 assert!(tags.has("newtag"));
599 }
600 }
601
602 mod rename_by_wildcard {
603 use pretty_assertions::assert_eq;
604
605 use super::super::super::*;
606
607 #[test]
608 fn it_renames_tags_matching_wildcard() {
609 let mut tags = Tags::from_iter(vec![
610 Tag::new("proj-a", Some("value")),
611 Tag::new("proj-b", None::<String>),
612 Tag::new("coding", None::<String>),
613 ]);
614
615 let renamed = tags.rename_by_wildcard("proj-*", "project");
616
617 assert_eq!(renamed, 2);
618 assert!(tags.has("project"));
619 assert!(!tags.has("proj-a"));
620 assert!(!tags.has("proj-b"));
621 }
622
623 #[test]
624 fn it_preserves_values() {
625 let mut tags = Tags::from_iter(vec![Tag::new("old", Some("val"))]);
626
627 tags.rename_by_wildcard("ol?", "new");
628
629 assert!(tags.has("new"));
630 assert_eq!(tags.iter().next().unwrap().value(), Some("val"));
631 }
632
633 #[test]
634 fn it_returns_zero_for_no_matches() {
635 let mut tags = Tags::from_iter(vec![Tag::new("coding", None::<String>)]);
636
637 let renamed = tags.rename_by_wildcard("proj-*", "project");
638
639 assert_eq!(renamed, 0);
640 assert!(tags.has("coding"));
641 }
642 }
643 }
644
645 mod wildcard_to_regex {
646 use super::*;
647
648 #[test]
649 fn it_converts_star_to_non_whitespace_pattern() {
650 let result = wildcard_to_regex("do*");
651
652 assert!(result.contains(r"\S*"));
653 }
654
655 #[test]
656 fn it_converts_question_mark_to_single_non_whitespace() {
657 let result = wildcard_to_regex("d?ne");
658
659 assert!(result.contains(r"\S"));
660 }
661
662 #[test]
663 fn it_escapes_regex_special_characters() {
664 let result = wildcard_to_regex("tag.name");
665
666 assert!(result.contains(r"\."));
667 }
668 }
669}