use std::path::Path;
use quick_xml::events::Event;
use quick_xml::reader::Reader;
use tracing::warn;
use unicode_normalization::UnicodeNormalization;
use crate::error::NzbError;
use crate::models::{Article, JobStatus, NzbFile, NzbJob, Priority};
pub fn parse_nzb(name: &str, data: &[u8]) -> Result<NzbJob, NzbError> {
let mut reader = Reader::from_reader(data);
reader.config_mut().trim_text(true);
let decoder = reader.decoder();
let mut files: Vec<NzbFile> = Vec::new();
let mut current_file: Option<FileBuilder> = None;
let mut current_groups: Vec<String> = Vec::new();
let mut current_segments: Vec<SegmentBuilder> = Vec::new();
let mut in_groups = false;
let mut in_segments = false;
let mut buf = Vec::new();
let mut meta_password: Option<String> = None;
let mut reading_password_meta = false;
loop {
match reader.read_event_into(&mut buf) {
Ok(Event::Start(ref e)) => match e.name().as_ref() {
b"file" => {
let mut subject = String::new();
let mut date = 0i64;
for attr in e.attributes().flatten() {
match attr.key.as_ref() {
b"subject" => {
subject = attr
.decode_and_unescape_value(decoder)
.map(|v| v.into_owned())
.unwrap_or_else(|_| {
String::from_utf8_lossy(&attr.value).into_owned()
});
}
b"date" => {
date = String::from_utf8_lossy(&attr.value).parse().unwrap_or(0);
}
_ => {}
}
}
current_file = Some(FileBuilder {
subject,
_date: date,
});
current_groups.clear();
current_segments.clear();
}
b"groups" => in_groups = true,
b"group" => {}
b"segments" => in_segments = true,
b"segment" => {
let mut number = 0u32;
let mut bytes = 0u64;
for attr in e.attributes().flatten() {
match attr.key.as_ref() {
b"number" => {
number = String::from_utf8_lossy(&attr.value).parse().unwrap_or(0);
}
b"bytes" => {
bytes = String::from_utf8_lossy(&attr.value).parse().unwrap_or(0);
}
_ => {}
}
}
current_segments.push(SegmentBuilder {
number,
bytes,
message_id: String::new(),
});
}
b"meta" => {
for attr in e.attributes().flatten() {
if attr.key.as_ref() == b"type" && attr.value.as_ref() == b"password" {
reading_password_meta = true;
}
}
}
_ => {}
},
Ok(Event::End(ref e)) => match e.name().as_ref() {
b"file" => {
if let Some(fb) = current_file.take() {
let filename = sanitize_filename(&extract_filename(&fb.subject));
let valid_count = current_segments.len();
current_segments
.retain(|s| s.number > 0 && s.bytes > 0 && !s.message_id.is_empty());
let dropped = valid_count - current_segments.len();
if dropped > 0 {
warn!(
filename = %filename,
dropped,
"Dropped {dropped} invalid segment(s) (zero bytes, zero number, or empty message-ID)"
);
}
let total_bytes: u64 = current_segments.iter().map(|s| s.bytes).sum();
let articles: Vec<Article> = current_segments
.drain(..)
.map(|s| Article {
message_id: s.message_id,
segment_number: s.number,
bytes: s.bytes,
downloaded: false,
data_begin: None,
data_size: None,
crc32: None,
tried_servers: Vec::new(),
tries: 0,
})
.collect();
let is_par2 = filename.to_lowercase().ends_with(".par2");
let (par2_setname, par2_vol, par2_blocks) = if is_par2 {
parse_par2_filename(&filename)
} else {
(None, None, None)
};
files.push(NzbFile {
id: uuid::Uuid::new_v4().to_string(),
filename,
bytes: total_bytes,
bytes_downloaded: 0,
is_par2,
par2_setname,
par2_vol,
par2_blocks,
assembled: false,
groups: current_groups.clone(),
articles,
});
}
}
b"groups" => in_groups = false,
b"segments" => in_segments = false,
_ => {}
},
Ok(Event::Text(ref t)) => {
let text = t.unescape().unwrap_or_default().into_owned();
if reading_password_meta {
meta_password = Some(text);
reading_password_meta = false;
} else if in_groups {
current_groups.push(text);
} else if in_segments && let Some(seg) = current_segments.last_mut() {
seg.message_id = text;
}
}
Ok(Event::Eof) => break,
Err(e) => return Err(NzbError::ParseError(format!("XML error: {e}"))),
_ => {}
}
buf.clear();
}
if files.is_empty() {
return Err(NzbError::InvalidNzb("No files found in NZB".into()));
}
let total_bytes: u64 = files.iter().map(|f| f.bytes).sum();
let article_count: usize = files.iter().map(|f| f.articles.len()).sum();
let file_count = files.len();
Ok(NzbJob {
id: uuid::Uuid::new_v4().to_string(),
name: name.nfc().collect(),
category: "Default".into(),
status: JobStatus::Queued,
priority: Priority::Normal,
total_bytes,
downloaded_bytes: 0,
file_count,
files_completed: 0,
article_count,
articles_downloaded: 0,
articles_failed: 0,
added_at: chrono::Utc::now(),
completed_at: None,
work_dir: std::path::PathBuf::new(), output_dir: std::path::PathBuf::new(),
password: meta_password,
error_message: None,
speed_bps: 0,
server_stats: Vec::new(),
files,
})
}
pub fn parse_nzb_file(path: &Path) -> Result<NzbJob, NzbError> {
let data = std::fs::read(path)?;
let name = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "Unknown".into());
parse_nzb(&name, &data)
}
struct FileBuilder {
subject: String,
_date: i64,
}
struct SegmentBuilder {
number: u32,
bytes: u64,
message_id: String,
}
fn sanitize_filename(name: &str) -> String {
let normalized: String = name.nfc().collect();
let mut out = String::with_capacity(normalized.len());
for ch in normalized.chars() {
match ch {
'<' | '>' | ':' | '"' | '|' | '?' | '*' => {}
c if c.is_control() => {}
'\\' => {}
_ => out.push(ch),
}
}
let trimmed = out.trim().trim_end_matches('.');
if trimmed.is_empty() {
return "unnamed".to_string();
}
trimmed.to_string()
}
fn extract_filename(subject: &str) -> String {
if let Some(start) = subject.find('"')
&& let Some(end) = subject[start + 1..].find('"')
{
return subject[start + 1..start + 1 + end].to_string();
}
if let Some(paren_pos) = subject.rfind('(') {
let before_paren = subject[..paren_pos].trim();
if let Some(last_space) = before_paren.rfind(' ') {
let candidate = &before_paren[last_space + 1..];
if candidate.contains('.') {
return candidate.to_string();
}
}
if before_paren.contains('.') {
return before_paren.to_string();
}
}
subject.to_string()
}
fn parse_par2_filename(filename: &str) -> (Option<String>, Option<u32>, Option<u32>) {
let lower = filename.to_lowercase();
if !lower.ends_with(".par2") {
return (None, None, None);
}
let without_ext = &filename[..filename.len() - 5];
if let Some(vol_pos) = without_ext.to_lowercase().rfind(".vol") {
let setname = without_ext[..vol_pos].to_string();
let vol_part = &without_ext[vol_pos + 4..];
if let Some(plus_pos) = vol_part.find('+') {
let vol: u32 = vol_part[..plus_pos].parse().unwrap_or(0);
let blocks: u32 = vol_part[plus_pos + 1..].parse().unwrap_or(0);
return (Some(setname), Some(vol), Some(blocks));
}
}
let setname = without_ext.to_string();
(Some(setname), None, None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_filename_quoted() {
let subject = r#"Some Poster "myfile.rar" (01/10)"#;
assert_eq!(extract_filename(subject), "myfile.rar");
}
#[test]
fn test_extract_filename_quoted_with_spaces() {
let subject = r#"Poster "My File Name.part01.rar" (01/10)"#;
assert_eq!(extract_filename(subject), "My File Name.part01.rar");
}
#[test]
fn test_extract_filename_before_parens() {
let subject = "Some.Movie.2024.720p.BluRay.x264-GROUP file.rar (01/50)";
assert_eq!(extract_filename(subject), "file.rar");
}
#[test]
fn test_extract_filename_dotted_name_before_parens() {
let subject = "movie.mkv (1/1)";
assert_eq!(extract_filename(subject), "movie.mkv");
}
#[test]
fn test_extract_filename_no_pattern() {
let subject = "just some text without pattern";
assert_eq!(extract_filename(subject), "just some text without pattern");
}
#[test]
fn test_extract_filename_obfuscated_hash() {
let subject = "a8f3c72d1e4b5689 (1/50)";
assert_eq!(extract_filename(subject), "a8f3c72d1e4b5689 (1/50)");
}
#[test]
fn test_parse_par2_with_volume() {
let (set, vol, blocks) = parse_par2_filename("MyDownload.vol00+01.par2");
assert_eq!(set, Some("MyDownload".into()));
assert_eq!(vol, Some(0));
assert_eq!(blocks, Some(1));
}
#[test]
fn test_parse_par2_with_large_volume() {
let (set, vol, blocks) = parse_par2_filename("data.vol15+16.par2");
assert_eq!(set, Some("data".into()));
assert_eq!(vol, Some(15));
assert_eq!(blocks, Some(16));
}
#[test]
fn test_parse_par2_index() {
let (set, vol, blocks) = parse_par2_filename("MyDownload.par2");
assert_eq!(set, Some("MyDownload".into()));
assert_eq!(vol, None);
assert_eq!(blocks, None);
}
#[test]
fn test_parse_par2_not_par2() {
let (set, vol, blocks) = parse_par2_filename("myfile.rar");
assert_eq!(set, None);
assert_eq!(vol, None);
assert_eq!(blocks, None);
}
#[test]
fn test_parse_par2_case_insensitive() {
let (set, _, _) = parse_par2_filename("MyFile.PAR2");
assert_eq!(set, Some("MyFile".into()));
let (set2, vol, blocks) = parse_par2_filename("MyFile.Vol00+01.PAR2");
assert_eq!(set2, Some("MyFile".into()));
assert_eq!(vol, Some(0));
assert_eq!(blocks, Some(1));
}
#[test]
fn test_parse_nzb_basic() {
let nzb_data = br#"<?xml version="1.0" encoding="UTF-8"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="test@example.com" date="1234567890" subject="test.rar (1/2)">
<groups><group>alt.binaries.test</group></groups>
<segments>
<segment number="1" bytes="768000">article1@example.com</segment>
<segment number="2" bytes="768000">article2@example.com</segment>
</segments>
</file>
</nzb>"#;
let job = parse_nzb("test", nzb_data).unwrap();
assert_eq!(job.name, "test");
assert_eq!(job.file_count, 1);
assert_eq!(job.article_count, 2);
assert_eq!(job.total_bytes, 1536000);
assert_eq!(job.files[0].articles[0].message_id, "article1@example.com");
}
#[test]
fn test_parse_nzb_multiple_files() {
let nzb_data = br#"<?xml version="1.0" encoding="UTF-8"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="p@x.com" date="100" subject="Some Post file1.rar (1/2)">
<groups><group>alt.test</group></groups>
<segments>
<segment number="1" bytes="500000">seg1@x</segment>
</segments>
</file>
<file poster="p@x.com" date="100" subject="Some Post file2.rar (2/2)">
<groups><group>alt.test</group></groups>
<segments>
<segment number="1" bytes="300000">seg2@x</segment>
</segments>
</file>
</nzb>"#;
let job = parse_nzb("multi", nzb_data).unwrap();
assert_eq!(job.file_count, 2);
assert_eq!(job.article_count, 2);
assert_eq!(job.total_bytes, 800000);
assert_eq!(job.files[0].filename, "file1.rar");
assert_eq!(job.files[1].filename, "file2.rar");
}
#[test]
fn test_parse_nzb_multiple_groups() {
let nzb_data = br#"<?xml version="1.0" encoding="UTF-8"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="p@x.com" date="100" subject="test.rar (1/1)">
<groups>
<group>alt.binaries.test</group>
<group>alt.binaries.misc</group>
</groups>
<segments>
<segment number="1" bytes="100000">seg@x</segment>
</segments>
</file>
</nzb>"#;
let job = parse_nzb("groups", nzb_data).unwrap();
assert_eq!(job.files[0].groups.len(), 2);
assert_eq!(job.files[0].groups[0], "alt.binaries.test");
assert_eq!(job.files[0].groups[1], "alt.binaries.misc");
}
#[test]
fn test_parse_nzb_with_password() {
let nzb_data = br#"<?xml version="1.0" encoding="UTF-8"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<head>
<meta type="password">secret123</meta>
</head>
<file poster="p@x.com" date="100" subject="test.rar (1/1)">
<groups><group>alt.test</group></groups>
<segments>
<segment number="1" bytes="100">seg@x</segment>
</segments>
</file>
</nzb>"#;
let job = parse_nzb("password", nzb_data).unwrap();
assert_eq!(job.password.as_deref(), Some("secret123"));
}
#[test]
fn test_parse_nzb_par2_detection() {
let nzb_data = br#"<?xml version="1.0" encoding="UTF-8"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="p@x.com" date="100" subject="Post data.par2 (1/1)">
<groups><group>alt.test</group></groups>
<segments>
<segment number="1" bytes="1000">s1@x</segment>
</segments>
</file>
<file poster="p@x.com" date="100" subject="Post data.vol00+01.par2 (1/1)">
<groups><group>alt.test</group></groups>
<segments>
<segment number="1" bytes="2000">s2@x</segment>
</segments>
</file>
<file poster="p@x.com" date="100" subject="Post data.rar (1/1)">
<groups><group>alt.test</group></groups>
<segments>
<segment number="1" bytes="3000">s3@x</segment>
</segments>
</file>
</nzb>"#;
let job = parse_nzb("par2", nzb_data).unwrap();
assert_eq!(job.file_count, 3);
assert!(job.files[0].is_par2);
assert_eq!(job.files[0].par2_setname.as_deref(), Some("data"));
assert!(job.files[0].par2_vol.is_none());
assert!(job.files[1].is_par2);
assert_eq!(job.files[1].par2_setname.as_deref(), Some("data"));
assert_eq!(job.files[1].par2_vol, Some(0));
assert_eq!(job.files[1].par2_blocks, Some(1));
assert!(!job.files[2].is_par2);
assert!(job.files[2].par2_setname.is_none());
}
#[test]
fn test_parse_nzb_empty_returns_error() {
let nzb_data = br#"<?xml version="1.0" encoding="UTF-8"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
</nzb>"#;
let result = parse_nzb("empty", nzb_data);
assert!(result.is_err());
match result {
Err(NzbError::InvalidNzb(msg)) => assert!(msg.contains("No files")),
_ => panic!("Expected InvalidNzb error"),
}
}
#[test]
fn test_parse_nzb_invalid_xml() {
let result = parse_nzb("bad", b"this is not xml at all <<<<");
assert!(result.is_err());
}
#[test]
fn test_parse_nzb_segment_ordering() {
let nzb_data = br#"<?xml version="1.0" encoding="UTF-8"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="p@x.com" date="100" subject="test.rar (1/1)">
<groups><group>alt.test</group></groups>
<segments>
<segment number="3" bytes="100">seg3@x</segment>
<segment number="1" bytes="100">seg1@x</segment>
<segment number="2" bytes="100">seg2@x</segment>
</segments>
</file>
</nzb>"#;
let job = parse_nzb("order", nzb_data).unwrap();
let articles = &job.files[0].articles;
assert_eq!(articles.len(), 3);
assert_eq!(articles[0].segment_number, 3);
assert_eq!(articles[1].segment_number, 1);
assert_eq!(articles[2].segment_number, 2);
}
#[test]
fn test_parse_nzb_article_initial_state() {
let nzb_data = br#"<?xml version="1.0" encoding="UTF-8"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="p@x.com" date="100" subject="test.rar (1/1)">
<groups><group>alt.test</group></groups>
<segments>
<segment number="1" bytes="50000">art@x</segment>
</segments>
</file>
</nzb>"#;
let job = parse_nzb("state", nzb_data).unwrap();
let article = &job.files[0].articles[0];
assert_eq!(article.message_id, "art@x");
assert_eq!(article.segment_number, 1);
assert_eq!(article.bytes, 50000);
assert!(!article.downloaded);
assert!(article.data_begin.is_none());
assert!(article.data_size.is_none());
assert!(article.crc32.is_none());
assert!(article.tried_servers.is_empty());
assert_eq!(article.tries, 0);
}
#[test]
fn test_parse_nzb_job_initial_state() {
let nzb_data = br#"<?xml version="1.0" encoding="UTF-8"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="p@x.com" date="100" subject="test.rar (1/1)">
<groups><group>alt.test</group></groups>
<segments>
<segment number="1" bytes="100">s@x</segment>
</segments>
</file>
</nzb>"#;
let job = parse_nzb("init", nzb_data).unwrap();
assert_eq!(job.status, JobStatus::Queued);
assert_eq!(job.priority, Priority::Normal);
assert_eq!(job.downloaded_bytes, 0);
assert_eq!(job.files_completed, 0);
assert_eq!(job.articles_downloaded, 0);
assert_eq!(job.articles_failed, 0);
assert!(job.completed_at.is_none());
assert!(job.password.is_none());
assert!(job.error_message.is_none());
assert_eq!(job.category, "Default");
}
#[test]
fn test_sanitize_removes_unsafe_chars() {
assert_eq!(sanitize_filename(r#"file<name>.rar"#), "filename.rar");
assert_eq!(sanitize_filename(r#""myfile.mkv""#), "myfile.mkv");
assert_eq!(sanitize_filename("file:name.rar"), "filename.rar");
assert_eq!(sanitize_filename("file|name?.rar"), "filename.rar");
}
#[test]
fn test_sanitize_preserves_normal_names() {
assert_eq!(sanitize_filename("movie.part01.rar"), "movie.part01.rar");
assert_eq!(
sanitize_filename("My.Movie.2024.1080p.WEB-DL.mkv"),
"My.Movie.2024.1080p.WEB-DL.mkv"
);
}
#[test]
fn test_sanitize_trims_whitespace_and_dots() {
assert_eq!(sanitize_filename(" file.rar "), "file.rar");
assert_eq!(sanitize_filename("file.rar..."), "file.rar");
}
#[test]
fn test_sanitize_empty_returns_unnamed() {
assert_eq!(sanitize_filename(""), "unnamed");
assert_eq!(sanitize_filename("<>:"), "unnamed");
}
#[test]
fn test_parse_nzb_unescapes_xml_entities_in_subject() {
let nzb_data = br#"<?xml version="1.0" encoding="UTF-8"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="p@x.com" date="100" subject="< Release.Name > - "Release.Name.part01.rar" yEnc (01/10)">
<groups><group>alt.test</group></groups>
<segments>
<segment number="1" bytes="100">seg@x</segment>
</segments>
</file>
</nzb>"#;
let job = parse_nzb("entity_test", nzb_data).unwrap();
assert_eq!(job.files[0].filename, "Release.Name.part01.rar");
}
#[test]
fn test_parse_nzb_unescapes_amp_entities() {
let nzb_data = br#"<?xml version="1.0" encoding="UTF-8"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="p@x.com" date="100" subject="His & Hers S01E01 "His.and.Hers.S01E01.mkv" (1/1)">
<groups><group>alt.test</group></groups>
<segments>
<segment number="1" bytes="100">seg@x</segment>
</segments>
</file>
</nzb>"#;
let job = parse_nzb("amp_test", nzb_data).unwrap();
assert_eq!(job.files[0].filename, "His.and.Hers.S01E01.mkv");
}
#[test]
fn test_sanitize_normalizes_nfd_to_nfc() {
let nfd = "caf\u{0065}\u{0301}.rar";
assert_eq!(sanitize_filename(nfd), "caf\u{00E9}.rar");
}
#[test]
fn test_parse_nzb_normalizes_job_name() {
let nzb_data = br#"<?xml version="1.0" encoding="UTF-8"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="p@x.com" date="100" subject="test.rar (1/1)">
<groups><group>alt.test</group></groups>
<segments>
<segment number="1" bytes="100">s@x</segment>
</segments>
</file>
</nzb>"#;
let job = parse_nzb("caf\u{0065}\u{0301}", nzb_data).unwrap();
assert_eq!(job.name, "caf\u{00E9}");
}
#[test]
fn test_extract_filename_with_angle_brackets() {
let subject = r#"< Mayday.S26E10 > - "Mayday.S26E10.part01.rar" yEnc (01/57)"#;
assert_eq!(extract_filename(subject), "Mayday.S26E10.part01.rar");
}
#[test]
fn test_parse_nzb_drops_zero_byte_segments() {
let nzb_data = br#"<?xml version="1.0" encoding="UTF-8"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="p@x.com" date="100" subject="test.rar (1/1)">
<groups><group>alt.test</group></groups>
<segments>
<segment number="1" bytes="500000">good@x</segment>
<segment number="2" bytes="0">zerobytes@x</segment>
<segment number="3" bytes="300000">also-good@x</segment>
</segments>
</file>
</nzb>"#;
let job = parse_nzb("zero_bytes", nzb_data).unwrap();
assert_eq!(job.files[0].articles.len(), 2, "Zero-byte segment should be dropped");
assert_eq!(job.files[0].articles[0].message_id, "good@x");
assert_eq!(job.files[0].articles[1].message_id, "also-good@x");
assert_eq!(job.total_bytes, 800000, "Total bytes should exclude zero-byte segment");
}
#[test]
fn test_parse_nzb_drops_zero_number_segments() {
let nzb_data = br#"<?xml version="1.0" encoding="UTF-8"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="p@x.com" date="100" subject="test.rar (1/1)">
<groups><group>alt.test</group></groups>
<segments>
<segment number="0" bytes="500000">bad-number@x</segment>
<segment number="1" bytes="500000">good@x</segment>
</segments>
</file>
</nzb>"#;
let job = parse_nzb("zero_num", nzb_data).unwrap();
assert_eq!(job.files[0].articles.len(), 1, "Zero-number segment should be dropped");
assert_eq!(job.files[0].articles[0].message_id, "good@x");
}
#[test]
fn test_parse_nzb_drops_empty_message_id_segments() {
let nzb_data = br#"<?xml version="1.0" encoding="UTF-8"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="p@x.com" date="100" subject="test.rar (1/1)">
<groups><group>alt.test</group></groups>
<segments>
<segment number="1" bytes="500000"></segment>
<segment number="2" bytes="500000">good@x</segment>
</segments>
</file>
</nzb>"#;
let job = parse_nzb("empty_msgid", nzb_data).unwrap();
assert_eq!(job.files[0].articles.len(), 1, "Empty message-ID segment should be dropped");
assert_eq!(job.files[0].articles[0].message_id, "good@x");
}
#[test]
fn test_parse_nzb_all_segments_invalid_creates_empty_file() {
let nzb_data = br#"<?xml version="1.0" encoding="UTF-8"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="p@x.com" date="100" subject="test.rar (1/1)">
<groups><group>alt.test</group></groups>
<segments>
<segment number="0" bytes="0"></segment>
<segment number="0" bytes="500000">bad@x</segment>
</segments>
</file>
</nzb>"#;
let job = parse_nzb("all_invalid", nzb_data).unwrap();
assert_eq!(job.files[0].articles.len(), 0);
assert_eq!(job.files[0].bytes, 0);
assert_eq!(job.total_bytes, 0);
assert_eq!(job.article_count, 0);
}
#[test]
fn test_parse_nzb_mixed_valid_invalid_segments_across_files() {
let nzb_data = br#"<?xml version="1.0" encoding="UTF-8"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="p@x.com" date="100" subject="file1.rar (1/2)">
<groups><group>alt.test</group></groups>
<segments>
<segment number="1" bytes="100">s1@x</segment>
<segment number="2" bytes="0">bad@x</segment>
</segments>
</file>
<file poster="p@x.com" date="100" subject="file2.rar (2/2)">
<groups><group>alt.test</group></groups>
<segments>
<segment number="1" bytes="200">s2@x</segment>
<segment number="2" bytes="300">s3@x</segment>
</segments>
</file>
</nzb>"#;
let job = parse_nzb("mixed", nzb_data).unwrap();
assert_eq!(job.file_count, 2);
assert_eq!(job.files[0].articles.len(), 1, "file1 should have 1 valid segment");
assert_eq!(job.files[0].bytes, 100);
assert_eq!(job.files[1].articles.len(), 2, "file2 should have 2 valid segments");
assert_eq!(job.files[1].bytes, 500);
assert_eq!(job.total_bytes, 600);
assert_eq!(job.article_count, 3);
}
#[test]
fn test_parse_nzb_negative_bytes_parsed_as_zero() {
let nzb_data = br#"<?xml version="1.0" encoding="UTF-8"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="p@x.com" date="100" subject="test.rar (1/1)">
<groups><group>alt.test</group></groups>
<segments>
<segment number="1" bytes="-1">neg@x</segment>
<segment number="2" bytes="500000">good@x</segment>
</segments>
</file>
</nzb>"#;
let job = parse_nzb("neg_bytes", nzb_data).unwrap();
assert_eq!(job.files[0].articles.len(), 1);
assert_eq!(job.files[0].articles[0].message_id, "good@x");
}
#[test]
fn test_parse_nzb_missing_bytes_attr_treated_as_zero() {
let nzb_data = br#"<?xml version="1.0" encoding="UTF-8"?>
<nzb xmlns="http://www.newzbin.com/DTD/2003/nzb">
<file poster="p@x.com" date="100" subject="test.rar (1/1)">
<groups><group>alt.test</group></groups>
<segments>
<segment number="1">no-bytes@x</segment>
<segment number="2" bytes="500000">good@x</segment>
</segments>
</file>
</nzb>"#;
let job = parse_nzb("no_bytes_attr", nzb_data).unwrap();
assert_eq!(job.files[0].articles.len(), 1);
assert_eq!(job.files[0].articles[0].message_id, "good@x");
}
}