use std::path::PathBuf; use crate::{ checksum::Checksum, model::{DownloadFile, PieceIndex, PieceState}, }; use super::model::{ControlFileError, ControlFileVersion, ControlMetadata}; /// Encodes normalized metadata into the simplified text control-file format. #[must_use] pub fn encode_control_metadata(metadata: &ControlMetadata) -> String { let mut lines = Vec::new(); lines.push(format!( "version={}.{}", metadata.version.major(), metadata.version.minor() )); lines.push(format!("files={}", metadata.files.len())); for file in &metadata.files { lines.push(format!( "file={}|{}|{}", file.path.display(), file.length, file.piece_length )); } lines.push(format!("checksums={}", metadata.checksums.len())); for checksum in &metadata.checksums { lines.push(format!("checksum={}", encode_checksum(checksum))); } lines.push(format!("completed_length={}", metadata.completed_length)); lines.push(format!("retry_count={}", metadata.retry_count)); if let Some(value) = &metadata.last_error { lines.push(format!("last_error={}", escape_control_field(value))); } if let Some(value) = metadata.last_error_at_unix_ms { lines.push(format!("last_error_at_unix_ms={value}")); } if let Some(value) = metadata.last_retry_at_unix_ms { lines.push(format!("last_retry_at_unix_ms={value}")); } if let Some(value) = metadata.next_retry_at_unix_ms { lines.push(format!("next_retry_at_unix_ms={value}")); } if let Some(value) = metadata.consecutive_failure_count { lines.push(format!("consecutive_failure_count={value}")); } if let Some(value) = metadata.active_segment_count { lines.push(format!("active_segment_count={value}")); } if let Some(value) = metadata.resume_verified_at_unix_ms { lines.push(format!("resume_verified_at_unix_ms={value}")); } if let Some(value) = metadata.resume_generation { lines.push(format!("resume_generation={value}")); } lines.push(format!("pieces={}", metadata.piece_states.len())); for (index, state) in &metadata.piece_states { lines.push(format!("piece={}|{}", index.0, encode_piece_state(*state))); } lines.join("\n") } /// Decodes the simplified control-file text format. /// /// # Errors /// /// Returns [`ControlFileError`] when the encoded text is malformed. #[expect( clippy::too_many_lines, reason = "text control metadata decoding keeps legacy and current field handling in one ordered parser" )] pub fn decode_control_metadata(content: &str) -> Result { let mut version = ControlFileVersion::CURRENT; let mut files = Vec::new(); let mut checksums = Vec::new(); let mut piece_states = Vec::new(); let mut completed_length = 0_u64; let mut retry_count = 0_u32; let mut last_error = None; let mut last_error_at_unix_ms = None; let mut last_retry_at_unix_ms = None; let mut next_retry_at_unix_ms = None; let mut consecutive_failure_count = None; let mut active_segment_count = None; let mut resume_verified_at_unix_ms = None; let mut resume_generation = None; for line in content.lines() { if let Some(raw) = line.strip_prefix("version=") { let mut parts = raw.split('.'); let major = parts .next() .ok_or_else(|| ControlFileError::Parse("missing version major".to_owned()))? .parse::() .map_err(|_| ControlFileError::Parse("invalid version major".to_owned()))?; let minor = parts .next() .ok_or_else(|| ControlFileError::Parse("missing version minor".to_owned()))? .parse::() .map_err(|_| ControlFileError::Parse("invalid version minor".to_owned()))?; version = ControlFileVersion::from_parts(major, minor); continue; } if let Some(raw) = line.strip_prefix("file=") { let mut parts = raw.split('|'); let path = parts .next() .ok_or_else(|| ControlFileError::Parse("missing file path".to_owned()))?; let length = parts .next() .ok_or_else(|| ControlFileError::Parse("missing file length".to_owned()))? .parse::() .map_err(|_| ControlFileError::Parse("invalid file length".to_owned()))?; let piece_length = parts .next() .ok_or_else(|| ControlFileError::Parse("missing file piece_length".to_owned()))? .parse::() .map_err(|_| ControlFileError::Parse("invalid file piece_length".to_owned()))?; files.push(DownloadFile { path: PathBuf::from(path), length, piece_length, }); continue; } if let Some(raw) = line.strip_prefix("checksum=") { checksums.push(decode_checksum(raw)?); continue; } if let Some(raw) = line.strip_prefix("completed_length=") { completed_length = raw .parse::() .map_err(|_| ControlFileError::Parse("invalid completed_length".to_owned()))?; continue; } if let Some(raw) = line.strip_prefix("retry_count=") { retry_count = raw .parse::() .map_err(|_| ControlFileError::Parse("invalid retry_count".to_owned()))?; continue; } if let Some(raw) = line.strip_prefix("last_error=") { last_error = Some(unescape_control_field(raw)); continue; } if let Some(raw) = line.strip_prefix("last_error_at_unix_ms=") { last_error_at_unix_ms = Some(raw.parse::().map_err(|_| { ControlFileError::Parse("invalid last_error_at_unix_ms".to_owned()) })?); continue; } if let Some(raw) = line.strip_prefix("last_retry_at_unix_ms=") { last_retry_at_unix_ms = Some(raw.parse::().map_err(|_| { ControlFileError::Parse("invalid last_retry_at_unix_ms".to_owned()) })?); continue; } if let Some(raw) = line.strip_prefix("next_retry_at_unix_ms=") { next_retry_at_unix_ms = Some(raw.parse::().map_err(|_| { ControlFileError::Parse("invalid next_retry_at_unix_ms".to_owned()) })?); continue; } if let Some(raw) = line.strip_prefix("consecutive_failure_count=") { consecutive_failure_count = Some(raw.parse::().map_err(|_| { ControlFileError::Parse("invalid consecutive_failure_count".to_owned()) })?); continue; } if let Some(raw) = line.strip_prefix("active_segment_count=") { active_segment_count = Some(raw.parse::().map_err(|_| { ControlFileError::Parse("invalid active_segment_count".to_owned()) })?); continue; } if let Some(raw) = line.strip_prefix("resume_verified_at_unix_ms=") { resume_verified_at_unix_ms = Some(raw.parse::().map_err(|_| { ControlFileError::Parse("invalid resume_verified_at_unix_ms".to_owned()) })?); continue; } if let Some(raw) = line.strip_prefix("resume_generation=") { resume_generation = Some(raw.parse::().map_err(|_| { ControlFileError::Parse("invalid resume_generation".to_owned()) })?); continue; } if let Some(raw) = line.strip_prefix("piece=") { let mut parts = raw.split('|'); let index = parts .next() .ok_or_else(|| ControlFileError::Parse("missing piece index".to_owned()))? .parse::() .map_err(|_| ControlFileError::Parse("invalid piece index".to_owned()))?; let state = parts .next() .ok_or_else(|| ControlFileError::Parse("missing piece state".to_owned())) .and_then(decode_piece_state)?; piece_states.push((PieceIndex(index), state)); } } Ok(ControlMetadata { version, files, checksums, piece_states, completed_length, retry_count, last_error, last_error_at_unix_ms, last_retry_at_unix_ms, next_retry_at_unix_ms, consecutive_failure_count, active_segment_count, resume_verified_at_unix_ms, resume_generation, }) } /// Encodes a piece state for the text control-file format. const fn encode_piece_state(state: PieceState) -> &'static str { match state { PieceState::Pending => "pending", PieceState::InFlight => "in-flight", PieceState::Verified => "verified", PieceState::Failed => "failed", } } /// Decodes a piece state from the text control-file format. fn decode_piece_state(raw: &str) -> Result { match raw { "pending" => Ok(PieceState::Pending), "in-flight" => Ok(PieceState::InFlight), "verified" => Ok(PieceState::Verified), "failed" => Ok(PieceState::Failed), _ => Err(ControlFileError::Parse("invalid piece state".to_owned())), } } /// Encodes a checksum into the text control-file format. fn encode_checksum(checksum: &Checksum) -> String { match checksum { Checksum::Sha1(value) => format!("sha1:{value}"), Checksum::Sha256(value) => format!("sha256:{value}"), Checksum::Md5(value) => format!("md5:{value}"), Checksum::Adler32(value) => format!("adler32:{value}"), Checksum::Crc32(value) => format!("crc32:{value}"), } } /// Decodes a checksum from the text control-file format. fn decode_checksum(raw: &str) -> Result { let mut parts = raw.splitn(2, ':'); let algorithm = parts .next() .ok_or_else(|| ControlFileError::Parse("missing checksum algorithm".to_owned()))?; let value = parts .next() .ok_or_else(|| ControlFileError::Parse("missing checksum value".to_owned()))? .to_owned(); match algorithm { "sha1" => Ok(Checksum::Sha1(value)), "sha256" => Ok(Checksum::Sha256(value)), "md5" => Ok(Checksum::Md5(value)), "adler32" => Ok(Checksum::Adler32(value)), "crc32" => Ok(Checksum::Crc32(value)), _ => Err(ControlFileError::Parse( "unsupported checksum algorithm".to_owned(), )), } } /// Escapes free-form text fields embedded in a control file. fn escape_control_field(raw: &str) -> String { raw.replace('\\', "\\\\") .replace('\t', "\\t") .replace('\n', "\\n") .replace('\r', "\\r") .replace(';', "\\s") .replace(',', "\\c") .replace('=', "\\e") } /// Reverses [`escape_control_field`] for text control-file fields. fn unescape_control_field(raw: &str) -> String { let mut out = String::new(); let mut chars = raw.chars(); while let Some(ch) = chars.next() { if ch == '\\' { match chars.next() { Some('t') => out.push('\t'), Some('n') => out.push('\n'), Some('r') => out.push('\r'), Some('s') => out.push(';'), Some('c') => out.push(','), Some('e') => out.push('='), Some('\\') | None => out.push('\\'), Some(other) => { out.push('\\'); out.push(other); } } } else { out.push(ch); } } out }