Skip to main content

phemios_core/
federation.rs

1//! Application-only reference namespaces preserve the engine's original identities.
2use serde_json::Value;
3/// An explicit catalog owner; remote and local identities never collide.
4#[derive(Clone, Copy, Debug, PartialEq, Eq)]
5pub enum Source {
6    /// App-owned embedded engine.
7    Local,
8    /// Explicitly connected remote engine.
9    Server,
10}
11impl Source {
12    /// Validate the narrow IPC source vocabulary.
13    pub fn parse(value: &str) -> Result<Self, String> {
14        match value {
15            "local" => Ok(Self::Local),
16            "server" => Ok(Self::Server),
17            _ => Err("Origine de bibliothèque invalide.".into()),
18        }
19    }
20    /// Stable non-secret owner marker.
21    pub fn name(self) -> &'static str {
22        match self {
23            Self::Local => "local",
24            Self::Server => "server",
25        }
26    }
27}
28fn entity_kind(kind: &str) -> bool {
29    matches!(
30        kind,
31        "artist" | "release" | "track" | "recording" | "work" | "release_group" | "label" | "genre"
32    )
33}
34/// Qualify only an engine identity, preserving its entity kind.
35pub fn qualify(reference: &str, source: Source) -> String {
36    match reference.split_once(':') {
37        Some((kind, suffix)) if entity_kind(kind) => {
38            format!("{kind}:@phemios/{}/{suffix}", source.name())
39        }
40        _ => reference.into(),
41    }
42}
43/// Unwrap an application identity. Legacy raw references belong to the remote server.
44/// Only a marker immediately after the entity kind is structural; keys remain literal.
45pub fn resolve(reference: &str) -> Result<(Source, String), String> {
46    let Some((kind, key)) = reference.split_once(':') else {
47        return Ok((Source::Server, reference.into()));
48    };
49    let Some(rest) = key.strip_prefix("@phemios/") else {
50        return Ok((Source::Server, reference.into()));
51    };
52    let (source, suffix) = rest
53        .split_once('/')
54        .ok_or("Référence de bibliothèque invalide.")?;
55    if suffix.is_empty() {
56        return Err("Référence de bibliothèque invalide.".into());
57    }
58    Ok((Source::parse(source)?, format!("{kind}:{suffix}")))
59}
60fn identity_key(key: &str) -> bool {
61    matches!(
62        key,
63        "items"
64            | "reference"
65            | "ref"
66            | "release"
67            | "track"
68            | "artist"
69            | "recording"
70            | "work"
71            | "label"
72            | "labels"
73            | "release_refs"
74            | "contributors"
75            | "tracks"
76            | "albums"
77            | "releases"
78            | "artists"
79    )
80}
81/// Rewrite schema identity slots only; notes, tags, filenames and source claims remain exact.
82pub fn map(value: &mut Value, source: Source, incoming: bool) -> Result<(), String> {
83    fn walk(value: &mut Value, key: &str, source: Source, incoming: bool) -> Result<(), String> {
84        match value {
85            Value::Object(fields) => {
86                for (key, value) in fields {
87                    walk(value, key, source, incoming)?;
88                }
89            }
90            Value::Array(rows) => {
91                for row in rows {
92                    walk(row, key, source, incoming)?;
93                }
94            }
95            Value::String(text)
96                if identity_key(key)
97                    && text
98                        .split_once(':')
99                        .is_some_and(|(kind, _)| entity_kind(kind)) =>
100            {
101                if incoming {
102                    *text = qualify(text, source);
103                } else {
104                    let (owner, raw) = resolve(text)?;
105                    if owner != source {
106                        return Err("Cette opération doit concerner une seule bibliothèque.".into());
107                    }
108                    *text = raw;
109                }
110            }
111            _ => {}
112        }
113        Ok(())
114    }
115    walk(value, "", source, incoming)
116}
117#[cfg(test)]
118#[path = "federation_tests.rs"]
119mod tests;
120
121/// Resolve and unwrap known request selectors before choosing a native client.
122/// Track lyrics use `track`, while ordinary catalog routes use `ref`/`reference`.
123pub fn request_origin(
124    query: &mut std::collections::BTreeMap<String, String>,
125    body: &mut Option<Value>,
126    fallback: Source,
127) -> Result<Source, String> {
128    let hint = ["ref", "reference", "track"]
129        .iter()
130        .find_map(|key| query.get(*key).map(String::as_str))
131        .or_else(|| {
132            body.as_ref().and_then(|value| {
133                ["release", "reference", "track"]
134                    .iter()
135                    .find_map(|key| value[*key].as_str())
136            })
137        });
138    let origin = hint
139        .map(resolve)
140        .transpose()?
141        .map_or(fallback, |value| value.0);
142    for key in ["ref", "reference", "track"] {
143        if let Some(reference) = query.get_mut(key) {
144            let (owner, raw) = resolve(reference)?;
145            if owner != origin {
146                return Err("Origines incompatibles.".into());
147            }
148            *reference = raw;
149        }
150    }
151    if let Some(value) = body {
152        map(value, origin, false)?;
153    }
154    Ok(origin)
155}