use crate::error::{ErrorCode, MitosisError, Result};
use std::io::Read;
use std::time::Duration;
pub fn validate_name(kind: &str, name: &str) -> Result<()> {
let mut chars = name.chars();
let ok = name.len() <= 63
&& matches!(chars.next(), Some(c) if c.is_ascii_lowercase() || c.is_ascii_digit())
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-');
if ok {
return Ok(());
}
Err(MitosisError::new(
ErrorCode::InvalidRequest,
format!("invalid {kind} name {name:?}"),
)
.with_hint("use 1-63 characters: a-z, 0-9, '_' or '-', starting with a letter or digit"))
}
pub fn check_new_clone_name(name: &str) -> Result<()> {
if !is_clone_id(name) {
return Ok(());
}
Err(MitosisError::new(
ErrorCode::InvalidRequest,
format!("invalid clone name {name:?}: it looks like a clone id"),
)
.with_hint("clone ids are 'c' plus 6 hex digits; pick a name that is not, like my-clone"))
}
pub fn parse_duration(s: &str) -> Result<Duration> {
let bad = || {
MitosisError::new(ErrorCode::InvalidRequest, format!("invalid duration {s:?}"))
.with_hint("use a positive whole number plus a unit, like 90s, 30m, 2h or 1d")
};
let unit = s.chars().last().ok_or_else(bad)?;
let mult: u64 = match unit {
's' => 1,
'm' => 60,
'h' => 3600,
'd' => 86_400,
_ => return Err(bad()),
};
let digits = &s[..s.len() - 1];
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return Err(bad());
}
let n: u64 = digits.parse().map_err(|_| bad())?;
if n == 0 {
return Err(bad());
}
n.checked_mul(mult).map(Duration::from_secs).ok_or_else(bad)
}
pub fn parse_wait(s: &str) -> Result<Duration> {
if s == "0" || s == "0s" {
Ok(Duration::ZERO)
} else {
parse_duration(s)
}
}
pub fn format_duration(d: Duration) -> String {
let mut secs = d.as_secs();
if secs == 0 {
return "0s".into();
}
let mut out = String::new();
for (unit, size) in [('d', 86_400), ('h', 3600), ('m', 60), ('s', 1)] {
if secs >= size {
out.push_str(&format!("{}{unit}", secs / size));
secs %= size;
}
}
out
}
pub fn random_hex(bytes: usize) -> String {
let mut buf = vec![0u8; bytes];
std::fs::File::open("/dev/urandom")
.and_then(|mut f| f.read_exact(&mut buf))
.expect("read /dev/urandom");
buf.iter().map(|b| format!("{b:02x}")).collect()
}
pub(crate) fn new_clone_id() -> String {
format!("c{}", random_hex(3))
}
pub(crate) fn is_clone_id(s: &str) -> bool {
s.len() == 7
&& s.starts_with('c')
&& s[1..]
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn names() {
assert!(validate_name("template", "main").is_ok());
assert!(validate_name("clone", "feature-x_2").is_ok());
assert!(validate_name("clone", "cafe123").is_ok());
assert!(check_new_clone_name("cafe123").is_err());
assert!(check_new_clone_name("c0ffee1").is_err());
assert!(validate_name("template", "cafe123").is_ok());
assert!(check_new_clone_name("cafe1234").is_ok());
assert!(check_new_clone_name("cafe12g").is_ok());
assert!(validate_name("clone", &"a".repeat(63)).is_ok());
for bad in ["", "../x", "a/b", "Main", "-x", &"a".repeat(64)] {
assert_eq!(
validate_name("template", bad).unwrap_err().code,
ErrorCode::InvalidRequest
);
}
}
#[test]
fn durations() {
assert_eq!(parse_duration("2h").unwrap(), Duration::from_secs(7200));
assert_eq!(parse_duration("90s").unwrap(), Duration::from_secs(90));
assert_eq!(parse_duration("1d").unwrap(), Duration::from_secs(86_400));
for bad in ["", "0m", "abc", "5", "-1h", "+1h", "1.5h"] {
assert!(parse_duration(bad).is_err(), "{bad:?}");
}
assert_eq!(format_duration(Duration::from_secs(7200)), "2h");
assert_eq!(format_duration(Duration::from_secs(90)), "1m30s");
assert_eq!(format_duration(Duration::from_secs(0)), "0s");
}
#[test]
fn hex() {
assert_eq!(random_hex(12).len(), 24);
}
#[test]
fn clone_ids() {
for _ in 0..20 {
assert!(is_clone_id(&new_clone_id()));
}
for bad in [
"c12345", "c1234567", "cABCDEF", "x123456", "c12345g", "zzz", "build-1",
] {
assert!(!is_clone_id(bad), "{bad}");
}
}
}