Skip to main content

fakecloud_translate/
shared.rs

1//! Primitives shared across the Amazon Translate handlers: ARN synthesis,
2//! deterministic id derivation, timestamps, and terminology-file parsing. Kept
3//! in one place so the create / get paths cannot diverge on wire format.
4
5use base64::Engine;
6
7/// Current time as awsJson1_1 epoch-seconds (a floating-point number). The
8/// Translate `Timestamp` shape carries no `@timestampFormat`, so awsJson1_1's
9/// default epoch-seconds applies.
10pub fn now_epoch() -> f64 {
11    chrono::Utc::now().timestamp_millis() as f64 / 1000.0
12}
13
14/// FNV-1a hash for deterministic synthesis of ids from a seed so a given
15/// resource's derived value is stable across reads and restarts.
16pub fn hash_str(s: &str) -> u64 {
17    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
18    for b in s.as_bytes() {
19        h ^= u64::from(*b);
20        h = h.wrapping_mul(0x0000_0100_0000_01b3);
21    }
22    h
23}
24
25/// A 32-character lowercase-hex job id (matching the `JobId` `@length` max of
26/// 32 and the permissive `JobId` pattern). Derived deterministically from a
27/// seed so repeated calls in a test are stable, but seeded with a UUID at the
28/// call site to stay unique across jobs.
29pub fn job_id() -> String {
30    uuid::Uuid::new_v4().simple().to_string()
31}
32
33/// Amazon Translate resource ARN,
34/// `arn:aws:translate:{region}:{account}:{resource_type}/{name}`.
35///
36/// `resource_type` is one of the kebab-case type names the live service uses
37/// (`terminology`, `parallel-data`).
38pub fn resource_arn(region: &str, account: &str, resource_type: &str, name: &str) -> String {
39    format!("arn:aws:translate:{region}:{account}:{resource_type}/{name}")
40}
41
42/// Split a Translate ARN into `(resource_type, name)`.
43/// `arn:aws:translate:{region}:{account}:{type}/{name}` -> `(type, name)`.
44pub fn parse_resource_arn(arn: &str) -> Option<(String, String)> {
45    let mut parts = arn.splitn(6, ':');
46    let tail = parts.nth(5)?;
47    let (rtype, name) = tail.split_once('/')?;
48    if rtype.is_empty() || name.is_empty() {
49        return None;
50    }
51    Some((rtype.to_string(), name.to_string()))
52}
53
54/// A service-managed S3 location Translate exposes for a downloadable resource
55/// file (terminology / parallel-data). The file itself is not produced; this
56/// is the presigned-style location it would live at.
57pub fn data_location(region: &str, account: &str, kind: &str, name: &str, file: &str) -> String {
58    let h = hash_str(&format!("{account}/{kind}/{name}/{file}"));
59    format!("https://aws-translate-{region}-prod.s3.{region}.amazonaws.com/{account}/{kind}/{name}/{h:016x}/{file}")
60}
61
62/// Facts read from an imported terminology / parallel-data file.
63#[derive(Debug, Default, Clone)]
64pub struct FileFacts {
65    /// Decoded byte size of the file.
66    pub size_bytes: u64,
67    /// Number of data records (rows past the header for CSV/TSV).
68    pub record_count: u64,
69    /// Source language code (first column header of a CSV/TSV), if derivable.
70    pub source_language: Option<String>,
71    /// Target language codes (remaining column headers), if derivable.
72    pub target_languages: Vec<String>,
73}
74
75/// Decode a wire blob member. awsJson1_1 base64-encodes blobs; the AWS SDK and
76/// terraform always send base64. If the value is not valid base64 (e.g. a raw
77/// synthetic probe string) fall back to the raw UTF-8 bytes so the parser never
78/// panics on unexpected input.
79pub fn decode_blob(s: &str) -> Vec<u8> {
80    base64::engine::general_purpose::STANDARD
81        .decode(s)
82        .unwrap_or_else(|_| s.as_bytes().to_vec())
83}
84
85/// Parse a terminology / parallel-data file (CSV or TSV) for its size, record
86/// count, and language columns. This reads the caller-supplied data file only;
87/// it runs no translation. Unknown / binary formats yield just the byte size.
88pub fn parse_file(bytes: &[u8], format: &str) -> FileFacts {
89    let mut facts = FileFacts {
90        size_bytes: bytes.len() as u64,
91        ..Default::default()
92    };
93    let text = match std::str::from_utf8(bytes) {
94        Ok(t) => t,
95        Err(_) => return facts,
96    };
97    let delim = match format {
98        "TSV" => '\t',
99        // CSV (and, best-effort, TMX which we don't structurally parse) split
100        // on commas.
101        _ => ',',
102    };
103    let mut lines = text.lines().filter(|l| !l.trim().is_empty());
104    if let Some(header) = lines.next() {
105        let cols: Vec<&str> = header.split(delim).map(str::trim).collect();
106        if cols.len() >= 2 {
107            facts.source_language = Some(cols[0].to_string());
108            facts.target_languages = cols[1..].iter().map(|s| s.to_string()).collect();
109        }
110    }
111    facts.record_count = lines.count() as u64;
112    facts
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn arn_round_trips() {
121        let arn = resource_arn("us-east-1", "000000000000", "terminology", "my-term");
122        assert_eq!(
123            arn,
124            "arn:aws:translate:us-east-1:000000000000:terminology/my-term"
125        );
126        assert_eq!(
127            parse_resource_arn(&arn),
128            Some(("terminology".to_string(), "my-term".to_string()))
129        );
130    }
131
132    #[test]
133    fn parse_rejects_non_translate() {
134        assert_eq!(parse_resource_arn("arn:aws:s3:::bucket"), None);
135    }
136
137    #[test]
138    fn csv_terminology_parses_languages_and_count() {
139        let csv = "en,fr,es\nhello,bonjour,hola\ndog,chien,perro\n";
140        let f = parse_file(csv.as_bytes(), "CSV");
141        assert_eq!(f.source_language.as_deref(), Some("en"));
142        assert_eq!(f.target_languages, vec!["fr", "es"]);
143        assert_eq!(f.record_count, 2);
144        assert_eq!(f.size_bytes, csv.len() as u64);
145    }
146
147    #[test]
148    fn tsv_terminology_parses_tab_columns() {
149        let tsv = "en\tde\nhello\thallo\n";
150        let f = parse_file(tsv.as_bytes(), "TSV");
151        assert_eq!(f.source_language.as_deref(), Some("en"));
152        assert_eq!(f.target_languages, vec!["de"]);
153        assert_eq!(f.record_count, 1);
154    }
155
156    #[test]
157    fn decode_blob_falls_back_on_non_base64() {
158        // Contains characters outside the base64 alphabet -> raw bytes.
159        assert_eq!(decode_blob("en,fr\n"), b"en,fr\n");
160    }
161
162    #[test]
163    fn job_id_is_32_hex() {
164        let id = job_id();
165        assert_eq!(id.len(), 32);
166        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
167    }
168}