Skip to main content

crate_checker/
utils.rs

1//! Utility functions for the crate checker application
2
3use 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
10/// Parse JSON input for batch operations
11pub fn parse_json_input(json: &str) -> Result<BatchInput> {
12    debug!("Parsing JSON input: {}", json);
13
14    // First, parse as generic JSON to inspect structure
15    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    // Try to deserialize as BatchInput
21    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            // Provide helpful error message based on the JSON structure
29            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
55/// Parse JSON input from a file
56pub 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
68/// Validate a batch input structure
69pub 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
127/// Format duration in human-readable form
128pub 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
143/// Format file size in human-readable form
144pub 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
167/// Format download count in human-readable form
168pub 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
180/// Sanitize crate name for safe usage
181pub fn sanitize_crate_name(name: &str) -> String {
182    name.chars()
183        .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
184        .collect()
185}
186
187/// Check if a version string looks like a semver version
188pub fn is_semver_like(version: &str) -> bool {
189    // Basic check for semver-like pattern: X.Y.Z with optional pre-release/build
190    let parts: Vec<&str> = version.split(&['.', '-', '+'][..]).collect();
191    parts.len() >= 3 && parts.iter().take(3).all(|part| part.parse::<u32>().is_ok())
192}
193
194/// Extract the major.minor.patch part from a version string
195pub 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
204/// Create example batch inputs for help/documentation
205pub 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
228/// Truncate text to a maximum length with ellipsis
229pub 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
237/// Create a progress indicator string
238pub 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
250/// Parse a timeout string (e.g., "30s", "2m", "1h")
251pub 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}