1use 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 #[arg(default_value = ".")]
23 path: PathBuf,
24 #[arg(long, short = 'o', value_name = "FILE")]
26 output: Option<PathBuf>,
27 #[arg(long)]
29 absolute: bool,
30 #[arg(long)]
32 json: bool,
33}
34
35pub 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
50pub 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 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
172pub 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 .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}