Skip to content

Commit 2db445a

Browse files
Merge branch 'humanize-sns-configuration-v2-daniel-wong' into 'master'
feat: NNS1-1969: Humanize numbers in SNS configuration v2. See merge request dfinity-lab/public/ic!12576
2 parents b87dfd9 + 6827e51 commit 2db445a

File tree

19 files changed

+445
-54
lines changed

19 files changed

+445
-54
lines changed

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rs/nervous_system/humanize/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ DEPENDENCIES = [
77
"@crate_index//:humantime",
88
"@crate_index//:lazy_static",
99
"@crate_index//:regex",
10+
"@crate_index//:serde",
1011
]
1112

1213
DEV_DEPENDENCIES = DEPENDENCIES + [
14+
"@crate_index//:serde_yaml",
1315
]
1416

1517
rust_library(

rs/nervous_system/humanize/Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ edition = "2021"
55

66
[dependencies]
77
humantime = "2.1.0"
8+
ic-nervous-system-proto = { path = "../proto" }
89
lazy_static = "1.4.0"
910
regex = "1.3.0"
10-
ic-nervous-system-proto = { path = "../proto" }
11+
serde = "1.0.99"
12+
13+
[dev-dependencies]
14+
serde_yaml = "0.8.24"

rs/nervous_system/humanize/src/lib.rs

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,23 @@ use core::fmt::Display;
22
use ic_nervous_system_proto::pb::v1 as nervous_system_pb;
33
use lazy_static::lazy_static;
44
use regex::Regex;
5-
use std::str::FromStr;
5+
use std::{collections::VecDeque, str::FromStr};
6+
7+
pub mod serde;
68

79
#[cfg(test)]
810
mod tests;
911

12+
// Normally, we would import this from ic_nervous_system_common, but we'd be
13+
// dragging in lots of stuff along with it. The main problem with that is that
14+
// any random change that requires ic_nervous_system_common to be rebuilt will
15+
// also trigger a rebuild here. This gives us a "fire wall" to prevent fires
16+
// from spreading.
17+
//
18+
// TODO(NNS1-2284): Move E8, and other such things to their own tiny library to
19+
// avoid triggering mass rebuilds.
20+
const E8: u64 = 100_000_000;
21+
1022
/// Parses decimal strings ending in "tokens" (plural), decimal strings end in
1123
/// "token" (singular) , or integer strings (again, base 10) ending in "e8s". In
1224
/// the case of "tokens" strings, the maximum number of digits after the
@@ -16,6 +28,8 @@ mod tests;
1628
///
1729
/// Whitespace around number is insignificant. E.g. " 42 tokens" is equivalent
1830
/// to "42tokens".
31+
///
32+
/// Inverse of [`format_tokens`].
1933
pub fn parse_tokens(s: &str) -> Result<nervous_system_pb::Tokens, String> {
2034
let e8s = if let Some(s) = s.strip_suffix("tokens").map(|s| s.trim()) {
2135
parse_fixed_point_decimal(s, /* decimal_places = */ 8)?
@@ -39,6 +53,8 @@ pub fn parse_tokens(s: &str) -> Result<nervous_system_pb::Tokens, String> {
3953
/// 1 week + 2 days + 3 hours
4054
/// =
4155
/// (1 * (7 * 24 * 60 * 60) + 2 * 24 * 60 * 60 + 3 * (60 * 60)) seconds
56+
///
57+
/// Inverse of [`format_duration`].
4258
pub fn parse_duration(s: &str) -> Result<nervous_system_pb::Duration, String> {
4359
humantime::parse_duration(s)
4460
.map(|d| nervous_system_pb::Duration {
@@ -49,6 +65,8 @@ pub fn parse_duration(s: &str) -> Result<nervous_system_pb::Duration, String> {
4965

5066
/// Similar to parse_fixed_point_decimal(s, 2), except a trailing percent sign
5167
/// is REQUIRED (and not fed into parse_fixed_point_decimal).
68+
///
69+
/// Inverse of [`format_percentage`].
5270
pub fn parse_percentage(s: &str) -> Result<nervous_system_pb::Percentage, String> {
5371
let number = s
5472
.strip_suffix('%')
@@ -140,3 +158,91 @@ where
140158
n.checked_mul(boost)
141159
.ok_or_else(|| format!("Too large of a decimal shift: {} >> {}", n, count))
142160
}
161+
162+
/// The inverse of [`parse_tokens`].
163+
///
164+
/// One wrinkle: if e8s is None, then this is equivalent to e8s = Some(0). This
165+
/// follows the same logic as Protocol Buffers. If the caller wants None to be
166+
/// treated differently, they must do it themselves.
167+
pub fn format_tokens(tokens: &nervous_system_pb::Tokens) -> String {
168+
let nervous_system_pb::Tokens { e8s } = tokens;
169+
let e8s = e8s.unwrap_or(0);
170+
171+
if 0 < e8s && e8s < 1_000_000 {
172+
return format!("{} e8s", group_digits(e8s));
173+
}
174+
175+
// TODO: format_fixed_point_decimal. parse_fixed_point_decimal seems
176+
// lonesome. But seriously, it can also be used in format_percentage.
177+
178+
let whole = e8s / E8;
179+
let fractional = e8s % E8;
180+
181+
let fractional = if fractional == 0 {
182+
"".to_string()
183+
} else {
184+
// TODO: Group.
185+
format!(".{:08}", fractional).trim_matches('0').to_string()
186+
};
187+
188+
let units = if e8s == E8 { "token" } else { "tokens" };
189+
190+
format!("{}{} {}", group_digits(whole), fractional, units)
191+
}
192+
193+
/// The inverse of [`parse_duration`].
194+
///
195+
/// One wrinkle: if seconds is None, then this is equivalent to seconds =
196+
/// Some(0). This follows the same logic as Protocol Buffers. If the caller
197+
/// wants None to be treated differently, they must do it themselves.
198+
pub fn format_duration(duration: &nervous_system_pb::Duration) -> String {
199+
let nervous_system_pb::Duration { seconds } = duration;
200+
let seconds = seconds.unwrap_or(0);
201+
202+
humantime::format_duration(std::time::Duration::from_secs(seconds)).to_string()
203+
}
204+
205+
/// The inverse of [`parse_percentage`].
206+
///
207+
/// One wrinkle: if basis_points is None, then this is equivalent to
208+
/// basis_points = Some(0). This follows the same logic as Protocol Buffers. If
209+
/// the caller wants None to be treated differently, they must do it themselves.
210+
pub fn format_percentage(percentage: &nervous_system_pb::Percentage) -> String {
211+
let nervous_system_pb::Percentage { basis_points } = percentage;
212+
let basis_points = basis_points.unwrap_or(0);
213+
214+
let whole = basis_points / 100;
215+
let fractional = basis_points % 100;
216+
217+
let fractional = if fractional == 0 {
218+
"".to_string()
219+
} else {
220+
format!(".{:02}", fractional).trim_matches('0').to_string()
221+
};
222+
223+
format!("{}{}%", group_digits(whole), fractional)
224+
}
225+
226+
pub(crate) fn group_digits(n: u64) -> String {
227+
let mut left_todo = n;
228+
let mut groups = VecDeque::new();
229+
230+
while left_todo > 0 {
231+
let group = left_todo % 1000;
232+
left_todo /= 1000;
233+
234+
let group = if left_todo == 0 {
235+
format!("{}", group)
236+
} else {
237+
format!("{:03}", group)
238+
};
239+
240+
groups.push_front(group);
241+
}
242+
243+
if groups.is_empty() {
244+
return "0".to_string();
245+
}
246+
247+
Vec::from(groups).join("_")
248+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
//! This module exists to contain submodules that correspond to types that we
2+
//! know how to humanize. These submodules are suitable for use with
3+
//! #[serde(with = "module")].
4+
5+
pub mod duration;
6+
pub mod percentage;
7+
pub mod tokens;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
use crate::{format_duration, parse_duration};
2+
use ic_nervous_system_proto::pb::v1::Duration;
3+
use serde::{ser::Error, Deserialize, Deserializer, Serializer};
4+
5+
#[cfg(test)]
6+
mod duration_tests;
7+
8+
pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
9+
where
10+
D: Deserializer<'de>,
11+
{
12+
let string: String = Deserialize::deserialize(deserializer)?;
13+
parse_duration(&string).map_err(serde::de::Error::custom)
14+
}
15+
16+
pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
17+
where
18+
S: Serializer,
19+
{
20+
if duration.seconds.is_none() {
21+
return Err(S::Error::custom(
22+
"Unable to format Duration, because seconds is blank.",
23+
));
24+
}
25+
serializer.serialize_str(&format_duration(duration))
26+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
use super::*;
2+
3+
use serde::Serialize;
4+
5+
#[test]
6+
fn test_round_trip() {
7+
fn assert_survives_round_trip(
8+
original_duration_str: &str,
9+
expected_seconds: u64,
10+
expected_formatted_str: &str,
11+
) {
12+
#[derive(Debug, PartialEq, Eq, Deserialize, Serialize)]
13+
struct T {
14+
#[serde(with = "crate::serde::duration")]
15+
duration: Duration,
16+
}
17+
18+
let yaml = format!("duration: {}", original_duration_str);
19+
let t: T = serde_yaml::from_str(&yaml).unwrap();
20+
21+
assert_eq!(
22+
t,
23+
T {
24+
duration: Duration {
25+
seconds: Some(expected_seconds),
26+
}
27+
},
28+
"original_duration_str = {:?}",
29+
original_duration_str,
30+
);
31+
32+
assert_eq!(
33+
serde_yaml::to_string(&t).unwrap(),
34+
format!("---\nduration: {}\n", expected_formatted_str),
35+
"original_duration_str = {:?}",
36+
original_duration_str,
37+
);
38+
}
39+
40+
assert_survives_round_trip("1 hour", 3600, "1h");
41+
assert_survives_round_trip("1h 2m 3s", 3723, "1h 2m 3s");
42+
assert_survives_round_trip("2 weeks", 2 * 7 * 24 * 60 * 60, "14days");
43+
assert_survives_round_trip("8 years", (8 * 365 + 2) * 24 * 60 * 60, "8years");
44+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
use crate::{format_percentage, parse_percentage};
2+
use ic_nervous_system_proto::pb::v1::Percentage;
3+
use serde::{ser::Error, Deserialize, Deserializer, Serializer};
4+
5+
#[cfg(test)]
6+
mod percentage_tests;
7+
8+
pub fn deserialize<'de, D>(deserializer: D) -> Result<Percentage, D::Error>
9+
where
10+
D: Deserializer<'de>,
11+
{
12+
let string: String = Deserialize::deserialize(deserializer)?;
13+
parse_percentage(&string).map_err(serde::de::Error::custom)
14+
}
15+
16+
pub fn serialize<S>(percentage: &Percentage, serializer: S) -> Result<S::Ok, S::Error>
17+
where
18+
S: Serializer,
19+
{
20+
if percentage.basis_points.is_none() {
21+
return Err(S::Error::custom(
22+
"Unable to format Percentage, because basis_points is blank.",
23+
));
24+
}
25+
serializer.serialize_str(&format_percentage(percentage))
26+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
use super::*;
2+
3+
use serde::Serialize;
4+
5+
#[test]
6+
fn test_round_trip() {
7+
fn assert_survives_round_trip(
8+
original_percentage_str: &str,
9+
expected_basis_points: u64,
10+
expected_formatted_str: &str,
11+
) {
12+
#[derive(Debug, PartialEq, Eq, Deserialize, Serialize)]
13+
struct T {
14+
#[serde(with = "crate::serde::percentage")]
15+
homelessness_rate: Percentage,
16+
}
17+
18+
let yaml = format!("homelessness_rate: {}", original_percentage_str);
19+
let t: T = serde_yaml::from_str(&yaml).unwrap();
20+
21+
assert_eq!(
22+
t,
23+
T {
24+
homelessness_rate: Percentage {
25+
basis_points: Some(expected_basis_points),
26+
}
27+
},
28+
"original_percentage_str = {:?}",
29+
original_percentage_str,
30+
);
31+
32+
assert_eq!(
33+
serde_yaml::to_string(&t).unwrap(),
34+
format!("---\nhomelessness_rate: {}\n", expected_formatted_str),
35+
"original_percentage_str = {:?}",
36+
original_percentage_str,
37+
);
38+
}
39+
40+
assert_survives_round_trip("0%", 0, "0%");
41+
assert_survives_round_trip("0.1%", 10, "0.1%");
42+
assert_survives_round_trip("0.89%", 89, "0.89%");
43+
assert_survives_round_trip("1%", 100, "1%");
44+
assert_survives_round_trip("2.30%", 230, "2.3%");
45+
assert_survives_round_trip("57.68%", 57_68, "57.68%");
46+
assert_survives_round_trip("1_234.56%", 12_34_56, "1_234.56%");
47+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
use crate::{format_tokens, parse_tokens};
2+
use ic_nervous_system_proto::pb::v1::Tokens;
3+
use serde::{ser::Error, Deserialize, Deserializer, Serializer};
4+
5+
#[cfg(test)]
6+
mod tokens_tests;
7+
8+
pub fn deserialize<'de, D>(deserializer: D) -> Result<Tokens, D::Error>
9+
where
10+
D: Deserializer<'de>,
11+
{
12+
let string: String = Deserialize::deserialize(deserializer)?;
13+
parse_tokens(&string).map_err(serde::de::Error::custom)
14+
}
15+
16+
pub fn serialize<S>(tokens: &Tokens, serializer: S) -> Result<S::Ok, S::Error>
17+
where
18+
S: Serializer,
19+
{
20+
if tokens.e8s.is_none() {
21+
return Err(S::Error::custom(
22+
"Unable to format Tokens, because e8s is blank.",
23+
));
24+
}
25+
serializer.serialize_str(&format_tokens(tokens))
26+
}

0 commit comments

Comments
 (0)