1use std::collections::{BTreeMap, BTreeSet};
15
16use crate::text;
17
18use super::{
19 Artist, AudioFile, Catalog, EntityKind, Genre, Id, Label, Recording, Relation, Release,
20 ReleaseGroup, Track, Work, is_performing_role,
21};
22
23impl Catalog {
24 pub fn release_added_at(&self, id: Id) -> Option<u64> {
26 let release = self.release(id)?;
27 let mut earliest = None;
28 for id in &release.track_ids {
29 let file = self.file(self.track(*id)?.file_id)?;
30 let added = *self.file_added_at.get(&file.path)?;
31 earliest = Some(earliest.map_or(added, |old: u64| old.min(added)));
32 }
33 earliest
34 }
35
36 pub fn playback_release_order(&self, mut ids: Vec<Id>) -> Vec<Id> {
39 ids.sort_by(|a, b| {
40 self.release(*a)
41 .map(|r| (r.year.unwrap_or(u32::MAX), &r.folder, &r.title))
42 .cmp(
43 &self
44 .release(*b)
45 .map(|r| (r.year.unwrap_or(u32::MAX), &r.folder, &r.title)),
46 )
47 });
48 ids
49 }
50
51 pub fn artist(&self, id: Id) -> Option<&Artist> {
53 self.artists.get(id as usize)
54 }
55
56 pub fn release(&self, id: Id) -> Option<&Release> {
58 self.releases.get(id as usize)
59 }
60
61 pub fn release_group(&self, id: Id) -> Option<&ReleaseGroup> {
63 self.release_groups.get(id as usize)
64 }
65
66 pub fn track(&self, id: Id) -> Option<&Track> {
68 self.tracks.get(id as usize)
69 }
70
71 pub fn recording(&self, id: Id) -> Option<&Recording> {
73 self.recordings.get(id as usize)
74 }
75
76 pub fn tracks_of_recording(&self, recording_id: Id) -> Vec<Id> {
78 self.recording(recording_id)
79 .map(|recording| recording.track_ids.clone())
80 .unwrap_or_default()
81 }
82
83 pub fn work(&self, id: Id) -> Option<&Work> {
85 self.works.get(id as usize)
86 }
87
88 pub fn recordings_of_work(&self, work_id: Id) -> Vec<Id> {
90 self.work(work_id)
91 .map(|work| work.recording_ids.clone())
92 .unwrap_or_default()
93 }
94
95 pub fn find_recordings(&self, query: &str) -> Vec<&Recording> {
97 if let Some(path) = query.strip_prefix("local:") {
101 return self
102 .recordings
103 .iter()
104 .filter(|recording| {
105 recording.track_ids.iter().any(|&track_id| {
106 self.track(track_id)
107 .and_then(|track| self.file(track.file_id))
108 .is_some_and(|file| file.path == path)
109 })
110 })
111 .collect();
112 }
113 let key = text::normalize(query);
114 self.recordings
115 .iter()
116 .filter(|recording| {
117 recording.key == key
118 || recording.key.contains(&key)
119 || recording.mbid.as_deref() == Some(query)
120 })
121 .collect()
122 }
123
124 pub fn find_works(&self, query: &str) -> Vec<&Work> {
126 let key = text::normalize(query);
127 self.works
128 .iter()
129 .filter(|work| work.key == key || work.key.contains(&key) || work.mbid == query)
130 .collect()
131 }
132
133 pub fn find_release_groups(&self, query: &str) -> Vec<&ReleaseGroup> {
135 let key = text::normalize(query);
136 self.release_groups
137 .iter()
138 .filter(|group| group.key == key || group.key.contains(&key) || group.mbid == query)
139 .collect()
140 }
141
142 pub fn file(&self, id: Id) -> Option<&AudioFile> {
144 self.files.get(id as usize)
145 }
146
147 pub fn relations_from(&self, kind: EntityKind, id: Id) -> Vec<&Relation> {
149 self.relations
150 .iter()
151 .filter(|relation| relation.source_kind == kind && relation.source_id == id)
152 .collect()
153 }
154
155 pub fn related_entities(
157 &self,
158 kind: EntityKind,
159 id: Id,
160 relation_kind: &str,
161 target_kind: EntityKind,
162 ) -> Vec<Id> {
163 self.relations
164 .iter()
165 .filter(|relation| {
166 relation.source_kind == kind
167 && relation.source_id == id
168 && relation.target_kind == target_kind
169 && relation.kind == relation_kind
170 })
171 .map(|relation| relation.target_id)
172 .collect()
173 }
174
175 pub fn analyses_of(
177 &self,
178 file: &AudioFile,
179 ) -> impl Iterator<Item = &crate::analysis::FileAnalysis> {
180 self.analyses.iter().filter(|a| a.path == file.path)
181 }
182
183 pub fn pending_analyses_list(&self) -> Vec<&crate::analysis::FileAnalysis> {
192 let known: BTreeSet<&str> = self.files.iter().map(|f| f.path.as_str()).collect();
193 self.analyses
194 .iter()
195 .filter(|a| !known.contains(a.path.as_str()))
196 .collect()
197 }
198
199 pub fn pending_analyses(&self) -> usize {
206 self.pending_analyses_list().len()
207 }
208
209 pub fn label(&self, id: Id) -> Option<&Label> {
211 self.labels.get(id as usize)
212 }
213
214 pub fn genre(&self, id: Id) -> Option<&Genre> {
216 self.genres.get(id as usize)
217 }
218
219 pub fn total_duration_ms(&self) -> u64 {
221 self.tracks
222 .iter()
223 .filter_map(|t| t.duration_ms)
224 .fold(0, u64::saturating_add)
225 }
226
227 pub fn total_size(&self) -> u64 {
229 self.files
230 .iter()
231 .map(|f| f.size)
232 .fold(0, u64::saturating_add)
233 }
234
235 pub fn releases_as_album_artist(&self, artist_id: Id) -> Vec<Id> {
240 self.releases
241 .iter()
242 .filter(|r| r.album_artist_id == Some(artist_id))
243 .map(|r| r.id)
244 .collect()
245 }
246
247 pub fn guest_appearances(&self, artist_id: Id) -> Vec<Id> {
250 let own: BTreeSet<Id> = self
251 .releases_as_album_artist(artist_id)
252 .into_iter()
253 .collect();
254 self.releases_for(artist_id, true)
255 .into_iter()
256 .filter(|id| !own.contains(id))
257 .collect()
258 }
259
260 pub fn releases_with_writing_credit(&self, artist_id: Id) -> Vec<Id> {
267 self.releases_for(artist_id, false)
268 }
269
270 pub fn writing_tracks_of_artist(&self, artist_id: Id) -> Vec<Id> {
272 let mut set = BTreeSet::new();
273 for credit in self.credits.iter() {
274 if credit.artist_id == artist_id
275 && credit.entity_kind == EntityKind::Track
276 && !is_performing_role(&credit.role)
277 {
278 set.insert(credit.entity_id);
279 }
280 }
281 set.into_iter().collect()
282 }
283
284 pub fn releases_written_without_performing(&self, artist_id: Id) -> Vec<Id> {
291 let heard: BTreeSet<Id> = self
292 .releases_as_album_artist(artist_id)
293 .into_iter()
294 .chain(self.guest_appearances(artist_id))
295 .collect();
296 self.releases_for(artist_id, false)
297 .into_iter()
298 .filter(|id| !heard.contains(id))
299 .collect()
300 }
301
302 fn releases_for(&self, artist_id: Id, performing: bool) -> Vec<Id> {
305 let mut out = BTreeSet::new();
306 for credit in self.credits.iter().filter(|c| c.artist_id == artist_id) {
307 if is_performing_role(&credit.role) != performing {
308 continue;
309 }
310 let release_id = match credit.entity_kind {
311 EntityKind::Release => Some(credit.entity_id),
312 EntityKind::Track => self.track(credit.entity_id).and_then(|t| t.release_id),
313 _ => None,
314 };
315 if let Some(id) = release_id {
316 out.insert(id);
317 }
318 }
319 out.into_iter().collect()
320 }
321
322 pub fn performed_tracks_of_artist(&self, artist_id: Id) -> Vec<Id> {
324 let mut set = BTreeSet::new();
325 for credit in self.credits.iter() {
326 if credit.artist_id == artist_id
327 && credit.entity_kind == EntityKind::Track
328 && is_performing_role(&credit.role)
329 {
330 set.insert(credit.entity_id);
331 }
332 }
333 set.into_iter().collect()
334 }
335
336 pub fn written_tracks_without_performing(&self, artist_id: Id) -> Vec<Id> {
344 let performed: BTreeSet<Id> = self
345 .performed_tracks_of_artist(artist_id)
346 .into_iter()
347 .collect();
348 let mut set = BTreeSet::new();
349 for credit in self.credits.iter() {
350 if credit.artist_id == artist_id
351 && credit.entity_kind == EntityKind::Track
352 && !is_performing_role(&credit.role)
353 && !performed.contains(&credit.entity_id)
354 {
355 set.insert(credit.entity_id);
356 }
357 }
358 set.into_iter().collect()
359 }
360
361 pub fn releases_of_artist(&self, artist_id: Id) -> Vec<Id> {
363 let mut set = BTreeSet::new();
364 for credit in self.credits.iter().filter(|c| c.artist_id == artist_id) {
365 match credit.entity_kind {
366 EntityKind::Release => {
367 set.insert(credit.entity_id);
368 }
369 EntityKind::Track => {
370 if let Some(release_id) =
371 self.track(credit.entity_id).and_then(|t| t.release_id)
372 {
373 set.insert(release_id);
374 }
375 }
376 _ => {}
377 }
378 }
379 set.into_iter().collect()
380 }
381
382 pub fn tracks_of_artist(&self, artist_id: Id) -> Vec<Id> {
387 let mut set = BTreeSet::new();
388 for credit in self.credits.iter() {
389 if credit.artist_id == artist_id && credit.entity_kind == EntityKind::Track {
390 set.insert(credit.entity_id);
391 }
392 }
393 set.into_iter().collect()
394 }
395
396 pub fn credits_on(&self, kind: EntityKind, id: Id) -> Vec<(&Artist, &str)> {
398 self.credits
399 .iter()
400 .filter(|c| c.entity_kind == kind && c.entity_id == id)
401 .filter_map(|c| self.artist(c.artist_id).map(|a| (a, c.role.as_str())))
402 .collect()
403 }
404
405 pub fn neighbours_of_artist(&self, artist_id: Id) -> Vec<(&Artist, u32, &str)> {
407 let mut out: Vec<(&Artist, u32, &str)> = self
408 .relations
409 .iter()
410 .filter(|r| {
411 r.source_kind == EntityKind::Artist
412 && r.source_id == artist_id
413 && r.target_kind == EntityKind::Artist
414 && r.kind == "collaborated"
415 })
416 .filter_map(|r| {
417 self.artist(r.target_id)
418 .map(|a| (a, r.weight, r.kind.as_str()))
419 })
420 .collect();
421 out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.name.cmp(&b.0.name)));
422 out
423 }
424
425 pub fn tracks_in_common(&self, a: Id, b: Id) -> Vec<Id> {
431 let performing_on = |artist: Id| -> BTreeSet<Id> {
432 self.credits
433 .iter()
434 .filter(|c| {
435 c.entity_kind == EntityKind::Track
436 && c.artist_id == artist
437 && is_performing_role(&c.role)
438 })
439 .map(|c| c.entity_id)
440 .collect()
441 };
442 let (left, right) = (performing_on(a), performing_on(b));
443 left.intersection(&right).copied().collect()
444 }
445
446 pub fn related_releases(&self, release_id: Id, kind: &str) -> Vec<Id> {
451 self.relations
452 .iter()
453 .filter(|r| {
454 r.source_kind == EntityKind::Release
455 && r.source_id == release_id
456 && r.target_kind == EntityKind::Release
457 && r.kind == kind
458 })
459 .map(|r| r.target_id)
460 .collect()
461 }
462
463 pub fn genres_of(&self, kind: EntityKind, id: Id) -> Vec<&Genre> {
465 self.genre_links
466 .iter()
467 .filter(|g| g.entity_kind == kind && g.entity_id == id)
468 .filter_map(|g| self.genre(g.genre_id))
469 .collect()
470 }
471
472 pub fn search(&self, query: &str, limit: usize) -> Vec<SearchHit> {
474 let needle = text::normalize(query);
475 if needle.is_empty() {
476 return Vec::new();
477 }
478 let mut hits: Vec<SearchHit> = Vec::new();
479
480 let mut push = |kind: EntityKind, id: Id, name: &str, key: &str, detail: String| {
481 let score = match () {
482 _ if key == needle => 0u8,
483 _ if key.starts_with(&needle) => 1,
484 _ if key.contains(&needle) => 2,
485 _ => return,
486 };
487 hits.push(SearchHit {
488 kind,
489 id,
490 name: name.to_string(),
491 detail,
492 score,
493 });
494 };
495
496 for a in &self.artists {
497 push(EntityKind::Artist, a.id, &a.name, &a.key, String::new());
498 }
499 for r in &self.releases {
500 let detail = r
501 .album_artist_id
502 .and_then(|id| self.artist(id))
503 .map(|a| a.name.clone())
504 .unwrap_or_else(|| "Various Artists".to_string());
505 push(EntityKind::Release, r.id, &r.title, &r.key, detail);
506 }
507 for t in &self.tracks {
508 let key = text::normalize(&t.title);
509 let detail = t
510 .release_id
511 .and_then(|id| self.release(id))
512 .map(|r| r.title.clone())
513 .unwrap_or_default();
514 push(EntityKind::Track, t.id, &t.title, &key, detail);
515 }
516 for recording in &self.recordings {
517 push(
518 EntityKind::Recording,
519 recording.id,
520 &recording.title,
521 &recording.key,
522 ui_count(recording.track_ids.len(), "local placement"),
523 );
524 }
525 for work in &self.works {
526 push(
527 EntityKind::Work,
528 work.id,
529 &work.title,
530 &work.key,
531 ui_count(work.recording_ids.len(), "recording"),
532 );
533 }
534 for group in &self.release_groups {
535 push(
536 EntityKind::ReleaseGroup,
537 group.id,
538 &group.title,
539 &group.key,
540 ui_count(group.release_ids.len(), "local edition"),
541 );
542 }
543 for l in &self.labels {
544 push(EntityKind::Label, l.id, &l.name, &l.key, String::new());
545 }
546
547 hits.sort_by(|a, b| {
548 a.score
549 .cmp(&b.score)
550 .then_with(|| a.kind.cmp(&b.kind))
551 .then_with(|| a.name.len().cmp(&b.name.len()))
552 .then_with(|| a.name.cmp(&b.name))
553 });
554 hits.truncate(limit);
555 hits
556 }
557
558 pub fn find_artist(&self, name: &str) -> Option<&Artist> {
560 let key = text::normalize(name);
561 self.artists.iter().find(|a| a.key == key)
562 }
563
564 pub fn find_artists(&self, name: &str) -> (Vec<&Artist>, TitleMatch) {
580 let key = text::normalize(name);
581 if key.is_empty() {
582 return (Vec::new(), TitleMatch::Exact);
583 }
584 let exact: Vec<&Artist> = self.artists.iter().filter(|a| a.key == key).collect();
585 if !exact.is_empty() {
586 return (exact, TitleMatch::Exact);
587 }
588 let aliases: Vec<&Artist> = self
589 .artists
590 .iter()
591 .filter(|artist| {
592 artist
593 .aliases
594 .iter()
595 .any(|alias| text::normalize(alias) == key)
596 })
597 .collect();
598 if !aliases.is_empty() {
599 return (aliases, TitleMatch::Exact);
600 }
601 let partial = self
602 .artists
603 .iter()
604 .filter(|artist| {
605 artist.key.contains(&key)
606 || artist
607 .aliases
608 .iter()
609 .any(|alias| text::normalize(alias).contains(&key))
610 })
611 .collect();
612 (partial, TitleMatch::Partial)
613 }
614
615 pub fn find_releases(&self, title: &str) -> (Vec<&Release>, TitleMatch) {
623 let identified: Vec<&Release> = self
624 .releases
625 .iter()
626 .filter(|release| release.mbid.as_deref() == Some(title))
627 .collect();
628 if !identified.is_empty() {
629 return (identified, TitleMatch::Exact);
630 }
631 let key = text::normalize(title);
632 if key.is_empty() {
633 return (Vec::new(), TitleMatch::Exact);
634 }
635 let exact: Vec<&Release> = self.releases.iter().filter(|r| r.key == key).collect();
636 if !exact.is_empty() {
637 return (exact, TitleMatch::Exact);
638 }
639 let partial = self
640 .releases
641 .iter()
642 .filter(|r| r.key.contains(&key))
643 .collect();
644 (partial, TitleMatch::Partial)
645 }
646
647 pub fn find_tracks(&self, title: &str) -> (Vec<&Track>, TitleMatch) {
657 let key = text::normalize(title);
658 if key.is_empty() {
659 return (Vec::new(), TitleMatch::Exact);
660 }
661 let exact: Vec<&Track> = self
662 .tracks
663 .iter()
664 .filter(|t| text::normalize(&t.title) == key)
665 .collect();
666 if !exact.is_empty() {
667 return (exact, TitleMatch::Exact);
668 }
669 let partial = self
670 .tracks
671 .iter()
672 .filter(|t| text::normalize(&t.title).contains(&key))
673 .collect();
674 (partial, TitleMatch::Partial)
675 }
676
677 pub fn find_genres(&self, name: &str) -> (Vec<&Genre>, TitleMatch) {
683 let key = text::normalize(name);
684 if key.is_empty() {
685 return (Vec::new(), TitleMatch::Exact);
686 }
687 let exact: Vec<&Genre> = self.genres.iter().filter(|g| g.key == key).collect();
688 if !exact.is_empty() {
689 return (exact, TitleMatch::Exact);
690 }
691 (
692 self.genres
693 .iter()
694 .filter(|g| g.key.contains(&key))
695 .collect(),
696 TitleMatch::Partial,
697 )
698 }
699
700 pub fn find_labels(&self, name: &str) -> (Vec<&Label>, TitleMatch) {
702 let key = text::normalize(name);
703 if key.is_empty() {
704 return (Vec::new(), TitleMatch::Exact);
705 }
706 let exact: Vec<&Label> = self.labels.iter().filter(|l| l.key == key).collect();
707 if !exact.is_empty() {
708 return (exact, TitleMatch::Exact);
709 }
710 (
711 self.labels
712 .iter()
713 .filter(|l| l.key.contains(&key))
714 .collect(),
715 TitleMatch::Partial,
716 )
717 }
718
719 pub fn tracks_of_genre(&self, genre_id: Id) -> Vec<Id> {
724 let mut tracks: BTreeSet<Id> = BTreeSet::new();
725 for link in &self.genre_links {
726 if link.genre_id != genre_id {
727 continue;
728 }
729 match link.entity_kind {
730 EntityKind::Track => {
731 tracks.insert(link.entity_id);
732 }
733 EntityKind::Release => {
734 if let Some(release) = self.release(link.entity_id) {
735 tracks.extend(release.track_ids.iter().copied());
736 }
737 }
738 EntityKind::Artist
739 | EntityKind::Recording
740 | EntityKind::Work
741 | EntityKind::ReleaseGroup
742 | EntityKind::Label
743 | EntityKind::Genre => {}
744 }
745 }
746 tracks.into_iter().collect()
747 }
748
749 pub fn releases_of_genre(&self, genre_id: Id) -> Vec<Id> {
751 let tracks: BTreeSet<Id> = self.tracks_of_genre(genre_id).into_iter().collect();
752 let mut releases: BTreeSet<Id> = BTreeSet::new();
753 for track in self.tracks.iter().filter(|t| tracks.contains(&t.id)) {
754 if let Some(id) = track.release_id {
755 releases.insert(id);
756 }
757 }
758 releases.into_iter().collect()
759 }
760
761 pub fn tracks_of_label(&self, label_id: Id) -> Vec<Id> {
763 self.releases
764 .iter()
765 .filter(|r| r.label_ids.contains(&label_id))
766 .flat_map(|r| r.track_ids.iter().copied())
767 .collect()
768 }
769
770 pub fn releases_of_label(&self, label_id: Id) -> Vec<Id> {
772 self.releases
773 .iter()
774 .filter(|r| r.label_ids.contains(&label_id))
775 .map(|r| r.id)
776 .collect()
777 }
778
779 pub fn artists_in_role(&self, role: &str) -> Vec<(Id, usize)> {
789 let mut counts: BTreeMap<Id, usize> = BTreeMap::new();
790 for credit in self.credits.iter().filter(|c| c.role == role) {
791 *counts.entry(credit.artist_id).or_insert(0) += 1;
792 }
793 let mut rows: Vec<(Id, usize)> = counts.into_iter().collect();
794 rows.sort_by(|a, b| {
795 b.1.cmp(&a.1).then_with(|| {
796 self.artist(a.0)
797 .map(|x| x.sort_name.as_str())
798 .cmp(&self.artist(b.0).map(|x| x.sort_name.as_str()))
799 })
800 });
801 rows
802 }
803
804 pub fn tracks_of_artist_in_role(&self, artist_id: Id, role: &str) -> Vec<Id> {
815 let mut tracks: BTreeSet<Id> = BTreeSet::new();
816 for credit in self
817 .credits
818 .iter()
819 .filter(|c| c.artist_id == artist_id && c.role == role)
820 {
821 match credit.entity_kind {
822 EntityKind::Track => {
823 tracks.insert(credit.entity_id);
824 }
825 EntityKind::Release => {
826 if let Some(release) = self.release(credit.entity_id) {
827 tracks.extend(release.track_ids.iter().copied());
828 }
829 }
830 EntityKind::Artist
831 | EntityKind::Recording
832 | EntityKind::Work
833 | EntityKind::ReleaseGroup
834 | EntityKind::Label
835 | EntityKind::Genre => {}
836 }
837 }
838 tracks.into_iter().collect()
839 }
840
841 pub fn roles_of_artist(&self, artist_id: Id) -> Vec<(&str, usize)> {
847 let mut counts: BTreeMap<&str, usize> = BTreeMap::new();
848 for credit in self.credits.iter().filter(|c| c.artist_id == artist_id) {
849 *counts.entry(credit.role.as_str()).or_insert(0) += 1;
850 }
851 let mut rows: Vec<(&str, usize)> = counts.into_iter().collect();
852 rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(b.0)));
853 rows
854 }
855
856 pub fn roles_in_use(&self) -> Vec<&str> {
861 let mut roles: BTreeSet<&str> = BTreeSet::new();
862 for credit in &self.credits {
863 roles.insert(credit.role.as_str());
864 }
865 roles.into_iter().collect()
866 }
867
868 pub fn tracks_with_comment(&self, text: &str) -> Vec<Id> {
875 let needle = text::normalize(text);
876 if needle.is_empty() {
877 return Vec::new();
878 }
879 self.tracks
880 .iter()
881 .filter(|t| {
882 self.file(t.file_id)
883 .and_then(|f| f.first_tag("comment"))
884 .map(|c| text::normalize(c).contains(&needle))
885 .unwrap_or(false)
886 })
887 .map(|t| t.id)
888 .collect()
889 }
890
891 pub fn lyrics_of_track(&self, track_id: Id) -> Option<crate::lyrics::Lyrics> {
901 let file = self.file(self.track(track_id)?.file_id)?;
902 file.first_tag("lyrics")
903 .and_then(|text| crate::lyrics::from_tag(&file.path, text))
904 .or_else(|| crate::lyrics::read(std::path::Path::new(file.lyrics_path.as_ref()?)))
905 }
906
907 pub fn tracks_with_lyric(&self, text: &str) -> Vec<(Id, String)> {
914 let needle = text::normalize(text);
915 if needle.is_empty() {
916 return Vec::new();
917 }
918 self.tracks
919 .iter()
920 .filter_map(|track| {
921 let lyrics = self.lyrics_of_track(track.id)?;
922 let line = lyrics
923 .lines
924 .iter()
925 .find(|line| text::normalize(&line.text).contains(&needle))?;
926 Some((track.id, line.text.clone()))
927 })
928 .collect()
929 }
930
931 pub fn comment_of_track(&self, track_id: Id) -> Option<&str> {
933 let track = self.track(track_id)?;
934 self.file(track.file_id)?.first_tag("comment")
935 }
936}
937
938fn ui_count(count: usize, noun: &str) -> String {
939 format!("{count} {noun}{}", if count == 1 { "" } else { "s" })
940}
941
942#[derive(Debug, Clone, Copy, PartialEq, Eq)]
944pub enum TitleMatch {
945 Exact,
947 Partial,
949}
950
951#[derive(Debug, Clone)]
953pub struct SearchHit {
954 pub kind: EntityKind,
956 pub id: Id,
958 pub name: String,
960 pub detail: String,
962 score: u8,
963}
964
965#[cfg(test)]
966#[path = "query_tests.rs"]
967mod tests;