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::{Output, 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
73fn pulumi_content_to_json_value(content: PulumiValueContent) -> Option<serde_json::Value> {
74    match content {
75        PulumiValueContent::String(value) => Some(serde_json::Value::String(value)),
76        PulumiValueContent::Integer(value) => Some(serde_json::Value::from(value)),
77        PulumiValueContent::Number(value) => Some(serde_json::Value::from(value)),
78        PulumiValueContent::Boolean(value) => Some(serde_json::Value::from(value)),
79        PulumiValueContent::Array(values) => values
80            .into_iter()
81            .map(|value| pulumi_content_to_json_value(value.content))
82            .collect::<Option<Vec<_>>>()
83            .map(serde_json::Value::Array),
84        PulumiValueContent::Object(fields) => fields
85            .into_iter()
86            .map(|(key, value)| pulumi_content_to_json_value(value.content).map(|v| (key, v)))
87            .collect::<Option<serde_json::Map<_, _>>>()
88            .map(serde_json::Value::Object),
89        PulumiValueContent::None => Some(serde_json::Value::Null),
90        PulumiValueContent::Nothing => None,
91    }
92}
93
94pub fn to_json<T>(value: &T) -> Output<String>
95where
96    T: ToPulumiValue + Clone + Send + Sync + 'static,
97{
98    let value = value.clone();
99    Output::from_resolved_future(async move {
100        let pulumi_value = value.to_pulumi_value().await;
101        let secret = pulumi_value.secret;
102        let dependencies = pulumi_value.dependencies.clone();
103        match pulumi_content_to_json_value(pulumi_value.content) {
104            None => pulumi_gestalt_model::ResolvedOutput {
105                value: None,
106                secret,
107                dependencies,
108            },
109            Some(json) => pulumi_gestalt_model::ResolvedOutput {
110                value: Some(json.to_string()),
111                secret,
112                dependencies,
113            },
114        }
115    })
116}
117
118pub fn sha1(input: impl AsRef<str>) -> String {
119    let hash = Sha1::digest(input.as_ref().as_bytes());
120    hex::encode(hash)
121}
122
123pub fn read_file(path: impl AsRef<str>) -> Result<String> {
124    std::fs::read_to_string(path.as_ref())
125        .with_context(|| format!("Failed to read file: {}", path.as_ref()))
126}
127
128pub fn filebase64(path: impl AsRef<str>) -> Result<String> {
129    let bytes = std::fs::read(path.as_ref())
130        .with_context(|| format!("Failed to read file: {}", path.as_ref()))?;
131    Ok(STANDARD.encode(bytes))
132}
133
134pub fn filebase64sha256(path: impl AsRef<str>) -> Result<String> {
135    use sha2::{Digest as Sha2Digest, Sha256};
136    let bytes = std::fs::read(path.as_ref())
137        .with_context(|| format!("Failed to read file: {}", path.as_ref()))?;
138    let hash = Sha256::digest(&bytes);
139    Ok(STANDARD.encode(hash))
140}
141
142pub fn element<T: Clone, I: TryInto<i64>>(list: impl AsRef<[T]>, index: I) -> Result<T> {
143    let index = index
144        .try_into()
145        .map_err(|_| anyhow!("Failed to convert list index to i64"))?;
146    if index < 0 {
147        bail!("List index cannot be negative: {index}");
148    }
149    let index = usize::try_from(index).context("Failed to convert list index to usize")?;
150    let list_ref = list.as_ref();
151    list_ref.get(index).cloned().ok_or_else(|| {
152        anyhow!(
153            "List index {index} is out of bounds for length {}",
154            list_ref.len()
155        )
156    })
157}
158
159pub fn join<T: AsRef<str>>(separator: impl AsRef<str>, list: impl AsRef<[T]>) -> String {
160    list.as_ref()
161        .iter()
162        .map(AsRef::as_ref)
163        .collect::<Vec<_>>()
164        .join(separator.as_ref())
165}
166
167pub fn length<T>(list: impl AsRef<[T]>) -> i64 {
168    i64::try_from(list.as_ref().len()).expect("List length exceeds i64::MAX")
169}
170
171pub fn length_string(input: impl AsRef<str>) -> i64 {
172    i64::try_from(input.as_ref().graphemes(true).count()).expect("String length exceeds i64::MAX")
173}
174
175pub fn split(separator: impl AsRef<str>, text: impl AsRef<str>) -> Vec<String> {
176    text.as_ref()
177        .split(separator.as_ref())
178        .map(ToOwned::to_owned)
179        .collect()
180}
181
182pub fn single_or_none<T: Clone>(list: impl AsRef<[T]>) -> Result<Option<T>> {
183    let list_ref = list.as_ref();
184    if list_ref.is_empty() {
185        return Ok(None);
186    }
187    if list_ref.len() != 1 {
188        bail!(
189            "singleOrNone expected input list to have at most one element, got {}",
190            list_ref.len()
191        );
192    }
193    Ok(Some(list_ref[0].clone()))
194}
195
196pub fn entries<T: Clone>(map: impl Borrow<BTreeMap<String, T>>) -> Vec<Entry<T>> {
197    map.borrow()
198        .iter()
199        .map(|(key, value)| Entry {
200            key: key.clone(),
201            value: value.clone(),
202        })
203        .collect()
204}
205
206pub fn lookup<K, V, Q>(map: impl Borrow<BTreeMap<K, V>>, key: &Q, default: impl Into<V>) -> V
207where
208    K: Borrow<Q> + Ord,
209    Q: Ord + ?Sized,
210    V: Clone,
211{
212    map.borrow()
213        .get(key)
214        .cloned()
215        .unwrap_or_else(|| default.into())
216}
217
218#[cfg(test)]
219mod tests {
220    use super::{
221        Entry, cwd, element, entries, filebase64, filebase64sha256, from_base64, join, length,
222        length_string, lookup, read_file, sha1, single_or_none, split, to_base64, to_json,
223    };
224    use crate::pulumi_any;
225    use futures::executor::block_on;
226    use pulumi_gestalt_model::{Output, PulumiValueContent, ToPulumiValue};
227    use std::collections::BTreeMap;
228
229    #[test]
230    fn to_base64_encodes_known_text() {
231        assert_eq!(to_base64("hello"), "aGVsbG8=");
232    }
233
234    #[test]
235    fn cwd_matches_std_env_current_dir() {
236        let expected = std::env::current_dir()
237            .unwrap()
238            .into_os_string()
239            .into_string()
240            .unwrap();
241        let actual = cwd().unwrap();
242
243        assert_eq!(actual, expected);
244    }
245
246    #[test]
247    fn from_base64_decodes_known_text() {
248        assert_eq!(from_base64("aGVsbG8=").unwrap(), "hello");
249    }
250
251    #[test]
252    fn roundtrip_text_data() {
253        let text = "Hello, World! ๐ŸŒ";
254        let encoded = to_base64(text);
255        let decoded = from_base64(encoded).unwrap();
256
257        assert_eq!(decoded, text);
258    }
259
260    #[test]
261    fn from_base64_binary_data_returns_error() {
262        // Binary data with invalid UTF-8 sequence
263        let payload = vec![0x00, 0xff, 0x10, 0x41];
264        let encoded = to_base64(&payload);
265        let result = from_base64(encoded);
266
267        assert!(result.is_err());
268        let error_msg = result.unwrap_err().to_string();
269        assert!(error_msg.contains("not valid UTF-8"));
270        assert!(error_msg.contains("binary content"));
271    }
272
273    #[test]
274    fn from_base64_invalid_input_returns_error() {
275        assert!(from_base64("%%%").is_err());
276    }
277
278    #[test]
279    fn to_json_serializes_scalars() {
280        let string_resolved = block_on(to_json(&"hello").resolve());
281        let number_resolved = block_on(to_json(&42.5f64).resolve());
282        let bool_resolved = block_on(to_json(&true).resolve());
283
284        assert_eq!(string_resolved.value, Some("\"hello\"".to_string()));
285        assert_eq!(number_resolved.value, Some("42.5".to_string()));
286        assert_eq!(bool_resolved.value, Some("true".to_string()));
287    }
288
289    #[test]
290    fn to_json_serializes_arrays_and_objects() {
291        let value = pulumi_any!({
292            "items": ["x", "y", "z"],
293            "metadata": {"count": 3}
294        });
295        let resolved = block_on(to_json(&value).resolve());
296
297        let parsed: serde_json::Value =
298            serde_json::from_str(resolved.value.as_ref().unwrap()).unwrap();
299        assert_eq!(
300            parsed,
301            serde_json::json!({
302                "items": ["x", "y", "z"],
303                "metadata": {"count": 3}
304            })
305        );
306    }
307
308    #[test]
309    fn to_json_serializes_none_as_null() {
310        let none_value: Option<String> = None;
311        let resolved = block_on(to_json(&none_value).resolve());
312        assert_eq!(resolved.value, Some("null".to_string()));
313    }
314
315    #[test]
316    fn to_json_returns_unknown_for_root_nothing() {
317        let value: Output<String> = Output::new_nothing();
318        let resolved = block_on(to_json(&value).resolve());
319        assert_eq!(resolved.value, None);
320    }
321
322    #[test]
323    fn to_json_returns_unknown_for_nested_nothing() {
324        let value = pulumi_any!({
325            "known": "value",
326            "unknown": Output::<String>::new_nothing(),
327        });
328        let resolved = block_on(to_json(&value).resolve());
329        assert_eq!(resolved.value, None);
330    }
331
332    #[test]
333    fn to_json_returns_unknown_for_deeply_nested_nothing() {
334        let value = pulumi_any!({
335            "l1": {
336                "l2": [
337                    {"l3": "still-known"},
338                    {"l3": Output::<String>::new_nothing()},
339                ]
340            }
341        });
342        let resolved = block_on(to_json(&value).resolve());
343        assert_eq!(resolved.value, None);
344    }
345
346    #[test]
347    fn to_json_preserves_secret_on_root_secret() {
348        let value = Output::new_secret("top-secret".to_string());
349        let resolved = block_on(to_json(&value).resolve());
350        assert_eq!(resolved.value, Some("\"top-secret\"".to_string()));
351        assert!(resolved.secret);
352    }
353
354    #[test]
355    fn to_json_preserves_secret_for_nested_secret() {
356        let value = pulumi_any!({
357            "public": "ok",
358            "secret": Output::new_secret("hidden".to_string()),
359        });
360        let resolved = block_on(to_json(&value).resolve());
361        assert!(resolved.secret);
362    }
363
364    #[test]
365    fn to_json_preserves_secret_for_deeply_nested_secret() {
366        let value = pulumi_any!({
367            "l1": {
368                "l2": [
369                    {"l3": "visible"},
370                    {"l3": Output::new_secret("hidden".to_string())},
371                ]
372            }
373        });
374        let resolved = block_on(to_json(&value).resolve());
375        assert!(resolved.secret);
376    }
377
378    #[test]
379    fn to_json_unknown_and_secret_nested_results_in_unknown_secret_output() {
380        let value = pulumi_any!({
381            "l1": {
382                "unknown": Output::<String>::new_nothing(),
383                "l2": {
384                    "secret": Output::new_secret("hidden".to_string()),
385                }
386            }
387        });
388        let resolved = block_on(to_json(&value).resolve());
389        assert_eq!(resolved.value, None);
390        assert!(resolved.secret);
391    }
392
393    #[test]
394    fn to_base64_accepts_multiple_input_types() {
395        let string = "xyz".to_string();
396        let from_string = to_base64(string);
397        let from_str = to_base64("abc");
398        let from_bytes = to_base64(vec![97_u8, 98_u8, 99_u8]);
399
400        assert_eq!(from_string, "eHl6");
401        assert_eq!(from_str, "YWJj");
402        assert_eq!(from_str, from_bytes);
403    }
404
405    #[test]
406    fn sha1_hashes_known_values() {
407        assert_eq!(
408            sha1("hello world"),
409            "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed"
410        );
411        assert_eq!(
412            sha1("goodbye world"),
413            "0078bb8e5c9d8abf7f1e4e14c87d9023235b6230"
414        );
415    }
416
417    #[test]
418    fn read_file_reads_existing_file() {
419        use std::io::Write;
420        let mut tmp = tempfile::NamedTempFile::new().unwrap();
421        write!(tmp, "hello from file").unwrap();
422        let result = read_file(tmp.path().to_str().unwrap()).unwrap();
423        assert_eq!(result, "hello from file");
424    }
425
426    #[test]
427    fn read_file_returns_error_for_missing_file() {
428        let result = read_file("/nonexistent/path/file.txt");
429        assert!(result.is_err());
430        let msg = result.unwrap_err().to_string();
431        assert!(msg.contains("Failed to read file"));
432    }
433
434    #[test]
435    fn filebase64_encodes_file_contents() {
436        use std::io::Write;
437        let mut tmp = tempfile::NamedTempFile::new().unwrap();
438        write!(tmp, "hello").unwrap();
439        let result = filebase64(tmp.path().to_str().unwrap()).unwrap();
440        assert_eq!(result, "aGVsbG8=");
441    }
442
443    #[test]
444    fn filebase64sha256_hashes_known_content() {
445        use std::io::Write;
446        // "The quick brown fox jumps over the lazy dog" without trailing newline
447        let mut tmp = tempfile::NamedTempFile::new().unwrap();
448        write!(tmp, "The quick brown fox jumps over the lazy dog").unwrap();
449        let result = filebase64sha256(tmp.path().to_str().unwrap()).unwrap();
450        assert_eq!(result, "16j7swfXgJRpypq8sAguT41WUeRtPNt2LQLQvzfJ5ZI=");
451    }
452
453    #[test]
454    fn element_returns_item_for_valid_index() {
455        let values = vec!["a".to_string(), "b".to_string(), "c".to_string()];
456        assert_eq!(element(&values, 1).unwrap(), "b");
457    }
458
459    #[test]
460    fn element_returns_error_for_negative_index() {
461        let values = vec!["a", "b"];
462        let error = element(&values, -1).unwrap_err().to_string();
463        assert!(error.contains("cannot be negative"));
464    }
465
466    #[test]
467    fn element_returns_error_for_out_of_bounds_index() {
468        let values = vec!["a", "b"];
469        let error = element(&values, 2).unwrap_err().to_string();
470        assert!(error.contains("out of bounds"));
471    }
472
473    #[test]
474    fn join_joins_values_with_separator() {
475        let values = vec!["a".to_string(), "b".to_string(), "c".to_string()];
476        assert_eq!(join("|", &values), "a|b|c");
477    }
478
479    #[test]
480    fn length_returns_i64_length() {
481        let values = vec!["x", "y", "z"];
482        assert_eq!(length(&values), 3_i64);
483    }
484
485    #[test]
486    fn length_string_counts_ascii_graphemes() {
487        assert_eq!(length_string("abcd"), 4_i64);
488    }
489
490    #[test]
491    fn length_string_counts_combining_marks_as_single_grapheme() {
492        let text = "a\u{0301}";
493        assert_eq!(length_string(text), 1_i64);
494    }
495
496    #[test]
497    fn length_string_counts_zwj_emoji_as_single_grapheme() {
498        let text = "๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ฆ";
499        assert_eq!(length_string(text), 1_i64);
500    }
501
502    #[test]
503    fn length_string_counts_mixed_text_by_graphemes() {
504        let text = "A๐Ÿ‘ฉโ€๐Ÿ’ปB";
505        assert_eq!(length_string(text), 3_i64);
506    }
507
508    #[test]
509    fn length_list_behavior_is_unchanged() {
510        let values = vec![0_u8, 1_u8, 2_u8, 3_u8];
511        assert_eq!(length(&values), 4_i64);
512    }
513
514    #[test]
515    fn split_returns_segments() {
516        assert_eq!(split("-", "a-b-c"), vec!["a", "b", "c"]);
517    }
518
519    #[test]
520    fn single_or_none_returns_none_for_empty_list() {
521        let values: Vec<String> = vec![];
522        assert_eq!(single_or_none(&values).unwrap(), None);
523    }
524
525    #[test]
526    fn single_or_none_returns_some_for_single_item_list() {
527        let values = vec!["only".to_string()];
528        assert_eq!(single_or_none(&values).unwrap(), Some("only".to_string()));
529    }
530
531    #[test]
532    fn single_or_none_returns_error_for_multi_item_list() {
533        let values = vec!["a", "b"];
534        let error = single_or_none(&values).unwrap_err().to_string();
535        assert!(error.contains("at most one element"));
536    }
537
538    #[test]
539    fn entries_returns_key_value_pairs_for_non_empty_map() {
540        let mut map = BTreeMap::new();
541        map.insert("b".to_string(), "2".to_string());
542        map.insert("a".to_string(), "1".to_string());
543
544        let output = entries(Box::new(map));
545
546        assert_eq!(
547            output,
548            vec![
549                Entry {
550                    key: "a".to_string(),
551                    value: "1".to_string(),
552                },
553                Entry {
554                    key: "b".to_string(),
555                    value: "2".to_string(),
556                }
557            ]
558        );
559    }
560
561    #[test]
562    fn entries_returns_empty_vec_for_empty_map() {
563        let map: BTreeMap<String, String> = BTreeMap::new();
564        assert_eq!(entries(Box::new(map)), Vec::<Entry<String>>::new());
565    }
566
567    #[test]
568    fn entries_returns_empty_vec_for_empty_map_borrow() {
569        let map: BTreeMap<String, String> = BTreeMap::new();
570        assert_eq!(entries(&map), Vec::<Entry<String>>::new());
571    }
572
573    #[test]
574    fn entries_accepts_owned_map() {
575        let map: BTreeMap<String, String> = BTreeMap::new();
576        assert_eq!(entries(map), Vec::<Entry<String>>::new());
577    }
578
579    #[test]
580    fn lookup_returns_value_for_existing_key() {
581        let mut map = BTreeMap::new();
582        map.insert("answer".to_string(), "42".to_string());
583
584        assert_eq!(lookup(Box::new(map), "answer", "default"), "42".to_string());
585    }
586
587    #[test]
588    fn lookup_returns_default_for_missing_key() {
589        let mut map = BTreeMap::new();
590        map.insert("answer".to_string(), "42".to_string());
591
592        assert_eq!(
593            lookup(Box::new(map), "missing", "default"),
594            "default".to_string()
595        );
596    }
597
598    #[test]
599    fn entries_are_convertible_to_pulumi_values() {
600        let mut map = BTreeMap::new();
601        map.insert("a".to_string(), 1i32);
602        let values = entries(&map);
603        let output = block_on(values.to_pulumi_value());
604
605        assert!(matches!(output.content, PulumiValueContent::Array(_)));
606    }
607}