blob: f01acb03d98d543005ad3cef60cae291f51fe869 [file] [log] [blame]
Brian Silvermancc09f182022-03-09 15:40:20 -08001use std::collections::{btree_set, BTreeMap, BTreeSet};
Adam Snaider1c095c92023-07-08 02:09:58 -04002use std::iter::{once, FromIterator};
3
4use serde::ser::{SerializeMap, SerializeTupleStruct, Serializer};
5use serde::{Deserialize, Serialize};
6use serde_starlark::{FunctionCall, LineComment, MULTILINE};
7
8use crate::utils::starlark::serialize::MultilineArray;
Brian Silvermancc09f182022-03-09 15:40:20 -08009
10pub trait SelectMap<T, U> {
11 // A selectable should also implement a `map` function allowing one type of selectable
12 // to be mutated into another. However, the approach I'm looking for requires GAT
13 // (Generic Associated Types) which are not yet stable.
14 // https://github.com/rust-lang/rust/issues/44265
15 type Mapped;
16 fn map<F: Copy + Fn(T) -> U>(self, func: F) -> Self::Mapped;
17}
18
19pub trait Select<T> {
20 /// Gather a list of all conditions currently set on the selectable. A conditional
21 /// would be the key of the select statement.
22 fn configurations(&self) -> BTreeSet<Option<&String>>;
23}
24
25#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, Clone)]
26pub struct SelectList<T: Ord> {
Adam Snaider1c095c92023-07-08 02:09:58 -040027 // Invariant: any T in `common` is not anywhere in `selects`.
Brian Silvermancc09f182022-03-09 15:40:20 -080028 common: BTreeSet<T>,
Adam Snaider1c095c92023-07-08 02:09:58 -040029 // Invariant: none of the sets are empty.
Brian Silvermancc09f182022-03-09 15:40:20 -080030 selects: BTreeMap<String, BTreeSet<T>>,
Adam Snaider1c095c92023-07-08 02:09:58 -040031 // Elements that used to be in `selects` before the most recent
32 // `remap_configurations` operation, but whose old configuration did not get
33 // mapped to any new configuration. They could be ignored, but are preserved
34 // here to generate comments that help the user understand what happened.
35 #[serde(skip_serializing_if = "BTreeSet::is_empty", default = "BTreeSet::new")]
36 unmapped: BTreeSet<T>,
Brian Silvermancc09f182022-03-09 15:40:20 -080037}
38
39impl<T: Ord> Default for SelectList<T> {
40 fn default() -> Self {
41 Self {
42 common: BTreeSet::new(),
43 selects: BTreeMap::new(),
Adam Snaider1c095c92023-07-08 02:09:58 -040044 unmapped: BTreeSet::new(),
Brian Silvermancc09f182022-03-09 15:40:20 -080045 }
46 }
47}
48
49impl<T: Ord> SelectList<T> {
50 // TODO: This should probably be added to the [Select] trait
51 pub fn insert(&mut self, value: T, configuration: Option<String>) {
52 match configuration {
53 None => {
Adam Snaider1c095c92023-07-08 02:09:58 -040054 self.selects.retain(|_, set| {
55 set.remove(&value);
56 !set.is_empty()
57 });
Brian Silvermancc09f182022-03-09 15:40:20 -080058 self.common.insert(value);
59 }
60 Some(cfg) => {
Adam Snaider1c095c92023-07-08 02:09:58 -040061 if !self.common.contains(&value) {
62 self.selects.entry(cfg).or_default().insert(value);
63 }
Brian Silvermancc09f182022-03-09 15:40:20 -080064 }
Adam Snaider1c095c92023-07-08 02:09:58 -040065 }
Brian Silvermancc09f182022-03-09 15:40:20 -080066 }
67
68 // TODO: This should probably be added to the [Select] trait
Adam Snaider1c095c92023-07-08 02:09:58 -040069 pub fn get_iter(&self, config: Option<&String>) -> Option<btree_set::Iter<T>> {
Brian Silvermancc09f182022-03-09 15:40:20 -080070 match config {
71 Some(conf) => self.selects.get(conf).map(|set| set.iter()),
72 None => Some(self.common.iter()),
73 }
74 }
75
76 /// Determine whether or not the select should be serialized
Adam Snaider1c095c92023-07-08 02:09:58 -040077 pub fn is_empty(&self) -> bool {
78 self.common.is_empty() && self.selects.is_empty() && self.unmapped.is_empty()
79 }
80
81 /// Maps configuration names by `f`. This function must be injective
82 /// (that is `a != b --> f(a) != f(b)`).
83 pub fn map_configuration_names<F>(self, mut f: F) -> Self
84 where
85 F: FnMut(String) -> String,
86 {
87 Self {
88 common: self.common,
89 selects: self.selects.into_iter().map(|(k, v)| (f(k), v)).collect(),
90 unmapped: self.unmapped,
91 }
92 }
93}
94
95#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
96pub struct WithOriginalConfigurations<T> {
97 value: T,
98 original_configurations: Option<BTreeSet<String>>,
99}
100
101impl<T: Ord + Clone> SelectList<T> {
102 /// Generates a new SelectList re-keyed by the given configuration mapping.
103 /// This mapping maps from configurations in the current SelectList to sets of
104 /// configurations in the new SelectList.
105 pub fn remap_configurations(
106 self,
107 mapping: &BTreeMap<String, BTreeSet<String>>,
108 ) -> SelectList<WithOriginalConfigurations<T>> {
109 // Map new configuration -> value -> old configurations.
110 let mut remapped: BTreeMap<String, BTreeMap<T, BTreeSet<String>>> = BTreeMap::new();
111 // Map value -> old configurations.
112 let mut unmapped: BTreeMap<T, BTreeSet<String>> = BTreeMap::new();
113
114 for (original_configuration, values) in self.selects {
115 match mapping.get(&original_configuration) {
116 Some(configurations) => {
117 for configuration in configurations {
118 for value in &values {
119 remapped
120 .entry(configuration.clone())
121 .or_default()
122 .entry(value.clone())
123 .or_default()
124 .insert(original_configuration.clone());
125 }
126 }
127 }
128 None => {
129 for value in values {
130 unmapped
131 .entry(value)
132 .or_default()
133 .insert(original_configuration.clone());
134 }
135 }
136 }
137 }
138
139 SelectList {
140 common: self
141 .common
142 .into_iter()
143 .map(|value| WithOriginalConfigurations {
144 value,
145 original_configurations: None,
146 })
147 .collect(),
148 selects: remapped
149 .into_iter()
150 .map(|(new_configuration, value_to_original_configuration)| {
151 (
152 new_configuration,
153 value_to_original_configuration
154 .into_iter()
155 .map(
156 |(value, original_configurations)| WithOriginalConfigurations {
157 value,
158 original_configurations: Some(original_configurations),
159 },
160 )
161 .collect(),
162 )
163 })
164 .collect(),
165 unmapped: unmapped
166 .into_iter()
167 .map(
168 |(value, original_configurations)| WithOriginalConfigurations {
169 value,
170 original_configurations: Some(original_configurations),
171 },
172 )
173 .collect(),
174 }
175 }
176}
177
178#[derive(Serialize)]
179#[serde(rename = "selects.NO_MATCHING_PLATFORM_TRIPLES")]
180struct NoMatchingPlatformTriples;
181
182// TODO: after removing the remaining tera template usages of SelectList, this
183// inherent method should become the Serialize impl.
184impl<T: Ord> SelectList<T> {
185 pub fn serialize_starlark<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
186 where
187 T: Serialize,
188 S: Serializer,
189 {
190 // Output looks like:
191 //
192 // [
193 // "common...",
194 // ] + select({
195 // "configuration": [
196 // "value...", # cfg(whatever)
197 // ],
198 // "//conditions:default": [],
199 // })
200 //
201 // The common part and select are each omitted if they are empty (except
202 // if the entire thing is empty, in which case we serialize the common
203 // part to get an empty array).
204 //
205 // If there are unmapped entries, we include them like this:
206 //
207 // [
208 // "common...",
209 // ] + selects.with_unmapped({
210 // "configuration": [
211 // "value...", # cfg(whatever)
212 // ],
213 // "//conditions:default": [],
214 // selects.NO_MATCHING_PLATFORM_TRIPLES: [
215 // "value...", # cfg(obscure)
216 // ],
217 // })
218
219 let mut plus = serializer.serialize_tuple_struct("+", MULTILINE)?;
220
221 if !self.common.is_empty() || self.selects.is_empty() && self.unmapped.is_empty() {
222 plus.serialize_field(&MultilineArray(&self.common))?;
223 }
224
225 if !self.selects.is_empty() || !self.unmapped.is_empty() {
226 struct SelectInner<'a, T: Ord>(&'a SelectList<T>);
227
228 impl<'a, T> Serialize for SelectInner<'a, T>
229 where
230 T: Ord + Serialize,
231 {
232 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
233 where
234 S: Serializer,
235 {
236 let mut map = serializer.serialize_map(Some(MULTILINE))?;
237 for (cfg, value) in &self.0.selects {
238 map.serialize_entry(cfg, &MultilineArray(value))?;
239 }
240 map.serialize_entry("//conditions:default", &[] as &[T])?;
241 if !self.0.unmapped.is_empty() {
242 map.serialize_entry(
243 &NoMatchingPlatformTriples,
244 &MultilineArray(&self.0.unmapped),
245 )?;
246 }
247 map.end()
248 }
249 }
250
251 let function = if self.unmapped.is_empty() {
252 "select"
253 } else {
254 "selects.with_unmapped"
255 };
256
257 plus.serialize_field(&FunctionCall::new(function, [SelectInner(self)]))?;
258 }
259
260 plus.end()
261 }
262}
263
264impl<T> Serialize for WithOriginalConfigurations<T>
265where
266 T: Serialize,
267{
268 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
269 where
270 S: Serializer,
271 {
272 if let Some(original_configurations) = &self.original_configurations {
273 let comment =
274 Vec::from_iter(original_configurations.iter().map(String::as_str)).join(", ");
275 LineComment::new(&self.value, &comment).serialize(serializer)
276 } else {
277 self.value.serialize(serializer)
278 }
Brian Silvermancc09f182022-03-09 15:40:20 -0800279 }
280}
281
282impl<T: Ord> Select<T> for SelectList<T> {
283 fn configurations(&self) -> BTreeSet<Option<&String>> {
284 let configs = self.selects.keys().map(Some);
285 match self.common.is_empty() {
286 true => configs.collect(),
287 false => configs.chain(once(None)).collect(),
288 }
289 }
290}
291
292impl<T: Ord, U: Ord> SelectMap<T, U> for SelectList<T> {
293 type Mapped = SelectList<U>;
294
295 fn map<F: Copy + Fn(T) -> U>(self, func: F) -> Self::Mapped {
Adam Snaider1c095c92023-07-08 02:09:58 -0400296 let common: BTreeSet<U> = self.common.into_iter().map(func).collect();
297 let selects: BTreeMap<String, BTreeSet<U>> = self
298 .selects
299 .into_iter()
300 .filter_map(|(key, set)| {
301 let set: BTreeSet<U> = set
302 .into_iter()
303 .map(func)
304 .filter(|value| !common.contains(value))
305 .collect();
306 if set.is_empty() {
307 None
308 } else {
309 Some((key, set))
310 }
311 })
312 .collect();
Brian Silvermancc09f182022-03-09 15:40:20 -0800313 SelectList {
Adam Snaider1c095c92023-07-08 02:09:58 -0400314 common,
315 selects,
316 unmapped: self.unmapped.into_iter().map(func).collect(),
Brian Silvermancc09f182022-03-09 15:40:20 -0800317 }
318 }
319}
320
321#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize, Clone)]
322pub struct SelectDict<T: Ord> {
Adam Snaider1c095c92023-07-08 02:09:58 -0400323 // Invariant: keys in this map are not in any of the inner maps of `selects`.
Brian Silvermancc09f182022-03-09 15:40:20 -0800324 common: BTreeMap<String, T>,
Adam Snaider1c095c92023-07-08 02:09:58 -0400325 // Invariant: none of the inner maps are empty.
Brian Silvermancc09f182022-03-09 15:40:20 -0800326 selects: BTreeMap<String, BTreeMap<String, T>>,
Adam Snaider1c095c92023-07-08 02:09:58 -0400327 // Elements that used to be in `selects` before the most recent
328 // `remap_configurations` operation, but whose old configuration did not get
329 // mapped to any new configuration. They could be ignored, but are preserved
330 // here to generate comments that help the user understand what happened.
331 #[serde(skip_serializing_if = "BTreeMap::is_empty", default = "BTreeMap::new")]
332 unmapped: BTreeMap<String, T>,
Brian Silvermancc09f182022-03-09 15:40:20 -0800333}
334
335impl<T: Ord> Default for SelectDict<T> {
336 fn default() -> Self {
337 Self {
338 common: BTreeMap::new(),
339 selects: BTreeMap::new(),
Adam Snaider1c095c92023-07-08 02:09:58 -0400340 unmapped: BTreeMap::new(),
Brian Silvermancc09f182022-03-09 15:40:20 -0800341 }
342 }
343}
344
345impl<T: Ord> SelectDict<T> {
Adam Snaider1c095c92023-07-08 02:09:58 -0400346 pub fn insert(&mut self, key: String, value: T, configuration: Option<String>) {
Brian Silvermancc09f182022-03-09 15:40:20 -0800347 match configuration {
348 None => {
Adam Snaider1c095c92023-07-08 02:09:58 -0400349 self.selects.retain(|_, map| {
350 map.remove(&key);
351 !map.is_empty()
352 });
353 self.common.insert(key, value);
Brian Silvermancc09f182022-03-09 15:40:20 -0800354 }
355 Some(cfg) => {
Adam Snaider1c095c92023-07-08 02:09:58 -0400356 if !self.common.contains_key(&key) {
357 self.selects.entry(cfg).or_default().insert(key, value);
358 }
Brian Silvermancc09f182022-03-09 15:40:20 -0800359 }
Adam Snaider1c095c92023-07-08 02:09:58 -0400360 }
Brian Silvermancc09f182022-03-09 15:40:20 -0800361 }
362
Adam Snaider1c095c92023-07-08 02:09:58 -0400363 pub fn extend(&mut self, entries: BTreeMap<String, T>, configuration: Option<String>) {
364 for (key, value) in entries {
365 self.insert(key, value, configuration.clone());
366 }
367 }
368
369 pub fn is_empty(&self) -> bool {
370 self.common.is_empty() && self.selects.is_empty() && self.unmapped.is_empty()
371 }
372}
373
374impl<T: Ord + Clone> SelectDict<T> {
375 /// Generates a new SelectDict re-keyed by the given configuration mapping.
376 /// This mapping maps from configurations in the current SelectDict to sets
377 /// of configurations in the new SelectDict.
378 pub fn remap_configurations(
379 self,
380 mapping: &BTreeMap<String, BTreeSet<String>>,
381 ) -> SelectDict<WithOriginalConfigurations<T>> {
382 // Map new configuration -> entry -> old configurations.
383 let mut remapped: BTreeMap<String, BTreeMap<(String, T), BTreeSet<String>>> =
384 BTreeMap::new();
385 // Map entry -> old configurations.
386 let mut unmapped: BTreeMap<(String, T), BTreeSet<String>> = BTreeMap::new();
387
388 for (original_configuration, entries) in self.selects {
389 match mapping.get(&original_configuration) {
390 Some(configurations) => {
391 for configuration in configurations {
392 for (key, value) in &entries {
393 remapped
394 .entry(configuration.clone())
395 .or_default()
396 .entry((key.clone(), value.clone()))
397 .or_default()
398 .insert(original_configuration.clone());
399 }
400 }
401 }
402 None => {
403 for (key, value) in entries {
404 unmapped
405 .entry((key, value))
406 .or_default()
407 .insert(original_configuration.clone());
408 }
409 }
410 }
411 }
412
413 SelectDict {
414 common: self
415 .common
416 .into_iter()
417 .map(|(key, value)| {
418 (
419 key,
420 WithOriginalConfigurations {
421 value,
422 original_configurations: None,
423 },
424 )
425 })
426 .collect(),
427 selects: remapped
428 .into_iter()
429 .map(|(new_configuration, entry_to_original_configuration)| {
430 (
431 new_configuration,
432 entry_to_original_configuration
433 .into_iter()
434 .map(|((key, value), original_configurations)| {
435 (
436 key,
437 WithOriginalConfigurations {
438 value,
439 original_configurations: Some(original_configurations),
440 },
441 )
442 })
443 .collect(),
444 )
445 })
446 .collect(),
447 unmapped: unmapped
448 .into_iter()
449 .map(|((key, value), original_configurations)| {
450 (
451 key,
452 WithOriginalConfigurations {
453 value,
454 original_configurations: Some(original_configurations),
455 },
456 )
457 })
458 .collect(),
459 }
460 }
461}
462
463// TODO: after removing the remaining tera template usages of SelectDict, this
464// inherent method should become the Serialize impl.
465impl<T: Ord + Serialize> SelectDict<T> {
466 pub fn serialize_starlark<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
467 where
468 S: Serializer,
469 {
470 // If there are no platform-specific entries, we output just an ordinary
471 // dict.
472 //
473 // If there are platform-specific ones, we use the following. Ideally it
474 // could be done as `dicts.add({...}, select({...}))` but bazel_skylib's
475 // dicts.add does not support selects.
476 //
477 // select({
478 // "configuration": {
479 // "common-key": "common-value",
480 // "plat-key": "plat-value", # cfg(whatever)
481 // },
482 // "//conditions:default": {},
483 // })
484 //
485 // If there are unmapped entries, we include them like this:
486 //
487 // selects.with_unmapped({
488 // "configuration": {
489 // "common-key": "common-value",
490 // "plat-key": "plat-value", # cfg(whatever)
491 // },
492 // "//conditions:default": [],
493 // selects.NO_MATCHING_PLATFORM_TRIPLES: {
494 // "unmapped-key": "unmapped-value", # cfg(obscure)
495 // },
496 // })
497
498 if self.selects.is_empty() && self.unmapped.is_empty() {
499 return self.common.serialize(serializer);
500 }
501
502 struct SelectInner<'a, T: Ord>(&'a SelectDict<T>);
503
504 impl<'a, T> Serialize for SelectInner<'a, T>
505 where
506 T: Ord + Serialize,
507 {
508 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
509 where
510 S: Serializer,
511 {
512 let mut map = serializer.serialize_map(Some(MULTILINE))?;
513 for (cfg, value) in &self.0.selects {
514 let mut combined = BTreeMap::new();
515 combined.extend(&self.0.common);
516 combined.extend(value);
517 map.serialize_entry(cfg, &combined)?;
518 }
519 map.serialize_entry("//conditions:default", &self.0.common)?;
520 if !self.0.unmapped.is_empty() {
521 map.serialize_entry(&NoMatchingPlatformTriples, &self.0.unmapped)?;
522 }
523 map.end()
524 }
525 }
526
527 let function = if self.unmapped.is_empty() {
528 "select"
529 } else {
530 "selects.with_unmapped"
531 };
532
533 FunctionCall::new(function, [SelectInner(self)]).serialize(serializer)
Brian Silvermancc09f182022-03-09 15:40:20 -0800534 }
535}
536
537impl<T: Ord> Select<T> for SelectDict<T> {
538 fn configurations(&self) -> BTreeSet<Option<&String>> {
539 let configs = self.selects.keys().map(Some);
540 match self.common.is_empty() {
541 true => configs.collect(),
542 false => configs.chain(once(None)).collect(),
543 }
544 }
545}
546
Adam Snaider1c095c92023-07-08 02:09:58 -0400547#[cfg(test)]
548mod test {
549 use super::*;
Brian Silvermancc09f182022-03-09 15:40:20 -0800550
Adam Snaider1c095c92023-07-08 02:09:58 -0400551 use indoc::indoc;
552
553 #[test]
554 fn remap_select_list_configurations() {
555 let mut select_list = SelectList::default();
556 select_list.insert("dep-a".to_owned(), Some("cfg(macos)".to_owned()));
557 select_list.insert("dep-b".to_owned(), Some("cfg(macos)".to_owned()));
558 select_list.insert("dep-d".to_owned(), Some("cfg(macos)".to_owned()));
559 select_list.insert("dep-a".to_owned(), Some("cfg(x86_64)".to_owned()));
560 select_list.insert("dep-c".to_owned(), Some("cfg(x86_64)".to_owned()));
561 select_list.insert("dep-e".to_owned(), Some("cfg(pdp11)".to_owned()));
562 select_list.insert("dep-d".to_owned(), None);
563
564 let mapping = BTreeMap::from([
565 (
566 "cfg(macos)".to_owned(),
567 BTreeSet::from(["x86_64-macos".to_owned(), "aarch64-macos".to_owned()]),
568 ),
569 (
570 "cfg(x86_64)".to_owned(),
571 BTreeSet::from(["x86_64-linux".to_owned(), "x86_64-macos".to_owned()]),
572 ),
573 ]);
574
575 let mut expected = SelectList::default();
576 expected.insert(
577 WithOriginalConfigurations {
578 value: "dep-a".to_owned(),
579 original_configurations: Some(BTreeSet::from([
580 "cfg(macos)".to_owned(),
581 "cfg(x86_64)".to_owned(),
582 ])),
583 },
584 Some("x86_64-macos".to_owned()),
585 );
586 expected.insert(
587 WithOriginalConfigurations {
588 value: "dep-b".to_owned(),
589 original_configurations: Some(BTreeSet::from(["cfg(macos)".to_owned()])),
590 },
591 Some("x86_64-macos".to_owned()),
592 );
593 expected.insert(
594 WithOriginalConfigurations {
595 value: "dep-c".to_owned(),
596 original_configurations: Some(BTreeSet::from(["cfg(x86_64)".to_owned()])),
597 },
598 Some("x86_64-macos".to_owned()),
599 );
600 expected.insert(
601 WithOriginalConfigurations {
602 value: "dep-a".to_owned(),
603 original_configurations: Some(BTreeSet::from(["cfg(macos)".to_owned()])),
604 },
605 Some("aarch64-macos".to_owned()),
606 );
607 expected.insert(
608 WithOriginalConfigurations {
609 value: "dep-b".to_owned(),
610 original_configurations: Some(BTreeSet::from(["cfg(macos)".to_owned()])),
611 },
612 Some("aarch64-macos".to_owned()),
613 );
614 expected.insert(
615 WithOriginalConfigurations {
616 value: "dep-a".to_owned(),
617 original_configurations: Some(BTreeSet::from(["cfg(x86_64)".to_owned()])),
618 },
619 Some("x86_64-linux".to_owned()),
620 );
621 expected.insert(
622 WithOriginalConfigurations {
623 value: "dep-c".to_owned(),
624 original_configurations: Some(BTreeSet::from(["cfg(x86_64)".to_owned()])),
625 },
626 Some("x86_64-linux".to_owned()),
627 );
628 expected.insert(
629 WithOriginalConfigurations {
630 value: "dep-d".to_owned(),
631 original_configurations: None,
632 },
633 None,
634 );
635
636 expected.unmapped.insert(WithOriginalConfigurations {
637 value: "dep-e".to_owned(),
638 original_configurations: Some(BTreeSet::from(["cfg(pdp11)".to_owned()])),
639 });
640
641 let select_list = select_list.remap_configurations(&mapping);
642 assert_eq!(select_list, expected);
643
644 let expected_starlark = indoc! {r#"
645 [
646 "dep-d",
647 ] + selects.with_unmapped({
648 "aarch64-macos": [
649 "dep-a", # cfg(macos)
650 "dep-b", # cfg(macos)
651 ],
652 "x86_64-linux": [
653 "dep-a", # cfg(x86_64)
654 "dep-c", # cfg(x86_64)
655 ],
656 "x86_64-macos": [
657 "dep-a", # cfg(macos), cfg(x86_64)
658 "dep-b", # cfg(macos)
659 "dep-c", # cfg(x86_64)
660 ],
661 "//conditions:default": [],
662 selects.NO_MATCHING_PLATFORM_TRIPLES: [
663 "dep-e", # cfg(pdp11)
664 ],
665 })
666 "#};
667
668 assert_eq!(
669 select_list
670 .serialize_starlark(serde_starlark::Serializer)
671 .unwrap(),
672 expected_starlark,
673 );
Brian Silvermancc09f182022-03-09 15:40:20 -0800674 }
675}