use ::std::cmp::max;
use ::std::cmp::min;
use ::std::io;
use ::base64::{encode_config, URL_SAFE_NO_PAD};
use ::log::debug;
use ::sha2::Digest;
use ::sha2::Sha256;
use super::NamesafeArgs;
pub fn namesafe(
mut args: NamesafeArgs,
mut line_supplier: impl FnMut() -> Option<io::Result<String>>,
mut out_line_handler: impl FnMut(&str),
) -> Result<(), String> {
if args.max_length < 8 {
debug!("maximum length too low ({}), setting to 8", args.max_length);
args.max_length = 8
}
let mut any_line = false;
while let Some(line_res) = line_supplier() {
let oldline = line_res.map_err(|err| format!("failed to read line, {}", err))?;
let newline = namesafe_line(&oldline, &args);
if args.single_line && any_line {
return Err("namesafe failed because it received more than one line, and --single was requested".to_owned());
};
out_line_handler(&newline);
any_line = true;
}
if args.allow_empty || any_line {
Ok(())
} else {
Err("namesafe failed because it did not receive any lines (use --allow-empty if this is okay)".to_owned())
}
}
pub fn namesafe_line(original: &str, args: &NamesafeArgs) -> String {
debug_assert!(args.max_length >= 8);
assert!(!args.keep_extension, "keeping extension not yet supported"); let mut count = 0;
let max_length = max(8, args.max_length as usize);
let mut is_prev_special = true;
let mut filtered = original
.chars()
.map(|c| if args.charset.is_allowed(c) { c } else { '_' })
.filter(|c| skip_subsequent_special(*c, &mut is_prev_special))
.inspect(|_| count += 1)
.collect::<String>();
let was_changed = original != filtered;
let was_too_long = count > max_length;
let do_hash = filtered.len() < 2 || args.hash_policy.should_hash(was_changed, was_too_long);
debug!(
"for line {original}: was_changed={was_changed}, was_too_long={was_too_long}, \
count={count}, max_length={max_length}, do_hash={do_hash}, hash_policy={0:?}",
args.hash_policy
);
while filtered.ends_with('_') || filtered.ends_with('-') {
filtered.pop();
}
if !do_hash {
return shorten(&filtered, count, max_length, args.keep_tail);
}
if !filtered.is_empty() {
filtered.push('_');
}
let hash_length = min(12, max_length / 2);
let hash = compute_hash(original, hash_length);
let text_len = args.max_length as usize - hash.len();
let mut shortened = shorten(&filtered, count, text_len, args.keep_tail);
shortened.push_str(&hash);
shortened
}
fn shorten(filtered: &str, actual_len: usize, goal_len: usize, keep_tail: bool) -> String {
if keep_tail {
filtered
.chars()
.skip(actual_len - goal_len)
.collect::<String>()
} else {
filtered.chars().take(goal_len).collect::<String>()
}
}
fn skip_subsequent_special(symbol: char, is_prev_special: &mut bool) -> bool {
let is_special = symbol == '_' || symbol == '-';
if is_special && *is_prev_special {
return false;
}
*is_prev_special = is_special;
true
}
fn compute_hash(text: &str, hash_length: usize) -> String {
let mut hasher = Sha256::new();
hasher.update(text.as_bytes());
let hash_out = hasher.finalize();
let encoded = encode_config(hash_out, URL_SAFE_NO_PAD);
encoded[..hash_length].to_ascii_lowercase()
}
#[cfg(test)]
mod tests {
use ::clap::Parser;
use crate::escape::HashPolicy;
use super::*;
#[test]
fn already_valid() {
let res = namesafe_line("Hello_world", &NamesafeArgs::default());
assert_eq!(res, "Hello_world");
}
#[test]
fn legal_filename_hash() {
let res = namesafe_line(
"Hello world",
&NamesafeArgs {
hash_policy: HashPolicy::Always,
..Default::default()
},
);
assert_eq!(res, "Hello_world_zoyiygcyaow6");
}
#[test]
fn long_illegal_filename() {
let res = namesafe_line(
" _ hello WORLD hello world 你好 你好 你好 hello world- !!! !@#$%^& bye 123",
&NamesafeArgs::default(),
);
assert_eq!(res, "hello_WORLD_hello_wozc4zyofxrnr1");
}
#[test]
fn subsequent_weird_symbols() {
let res = namesafe_line(
"_-_hello!@#$%^&world-_-make-this-name-really-really-long",
&NamesafeArgs {
hash_policy: HashPolicy::Never,
..Default::default()
},
);
assert_eq!(res, "hello_world-make-this-name-reall");
}
#[test]
fn long_legal_filename_tail() {
let res = namesafe_line(
"The King is dead. Long live the Queen!",
&NamesafeArgs {
max_length: 15,
keep_tail: true,
hash_policy: HashPolicy::Never,
..Default::default()
},
);
assert_eq!(res, "live_the_Queen");
}
#[test]
fn dashes_and_underscores() {
let res = namesafe_line(
" _-_ ",
&NamesafeArgs {
hash_policy: HashPolicy::Never,
..Default::default()
},
);
assert_eq!(res, "cavx4zqano9q");
}
#[test]
fn long_once_without_hash() {
let args = NamesafeArgs::parse_from(vec!["namesafe", "-1", "-x=n"]);
let res = namesafe_line("commits-for-review-unpushed-firstN", &args);
assert_eq!(res, "commits-for-review-unpushed-firs");
}
}