use super::picture_info::PictureInfo;
use super::track_info::{TagEntry, TrackInfo};
use crate::prelude::*;
const MAX_VALUE_WIDTH: usize = 46;
const MAX_VALUE_LINES: usize = 3;
pub struct InspectFactory {
style: bool,
}
impl InspectFactory {
pub fn new(style: bool) -> Self {
Self { style }
}
pub(crate) fn create(&self, dir: &Path) -> Result<String, Failure<InspectAction>> {
let (properties, tags) = self.create_split(dir)?;
Ok(format!("{properties}{}{tags}", self.divider()))
}
pub(crate) fn create_split(
&self,
dir: &Path,
) -> Result<(String, String), Failure<InspectAction>> {
let tracks = TrackInfo::read_dir(dir)?;
let properties = self.format_properties_table(&tracks);
let tags = self.format_all_tags(&tracks);
Ok((properties, tags))
}
pub(crate) fn format_properties_table(&self, tracks: &[TrackInfo]) -> String {
const L: bool = false;
const R: bool = true;
let has_flac_columns = tracks.iter().any(|t| t.bit_depth.is_some());
let has_mixed_types = tracks
.first()
.is_some_and(|first| tracks.iter().any(|t| t.file_type != first.file_type));
let mut headers: Vec<Vec<&str>> = vec![vec!["D"], vec!["T"]];
let mut align = vec![L, L];
if has_mixed_types {
headers.push(vec!["Type"]);
align.push(L);
}
let kbps = self.style_unit("kbps");
let khz = self.style_unit("kHz");
headers.extend([
vec!["Time"],
vec!["Size"],
vec!["Bit", "Rate", &kbps],
vec!["Sample", "Rate", &khz],
vec!["Channels"],
]);
align.extend([R, R, R, L, L]);
if has_flac_columns {
headers.push(vec!["Bit", "Depth"]);
align.push(L);
}
let headers = self.style_headers(headers);
let mut builder = TableBuilder::new()
.ansi(self.style)
.multi_line_headers(headers)
.right_align(align)
.newline_after_headers();
for track in tracks {
let mut row = vec![
self.style_key(track.disc.clone().unwrap_or_default()),
self.style_key(track.track.clone().unwrap_or_default()),
];
if has_mixed_types {
row.push(track.file_type.clone());
}
row.extend([
track.format_duration(),
self.format_size(track.file_size),
track.bit_rate.to_string(),
track.format_sample_rate(),
track.channels.clone(),
]);
if has_flac_columns {
row.push(track.bit_depth.map_or_else(String::new, |d| d.to_string()));
}
builder = builder.row(row);
}
builder.build()
}
pub(crate) fn format_all_tags(&self, tracks: &[TrackInfo]) -> String {
let mut output = String::new();
for (i, track) in tracks.iter().enumerate() {
if i > 0 {
output.push_str(&self.divider());
}
output.push_str(&track.format_tags(self));
}
output
}
#[expect(
clippy::integer_division,
reason = "intentional integer division for file size"
)]
fn format_size(&self, bytes: u64) -> String {
const KIB: u64 = 1024;
const MIB: u64 = 1024 * 1024;
if bytes >= MIB {
let mib = bytes / MIB;
let kib_remainder = (bytes % MIB) / KIB;
let decimal = kib_remainder * 10 / 1024;
format!("{mib}.{decimal} {}", self.style_unit("MiB"))
} else {
let kib = bytes / KIB;
if kib >= 100 {
format!("{kib} {}", self.style_unit("KiB"))
} else {
let decimal = (bytes % KIB) * 10 / KIB;
format!("{kib}.{decimal} {}", self.style_unit("KiB"))
}
}
}
pub(crate) fn divider(&self) -> String {
if !self.style {
return "\n---\n".to_owned();
}
"\n---\n".black().to_string()
}
fn style_headers(&self, headers: Vec<Vec<&str>>) -> Vec<Vec<String>> {
if !self.style {
return headers
.into_iter()
.map(|col| col.into_iter().map(str::to_owned).collect())
.collect();
}
headers
.into_iter()
.map(|col| col.into_iter().map(|a| self.style_info(a)).collect())
.collect()
}
fn style_key(&self, input: impl Into<String>) -> String {
if !self.style {
return input.into();
}
input.into().yellow().to_string()
}
fn style_path(&self, input: impl Into<String>) -> String {
if !self.style {
return input.into();
}
input.into().green().to_string()
}
fn style_unit(&self, input: impl Into<String>) -> String {
if !self.style {
return input.into();
}
input.into().white().dimmed().to_string()
}
fn style_info(&self, input: impl Into<String>) -> String {
if !self.style {
return input.into();
}
input.into().cyan().to_string()
}
}
impl TrackInfo {
#[expect(
clippy::integer_division,
reason = "intentional integer division for MM:SS"
)]
fn format_duration(&self) -> String {
let total_secs = self.duration.as_secs();
let minutes = total_secs / 60;
let seconds = total_secs % 60;
format!("{minutes:02}:{seconds:02}")
}
#[expect(
clippy::integer_division,
reason = "intentional integer division for kHz conversion"
)]
fn format_sample_rate(&self) -> String {
let khz = self.sample_rate / 1000;
let remainder = self.sample_rate % 1000;
if remainder == 0 {
khz.to_string()
} else {
let decimal = remainder / 100;
format!("{khz}.{decimal}")
}
}
fn format_tags(&self, factory: &InspectFactory) -> String {
let mut output = String::from("\n");
output.push_str(&factory.style_path(&self.sub_path));
output.push_str("\n\n");
if !self.tags.is_empty() {
output.push_str(&self.format_tags_table(factory));
}
if !self.pictures.is_empty() {
output.push('\n');
output.push_str(&self.format_pictures_table(factory));
}
output
}
fn format_tags_table(&self, factory: &InspectFactory) -> String {
let mut builder = TableBuilder::new()
.ansi(factory.style)
.max_column_width(1, MAX_VALUE_WIDTH)
.max_cell_lines(MAX_VALUE_LINES);
for entry in &self.tags {
builder = builder.row([
factory.style_key(entry.format_item()),
entry.value.clone(),
factory.style_info(entry.native.as_deref().unwrap_or("")),
]);
}
builder.build()
}
fn format_pictures_table(&self, factory: &InspectFactory) -> String {
let mut builder = TableBuilder::new().ansi(factory.style);
for pic in &self.pictures {
builder = builder.row(pic.as_row(factory));
}
builder.build()
}
}
impl TagEntry {
fn format_item(&self) -> String {
split_camel_case(&format!("{:?}", self.key))
}
}
impl PictureInfo {
fn as_row(&self, factory: &InspectFactory) -> [String; 4] {
[
factory.style_key(self.type_name.clone()),
factory.format_size(u64::try_from(self.size).unwrap_or(u64::MAX)),
self.mime.clone(),
factory.style_info(self.native.clone()),
]
}
}
fn split_camel_case(s: &str) -> String {
let mut result = String::with_capacity(s.len() + 4);
for (i, ch) in s.char_indices() {
if i > 0 && ch.is_uppercase() {
result.push(' ');
}
result.push(ch);
}
result
}