Skip to main content

pulumi_gestalt_rust/
stdlib.rs

1use anyhow::{Context as AnyhowContext, Result, anyhow, bail};
2use base64::Engine;
3use base64::engine::general_purpose::STANDARD;
4use pulumi_gestalt_model::{PulumiValue, PulumiValueContent, ToPulumiValue};
5use sha1::{Digest, Sha1};
6use std::borrow::Borrow;
7use std::collections::BTreeMap;
8use std::collections::HashSet;
9use unicode_segmentation::UnicodeSegmentation;
10
11#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
12pub struct Entry<T> {
13    pub key: String,
14    pub value: T,
15}
16
17impl<T> ToPulumiValue for Entry<T>
18where
19    T: ToPulumiValue + Send + Sync + 'static,
20{
21    fn to_pulumi_value(&self) -> impl std::future::Future<Output = PulumiValue> + Send {
22        let key = self.key.clone();
23        let value_future = self.value.to_pulumi_value();
24        async move {
25            let value = value_future.await;
26            let secret = value.secret;
27            let dependencies = value.dependencies.clone();
28            PulumiValue {
29                content: PulumiValueContent::Object(vec![
30                    (
31                        "key".to_string(),
32                        PulumiValue {
33                            content: PulumiValueContent::String(key),
34                            secret: false,
35                            dependencies: HashSet::new(),
36                        },
37                    ),
38                    ("value".to_string(), value),
39                ]),
40                secret,
41                dependencies,
42            }
43        }
44    }
45}
46
47pub fn cwd() -> Result<String> {
48    let path = std::env::current_dir().context("Failed to read current working directory")?;
49
50    path.into_os_string().into_string().map_err(|e| {
51        anyhow!(
52            "Current working directory path is not valid UTF-8 and cannot be represented as a string: [{:?}]",
53            e
54        )
55    })
56}
57
58pub fn to_base64(input: impl AsRef<[u8]>) -> String {
59    STANDARD.encode(input)
60}
61
62pub fn from_base64(input: impl AsRef<str>) -> Result<String> {
63    let bytes = STANDARD
64        .decode(input.as_ref())
65        .context("Failed to decode base64 data")?;
66
67    String::from_utf8(bytes).context(
68        "Decoded base64 data is not valid UTF-8 string. \
69         The data may be binary content that cannot be represented as a string.",
70    )
71}
72
73pub fn sha1(input: impl AsRef<str>) -> String {
74    let hash = Sha1::digest(input.as_ref().as_bytes());
75    hex::encode(hash)
76}
77
78pub fn read_file(path: impl AsRef<str>) -> Result<String> {
79    std::fs::read_to_string(path.as_ref())
80        .with_context(|| format!("Failed to read file: {}", path.as_ref()))
81}
82
83pub fn filebase64(path: impl AsRef<str>) -> Result<String> {
84    let bytes = std::fs::read(path.as_ref())
85        .with_context(|| format!("Failed to read file: {}", path.as_ref()))?;
86    Ok(STANDARD.encode(bytes))
87}
88
89pub fn filebase64sha256(path: impl AsRef<str>) -> Result<String> {
90    use sha2::{Digest as Sha2Digest, Sha256};
91    let bytes = std::fs::read(path.as_ref())
92        .with_context(|| format!("Failed to read file: {}", path.as_ref()))?;
93    let hash = Sha256::digest(&bytes);
94    Ok(STANDARD.encode(hash))
95}
96
97pub fn element<T: Clone, I: TryInto<i64>>(list: impl AsRef<[T]>, index: I) -> Result<T> {
98    let index = index
99        .try_into()
100        .map_err(|_| anyhow!("Failed to convert list index to i64"))?;
101    if index < 0 {
102        bail!("List index cannot be negative: {index}");
103    }
104    let index = usize::try_from(index).context("Failed to convert list index to usize")?;
105    let list_ref = list.as_ref();
106    list_ref.get(index).cloned().ok_or_else(|| {
107        anyhow!(
108            "List index {index} is out of bounds for length {}",
109            list_ref.len()
110        )
111    })
112}
113
114pub fn join<T: AsRef<str>>(separator: impl AsRef<str>, list: impl AsRef<[T]>) -> String {
115    list.as_ref()
116        .iter()
117        .map(AsRef::as_ref)
118        .collect::<Vec<_>>()
119        .join(separator.as_ref())
120}
121
122pub fn length<T>(list: impl AsRef<[T]>) -> i64 {
123    i64::try_from(list.as_ref().len()).expect("List length exceeds i64::MAX")
124}
125
126pub fn length_string(input: impl AsRef<str>) -> i64 {
127    i64::try_from(input.as_ref().graphemes(true).count()).expect("String length exceeds i64::MAX")
128}
129
130pub fn split(separator: impl AsRef<str>, text: impl AsRef<str>) -> Vec<String> {
131    text.as_ref()
132        .split(separator.as_ref())
133        .map(ToOwned::to_owned)
134        .collect()
135}
136
137pub fn single_or_none<T: Clone>(list: impl AsRef<[T]>) -> Result<Option<T>> {
138    let list_ref = list.as_ref();
139    if list_ref.is_empty() {
140        return Ok(None);
141    }
142    if list_ref.len() != 1 {
143        bail!(
144            "singleOrNone expected input list to have at most one element, got {}",
145            list_ref.len()
146        );
147    }
148    Ok(Some(list_ref[0].clone()))
149}
150
151pub fn entries<T: Clone>(map: impl Borrow<BTreeMap<String, T>>) -> Vec<Entry<T>> {
152    map.borrow()
153        .iter()
154        .map(|(key, value)| Entry {
155            key: key.clone(),
156            value: value.clone(),
157        })
158        .collect()
159}
160
161pub fn lookup<K, V, Q>(map: impl Borrow<BTreeMap<K, V>>, key: &Q, default: impl Into<V>) -> V
162where
163    K: Borrow<Q> + Ord,
164    Q: Ord + ?Sized,
165    V: Clone,
166{
167    map.borrow()
168        .get(key)
169        .cloned()
170        .unwrap_or_else(|| default.into())
171}
172
173#[cfg(test)]
174mod tests {
175    use super::{
176        Entry, cwd, element, entries, filebase64, filebase64sha256, from_base64, join, length,
177        length_string, lookup, read_file, sha1, single_or_none, split, to_base64,
178    };
179    use pulumi_gestalt_model::{PulumiValueContent, ToPulumiValue};
180    use std::collections::BTreeMap;
181
182    #[test]
183    fn to_base64_encodes_known_text() {
184        assert_eq!(to_base64("hello"), "aGVsbG8=");
185    }
186
187    #[test]
188    fn cwd_matches_std_env_current_dir() {
189        let expected = std::env::current_dir()
190            .unwrap()
191            .into_os_string()
192            .into_string()
193            .unwrap();
194        let actual = cwd().unwrap();
195
196        assert_eq!(actual, expected);
197    }
198
199    #[test]
200    fn from_base64_decodes_known_text() {
201        assert_eq!(from_base64("aGVsbG8=").unwrap(), "hello");
202    }
203
204    #[test]
205    fn roundtrip_text_data() {
206        let text = "Hello, World! ๐ŸŒ";
207        let encoded = to_base64(text);
208        let decoded = from_base64(encoded).unwrap();
209
210        assert_eq!(decoded, text);
211    }
212
213    #[test]
214    fn from_base64_binary_data_returns_error() {
215        // Binary data with invalid UTF-8 sequence
216        let payload = vec![0x00, 0xff, 0x10, 0x41];
217        let encoded = to_base64(&payload);
218        let result = from_base64(encoded);
219
220        assert!(result.is_err());
221        let error_msg = result.unwrap_err().to_string();
222        assert!(error_msg.contains("not valid UTF-8"));
223        assert!(error_msg.contains("binary content"));
224    }
225
226    #[test]
227    fn from_base64_invalid_input_returns_error() {
228        assert!(from_base64("%%%").is_err());
229    }
230
231    #[test]
232    fn to_base64_accepts_multiple_input_types() {
233        let string = "xyz".to_string();
234        let from_string = to_base64(string);
235        let from_str = to_base64("abc");
236        let from_bytes = to_base64(vec![97_u8, 98_u8, 99_u8]);
237
238        assert_eq!(from_string, "eHl6");
239        assert_eq!(from_str, "YWJj");
240        assert_eq!(from_str, from_bytes);
241    }
242
243    #[test]
244    fn sha1_hashes_known_values() {
245        assert_eq!(
246            sha1("hello world"),
247            "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed"
248        );
249        assert_eq!(
250            sha1("goodbye world"),
251            "0078bb8e5c9d8abf7f1e4e14c87d9023235b6230"
252        );
253    }
254
255    #[test]
256    fn read_file_reads_existing_file() {
257        use std::io::Write;
258        let mut tmp = tempfile::NamedTempFile::new().unwrap();
259        write!(tmp, "hello from file").unwrap();
260        let result = read_file(tmp.path().to_str().unwrap()).unwrap();
261        assert_eq!(result, "hello from file");
262    }
263
264    #[test]
265    fn read_file_returns_error_for_missing_file() {
266        let result = read_file("/nonexistent/path/file.txt");
267        assert!(result.is_err());
268        let msg = result.unwrap_err().to_string();
269        assert!(msg.contains("Failed to read file"));
270    }
271
272    #[test]
273    fn filebase64_encodes_file_contents() {
274        use std::io::Write;
275        let mut tmp = tempfile::NamedTempFile::new().unwrap();
276        write!(tmp, "hello").unwrap();
277        let result = filebase64(tmp.path().to_str().unwrap()).unwrap();
278        assert_eq!(result, "aGVsbG8=");
279    }
280
281    #[test]
282    fn filebase64sha256_hashes_known_content() {
283        use std::io::Write;
284        // "The quick brown fox jumps over the lazy dog" without trailing newline
285        let mut tmp = tempfile::NamedTempFile::new().unwrap();
286        write!(tmp, "The quick brown fox jumps over the lazy dog").unwrap();
287        let result = filebase64sha256(tmp.path().to_str().unwrap()).unwrap();
288        assert_eq!(result, "16j7swfXgJRpypq8sAguT41WUeRtPNt2LQLQvzfJ5ZI=");
289    }
290
291    #[test]
292    fn element_returns_item_for_valid_index() {
293        let values = vec!["a".to_string(), "b".to_string(), "c".to_string()];
294        assert_eq!(element(&values, 1).unwrap(), "b");
295    }
296
297    #[test]
298    fn element_returns_error_for_negative_index() {
299        let values = vec!["a", "b"];
300        let error = element(&values, -1).unwrap_err().to_string();
301        assert!(error.contains("cannot be negative"));
302    }
303
304    #[test]
305    fn element_returns_error_for_out_of_bounds_index() {
306        let values = vec!["a", "b"];
307        let error = element(&values, 2).unwrap_err().to_string();
308        assert!(error.contains("out of bounds"));
309    }
310
311    #[test]
312    fn join_joins_values_with_separator() {
313        let values = vec!["a".to_string(), "b".to_string(), "c".to_string()];
314        assert_eq!(join("|", &values), "a|b|c");
315    }
316
317    #[test]
318    fn length_returns_i64_length() {
319        let values = vec!["x", "y", "z"];
320        assert_eq!(length(&values), 3_i64);
321    }
322
323    #[test]
324    fn length_string_counts_ascii_graphemes() {
325        assert_eq!(length_string("abcd"), 4_i64);
326    }
327
328    #[test]
329    fn length_string_counts_combining_marks_as_single_grapheme() {
330        let text = "a\u{0301}";
331        assert_eq!(length_string(text), 1_i64);
332    }
333
334    #[test]
335    fn length_string_counts_zwj_emoji_as_single_grapheme() {
336        let text = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ";
337        assert_eq!(length_string(text), 1_i64);
338    }
339
340    #[test]
341    fn length_string_counts_mixed_text_by_graphemes() {
342        let text = "A๐Ÿ‘ฉโ€๐Ÿ’ปB";
343        assert_eq!(length_string(text), 3_i64);
344    }
345
346    #[test]
347    fn length_list_behavior_is_unchanged() {
348        let values = vec![0_u8, 1_u8, 2_u8, 3_u8];
349        assert_eq!(length(&values), 4_i64);
350    }
351
352    #[test]
353    fn split_returns_segments() {
354        assert_eq!(split("-", "a-b-c"), vec!["a", "b", "c"]);
355    }
356
357    #[test]
358    fn single_or_none_returns_none_for_empty_list() {
359        let values: Vec<String> = vec![];
360        assert_eq!(single_or_none(&values).unwrap(), None);
361    }
362
363    #[test]
364    fn single_or_none_returns_some_for_single_item_list() {
365        let values = vec!["only".to_string()];
366        assert_eq!(single_or_none(&values).unwrap(), Some("only".to_string()));
367    }
368
369    #[test]
370    fn single_or_none_returns_error_for_multi_item_list() {
371        let values = vec!["a", "b"];
372        let error = single_or_none(&values).unwrap_err().to_string();
373        assert!(error.contains("at most one element"));
374    }
375
376    #[test]
377    fn entries_returns_key_value_pairs_for_non_empty_map() {
378        let mut map = BTreeMap::new();
379        map.insert("b".to_string(), "2".to_string());
380        map.insert("a".to_string(), "1".to_string());
381
382        let output = entries(Box::new(map));
383
384        assert_eq!(
385            output,
386            vec![
387                Entry {
388                    key: "a".to_string(),
389                    value: "1".to_string(),
390                },
391                Entry {
392                    key: "b".to_string(),
393                    value: "2".to_string(),
394                }
395            ]
396        );
397    }
398
399    #[test]
400    fn entries_returns_empty_vec_for_empty_map() {
401        let map: BTreeMap<String, String> = BTreeMap::new();
402        assert_eq!(entries(Box::new(map)), Vec::<Entry<String>>::new());
403    }
404
405    #[test]
406    fn entries_returns_empty_vec_for_empty_map_borrow() {
407        let map: BTreeMap<String, String> = BTreeMap::new();
408        assert_eq!(entries(&map), Vec::<Entry<String>>::new());
409    }
410
411    #[test]
412    fn entries_accepts_owned_map() {
413        let map: BTreeMap<String, String> = BTreeMap::new();
414        assert_eq!(entries(map), Vec::<Entry<String>>::new());
415    }
416
417    #[test]
418    fn lookup_returns_value_for_existing_key() {
419        let mut map = BTreeMap::new();
420        map.insert("answer".to_string(), "42".to_string());
421
422        assert_eq!(lookup(Box::new(map), "answer", "default"), "42".to_string());
423    }
424
425    #[test]
426    fn lookup_returns_default_for_missing_key() {
427        let mut map = BTreeMap::new();
428        map.insert("answer".to_string(), "42".to_string());
429
430        assert_eq!(
431            lookup(Box::new(map), "missing", "default"),
432            "default".to_string()
433        );
434    }
435
436    #[test]
437    fn entries_are_convertible_to_pulumi_values() {
438        let mut map = BTreeMap::new();
439        map.insert("a".to_string(), 1i32);
440        let values = entries(&map);
441        let output =
442            pulumi_gestalt_model::__private::futures::executor::block_on(values.to_pulumi_value());
443
444        assert!(matches!(output.content, PulumiValueContent::Array(_)));
445    }
446}