1use anyhow::{Context, Result};
8use serde::Serialize;
9use std::str::FromStr;
10use std::time::{Duration, Instant};
11
12pub struct Profiler {
25 name: String,
26 measurements: Vec<Duration>,
27 current_start: Option<Instant>,
28}
29
30impl Profiler {
31 #[must_use]
33 pub fn new(name: impl Into<String>) -> Self {
34 Self {
35 name: name.into(),
36 measurements: Vec::new(),
37 current_start: None,
38 }
39 }
40
41 pub fn start(&mut self) {
45 self.current_start = Some(Instant::now());
46 }
47
48 pub fn stop(&mut self) {
52 if let Some(start) = self.current_start.take() {
53 self.measurements.push(start.elapsed());
54 }
55 }
56
57 #[must_use]
61 pub fn report(&self) -> String {
62 let n = self.measurements.len();
63 if n == 0 {
64 return format!("=== {} ===\nNo measurements recorded.\n", self.name);
65 }
66
67 let stats = compute_stats(&self.measurements);
68
69 let header = format!("=== {} ({} iterations) ===", self.name, n);
70 let row = format!(
71 "count={n} min={:.3}ms mean={:.3}ms median={:.3}ms p95={:.3}ms p99={:.3}ms max={:.3}ms",
72 stats.min_ms, stats.mean_ms, stats.median_ms, stats.p95_ms, stats.p99_ms, stats.max_ms,
73 );
74 format!("{header}\n{row}\n")
75 }
76
77 pub fn export_json(&self) -> Result<String> {
83 let n = self.measurements.len();
84 let measurements_ms: Vec<f64> = self.measurements.iter().map(duration_to_ms).collect();
85
86 let payload = if n == 0 {
87 ProfilerJson {
88 name: self.name.clone(),
89 count: 0,
90 min_ms: 0.0,
91 mean_ms: 0.0,
92 median_ms: 0.0,
93 p95_ms: 0.0,
94 p99_ms: 0.0,
95 max_ms: 0.0,
96 measurements_ms,
97 }
98 } else {
99 let stats = compute_stats(&self.measurements);
100 ProfilerJson {
101 name: self.name.clone(),
102 count: n,
103 min_ms: stats.min_ms,
104 mean_ms: stats.mean_ms,
105 median_ms: stats.median_ms,
106 p95_ms: stats.p95_ms,
107 p99_ms: stats.p99_ms,
108 max_ms: stats.max_ms,
109 measurements_ms,
110 }
111 };
112
113 serde_json::to_string_pretty(&payload)
114 .context("Failed to serialise profiler report to JSON")
115 }
116}
117
118#[derive(Serialize)]
121struct ProfilerJson {
122 name: String,
123 count: usize,
124 min_ms: f64,
125 mean_ms: f64,
126 median_ms: f64,
127 p95_ms: f64,
128 p99_ms: f64,
129 max_ms: f64,
130 measurements_ms: Vec<f64>,
131}
132
133struct Stats {
136 min_ms: f64,
137 mean_ms: f64,
138 median_ms: f64,
139 p95_ms: f64,
140 p99_ms: f64,
141 max_ms: f64,
142}
143
144fn duration_to_ms(d: &Duration) -> f64 {
145 d.as_secs_f64() * 1_000.0
146}
147
148fn compute_stats(measurements: &[Duration]) -> Stats {
149 let n = measurements.len();
150 debug_assert!(n > 0, "compute_stats called with empty slice");
151
152 let mut sorted: Vec<f64> = measurements.iter().map(duration_to_ms).collect();
153 sorted.sort_by(|a, b| a.total_cmp(b));
154
155 let min_ms = sorted[0];
156 let max_ms = sorted[n - 1];
157 let mean_ms = sorted.iter().sum::<f64>() / n as f64;
158 let median_ms = percentile_from_sorted(&sorted, 50.0);
159 let p95_ms = percentile_from_sorted(&sorted, 95.0);
160 let p99_ms = percentile_from_sorted(&sorted, 99.0);
161
162 Stats {
163 min_ms,
164 mean_ms,
165 median_ms,
166 p95_ms,
167 p99_ms,
168 max_ms,
169 }
170}
171
172fn percentile_from_sorted(sorted: &[f64], p: f64) -> f64 {
174 let n = sorted.len();
175 if n == 1 {
176 return sorted[0];
177 }
178 let rank = p / 100.0 * (n - 1) as f64;
180 let lower = rank.floor() as usize;
181 let upper = rank.ceil() as usize;
182 if lower == upper {
183 sorted[lower]
184 } else {
185 let frac = rank - lower as f64;
186 sorted[lower] * (1.0 - frac) + sorted[upper] * frac
187 }
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum Operation {
195 Open,
197 ReadFeatures,
199 ReadBands,
201 Stats,
203}
204
205impl Operation {
206 pub fn execute(&self, input: &str) -> Result<()> {
212 let path = std::path::Path::new(input);
213 let ext = path
214 .extension()
215 .and_then(|e| e.to_str())
216 .map(|e| e.to_lowercase())
217 .unwrap_or_default();
218
219 match self {
220 Self::Open => execute_open(input, &ext),
221 Self::ReadFeatures => execute_read_features(input, &ext),
222 Self::ReadBands => execute_read_bands(input, &ext),
223 Self::Stats => execute_stats(input, &ext),
224 }
225 }
226}
227
228impl FromStr for Operation {
229 type Err = anyhow::Error;
230
231 fn from_str(s: &str) -> Result<Self, Self::Err> {
232 match s.to_lowercase().as_str() {
233 "open" | "open-dataset" | "opendataset" => Ok(Self::Open),
234 "read-features" | "readfeatures" | "features" => Ok(Self::ReadFeatures),
235 "read-bands" | "readbands" | "bands" => Ok(Self::ReadBands),
236 "stats" | "compute-stats" | "computestats" => Ok(Self::Stats),
237 other => anyhow::bail!(
238 "Unknown operation: '{other}'. Valid options: open, read-features, read-bands, stats"
239 ),
240 }
241 }
242}
243
244impl std::fmt::Display for Operation {
245 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246 let name = match self {
247 Self::Open => "open",
248 Self::ReadFeatures => "read-features",
249 Self::ReadBands => "read-bands",
250 Self::Stats => "stats",
251 };
252 write!(f, "{name}")
253 }
254}
255
256#[allow(dead_code)]
268pub fn execute_operation(op: &Operation, input: &str, iterations: usize) -> Result<Vec<Duration>> {
269 let mut durations = Vec::with_capacity(iterations);
270 for i in 0..iterations {
271 let start = Instant::now();
272 op.execute(input)
273 .with_context(|| format!("Iteration {i} of operation '{op}' failed"))?;
274 durations.push(start.elapsed());
275 }
276 Ok(durations)
277}
278
279fn execute_open(input: &str, ext: &str) -> Result<()> {
282 match ext {
283 "tif" | "tiff" => {
284 use oxigeo_core::io::FileDataSource;
285 use oxigeo_geotiff::GeoTiffReader;
286 let source = FileDataSource::open(input)
287 .with_context(|| format!("Failed to open GeoTIFF: {input}"))?;
288 let _reader = GeoTiffReader::open(source)
289 .with_context(|| format!("Failed to parse GeoTIFF header: {input}"))?;
290 Ok(())
291 }
292 "geojson" | "json" => {
293 use oxigeo_geojson::GeoJsonReader;
294 use std::fs::File;
295 use std::io::BufReader;
296 let file =
297 File::open(input).with_context(|| format!("Failed to open GeoJSON: {input}"))?;
298 let _reader = GeoJsonReader::new(BufReader::new(file));
299 Ok(())
300 }
301 "fgb" => {
302 use oxigeo_flatgeobuf::FlatGeobufReader;
303 use std::fs::File;
304 let file =
305 File::open(input).with_context(|| format!("Failed to open FlatGeobuf: {input}"))?;
306 let _reader = FlatGeobufReader::new(file)
307 .with_context(|| format!("Failed to parse FlatGeobuf header: {input}"))?;
308 Ok(())
309 }
310 other => anyhow::bail!(
311 "Unsupported file extension for 'open' operation: '{other}'. \
312 Supported: tif, tiff, geojson, json, fgb"
313 ),
314 }
315}
316
317fn execute_read_features(input: &str, ext: &str) -> Result<()> {
318 match ext {
319 "geojson" | "json" => {
320 use oxigeo_geojson::GeoJsonReader;
321 use std::fs::File;
322 use std::io::BufReader;
323 let file =
324 File::open(input).with_context(|| format!("Failed to open GeoJSON: {input}"))?;
325 let mut reader = GeoJsonReader::new(BufReader::new(file));
326 let _fc = reader
327 .read_feature_collection()
328 .with_context(|| format!("Failed to read features from {input}"))?;
329 Ok(())
330 }
331 "fgb" => {
332 use oxigeo_flatgeobuf::FlatGeobufReader;
333 use std::fs::File;
334 let file =
335 File::open(input).with_context(|| format!("Failed to open FlatGeobuf: {input}"))?;
336 let mut reader = FlatGeobufReader::new(file)
337 .with_context(|| format!("Failed to parse FlatGeobuf: {input}"))?;
338 let mut iter = reader
339 .features()
340 .with_context(|| format!("Failed to iterate features from {input}"))?;
341 while iter.next().is_some() {}
342 Ok(())
343 }
344 other => anyhow::bail!(
345 "Unsupported file extension for 'read-features' operation: '{other}'. \
346 Supported: geojson, json, fgb"
347 ),
348 }
349}
350
351fn execute_read_bands(input: &str, ext: &str) -> Result<()> {
352 match ext {
353 "tif" | "tiff" => {
354 use oxigeo_core::io::FileDataSource;
355 use oxigeo_geotiff::GeoTiffReader;
356 let source = FileDataSource::open(input)
357 .with_context(|| format!("Failed to open GeoTIFF: {input}"))?;
358 let reader = GeoTiffReader::open(source)
359 .with_context(|| format!("Failed to parse GeoTIFF: {input}"))?;
360 for band in 0..reader.band_count().max(1) {
367 let _data = reader
368 .read_band(0, band as usize)
369 .with_context(|| format!("Failed to read band {band} from {input}"))?;
370 }
371 Ok(())
372 }
373 other => anyhow::bail!(
374 "Unsupported file extension for 'read-bands' operation: '{other}'. \
375 Supported: tif, tiff"
376 ),
377 }
378}
379
380fn execute_stats(input: &str, ext: &str) -> Result<()> {
381 match ext {
382 "tif" | "tiff" => execute_read_bands(input, ext),
383 "geojson" | "json" | "fgb" => execute_read_features(input, ext),
384 other => anyhow::bail!(
385 "Unsupported file extension for 'stats' operation: '{other}'. \
386 Supported: tif, tiff, geojson, json, fgb"
387 ),
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394
395 #[test]
396 fn test_profiler_no_measurements() {
397 let p = Profiler::new("test");
398 let report = p.report();
399 assert!(
400 report.contains("No measurements"),
401 "empty profiler should say no measurements"
402 );
403 }
404
405 #[test]
406 fn test_profiler_single_measurement() {
407 let mut p = Profiler::new("single");
408 p.start();
409 std::thread::sleep(Duration::from_millis(5));
410 p.stop();
411 let report = p.report();
412 assert!(report.contains("count=1"), "report should show count=1");
413 assert!(
414 report.contains("single"),
415 "report should include profiler name"
416 );
417 }
418
419 #[test]
420 fn test_profiler_export_json_empty() {
421 let p = Profiler::new("empty");
422 let json = p.export_json().expect("export_json should not fail");
423 let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
424 assert_eq!(v["name"], "empty");
425 assert_eq!(v["count"], 0);
426 assert!(
427 v["measurements_ms"]
428 .as_array()
429 .map(|a| a.is_empty())
430 .unwrap_or(false),
431 "measurements_ms should be empty"
432 );
433 }
434
435 #[test]
436 fn test_profiler_export_json_with_data() {
437 let mut p = Profiler::new("json_test");
438 for _ in 0..3 {
439 p.start();
440 std::thread::sleep(Duration::from_millis(2));
441 p.stop();
442 }
443 let json = p.export_json().expect("export_json should not fail");
444 let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
445 assert_eq!(v["count"], 3);
446 assert_eq!(
447 v["measurements_ms"]
448 .as_array()
449 .map(|a| a.len())
450 .unwrap_or(0),
451 3
452 );
453 assert!(v["min_ms"].as_f64().unwrap_or(0.0) > 0.0);
454 assert!(v["max_ms"].as_f64().unwrap_or(0.0) >= v["min_ms"].as_f64().unwrap_or(0.0));
455 }
456
457 #[test]
458 fn test_operation_from_str_valid() {
459 assert_eq!("open".parse::<Operation>().ok(), Some(Operation::Open));
460 assert_eq!(
461 "read-features".parse::<Operation>().ok(),
462 Some(Operation::ReadFeatures)
463 );
464 assert_eq!(
465 "read-bands".parse::<Operation>().ok(),
466 Some(Operation::ReadBands)
467 );
468 assert_eq!("stats".parse::<Operation>().ok(), Some(Operation::Stats));
469 assert_eq!("OPEN".parse::<Operation>().ok(), Some(Operation::Open));
471 assert_eq!(
472 "features".parse::<Operation>().ok(),
473 Some(Operation::ReadFeatures)
474 );
475 }
476
477 #[test]
478 fn test_operation_from_str_invalid() {
479 let result = "unknown_op".parse::<Operation>();
480 assert!(result.is_err(), "unknown operation should return Err");
481 let err_msg = result
482 .expect_err("parsing an unknown operation must fail")
483 .to_string();
484 assert!(
485 err_msg.contains("Unknown operation"),
486 "error should mention 'Unknown operation'"
487 );
488 }
489
490 #[test]
491 fn test_percentile_from_sorted_single() {
492 let data = vec![42.0f64];
493 assert!((percentile_from_sorted(&data, 50.0) - 42.0).abs() < 1e-10);
494 assert!((percentile_from_sorted(&data, 95.0) - 42.0).abs() < 1e-10);
495 }
496
497 #[test]
498 fn test_percentile_from_sorted_multiple() {
499 let data: Vec<f64> = (1..=10).map(|x| x as f64).collect();
500 let median = percentile_from_sorted(&data, 50.0);
501 assert!(
503 (median - 5.5).abs() < 1e-10,
504 "median should be 5.5, got {median}"
505 );
506 let min = percentile_from_sorted(&data, 0.0);
507 assert!((min - 1.0).abs() < 1e-10);
508 let max = percentile_from_sorted(&data, 100.0);
509 assert!((max - 10.0).abs() < 1e-10);
510 }
511
512 #[test]
513 fn test_stop_without_start_is_noop() {
514 let mut p = Profiler::new("noop");
515 p.stop(); assert_eq!(p.measurements.len(), 0);
517 }
518}