1use std::{
2 collections::HashSet,
3 fmt::{Display, Formatter, Result as FmtResult},
4};
5
6use regex::Regex;
7
8#[derive(Clone, Debug, Eq)]
13pub struct Tag {
14 name: String,
15 value: Option<String>,
16}
17
18impl Tag {
19 pub fn new(name: impl Into<String>, value: Option<impl Into<String>>) -> Self {
23 let name = name.into();
24 let name = name.strip_prefix('@').map(String::from).unwrap_or(name);
25 Self {
26 name,
27 value: value.map(Into::into),
28 }
29 }
30
31 pub fn name(&self) -> &str {
33 &self.name
34 }
35
36 pub fn value(&self) -> Option<&str> {
38 self.value.as_deref()
39 }
40}
41
42impl Display for Tag {
43 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
44 match &self.value {
45 Some(v) => write!(f, "@{}({})", self.name, v),
46 None => write!(f, "@{}", self.name),
47 }
48 }
49}
50
51impl PartialEq for Tag {
52 fn eq(&self, other: &Self) -> bool {
55 self.name.eq_ignore_ascii_case(&other.name) && self.value == other.value
56 }
57}
58
59#[derive(Clone, Debug, Default, Eq, PartialEq)]
61pub struct Tags {
62 inner: Vec<Tag>,
63}
64
65impl Tags {
66 #[allow(clippy::should_implement_trait)]
68 pub fn from_iter(iter: impl IntoIterator<Item = Tag>) -> Self {
69 Self {
70 inner: iter.into_iter().collect(),
71 }
72 }
73
74 pub fn new() -> Self {
76 Self::default()
77 }
78
79 pub fn add(&mut self, tag: Tag) {
81 if let Some(pos) = self.position(&tag.name) {
82 self.inner[pos] = tag;
83 } else {
84 self.inner.push(tag);
85 }
86 }
87
88 pub fn dedup(&mut self) {
91 let mut seen = HashSet::new();
92 self.inner.retain(|tag| seen.insert(tag.name.to_ascii_lowercase()));
93 }
94
95 pub fn has(&self, name: &str) -> bool {
97 let name = name.strip_prefix('@').unwrap_or(name);
98 self.inner.iter().any(|t| t.name.eq_ignore_ascii_case(name))
99 }
100
101 pub fn is_empty(&self) -> bool {
103 self.inner.is_empty()
104 }
105
106 pub fn iter(&self) -> impl Iterator<Item = &Tag> {
108 self.inner.iter()
109 }
110
111 #[allow(dead_code)]
113 pub fn len(&self) -> usize {
114 self.inner.len()
115 }
116
117 pub fn matches_wildcard(&self, pattern: &str) -> bool {
122 let pattern = pattern.strip_prefix('@').unwrap_or(pattern);
123 let rx_str = wildcard_to_regex(pattern);
124 let Ok(rx) = Regex::new(&rx_str) else {
125 return false;
126 };
127 self.inner.iter().any(|t| rx.is_match(&t.name))
128 }
129
130 pub fn remove(&mut self, name: &str) -> usize {
133 let name = name.strip_prefix('@').unwrap_or(name);
134 let before = self.inner.len();
135 self.inner.retain(|t| !t.name.eq_ignore_ascii_case(name));
136 before - self.inner.len()
137 }
138
139 pub fn remove_by_regex(&mut self, pattern: &str) -> usize {
142 let ci_pattern = format!("(?i){pattern}");
143 let Ok(rx) = Regex::new(&ci_pattern) else {
144 return 0;
145 };
146 let before = self.inner.len();
147 self.inner.retain(|t| !rx.is_match(&t.name));
148 before - self.inner.len()
149 }
150
151 pub fn remove_by_wildcard(&mut self, pattern: &str) -> usize {
154 let pattern = pattern.strip_prefix('@').unwrap_or(pattern);
155 let rx_str = wildcard_to_regex(pattern);
156 let Ok(rx) = Regex::new(&rx_str) else {
157 return 0;
158 };
159 let before = self.inner.len();
160 self.inner.retain(|t| !rx.is_match(&t.name));
161 before - self.inner.len()
162 }
163
164 pub fn rename(&mut self, old_name: &str, new_name: &str) -> usize {
167 let old = old_name.strip_prefix('@').unwrap_or(old_name);
168 let new = new_name.strip_prefix('@').unwrap_or(new_name);
169 let mut count = 0;
170 for tag in &mut self.inner {
171 if tag.name.eq_ignore_ascii_case(old) {
172 tag.name = new.to_string();
173 count += 1;
174 }
175 }
176 count
177 }
178
179 pub fn rename_by_wildcard(&mut self, pattern: &str, new_name: &str) -> usize {
182 let pattern = pattern.strip_prefix('@').unwrap_or(pattern);
183 let new = new_name.strip_prefix('@').unwrap_or(new_name);
184 let rx_str = wildcard_to_regex(pattern);
185 let Ok(rx) = Regex::new(&rx_str) else {
186 return 0;
187 };
188 let mut count = 0;
189 for tag in &mut self.inner {
190 if rx.is_match(&tag.name) {
191 tag.name = new.to_string();
192 count += 1;
193 }
194 }
195 count
196 }
197
198 fn position(&self, name: &str) -> Option<usize> {
200 let name = name.strip_prefix('@').unwrap_or(name);
201 self.inner.iter().position(|t| t.name.eq_ignore_ascii_case(name))
202 }
203}
204
205impl Display for Tags {
206 fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
207 let parts: Vec<String> = self.inner.iter().map(|t| t.to_string()).collect();
208 write!(f, "{}", parts.join(" "))
209 }
210}
211
212fn wildcard_to_regex(pattern: &str) -> String {
217 let mut rx = String::from("(?i)^");
218 for ch in pattern.chars() {
219 match ch {
220 '*' => rx.push_str(r"\S*"),
221 '?' => rx.push_str(r"\S"),
222 _ => {
223 for escaped in regex::escape(&ch.to_string()).chars() {
224 rx.push(escaped);
225 }
226 }
227 }
228 }
229 rx.push('$');
230 rx
231}
232
233#[cfg(test)]
234mod test {
235 use super::*;
236
237 mod tag {
238 mod display {
239 use pretty_assertions::assert_eq;
240
241 use super::super::super::*;
242
243 #[test]
244 fn it_formats_tag_without_value() {
245 let tag = Tag::new("coding", None::<String>);
246
247 assert_eq!(tag.to_string(), "@coding");
248 }
249
250 #[test]
251 fn it_formats_tag_with_value() {
252 let tag = Tag::new("done", Some("2024-03-17 14:00"));
253
254 assert_eq!(tag.to_string(), "@done(2024-03-17 14:00)");
255 }
256 }
257
258 mod eq {
259 use super::super::super::*;
260
261 #[test]
262 fn it_matches_case_insensitively() {
263 let a = Tag::new("Done", Some("value"));
264 let b = Tag::new("done", Some("value"));
265
266 assert_eq!(a, b);
267 }
268
269 #[test]
270 fn it_does_not_match_different_values() {
271 let a = Tag::new("done", Some("a"));
272 let b = Tag::new("done", Some("b"));
273
274 assert_ne!(a, b);
275 }
276 }
277
278 mod new {
279 use pretty_assertions::assert_eq;
280
281 use super::super::super::*;
282
283 #[test]
284 fn it_strips_at_prefix() {
285 let tag = Tag::new("@coding", None::<String>);
286
287 assert_eq!(tag.name(), "coding");
288 }
289
290 #[test]
291 fn it_preserves_original_case() {
292 let tag = Tag::new("MyTag", None::<String>);
293
294 assert_eq!(tag.name(), "MyTag");
295 }
296 }
297 }
298
299 mod tags {
300 mod add {
301 use pretty_assertions::assert_eq;
302
303 use super::super::super::*;
304
305 #[test]
306 fn it_adds_a_new_tag() {
307 let mut tags = Tags::new();
308 tags.add(Tag::new("coding", None::<String>));
309
310 assert_eq!(tags.len(), 1);
311 assert!(tags.has("coding"));
312 }
313
314 #[test]
315 fn it_replaces_existing_tag_with_same_name() {
316 let mut tags = Tags::new();
317 tags.add(Tag::new("done", None::<String>));
318 tags.add(Tag::new("done", Some("2024-03-17")));
319
320 assert_eq!(tags.len(), 1);
321 assert_eq!(tags.iter().next().unwrap().value(), Some("2024-03-17"));
322 }
323 }
324
325 mod dedup {
326 use pretty_assertions::assert_eq;
327
328 use super::super::super::*;
329
330 #[test]
331 fn it_removes_case_insensitive_duplicates() {
332 let mut tags = Tags::from_iter(vec![
333 Tag::new("coding", None::<String>),
334 Tag::new("Coding", None::<String>),
335 Tag::new("CODING", None::<String>),
336 ]);
337
338 tags.dedup();
339
340 assert_eq!(tags.len(), 1);
341 assert_eq!(tags.iter().next().unwrap().name(), "coding");
342 }
343 }
344
345 mod display {
346 use pretty_assertions::assert_eq;
347
348 use super::super::super::*;
349
350 #[test]
351 fn it_joins_tags_with_spaces() {
352 let tags = Tags::from_iter(vec![
353 Tag::new("coding", None::<String>),
354 Tag::new("done", Some("2024-03-17")),
355 ]);
356
357 assert_eq!(tags.to_string(), "@coding @done(2024-03-17)");
358 }
359 }
360
361 mod has {
362 use super::super::super::*;
363
364 #[test]
365 fn it_finds_tag_case_insensitively() {
366 let mut tags = Tags::new();
367 tags.add(Tag::new("Coding", None::<String>));
368
369 assert!(tags.has("coding"));
370 assert!(tags.has("CODING"));
371 assert!(tags.has("Coding"));
372 }
373
374 #[test]
375 fn it_handles_at_prefix() {
376 let mut tags = Tags::new();
377 tags.add(Tag::new("coding", None::<String>));
378
379 assert!(tags.has("@coding"));
380 }
381 }
382
383 mod matches_wildcard {
384 use super::super::super::*;
385
386 #[test]
387 fn it_matches_star_wildcard() {
388 let tags = Tags::from_iter(vec![Tag::new("coding", None::<String>)]);
389
390 assert!(tags.matches_wildcard("cod*"));
391 assert!(tags.matches_wildcard("*ing"));
392 assert!(tags.matches_wildcard("*"));
393 }
394
395 #[test]
396 fn it_matches_question_mark_wildcard() {
397 let tags = Tags::from_iter(vec![Tag::new("done", None::<String>)]);
398
399 assert!(tags.matches_wildcard("d?ne"));
400 assert!(!tags.matches_wildcard("d?e"));
401 }
402
403 #[test]
404 fn it_matches_case_insensitively() {
405 let tags = Tags::from_iter(vec![Tag::new("Coding", None::<String>)]);
406
407 assert!(tags.matches_wildcard("coding"));
408 assert!(tags.matches_wildcard("CODING"));
409 }
410
411 #[test]
412 fn it_strips_at_prefix_from_pattern() {
413 let tags = Tags::from_iter(vec![Tag::new("coding", None::<String>)]);
414
415 assert!(tags.matches_wildcard("@coding"));
416 }
417 }
418
419 mod remove {
420 use pretty_assertions::assert_eq;
421
422 use super::super::super::*;
423
424 #[test]
425 fn it_removes_tag_by_name() {
426 let mut tags = Tags::from_iter(vec![
427 Tag::new("coding", None::<String>),
428 Tag::new("done", None::<String>),
429 ]);
430
431 let removed = tags.remove("coding");
432
433 assert_eq!(removed, 1);
434 assert_eq!(tags.len(), 1);
435 assert!(!tags.has("coding"));
436 }
437
438 #[test]
439 fn it_removes_case_insensitively() {
440 let mut tags = Tags::from_iter(vec![Tag::new("Coding", None::<String>)]);
441
442 let removed = tags.remove("coding");
443
444 assert_eq!(removed, 1);
445 assert!(tags.is_empty());
446 }
447 }
448
449 mod remove_by_regex {
450 use pretty_assertions::assert_eq;
451
452 use super::super::super::*;
453
454 #[test]
455 fn it_removes_tags_matching_regex() {
456 let mut tags = Tags::from_iter(vec![
457 Tag::new("project-123", None::<String>),
458 Tag::new("project-456", None::<String>),
459 Tag::new("coding", None::<String>),
460 ]);
461
462 let removed = tags.remove_by_regex("^project-\\d+$");
463
464 assert_eq!(removed, 2);
465 assert_eq!(tags.len(), 1);
466 assert!(tags.has("coding"));
467 }
468
469 #[test]
470 fn it_matches_case_insensitively() {
471 let mut tags = Tags::from_iter(vec![Tag::new("Coding", None::<String>)]);
472
473 let removed = tags.remove_by_regex("^coding$");
474
475 assert_eq!(removed, 1);
476 assert!(tags.is_empty());
477 }
478
479 #[test]
480 fn it_returns_zero_for_invalid_regex() {
481 let mut tags = Tags::from_iter(vec![Tag::new("coding", None::<String>)]);
482
483 let removed = tags.remove_by_regex("[invalid");
484
485 assert_eq!(removed, 0);
486 assert_eq!(tags.len(), 1);
487 }
488 }
489
490 mod remove_by_wildcard {
491 use pretty_assertions::assert_eq;
492
493 use super::super::super::*;
494
495 #[test]
496 fn it_removes_tags_matching_wildcard() {
497 let mut tags = Tags::from_iter(vec![
498 Tag::new("project-a", None::<String>),
499 Tag::new("project-b", None::<String>),
500 Tag::new("coding", None::<String>),
501 ]);
502
503 let removed = tags.remove_by_wildcard("project-*");
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_wildcard("cod*");
515
516 assert_eq!(removed, 1);
517 assert!(tags.is_empty());
518 }
519
520 #[test]
521 fn it_strips_at_prefix_from_pattern() {
522 let mut tags = Tags::from_iter(vec![Tag::new("coding", None::<String>)]);
523
524 let removed = tags.remove_by_wildcard("@coding");
525
526 assert_eq!(removed, 1);
527 assert!(tags.is_empty());
528 }
529 }
530
531 mod rename {
532 use pretty_assertions::assert_eq;
533
534 use super::super::super::*;
535
536 #[test]
537 fn it_renames_matching_tags() {
538 let mut tags = Tags::from_iter(vec![
539 Tag::new("old_tag", Some("value")),
540 Tag::new("other", None::<String>),
541 ]);
542
543 let renamed = tags.rename("old_tag", "new_tag");
544
545 assert_eq!(renamed, 1);
546 assert!(tags.has("new_tag"));
547 assert!(!tags.has("old_tag"));
548 assert_eq!(tags.iter().next().unwrap().value(), Some("value"));
549 }
550
551 #[test]
552 fn it_renames_case_insensitively() {
553 let mut tags = Tags::from_iter(vec![Tag::new("OldTag", None::<String>)]);
554
555 let renamed = tags.rename("oldtag", "newtag");
556
557 assert_eq!(renamed, 1);
558 assert!(tags.has("newtag"));
559 }
560 }
561
562 mod rename_by_wildcard {
563 use pretty_assertions::assert_eq;
564
565 use super::super::super::*;
566
567 #[test]
568 fn it_renames_tags_matching_wildcard() {
569 let mut tags = Tags::from_iter(vec![
570 Tag::new("proj-a", Some("value")),
571 Tag::new("proj-b", None::<String>),
572 Tag::new("coding", None::<String>),
573 ]);
574
575 let renamed = tags.rename_by_wildcard("proj-*", "project");
576
577 assert_eq!(renamed, 2);
578 assert!(tags.has("project"));
579 assert!(!tags.has("proj-a"));
580 assert!(!tags.has("proj-b"));
581 }
582
583 #[test]
584 fn it_preserves_values() {
585 let mut tags = Tags::from_iter(vec![Tag::new("old", Some("val"))]);
586
587 tags.rename_by_wildcard("ol?", "new");
588
589 assert!(tags.has("new"));
590 assert_eq!(tags.iter().next().unwrap().value(), Some("val"));
591 }
592
593 #[test]
594 fn it_returns_zero_for_no_matches() {
595 let mut tags = Tags::from_iter(vec![Tag::new("coding", None::<String>)]);
596
597 let renamed = tags.rename_by_wildcard("proj-*", "project");
598
599 assert_eq!(renamed, 0);
600 assert!(tags.has("coding"));
601 }
602 }
603 }
604
605 mod wildcard_to_regex {
606 use super::*;
607
608 #[test]
609 fn it_converts_star_to_non_whitespace_pattern() {
610 let result = wildcard_to_regex("do*");
611
612 assert!(result.contains(r"\S*"));
613 }
614
615 #[test]
616 fn it_converts_question_mark_to_single_non_whitespace() {
617 let result = wildcard_to_regex("d?ne");
618
619 assert!(result.contains(r"\S"));
620 }
621
622 #[test]
623 fn it_escapes_regex_special_characters() {
624 let result = wildcard_to_regex("tag.name");
625
626 assert!(result.contains(r"\."));
627 }
628 }
629}