1use std::ffi::OsString;
2use std::fmt;
3use std::path::Path;
4use std::time::Duration;
5
6use serde::Serialize;
7use serde_json::Value;
8
9use crate::process::{CommandSpec, ProcessError, ProcessOutput, run};
10use crate::schema::{validate_conversion_report, validate_probe_report};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum PayloadMode {
14 Full,
15 Digest,
16}
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19enum ProbeOperation {
20 Inspect,
21 Roundtrip,
22 ConvertGeoJson,
23 ConvertRaster,
24}
25
26impl ProbeOperation {
27 const fn label(self) -> &'static str {
28 match self {
29 Self::Inspect => "inspect",
30 Self::Roundtrip => "roundtrip",
31 Self::ConvertGeoJson => "convert-geojson",
32 Self::ConvertRaster => "convert-raster",
33 }
34 }
35}
36
37impl PayloadMode {
38 const fn label(self) -> &'static str {
39 match self {
40 Self::Full => "full",
41 Self::Digest => "digest",
42 }
43 }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum GeoJsonCoordinateSpace {
48 Level0Pixels,
49 SourcePixels,
50 SlideMillimeters,
51}
52
53impl GeoJsonCoordinateSpace {
54 const fn label(self) -> &'static str {
55 match self {
56 Self::Level0Pixels => "level0-pixels",
57 Self::SourcePixels => "source-pixels",
58 Self::SlideMillimeters => "slide-mm",
59 }
60 }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum GeoJsonTarget {
65 Ann,
66 Seg,
67 Sr,
68}
69
70impl GeoJsonTarget {
71 const fn label(self) -> &'static str {
72 match self {
73 Self::Ann => "ann",
74 Self::Seg => "seg",
75 Self::Sr => "sr",
76 }
77 }
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum RasterChannels {
82 Auto,
83 One(String),
84 All,
85}
86
87#[derive(Debug, Clone)]
88pub struct ProbeObservation {
89 pub command: Vec<String>,
90 pub report: Value,
91 pub stderr: String,
92 pub elapsed_ms: f64,
93 pub peak_rss_bytes: u64,
94 pub rss_sampled: bool,
95 pub sample_interval_ms: Option<u64>,
96 pub stdout_total_bytes: u64,
97 pub stderr_total_bytes: u64,
98 pub stderr_truncated: bool,
99}
100
101#[derive(Debug, Clone, Copy, Serialize)]
102pub(crate) struct ProbeMeasurements {
103 pub runtime_ms: Option<f64>,
104 pub peak_rss_bytes: Option<u64>,
105 pub peak_tracked_heap_bytes: Option<u64>,
106}
107
108pub(crate) fn tracked_heap_bytes(report: &Value) -> Option<u64> {
109 report
110 .pointer("/runtime/peak_tracked_heap_bytes")
111 .or_else(|| report.get("peak_tracked_heap_bytes"))
112 .and_then(Value::as_u64)
113}
114
115impl ProbeObservation {
116 pub(crate) fn measurements(&self) -> ProbeMeasurements {
117 ProbeMeasurements {
118 runtime_ms: Some(self.elapsed_ms),
119 peak_rss_bytes: self.rss_sampled.then_some(self.peak_rss_bytes),
120 peak_tracked_heap_bytes: tracked_heap_bytes(&self.report),
121 }
122 }
123}
124
125#[derive(Debug, Clone, Serialize)]
126pub struct ProbeError {
127 #[serde(flatten)]
128 details: Box<ProbeErrorDetails>,
129 pub report: Option<Value>,
130 pub stderr: String,
131 pub returncode: Option<i32>,
132 pub peak_rss_bytes: Option<u64>,
133 pub process: Option<Box<ProbeProcessEvidence>>,
134 pub command: Option<Box<Vec<String>>>,
135}
136
137#[derive(Debug, Clone, Serialize)]
138struct ProbeErrorDetails {
139 message: String,
140 elapsed_ms: Option<f64>,
141}
142
143#[derive(Debug, Clone, Serialize)]
144pub struct ProbeProcessEvidence {
145 pub stdout_truncated: bool,
146 pub stderr_truncated: bool,
147 pub stdout_total_bytes: u64,
148 pub stderr_total_bytes: u64,
149 pub sample_interval_ms: Option<u64>,
150}
151
152impl fmt::Display for ProbeError {
153 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
154 formatter.write_str(&self.details.message)
155 }
156}
157
158impl std::error::Error for ProbeError {}
159
160impl ProbeError {
161 pub(crate) fn measurements(&self) -> ProbeMeasurements {
162 ProbeMeasurements {
163 runtime_ms: self.elapsed_ms(),
164 peak_rss_bytes: self.peak_rss_bytes,
165 peak_tracked_heap_bytes: self.report.as_ref().and_then(tracked_heap_bytes),
166 }
167 }
168
169 #[must_use]
171 pub(crate) const fn elapsed_ms(&self) -> Option<f64> {
172 self.details.elapsed_ms
173 }
174
175 fn with_command(mut self, command: &[String]) -> Self {
176 self.command = Some(Box::new(command.to_vec()));
177 self
178 }
179}
180
181#[derive(Debug, Clone)]
182pub struct ViewerProbe {
183 executable: CommandSpec,
184 timeout: Duration,
185}
186
187impl ViewerProbe {
188 pub fn new(executable: Vec<String>, timeout: Option<Duration>) -> Result<Self, String> {
194 let executable = CommandSpec::from_strings(executable, "annotation_probe")?;
195 Self::from_spec(executable, timeout)
196 }
197
198 pub fn from_program(
204 program: impl Into<OsString>,
205 arguments: Vec<OsString>,
206 timeout: Option<Duration>,
207 ) -> Result<Self, String> {
208 let executable = CommandSpec::new(program.into(), arguments)?;
209 Self::from_spec(executable, timeout)
210 }
211
212 fn from_spec(executable: CommandSpec, timeout: Option<Duration>) -> Result<Self, String> {
213 let timeout = timeout.unwrap_or(Duration::from_mins(10));
214 if timeout.is_zero() {
215 return Err("timeout must be positive".to_owned());
216 }
217 Ok(Self {
218 executable,
219 timeout,
220 })
221 }
222
223 #[must_use]
224 pub fn program(&self) -> &Path {
225 Path::new(self.executable.program())
226 }
227
228 pub fn inspect(
234 &self,
235 source: &Path,
236 annotation: &Path,
237 canonical_source: Option<&Path>,
238 payload: PayloadMode,
239 ) -> Result<ProbeObservation, ProbeError> {
240 let mut command = self.executable.clone();
241 command.extend(["inspect", "--source"]);
242 command.push(source.as_os_str());
243 if let Some(canonical_source) = canonical_source {
244 command.push("--canonical-source");
245 command.push(canonical_source.as_os_str());
246 }
247 command.extend([
248 OsString::from("--payload"),
249 OsString::from(payload.label()),
250 annotation.as_os_str().to_owned(),
251 ]);
252 self.execute(&command, ProbeOperation::Inspect, validate_probe_report)
253 }
254
255 pub fn roundtrip(
261 &self,
262 source: &Path,
263 annotation: &Path,
264 output: &Path,
265 canonical_source: Option<&Path>,
266 payload: PayloadMode,
267 allow_lossy: bool,
268 ) -> Result<ProbeObservation, ProbeError> {
269 let mut command = self.executable.clone();
270 command.extend(["roundtrip", "--source"]);
271 command.push(source.as_os_str());
272 command.push("--output");
273 command.push(output.as_os_str());
274 if let Some(canonical_source) = canonical_source {
275 command.push("--canonical-source");
276 command.push(canonical_source.as_os_str());
277 }
278 if allow_lossy {
279 command.push("--allow-lossy");
280 }
281 command.extend([
282 OsString::from("--payload"),
283 OsString::from(payload.label()),
284 annotation.as_os_str().to_owned(),
285 ]);
286 self.execute(&command, ProbeOperation::Roundtrip, validate_probe_report)
287 }
288
289 #[allow(clippy::too_many_arguments)]
295 pub fn convert_geojson_bundle(
296 &self,
297 source: &Path,
298 canonical_source: Option<&Path>,
299 mapping: &Path,
300 coordinate_space: GeoJsonCoordinateSpace,
301 targets: &[GeoJsonTarget],
302 output_directory: &Path,
303 geojson: &Path,
304 allow_lossy: bool,
305 ) -> Result<ProbeObservation, ProbeError> {
306 if targets.is_empty() {
307 return Err(configuration_error(
308 "GeoJSON conversion requires at least one target",
309 ));
310 }
311 let mut command = self.executable.clone();
312 command.extend([
313 OsString::from("convert-geojson"),
314 OsString::from("--source"),
315 source.as_os_str().to_owned(),
316 ]);
317 if let Some(canonical_source) = canonical_source {
318 command.extend([
319 OsString::from("--canonical-source"),
320 canonical_source.as_os_str().to_owned(),
321 ]);
322 }
323 command.extend([
324 OsString::from("--mapping"),
325 mapping.as_os_str().to_owned(),
326 OsString::from("--coordinate-space"),
327 OsString::from(coordinate_space.label()),
328 ]);
329 for target in targets {
330 command.extend(["--target", target.label()]);
331 }
332 if allow_lossy {
333 command.push("--allow-lossy");
334 }
335 command.extend([
336 OsString::from("--output-dir"),
337 output_directory.as_os_str().to_owned(),
338 geojson.as_os_str().to_owned(),
339 ]);
340 self.execute(
341 &command,
342 ProbeOperation::ConvertGeoJson,
343 validate_conversion_report,
344 )
345 }
346
347 #[allow(clippy::too_many_arguments)]
353 pub fn convert_raster_bundle(
354 &self,
355 source: &Path,
356 canonical_source: Option<&Path>,
357 profile: &Path,
358 channels: RasterChannels,
359 output_directory: &Path,
360 maximum_instance_bytes: Option<u64>,
361 raster: &Path,
362 ) -> Result<ProbeObservation, ProbeError> {
363 let mut command = self.executable.clone();
364 command.extend([
365 OsString::from("convert-raster"),
366 OsString::from("--source"),
367 source.as_os_str().to_owned(),
368 ]);
369 if let Some(canonical_source) = canonical_source {
370 command.extend([
371 OsString::from("--canonical-source"),
372 canonical_source.as_os_str().to_owned(),
373 ]);
374 }
375 command.extend([OsString::from("--profile"), profile.as_os_str().to_owned()]);
376 match channels {
377 RasterChannels::Auto => {}
378 RasterChannels::One(value) => {
379 command.extend([OsString::from("--channel"), OsString::from(value)]);
380 }
381 RasterChannels::All => command.push("--all-channels"),
382 }
383 if let Some(maximum) = maximum_instance_bytes {
384 command.extend([
385 OsString::from("--max-instance-bytes"),
386 OsString::from(maximum.to_string()),
387 ]);
388 }
389 command.extend([
390 OsString::from("--output-dir"),
391 output_directory.as_os_str().to_owned(),
392 raster.as_os_str().to_owned(),
393 ]);
394 self.execute(
395 &command,
396 ProbeOperation::ConvertRaster,
397 validate_conversion_report,
398 )
399 }
400
401 fn execute(
402 &self,
403 command: &CommandSpec,
404 expected_operation: ProbeOperation,
405 validate: fn(&Value) -> Result<(), String>,
406 ) -> Result<ProbeObservation, ProbeError> {
407 let display_command = command.display();
408 let output = run(&command.process_spec(self.timeout))
409 .map_err(|error| process_error(error).with_command(&display_command))?;
410 let stderr = String::from_utf8_lossy(&output.stderr.bytes).into_owned();
411 if output.stdout.truncated {
412 return Err(observed_error(
413 format!(
414 "annotation_probe report exceeded the {} byte stdout limit ({} bytes observed)",
415 output.stdout.bytes.len(),
416 output.stdout.total_bytes
417 ),
418 None,
419 stderr,
420 &output,
421 &display_command,
422 ));
423 }
424 let report: Value = serde_json::from_slice(&output.stdout.bytes).map_err(|error| {
425 observed_error(
426 format!("annotation_probe emitted invalid JSON: {error}"),
427 None,
428 stderr.clone(),
429 &output,
430 &display_command,
431 )
432 })?;
433 validate(&report).map_err(|message| {
434 observed_error(
435 message,
436 Some(report.clone()),
437 stderr.clone(),
438 &output,
439 &display_command,
440 )
441 })?;
442 let operation = report.get("operation").and_then(Value::as_str);
443 if operation != Some(expected_operation.label()) {
444 return Err(observed_error(
445 format!(
446 "annotation_probe reported operation {} for requested {}",
447 operation.unwrap_or("<missing>"),
448 expected_operation.label()
449 ),
450 Some(report),
451 stderr,
452 &output,
453 &display_command,
454 ));
455 }
456 if !output.status.success() || report.get("status").and_then(Value::as_str) != Some("ok") {
457 let message = report
458 .pointer("/error/message")
459 .and_then(Value::as_str)
460 .map_or_else(
461 || format!("annotation_probe exited with status {}", output.status),
462 str::to_owned,
463 );
464 return Err(observed_error(
465 message,
466 Some(report),
467 stderr,
468 &output,
469 &display_command,
470 ));
471 }
472 Ok(ProbeObservation {
473 command: display_command,
474 report,
475 stderr,
476 elapsed_ms: output.elapsed.as_secs_f64() * 1000.0,
477 peak_rss_bytes: output.peak_rss_bytes,
478 rss_sampled: output.rss_sampled,
479 sample_interval_ms: output
480 .sample_interval
481 .and_then(|interval| u64::try_from(interval.as_millis()).ok()),
482 stdout_total_bytes: output.stdout.total_bytes,
483 stderr_total_bytes: output.stderr.total_bytes,
484 stderr_truncated: output.stderr.truncated,
485 })
486 }
487}
488
489fn configuration_error(message: &str) -> ProbeError {
490 ProbeError {
491 details: Box::new(ProbeErrorDetails {
492 message: message.to_owned(),
493 elapsed_ms: None,
494 }),
495 report: None,
496 stderr: String::new(),
497 returncode: None,
498 peak_rss_bytes: None,
499 process: None,
500 command: None,
501 }
502}
503
504fn observed_error(
505 message: String,
506 report: Option<Value>,
507 stderr: String,
508 output: &ProcessOutput,
509 command: &[String],
510) -> ProbeError {
511 ProbeError {
512 details: Box::new(ProbeErrorDetails {
513 message,
514 elapsed_ms: Some(output.elapsed.as_secs_f64() * 1000.0),
515 }),
516 report,
517 stderr,
518 returncode: output.status.code(),
519 peak_rss_bytes: output.rss_sampled.then_some(output.peak_rss_bytes),
520 process: Some(Box::new(ProbeProcessEvidence {
521 stdout_truncated: output.stdout.truncated,
522 stderr_truncated: output.stderr.truncated,
523 stdout_total_bytes: output.stdout.total_bytes,
524 stderr_total_bytes: output.stderr.total_bytes,
525 sample_interval_ms: output
526 .sample_interval
527 .and_then(|interval| u64::try_from(interval.as_millis()).ok()),
528 })),
529 command: Some(Box::new(command.to_vec())),
530 }
531}
532
533fn process_error(error: ProcessError) -> ProbeError {
534 match error {
535 ProcessError::TimedOut { timeout, output } => ProbeError {
536 details: Box::new(ProbeErrorDetails {
537 elapsed_ms: Some(output.elapsed.as_secs_f64() * 1000.0),
538 message: format!(
539 "annotation_probe timed out after {} seconds",
540 timeout.as_secs_f64()
541 ),
542 }),
543 report: (!output.stdout.truncated)
544 .then(|| serde_json::from_slice(&output.stdout.bytes).ok())
545 .flatten(),
546 stderr: String::from_utf8_lossy(&output.stderr.bytes).into_owned(),
547 returncode: None,
548 peak_rss_bytes: output.rss_sampled.then_some(output.peak_rss_bytes),
549 process: Some(Box::new(ProbeProcessEvidence {
550 stdout_truncated: output.stdout.truncated,
551 stderr_truncated: output.stderr.truncated,
552 stdout_total_bytes: output.stdout.total_bytes,
553 stderr_total_bytes: output.stderr.total_bytes,
554 sample_interval_ms: output
555 .sample_interval
556 .and_then(|interval| u64::try_from(interval.as_millis()).ok()),
557 })),
558 command: None,
559 },
560 error => ProbeError {
561 details: Box::new(ProbeErrorDetails {
562 elapsed_ms: None,
563 message: format!("annotation_probe execution failed: {error}"),
564 }),
565 report: None,
566 stderr: String::new(),
567 returncode: None,
568 peak_rss_bytes: None,
569 process: None,
570 command: None,
571 },
572 }
573}