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 let _data = reader
361 .read_band(0, 0)
362 .with_context(|| format!("Failed to read band from {input}"))?;
363 Ok(())
364 }
365 other => anyhow::bail!(
366 "Unsupported file extension for 'read-bands' operation: '{other}'. \
367 Supported: tif, tiff"
368 ),
369 }
370}
371
372fn execute_stats(input: &str, ext: &str) -> Result<()> {
373 match ext {
374 "tif" | "tiff" => execute_read_bands(input, ext),
375 "geojson" | "json" | "fgb" => execute_read_features(input, ext),
376 other => anyhow::bail!(
377 "Unsupported file extension for 'stats' operation: '{other}'. \
378 Supported: tif, tiff, geojson, json, fgb"
379 ),
380 }
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386
387 #[test]
388 fn test_profiler_no_measurements() {
389 let p = Profiler::new("test");
390 let report = p.report();
391 assert!(
392 report.contains("No measurements"),
393 "empty profiler should say no measurements"
394 );
395 }
396
397 #[test]
398 fn test_profiler_single_measurement() {
399 let mut p = Profiler::new("single");
400 p.start();
401 std::thread::sleep(Duration::from_millis(5));
402 p.stop();
403 let report = p.report();
404 assert!(report.contains("count=1"), "report should show count=1");
405 assert!(
406 report.contains("single"),
407 "report should include profiler name"
408 );
409 }
410
411 #[test]
412 fn test_profiler_export_json_empty() {
413 let p = Profiler::new("empty");
414 let json = p.export_json().expect("export_json should not fail");
415 let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
416 assert_eq!(v["name"], "empty");
417 assert_eq!(v["count"], 0);
418 assert!(
419 v["measurements_ms"]
420 .as_array()
421 .map(|a| a.is_empty())
422 .unwrap_or(false),
423 "measurements_ms should be empty"
424 );
425 }
426
427 #[test]
428 fn test_profiler_export_json_with_data() {
429 let mut p = Profiler::new("json_test");
430 for _ in 0..3 {
431 p.start();
432 std::thread::sleep(Duration::from_millis(2));
433 p.stop();
434 }
435 let json = p.export_json().expect("export_json should not fail");
436 let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
437 assert_eq!(v["count"], 3);
438 assert_eq!(
439 v["measurements_ms"]
440 .as_array()
441 .map(|a| a.len())
442 .unwrap_or(0),
443 3
444 );
445 assert!(v["min_ms"].as_f64().unwrap_or(0.0) > 0.0);
446 assert!(v["max_ms"].as_f64().unwrap_or(0.0) >= v["min_ms"].as_f64().unwrap_or(0.0));
447 }
448
449 #[test]
450 fn test_operation_from_str_valid() {
451 assert_eq!("open".parse::<Operation>().ok(), Some(Operation::Open));
452 assert_eq!(
453 "read-features".parse::<Operation>().ok(),
454 Some(Operation::ReadFeatures)
455 );
456 assert_eq!(
457 "read-bands".parse::<Operation>().ok(),
458 Some(Operation::ReadBands)
459 );
460 assert_eq!("stats".parse::<Operation>().ok(), Some(Operation::Stats));
461 assert_eq!("OPEN".parse::<Operation>().ok(), Some(Operation::Open));
463 assert_eq!(
464 "features".parse::<Operation>().ok(),
465 Some(Operation::ReadFeatures)
466 );
467 }
468
469 #[test]
470 fn test_operation_from_str_invalid() {
471 let result = "unknown_op".parse::<Operation>();
472 assert!(result.is_err(), "unknown operation should return Err");
473 let err_msg = result
474 .expect_err("parsing an unknown operation must fail")
475 .to_string();
476 assert!(
477 err_msg.contains("Unknown operation"),
478 "error should mention 'Unknown operation'"
479 );
480 }
481
482 #[test]
483 fn test_percentile_from_sorted_single() {
484 let data = vec![42.0f64];
485 assert!((percentile_from_sorted(&data, 50.0) - 42.0).abs() < 1e-10);
486 assert!((percentile_from_sorted(&data, 95.0) - 42.0).abs() < 1e-10);
487 }
488
489 #[test]
490 fn test_percentile_from_sorted_multiple() {
491 let data: Vec<f64> = (1..=10).map(|x| x as f64).collect();
492 let median = percentile_from_sorted(&data, 50.0);
493 assert!(
495 (median - 5.5).abs() < 1e-10,
496 "median should be 5.5, got {median}"
497 );
498 let min = percentile_from_sorted(&data, 0.0);
499 assert!((min - 1.0).abs() < 1e-10);
500 let max = percentile_from_sorted(&data, 100.0);
501 assert!((max - 10.0).abs() < 1e-10);
502 }
503
504 #[test]
505 fn test_stop_without_start_is_noop() {
506 let mut p = Profiler::new("noop");
507 p.stop(); assert_eq!(p.measurements.len(), 0);
509 }
510}