siderust 0.6.0

High-precision astronomy and satellite mechanics in Rust.
Documentation
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Vallés Puig, Ramon

use std::{
    collections::BTreeMap,
    env,
    fs::{self, File},
    io::{BufRead, BufReader},
    path::{Path, PathBuf},
};

use anyhow::{Context, Result};
use regex::Regex;
use std::fmt::Write;

/// ---------------------------  PARSER  ---------------------------

#[derive(Debug)]
struct Entry {
    ints: Vec<i64>,
    floats: Vec<String>, // We use String to avoid formating the values in generate_rust
}

fn file_format(key: &str) -> Option<(usize, usize)> {
    let idx: u32 = key.trim_start_matches("ELP").parse().ok()?;
    match idx {
        1..=3 => Some((4, 7)),
        10..=21 => Some((11, 3)),
        4..=9 | 22..=36 => Some((5, 3)),
        _ => None,
    }
}

fn parse_line(line: &str, n_ints: usize, n_floats: usize, token_re: &Regex) -> Result<Entry> {
    let tokens: Vec<&str> = token_re.find_iter(line).map(|m| m.as_str()).collect();
    if tokens.len() != n_ints + n_floats {
        anyhow::bail!(
            "Unexpected column count {} (wanted {})",
            tokens.len(),
            n_ints + n_floats
        );
    }
    let ints = tokens[..n_ints]
        .iter()
        .map(|t| t.parse::<i64>().map_err(|e| anyhow::anyhow!(e)))
        .collect::<Result<Vec<_>>>()?;
    let floats = tokens[n_ints..].iter().map(|t| t.to_string()).collect();
    Ok(Entry { ints, floats })
}

fn parse_file(path: &Path, n_ints: usize, n_floats: usize) -> Result<Vec<Entry>> {
    let file = File::open(path).with_context(|| format!("open {path:?}"))?;
    let reader = BufReader::new(file);
    let line_re = Regex::new(r"^\s*[-+]?\d").unwrap();
    let token_re = Regex::new(r"[-+]?\d+\.\d+|[-+]?\d+").unwrap();

    reader
        .lines()
        .map_while(Result::ok)
        .filter(|l| line_re.is_match(l))
        .map(|l| parse_line(&l, n_ints, n_floats, &token_re))
        .collect()
}

fn parse_all_elps(dir: &Path) -> Result<BTreeMap<String, Vec<Entry>>> {
    let mut map = BTreeMap::new();

    let mut paths: Vec<_> = fs::read_dir(dir)
        .with_context(|| format!("read-dir {dir:?}"))?
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .filter(|p| {
            p.file_name()
                .and_then(|n| n.to_str())
                .map(|s| s.starts_with("ELP"))
                .unwrap_or(false)
        })
        .collect();

    paths.sort();

    for path in paths {
        let key = path.file_stem().unwrap().to_string_lossy().to_uppercase();
        if let Some((n_ints, n_floats)) = file_format(&key) {
            let entries =
                parse_file(&path, n_ints, n_floats).with_context(|| format!("Parsing {key}"))?;
            map.insert(key, entries);
        } else {
            println!("cargo:warning=Skipping {key}: unknown format");
        }
    }
    Ok(map)
}

/// ----------------------  RUST CODE GENERATOR  -------------------
fn generate_rust(data: &BTreeMap<String, Vec<Entry>>) -> Result<String> {
    let mut out = String::new();

    writeln!(
        out,
        "// ---------------------------------------------------\n"
    )?;
    writeln!(
        out,
        "// **AUTOGENERATED** by build.rs – DO NOT EDIT BY HAND\n"
    )?;
    writeln!(
        out,
        "// ---------------------------------------------------\n"
    )?;

    writeln!(out, "use crate::calculus::elp2000::elp_structs::*;\n")?;

    for (name, entries) in data {
        let idx: u32 = name.trim_start_matches("ELP").parse().unwrap();
        let typ = match idx {
            1..=3 => "MainProblem",
            4..=9 | 22..=36 => "EarthPert",
            10..=21 => "PlanetPert",
            _ => continue,
        };

        writeln!(out, "pub static {name}: &[{typ}] = &[")?;
        for e in entries {
            match typ {
                "MainProblem" => {
                    let ilu = e
                        .ints
                        .iter()
                        .map(|v| v.to_string())
                        .collect::<Vec<_>>()
                        .join(", ");
                    let a = &e.floats[0];
                    let b = e.floats[1..].join(", ");
                    writeln!(out, "    {typ} {{ ilu: [{ilu}], a: {a}, b: [{b}] }},")?;
                }
                "EarthPert" => {
                    let iz = format!("{}{}", e.ints[0], ".0"); // "1.0", "-2.0", …
                    let ilu = e.ints[1..5]
                        .iter()
                        .map(|v| v.to_string())
                        .collect::<Vec<_>>()
                        .join(", ");
                    let (o, a, p) = (&e.floats[0], &e.floats[1], &e.floats[2]);
                    writeln!(
                        out,
                        "    {typ} {{ iz: {iz}, ilu: [{ilu}], o: {o}, a: {a}, p: {p} }},"
                    )?;
                }
                "PlanetPert" => {
                    let ipla = e
                        .ints
                        .iter()
                        .map(|v| v.to_string())
                        .collect::<Vec<_>>()
                        .join(", ");
                    let (theta, o, p) = (&e.floats[0], &e.floats[1], &e.floats[2]);
                    writeln!(
                        out,
                        "    {typ} {{ ipla: [{ipla}], theta: {theta}, o: {o}, p: {p} }},"
                    )?;
                }
                _ => unreachable!(),
            }
        }
        writeln!(out, "];\n")?;
    }
    Ok(out)
}

/// ----------------------  DATASET HANDLING  ----------------------
/// Ensure the 36 `ELPn` files are present under `dir`.
///
fn ensure_dataset(dir: &Path) -> Result<()> {
    use reqwest::blocking::Client;

    fs::create_dir_all(dir)?;

    let base = "https://cdsarc.cds.unistra.fr/ftp/VI/79/";
    let client = Client::builder()
        .user_agent("ELP build script (rust)")
        .build()?;

    for n in 1..=36 {
        let name = format!("ELP{n}");
        let path = dir.join(&name);
        if path.exists() {
            continue;
        }
        let url = format!("{base}/{name}");
        println!("cargo:info=Downloading {url}");
        let bytes = client
            .get(&url)
            .send()
            .with_context(|| format!("GET {url}"))?
            .error_for_status()?
            .bytes()?;
        fs::write(&path, &bytes).with_context(|| format!("write {path:?}"))?;
    }
    Ok(())
}

/// ---------------------------  ENTRYPOINT  -----------------------
#[allow(dead_code)]
pub fn run(data_dir: &Path) -> Result<()> {
    ensure_dataset(data_dir)?;

    // obliga a reconstruir si los ficheros cambian
    println!("cargo:rerun-if-changed=build.rs");
    println!("cargo:rerun-if-changed={}", data_dir.display());

    let parsed = parse_all_elps(data_dir)?;
    let code = generate_rust(&parsed)?;

    // escribe el fichero dentro de OUT_DIR
    let out_dir = PathBuf::from(env::var("OUT_DIR")?);
    fs::write(out_dir.join("elp_data.rs"), code.as_bytes())?;
    println!("cargo:info=elp_data.rs generated");

    Ok(())
}

/// Like [`run`] but writes `elp_data.rs` to `gen_dir` instead of `OUT_DIR`.
///
/// Used by `build.rs` when `SIDERUST_REGEN=1` to overwrite the committed
/// table in `src/generated/`.
pub fn run_regen(data_dir: &Path, gen_dir: &Path) -> Result<()> {
    ensure_dataset(data_dir)?;
    let parsed = parse_all_elps(data_dir)?;
    let code = generate_rust(&parsed)?;
    fs::create_dir_all(gen_dir)?;
    fs::write(gen_dir.join("elp_data.rs"), code.as_bytes())?;
    Ok(())
}