#![allow(
clippy::cast_possible_truncation,
clippy::cast_possible_wrap,
clippy::cast_sign_loss,
reason = "M175: save-path token expansion — piece counts bounded by realistic torrent size"
)]
use std::path::{Path, PathBuf};
use crate::category_manager::{CategoryError, CategoryRegistry};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SimpleContentType {
Audio,
Video,
Other,
}
impl SimpleContentType {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Audio => "Audio",
Self::Video => "Video",
Self::Other => "Other",
}
}
}
#[derive(Debug, Clone)]
pub struct TorrentSavePathContext {
pub primary_tracker_host: Option<String>,
pub added_at_utc_secs: i64,
pub content_type: SimpleContentType,
}
impl TorrentSavePathContext {
#[must_use]
pub fn new(added_at_utc_secs: i64) -> Self {
Self {
primary_tracker_host: None,
added_at_utc_secs,
content_type: SimpleContentType::Other,
}
}
#[must_use]
pub fn primary_tracker_host(&self) -> &str {
self.primary_tracker_host.as_deref().unwrap_or("unknown")
}
#[must_use]
pub fn classified_content_type(&self) -> &'static str {
self.content_type.as_str()
}
}
#[derive(Debug, thiserror::Error)]
pub enum ExpandSavePathError {
#[error("unknown save-path token: {{{token}}}")]
UnknownToken {
token: String,
},
#[error("unterminated save-path token starting at byte offset {offset}")]
UnterminatedToken {
offset: usize,
},
#[error("empty save-path token at byte offset {offset}")]
EmptyToken {
offset: usize,
},
#[error("category not found: {name}")]
CategoryNotFound {
name: String,
},
#[error("category lookup: {0}")]
Category(#[from] CategoryError),
}
pub fn expand_save_path_template(
template: &Path,
category_name: &str,
ctx: &TorrentSavePathContext,
) -> Result<PathBuf, ExpandSavePathError> {
let template_str = template.to_string_lossy();
let expanded = expand_str(&template_str, category_name, ctx)?;
Ok(PathBuf::from(expanded))
}
pub fn expand_save_path_for_category(
registry: &CategoryRegistry,
category_name: &str,
ctx: &TorrentSavePathContext,
) -> Result<PathBuf, ExpandSavePathError> {
let meta =
registry
.get(category_name)
.ok_or_else(|| ExpandSavePathError::CategoryNotFound {
name: category_name.to_owned(),
})?;
expand_save_path_template(&meta.save_path, category_name, ctx)
}
fn expand_str(
template: &str,
category_name: &str,
ctx: &TorrentSavePathContext,
) -> Result<String, ExpandSavePathError> {
let mut out = String::with_capacity(template.len());
let bytes = template.as_bytes();
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b == b'{' {
let start = i;
let close = template[i..]
.find('}')
.ok_or(ExpandSavePathError::UnterminatedToken { offset: start })?;
let close_abs = start + close;
let token = &template[start + 1..close_abs];
if token.is_empty() {
return Err(ExpandSavePathError::EmptyToken { offset: start });
}
let replacement = resolve_token(token, category_name, ctx)?;
out.push_str(&replacement);
i = close_abs + 1;
} else {
let ch = template[i..].chars().next().expect("non-empty slice");
out.push(ch);
i = i.saturating_add(ch.len_utf8());
}
}
Ok(out)
}
fn resolve_token(
token: &str,
category_name: &str,
ctx: &TorrentSavePathContext,
) -> Result<String, ExpandSavePathError> {
match token {
"category" => Ok(category_name.to_owned()),
"tracker" => Ok(ctx.primary_tracker_host().to_owned()),
"yyyy" => Ok(format_year(ctx.added_at_utc_secs)),
"mm" => Ok(format_month(ctx.added_at_utc_secs)),
"content_type" => Ok(ctx.classified_content_type().to_owned()),
other => Err(ExpandSavePathError::UnknownToken {
token: other.to_owned(),
}),
}
}
fn format_year(utc_secs: i64) -> String {
let (year, _, _) = ymd_from_utc_secs(utc_secs);
format!("{year:04}")
}
fn format_month(utc_secs: i64) -> String {
let (_, month, _) = ymd_from_utc_secs(utc_secs);
format!("{month:02}")
}
fn ymd_from_utc_secs(utc_secs: i64) -> (i64, u32, u32) {
let days_secs = 86_400_i64;
let days = utc_secs.div_euclid(days_secs);
let z = days.saturating_add(719_468);
let era = if z >= 0 { z } else { z.saturating_sub(146_096) } / 146_097;
let doe = (z - era.saturating_mul(146_097)) as u64; let yoe = (doe
.saturating_sub(doe / 1460)
.saturating_sub(doe / 36_524)
.saturating_add(doe / 146_096))
/ 365;
let y = (yoe as i64).saturating_add(era.saturating_mul(400));
let doy = doe
.saturating_sub(yoe.saturating_mul(365))
.saturating_sub(yoe / 4)
.saturating_add(yoe / 100);
let mp = (5 * doy + 2) / 153;
#[allow(clippy::cast_possible_truncation)]
let day = (doy.saturating_sub((153 * mp + 2) / 5).saturating_add(1)) as u32;
let month: u32 = if mp < 10 {
u32::try_from(mp + 3).unwrap_or(0)
} else {
u32::try_from(mp.saturating_sub(9)).unwrap_or(0)
};
let year = if month <= 2 { y.saturating_add(1) } else { y };
(year, month, day)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::category_manager::CategoryRegistry;
fn ctx(secs: i64) -> TorrentSavePathContext {
TorrentSavePathContext {
primary_tracker_host: Some("archlinux.org".into()),
added_at_utc_secs: secs,
content_type: SimpleContentType::Audio,
}
}
#[test]
fn ymd_from_utc_secs_known_dates() {
assert_eq!(ymd_from_utc_secs(0), (1970, 1, 1));
assert_eq!(ymd_from_utc_secs(1_713_789_296), (2024, 4, 22));
let secs = 1_745_280_000_i64;
assert_eq!(ymd_from_utc_secs(secs), (2025, 4, 22));
assert_eq!(ymd_from_utc_secs(951_825_600), (2000, 2, 29));
assert_eq!(ymd_from_utc_secs(-1), (1969, 12, 31));
}
#[test]
fn expand_template_no_tokens_passes_through_verbatim() {
let expanded = expand_save_path_template(
Path::new("/srv/downloads/static"),
"Linux",
&ctx(1_713_789_296),
)
.expect("plain literal must succeed");
assert_eq!(expanded, PathBuf::from("/srv/downloads/static"));
}
#[test]
fn expand_template_resolves_all_five_tokens() {
let expanded = expand_save_path_template(
Path::new("/srv/{category}/{tracker}/{yyyy}/{mm}/{content_type}"),
"Linux",
&ctx(1_713_789_296), )
.expect("five-token template must succeed");
assert_eq!(
expanded,
PathBuf::from("/srv/Linux/archlinux.org/2024/04/Audio")
);
}
#[test]
fn expand_template_year_and_month_zero_padded() {
let expanded =
expand_save_path_template(Path::new("{yyyy}/{mm}"), "Other", &ctx(631_152_000))
.expect("zero-padded month");
assert_eq!(expanded, PathBuf::from("1990/01"));
}
#[test]
fn expand_template_tracker_falls_back_to_unknown() {
let mut c = ctx(0);
c.primary_tracker_host = None;
let expanded = expand_save_path_template(Path::new("/srv/{tracker}"), "Linux", &c)
.expect("missing tracker → 'unknown'");
assert_eq!(expanded, PathBuf::from("/srv/unknown"));
}
#[test]
fn expand_template_repeated_tokens() {
let expanded = expand_save_path_template(
Path::new("/{category}/{category}/{yyyy}-{mm}"),
"Music",
&ctx(1_713_789_296),
)
.expect("repeated tokens compose");
assert_eq!(expanded, PathBuf::from("/Music/Music/2024-04"));
}
#[test]
fn unknown_token_returns_typed_error_never_silent_literal() {
let err = expand_save_path_template(Path::new("/srv/{nonsense}"), "Linux", &ctx(0))
.expect_err("unknown token must error");
match err {
ExpandSavePathError::UnknownToken { token } => {
assert_eq!(
token, "nonsense",
"the offending token name must round-trip verbatim"
);
}
other => panic!("expected UnknownToken, got {other:?}"),
}
}
#[test]
fn unterminated_token_returns_typed_error() {
let err = expand_save_path_template(Path::new("/srv/{category"), "Linux", &ctx(0))
.expect_err("unterminated token must error");
assert!(matches!(err, ExpandSavePathError::UnterminatedToken { .. }));
}
#[test]
fn empty_token_returns_typed_error() {
let err = expand_save_path_template(Path::new("/srv/{}"), "Linux", &ctx(0))
.expect_err("empty token must error");
assert!(matches!(err, ExpandSavePathError::EmptyToken { .. }));
}
#[test]
fn expand_for_category_unknown_name_returns_category_not_found() {
let dir = tempfile::tempdir().expect("temp");
let registry = CategoryRegistry::new(dir.path().join("categories.toml"));
let err = expand_save_path_for_category(®istry, "Ghost", &ctx(0))
.expect_err("absent category must error");
match err {
ExpandSavePathError::CategoryNotFound { name } => {
assert_eq!(name, "Ghost");
}
other => panic!("expected CategoryNotFound, got {other:?}"),
}
}
#[test]
fn expand_for_category_uses_registry_template() {
let dir = tempfile::tempdir().expect("temp");
let mut registry = CategoryRegistry::new(dir.path().join("categories.toml"));
registry
.create("Linux".into(), PathBuf::from("/srv/{category}/{yyyy}"))
.expect("create");
let expanded = expand_save_path_for_category(®istry, "Linux", &ctx(1_713_789_296))
.expect("registry-driven expansion");
assert_eq!(expanded, PathBuf::from("/srv/Linux/2024"));
}
#[test]
fn content_type_str_round_trip() {
assert_eq!(SimpleContentType::Audio.as_str(), "Audio");
assert_eq!(SimpleContentType::Video.as_str(), "Video");
assert_eq!(SimpleContentType::Other.as_str(), "Other");
}
#[test]
fn ctx_new_defaults_to_unknown_tracker_and_other_content() {
let c = TorrentSavePathContext::new(0);
assert_eq!(c.primary_tracker_host(), "unknown");
assert_eq!(c.classified_content_type(), "Other");
}
}