Skip to main content

romm_api/
feature_compat.rs

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
184/// All feature gates enabled — for TUI unit/integration test `App::new` calls.
185pub 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            &registry_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            &registry_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(&registry_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(&registry_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}