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
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 fn registry_for(paths: &[(&str, &str)]) -> EndpointRegistry {
123 let mut paths_json = serde_json::Map::new();
124 for (method, path) in paths {
125 let entry = paths_json
126 .entry((*path).to_string())
127 .or_insert_with(|| serde_json::json!({}));
128 entry.as_object_mut().unwrap().insert(
129 method.to_ascii_lowercase(),
130 serde_json::json!({ "responses": { "200": { "description": "ok" } } }),
131 );
132 }
133 EndpointRegistry::from_openapi_json(
134 &serde_json::json!({
135 "openapi": "3.0.0",
136 "paths": paths_json
137 })
138 .to_string(),
139 )
140 .expect("parse")
141 }
142
143 #[test]
144 fn feature_compatibility_supported_when_all_required_endpoints_exist() {
145 let required = [
146 RequiredEndpoint {
147 method: "GET",
148 path: "/api/a",
149 },
150 RequiredEndpoint {
151 method: "POST",
152 path: "/api/b/{id}",
153 },
154 ];
155 let compat = FeatureCompatibility::from_registry(
156 "demo",
157 "Demo feature unsupported.",
158 &required,
159 ®istry_for(&[("GET", "/api/a"), ("POST", "/api/b/{id}")]),
160 );
161
162 assert!(compat.supported);
163 assert!(compat.missing.is_empty());
164 }
165
166 #[test]
167 fn feature_compatibility_reports_missing_endpoints() {
168 let required = [
169 RequiredEndpoint {
170 method: "GET",
171 path: "/api/a",
172 },
173 RequiredEndpoint {
174 method: "POST",
175 path: "/api/b",
176 },
177 ];
178 let compat = FeatureCompatibility::from_registry(
179 "demo",
180 "Demo feature unsupported.",
181 &required,
182 ®istry_for(&[("GET", "/api/a")]),
183 );
184
185 assert!(!compat.supported);
186 assert_eq!(
187 compat.missing,
188 vec![RequiredEndpoint {
189 method: "POST",
190 path: "/api/b"
191 }]
192 );
193 assert_eq!(
194 compat.unsupported_message(),
195 "Demo feature unsupported. Missing endpoint(s): POST /api/b"
196 );
197 }
198
199 #[test]
200 fn save_sync_compatibility_supported_when_all_required_endpoints_exist() {
201 let paths = SAVE_SYNC_REQUIRED_ENDPOINTS
202 .iter()
203 .map(|ep| (ep.method, ep.path))
204 .collect::<Vec<_>>();
205 let compat = save_sync_compatibility(®istry_for(&paths));
206
207 assert_eq!(compat.feature, SAVE_SYNC_FEATURE);
208 assert!(compat.supported);
209 assert!(compat.missing.is_empty());
210 }
211
212 #[test]
213 fn save_sync_compatibility_reports_partial_missing_endpoints() {
214 let compat = save_sync_compatibility(®istry_for(&[("GET", "/api/devices")]));
215
216 assert!(!compat.supported);
217 assert_eq!(compat.missing.len(), SAVE_SYNC_REQUIRED_ENDPOINTS.len() - 1);
218 assert!(compat
219 .unsupported_message()
220 .contains("POST /api/sync/negotiate"));
221 }
222
223 #[test]
224 fn save_sync_compatibility_empty_registry_is_unsupported() {
225 let compat = save_sync_compatibility(&EndpointRegistry::default());
226
227 assert!(!compat.supported);
228 assert_eq!(compat.missing.len(), SAVE_SYNC_REQUIRED_ENDPOINTS.len());
229 }
230}