aede_core/
doctor.rs

1//! Library diagnosis.
2//!
3//! A real library is always damaged: missing tags, duplicates, incomplete
4//! albums, mixed editions. This module brings them to light without changing
5//! anything — automatic correction will come later, and will have to be
6//! reversible.
7
8use std::collections::{BTreeMap, BTreeSet};
9
10use crate::model::{self, Catalog, Id};
11use crate::text;
12
13/// How much a problem hurts, from the most serious to the least.
14///
15/// The declaration order is the order of the report: the derived comparison puts `Error` before
16/// `Warning` before `Info`, and [`diagnose`] sorts on it.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
18pub enum Severity {
19    /// Prevents the rest from working (no title, no duration).
20    Error,
21    /// Degrades browsing or ranking.
22    Warning,
23    /// Plain completeness remark.
24    Info,
25}
26
27impl Severity {
28    /// Short lowercase name of the level, meant to prefix a report line or to filter on.
29    pub fn label(self) -> &'static str {
30        match self {
31            Severity::Error => "error",
32            Severity::Warning => "warning",
33            Severity::Info => "info",
34        }
35    }
36}
37
38/// Nature of a problem found in the library, independent of the file it was found on.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
40pub enum IssueKind {
41    /// No title in the tags: the one on display was inferred from the file name.
42    MissingTitle,
43    /// No artist in the tags, so the track hangs off no discography.
44    MissingArtist,
45    /// No album in the tags: the track will stay on its own, outside any release.
46    MissingAlbum,
47    /// No usable year, which leaves the track out of any chronological ordering.
48    MissingDate,
49    /// No track number, so playback order within the album cannot be guaranteed.
50    MissingTrackNumber,
51    /// Duration could not be read, which usually points at a truncated or corrupted file.
52    MissingDuration,
53    /// Same artist and title heard again at a near-identical duration: the copies waste space.
54    DuplicateTrack,
55    /// The same audio, proved by fingerprint rather than guessed from tags.
56    ///
57    /// Stronger than [`IssueKind::DuplicateTrack`] and it catches what that
58    /// cannot: two files whose tags say quite different things, or nothing at
59    /// all, and whose sound is the same recording. A tag-based guess has to
60    /// call itself "likely"; this one does not.
61    SameAudio,
62    /// A whole album is present twice, in the same quality: one of the two copies is dead weight.
63    DuplicateAlbum,
64    /// The same album is kept in two encodings. Not a defect — worth knowing.
65    OtherEdition,
66    /// A decoder found the audio no longer matches the MD5 the file carries.
67    Md5Mismatch,
68    /// Tracks missing from a disc, the sign of a rip left unfinished — whether
69    /// they are gaps in the numbering or a tail short of the announced total.
70    IncompleteAlbum,
71    /// One release mixes codecs or sample rates, which usually means it was assembled from
72    /// different sources.
73    MixedQuality,
74    /// The release carries no cover art, neither embedded nor beside the files.
75    MissingCover,
76    /// The announced year falls outside the plausible range for a recording.
77    SuspiciousYear,
78    /// A checksum carried by the file does not match its contents: the audio
79    /// has been damaged since it was written.
80    DamagedAudio,
81    /// Two artist rows that look like one musician written two ways.
82    ///
83    /// **Suggested, never applied**, and that is the whole design. A shared
84    /// `MUSICBRAINZ_ARTISTID` merges two spellings with no heuristic at all;
85    /// this is the other half of the problem — files that never met
86    /// MusicBrainz — where nobody outside the owner of the disk can know the
87    /// answer. Matching on a fragment of a name merges Angus Young with Neil
88    /// Young, so what this does is put a pair in front of somebody who can
89    /// tell, and name the command that acts on it.
90    SameArtistMaybe,
91    /// A source and the tags say different things about the same release.
92    ///
93    /// Reported, never resolved. Which of the two is right is not something
94    /// this program can know — a tag may be wrong, and so may MusicBrainz —
95    /// and the whole reason a fetched value is kept beside the tag rather than
96    /// on top of it is that the disagreement stays visible and the user
97    /// decides.
98    SourceDisagrees,
99    /// Two trusted external sources contradict one another.
100    SourcesDisagree,
101    /// An approximate attachment has not been accepted or rejected yet.
102    SourceNeedsReview,
103    /// An exact MusicBrainz identity contradicts the identifier in local tags.
104    SourceIdentityConflict,
105    /// A trusted recording identity exists, but its relationship pass has not
106    /// completed, so credits and works may be missing.
107    IncompleteSourceCredits,
108    /// A personal note points to a relationship no longer in the current graph.
109    OrphanedRelationAnnotation,
110}
111
112impl IssueKind {
113    /// Weight given to this kind of problem, which fixes its rank in the report.
114    pub fn severity(self) -> Severity {
115        match self {
116            IssueKind::MissingTitle
117            | IssueKind::MissingArtist
118            | IssueKind::MissingDuration
119            | IssueKind::DamagedAudio
120            | IssueKind::Md5Mismatch => Severity::Error,
121            IssueKind::MissingAlbum
122            | IssueKind::DuplicateTrack
123            | IssueKind::SameAudio
124            | IssueKind::DuplicateAlbum
125            | IssueKind::IncompleteAlbum
126            | IssueKind::MixedQuality
127            | IssueKind::SuspiciousYear => Severity::Warning,
128            IssueKind::MissingDate
129            | IssueKind::MissingTrackNumber
130            | IssueKind::MissingCover
131            | IssueKind::SourceDisagrees
132            | IssueKind::SourcesDisagree
133            | IssueKind::SourceNeedsReview
134            | IssueKind::SourceIdentityConflict
135            | IssueKind::IncompleteSourceCredits
136            | IssueKind::OrphanedRelationAnnotation
137            | IssueKind::SameArtistMaybe
138            | IssueKind::OtherEdition => Severity::Info,
139        }
140    }
141
142    /// Wording of the problem in one short phrase, ready to head a report line.
143    pub fn label(self) -> &'static str {
144        match self {
145            IssueKind::SourceDisagrees => "source disagrees",
146            IssueKind::SourcesDisagree => "sources disagree",
147            IssueKind::SourceNeedsReview => "source claim needs review",
148            IssueKind::SourceIdentityConflict => "source identity conflict",
149            IssueKind::IncompleteSourceCredits => "source credits incomplete",
150            IssueKind::OrphanedRelationAnnotation => "relation annotation is waiting",
151            // "possibly", and the word is doing work: nothing here is a
152            // finding, and a line reading "the same artist" would be a claim
153            // this program has no way to make.
154            IssueKind::SameArtistMaybe => "possibly one artist",
155            IssueKind::MissingTitle => "missing title",
156            IssueKind::MissingArtist => "missing artist",
157            IssueKind::MissingAlbum => "missing album",
158            IssueKind::MissingDate => "missing year",
159            IssueKind::MissingTrackNumber => "missing track number",
160            IssueKind::MissingDuration => "unreadable duration",
161            IssueKind::DuplicateTrack => "likely duplicate",
162            // Not "likely": the audio is the same audio. The word is what
163            // separates this from the guess above it, and a reader deciding
164            // whether to delete a file needs to know which of the two they
165            // are looking at.
166            IssueKind::SameAudio => "the same audio",
167            IssueKind::DuplicateAlbum => "album present twice",
168            IssueKind::OtherEdition => "album kept in two encodings",
169            IssueKind::Md5Mismatch => "audio does not match its MD5",
170            IssueKind::IncompleteAlbum => "incomplete album",
171            IssueKind::MixedQuality => "mixed quality within an album",
172            IssueKind::MissingCover => "missing cover art",
173            IssueKind::SuspiciousYear => "implausible year",
174            IssueKind::DamagedAudio => "damaged audio",
175        }
176    }
177}
178
179/// How many parts the file says the whole holds, if it says so at all.
180///
181/// One helper for two questions — how many tracks on this disc, how many discs
182/// in this set — because they are the same question asked of two vocabularies,
183/// and both are read the same way: a dedicated total field, or the trailing
184/// half of a `9/12` written into the number itself, which many taggers do.
185/// `field` names the total, `numbered` the field that may carry the pair.
186///
187/// Only the leading digits of a value are kept, and a value that does not parse
188/// is no answer rather than a wrong one. The names given here are the canonical
189/// ones: `tags::canonical_key` has already folded `totaltracks`, `disctotal`
190/// and the rest into a single spelling, so this must not keep a second list of
191/// aliases that would then have to be maintained beside it.
192fn announced(file: &crate::model::AudioFile, field: &str, numbered: &str) -> Option<u32> {
193    let leading = |value: &str| {
194        let digits: String = value
195            .trim()
196            .chars()
197            .take_while(|c| c.is_ascii_digit())
198            .collect();
199        digits.parse::<u32>().ok()
200    };
201    let from_pair = file
202        .first_tag(numbered)
203        .and_then(|value| value.split_once('/').map(|(_, total)| total.to_string()))
204        .and_then(|total| leading(&total));
205    let from_field = file.first_tag(field).and_then(leading);
206    from_pair.into_iter().chain(from_field).max()
207}
208
209/// How many tracks the file says its disc holds, if it says so at all.
210fn announced_total(file: &crate::model::AudioFile) -> Option<u32> {
211    announced(file, "tracktotal", "tracknumber")
212}
213
214/// How many discs the file says its set holds, if it says so at all.
215fn announced_discs(file: &crate::model::AudioFile) -> Option<u32> {
216    announced(file, "disctotal", "discnumber")
217}
218
219/// One observation made on the library, tied to the files that carry it.
220#[derive(Debug, Clone)]
221pub struct Issue {
222    /// What was observed, which also decides how serious the observation is.
223    pub kind: IssueKind,
224    /// Plain description, ready to be displayed.
225    pub detail: String,
226    /// Files concerned (a single one, or several for duplicates).
227    pub files: Vec<String>,
228}
229
230impl Issue {
231    /// Weight of the observation, taken from its kind: no problem carries a level of its own.
232    pub fn severity(&self) -> Severity {
233        self.kind.severity()
234    }
235}
236
237/// Full analysis. The result is sorted by severity then by kind, for a display
238/// that is stable from one run to the next.
239pub fn diagnose(catalog: &Catalog, sources: &crate::sources::Sources) -> Vec<Issue> {
240    let mut issues = Vec::new();
241    check_tracks(catalog, &mut issues);
242    check_integrity(catalog, &mut issues);
243    check_imported_analyses(catalog, &mut issues);
244    check_source_quality(catalog, sources, &mut issues);
245    check_sources(catalog, sources, &mut issues);
246    check_duplicate_albums(catalog, &mut issues);
247    check_other_editions(catalog, &mut issues);
248    check_duplicates(catalog, &mut issues);
249    check_same_audio(catalog, &mut issues);
250    check_releases(catalog, &mut issues);
251    check_same_person(catalog, &mut issues);
252
253    issues.sort_by(|a, b| {
254        a.severity()
255            .cmp(&b.severity())
256            .then_with(|| a.kind.cmp(&b.kind))
257            .then_with(|| a.files.cmp(&b.files))
258    });
259    issues
260}
261
262/// Personal relationship annotations whose edge is absent from the current graph.
263///
264/// They are kept rather than deleted: a disconnected drive, a rejected source
265/// claim, or a later rescan can make the relationship visible again.
266/// Account-specific callers must first scope the supplied relationship
267/// annotations to their owner; this generic helper does not select one.
268pub fn orphaned_relation_annotations(
269    catalog: &Catalog,
270    sources: &crate::sources::Sources,
271    user: &crate::user::UserData,
272) -> Vec<Issue> {
273    let current = crate::graph::edges(catalog, sources)
274        .into_iter()
275        .map(|edge| edge.reference)
276        .collect::<BTreeSet<_>>();
277    user.relation_annotations
278        .iter()
279        .filter(|annotation| {
280            !current.contains(&annotation.relation)
281                && (!annotation.relation.kind.starts_with("credit:")
282                    || !current.iter().any(|relation| {
283                        crate::user::same_annotation_relation(catalog, &annotation.relation, relation)
284                    }))
285        })
286        .map(|annotation| Issue {
287            kind: IssueKind::OrphanedRelationAnnotation,
288            detail: format!(
289                "{} → {} → {} is not in the current graph; it is kept — aede relation {} --remove drops your annotation",
290                annotation.relation.source.to_token(),
291                annotation.relation.kind,
292                annotation.relation.target.to_token(),
293                annotation.relation.id()
294            ),
295            files: Vec::new(),
296        })
297        .collect()
298}
299
300/// Number of problems per severity.
301pub fn summary(issues: &[Issue]) -> BTreeMap<Severity, usize> {
302    let mut out = BTreeMap::new();
303    for issue in issues {
304        *out.entry(issue.severity()).or_insert(0) += 1;
305    }
306    out
307}
308
309fn check_tracks(catalog: &Catalog, issues: &mut Vec<Issue>) {
310    for track in &catalog.tracks {
311        let Some(file) = catalog.file(track.file_id) else {
312            continue;
313        };
314        let path = file.path.clone();
315
316        if file.first_tag("title").is_none() {
317            issues.push(Issue {
318                kind: IssueKind::MissingTitle,
319                detail: format!("title inferred from the file name: \"{}\"", track.title),
320                files: vec![path.clone()],
321            });
322        }
323        if file
324            .first_tag("artists")
325            .or_else(|| file.first_tag("artist"))
326            .is_none()
327        {
328            issues.push(Issue {
329                kind: IssueKind::MissingArtist,
330                detail: "no artist in the tags".into(),
331                files: vec![path.clone()],
332            });
333        }
334        if file.first_tag("album").is_none() {
335            issues.push(Issue {
336                kind: IssueKind::MissingAlbum,
337                detail: "track cannot be attached to an album".into(),
338                files: vec![path.clone()],
339            });
340        }
341        if track.duration_ms.is_none() || track.duration_ms == Some(0) {
342            issues.push(Issue {
343                kind: IssueKind::MissingDuration,
344                detail: "duration not determined: file possibly corrupted".into(),
345                files: vec![path.clone()],
346            });
347        }
348        if track.track_no.is_none() {
349            issues.push(Issue {
350                kind: IssueKind::MissingTrackNumber,
351                detail: "playback order not guaranteed within the album".into(),
352                files: vec![path.clone()],
353            });
354        }
355
356        let year = file
357            .first_tag("originaldate")
358            .or_else(|| file.first_tag("date"))
359            .and_then(text::extract_year);
360        match year {
361            None => issues.push(Issue {
362                kind: IssueKind::MissingDate,
363                detail: "no usable year".into(),
364                files: vec![path],
365            }),
366            Some(year) if !(1860..=2100).contains(&year) => issues.push(Issue {
367                kind: IssueKind::SuspiciousYear,
368                detail: format!("year announced: {year}"),
369                files: vec![path],
370            }),
371            Some(_) => {}
372        }
373    }
374}
375
376/// Reports the files whose stored checksums did not match.
377///
378/// Only what `aede check` has already established: this never reads a file, so
379/// `doctor` stays instant. Files with no verdict are silently passed over here
380/// and counted by the command itself, which can say how to obtain one.
381fn check_integrity(catalog: &Catalog, issues: &mut Vec<Issue>) {
382    for file in &catalog.files {
383        if let Some(record) = &file.integrity
384            && let crate::audit::integrity::Verdict::Damaged { detail } = &record.verdict
385        {
386            issues.push(Issue {
387                kind: IssueKind::DamagedAudio,
388                detail: format!("{detail} — restore from a backup or rip again"),
389                files: vec![file.path.clone()],
390            });
391        }
392    }
393}
394
395/// Reports what an imported analysis found and Aède cannot see.
396///
397/// An MD5 mismatch is an **error**, even when Aède's own check said the file
398/// was intact: the two look at different things. The frame checksums prove the
399/// container was not corrupted; the MD5 proves the decoded audio is the audio
400/// that was encoded. A file can pass the first and fail the second — a stream
401/// re-encoded by a non-conforming tool — and that case is exactly what Aède
402/// cannot see before it decodes anything itself.
403/// Where a source and the tags say different things about one release.
404///
405/// Only releases for now, and on purpose: an artist's country has no tag to
406/// disagree with, so a fetched artist fact is an addition rather than a second
407/// opinion. A release is where the two actually meet — Picard writes
408/// `RELEASETYPE`, `DATE` and `LABEL` — and it is therefore the only place this
409/// report has anything to say.
410///
411/// A record the catalog cannot place is skipped rather than reported, exactly
412/// as a waiting analysis is: it is not a defect that MusicBrainz holds
413/// something about an album nobody has scanned yet.
414fn check_source_quality(
415    catalog: &Catalog,
416    sources: &crate::sources::Sources,
417    issues: &mut Vec<Issue>,
418) {
419    use crate::sources::{Facts, ReviewReason};
420
421    for item in sources
422        .review_items(catalog)
423        .into_iter()
424        .filter(|item| item.decision.is_none())
425    {
426        let (kind, detail) = match item.reason {
427            ReviewReason::Approximate { score } => (
428                IssueKind::SourceNeedsReview,
429                format!(
430                    "{} {}: {} proposed {} at {score}% — aede review --accept={} or --reject={}",
431                    item.entity.kind.as_str(),
432                    item.entity.key,
433                    item.source,
434                    item.source_id
435                        .as_deref()
436                        .unwrap_or("an unidentified candidate"),
437                    item.id,
438                    item.id
439                ),
440            ),
441            ReviewReason::IdentityConflict {
442                local_id,
443                sourced_id,
444            } => (
445                IssueKind::SourceIdentityConflict,
446                format!(
447                    "{} {}: local identity {local_id}, {} says {sourced_id} — \
448                     aede review --accept={} or --reject={}",
449                    item.entity.kind.as_str(),
450                    item.entity.key,
451                    item.source,
452                    item.id,
453                    item.id
454                ),
455            ),
456            ReviewReason::PriorDecision => continue,
457        };
458        issues.push(Issue {
459            kind,
460            files: files_for_entity(catalog, &item.entity),
461            detail,
462        });
463    }
464
465    // A trusted recording may still carry only identification facts. That is
466    // not an ambiguous identity any more, but it does mean the graph cannot
467    // claim its credits are complete. Another placement can already carry a
468    // trusted completed answer for this same canonical recording.
469    let complete_recordings: BTreeSet<_> = crate::credit_coverage::recordings(catalog, sources)
470        .into_iter()
471        .filter(|coverage| {
472            matches!(
473                coverage.status,
474                crate::credit_coverage::CreditStatus::Empty
475                    | crate::credit_coverage::CreditStatus::Credited
476            )
477        })
478        .map(|coverage| coverage.recording_id)
479        .collect();
480    let mut reported_recordings = BTreeSet::new();
481    let mut incomplete_files = Vec::new();
482    for record in &sources.records {
483        let Facts::Track(facts) = &record.facts else {
484            continue;
485        };
486        if !sources.is_trusted(catalog, record)
487            || facts.recording.is_none()
488            || facts.relationships_complete
489        {
490            continue;
491        }
492        let entity = record.entity();
493        let Some(track_id) = entity.resolve(catalog) else {
494            continue;
495        };
496        let Some(recording_id) = catalog.track(track_id).map(|track| track.recording_id) else {
497            continue;
498        };
499        if complete_recordings.contains(&recording_id) || !reported_recordings.insert(recording_id)
500        {
501            continue;
502        }
503        incomplete_files.extend(files_for_entity(catalog, &entity));
504    }
505    if !reported_recordings.is_empty() {
506        issues.push(Issue {
507            kind: IssueKind::IncompleteSourceCredits,
508            detail: format!(
509                "{} identified without a complete relationship pass — run aede fetch --credits",
510                match reported_recordings.len() {
511                    1 => "1 recording is".to_string(),
512                    count => format!("{count} recordings are"),
513                }
514            ),
515            files: incomplete_files,
516        });
517    }
518
519    check_source_disagreements(catalog, sources, issues);
520}
521
522fn files_for_entity(catalog: &Catalog, entity: &crate::user::EntityRef) -> Vec<String> {
523    let Some(id) = entity.resolve(catalog) else {
524        return Vec::new();
525    };
526    let track_ids: Vec<Id> = match entity.kind {
527        model::EntityKind::Track => vec![id],
528        model::EntityKind::Release => catalog
529            .release(id)
530            .map(|release| release.track_ids.clone())
531            .unwrap_or_default(),
532        model::EntityKind::Artist => catalog.tracks_of_artist(id),
533        model::EntityKind::Label => catalog.tracks_of_label(id),
534        _ => Vec::new(),
535    };
536    track_ids
537        .into_iter()
538        .filter_map(|track| catalog.track(track))
539        .filter_map(|track| catalog.file(track.file_id))
540        .map(|file| file.path.clone())
541        .take(1)
542        .collect()
543}
544
545fn check_source_disagreements(
546    catalog: &Catalog,
547    sources: &crate::sources::Sources,
548    issues: &mut Vec<Issue>,
549) {
550    use crate::sources::Facts;
551
552    let mut by_entity: BTreeMap<(model::EntityKind, String), Vec<&crate::sources::SourceRecord>> =
553        BTreeMap::new();
554    for record in &sources.records {
555        if sources.is_trusted(catalog, record) {
556            by_entity
557                .entry((record.facts.kind(), record.key.clone()))
558                .or_default()
559                .push(record);
560        }
561    }
562    for ((kind, key), records) in by_entity {
563        if records.len() < 2 {
564            continue;
565        }
566        let entity = crate::user::EntityRef {
567            kind,
568            key: key.clone(),
569        };
570        for left_index in 0..records.len() {
571            for right in records.iter().skip(left_index + 1) {
572                let left = records[left_index];
573                for (field, a, b) in conflicting_fields(&left.facts, &right.facts) {
574                    issues.push(Issue {
575                        kind: IssueKind::SourcesDisagree,
576                        detail: format!(
577                            "{} {key}: {} says {field} is \"{a}\", {} says \"{b}\"",
578                            kind.as_str(),
579                            left.source,
580                            right.source
581                        ),
582                        files: files_for_entity(catalog, &entity),
583                    });
584                }
585            }
586        }
587    }
588
589    fn conflicting_fields(left: &Facts, right: &Facts) -> Vec<(&'static str, String, String)> {
590        let mut out = Vec::new();
591        let compare = |out: &mut Vec<(&'static str, String, String)>,
592                       field: &'static str,
593                       a: Option<&str>,
594                       b: Option<&str>| {
595            let (Some(a), Some(b)) = (a, b) else {
596                return;
597            };
598            if !a.trim().is_empty()
599                && !b.trim().is_empty()
600                && crate::text::normalize(a) != crate::text::normalize(b)
601            {
602                out.push((field, a.to_string(), b.to_string()));
603            }
604        };
605        match (left, right) {
606            (Facts::Artist(a), Facts::Artist(b)) => {
607                compare(&mut out, "type", a.kind.as_deref(), b.kind.as_deref());
608                compare(&mut out, "area", a.area.as_deref(), b.area.as_deref());
609                compare(
610                    &mut out,
611                    "begin date",
612                    a.began.as_deref(),
613                    b.began.as_deref(),
614                );
615                compare(&mut out, "end date", a.ended.as_deref(), b.ended.as_deref());
616            }
617            (Facts::Release(a), Facts::Release(b)) => {
618                compare(
619                    &mut out,
620                    "release type",
621                    a.primary_type.as_deref(),
622                    b.primary_type.as_deref(),
623                );
624                if let (Some(a), Some(b)) = (&a.first_released, &b.first_released)
625                    && matches!(
626                        crate::sources::verdict_date(a, Some(b)),
627                        crate::sources::Verdict::Differs { .. }
628                    )
629                {
630                    out.push(("first release date", a.clone(), b.clone()));
631                }
632                compare(&mut out, "label", a.label.as_deref(), b.label.as_deref());
633            }
634            (Facts::Track(a), Facts::Track(b)) => {
635                compare(
636                    &mut out,
637                    "recording",
638                    a.recording.as_deref(),
639                    b.recording.as_deref(),
640                );
641                compare(&mut out, "title", a.title.as_deref(), b.title.as_deref());
642                compare(&mut out, "album", a.album.as_deref(), b.album.as_deref());
643                if !a.artists.is_empty()
644                    && !b.artists.is_empty()
645                    && !matches!(
646                        crate::sources::verdict_set(&a.artists, &b.artists),
647                        crate::sources::Verdict::Agrees
648                    )
649                {
650                    out.push(("artists", a.artists.join(", "), b.artists.join(", ")));
651                }
652            }
653            _ => {}
654        }
655        out
656    }
657}
658
659fn check_sources(catalog: &Catalog, sources: &crate::sources::Sources, issues: &mut Vec<Issue>) {
660    use crate::sources::{Facts, Verdict};
661    use crate::user::EntityRef;
662
663    for record in &sources.records {
664        if !sources.is_trusted(catalog, record) {
665            continue;
666        }
667        let Facts::Release(facts) = &record.facts else {
668            continue;
669        };
670        let entity: EntityRef = record.entity();
671        let Some(release) = entity.resolve(catalog).and_then(|id| catalog.release(id)) else {
672            continue;
673        };
674
675        // The first file of the release answers for its tags: `RELEASETYPE`
676        // and `LABEL` belong to the edition, not to one track of it.
677        let tag = |name: &str| -> Option<String> {
678            release
679                .track_ids
680                .first()
681                .and_then(|&t| catalog.track(t))
682                .and_then(|t| catalog.file(t.file_id))
683                .and_then(|f| f.first_tag(name).map(str::to_string))
684        };
685
686        let label_tag = release
687            .label_ids
688            .first()
689            .and_then(|&id| catalog.label(id))
690            .map(|l| l.name.clone());
691
692        let comparisons = [
693            (
694                "release type",
695                facts.primary_type.as_deref(),
696                tag("releasetype"),
697                false,
698            ),
699            (
700                "date",
701                facts.first_released.as_deref(),
702                release.date.clone(),
703                true,
704            ),
705            ("label", facts.label.as_deref(), label_tag, false),
706        ];
707
708        for (what, theirs, yours, is_date) in comparisons {
709            let Some(theirs) = theirs else { continue };
710            let verdict = match is_date {
711                true => crate::sources::verdict_date(theirs, yours.as_deref()),
712                false => crate::sources::verdict(theirs, yours.as_deref()),
713            };
714            let Verdict::Differs { theirs, yours } = verdict else {
715                continue;
716            };
717            issues.push(Issue {
718                kind: IssueKind::SourceDisagrees,
719                detail: format!(
720                    "\"{}\": {} says the {what} is \"{theirs}\", the tags say \"{yours}\"",
721                    release.title, record.source
722                ),
723                files: release
724                    .track_ids
725                    .iter()
726                    .filter_map(|&t| catalog.track(t))
727                    .filter_map(|t| catalog.file(t.file_id))
728                    .map(|f| f.path.clone())
729                    .take(1)
730                    .collect(),
731            });
732        }
733    }
734}
735
736fn check_imported_analyses(catalog: &Catalog, issues: &mut Vec<Issue>) {
737    let files: BTreeMap<&str, &model::AudioFile> =
738        catalog.files.iter().map(|f| (f.path.as_str(), f)).collect();
739    for record in &catalog.analyses {
740        // An analysis of a file the catalog does not hold is not a problem to
741        // report: it is waiting for the folder it speaks of to be scanned.
742        let Some(file) = files.get(record.path.as_str()) else {
743            continue;
744        };
745        // An analysis of bytes that changed since says nothing about the file
746        // that is there now.
747        if !record.still_applies(file.size, file.mtime) {
748            continue;
749        }
750
751        if record.md5_failed() {
752            let contradiction = matches!(
753                file.integrity.as_ref().map(|r| &r.verdict),
754                Some(crate::audit::integrity::Verdict::Intact)
755            );
756            let detail = if contradiction {
757                format!(
758                    "{} decoded the audio and it does not match the file's own MD5, \
759                     although the frame checksums are valid: the stream was re-encoded",
760                    record.source
761                )
762            } else {
763                format!(
764                    "{} found the audio does not match the file's own MD5",
765                    record.source
766                )
767            };
768            issues.push(Issue {
769                kind: IssueKind::Md5Mismatch,
770                detail,
771                files: vec![file.path.clone()],
772            });
773        }
774
775        // The spectral verdicts — transcoding, upscaling, upsampling — are read
776        // from the report, stored and kept up to date, and deliberately not
777        // reported here. See `analysis::FileAnalysis::suspect_encoding`, which
778        // nothing calls on purpose.
779    }
780}
781
782/// Reports the albums the model linked to a copy of themselves.
783///
784/// Only the copies that are identical in quality: a hi-res edition beside the
785/// CD rip is a deliberate second copy and not something to fix.
786fn check_duplicate_albums(catalog: &Catalog, issues: &mut Vec<Issue>) {
787    let mut seen: std::collections::BTreeSet<(Id, Id)> = Default::default();
788    for relation in &catalog.relations {
789        if relation.source_kind == model::EntityKind::Release
790            && relation.target_kind == model::EntityKind::Release
791            && relation.kind == model::DUPLICATE
792        {
793            let Some(release) = catalog.release(relation.source_id) else {
794                continue;
795            };
796            let other_id = relation.target_id;
797            let pair = (release.id.min(other_id), release.id.max(other_id));
798            if !seen.insert(pair) {
799                continue;
800            }
801            let Some(other) = catalog.release(other_id) else {
802                continue;
803            };
804            // The smaller of the two is what deleting one would free; both are
805            // named, since only the user knows which folder to keep.
806            let wasted = release_size(catalog, release.id).min(release_size(catalog, other_id));
807            issues.push(Issue {
808                kind: IssueKind::DuplicateAlbum,
809                detail: format!(
810                    "\"{}\" is present twice in the same quality ({} recoverable)",
811                    release.title,
812                    text::format_size(wasted)
813                ),
814                files: vec![release.folder.clone(), other.folder.clone()],
815            });
816        }
817    }
818}
819
820/// `true` when the tracks all come from releases the model already tied
821/// together, whichever way.
822///
823/// A copied album would otherwise be reported once per track, and so would a
824/// hi-res edition sitting beside its CD rip — which is not a defect at all. The
825/// album-level lines say both things once, and name the folders.
826fn covered_by_the_album(catalog: &Catalog, cluster: &[(Id, u64)]) -> bool {
827    let releases: Vec<Id> = cluster
828        .iter()
829        .filter_map(|&(id, _)| catalog.track(id))
830        .filter_map(|t| t.release_id)
831        .collect();
832    if releases.len() != cluster.len() || releases.len() < 2 {
833        return false;
834    }
835    let first = releases[0];
836    let linked: std::collections::BTreeSet<Id> = catalog
837        .related_releases(first, model::DUPLICATE)
838        .into_iter()
839        .chain(catalog.related_releases(first, model::OTHER_EDITION))
840        .collect();
841    releases
842        .iter()
843        .skip(1)
844        .all(|other| *other == first || linked.contains(other))
845}
846
847/// Notes the albums kept in two encodings. Not a defect: a hi-res copy beside
848/// the CD rip is a choice, and saying nothing at all would look like an
849/// oversight next to the duplicate lines.
850fn check_other_editions(catalog: &Catalog, issues: &mut Vec<Issue>) {
851    let mut seen: std::collections::BTreeSet<(Id, Id)> = Default::default();
852    for relation in &catalog.relations {
853        if relation.source_kind == model::EntityKind::Release
854            && relation.target_kind == model::EntityKind::Release
855            && relation.kind == model::OTHER_EDITION
856        {
857            let Some(release) = catalog.release(relation.source_id) else {
858                continue;
859            };
860            let other_id = relation.target_id;
861            let pair = (release.id.min(other_id), release.id.max(other_id));
862            if !seen.insert(pair) {
863                continue;
864            }
865            let Some(other) = catalog.release(other_id) else {
866                continue;
867            };
868            issues.push(Issue {
869                kind: IssueKind::OtherEdition,
870                detail: format!(
871                    "\"{}\" is kept in two encodings ({} and {})",
872                    release.title,
873                    quality_summary(catalog, release.id),
874                    quality_summary(catalog, other_id)
875                ),
876                files: vec![release.folder.clone(), other.folder.clone()],
877            });
878        }
879    }
880}
881
882/// The formats a release is made of, in one short phrase.
883fn quality_summary(catalog: &Catalog, release_id: Id) -> String {
884    let labels: std::collections::BTreeSet<String> = catalog
885        .release(release_id)
886        .map(|r| {
887            r.track_ids
888                .iter()
889                .filter_map(|&id| catalog.track(id))
890                .filter_map(|t| catalog.file(t.file_id))
891                .map(|f| f.properties.quality_label())
892                .collect()
893        })
894        .unwrap_or_default();
895    labels.into_iter().collect::<Vec<_>>().join(", ")
896}
897
898fn release_size(catalog: &Catalog, release_id: Id) -> u64 {
899    catalog
900        .release(release_id)
901        .map(|r| {
902            r.track_ids
903                .iter()
904                .filter_map(|&id| catalog.track(id))
905                .filter_map(|t| catalog.file(t.file_id))
906                .map(|f| f.size)
907                .fold(0u64, u64::saturating_add)
908        })
909        .unwrap_or(0)
910}
911
912/// Files whose fingerprints are identical: the same audio, whatever the tags
913/// say about it.
914///
915/// This is what a fingerprint buys beyond identifying a file, and it is worth
916/// more than the identification for a library somebody has been keeping for
917/// years: [`IssueKind::DuplicateTrack`] compares artist, title and duration,
918/// so it can only find copies whose *tags* already agree. Two rips of one
919/// track filed under different titles — or under none — are invisible to it
920/// and obvious here.
921///
922/// Only files that have been fingerprinted take part, which is what makes the
923/// report honest: a library where nothing has been fingerprinted reports no
924/// identical audio, and that is a silence rather than a clean bill of health.
925/// `aede fingerprint --full` is what fills it in.
926fn check_same_audio(catalog: &Catalog, issues: &mut Vec<Issue>) {
927    let mut groups: BTreeMap<&str, Vec<&crate::model::AudioFile>> = BTreeMap::new();
928    for file in &catalog.files {
929        if let Some(print) = &file.fingerprint {
930            groups.entry(print.data.as_str()).or_default().push(file);
931        }
932    }
933    for (_, files) in groups {
934        if files.len() < 2 {
935            continue;
936        }
937        // Everything after the first is what could go, which is the number a
938        // reader is actually deciding about.
939        let wasted = files
940            .iter()
941            .skip(1)
942            .map(|f| f.size)
943            .fold(0u64, u64::saturating_add);
944        issues.push(Issue {
945            kind: IssueKind::SameAudio,
946            detail: format!(
947                "{} files are the same recording ({} recoverable)",
948                files.len(),
949                text::format_size(wasted)
950            ),
951            files: files.iter().map(|f| f.path.clone()).collect(),
952        });
953    }
954}
955
956/// Suggests copies with matching tagged artist/title and readable durations.
957///
958/// Filename-derived titles are not identity evidence. A group spans at most
959/// three seconds, so intermediate durations cannot join different versions.
960fn check_duplicates(catalog: &Catalog, issues: &mut Vec<Issue>) {
961    let mut groups: BTreeMap<(String, String), Vec<(Id, u64)>> = BTreeMap::new();
962
963    for track in &catalog.tracks {
964        let Some(file) = catalog.file(track.file_id) else {
965            continue;
966        };
967        let Some(duration) = track.duration_ms.filter(|duration| *duration > 0) else {
968            continue;
969        };
970        let artist = file.first_tag("artist").unwrap_or("");
971        let title = file.first_tag("title").unwrap_or("");
972        let key = (text::normalize(artist), text::normalize(title));
973        if key.0.is_empty() || key.1.is_empty() {
974            continue;
975        }
976        groups.entry(key).or_default().push((track.id, duration));
977    }
978
979    for ((artist, title), mut entries) in groups {
980        if entries.len() < 2 {
981            continue;
982        }
983        entries.sort_by_key(|&(_, duration)| duration);
984
985        // Every member must be close to the shortest version. Comparing only
986        // neighbours would chain studio and live performances into one group.
987        let mut cluster: Vec<(Id, u64)> = vec![entries[0]];
988        let mut clusters: Vec<Vec<(Id, u64)>> = Vec::new();
989        for &entry in &entries[1..] {
990            let first = cluster.first().map(|&(_, d)| d).unwrap_or(0);
991            if entry.1.abs_diff(first) <= 3_000 {
992                cluster.push(entry);
993            } else {
994                clusters.push(std::mem::take(&mut cluster));
995                cluster = vec![entry];
996            }
997        }
998        clusters.push(cluster);
999
1000        for cluster in clusters.into_iter().filter(|c| c.len() > 1) {
1001            // A copied album would otherwise be reported once per track: the
1002            // album-level issue already names the two folders, and thirteen
1003            // lines saying the same thing bury everything else.
1004            if covered_by_the_album(catalog, &cluster) {
1005                continue;
1006            }
1007            let files: Vec<String> = cluster
1008                .iter()
1009                .filter_map(|&(id, _)| catalog.track(id))
1010                .filter_map(|t| catalog.file(t.file_id))
1011                .map(|f| f.path.clone())
1012                .collect();
1013            let wasted: u64 = cluster
1014                .iter()
1015                .skip(1)
1016                .filter_map(|&(id, _)| catalog.track(id))
1017                .filter_map(|t| catalog.file(t.file_id))
1018                .map(|f| f.size)
1019                .fold(0u64, u64::saturating_add);
1020            issues.push(Issue {
1021                kind: IssueKind::DuplicateTrack,
1022                detail: format!(
1023                    "\"{title}\" by \"{artist}\" present {} times ({} recoverable)",
1024                    cluster.len(),
1025                    text::format_size(wasted)
1026                ),
1027                files,
1028            });
1029        }
1030    }
1031}
1032
1033/// What names an album across the folders it may be spread over.
1034///
1035/// Deliberately *not* the release identity, which includes the folder — that is
1036/// what tells a CD rip from a vinyl rip of the same record, and it is exactly
1037/// the part that has to be dropped here to see the two halves of one set.
1038fn album_key(release: &model::Release) -> (String, Option<Id>) {
1039    (release.key.clone(), release.album_artist_id)
1040}
1041
1042/// Every disc the library holds of each album, wherever it sits on disk.
1043fn discs_by_album(catalog: &Catalog) -> BTreeMap<(String, Option<Id>), BTreeSet<u32>> {
1044    let mut out: BTreeMap<(String, Option<Id>), BTreeSet<u32>> = BTreeMap::new();
1045    for release in &catalog.releases {
1046        let discs = out.entry(album_key(release)).or_default();
1047        for track in release.track_ids.iter().filter_map(|&id| catalog.track(id)) {
1048            discs.insert(track.disc_no.unwrap_or(1));
1049        }
1050    }
1051    out
1052}
1053
1054fn check_releases(catalog: &Catalog, issues: &mut Vec<Issue>) {
1055    let discs_elsewhere = discs_by_album(catalog);
1056    for release in &catalog.releases {
1057        let tracks: Vec<_> = release
1058            .track_ids
1059            .iter()
1060            .filter_map(|&id| catalog.track(id))
1061            .collect();
1062        if tracks.is_empty() {
1063            continue;
1064        }
1065        let paths: Vec<String> = tracks
1066            .iter()
1067            .filter_map(|t| catalog.file(t.file_id))
1068            .map(|f| f.path.clone())
1069            .collect();
1070
1071        // Gaps in the numbering, disc by disc — and what the files themselves
1072        // say the disc should hold, which is the only way to see the tracks
1073        // missing from the *end*. On gaps alone, an album truncated after track
1074        // 9 of 12 looks perfectly whole: there is nothing between 1 and 9 left
1075        // to be missing. That is exactly the shape an interrupted rip has, so
1076        // the check was blind to its most common case.
1077        let mut by_disc: BTreeMap<u32, (Vec<u32>, u32)> = BTreeMap::new();
1078        for track in &tracks {
1079            if let Some(no) = track.track_no {
1080                let entry = by_disc.entry(track.disc_no.unwrap_or(1)).or_default();
1081                entry.0.push(no);
1082                if let Some(total) = catalog.file(track.file_id).and_then(announced_total) {
1083                    entry.1 = entry.1.max(total);
1084                }
1085            }
1086        }
1087        for (disc, (mut numbers, announced)) in by_disc {
1088            numbers.sort_unstable();
1089            numbers.dedup();
1090            let Some(&last) = numbers.last() else {
1091                continue;
1092            };
1093            // A total smaller than what is present is a wrong tag, not a
1094            // missing track, so the ceiling is whichever of the two is larger.
1095            let max = last.max(announced);
1096            // Only twelve positions and an ellipsis are displayed. Totals
1097            // come from untrusted tags and must not allocate billions of rows.
1098            let missing: Vec<u32> = (1..=max)
1099                .filter(|n| numbers.binary_search(n).is_err())
1100                .take(13)
1101                .collect();
1102            if !missing.is_empty() {
1103                let list: Vec<String> = missing.iter().take(12).map(|n| n.to_string()).collect();
1104                issues.push(Issue {
1105                    kind: IssueKind::IncompleteAlbum,
1106                    detail: format!(
1107                        "\"{}\" disc {disc}: missing tracks {}{}",
1108                        release.title,
1109                        list.join(", "),
1110                        if missing.len() > 12 { "…" } else { "" }
1111                    ),
1112                    files: paths.clone(),
1113                });
1114            }
1115        }
1116
1117        // A whole disc missing, which the check above cannot see: every disc
1118        // that is there is complete, and nothing in the numbering says how many
1119        // there should be. A four-disc soundtrack ripped as three looks perfect
1120        // until the day it is played. `disctotal` answers it, and so does a
1121        // hole in the disc numbers themselves.
1122        let present: BTreeSet<u32> = tracks.iter().map(|t| t.disc_no.unwrap_or(1)).collect();
1123        let announced = tracks
1124            .iter()
1125            .filter_map(|t| catalog.file(t.file_id))
1126            .filter_map(announced_discs)
1127            .max();
1128        // Same rule as the tracks: a total smaller than what is here is a wrong
1129        // tag rather than a missing disc.
1130        let ceiling = present
1131            .iter()
1132            .copied()
1133            .max()
1134            .unwrap_or(1)
1135            .max(announced.unwrap_or(0));
1136        let sibling = discs_elsewhere.get(&album_key(release));
1137        let missing: Vec<u32> = (1..=ceiling)
1138            .filter(|n| !present.contains(n))
1139            // Not missing, merely somewhere else: a set laid out as two folders
1140            // the disc-folder rule does not recognise — "Album CD1" beside
1141            // "Album CD2" rather than under a common parent — makes two
1142            // releases, each of which would otherwise report the other as
1143            // missing. Look for it in the library before calling it lost.
1144            .filter(|n| !sibling.is_some_and(|discs| discs.contains(n)))
1145            .take(13)
1146            .collect();
1147        if !missing.is_empty() {
1148            let list: Vec<String> = missing.iter().take(12).map(|n| n.to_string()).collect();
1149            issues.push(Issue {
1150                kind: IssueKind::IncompleteAlbum,
1151                detail: format!(
1152                    "\"{}\": missing {} {}{}{}",
1153                    release.title,
1154                    if missing.len() > 1 { "discs" } else { "disc" },
1155                    list.join(", "),
1156                    if missing.len() > 12 { "…" } else { "" },
1157                    match announced {
1158                        Some(total) => format!(" of {total}"),
1159                        None => String::new(),
1160                    }
1161                ),
1162                files: paths.clone(),
1163            });
1164        }
1165
1166        // A mix of codecs or sample rates within a single album: the sign of an
1167        // album pieced together from different sources.
1168        let files: Vec<_> = tracks
1169            .iter()
1170            .filter_map(|t| catalog.file(t.file_id))
1171            .collect();
1172        let codecs: std::collections::BTreeSet<&str> =
1173            files.iter().map(|f| f.properties.codec.as_str()).collect();
1174        let rates: std::collections::BTreeSet<u32> = files
1175            .iter()
1176            .filter_map(|f| f.properties.sample_rate)
1177            .collect();
1178        if codecs.len() > 1 || rates.len() > 1 {
1179            let mut details = Vec::new();
1180            if codecs.len() > 1 {
1181                details.push(format!(
1182                    "codecs: {}",
1183                    codecs.iter().copied().collect::<Vec<_>>().join(", ")
1184                ));
1185            }
1186            if rates.len() > 1 {
1187                details.push(format!(
1188                    "sample rates: {}",
1189                    rates
1190                        .iter()
1191                        .map(|r| r.to_string())
1192                        .collect::<Vec<_>>()
1193                        .join(", ")
1194                ));
1195            }
1196            issues.push(Issue {
1197                kind: IssueKind::MixedQuality,
1198                detail: format!("\"{}\" — {}", release.title, details.join("; ")),
1199                files: paths.clone(),
1200            });
1201        }
1202
1203        // Cover art: embedded, or dropped in the folder.
1204        let has_cover = release.cover_path.is_some() || files.iter().any(|f| f.has_embedded_art);
1205        if !has_cover {
1206            issues.push(Issue {
1207                kind: IssueKind::MissingCover,
1208                detail: format!("\"{}\" has no cover art", release.title),
1209                files: vec![release.folder.clone()],
1210            });
1211        }
1212    }
1213}
1214
1215#[cfg(test)]
1216#[path = "doctor_tests.rs"]
1217mod tests;
1218
1219#[cfg(test)]
1220#[path = "doctor_source_tests.rs"]
1221mod source_tests;
1222
1223/// Pairs of artist rows that look like one musician written two ways.
1224///
1225/// **Suggestions, and nothing here ever acts on one.** The half of artist
1226/// identity that has an exact answer — two spellings under one
1227/// `MUSICBRAINZ_ARTISTID` — is settled in `model::identity` before anything is
1228/// interned, so anything still standing as two rows by the time this runs is a
1229/// pair no authority can rule on. Matching on a fragment of a name merges
1230/// Angus Young with Neil Young; the only safe thing to do with a resemblance is
1231/// to show it to somebody who knows.
1232///
1233/// Two shapes are reported, and each has to earn it.
1234///
1235/// **An initialism.** `O. Osbourne` beside `Ozzy Osbourne`: the same number of
1236/// words, each word either identical or a single letter that opens the other.
1237/// Tight enough to stand on its own — the abbreviated spelling usually lives on
1238/// a different album, so there is nothing else to corroborate it with.
1239///
1240/// **A shortened name that shares an album.** `Osbourne` beside `Ozzy
1241/// Osbourne`, where one name's words end the other's. Loose on its own, which
1242/// is why it is only reported when the two are credited on **the same release
1243/// folder**: two people who differ by a dropped first name and appear on one
1244/// record together are, in practice, one person tagged twice.
1245///
1246/// A pair whose two rows carry **different** MusicBrainz identifiers is never
1247/// reported: that is not a resemblance, it is two people, said so by the only
1248/// authority on the question.
1249fn check_same_person(catalog: &Catalog, issues: &mut Vec<Issue>) {
1250    // Artists with nothing behind them are skipped: a name credited on no
1251    // track is usually a stray tag, and a report of two strays helps nobody.
1252    let named: Vec<&model::Artist> = catalog
1253        .artists
1254        .iter()
1255        .filter(|a| !catalog.tracks_of_artist(a.id).is_empty())
1256        .collect();
1257
1258    for (at, one) in named.iter().enumerate() {
1259        for other in named.iter().skip(at + 1) {
1260            // Two identifiers are two people. Two rows sharing one would
1261            // already be one row, so this only ever excludes real pairs.
1262            if let (Some(a), Some(b)) = (&one.mbid, &other.mbid)
1263                && a != b
1264            {
1265                continue;
1266            }
1267            let together = || shares_a_release(catalog, one.id, other.id);
1268            let why = if initialism(&one.key, &other.key) {
1269                "one is the other with a first name reduced to its initial"
1270            } else if shortened(&one.key, &other.key) && together() {
1271                "one is the other without its first name, and both are credited on one album"
1272            } else {
1273                continue;
1274            };
1275            // The longer spelling second, so the line reads the way the
1276            // command that acts on it is typed: the short one gives way.
1277            let (short, long) = match one.key.len() <= other.key.len() {
1278                true => (one, other),
1279                false => (other, one),
1280            };
1281            issues.push(Issue {
1282                kind: IssueKind::SameArtistMaybe,
1283                detail: format!(
1284                    "\"{}\" ({}) and \"{}\" ({}): {why}. \
1285                     If they are, aede merge \"{}\" \"{}\" says so — \
1286                     nothing in your files changes",
1287                    short.name,
1288                    crate::text::plural(catalog.tracks_of_artist(short.id).len(), "track"),
1289                    long.name,
1290                    crate::text::plural(catalog.tracks_of_artist(long.id).len(), "track"),
1291                    short.name,
1292                    long.name,
1293                ),
1294                // A couple of files from the lesser spelling, because the way
1295                // to settle this is to look at what they are tagged with.
1296                files: catalog
1297                    .tracks_of_artist(short.id)
1298                    .iter()
1299                    .filter_map(|&t| catalog.track(t))
1300                    .filter_map(|t| catalog.file(t.file_id))
1301                    .map(|f| f.path.clone())
1302                    .take(2)
1303                    .collect(),
1304            });
1305        }
1306    }
1307}
1308
1309/// `true` when two normalised names differ only by a first name reduced to its
1310/// initial: `o osbourne` and `ozzy osbourne`.
1311///
1312/// Word for word, so a shared letter anywhere else cannot trigger it, and at
1313/// least one word has to actually be an initial — otherwise two identical names
1314/// would qualify, and they are one row already.
1315fn initialism(one: &str, other: &str) -> bool {
1316    let (a, b): (Vec<&str>, Vec<&str>) = (one.split(' ').collect(), other.split(' ').collect());
1317    if a.len() != b.len() || a.len() < 2 {
1318        return false;
1319    }
1320    let mut abbreviated = false;
1321    for (left, right) in a.iter().zip(&b) {
1322        if left == right {
1323            continue;
1324        }
1325        let opens = |short: &str, full: &str| {
1326            short.chars().count() == 1 && full.starts_with(short) && full.chars().count() > 1
1327        };
1328        match opens(left, right) || opens(right, left) {
1329            true => abbreviated = true,
1330            false => return false,
1331        }
1332    }
1333    abbreviated
1334}
1335
1336/// `true` when one name's words are the tail of the other's: `osbourne` inside
1337/// `ozzy osbourne`.
1338///
1339/// Whole words, never a substring, because a substring match is what merges
1340/// `Young` into everybody. On its own this is far too loose to act on, which is
1341/// why its caller also demands a shared release.
1342fn shortened(one: &str, other: &str) -> bool {
1343    let (a, b): (Vec<&str>, Vec<&str>) = (one.split(' ').collect(), other.split(' ').collect());
1344    let (short, long) = if a.len() < b.len() { (a, b) } else { (b, a) };
1345    !short.is_empty() && short.len() < long.len() && long.ends_with(&short[..])
1346}
1347
1348/// `true` when two artists are credited on one and the same release.
1349fn shares_a_release(catalog: &Catalog, one: Id, other: Id) -> bool {
1350    let mine: BTreeSet<Id> = catalog.releases_of_artist(one).into_iter().collect();
1351    catalog
1352        .releases_of_artist(other)
1353        .iter()
1354        .any(|id| mine.contains(id))
1355}