aede_core/
places.rs

1//! Where the artists on the shelf are from.
2//!
3//! # Why this is not a field of the catalog
4//!
5//! There is no widely used tag for an artist's country. `RELEASECOUNTRY`
6//! exists, but it answers a different question — the country a *release* came
7//! out in — and answers this one wrongly: an American pressing of a French
8//! band is not an American band. So the fact comes from MusicBrainz, which
9//! means it lives in the attributed layer and not in the graph.
10//!
11//! That makes this the first thing in the program a listing can be filtered by
12//! that **the catalog does not know**. The consequence is worth stating plainly
13//! rather than discovering: a library that has never run `aede fetch` has no
14//! countries at all, and every command here says so instead of answering with
15//! an empty table that looks like a library of stateless musicians.
16//!
17//! # The vocabulary is read from the data
18//!
19//! No table of countries is written down here. What `aede countries` lists is
20//! what MusicBrainz actually said about *these* artists, the way
21//! `Catalog::roles_in_use` reads the roles from the credits rather than from a
22//! fixed list. A country nobody on the shelf is from is not a country this
23//! program has an opinion about.
24//!
25//! The price of that, stated rather than discovered: MusicBrainz answers the
26//! **most specific area** it holds for an artist, which is usually a country
27//! and is sometimes a county or a city. A reader's shelf produced a row for
28//! *County Antrim*. These are the places the source named, and the listing says
29//! so; the ISO code is the only thing in the table that an authority assigned,
30//! which is why it has a column to itself.
31//!
32//! # Short forms, and where they are allowed to come from
33//!
34//! Nobody wants to type "United Kingdom". The temptation is a table of
35//! synonyms — UK, GB, Great Britain, Royaume-Uni — and it is a slope with no
36//! bottom: whose vernacular, in which language, and who maintains it. So a
37//! country is resolved in four steps, and **each one is derived from the
38//! source or from the name itself**, never from a list this program invented:
39//!
40//! 1. the **name**, exactly: `united kingdom`;
41//! 2. the **ISO code** the source states: `gb`, `fr`, `us`;
42//! 3. the **initials** of a multi-word name: `uk`, `nz` — computed from the
43//!    name, so no country needs to be known about in advance, and offered only
44//!    where they name one place on this shelf — see `keep_usable_initials`;
45//! 4. any **substring** of the name: `kingdom`, `united`, which may reach
46//!    several and says so.
47//!
48//! `USA` and `Royaume-Uni` are therefore refused, and that is the intended
49//! answer rather than a gap: neither is the source's name nor its code, and
50//! this program speaks English throughout. The error names `aede countries`,
51//! which lists every form that works — because what a screen displays must be
52//! what the parser accepts.
53//!
54//! An unmatched value is an error naming the listing — the same contract
55//! `--genre`, `--label` and `--role` already have.
56
57use std::collections::BTreeMap;
58
59use crate::model::{Catalog, EntityKind, Id, TitleMatch};
60use crate::sources::{Facts, Sources};
61use crate::text;
62use crate::user::EntityRef;
63
64/// The country an artist is from, as the layer holds it.
65///
66/// `None` covers three quite different things — never fetched, fetched and
67/// MusicBrainz has no area, or matched to nobody — and callers that need to
68/// tell them apart read the record itself. What they share is that this
69/// program cannot say where the artist is from, which is what a filter needs.
70pub fn country_of(catalog: &Catalog, held: &Sources, artist: Id) -> Option<String> {
71    said_about(catalog, held, artist).0
72}
73
74/// The country and its code, as the layer holds them for one artist.
75fn said_about(catalog: &Catalog, held: &Sources, artist: Id) -> (Option<String>, Option<String>) {
76    let Some(entity) = EntityRef::of(catalog, EntityKind::Artist, artist) else {
77        return (None, None);
78    };
79    let Some(record) = held.get(&entity, crate::sources::MUSICBRAINZ) else {
80        return (None, None);
81    };
82    if !held.is_trusted(catalog, record) {
83        return (None, None);
84    }
85    match &record.facts {
86        Facts::Artist(facts) => {
87            let keep = |value: &Option<String>| {
88                value
89                    .clone()
90                    .map(|v| v.trim().to_string())
91                    .filter(|v| !v.is_empty())
92            };
93            (keep(&facts.area), keep(&facts.country_code))
94        }
95        _ => (None, None),
96    }
97}
98
99/// One country, and who on the shelf is from it.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct Place {
102    /// As the source spells it: "France", "United Kingdom".
103    pub name: String,
104    /// Normalised, for matching. See [`text::normalize`].
105    pub key: String,
106    /// The ISO code, lowercased, when any record carries one.
107    ///
108    /// Absent for a place whose artists were all fetched before the code was
109    /// kept, and absent for a place MusicBrainz gave no code for. **This is
110    /// the only thing here that an authority assigned**, which is why it has a
111    /// column of its own: [`Place::initials`] beside it in one column made
112    /// `US` — the initials of "United States" — read as an ISO code, and made
113    /// a country with no code look like a country with no short form.
114    pub code: Option<String>,
115    /// The initials of a multi-word name, when they name this place alone.
116    ///
117    /// Computed by [`countries`] over the whole set rather than from the name
118    /// on its own, because whether a short form is *usable* is a property of
119    /// the set: `keep_usable_initials` says what disqualifies one.
120    pub initials: Option<String>,
121    /// The artists from there, in catalog order.
122    pub artists: Vec<Id>,
123}
124
125/// The initials of a multi-word name: "United Kingdom" → `uk`.
126///
127/// `None` for a one-word name, where the initial would be a single letter
128/// matching half the world.
129fn derive_initials(key: &str) -> Option<String> {
130    let letters: String = key
131        .split_whitespace()
132        .filter_map(|word| word.chars().next())
133        .collect();
134    (letters.chars().count() > 1).then_some(letters)
135}
136
137impl Place {
138    /// Every short form a reader may type for this place. Empty when the name
139    /// is the only way in.
140    ///
141    /// The code first: it is the one an authority assigns. There is no guard
142    /// here against the two being the same word — `US` is both the code and the
143    /// initials of "United States" — because `keep_usable_initials` has
144    /// already dropped initials that repeat their own place's code. Deduplicating
145    /// at the point of display would have meant deduplicating at every point of
146    /// display, and one of them was going to be forgotten: the table, which
147    /// shows the two in separate columns, duly printed `US` twice.
148    pub fn short_forms(&self) -> Vec<String> {
149        let mut out = Vec::new();
150        if let Some(code) = &self.code {
151            out.push(code.to_uppercase());
152        }
153        if let Some(letters) = &self.initials {
154            out.push(letters.to_uppercase());
155        }
156        out
157    }
158}
159
160/// Every country the layer knows about, most artists first.
161///
162/// Ties broken by name so that two runs over the same library answer in the
163/// same order — a listing that shuffles itself between runs cannot be diffed,
164/// and this one is meant to be read twice.
165pub fn countries(catalog: &Catalog, held: &Sources) -> Vec<Place> {
166    let mut by_name: BTreeMap<String, Place> = BTreeMap::new();
167    for artist in &catalog.artists {
168        let (Some(name), code) = said_about(catalog, held, artist.id) else {
169            continue;
170        };
171        let place = by_name.entry(name.clone()).or_insert_with(|| Place {
172            key: text::normalize(&name),
173            name,
174            code: None,
175            initials: None,
176            artists: Vec::new(),
177        });
178        // One artist fetched since the code was kept is enough to give the
179        // whole country its code: the code is a property of the place, not of
180        // whoever happened to be asked about first.
181        if place.code.is_none() {
182            place.code = code.map(|c| c.to_ascii_lowercase());
183        }
184        place.artists.push(artist.id);
185    }
186    let mut out: Vec<Place> = by_name.into_values().collect();
187    keep_usable_initials(&mut out);
188    out.sort_by(|a, b| {
189        b.artists
190            .len()
191            .cmp(&a.artists.len())
192            .then(a.name.cmp(&b.name))
193    });
194    out
195}
196
197/// Gives each place its initials, but only where they name that place alone.
198///
199/// **Derived is not the same as usable.** The initials of a name are derived
200/// from it and invented by nobody, which is what the rule about short forms
201/// asks for — and it is not enough. MusicBrainz answers the most specific area
202/// it holds for an artist, so this list is not all countries: a reader's shelf
203/// produced a row for *County Antrim*, whose initials are `CA`, in a library
204/// that also holds Canadian artists. `--country CA` then meant two things, and
205/// the listing showed the collision as though it were a helpful abbreviation.
206///
207/// So initials are dropped when they would answer for anything else:
208///
209/// - when **two places** derive the same ones — neither may claim them;
210/// - when they are **some other place's ISO code** — the code was assigned by
211///   an authority and the initials by this function, and a tie between the two
212///   is not a tie;
213/// - when they are **this place's own code**, which is not a collision but a
214///   repetition: `US` is both, and a reader shown it in a `Code` column and an
215///   `Also` column reads two facts where there is one. Dropped here rather than
216///   where it is printed, because it was printed in three places and the third
217///   forgot.
218///
219/// A place that loses them keeps its name and its own code; nothing becomes
220/// unreachable, only unambiguous. And the collision is a property of *this*
221/// library: a shelf with no Canadian artist keeps `CA` for County Antrim,
222/// which is correct there and would be wrong to forbid on the strength of a
223/// world atlas this program does not hold.
224fn keep_usable_initials(places: &mut [Place]) {
225    let codes: std::collections::BTreeSet<String> =
226        places.iter().filter_map(|p| p.code.clone()).collect();
227    let mut derived: BTreeMap<String, usize> = BTreeMap::new();
228    for place in places.iter() {
229        if let Some(letters) = derive_initials(&place.key) {
230            *derived.entry(letters).or_insert(0) += 1;
231        }
232    }
233    for place in places.iter_mut() {
234        let Some(letters) = derive_initials(&place.key) else {
235            continue;
236        };
237        let shared = derived.get(&letters).is_some_and(|&n| n > 1);
238        let its_own_code = place.code.as_deref() == Some(letters.as_str());
239        let someone_elses_code = codes.contains(&letters) && !its_own_code;
240        if !shared && !someone_elses_code && !its_own_code {
241            place.initials = Some(letters);
242        }
243    }
244}
245
246/// How many of these places carry no ISO code.
247///
248/// The number behind the one message this listing owes a reader whose codes are
249/// all missing: the field is newer than their fetch, and nothing on screen says
250/// so — the column is simply empty, which reads as "this program has no codes"
251/// rather than "this catalog has not been asked since".
252pub fn without_code(places: &[Place]) -> usize {
253    places.iter().filter(|p| p.code.is_none()).count()
254}
255
256/// Every country a typed value reaches, and how it reached them.
257///
258/// Four steps, each derived from the source or from the name — the reasoning
259/// is on the module. The first three are reported as [`TitleMatch::Exact`]
260/// because each names exactly one country: a code and a set of initials are
261/// identifiers, not guesses, and telling a reader their `uk` "partly matched"
262/// would be false modesty about a certainty.
263///
264/// Only the substring step widens, and it says so: `united` reaches both the
265/// United Kingdom and the United States, and the caller is told so the answer
266/// can name them rather than pretend one was asked for.
267pub fn find(places: &[Place], name: &str) -> (Vec<Place>, TitleMatch) {
268    let key = text::normalize(name);
269    if key.is_empty() {
270        return (Vec::new(), TitleMatch::Exact);
271    }
272    for by in [
273        |p: &Place, key: &str| p.key == key,
274        |p: &Place, key: &str| p.code.as_deref() == Some(key),
275        |p: &Place, key: &str| p.initials.as_deref() == Some(key),
276    ] {
277        let found: Vec<Place> = places.iter().filter(|p| by(p, &key)).cloned().collect();
278        if !found.is_empty() {
279            return (found, TitleMatch::Exact);
280        }
281    }
282    (
283        places
284            .iter()
285            .filter(|p| p.key.contains(&key))
286            .cloned()
287            .collect(),
288        TitleMatch::Partial,
289    )
290}
291
292/// How many artists on the shelf have been asked about at all.
293///
294/// The denominator behind every message here. "No country matches" means one
295/// thing in a library that has been fetched and quite another in one that has
296/// not, and a reader shown the same sentence for both learns nothing —
297/// the same distinction the cover pass had to be taught four times over.
298pub fn asked_about(catalog: &Catalog, held: &Sources) -> usize {
299    catalog
300        .artists
301        .iter()
302        .filter(|artist| {
303            EntityRef::of(catalog, EntityKind::Artist, artist.id)
304                .and_then(|entity| held.get(&entity, crate::sources::MUSICBRAINZ).cloned())
305                .is_some()
306        })
307        .count()
308}
309
310#[cfg(test)]
311#[path = "places_tests.rs"]
312mod tests;