ferric_ai/
helpers.rs

1// Helper functions for common parsing and file operations
2
3use crate::cli::Cli;
4use crate::error::Result;
5use crate::parser::{FlamegraphParser, Tree};
6use std::fs;
7use std::path::{Path, PathBuf};
8use glob::glob;
9
10/// Parse trees without comparison support for optimal performance
11/// This is useful for commands that don't need comparison functionality
12pub fn parse_trees_no_comparison(cli: &Cli) -> Result<Vec<Tree>> {
13    let mut trees = Vec::new();
14
15    // Resolve glob patterns for primary input
16    let svg_files = resolve_path_to_files(&cli.flamegraph)?;
17    for file in svg_files {
18        let tree = parse_single_svg(&file)?;
19        trees.push(tree);
20    }
21
22    Ok(trees)
23}
24
25/// Parse a single SVG file into a Tree
26pub fn parse_single_svg(path: &std::path::Path) -> Result<Tree> {
27    // Read and parse the flamegraph
28    let svg_content = fs::read_to_string(path)
29        .map_err(|e| crate::error::FerricError::ParserError(
30            format!("Failed to read flamegraph file '{}': {}", path.display(), e)
31        ))?;
32
33    let mut parser = FlamegraphParser::default();
34    let parser_nodes = parser.parse_svg(&svg_content)?;
35    let _total_samples = parser.get_total_samples(); // Not used - Tree calculates from root nodes
36    let tree = Tree::from_parser_nodes(parser_nodes, 0); // total_samples will be calculated automatically
37
38    Ok(tree)
39}
40
41/// Find all SVG files in a directory
42pub fn find_svg_files(path: &std::path::Path) -> Result<Vec<std::path::PathBuf>> {
43    let mut svg_files = Vec::new();
44
45    if !path.is_dir() {
46        return Ok(svg_files);
47    }
48
49    let entries = fs::read_dir(path)
50        .map_err(|e| crate::error::FerricError::InvalidInput(
51            format!("Cannot read directory {}: {}", path.display(), e)
52        ))?;
53
54    for entry in entries {
55        let entry = entry.map_err(|e| crate::error::FerricError::InvalidInput(
56            format!("Error reading directory entry: {}", e)
57        ))?;
58
59        let path = entry.path();
60        if path.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("svg") {
61            svg_files.push(path);
62        }
63    }
64
65    svg_files.sort();
66    Ok(svg_files)
67}
68
69/// Resolve a path to a list of SVG files, supporting glob patterns
70///
71/// This function handles:
72/// - Single files: returns the file if it exists and is .svg
73/// - Directories: finds all .svg files in the directory
74/// - Glob patterns: expands patterns like "*.svg" or "profiles/**/*.svg"
75pub fn resolve_path_to_files(path: &Path) -> Result<Vec<PathBuf>> {
76    let path_str = path.to_string_lossy();
77
78    // Check if this looks like a glob pattern (contains *, ?, [, or **)
79    if path_str.contains('*') || path_str.contains('?') || path_str.contains('[') {
80        return resolve_glob_pattern(&path_str);
81    }
82
83    // Handle regular paths (files or directories)
84    if path.is_dir() {
85        find_svg_files(path)
86    } else if path.is_file() {
87        // Check if it's an SVG file
88        if path.extension().and_then(|ext| ext.to_str()) == Some("svg") {
89            Ok(vec![path.to_path_buf()])
90        } else {
91            Err(crate::error::FerricError::InvalidInput(
92                format!("File '{}' is not an SVG file", path.display())
93            ))
94        }
95    } else {
96        Err(crate::error::FerricError::InvalidInput(
97            format!("Path '{}' does not exist", path.display())
98        ))
99    }
100}
101
102/// Resolve a glob pattern to a list of SVG files
103fn resolve_glob_pattern(pattern: &str) -> Result<Vec<PathBuf>> {
104    let mut svg_files = Vec::new();
105
106    let glob_results = glob(pattern)
107        .map_err(|e| crate::error::FerricError::InvalidInput(
108            format!("Invalid glob pattern '{}': {}", pattern, e)
109        ))?;
110
111    for entry in glob_results {
112        match entry {
113            Ok(path) => {
114                if path.is_file() && path.extension().and_then(|ext| ext.to_str()) == Some("svg") {
115                    svg_files.push(path);
116                }
117            }
118            Err(e) => {
119                return Err(crate::error::FerricError::InvalidInput(
120                    format!("Error reading glob pattern '{}': {}", pattern, e)
121                ));
122            }
123        }
124    }
125
126    if svg_files.is_empty() {
127        return Err(crate::error::FerricError::InvalidInput(
128            format!("No SVG files found matching pattern '{}'", pattern)
129        ));
130    }
131
132    svg_files.sort();
133    Ok(svg_files)
134}
135
136// Helper functions for formatting and comparison operations
137
138/// Format duration in human-readable form
139pub fn format_duration(duration: std::time::Duration) -> String {
140    let total_seconds = duration.as_secs();
141
142    if total_seconds < 60 {
143        format!("{} seconds", total_seconds)
144    } else if total_seconds < 3600 {
145        let minutes = total_seconds / 60;
146        let seconds = total_seconds % 60;
147        if seconds == 0 {
148            format!("{} minutes", minutes)
149        } else {
150            format!("{} minutes {} seconds", minutes, seconds)
151        }
152    } else if total_seconds < 86400 {
153        let hours = total_seconds / 3600;
154        let minutes = (total_seconds % 3600) / 60;
155        if minutes == 0 {
156            format!("{} hours", hours)
157        } else {
158            format!("{} hours {} minutes", hours, minutes)
159        }
160    } else if total_seconds < 604800 {
161        let days = total_seconds / 86400;
162        let hours = (total_seconds % 86400) / 3600;
163        if hours == 0 {
164            format!("{} days", days)
165        } else {
166            format!("{} days {} hours", days, hours)
167        }
168    } else if total_seconds < 31536000 {
169        let weeks = total_seconds / 604800;
170        let days = (total_seconds % 604800) / 86400;
171        if days == 0 {
172            format!("{} weeks", weeks)
173        } else {
174            format!("{} weeks {} days", weeks, days)
175        }
176    } else {
177        let years = total_seconds / 31536000;
178        let weeks = (total_seconds % 31536000) / 604800;
179        if weeks == 0 {
180            format!("{} years", years)
181        } else {
182            format!("{} years {} weeks", years, weeks)
183        }
184    }
185}
186
187/// Get magnitude description for percent changes
188pub fn get_magnitude(percent_change: f64) -> &'static str {
189    if percent_change < 5.0 {
190        "SLIGHT"
191    } else if percent_change < 15.0 {
192        "MODERATE"
193    } else if percent_change < 50.0 {
194        "SIGNIFICANT"
195    } else {
196        "MAJOR"
197    }
198}
199
200/// Get percentile value from sorted array - works with f64 values
201pub fn get_percentile(sorted_values: &[f64], percentile: f64) -> f64 {
202    if sorted_values.is_empty() {
203        return 0.0;
204    }
205
206    let index = ((sorted_values.len() as f64 - 1.0) * percentile) as usize;
207    sorted_values.get(index).copied().unwrap_or(0.0)
208}
209
210/// Compare a numeric metric with LLM-friendly indicators - generic over numeric types
211pub fn print_compare_metric<T>(name: &str, primary_val: T, comparison_val: T, unit: &str, higher_is_worse: bool)
212where
213    T: Copy + std::fmt::Display + std::ops::Sub<Output = T> + PartialOrd + Into<f64>
214{
215    let primary_f64: f64 = primary_val.into();
216    let comparison_f64: f64 = comparison_val.into();
217
218    let diff = comparison_f64 - primary_f64;
219    let abs_diff = diff.abs();
220    let percent_change = if primary_f64 != 0.0 { (diff / primary_f64) * 100.0 } else { 0.0 };
221
222    // Normalize to avoid negative zero display (e.g., -0.0%)
223    let normalized_percent_change = if percent_change.abs() < 0.05 { 0.0 } else { percent_change };
224
225    let (indicator, magnitude, color) = if abs_diff < 0.01 {
226        ("≈", "SAME", "🟡")
227    } else if diff > 0.0 {
228        if higher_is_worse {
229            ("↗", get_magnitude(percent_change.abs()), "🔴") // Worse
230        } else {
231            ("↗", get_magnitude(percent_change.abs()), "🟢") // Better
232        }
233    } else if higher_is_worse {
234        ("↘", get_magnitude(percent_change.abs()), "🟢") // Better
235    } else {
236        ("↘", get_magnitude(percent_change.abs()), "🔴") // Worse
237    };
238
239    println!("├─ {}: {} {} vs {} {} {} {} ({:+.1}% change) {}",
240            name, primary_val, unit, comparison_val, unit, indicator, magnitude, normalized_percent_change, color);
241}
242
243/// Compare classification fields
244pub fn print_compare_classification(name: &str, primary_val: &str, comparison_val: &str) {
245    if primary_val == comparison_val {
246        println!("├─ {}: {} ≈ {} (SAME) 🟡", name, primary_val, comparison_val);
247    } else {
248        println!("├─ {}: {} ➜ {} (CHANGED) 🔄", name, primary_val, comparison_val);
249    }
250}
251
252/// Aggregate CPU data by function name to handle recursive calls properly
253/// Returns sorted vector by aggregated CPU percentage (descending)
254pub fn aggregate_cpu_by_function(cpu_data: &[(String, f64)]) -> Vec<(String, f64)> {
255    use std::collections::HashMap;
256
257    let mut function_totals: HashMap<String, f64> = HashMap::new();
258    for (function_name, cpu_percent) in cpu_data {
259        *function_totals.entry(function_name.clone()).or_insert(0.0) += cpu_percent;
260    }
261
262    let mut sorted_functions: Vec<(String, f64)> = function_totals.into_iter().collect();
263    sorted_functions.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
264
265    sorted_functions
266}