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 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 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}