Skip to main content

navcore_enc_check/
main.rs

1//! Checks routes against a chart, from the command line.
2//!
3//! ```text
4//! enc-check leg    --chart enc.gpkg --from 45.5443,13.7285 --to 45.5688,13.7002
5//! enc-check route  --chart enc.gpkg --name "Piran to Koper" \
6//!                  --waypoint 45.5150,13.5680 --waypoint 45.5500,13.6200
7//! enc-check find   --chart enc.gpkg --from 45.5443,13.7285 --to 45.5688,13.7002
8//! enc-check gateway --chart enc.gpkg --harbor 45.5480,13.7300
9//! enc-check bake   --chart enc.gpkg
10//! enc-check bench  --chart enc.gpkg
11//! enc-check bench  --chart enc.gpkg --write-baseline
12//! ```
13//!
14//! `leg` checks a line between two positions. `route` checks a plan
15//! given on the command line, leg by leg, against the vessel's own
16//! standing tolerances. `find` asks `route-find` for a route between
17//! two positions. `gateway` asks `route-find` for the nearest usable
18//! point to a harbour's nominal position (a pier, an anchorage); see
19//! `route_find::gateway`'s own module doc for why `find` cannot answer
20//! this directly. `bake` writes `route-find`'s ahead-of-time land
21//! cache for a chart, so `find` (and every route found through `ffi`)
22//! avoids re-fetching and simplifying the same land geometry on every
23//! query; see `route_find::land_cache` for details. `bench` runs
24//! `find` over [`bench::REFERENCE_ROUTES`], a fixed set of validated
25//! positions, and fails if any route's length or wall-clock time
26//! regresses past [`bench::distance_regressed`] or
27//! [`bench::time_regressed`]'s margin against `--baseline`'s stored
28//! numbers. `--write-baseline` records the current run's numbers as
29//! the new baseline instead of comparing against them, for recording
30//! an intentional change in length or speed. Requires a real chart, as
31//! `find` does; see this crate's `bench-baseline.tsv`.
32//!
33//! Exit codes: 0 clear, 1 unsafe, 2 the check could not answer for
34//! part of the leg (no coverage, or coverage too coarse for the
35//! corridor).
36
37mod bench;
38
39use std::path::{Path, PathBuf};
40use std::process::ExitCode;
41use std::time::Instant;
42
43use clap::{Parser, Subcommand};
44use enc_store::{ChartStore, Finding, Leg, Severity, Vessel, check_leg};
45use nav_math::{Position, rhumb};
46use route_check::{Defaults, RouteReport, check_route};
47use route_find::{FindOptions, bake_land_cache, find_dock_to_dock_route, find_gateway, find_gateway_route, find_route};
48use routes::{Route, Waypoint};
49
50#[derive(Parser, Debug)]
51#[command(about = "Check routes against a chart GeoPackage", version)]
52struct Options {
53    #[command(subcommand)]
54    command: Command,
55}
56
57#[derive(Subcommand, Debug)]
58enum Command {
59    /// Check a single leg between two positions.
60    Leg {
61        #[command(flatten)]
62        chart: ChartArgs,
63
64        /// Where the leg starts, as `lat,lon` in degrees.
65        #[arg(long, value_name = "LAT,LON", value_parser = position)]
66        from: Position,
67
68        /// Where the leg ends, as `lat,lon` in degrees.
69        #[arg(long, value_name = "LAT,LON", value_parser = position)]
70        to: Position,
71    },
72
73    /// Check a route given straight on the command line, leg by leg.
74    Route {
75        #[command(flatten)]
76        chart: ChartArgs,
77
78        /// What to call it, for the printed report only.
79        #[arg(long, value_name = "NAME")]
80        name: Option<String>,
81
82        /// A waypoint, as `lat,lon`. Repeat, in the order they are sailed.
83        #[arg(long = "waypoint", value_name = "LAT,LON", value_parser = position,
84              num_args = 1.., required = true)]
85        waypoints: Vec<Position>,
86    },
87
88    /// Find the shortest route between two positions that check_leg calls
89    /// safe, instead of checking one already drawn.
90    Find {
91        #[command(flatten)]
92        chart: ChartArgs,
93
94        /// Where the route starts, as `lat,lon` in degrees.
95        #[arg(long, value_name = "LAT,LON", value_parser = position)]
96        from: Position,
97
98        /// Where the route ends, as `lat,lon` in degrees.
99        #[arg(long, value_name = "LAT,LON", value_parser = position)]
100        to: Position,
101
102        /// How far beyond the direct line between the two the search may
103        /// reach, in nautical miles. Left unset, it auto-scales from the
104        /// direct distance and escalates a few times on its own if that
105        /// is not enough. Given explicitly, it is tried exactly once --
106        /// useful for asking "is N NM enough" rather than "find a way".
107        #[arg(long, value_name = "NM")]
108        margin: Option<f64>,
109
110        /// Lattice spacing for the coarse search, in nautical miles. Left
111        /// unset, it auto-scales from the direct distance.
112        #[arg(long, value_name = "NM")]
113        cell: Option<f64>,
114
115        /// Write the found route as a GPX 1.1 route file, in addition to
116        /// printing it.
117        #[arg(long, value_name = "FILE")]
118        gpx: Option<PathBuf>,
119
120        /// Never let the route cross a restricted area (RESARE), instead
121        /// of only reporting it. Off by default, the same posture
122        /// check_leg itself takes -- most restricted areas restrict
123        /// anchoring, not transit.
124        #[arg(long)]
125        avoid_restricted: bool,
126    },
127
128    /// Find the nearest point to a harbour's own nominal position --
129    /// dropped on a pier, an anchorage, or just a town's own marker --
130    /// that `find` can actually use as a `from`/`to`. See
131    /// `route_find::gateway`'s own doc for the search and why it stops
132    /// short of the pier itself.
133    Gateway {
134        #[command(flatten)]
135        chart: ChartArgs,
136
137        /// The harbour's own nominal position, as `lat,lon` in degrees.
138        #[arg(long, value_name = "LAT,LON", value_parser = position)]
139        harbor: Position,
140
141        /// Never let the search cross a restricted area, the same as
142        /// `find`'s own flag.
143        #[arg(long)]
144        avoid_restricted: bool,
145
146        /// Find a real, waypoint-by-waypoint route out to open water
147        /// (`route_find::find_gateway_route`) instead of just the
148        /// nearest usable point -- a second approach, tried alongside
149        /// the first rather than in place of it; see that function's
150        /// own doc.
151        #[arg(long)]
152        route: bool,
153    },
154
155    /// A full harbour-to-harbour passage
156    /// (`route_find::find_dock_to_dock_route`): each harbour's own
157    /// gateway, found the same way plain `gateway` does, with
158    /// `find`'s own open-water search filling the middle.
159    DockToDock {
160        #[command(flatten)]
161        chart: ChartArgs,
162
163        /// Where the passage starts, as `lat,lon` in degrees -- a pier
164        /// or anchorage, the same nominal position `gateway --harbor`
165        /// takes.
166        #[arg(long, value_name = "LAT,LON", value_parser = position)]
167        from: Position,
168
169        /// Where the passage ends, the same way.
170        #[arg(long, value_name = "LAT,LON", value_parser = position)]
171        to: Position,
172
173        /// Never let the search cross a restricted area, the same as
174        /// `find`'s own flag.
175        #[arg(long)]
176        avoid_restricted: bool,
177    },
178
179    /// Bake `route-find`'s own ahead-of-time land cache for a chart, so
180    /// every later `find` (and every route a client finds via `ffi`)
181    /// stops paying to fetch and simplify `LNDARE` on every query.
182    /// Written next to the chart as `<chart>.hazcache` unless `--out`
183    /// says otherwise; safe to re-run any time the chart itself changes,
184    /// since a stale cache is detected and ignored rather than trusted.
185    ///
186    /// The region to bake is detected automatically from the chart's
187    /// `M_COVR` coverage features, clustered by proximity so that
188    /// stray coverage rows for unrelated areas do not widen it. Not
189    /// specified on the command line, so the cache grows automatically
190    /// as charts are added to the ingest pipeline.
191    Bake {
192        /// The chart to bake a land cache for.
193        #[arg(long, value_name = "FILE")]
194        chart: PathBuf,
195
196        /// Where to write the cache. Defaults to the chart's own path
197        /// with `.hazcache` appended.
198        #[arg(long, value_name = "FILE")]
199        out: Option<PathBuf>,
200    },
201
202    /// Runs [`bench::REFERENCE_ROUTES`] against a chart and prints each
203    /// route's waypoint count, length and wall-clock time -- a fixed
204    /// set of measurements for judging whether a change to the search
205    /// or its caches improved, regressed, or had no effect.
206    ///
207    /// Exits non-zero if any reference route no longer finds a path,
208    /// or, unless `--write-baseline` is given, if length or wall-clock
209    /// time regresses past
210    /// [`bench::distance_regressed`]/[`bench::time_regressed`]'s
211    /// margin against `--baseline`'s stored numbers.
212    Bench {
213        #[command(flatten)]
214        chart: ChartArgs,
215
216        /// Never let a reference route cross a restricted area, the
217        /// same as `find`'s own flag -- kept off by default so `bench`
218        /// measures the common case unless asked otherwise.
219        #[arg(long)]
220        avoid_restricted: bool,
221
222        /// Where this run's own numbers are compared against (or, with
223        /// `--write-baseline`, written to). Defaults to a file tracked
224        /// alongside this crate's own source, resolved at compile time
225        /// so `enc-check bench` finds it from any working directory --
226        /// plain route names, lengths and times, never chart data, so
227        /// committing it carries none of the licensing weight a chart
228        /// fixture would.
229        #[arg(long, value_name = "FILE", default_value = concat!(env!("CARGO_MANIFEST_DIR"), "/bench-baseline.tsv"))]
230        baseline: PathBuf,
231
232        /// Overwrite `--baseline` with this run's own numbers instead of
233        /// comparing against them -- the way to accept a change that
234        /// genuinely made a route longer, slower, or both, on purpose.
235        #[arg(long)]
236        write_baseline: bool,
237    },
238}
239
240/// What every check needs: a chart and the vessel's own settings.
241#[derive(clap::Args, Debug)]
242struct ChartArgs {
243    /// The chart to check against.
244    #[arg(long, value_name = "FILE")]
245    chart: PathBuf,
246
247    /// The mariner's safety contour, in metres. The same setting the chart
248    /// is drawn with. A route's leg may set a stricter one for itself.
249    #[arg(long, value_name = "METRES", default_value_t = 3.0)]
250    safety_contour: f64,
251
252    /// How far off track the vessel may be, either side, in nautical miles.
253    /// Used where a leg does not say for itself.
254    #[arg(long, value_name = "NM", default_value_t = 0.05)]
255    corridor: f64,
256}
257
258fn main() -> ExitCode {
259    match Options::parse().command {
260        Command::Leg { chart, from, to } => run_leg(&chart, from, to),
261        Command::Route { chart, name, waypoints } => run_route(&chart, name.as_deref(), &waypoints),
262        Command::Find { chart, from, to, margin, cell, gpx, avoid_restricted } => {
263            run_find(&chart, from, to, margin, cell, gpx.as_deref(), avoid_restricted)
264        }
265        Command::Gateway { chart, harbor, avoid_restricted, route } => run_gateway(&chart, harbor, avoid_restricted, route),
266        Command::DockToDock { chart, from, to, avoid_restricted } => run_dock_to_dock(&chart, from, to, avoid_restricted),
267        Command::Bake { chart, out } => run_bake(&chart, out.as_deref()),
268        Command::Bench { chart, avoid_restricted, baseline, write_baseline } => {
269            run_bench(&chart, avoid_restricted, &baseline, write_baseline)
270        }
271    }
272}
273
274fn run_leg(chart: &ChartArgs, from: Position, to: Position) -> ExitCode {
275    let store = match ChartStore::open(&chart.chart) {
276        Ok(store) => store,
277        Err(error) => return fail(&error),
278    };
279
280    let leg = Leg::symmetric(from, to, chart.corridor);
281    let vessel = Vessel {
282        safety_contour_m: chart.safety_contour,
283    };
284    let findings = match check_leg(&store, leg, vessel) {
285        Ok(findings) => findings,
286        Err(error) => return fail(&error),
287    };
288
289    println!(
290        "{:.2} NM on {:03.0} degrees, corridor {:.2} NM either side, safety contour {:.1} m",
291        leg.length_nm(),
292        leg.course_at(leg.from, 0.0),
293        leg.narrowest_xtd_nm(),
294        vessel.safety_contour_m,
295    );
296    if findings.is_empty() {
297        println!("nothing found in the corridor");
298    }
299    for finding in &findings {
300        println!("{}", line(finding, 0.0));
301    }
302
303    verdict(findings.iter())
304}
305
306fn run_route(chart: &ChartArgs, name: Option<&str>, waypoints: &[Position]) -> ExitCode {
307    let charts = match ChartStore::open(&chart.chart) {
308        Ok(store) => store,
309        Err(error) => return fail(&error),
310    };
311
312    // No id worth minting for a one-off command-line check: nothing
313    // downstream of this reads it back, unlike a route actually
314    // published to Signal K via `routes::signalk::save`.
315    let mut route = Route::new("enc-check", waypoints.iter().copied().map(Waypoint::at).collect());
316    route.name = name.map(str::to_owned);
317
318    let defaults = Defaults {
319        safety_contour_m: chart.safety_contour,
320        port_xtd_nm: chart.corridor,
321        starboard_xtd_nm: chart.corridor,
322    };
323    let report = match check_route(&charts, &route, defaults) {
324        Ok(report) => report,
325        Err(error) => return fail(&error),
326    };
327
328    print_report(&report);
329    verdict(report.legs.iter().flat_map(|leg| leg.findings.iter()))
330}
331
332fn run_find(
333    chart: &ChartArgs,
334    from: Position,
335    to: Position,
336    margin_nm: Option<f64>,
337    cell_nm: Option<f64>,
338    gpx: Option<&Path>,
339    avoid_restricted: bool,
340) -> ExitCode {
341    let store = match ChartStore::open(&chart.chart) {
342        Ok(store) => store,
343        Err(error) => return fail(&error),
344    };
345
346    let options = FindOptions {
347        safety_contour_m: chart.safety_contour,
348        port_xtd_nm: chart.corridor,
349        starboard_xtd_nm: chart.corridor,
350        margin_nm,
351        cell_nm,
352        avoid_restricted_areas: avoid_restricted,
353    };
354
355    let waypoints = match find_route(&store, from, to, options) {
356        Ok(Some(waypoints)) => waypoints,
357        Ok(None) => {
358            match margin_nm {
359                Some(margin_nm) => println!("no safe route found within {margin_nm:.1} NM of the direct line"),
360                None => println!("no safe route found, even after widening the search margin on its own"),
361            }
362            return ExitCode::from(2);
363        }
364        Err(error) => return fail(&error),
365    };
366
367    let distance_nm: f64 = waypoints.windows(2).map(|pair| rhumb::distance_nm(pair[0].position, pair[1].position)).sum();
368    println!("{} waypoint(s), {distance_nm:.2} NM", waypoints.len());
369    for (index, waypoint) in waypoints.iter().enumerate() {
370        println!("  {}: {}", index + 1, format_position(waypoint.position));
371    }
372
373    if let Some(path) = gpx {
374        if let Err(error) = write_gpx(path, &waypoints) {
375            eprintln!("enc-check: writing {}: {error}", path.display());
376            return ExitCode::FAILURE;
377        }
378        println!("wrote {}", path.display());
379    }
380
381    ExitCode::SUCCESS
382}
383
384fn run_gateway(chart: &ChartArgs, harbor: Position, avoid_restricted: bool, route: bool) -> ExitCode {
385    let store = match ChartStore::open(&chart.chart) {
386        Ok(store) => store,
387        Err(error) => return fail(&error),
388    };
389
390    let options = FindOptions {
391        safety_contour_m: chart.safety_contour,
392        port_xtd_nm: chart.corridor,
393        starboard_xtd_nm: chart.corridor,
394        margin_nm: None,
395        cell_nm: None,
396        avoid_restricted_areas: avoid_restricted,
397    };
398
399    if route {
400        return match find_gateway_route(&store, harbor, &options) {
401            Ok(Some(waypoints)) => {
402                let distance_nm: f64 = waypoints.windows(2).map(|pair| rhumb::distance_nm(pair[0], pair[1])).sum();
403                println!("{} waypoint(s), {distance_nm:.2} NM", waypoints.len());
404                for (index, position) in waypoints.iter().enumerate() {
405                    println!("  {}: {}", index + 1, format_position(*position));
406                }
407                ExitCode::SUCCESS
408            }
409            Ok(None) => {
410                println!("no gateway route found within reach of {}", format_position(harbor));
411                ExitCode::from(2)
412            }
413            Err(error) => fail(&error),
414        };
415    }
416
417    match find_gateway(&store, harbor, &options) {
418        Ok(Some(gateway)) if gateway == harbor => {
419            println!("{} is already clear -- no gateway needed", format_position(harbor));
420        }
421        Ok(Some(gateway)) => {
422            let distance_nm = rhumb::distance_nm(harbor, gateway);
423            println!("{} ({distance_nm:.2} NM from {})", format_position(gateway), format_position(harbor));
424        }
425        Ok(None) => {
426            println!("no gateway found within reach of {}", format_position(harbor));
427            return ExitCode::from(2);
428        }
429        Err(error) => return fail(&error),
430    }
431
432    ExitCode::SUCCESS
433}
434
435fn run_dock_to_dock(chart: &ChartArgs, from: Position, to: Position, avoid_restricted: bool) -> ExitCode {
436    let store = match ChartStore::open(&chart.chart) {
437        Ok(store) => store,
438        Err(error) => return fail(&error),
439    };
440
441    let options = FindOptions {
442        safety_contour_m: chart.safety_contour,
443        port_xtd_nm: chart.corridor,
444        starboard_xtd_nm: chart.corridor,
445        margin_nm: None,
446        cell_nm: None,
447        avoid_restricted_areas: avoid_restricted,
448    };
449
450    match find_dock_to_dock_route(&store, from, to, &options) {
451        Ok(Some(waypoints)) => {
452            let distance_nm: f64 = waypoints.windows(2).map(|pair| rhumb::distance_nm(pair[0], pair[1])).sum();
453            println!("{} waypoint(s), {distance_nm:.2} NM", waypoints.len());
454            for (index, position) in waypoints.iter().enumerate() {
455                println!("  {}: {}", index + 1, format_position(*position));
456            }
457            ExitCode::SUCCESS
458        }
459        Ok(None) => {
460            println!("no dock-to-dock passage found between {} and {}", format_position(from), format_position(to));
461            ExitCode::from(2)
462        }
463        Err(error) => fail(&error),
464    }
465}
466
467fn run_bake(chart: &Path, out: Option<&Path>) -> ExitCode {
468    let store = match ChartStore::open(chart) {
469        Ok(store) => store,
470        Err(error) => return fail(&error),
471    };
472
473    match bake_land_cache(&store, out) {
474        Ok(path) => {
475            println!("wrote {}", path.display());
476            ExitCode::SUCCESS
477        }
478        Err(error) => fail(&error),
479    }
480}
481
482fn run_bench(chart: &ChartArgs, avoid_restricted: bool, baseline_path: &Path, write_baseline: bool) -> ExitCode {
483    let store = match ChartStore::open(&chart.chart) {
484        Ok(store) => store,
485        Err(error) => return fail(&error),
486    };
487    let options = FindOptions {
488        safety_contour_m: chart.safety_contour,
489        port_xtd_nm: chart.corridor,
490        starboard_xtd_nm: chart.corridor,
491        margin_nm: None,
492        cell_nm: None,
493        avoid_restricted_areas: avoid_restricted,
494    };
495
496    // Nothing to compare against while writing a new baseline -- every
497    // route below is reported as "no baseline" rather than measured
498    // against numbers this same run is about to overwrite anyway.
499    let baseline = if write_baseline {
500        bench::Baseline::new()
501    } else {
502        match bench::load_baseline(baseline_path) {
503            Ok(baseline) => baseline,
504            Err(error) => {
505                eprintln!("enc-check: reading baseline {}: {error}", baseline_path.display());
506                return ExitCode::FAILURE;
507            }
508        }
509    };
510
511    println!("{:<38} {:>10} {:>4} {:>10} {:>10}  ", "case", "direct NM", "wp", "route NM", "time");
512    let mut all_found = true;
513    let mut any_regressed = false;
514    let mut measured: Vec<(String, bench::BaselineEntry)> = Vec::new();
515    for case in bench::REFERENCE_ROUTES {
516        let direct_nm = rhumb::distance_nm(case.from, case.to);
517        let start = Instant::now();
518        let result = find_route(&store, case.from, case.to, options);
519        let elapsed = start.elapsed();
520
521        match result {
522            Ok(Some(waypoints)) => {
523                let route_nm: f64 = waypoints.windows(2).map(|pair| rhumb::distance_nm(pair[0].position, pair[1].position)).sum();
524                let elapsed_ms = elapsed.as_millis();
525
526                let note = match baseline.get(case.name) {
527                    _ if write_baseline => String::new(),
528                    None => "  (no baseline)".to_string(),
529                    Some(entry) => {
530                        let dist_regressed = bench::distance_regressed(entry.route_nm, route_nm);
531                        let time_regressed = bench::time_regressed(entry.elapsed_ms, elapsed_ms);
532                        if dist_regressed || time_regressed {
533                            any_regressed = true;
534                            format!(
535                                "  REGRESSED (baseline {:.2} NM / {} ms){}{}",
536                                entry.route_nm,
537                                entry.elapsed_ms,
538                                if dist_regressed { ", length" } else { "" },
539                                if time_regressed { ", time" } else { "" },
540                            )
541                        } else {
542                            String::new()
543                        }
544                    }
545                };
546                println!(
547                    "{:<38} {direct_nm:>10.2} {:>4} {route_nm:>10.2} {elapsed:>10.2?}{note}",
548                    case.name,
549                    waypoints.len()
550                );
551                measured.push((case.name.to_string(), bench::BaselineEntry { route_nm, elapsed_ms }));
552            }
553            Ok(None) => {
554                all_found = false;
555                println!("{:<38} {direct_nm:>10.2} {:>4} {:>10} {elapsed:>10.2?}  NO ROUTE FOUND", case.name, "-", "-");
556            }
557            Err(error) => {
558                all_found = false;
559                println!("{:<38} error: {error}", case.name);
560            }
561        }
562    }
563
564    if write_baseline {
565        if let Err(error) = bench::write_baseline(baseline_path, &measured) {
566            eprintln!("enc-check: writing baseline {}: {error}", baseline_path.display());
567            return ExitCode::FAILURE;
568        }
569        println!("wrote baseline to {}", baseline_path.display());
570    }
571
572    if all_found && !any_regressed { ExitCode::SUCCESS } else { ExitCode::FAILURE }
573}
574
575/// Writes a route as a GPX 1.1 `<rte>` -- the format both GPS units and
576/// chart plotters read a planned route back from, so a route this crate
577/// found can be opened somewhere other than this CLI's own text output.
578///
579/// The full `xsi:schemaLocation` header, not just the bare namespace:
580/// left out, the file is still well-formed XML, but stricter GPX readers
581/// (several marine navigation apps among them) validate against the
582/// schema declaration itself and refuse anything that doesn't carry one.
583fn write_gpx(path: &Path, waypoints: &[Waypoint]) -> std::io::Result<()> {
584    let mut gpx = String::from(
585        "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n\
586         <gpx version=\"1.1\" creator=\"nav-core route-find\" \
587         xmlns=\"http://www.topografix.com/GPX/1/1\" \
588         xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" \
589         xsi:schemaLocation=\"http://www.topografix.com/GPX/1/1 \
590         http://www.topografix.com/GPX/1/1/gpx.xsd\">\n\
591         <rte>\n  <name>route-find</name>\n",
592    );
593    for (index, waypoint) in waypoints.iter().enumerate() {
594        gpx.push_str(&format!(
595            "  <rtept lat=\"{:.7}\" lon=\"{:.7}\"><name>WP{:02}</name></rtept>\n",
596            waypoint.position.lat_deg,
597            waypoint.position.lon_deg,
598            index + 1
599        ));
600    }
601    gpx.push_str("</rte>\n</gpx>\n");
602    std::fs::write(path, gpx)
603}
604
605fn print_report(report: &RouteReport) {
606    println!(
607        "{} -- {:.2} NM over {} leg(s)",
608        report.name.as_deref().unwrap_or("(unnamed)"),
609        report.distance_nm,
610        report.legs.len(),
611    );
612
613    for leg in &report.legs {
614        let named = leg
615            .to_name
616            .as_deref()
617            .map_or_else(String::new, |name| format!(" to {name}"));
618        println!(
619            "\nleg {}{}: {:.2} NM, from {:.2} NM along the route",
620            leg.index + 1,
621            named,
622            leg.length_nm,
623            leg.starts_at_nm,
624        );
625        if leg.findings.is_empty() {
626            println!("  nothing found in the corridor");
627        }
628        for finding in &leg.findings {
629            println!("  {}", line(finding, leg.starts_at_nm));
630        }
631    }
632}
633
634/// One finding, with its distance along the leg and along the route.
635fn line(finding: &Finding, leg_starts_at_nm: f64) -> String {
636    let mark = match finding.severity {
637        Severity::Unsafe => "UNSAFE",
638        Severity::Unsurveyed => "NODATA",
639        Severity::Coarse => "COARSE",
640        Severity::Caution => "note  ",
641    };
642    let along = if finding.is_stretch() {
643        format!("{:.2}-{:.2} NM", finding.along_track_nm, finding.until_nm)
644    } else {
645        format!("{:.2} NM", finding.along_track_nm)
646    };
647    let on_route = if leg_starts_at_nm > 0.0 {
648        format!(" (route {:.2} NM)", leg_starts_at_nm + finding.along_track_nm)
649    } else {
650        String::new()
651    };
652
653    format!(
654        "{mark} {along:>15}{on_route}  {}  {:<7} {}",
655        format_position(finding.position),
656        finding.class,
657        finding.reason,
658    )
659}
660
661/// The exit code, from everything that was found.
662fn verdict<'a>(findings: impl Iterator<Item = &'a Finding>) -> ExitCode {
663    let mut worst: Option<Severity> = None;
664    for finding in findings {
665        worst = Some(worst.map_or(finding.severity, |seen| seen.min(finding.severity)));
666    }
667
668    match worst {
669        Some(Severity::Unsafe) => ExitCode::FAILURE,
670        Some(Severity::Unsurveyed | Severity::Coarse) => ExitCode::from(2),
671        _ => ExitCode::SUCCESS,
672    }
673}
674
675fn fail(error: &dyn std::error::Error) -> ExitCode {
676    eprintln!("enc-check: {error}");
677    ExitCode::FAILURE
678}
679
680/// Parses `lat,lon` in decimal degrees.
681fn position(text: &str) -> Result<Position, String> {
682    let (lat, lon) = text
683        .split_once(',')
684        .ok_or_else(|| format!("expected lat,lon but got {text:?}"))?;
685    let lat: f64 = lat
686        .trim()
687        .parse()
688        .map_err(|_| format!("{lat:?} is not a latitude"))?;
689    let lon: f64 = lon
690        .trim()
691        .parse()
692        .map_err(|_| format!("{lon:?} is not a longitude"))?;
693
694    let position = Position::new(lat, lon);
695    if !position.is_valid() {
696        return Err(format!("{lat}, {lon} is not a position on earth"));
697    }
698    Ok(position)
699}
700
701/// Degrees and decimal minutes, the way a position is read aloud.
702fn format_position(position: Position) -> String {
703    format!(
704        "{}{} {}{}",
705        degrees_minutes(position.lat_deg.abs(), 2),
706        if position.lat_deg < 0.0 { 'S' } else { 'N' },
707        degrees_minutes(position.lon_deg.abs(), 3),
708        if position.lon_deg < 0.0 { 'W' } else { 'E' },
709    )
710}
711
712fn degrees_minutes(value: f64, width: usize) -> String {
713    let degrees = value.trunc();
714    let minutes = (value - degrees) * 60.0;
715    format!("{degrees:0width$.0}\u{00b0}{minutes:05.2}\u{2032}")
716}