1use crate::error::{CrateCheckerError, Result};
4use crate::types::BatchInput;
5use serde_json::Value;
6use std::fs;
7use std::path::Path;
8use tracing::{debug, error, info};
9
10pub fn parse_json_input(json: &str) -> Result<BatchInput> {
12 debug!("Parsing JSON input: {}", json);
13
14 let value: Value = serde_json::from_str(json).map_err(|e| {
16 error!("Failed to parse JSON: {}", e);
17 CrateCheckerError::InvalidBatchInput(format!("Invalid JSON: {}", e))
18 })?;
19
20 match serde_json::from_value::<BatchInput>(value.clone()) {
22 Ok(batch_input) => {
23 info!("Successfully parsed batch input");
24 Ok(batch_input)
25 }
26 Err(e) => {
27 error!("Failed to deserialize batch input: {}", e);
28 let error_msg = match &value {
30 Value::Object(obj) => {
31 if obj.contains_key("operations") {
32 "Invalid operations format. Expected array of operation objects."
33 } else if obj.contains_key("crates") {
34 "Invalid crates list format. Expected array of strings."
35 } else if obj
36 .keys()
37 .all(|k| !["operations", "crates"].contains(&k.as_str()))
38 {
39 "Looks like a crate-version map, but some values may be invalid."
40 } else {
41 "Unknown JSON structure for batch input."
42 }
43 }
44 _ => "Expected JSON object for batch input.",
45 };
46
47 Err(CrateCheckerError::InvalidBatchInput(format!(
48 "{} Original error: {}",
49 error_msg, e
50 )))
51 }
52 }
53}
54
55pub fn parse_json_file<P: AsRef<Path>>(path: P) -> Result<BatchInput> {
57 let path = path.as_ref();
58 info!("Reading JSON file: {}", path.display());
59
60 let content = fs::read_to_string(path).map_err(|e| {
61 error!("Failed to read file {}: {}", path.display(), e);
62 CrateCheckerError::IoError(e)
63 })?;
64
65 parse_json_input(&content)
66}
67
68pub fn validate_batch_input(input: &BatchInput) -> Result<()> {
70 match input {
71 BatchInput::CrateVersionMap(map) => {
72 if map.is_empty() {
73 return Err(CrateCheckerError::ValidationError(
74 "Crate version map cannot be empty".to_string(),
75 ));
76 }
77
78 for (crate_name, version) in map {
79 if crate_name.is_empty() {
80 return Err(CrateCheckerError::ValidationError(
81 "Crate name cannot be empty".to_string(),
82 ));
83 }
84 if version.is_empty() {
85 return Err(CrateCheckerError::ValidationError(format!(
86 "Version for crate '{}' cannot be empty",
87 crate_name
88 )));
89 }
90 }
91 }
92 BatchInput::CrateList { crates } => {
93 if crates.is_empty() {
94 return Err(CrateCheckerError::ValidationError(
95 "Crates list cannot be empty".to_string(),
96 ));
97 }
98
99 for crate_name in crates {
100 if crate_name.is_empty() {
101 return Err(CrateCheckerError::ValidationError(
102 "Crate name cannot be empty".to_string(),
103 ));
104 }
105 }
106 }
107 BatchInput::Operations { operations } => {
108 if operations.is_empty() {
109 return Err(CrateCheckerError::ValidationError(
110 "Operations list cannot be empty".to_string(),
111 ));
112 }
113
114 for operation in operations {
115 if operation.operation.is_empty() {
116 return Err(CrateCheckerError::ValidationError(
117 "Operation type cannot be empty".to_string(),
118 ));
119 }
120 }
121 }
122 }
123
124 Ok(())
125}
126
127pub fn format_duration(duration: std::time::Duration) -> String {
129 let total_secs = duration.as_secs();
130 let millis = duration.subsec_millis();
131
132 if total_secs == 0 {
133 format!("{}ms", millis)
134 } else if total_secs < 60 {
135 format!("{}.{}s", total_secs, millis / 100)
136 } else {
137 let mins = total_secs / 60;
138 let secs = total_secs % 60;
139 format!("{}m {}s", mins, secs)
140 }
141}
142
143pub fn format_file_size(bytes: u64) -> String {
145 const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"];
146 const THRESHOLD: f64 = 1024.0;
147
148 if bytes == 0 {
149 return "0 B".to_string();
150 }
151
152 let mut size = bytes as f64;
153 let mut unit_index = 0;
154
155 while size >= THRESHOLD && unit_index < UNITS.len() - 1 {
156 size /= THRESHOLD;
157 unit_index += 1;
158 }
159
160 if unit_index == 0 {
161 format!("{} {}", bytes, UNITS[unit_index])
162 } else {
163 format!("{:.1} {}", size, UNITS[unit_index])
164 }
165}
166
167pub fn format_download_count(count: u64) -> String {
169 if count < 1_000 {
170 count.to_string()
171 } else if count < 1_000_000 {
172 format!("{:.1}K", count as f64 / 1_000.0)
173 } else if count < 1_000_000_000 {
174 format!("{:.1}M", count as f64 / 1_000_000.0)
175 } else {
176 format!("{:.1}B", count as f64 / 1_000_000_000.0)
177 }
178}
179
180pub fn sanitize_crate_name(name: &str) -> String {
182 name.chars()
183 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
184 .collect()
185}
186
187pub fn is_semver_like(version: &str) -> bool {
189 let parts: Vec<&str> = version.split(&['.', '-', '+'][..]).collect();
191 parts.len() >= 3 && parts.iter().take(3).all(|part| part.parse::<u32>().is_ok())
192}
193
194pub fn extract_version_core(version: &str) -> Option<String> {
196 let parts: Vec<&str> = version.split(&['-', '+'][..]).next()?.split('.').collect();
197 if parts.len() >= 3 {
198 Some(format!("{}.{}.{}", parts[0], parts[1], parts[2]))
199 } else {
200 None
201 }
202}
203
204pub fn create_example_batch_inputs() -> Vec<(&'static str, &'static str)> {
206 vec![
207 (
208 "Crate version map",
209 r#"{"serde": "1.0.0", "tokio": "1.28.0", "reqwest": "latest"}"#,
210 ),
211 (
212 "Crates list",
213 r#"{"crates": ["serde", "tokio", "reqwest", "clap"]}"#,
214 ),
215 (
216 "Advanced operations",
217 r#"{
218 "operations": [
219 {"crate": "serde", "version": "1.0.0", "operation": "check_version"},
220 {"crate": "tokio", "operation": "info"},
221 {"crates": ["tokio", "reqwest"], "operation": "batch_check"}
222 ]
223}"#,
224 ),
225 ]
226}
227
228pub fn truncate_text(text: &str, max_length: usize) -> String {
230 if text.len() <= max_length {
231 text.to_string()
232 } else {
233 format!("{}...", &text[..max_length.saturating_sub(3)])
234 }
235}
236
237pub fn progress_indicator(current: usize, total: usize, width: usize) -> String {
239 if total == 0 {
240 return "".to_string();
241 }
242
243 let progress = (current as f64 / total as f64 * width as f64) as usize;
244 let filled = "=".repeat(progress.min(width));
245 let empty = " ".repeat(width.saturating_sub(progress));
246
247 format!("[{}{}] {}/{}", filled, empty, current, total)
248}
249
250pub fn parse_timeout(input: &str) -> Result<std::time::Duration> {
252 let input = input.trim().to_lowercase();
253
254 if let Ok(secs) = input.parse::<u64>() {
255 return Ok(std::time::Duration::from_secs(secs));
256 }
257
258 if input.ends_with('s') {
259 let num_str = &input[..input.len() - 1];
260 if let Ok(secs) = num_str.parse::<u64>() {
261 return Ok(std::time::Duration::from_secs(secs));
262 }
263 } else if input.ends_with('m') {
264 let num_str = &input[..input.len() - 1];
265 if let Ok(mins) = num_str.parse::<u64>() {
266 return Ok(std::time::Duration::from_secs(mins * 60));
267 }
268 } else if input.ends_with('h') {
269 let num_str = &input[..input.len() - 1];
270 if let Ok(hours) = num_str.parse::<u64>() {
271 return Ok(std::time::Duration::from_secs(hours * 3600));
272 }
273 }
274
275 Err(CrateCheckerError::ValidationError(format!(
276 "Invalid timeout format: '{}'. Use formats like '30s', '5m', '1h'",
277 input
278 )))
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn test_parse_json_input_crate_version_map() {
287 let json = r#"{"serde": "1.0.0", "tokio": "latest"}"#;
288 let result = parse_json_input(json).unwrap();
289
290 match result {
291 BatchInput::CrateVersionMap(map) => {
292 assert_eq!(map.len(), 2);
293 assert_eq!(map.get("serde"), Some(&"1.0.0".to_string()));
294 assert_eq!(map.get("tokio"), Some(&"latest".to_string()));
295 }
296 _ => panic!("Expected CrateVersionMap"),
297 }
298 }
299
300 #[test]
301 fn test_parse_json_input_crates_list() {
302 let json = r#"{"crates": ["serde", "tokio"]}"#;
303 let result = parse_json_input(json).unwrap();
304
305 match result {
306 BatchInput::CrateList { crates } => {
307 assert_eq!(crates, vec!["serde", "tokio"]);
308 }
309 _ => panic!("Expected CrateList"),
310 }
311 }
312
313 #[test]
314 fn test_format_file_size() {
315 assert_eq!(format_file_size(0), "0 B");
316 assert_eq!(format_file_size(512), "512 B");
317 assert_eq!(format_file_size(1024), "1.0 KB");
318 assert_eq!(format_file_size(1536), "1.5 KB");
319 assert_eq!(format_file_size(1048576), "1.0 MB");
320 }
321
322 #[test]
323 fn test_format_download_count() {
324 assert_eq!(format_download_count(500), "500");
325 assert_eq!(format_download_count(1500), "1.5K");
326 assert_eq!(format_download_count(1500000), "1.5M");
327 assert_eq!(format_download_count(2500000000), "2.5B");
328 }
329
330 #[test]
331 fn test_is_semver_like() {
332 assert!(is_semver_like("1.0.0"));
333 assert!(is_semver_like("2.1.3-beta"));
334 assert!(is_semver_like("0.9.12+build.1"));
335 assert!(!is_semver_like("invalid"));
336 assert!(!is_semver_like("1.0"));
337 }
338
339 #[test]
340 fn test_parse_timeout() {
341 assert_eq!(
342 parse_timeout("30").unwrap(),
343 std::time::Duration::from_secs(30)
344 );
345 assert_eq!(
346 parse_timeout("45s").unwrap(),
347 std::time::Duration::from_secs(45)
348 );
349 assert_eq!(
350 parse_timeout("2m").unwrap(),
351 std::time::Duration::from_secs(120)
352 );
353 assert_eq!(
354 parse_timeout("1h").unwrap(),
355 std::time::Duration::from_secs(3600)
356 );
357 assert!(parse_timeout("invalid").is_err());
358 }
359}