1use serde::Serialize;
2
3use crate::openapi::EndpointRegistry;
4
5#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
6pub struct RequiredEndpoint {
7 pub method: &'static str,
8 pub path: &'static str,
9}
10
11impl RequiredEndpoint {
12 pub fn label(self) -> String {
13 format!("{} {}", self.method, self.path)
14 }
15}
16
17#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
18pub struct FeatureCompatibility {
19 pub feature: &'static str,
20 pub supported: bool,
21 pub missing: Vec<RequiredEndpoint>,
22 unsupported_message: &'static str,
23}
24
25impl FeatureCompatibility {
26 pub fn from_registry(
27 feature: &'static str,
28 unsupported_message: &'static str,
29 required: &[RequiredEndpoint],
30 registry: &EndpointRegistry,
31 ) -> Self {
32 let missing = required
33 .iter()
34 .copied()
35 .filter(|ep| !registry.has_endpoint(ep.method, ep.path))
36 .collect::<Vec<_>>();
37 Self {
38 feature,
39 supported: missing.is_empty(),
40 missing,
41 unsupported_message,
42 }
43 }
44
45 pub fn supported(feature: &'static str, unsupported_message: &'static str) -> Self {
46 Self {
47 feature,
48 supported: true,
49 missing: Vec::new(),
50 unsupported_message,
51 }
52 }
53
54 pub fn unsupported_message(&self) -> String {
55 if self.missing.is_empty() {
56 self.unsupported_message.to_string()
57 } else {
58 format!(
59 "{} Missing endpoint(s): {}",
60 self.unsupported_message,
61 self.missing
62 .iter()
63 .map(|ep| ep.label())
64 .collect::<Vec<_>>()
65 .join(", ")
66 )
67 }
68 }
69}
70
71pub const SAVE_SYNC_FEATURE: &str = "save-sync";
72
73pub const SAVE_SYNC_UNSUPPORTED_MESSAGE: &str =
74 "This RomM server does not expose save-sync endpoints; upgrade RomM to use romm-cli sync.";
75
76pub const SAVE_SYNC_REQUIRED_ENDPOINTS: [RequiredEndpoint; 6] = [
77 RequiredEndpoint {
78 method: "GET",
79 path: "/api/devices",
80 },
81 RequiredEndpoint {
82 method: "POST",
83 path: "/api/devices",
84 },
85 RequiredEndpoint {
86 method: "GET",
87 path: "/api/sync/sessions",
88 },
89 RequiredEndpoint {
90 method: "POST",
91 path: "/api/sync/negotiate",
92 },
93 RequiredEndpoint {
94 method: "POST",
95 path: "/api/sync/sessions/{session_id}/complete",
96 },
97 RequiredEndpoint {
98 method: "POST",
99 path: "/api/sync/devices/{device_id}/push-pull",
100 },
101];
102
103pub type SaveSyncCompatibility = FeatureCompatibility;
104
105pub fn save_sync_compatibility(registry: &EndpointRegistry) -> SaveSyncCompatibility {
106 FeatureCompatibility::from_registry(
107 SAVE_SYNC_FEATURE,
108 SAVE_SYNC_UNSUPPORTED_MESSAGE,
109 &SAVE_SYNC_REQUIRED_ENDPOINTS,
110 registry,
111 )
112}
113
114pub fn supported_save_sync_compatibility() -> SaveSyncCompatibility {
115 FeatureCompatibility::supported(SAVE_SYNC_FEATURE, SAVE_SYNC_UNSUPPORTED_MESSAGE)
116}
117
118pub const METADATA_EDIT_FEATURE: &str = "metadata-edit";
119
120pub const METADATA_EDIT_UNSUPPORTED_MESSAGE: &str =
121 "This RomM server does not expose ROM metadata edit endpoints; upgrade RomM to 4.8+.";
122
123pub const METADATA_EDIT_REQUIRED_ENDPOINTS: [RequiredEndpoint; 3] = [
124 RequiredEndpoint {
125 method: "GET",
126 path: "/api/search/roms",
127 },
128 RequiredEndpoint {
129 method: "PUT",
130 path: "/api/roms/{id}",
131 },
132 RequiredEndpoint {
133 method: "GET",
134 path: "/api/roms/{id}",
135 },
136];
137
138pub type MetadataEditCompatibility = FeatureCompatibility;
139
140pub fn metadata_edit_compatibility(registry: &EndpointRegistry) -> MetadataEditCompatibility {
141 FeatureCompatibility::from_registry(
142 METADATA_EDIT_FEATURE,
143 METADATA_EDIT_UNSUPPORTED_MESSAGE,
144 &METADATA_EDIT_REQUIRED_ENDPOINTS,
145 registry,
146 )
147}
148
149pub fn supported_metadata_edit_compatibility() -> MetadataEditCompatibility {
150 FeatureCompatibility::supported(METADATA_EDIT_FEATURE, METADATA_EDIT_UNSUPPORTED_MESSAGE)
151}
152
153pub const ACHIEVEMENTS_FEATURE: &str = "achievements";
154
155pub const ACHIEVEMENTS_UNSUPPORTED_MESSAGE: &str =
156 "This RomM server does not expose achievement fields; upgrade RomM to 4.9+.";
157
158pub const ACHIEVEMENTS_REQUIRED_ENDPOINTS: [RequiredEndpoint; 2] = [
159 RequiredEndpoint {
160 method: "GET",
161 path: "/api/roms/{id}",
162 },
163 RequiredEndpoint {
164 method: "GET",
165 path: "/api/users/me",
166 },
167];
168
169pub type AchievementsCompatibility = FeatureCompatibility;
170
171pub fn achievements_compatibility(registry: &EndpointRegistry) -> AchievementsCompatibility {
172 FeatureCompatibility::from_registry(
173 ACHIEVEMENTS_FEATURE,
174 ACHIEVEMENTS_UNSUPPORTED_MESSAGE,
175 &ACHIEVEMENTS_REQUIRED_ENDPOINTS,
176 registry,
177 )
178}
179
180pub fn supported_achievements_compatibility() -> AchievementsCompatibility {
181 FeatureCompatibility::supported(ACHIEVEMENTS_FEATURE, ACHIEVEMENTS_UNSUPPORTED_MESSAGE)
182}
183
184pub fn supported_feature_compat() -> (
186 SaveSyncCompatibility,
187 MetadataEditCompatibility,
188 AchievementsCompatibility,
189) {
190 (
191 supported_save_sync_compatibility(),
192 supported_metadata_edit_compatibility(),
193 supported_achievements_compatibility(),
194 )
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 fn registry_for(paths: &[(&str, &str)]) -> EndpointRegistry {
202 let mut paths_json = serde_json::Map::new();
203 for (method, path) in paths {
204 let entry = paths_json
205 .entry((*path).to_string())
206 .or_insert_with(|| serde_json::json!({}));
207 entry.as_object_mut().unwrap().insert(
208 method.to_ascii_lowercase(),
209 serde_json::json!({ "responses": { "200": { "description": "ok" } } }),
210 );
211 }
212 EndpointRegistry::from_openapi_json(
213 &serde_json::json!({
214 "openapi": "3.0.0",
215 "paths": paths_json
216 })
217 .to_string(),
218 )
219 .expect("parse")
220 }
221
222 #[test]
223 fn feature_compatibility_supported_when_all_required_endpoints_exist() {
224 let required = [
225 RequiredEndpoint {
226 method: "GET",
227 path: "/api/a",
228 },
229 RequiredEndpoint {
230 method: "POST",
231 path: "/api/b/{id}",
232 },
233 ];
234 let compat = FeatureCompatibility::from_registry(
235 "demo",
236 "Demo feature unsupported.",
237 &required,
238 ®istry_for(&[("GET", "/api/a"), ("POST", "/api/b/{id}")]),
239 );
240
241 assert!(compat.supported);
242 assert!(compat.missing.is_empty());
243 }
244
245 #[test]
246 fn feature_compatibility_reports_missing_endpoints() {
247 let required = [
248 RequiredEndpoint {
249 method: "GET",
250 path: "/api/a",
251 },
252 RequiredEndpoint {
253 method: "POST",
254 path: "/api/b",
255 },
256 ];
257 let compat = FeatureCompatibility::from_registry(
258 "demo",
259 "Demo feature unsupported.",
260 &required,
261 ®istry_for(&[("GET", "/api/a")]),
262 );
263
264 assert!(!compat.supported);
265 assert_eq!(
266 compat.missing,
267 vec![RequiredEndpoint {
268 method: "POST",
269 path: "/api/b"
270 }]
271 );
272 assert_eq!(
273 compat.unsupported_message(),
274 "Demo feature unsupported. Missing endpoint(s): POST /api/b"
275 );
276 }
277
278 #[test]
279 fn save_sync_compatibility_supported_when_all_required_endpoints_exist() {
280 let paths = SAVE_SYNC_REQUIRED_ENDPOINTS
281 .iter()
282 .map(|ep| (ep.method, ep.path))
283 .collect::<Vec<_>>();
284 let compat = save_sync_compatibility(®istry_for(&paths));
285
286 assert_eq!(compat.feature, SAVE_SYNC_FEATURE);
287 assert!(compat.supported);
288 assert!(compat.missing.is_empty());
289 }
290
291 #[test]
292 fn save_sync_compatibility_reports_partial_missing_endpoints() {
293 let compat = save_sync_compatibility(®istry_for(&[("GET", "/api/devices")]));
294
295 assert!(!compat.supported);
296 assert_eq!(compat.missing.len(), SAVE_SYNC_REQUIRED_ENDPOINTS.len() - 1);
297 assert!(compat
298 .unsupported_message()
299 .contains("POST /api/sync/negotiate"));
300 }
301
302 #[test]
303 fn save_sync_compatibility_empty_registry_is_unsupported() {
304 let compat = save_sync_compatibility(&EndpointRegistry::default());
305
306 assert!(!compat.supported);
307 assert_eq!(compat.missing.len(), SAVE_SYNC_REQUIRED_ENDPOINTS.len());
308 }
309}