Skip to main content

xil/cmd/
mp3_hash.rs

1//! `xil mp3-hash` — recursive MP3 SHA-256 hash log. Port of `XILU007_mp3_hash.py`.
2
3use std::ffi::OsString;
4use std::fs::{self, File};
5use std::io::{Read, Write};
6use std::path::{Path, PathBuf};
7
8use clap::Parser;
9use serde_json::json;
10use sha2::{Digest, Sha256};
11use xil_core::fsutil::{abspath, relpath};
12use xil_core::pyjson::{dumps, Style};
13use xil_core::{banner, log};
14
15#[derive(Parser)]
16#[command(
17    name = "xil-mp3-hash",
18    about = "Recursively hash MP3 files and log <path> : <sha256>"
19)]
20struct Args {
21    /// File to hash or directory to scan recursively (default: current directory)
22    #[arg(default_value = ".")]
23    path: PathBuf,
24    /// Write results to FILE in addition to logging
25    #[arg(long, short = 'o', value_name = "FILE")]
26    output: Option<PathBuf>,
27    /// Print absolute paths (default: paths relative to scan root)
28    #[arg(long)]
29    absolute: bool,
30    /// Output a JSON array of {"path": ..., "sha256": ...} to stdout (no banner)
31    #[arg(long)]
32    json: bool,
33}
34
35/// Hex SHA-256 of a file, read in 64 KiB chunks.
36pub fn hash_file(path: &Path) -> std::io::Result<String> {
37    let mut f = File::open(path)?;
38    let mut h = Sha256::new();
39    let mut buf = [0u8; 65536];
40    loop {
41        let n = f.read(&mut buf)?;
42        if n == 0 {
43            break;
44        }
45        h.update(&buf[..n]);
46    }
47    Ok(format!("{:x}", h.finalize()))
48}
49
50/// `os.walk` order: this directory's files sorted, then each subdirectory in
51/// directory-listing order (not sorted — Python does not sort `dirnames`).
52pub fn scan_mp3s(root: &Path) -> std::io::Result<Vec<(PathBuf, String)>> {
53    let mut out = Vec::new();
54    walk(root, &mut out)?;
55    Ok(out)
56}
57
58fn walk(dir: &Path, out: &mut Vec<(PathBuf, String)>) -> std::io::Result<()> {
59    let mut files = Vec::new();
60    let mut dirs = Vec::new();
61    for e in fs::read_dir(dir)?.filter_map(Result::ok) {
62        let p = e.path();
63        if p.is_dir() {
64            dirs.push(p);
65        } else {
66            files.push(p);
67        }
68    }
69    files.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
70    for f in files {
71        let is_mp3 = f
72            .file_name()
73            .and_then(|n| n.to_str())
74            .map(|n| n.to_lowercase().ends_with(".mp3"))
75            .unwrap_or(false);
76        if is_mp3 {
77            let full = abspath(&f);
78            let digest = hash_file(&full)?;
79            out.push((full, digest));
80        }
81    }
82    for d in dirs {
83        walk(&d, out)?;
84    }
85    Ok(())
86}
87
88fn execute(a: &Args) -> anyhow::Result<()> {
89    let quiet = a.json;
90    let root = abspath(&a.path);
91
92    let records = if root.is_file() {
93        vec![(root.clone(), hash_file(&root)?)]
94    } else if root.is_dir() {
95        if !quiet {
96            log::info(&format!("Scanning {} for MP3 files…", root.display()));
97        }
98        scan_mp3s(&root)?
99    } else {
100        log::error(&format!("Not a file or directory: {}", root.display()));
101        return Ok(());
102    };
103
104    if records.is_empty() {
105        if !quiet {
106            log::info(&format!("No MP3 files found under {}", root.display()));
107        }
108        return Ok(());
109    }
110
111    let display: Vec<(String, &str)> = records
112        .iter()
113        .map(|(p, d)| {
114            let label = if a.absolute {
115                p.display().to_string()
116            } else {
117                relpath(p, &root).display().to_string()
118            };
119            (label, d.as_str())
120        })
121        .collect();
122
123    if a.json {
124        let arr: Vec<_> = display
125            .iter()
126            .map(|(p, d)| json!({"path": p, "sha256": d}))
127            .collect();
128        println!("{}", dumps(&serde_json::Value::Array(arr), Style::INDENT2));
129    } else {
130        for (label, digest) in &display {
131            log::info(&format!("{label} : {digest}"));
132        }
133    }
134
135    if let Some(out) = &a.output {
136        let mut f = File::create(out)?;
137        for (label, digest) in &display {
138            writeln!(f, "{label} : {digest}")?;
139        }
140        if !quiet {
141            log::info(&format!("Written: {}", out.display()));
142        }
143    }
144
145    if !quiet {
146        log::info(&format!(
147            "Hashed {} MP3 file(s) under {}",
148            records.len(),
149            root.display()
150        ));
151    }
152    Ok(())
153}
154
155pub fn run(args: &[OsString]) -> anyhow::Result<i32> {
156    // Python configures logging at import time, so the log file exists in
157    // every mode; only the banner is skipped for --json.
158    log::init("mp3-hash");
159    let a: Args = match super::parse_or_exit("xil-mp3-hash", args) {
160        Ok(a) => a,
161        Err(code) => return Ok(code),
162    };
163    if a.json {
164        execute(&a)?;
165    } else {
166        let _banner = banner::begin(super::prog(), &super::argv_line(args));
167        execute(&a)?;
168    }
169    Ok(0)
170}
171
172/// The clap definition behind `--help`, for man pages.
173pub fn command() -> clap::Command {
174    <Args as clap::CommandFactory>::command()
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn hash_matches_known_sha256() {
183        let tmp = tempfile::tempdir().unwrap();
184        let p = tmp.path().join("a.mp3");
185        fs::write(&p, b"abc").unwrap();
186        assert_eq!(
187            hash_file(&p).unwrap(),
188            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
189        );
190    }
191
192    #[test]
193    fn scan_finds_mp3s_case_insensitively_and_sorted_within_dir() {
194        let tmp = tempfile::tempdir().unwrap();
195        let r = tmp.path();
196        fs::create_dir_all(r.join("sub")).unwrap();
197        for f in ["b.mp3", "a.MP3", "c.wav", "sub/d.mp3"] {
198            fs::write(r.join(f), f).unwrap();
199        }
200        let names: Vec<String> = scan_mp3s(r)
201            .unwrap()
202            .iter()
203            // Windows joins with a backslash; the assertion is about order
204            // and filtering, not the separator.
205            .map(|(p, _)| relpath(p, r).display().to_string().replace('\\', "/"))
206            .collect();
207        assert_eq!(names, vec!["a.MP3", "b.mp3", "sub/d.mp3"]);
208    }
209}