use crate::error::{Error, Result};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct ChunkName(String);
impl ChunkName {
const MAX_LEN: usize = 240;
pub fn new(raw: impl Into<String>) -> Result<Self> {
let raw = raw.into();
let invalid = |reason: &'static str| Error::InvalidName {
kind: "chunk name",
value: raw.clone(),
reason,
};
if raw.is_empty() {
return Err(invalid("must not be empty"));
}
if raw.len() > Self::MAX_LEN {
return Err(invalid("must be at most 240 bytes"));
}
if raw.contains(['\n', '\r', '\0']) {
return Err(invalid("must not contain newlines or NUL"));
}
Ok(Self(raw))
}
#[must_use]
pub fn from_path(path: &std::path::Path) -> Self {
let sanitised = path
.display()
.to_string()
.replace(['\n', '\r', '\0'], "\u{fffd}");
Self::new(elide(&sanitised)).unwrap_or_else(|_| Self(String::from("?")))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
#[must_use]
pub fn as_lua(&self) -> String {
format!("@{}", self.0)
}
}
fn elide(text: &str) -> String {
const MARK: &str = "…";
if text.len() <= ChunkName::MAX_LEN {
return text.to_owned();
}
let budget = ChunkName::MAX_LEN - MARK.len();
let start = text
.char_indices()
.map(|(index, _)| index)
.find(|&index| text.len() - index <= budget)
.unwrap_or(text.len());
format!("{MARK}{}", &text[start..])
}
impl core::fmt::Display for ChunkName {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str(&self.0)
}
}
#[cfg(test)]
mod tests {
#![expect(
clippy::unwrap_used,
reason = "tests unwrap known-valid fixtures; a panic is the intended failure signal"
)]
use super::ChunkName;
use std::path::Path;
#[test]
fn keeps_the_name_verbatim_and_marks_it_as_a_path_for_lua() {
let name = ChunkName::new("hooks/enforce.lua").unwrap();
assert_eq!(name.as_str(), "hooks/enforce.lua");
assert_eq!(name.as_lua(), "@hooks/enforce.lua");
}
#[test]
fn rejects_empty_names() {
assert!(ChunkName::new("").is_err());
}
#[test]
fn rejects_names_that_would_corrupt_a_traceback() {
for raw in ["a\nb", "a\rb", "a\0b"] {
assert!(ChunkName::new(raw).is_err(), "{raw:?} should be rejected");
}
}
#[test]
fn rejects_names_longer_than_the_limit() {
assert!(ChunkName::new("a".repeat(240)).is_ok());
assert!(ChunkName::new("a".repeat(241)).is_err());
}
#[test]
fn an_over_long_path_keeps_the_tail_that_identifies_it() {
let path = format!("/{}/plugins/hooks/enforce.lua", "deep".repeat(90));
let name = ChunkName::from_path(Path::new(&path));
assert!(name.as_str().len() <= 240, "{}", name.as_str().len());
assert!(
name.as_str().ends_with("/plugins/hooks/enforce.lua"),
"{}",
name.as_str()
);
assert!(name.as_str().starts_with('…'), "{}", name.as_str());
}
#[test]
fn an_over_long_path_is_never_cut_through_a_character() {
let path = format!("/{}/ünïcøde.lua", "påth".repeat(90));
let name = ChunkName::from_path(Path::new(&path));
assert!(name.as_str().len() <= 240);
assert!(name.as_str().ends_with("/ünïcøde.lua"), "{}", name.as_str());
}
#[test]
fn a_path_within_the_limit_is_left_exactly_as_it_is() {
let name = ChunkName::from_path(Path::new("/plugins/hooks/enforce.lua"));
assert_eq!(name.as_str(), "/plugins/hooks/enforce.lua");
}
#[test]
fn from_path_uses_the_path_text() {
let name = ChunkName::from_path(Path::new("plugins/airsstack/hooks/enforce.lua"));
assert_eq!(name.as_str(), "plugins/airsstack/hooks/enforce.lua");
}
#[test]
fn from_path_falls_back_rather_than_failing() {
let name = ChunkName::from_path(Path::new(""));
assert_eq!(name.as_str(), "?");
}
}