1use serde::{Deserialize, Serialize};
2use sha2::{Digest, Sha256};
3
4use super::types::{ApiError, EffectiveTranslationRef};
5
6pub(super) const PO_LOCK_KEY: &str = "lock";
8pub(super) const PO_AI_KEY: &str = "ai";
10
11const MACHINE_TRANSLATION_HASH_NAMESPACE: &[u8] = b"ferrocat:mt:v1";
12
13#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22#[serde(deny_unknown_fields)]
23pub struct MachineMetadata {
24 pub lock: String,
26 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub ai: Option<AiProvenance>,
29}
30
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
37#[serde(deny_unknown_fields)]
38pub struct AiProvenance {
39 pub model: String,
44 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub confidence: Option<f32>,
47}
48
49#[must_use]
67pub fn machine_translation_hash(translation: EffectiveTranslationRef<'_>) -> String {
68 let mut payload = Vec::new();
69 payload.extend_from_slice(MACHINE_TRANSLATION_HASH_NAMESPACE);
70 match translation {
71 EffectiveTranslationRef::Singular(value) => {
72 push_hash_component(&mut payload, "singular");
73 push_hash_component(&mut payload, value);
74 }
75 EffectiveTranslationRef::Plural(values) => {
76 push_hash_component(&mut payload, "plural");
77 let count = u32::try_from(values.len()).expect("plural translation count exceeds u32");
78 payload.extend_from_slice(&count.to_be_bytes());
79 for (category, value) in values {
80 push_hash_component(&mut payload, category);
81 push_hash_component(&mut payload, value);
82 }
83 }
84 }
85 let digest = Sha256::digest(&payload);
86 base64_url_no_pad(&digest[..16])
87}
88
89pub(super) fn validate_machine_metadata(metadata: &MachineMetadata) -> Result<(), ApiError> {
90 if metadata.lock.trim().is_empty() {
91 return Err(ApiError::InvalidArguments(
92 "machine-managed lock must not be empty".to_owned(),
93 ));
94 }
95 if let Some(ai) = &metadata.ai {
96 if ai.model.trim().is_empty() {
97 return Err(ApiError::InvalidArguments(
98 "ai model must not be empty".to_owned(),
99 ));
100 }
101 if ai
102 .confidence
103 .is_some_and(|confidence| !(0.0..=1.0).contains(&confidence))
104 {
105 return Err(ApiError::InvalidArguments(
106 "ai confidence must be between 0 and 1".to_owned(),
107 ));
108 }
109 }
110 Ok(())
111}
112
113pub(super) fn format_ai_descriptor(ai: &AiProvenance) -> String {
116 match ai.confidence {
117 Some(confidence) => format!("{}:{confidence}", ai.model),
118 None => ai.model.clone(),
119 }
120}
121
122pub(super) fn parse_ai_descriptor(value: &str) -> AiProvenance {
129 if let Some((model, suffix)) = value.rsplit_once(':')
130 && let Some(confidence) = parse_confidence(suffix)
131 {
132 return AiProvenance {
133 model: model.to_owned(),
134 confidence: Some(confidence),
135 };
136 }
137 AiProvenance {
138 model: value.to_owned(),
139 confidence: None,
140 }
141}
142
143fn parse_confidence(value: &str) -> Option<f32> {
144 value
145 .parse::<f32>()
146 .ok()
147 .filter(|confidence| (0.0..=1.0).contains(confidence))
148}
149
150fn push_hash_component(out: &mut Vec<u8>, value: &str) {
151 let value_len =
152 u32::try_from(value.len()).expect("machine translation hash component exceeds u32");
153 out.extend_from_slice(&value_len.to_be_bytes());
154 out.extend_from_slice(value.as_bytes());
155}
156
157fn base64_url_no_pad(bytes: &[u8]) -> String {
158 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
159 let mut out = String::with_capacity((bytes.len() * 4).div_ceil(3));
160 let mut index = 0;
161
162 while index + 3 <= bytes.len() {
163 let chunk = (u32::from(bytes[index]) << 16)
164 | (u32::from(bytes[index + 1]) << 8)
165 | u32::from(bytes[index + 2]);
166 out.push(ALPHABET[((chunk >> 18) & 0x3f) as usize] as char);
167 out.push(ALPHABET[((chunk >> 12) & 0x3f) as usize] as char);
168 out.push(ALPHABET[((chunk >> 6) & 0x3f) as usize] as char);
169 out.push(ALPHABET[(chunk & 0x3f) as usize] as char);
170 index += 3;
171 }
172
173 match bytes.len() - index {
174 1 => {
175 let chunk = u32::from(bytes[index]) << 16;
176 out.push(ALPHABET[((chunk >> 18) & 0x3f) as usize] as char);
177 out.push(ALPHABET[((chunk >> 12) & 0x3f) as usize] as char);
178 }
179 2 => {
180 let chunk = (u32::from(bytes[index]) << 16) | (u32::from(bytes[index + 1]) << 8);
181 out.push(ALPHABET[((chunk >> 18) & 0x3f) as usize] as char);
182 out.push(ALPHABET[((chunk >> 12) & 0x3f) as usize] as char);
183 out.push(ALPHABET[((chunk >> 6) & 0x3f) as usize] as char);
184 }
185 _ => {}
186 }
187
188 out
189}
190
191#[cfg(test)]
192mod tests {
193 use super::{
194 AiProvenance, MachineMetadata, format_ai_descriptor, machine_translation_hash,
195 parse_ai_descriptor, validate_machine_metadata,
196 };
197 use crate::api::EffectiveTranslationRef;
198 use std::collections::BTreeMap;
199
200 #[test]
201 fn hash_is_stable_for_singular_and_plural_translations() {
202 let singular = machine_translation_hash(EffectiveTranslationRef::Singular("Hallo"));
203 assert_eq!(
204 singular,
205 machine_translation_hash(EffectiveTranslationRef::Singular("Hallo"))
206 );
207 assert_ne!(
208 singular,
209 machine_translation_hash(EffectiveTranslationRef::Singular("Hello"))
210 );
211
212 let plural = BTreeMap::from([
213 ("one".to_owned(), "Eine Datei".to_owned()),
214 ("other".to_owned(), "{count} Dateien".to_owned()),
215 ]);
216 let repeated = BTreeMap::from([
217 ("one".to_owned(), "Eine Datei".to_owned()),
218 ("other".to_owned(), "{count} Dateien".to_owned()),
219 ]);
220 assert_eq!(
221 machine_translation_hash(EffectiveTranslationRef::Plural(&plural)),
222 machine_translation_hash(EffectiveTranslationRef::Plural(&repeated))
223 );
224 assert_eq!(singular.len(), 22);
225 }
226
227 #[test]
228 fn ai_descriptor_roundtrips_and_keeps_free_form_model() {
229 let ai = AiProvenance {
231 model: "openai/gpt-5.5-high".to_owned(),
232 confidence: Some(0.93),
233 };
234 let rendered = format_ai_descriptor(&ai);
235 assert_eq!(rendered, "openai/gpt-5.5-high:0.93");
236 assert_eq!(parse_ai_descriptor(&rendered), ai);
237
238 let bare = AiProvenance {
240 model: "grok-4".to_owned(),
241 confidence: None,
242 };
243 assert_eq!(format_ai_descriptor(&bare), "grok-4");
244 assert_eq!(parse_ai_descriptor("grok-4"), bare);
245
246 assert_eq!(
248 parse_ai_descriptor("vendor:model-x"),
249 AiProvenance {
250 model: "vendor:model-x".to_owned(),
251 confidence: None,
252 }
253 );
254 assert_eq!(
255 parse_ai_descriptor("weird-model:1.5"),
256 AiProvenance {
257 model: "weird-model:1.5".to_owned(),
258 confidence: None,
259 }
260 );
261 }
262
263 #[test]
264 fn validate_rejects_bad_metadata() {
265 assert!(
266 validate_machine_metadata(&MachineMetadata {
267 lock: " ".to_owned(),
268 ai: None,
269 })
270 .is_err()
271 );
272 assert!(
273 validate_machine_metadata(&MachineMetadata {
274 lock: "abc".to_owned(),
275 ai: Some(AiProvenance {
276 model: "".to_owned(),
277 confidence: None,
278 }),
279 })
280 .is_err()
281 );
282 assert!(
283 validate_machine_metadata(&MachineMetadata {
284 lock: "abc".to_owned(),
285 ai: Some(AiProvenance {
286 model: "m".to_owned(),
287 confidence: Some(1.5),
288 }),
289 })
290 .is_err()
291 );
292 assert!(
293 validate_machine_metadata(&MachineMetadata {
294 lock: "abc".to_owned(),
295 ai: Some(AiProvenance {
296 model: "m".to_owned(),
297 confidence: Some(0.5),
298 }),
299 })
300 .is_ok()
301 );
302 }
303
304 #[test]
305 fn base64_url_encodes_every_remainder() {
306 assert_eq!(super::base64_url_no_pad(b""), "");
309 assert_eq!(super::base64_url_no_pad(b"f"), "Zg");
310 assert_eq!(super::base64_url_no_pad(b"fo"), "Zm8");
311 assert_eq!(super::base64_url_no_pad(b"foo"), "Zm9v");
312 }
313}