ridal 0.5.2

Speeding up Ground Penetrating Radar (GPR) processing
Documentation
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FormatKind {
    Ramac,
    PulseEkko,
    Gssi,
}

#[derive(Debug, Clone, serde::Serialize)]
pub struct FormatCapabilities {
    pub read: bool,
    pub write: bool,
}

#[derive(Debug, Clone, serde::Serialize)]
pub struct FormatFiles {
    pub header: &'static str,
    pub data: &'static str,
    pub coordinates: &'static str,
}

#[derive(Debug, Clone, serde::Serialize)]
pub struct FormatInfo {
    pub name: &'static str,
    pub description: &'static str,
    pub capabilities: FormatCapabilities,
    pub files: FormatFiles,
}

#[derive(Debug, Clone)]
pub struct ResolvedInput {
    pub input: PathBuf,
    pub kind: FormatKind,
    pub header: PathBuf,
    pub data: PathBuf,
    pub coordinates: PathBuf,
}

pub fn all_formats() -> Vec<FormatInfo> {
    vec![
        FormatInfo {
            name: "ramac",
            description: "MalÄ RAMAC format",
            capabilities: FormatCapabilities {
                read: true,
                write: false,
            },
            files: FormatFiles {
                header: ".rad",
                data: ".rd3",
                coordinates: ".cor",
            },
        },
        FormatInfo {
            name: "pulseekko",
            description: "Sensors & Software pulseEKKO format",
            capabilities: FormatCapabilities {
                read: true,
                write: false,
            },
            files: FormatFiles {
                header: ".hd",
                data: ".dt1",
                coordinates: ".gp2",
            },
        },
        FormatInfo {
            name: "gssi",
            description: "GSSI DZT/DZG format",
            capabilities: FormatCapabilities {
                read: true,
                write: false,
            },
            files: FormatFiles {
                header: ".DZT",
                data: ".DZT",
                coordinates: ".DZG",
            },
        },
    ]
}

pub fn format_info(kind: FormatKind) -> FormatInfo {
    match kind {
        FormatKind::Ramac => all_formats()
            .into_iter()
            .find(|fmt| fmt.name == "ramac")
            .unwrap(),
        FormatKind::PulseEkko => all_formats()
            .into_iter()
            .find(|fmt| fmt.name == "pulseekko")
            .unwrap(),
        FormatKind::Gssi => all_formats()
            .into_iter()
            .find(|fmt| fmt.name == "gssi")
            .unwrap(),
    }
}

pub fn find_neighbor_case_insensitive(base: &Path, target_extension: &str) -> Option<PathBuf> {
    let parent = base.parent().unwrap_or_else(|| Path::new("."));
    let stem = base.file_stem().or_else(|| base.file_name())?.to_str()?;
    let target_extension = target_extension.trim_start_matches('.');

    std::fs::read_dir(parent)
        .ok()?
        .filter_map(|entry| entry.ok().map(|entry| entry.path()))
        .find(|path| {
            path.file_stem()
                .and_then(|s| s.to_str())
                .is_some_and(|s| s.eq_ignore_ascii_case(stem))
                && path
                    .extension()
                    .and_then(|s| s.to_str())
                    .is_some_and(|ext| ext.eq_ignore_ascii_case(target_extension))
        })
}

fn resolve_sidecar(base: &Path, target_extension: &str) -> PathBuf {
    find_neighbor_case_insensitive(base, target_extension)
        .unwrap_or_else(|| base.with_extension(target_extension))
}

pub fn resolve_input(input: &Path) -> Result<ResolvedInput, String> {
    let ext = input
        .extension()
        .and_then(|s| s.to_str())
        .map(|s| s.to_ascii_lowercase());

    match ext.as_deref() {
        Some("rad") => Ok(ResolvedInput {
            input: input.to_path_buf(),
            kind: FormatKind::Ramac,
            header: resolve_sidecar(input, "rad"),
            data: resolve_sidecar(input, "rd3"),
            coordinates: resolve_sidecar(input, "cor"),
        }),
        Some("rd3") | Some("cor") => Ok(ResolvedInput {
            input: input.to_path_buf(),
            kind: FormatKind::Ramac,
            header: resolve_sidecar(input, "rad"),
            data: resolve_sidecar(input, "rd3"),
            coordinates: resolve_sidecar(input, "cor"),
        }),
        Some("hd") => Ok(ResolvedInput {
            input: input.to_path_buf(),
            kind: FormatKind::PulseEkko,
            header: resolve_sidecar(input, "hd"),
            data: resolve_sidecar(input, "dt1"),
            coordinates: resolve_sidecar(input, "gp2"),
        }),
        Some("dt1") | Some("gp2") => Ok(ResolvedInput {
            input: input.to_path_buf(),
            kind: FormatKind::PulseEkko,
            header: resolve_sidecar(input, "hd"),
            data: resolve_sidecar(input, "dt1"),
            coordinates: resolve_sidecar(input, "gp2"),
        }),
        Some("dzt") | Some("dzg") | Some("dzx") => Ok(ResolvedInput {
            input: input.to_path_buf(),
            kind: FormatKind::Gssi,
            header: resolve_sidecar(input, "dzt"),
            data: resolve_sidecar(input, "dzt"),
            coordinates: resolve_sidecar(input, "dzg"),
        }),
        Some(other) => Err(format!(
            "Unsupported input extension '.{other}' for {:?}. Supported formats are RAMAC (.rad/.rd3/.cor), pulseEKKO (.hd/.dt1/.gp2), and GSSI (.dzt/.dzg/.dzx).",
            input
        )),
        None => {
            let ramac = find_neighbor_case_insensitive(input, "rad");
            let pulseekko = find_neighbor_case_insensitive(input, "hd");
            let gssi = find_neighbor_case_insensitive(input, "dzt");
            match (ramac, pulseekko, gssi) {
                (Some(ramac), None, None) => Ok(ResolvedInput {
                    input: input.to_path_buf(),
                    kind: FormatKind::Ramac,
                    header: ramac.clone(),
                    data: resolve_sidecar(&ramac, "rd3"),
                    coordinates: resolve_sidecar(&ramac, "cor"),
                }),
                (None, Some(pulseekko), None) => Ok(ResolvedInput {
                    input: input.to_path_buf(),
                    kind: FormatKind::PulseEkko,
                    header: pulseekko.clone(),
                    data: resolve_sidecar(&pulseekko, "dt1"),
                    coordinates: resolve_sidecar(&pulseekko, "gp2"),
                }),
                (None, None, Some(gssi)) => Ok(ResolvedInput {
                    input: input.to_path_buf(),
                    kind: FormatKind::Gssi,
                    header: gssi.clone(),
                    data: gssi.clone(),
                    coordinates: resolve_sidecar(&gssi, "dzg"),
                }),
                (Some(ramac), Some(pulseekko), None) => Err(format!(
                    "Ambiguous extension-less input {:?}: both {:?} and {:?} exist.",
                    input, ramac, pulseekko
                )),
                (Some(ramac), None, Some(gssi)) => Err(format!(
                    "Ambiguous extension-less input {:?}: both {:?} and {:?} exist.",
                    input, ramac, gssi
                )),
                (None, Some(pulseekko), Some(gssi)) => Err(format!(
                    "Ambiguous extension-less input {:?}: both {:?} and {:?} exist.",
                    input, pulseekko, gssi
                )),
                (Some(ramac), Some(pulseekko), Some(gssi)) => Err(format!(
                    "Ambiguous extension-less input {:?}: {:?}, {:?}, and {:?} exist.",
                    input, ramac, pulseekko, gssi
                )),
                (None, None, None) => Err(format!(
                    "Could not infer format for extension-less input {:?}. Tried {:?}, {:?}, and {:?}.",
                    input,
                    input.with_extension("rad"),
                    input.with_extension("hd"),
                    input.with_extension("dzt")
                )),
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_all_formats_names() {
        let names = all_formats()
            .into_iter()
            .map(|fmt| fmt.name.to_string())
            .collect::<Vec<String>>();
        assert_eq!(
            names,
            vec![
                "ramac".to_string(),
                "pulseekko".to_string(),
                "gssi".to_string()
            ]
        );
    }

    #[test]
    fn test_resolve_ramac_extensions() {
        let resolved = resolve_input(Path::new("line01.rad")).unwrap();
        assert_eq!(resolved.kind, FormatKind::Ramac);
        assert_eq!(resolved.data, PathBuf::from("line01.rd3"));
        assert_eq!(resolved.coordinates, PathBuf::from("line01.cor"));

        let resolved = resolve_input(Path::new("line01.rd3")).unwrap();
        assert_eq!(resolved.kind, FormatKind::Ramac);
        assert_eq!(resolved.header, PathBuf::from("line01.rad"));
    }

    #[test]
    fn test_resolve_pulseekko_extensions() {
        let resolved = resolve_input(Path::new("line01.hd")).unwrap();
        assert_eq!(resolved.kind, FormatKind::PulseEkko);
        assert_eq!(resolved.data, PathBuf::from("line01.dt1"));
        assert_eq!(resolved.coordinates, PathBuf::from("line01.gp2"));

        let resolved = resolve_input(Path::new("line01.dt1")).unwrap();
        assert_eq!(resolved.kind, FormatKind::PulseEkko);
        assert_eq!(resolved.header, PathBuf::from("line01.hd"));
    }

    #[test]
    fn test_resolve_gssi_extensions() {
        let resolved = resolve_input(Path::new("line01.dzt")).unwrap();
        assert_eq!(resolved.kind, FormatKind::Gssi);
        assert_eq!(resolved.data, PathBuf::from("line01.dzt"));
        assert_eq!(resolved.coordinates, PathBuf::from("line01.dzg"));

        let resolved = resolve_input(Path::new("line01.dzg")).unwrap();
        assert_eq!(resolved.kind, FormatKind::Gssi);
        assert_eq!(resolved.header, PathBuf::from("line01.dzt"));
    }

    #[test]
    fn test_resolve_case_insensitive_sidecars() {
        let temp_dir = tempfile::tempdir().unwrap();
        let rad = temp_dir.path().join("LINE01.RAD");
        let rd3 = temp_dir.path().join("LINE01.RD3");
        let cor = temp_dir.path().join("LINE01.COR");
        std::fs::write(&rad, "").unwrap();
        std::fs::write(&rd3, "").unwrap();
        std::fs::write(&cor, "").unwrap();

        let resolved = resolve_input(&temp_dir.path().join("LINE01")).unwrap();
        assert_eq!(resolved.kind, FormatKind::Ramac);
        assert_eq!(resolved.header, rad);
        assert_eq!(resolved.data, rd3);
        assert_eq!(resolved.coordinates, cor);

        let dzt = temp_dir.path().join("TRACK.DZT");
        let dzg = temp_dir.path().join("TRACK.DZG");
        std::fs::write(&dzt, "").unwrap();
        std::fs::write(&dzg, "").unwrap();

        let resolved = resolve_input(&temp_dir.path().join("TRACK")).unwrap();
        assert_eq!(resolved.kind, FormatKind::Gssi);
        assert_eq!(resolved.header, dzt);
        assert_eq!(resolved.coordinates, dzg);
    }
}