Skip to main content

xalen_ffi/
lib.rs

1#[cfg(test)]
2use std::ffi::CStr;
3use std::ffi::{c_char, c_double, c_int};
4use std::sync::OnceLock;
5
6use xalen_ayanamsa::Ayanamsa;
7use xalen_coords::obliquity::mean_obliquity;
8use xalen_ephem::{Almanac, Body};
9use xalen_houses::{GeoLocation, HouseSystem, compute_houses};
10use xalen_time::{JdUT1, JulianDay};
11
12static ALMANAC: OnceLock<Almanac> = OnceLock::new();
13
14fn get_almanac() -> &'static Almanac {
15    ALMANAC.get_or_init(Almanac::default_vedic)
16}
17
18#[repr(C)]
19pub struct XalenPosition {
20    pub longitude_deg: c_double,
21    pub latitude_deg: c_double,
22    pub distance_au: c_double,
23    pub status: c_int,
24}
25
26/// Full geocentric position: the six components Swiss Ephemeris returns from
27/// `swe_calc_ut(..., SEFLG_SPEED)` plus a retrograde flag. A NEW struct (not an
28/// extension of [`XalenPosition`]) so the existing `xalen_planet_position` ABI is
29/// untouched. Speeds are daily motion — degrees/day for longitude/latitude,
30/// AU/day for distance. `is_retrograde` is `1` when the (tropical) longitude rate
31/// is negative, else `0`.
32#[repr(C)]
33pub struct XalenPositionFull {
34    pub longitude_deg: c_double,
35    pub latitude_deg: c_double,
36    pub distance_au: c_double,
37    pub lon_speed_deg: c_double,
38    pub lat_speed_deg: c_double,
39    pub dist_speed_au: c_double,
40    pub is_retrograde: c_int,
41    pub status: c_int,
42}
43
44#[repr(C)]
45pub struct XalenHouses {
46    pub cusps: [c_double; 12],
47    pub ascendant_deg: c_double,
48    pub mc_deg: c_double,
49    pub status: c_int,
50}
51
52/// Status sentinel returned by every FFI entrypoint when a Rust panic was
53/// caught crossing the C ABI. Unwinding past `extern "C"` is undefined behaviour
54/// (it aborts the process under the default panic strategy), so each body is run
55/// inside `std::panic::catch_unwind` and converted to this value instead.
56const XALEN_FFI_PANIC: c_int = -99;
57/// Floating-point variant of [`XALEN_FFI_PANIC`] for functions that return a
58/// `c_double`. Distinct from the ordinary `-1.0` / `-2.0` error sentinels so a
59/// caught panic is distinguishable from a normal invalid-argument rejection.
60const XALEN_FFI_PANIC_F64: c_double = -99.0;
61
62/// Run `f` and catch any unwinding panic, returning `sentinel` instead of
63/// letting the panic cross the C ABI (which would be UB / abort).
64fn guard_int(f: impl FnOnce() -> c_int + std::panic::UnwindSafe, sentinel: c_int) -> c_int {
65    std::panic::catch_unwind(f).unwrap_or(sentinel)
66}
67
68/// Like [`guard_int`] for `c_double`-returning entrypoints.
69fn guard_f64(
70    f: impl FnOnce() -> c_double + std::panic::UnwindSafe,
71    sentinel: c_double,
72) -> c_double {
73    std::panic::catch_unwind(f).unwrap_or(sentinel)
74}
75
76/// Initialize the XALEN Ephemeris library. Thread-safe, idempotent.
77///
78/// Returns 0 on success, or [`XALEN_FFI_PANIC`] if an internal panic was caught.
79#[unsafe(no_mangle)]
80pub extern "C" fn xalen_init() -> c_int {
81    guard_int(
82        || {
83            let _ = get_almanac();
84            0
85        },
86        XALEN_FFI_PANIC,
87    )
88}
89
90/// Map a body integer ID to the Body enum.
91/// Canonical mapping:
92/// 0=Sun, 1=Moon, 2=Mercury, 3=Venus, 4=Mars, 5=Jupiter, 6=Saturn,
93/// 7=Uranus, 8=Neptune, 9=Rahu/MeanNode, 10=TrueNode, 11=Pluto,
94/// 12=Chiron, 13=Ketu (computed as Rahu+180)
95fn body_from_id(body_id: c_int) -> Option<Body> {
96    match body_id {
97        0 => Some(Body::Sun),
98        1 => Some(Body::Moon),
99        2 => Some(Body::Mercury),
100        3 => Some(Body::Venus),
101        4 => Some(Body::Mars),
102        5 => Some(Body::Jupiter),
103        6 => Some(Body::Saturn),
104        7 => Some(Body::Uranus),
105        8 => Some(Body::Neptune),
106        9 => Some(Body::MeanNode),
107        10 => Some(Body::TrueNode),
108        11 => Some(Body::Pluto),
109        12 => Some(Body::Chiron),
110        // 13 = Ketu handled specially by callers
111        _ => None,
112    }
113}
114
115/// Map an ayanamsa integer ID to the Ayanamsa enum.
116/// Canonical mapping:
117/// 0=Lahiri, 1=KP, 2=Raman, 3=FaganBradley, 4=TrueChitra, 5=TrueRevati,
118/// 6=SuryaSiddhanta, 7=Yukteswar, 8=JNBhasin, 9=DeLuce, 10=Ushashashi,
119/// 11=PushyaPaksha, 12=GalacticCenter, 13=LahiriICRC, 14=KPStraightLine,
120/// 15=Hipparchos, 16=LahiriVP285
121fn ayanamsa_from_id(id: c_int) -> Option<Ayanamsa> {
122    match id {
123        0 => Some(Ayanamsa::Lahiri),
124        1 => Some(Ayanamsa::KPKrishnamurti),
125        2 => Some(Ayanamsa::Raman),
126        3 => Some(Ayanamsa::FaganBradley),
127        4 => Some(Ayanamsa::TrueChitra),
128        5 => Some(Ayanamsa::TrueRevati),
129        6 => Some(Ayanamsa::SuryaSiddhanta),
130        7 => Some(Ayanamsa::YukteswarSriSS),
131        8 => Some(Ayanamsa::JNBhasin),
132        9 => Some(Ayanamsa::DeLuce),
133        10 => Some(Ayanamsa::Ushashashi),
134        11 => Some(Ayanamsa::PushyaPaksha),
135        12 => Some(Ayanamsa::GalacticCenter0Sag),
136        13 => Some(Ayanamsa::LahiriICRC),
137        14 => Some(Ayanamsa::KPStraightLine),
138        15 => Some(Ayanamsa::Hipparchos),
139        16 => Some(Ayanamsa::LahiriVP285),
140        _ => None,
141    }
142}
143
144/// Map a house system integer ID to the HouseSystem enum.
145/// Canonical mapping:
146/// 0=WholeSign, 1=Equal, 2=Placidus, 3=Koch, 4=Porphyry, 5=Regiomontanus,
147/// 6=Campanus, 7=Morinus, 8=Alcabitius, 9=Topocentric, 10=Sripati,
148/// 11=Vehlow, 12=Meridian, 13=Krusinski
149fn house_system_from_id(id: c_int) -> Option<HouseSystem> {
150    match id {
151        0 => Some(HouseSystem::WholeSign),
152        1 => Some(HouseSystem::Equal),
153        2 => Some(HouseSystem::Placidus),
154        3 => Some(HouseSystem::Koch),
155        4 => Some(HouseSystem::Porphyry),
156        5 => Some(HouseSystem::Regiomontanus),
157        6 => Some(HouseSystem::Campanus),
158        7 => Some(HouseSystem::Morinus),
159        8 => Some(HouseSystem::Alcabitius),
160        9 => Some(HouseSystem::Topocentric),
161        10 => Some(HouseSystem::Sripati),
162        11 => Some(HouseSystem::Vehlow),
163        12 => Some(HouseSystem::Meridian),
164        13 => Some(HouseSystem::KrusinskiPisa),
165        _ => None,
166    }
167}
168
169/// Compute geocentric tropical longitude of a planet.
170/// body: 0=Sun, 1=Moon, 2=Mercury, 3=Venus, 4=Mars, 5=Jupiter, 6=Saturn,
171///       7=Uranus, 8=Neptune, 9=Rahu/MeanNode, 10=TrueNode, 11=Pluto,
172///       12=Chiron, 13=Ketu (Rahu+180)
173///
174/// # Safety
175/// `out` must be a valid, writable pointer to a `XalenPosition` (or null, which
176/// returns -1). The function zero-initializes `*out` before use.
177#[unsafe(no_mangle)]
178pub unsafe extern "C" fn xalen_planet_position(
179    jd_ut1: c_double,
180    body_id: c_int,
181    out: *mut XalenPosition,
182) -> c_int {
183    if out.is_null() {
184        return -1;
185    }
186
187    // Zero-initialize output struct so callers never see stale data on error paths.
188    unsafe {
189        std::ptr::write_bytes(out, 0, 1);
190    }
191
192    // Run the body inside catch_unwind so a panic in the math layer becomes the
193    // XALEN_FFI_PANIC sentinel instead of unwinding across the C ABI (UB/abort).
194    // `out` is captured behind AssertUnwindSafe: we only write through it, and on
195    // a caught panic we stamp the panic status below.
196    let out_guard = std::panic::AssertUnwindSafe(out);
197    let result = std::panic::catch_unwind(move || {
198        let out = out_guard.0;
199        // Reject non-finite (NaN/Inf) Julian Day before any computation.
200        if !jd_ut1.is_finite() {
201            unsafe {
202                (*out).status = -2;
203            }
204            return -2;
205        }
206
207        // Handle Ketu (id 13) as Rahu + 180°. Ketu's ecliptic latitude is the
208        // NEGATION of Rahu's (the South Node sits opposite the North Node on the
209        // ecliptic), so negate it here to stay consistent with the full path in
210        // `xalen_planet_position_full`.
211        if body_id == 13 {
212            let almanac = get_almanac();
213            return match almanac.geocentric_ecliptic(Body::MeanNode, JdUT1(jd_ut1)) {
214                Ok(pos) => {
215                    unsafe {
216                        (*out).longitude_deg =
217                            (pos.longitude.to_degrees() + 180.0).rem_euclid(360.0);
218                        (*out).latitude_deg = -pos.latitude.to_degrees();
219                        (*out).distance_au = pos.distance;
220                        (*out).status = 0;
221                    }
222                    0
223                }
224                Err(_) => {
225                    unsafe {
226                        (*out).status = -3;
227                    }
228                    -3
229                }
230            };
231        }
232
233        let body = match body_from_id(body_id) {
234            Some(b) => b,
235            None => {
236                unsafe {
237                    (*out).status = -2;
238                }
239                return -2;
240            }
241        };
242
243        let almanac = get_almanac();
244        match almanac.geocentric_ecliptic(body, JdUT1(jd_ut1)) {
245            Ok(pos) => {
246                unsafe {
247                    (*out).longitude_deg = pos.longitude.to_degrees().rem_euclid(360.0);
248                    (*out).latitude_deg = pos.latitude.to_degrees();
249                    (*out).distance_au = pos.distance;
250                    (*out).status = 0;
251                }
252                0
253            }
254            Err(_) => {
255                unsafe {
256                    (*out).status = -3;
257                }
258                -3
259            }
260        }
261    });
262
263    match result {
264        Ok(code) => code,
265        Err(_) => {
266            // Panic was caught: record it in the (non-null, already-zeroed) struct.
267            unsafe {
268                (*out).status = XALEN_FFI_PANIC;
269            }
270            XALEN_FFI_PANIC
271        }
272    }
273}
274
275/// Compute the FULL geocentric position of a planet: the six components Swiss
276/// Ephemeris returns from `swe_calc_ut(..., SEFLG_SPEED)` plus a retrograde flag.
277/// This is the high-fidelity counterpart to [`xalen_planet_position`], which
278/// fills only longitude/latitude/distance.
279///
280/// body: 0=Sun .. 12=Chiron, 13=Ketu (Rahu+180). Speeds are daily motion
281/// (degrees/day for longitude/latitude, AU/day for distance). `is_retrograde` is
282/// `1` when the tropical longitude rate is negative, else `0`. For Ketu the speed
283/// and retrograde state are inherited from Rahu (the node it shadows).
284///
285/// # Safety
286/// `out` must be a valid, writable pointer to a `XalenPositionFull` (or null,
287/// which returns -1). The function zero-initializes `*out` before use.
288#[unsafe(no_mangle)]
289pub unsafe extern "C" fn xalen_planet_position_full(
290    jd_ut1: c_double,
291    body_id: c_int,
292    out: *mut XalenPositionFull,
293) -> c_int {
294    if out.is_null() {
295        return -1;
296    }
297
298    // Zero-initialize so callers never see stale data on error paths.
299    unsafe {
300        std::ptr::write_bytes(out, 0, 1);
301    }
302
303    let out_guard = std::panic::AssertUnwindSafe(out);
304    let result = std::panic::catch_unwind(move || {
305        let out = out_guard.0;
306        if !jd_ut1.is_finite() {
307            unsafe {
308                (*out).status = -2;
309            }
310            return -2;
311        }
312
313        // Ketu (id 13) = Rahu position + 180°, sharing Rahu's speed/retrograde.
314        let (body, ketu_shift) = if body_id == 13 {
315            (Body::MeanNode, true)
316        } else {
317            match body_from_id(body_id) {
318                Some(b) => (b, false),
319                None => {
320                    unsafe {
321                        (*out).status = -2;
322                    }
323                    return -2;
324                }
325            }
326        };
327
328        let almanac = get_almanac();
329        let jd = JdUT1(jd_ut1);
330        let pos = match almanac.geocentric_ecliptic(body, jd) {
331            Ok(p) => p,
332            Err(_) => {
333                unsafe {
334                    (*out).status = -3;
335                }
336                return -3;
337            }
338        };
339        let speed = match almanac.geocentric_speed(body, jd) {
340            Ok(s) => s,
341            Err(_) => {
342                unsafe {
343                    (*out).status = -3;
344                }
345                return -3;
346            }
347        };
348
349        let mut longitude = pos.longitude.to_degrees().rem_euclid(360.0);
350        let mut latitude = pos.latitude.to_degrees();
351        if ketu_shift {
352            longitude = (longitude + 180.0).rem_euclid(360.0);
353            latitude = -latitude; // Ketu's ecliptic latitude is opposite Rahu's
354        }
355
356        unsafe {
357            (*out).longitude_deg = longitude;
358            (*out).latitude_deg = latitude;
359            (*out).distance_au = pos.distance;
360            (*out).lon_speed_deg = speed.longitude_deg_per_day();
361            (*out).lat_speed_deg = speed.latitude_deg_per_day();
362            (*out).dist_speed_au = speed.distance;
363            (*out).is_retrograde = c_int::from(speed.longitude < 0.0);
364            (*out).status = 0;
365        }
366        0
367    });
368
369    match result {
370        Ok(code) => code,
371        Err(_) => {
372            unsafe {
373                (*out).status = XALEN_FFI_PANIC;
374            }
375            XALEN_FFI_PANIC
376        }
377    }
378}
379
380/// Compute sidereal longitude with specified ayanamsa.
381/// ayanamsa_id: 0=Lahiri, 1=KP, 2=Raman, 3=FaganBradley, ... 16=LahiriVP285
382/// body_id: 0=Sun .. 12=Chiron, 13=Ketu (Rahu+180)
383/// Returns -1.0 on invalid body_id, -2.0 on invalid ayanamsa_id.
384#[unsafe(no_mangle)]
385pub extern "C" fn xalen_sidereal_longitude(
386    jd_ut1: c_double,
387    body_id: c_int,
388    ayanamsa_id: c_int,
389) -> c_double {
390    guard_f64(
391        move || {
392            // Reject non-finite (NaN/Inf) Julian Day before any computation.
393            if !jd_ut1.is_finite() {
394                return -2.0;
395            }
396
397            let ayanamsa = match ayanamsa_from_id(ayanamsa_id) {
398                Some(a) => a,
399                None => return -2.0,
400            };
401
402            let almanac = get_almanac();
403            let jd = JdUT1(jd_ut1);
404            let tt = jd.to_tt(&xalen_time::DeltaTModel::StephensonMorrisonHohenkerk2016);
405            let aya_deg = ayanamsa.compute_deg(tt.as_f64());
406
407            // Handle Ketu (id 13) as Rahu + 180
408            if body_id == 13 {
409                return match almanac.sidereal_longitude_deg(Body::MeanNode, jd, aya_deg) {
410                    Ok(lon) => (lon + 180.0).rem_euclid(360.0),
411                    Err(_) => -1.0,
412                };
413            }
414
415            let body = match body_from_id(body_id) {
416                Some(b) => b,
417                None => return -1.0,
418            };
419
420            almanac
421                .sidereal_longitude_deg(body, jd, aya_deg)
422                .unwrap_or(-1.0)
423        },
424        XALEN_FFI_PANIC_F64,
425    )
426}
427
428/// Compute house cusps.
429/// system: 0=WholeSign, 1=Equal, 2=Placidus, 3=Koch, 4=Porphyry,
430///         5=Regiomontanus, 6=Campanus, 7=Morinus, 8=Alcabitius,
431///         9=Topocentric, 10=Sripati, 11=Vehlow, 12=Meridian, 13=Krusinski
432/// Returns -2 on invalid system_id.
433///
434/// # Safety
435/// `out` must be a valid, writable pointer to a `XalenHouses` (or null, which
436/// returns -1). The function zero-initializes `*out` before use.
437#[unsafe(no_mangle)]
438pub unsafe extern "C" fn xalen_houses(
439    jd_ut1: c_double,
440    latitude_deg: c_double,
441    longitude_deg: c_double,
442    system_id: c_int,
443    out: *mut XalenHouses,
444) -> c_int {
445    if out.is_null() {
446        return -1;
447    }
448
449    // Zero-initialize output struct so callers never see stale data on error paths.
450    unsafe {
451        std::ptr::write_bytes(out, 0, 1);
452    }
453
454    // Run the math inside catch_unwind: a panic in the house-cusp solver becomes
455    // the XALEN_FFI_PANIC sentinel rather than unwinding across the C ABI.
456    let out_guard = std::panic::AssertUnwindSafe(out);
457    let result = std::panic::catch_unwind(move || {
458        let out = out_guard.0;
459        // Reject non-finite (NaN/Inf) jd/lat/lon and out-of-range latitude before
460        // any math runs. Longitude is intentionally NOT range-bounded: it is
461        // periodic and legitimate callers may pass values outside [-180, 360].
462        if !jd_ut1.is_finite()
463            || !latitude_deg.is_finite()
464            || !longitude_deg.is_finite()
465            || !(-90.0..=90.0).contains(&latitude_deg)
466        {
467            unsafe {
468                (*out).status = -2;
469            }
470            return -2;
471        }
472
473        let system = match house_system_from_id(system_id) {
474            Some(s) => s,
475            None => {
476                unsafe {
477                    (*out).status = -2;
478                }
479                return -2;
480            }
481        };
482
483        let loc = GeoLocation::new(latitude_deg, longitude_deg);
484        let t = (jd_ut1 - 2_451_545.0) / 36525.0;
485        let epsilon = mean_obliquity(t);
486        let houses = compute_houses(jd_ut1, &loc, epsilon, system);
487
488        unsafe {
489            for i in 0..12 {
490                (*out).cusps[i] = houses.cusp_deg(i);
491            }
492            (*out).ascendant_deg = houses.ascendant.to_degrees().rem_euclid(360.0);
493            (*out).mc_deg = houses.mc.to_degrees().rem_euclid(360.0);
494            (*out).status = 0;
495        }
496        0
497    });
498
499    match result {
500        Ok(code) => code,
501        Err(_) => {
502            unsafe {
503                (*out).status = XALEN_FFI_PANIC;
504            }
505            XALEN_FFI_PANIC
506        }
507    }
508}
509
510/// Get the XALEN Ephemeris version string.
511///
512/// Returns a pointer to a static, NUL-terminated string. Returns a null pointer
513/// only if an internal panic was caught (it cannot happen in practice — the
514/// value is a compile-time constant — but the guard keeps the no-unwind-across-
515/// the-C-ABI invariant uniform across every entrypoint).
516#[unsafe(no_mangle)]
517pub extern "C" fn xalen_version() -> *const c_char {
518    // Sourced from the crate version so it can never drift from Cargo.toml; the
519    // original literal was stuck at "0.1.0".
520    let ptr = std::panic::catch_unwind(|| {
521        concat!("XALEN Ephemeris ", env!("CARGO_PKG_VERSION"), "\0").as_ptr() as usize
522    })
523    .unwrap_or(0);
524    ptr as *const c_char
525}
526
527/// Compute ayanamsa value in degrees for a given JD.
528/// Returns -1.0 on an invalid `ayanamsa_id` or a non-finite `jd_ut1`.
529#[unsafe(no_mangle)]
530pub extern "C" fn xalen_ayanamsa(jd_ut1: c_double, ayanamsa_id: c_int) -> c_double {
531    guard_f64(
532        move || {
533            let ayanamsa = match ayanamsa_from_id(ayanamsa_id) {
534                Some(a) => a,
535                None => return -1.0,
536            };
537            // Guard non-finite input like the other FFI entrypoints, so NaN/Inf
538            // can never silently return a non-finite ayanamsa instead of -1.0.
539            if !jd_ut1.is_finite() {
540                return -1.0;
541            }
542            let jd = JdUT1(jd_ut1);
543            let tt = jd.to_tt(&xalen_time::DeltaTModel::StephensonMorrisonHohenkerk2016);
544            ayanamsa.compute_deg(tt.as_f64())
545        },
546        XALEN_FFI_PANIC_F64,
547    )
548}
549
550#[cfg(test)]
551mod tests {
552    use super::*;
553
554    #[test]
555    fn init_succeeds() {
556        assert_eq!(xalen_init(), 0);
557    }
558
559    #[test]
560    fn planet_position_sun() {
561        xalen_init();
562        let mut pos = XalenPosition {
563            longitude_deg: 0.0,
564            latitude_deg: 0.0,
565            distance_au: 0.0,
566            status: -1,
567        };
568        let ret = unsafe { xalen_planet_position(2451545.0, 0, &mut pos) };
569        assert_eq!(ret, 0);
570        assert_eq!(pos.status, 0);
571        assert!(pos.longitude_deg >= 0.0 && pos.longitude_deg < 360.0);
572    }
573
574    #[test]
575    fn all_body_ids_valid() {
576        xalen_init();
577        // 0=Sun through 12=Chiron (real bodies)
578        for id in 0..=12 {
579            let mut pos = XalenPosition {
580                longitude_deg: 0.0,
581                latitude_deg: 0.0,
582                distance_au: 0.0,
583                status: -1,
584            };
585            let ret = unsafe { xalen_planet_position(2451545.0, id, &mut pos) };
586            assert_eq!(ret, 0, "Body ID {id} should succeed");
587            assert!(
588                pos.longitude_deg >= 0.0 && pos.longitude_deg < 360.0,
589                "Body {id} longitude should be [0,360), got {}",
590                pos.longitude_deg
591            );
592        }
593    }
594
595    #[test]
596    fn ketu_body_id_13() {
597        xalen_init();
598        let mut rahu_pos = XalenPosition {
599            longitude_deg: 0.0,
600            latitude_deg: 0.0,
601            distance_au: 0.0,
602            status: -1,
603        };
604        unsafe { xalen_planet_position(2451545.0, 9, &mut rahu_pos) }; // Rahu
605        let mut ketu_pos = XalenPosition {
606            longitude_deg: 0.0,
607            latitude_deg: 0.0,
608            distance_au: 0.0,
609            status: -1,
610        };
611        let ret = unsafe { xalen_planet_position(2451545.0, 13, &mut ketu_pos) }; // Ketu
612        assert_eq!(ret, 0, "Ketu (body 13) should succeed");
613        let expected = (rahu_pos.longitude_deg + 180.0).rem_euclid(360.0);
614        assert!(
615            (ketu_pos.longitude_deg - expected).abs() < 1e-10,
616            "Ketu should be Rahu+180: expected {expected}, got {}",
617            ketu_pos.longitude_deg
618        );
619        // Ketu's ecliptic latitude must be the NEGATION of Rahu's — the South
620        // Node sits opposite the North Node. This legacy path used to copy
621        // Rahu's latitude unchanged (sign bug), diverging from the full path.
622        assert!(
623            (ketu_pos.latitude_deg + rahu_pos.latitude_deg).abs() < 1e-10,
624            "Ketu latitude {} should be −Rahu latitude {}",
625            ketu_pos.latitude_deg,
626            rahu_pos.latitude_deg
627        );
628    }
629
630    #[test]
631    fn planet_position_full_six_tuple_and_speed() {
632        xalen_init();
633        let mut p = XalenPositionFull {
634            longitude_deg: 0.0,
635            latitude_deg: 0.0,
636            distance_au: 0.0,
637            lon_speed_deg: 0.0,
638            lat_speed_deg: 0.0,
639            dist_speed_au: 0.0,
640            is_retrograde: -1,
641            status: -1,
642        };
643        let ret = unsafe { xalen_planet_position_full(2451545.0, 0, &mut p) }; // Sun
644        assert_eq!(ret, 0);
645        assert_eq!(p.status, 0);
646        assert!(p.longitude_deg >= 0.0 && p.longitude_deg < 360.0);
647        // Validated vs pyswisseph swe.calc_ut(J2000, SUN, FLG_SWIEPH|FLG_SPEED):
648        //   lon_speed = 1.019432 deg/day, distance = 0.98332764 AU.
649        assert!(
650            (p.lon_speed_deg - 1.019432).abs() < 0.01,
651            "Sun lon_speed {} vs pyswisseph 1.019432",
652            p.lon_speed_deg
653        );
654        assert!(
655            (p.distance_au - 0.98332764).abs() < 1e-3,
656            "Sun distance {} vs pyswisseph 0.98332764",
657            p.distance_au
658        );
659        assert_eq!(p.is_retrograde, 0, "Sun is never retrograde");
660    }
661
662    #[test]
663    fn planet_position_full_node_retrograde_and_ketu() {
664        xalen_init();
665        // Mean node (id 9): always retrograde, negative lon_speed.
666        let mut rahu = XalenPositionFull {
667            longitude_deg: 0.0,
668            latitude_deg: 0.0,
669            distance_au: 0.0,
670            lon_speed_deg: 0.0,
671            lat_speed_deg: 0.0,
672            dist_speed_au: 0.0,
673            is_retrograde: -1,
674            status: -1,
675        };
676        let ret = unsafe { xalen_planet_position_full(2451545.0, 9, &mut rahu) };
677        assert_eq!(ret, 0);
678        assert_eq!(rahu.is_retrograde, 1, "mean node is always retrograde");
679        assert!(
680            rahu.lon_speed_deg < 0.0,
681            "node lon_speed {} should be < 0",
682            rahu.lon_speed_deg
683        );
684
685        // Ketu (id 13) = Rahu + 180, sharing Rahu's speed/retrograde state.
686        let mut ketu = XalenPositionFull {
687            longitude_deg: 0.0,
688            latitude_deg: 0.0,
689            distance_au: 0.0,
690            lon_speed_deg: 0.0,
691            lat_speed_deg: 0.0,
692            dist_speed_au: 0.0,
693            is_retrograde: -1,
694            status: -1,
695        };
696        let ret = unsafe { xalen_planet_position_full(2451545.0, 13, &mut ketu) };
697        assert_eq!(ret, 0);
698        let expected = (rahu.longitude_deg + 180.0).rem_euclid(360.0);
699        assert!(
700            (ketu.longitude_deg - expected).abs() < 1e-9,
701            "Ketu lon {} != Rahu+180 {}",
702            ketu.longitude_deg,
703            expected
704        );
705        // Ketu's ecliptic latitude is the negation of Rahu's; both FFI paths
706        // (this full one and the legacy xalen_planet_position) must agree.
707        assert!(
708            (ketu.latitude_deg + rahu.latitude_deg).abs() < 1e-9,
709            "Ketu latitude {} should be −Rahu latitude {}",
710            ketu.latitude_deg,
711            rahu.latitude_deg
712        );
713        assert_eq!(
714            ketu.is_retrograde, rahu.is_retrograde,
715            "Ketu shares Rahu retrograde"
716        );
717    }
718
719    #[test]
720    fn planet_position_full_rejects_bad_input() {
721        xalen_init();
722        // Null pointer.
723        let ret = unsafe { xalen_planet_position_full(2451545.0, 0, std::ptr::null_mut()) };
724        assert_eq!(ret, -1);
725        // Non-finite jd and invalid body id.
726        let mut p = XalenPositionFull {
727            longitude_deg: 0.0,
728            latitude_deg: 0.0,
729            distance_au: 0.0,
730            lon_speed_deg: 0.0,
731            lat_speed_deg: 0.0,
732            dist_speed_au: 0.0,
733            is_retrograde: -1,
734            status: 0,
735        };
736        assert_eq!(
737            unsafe { xalen_planet_position_full(f64::NAN, 0, &mut p) },
738            -2
739        );
740        assert_eq!(p.status, -2);
741        assert_eq!(
742            unsafe { xalen_planet_position_full(2451545.0, 99, &mut p) },
743            -2
744        );
745        assert_eq!(p.status, -2);
746    }
747
748    #[test]
749    fn sidereal_longitude_reasonable() {
750        xalen_init();
751        let lon = xalen_sidereal_longitude(2451545.0, 0, 0); // Sun, Lahiri
752        assert!(
753            lon >= 0.0 && lon < 360.0,
754            "Sidereal Sun should be 0-360°, got {lon}°"
755        );
756    }
757
758    #[test]
759    fn sidereal_all_ayanamsa_ids_valid() {
760        xalen_init();
761        for id in 0..=16 {
762            let lon = xalen_sidereal_longitude(2451545.0, 0, id); // Sun
763            assert!(
764                lon >= 0.0 && lon < 360.0,
765                "Ayanamsa ID {id} should produce valid sidereal lon, got {lon}"
766            );
767        }
768    }
769
770    #[test]
771    fn sidereal_invalid_ayanamsa_returns_error() {
772        let lon = xalen_sidereal_longitude(2451545.0, 0, 99);
773        assert!(
774            lon < 0.0,
775            "Invalid ayanamsa should return negative, got {lon}"
776        );
777    }
778
779    #[test]
780    fn sidereal_ketu_body_13() {
781        xalen_init();
782        let rahu_lon = xalen_sidereal_longitude(2451545.0, 9, 0); // Rahu, Lahiri
783        let ketu_lon = xalen_sidereal_longitude(2451545.0, 13, 0); // Ketu, Lahiri
784        assert!(
785            ketu_lon >= 0.0 && ketu_lon < 360.0,
786            "Ketu sidereal should be [0,360), got {ketu_lon}"
787        );
788        let expected = (rahu_lon + 180.0).rem_euclid(360.0);
789        assert!(
790            (ketu_lon - expected).abs() < 1e-10,
791            "Ketu sidereal should be Rahu+180: expected {expected}, got {ketu_lon}"
792        );
793    }
794
795    #[test]
796    fn houses_compute() {
797        xalen_init();
798        let mut h = XalenHouses {
799            cusps: [0.0; 12],
800            ascendant_deg: 0.0,
801            mc_deg: 0.0,
802            status: -1,
803        };
804        let ret = unsafe { xalen_houses(2451545.0, 18.52, 73.85, 0, &mut h) };
805        assert_eq!(ret, 0);
806        assert_eq!(h.status, 0);
807        assert!(h.ascendant_deg >= 0.0 && h.ascendant_deg < 360.0);
808    }
809
810    #[test]
811    fn houses_all_system_ids_valid() {
812        xalen_init();
813        for id in 0..=13 {
814            let mut h = XalenHouses {
815                cusps: [0.0; 12],
816                ascendant_deg: 0.0,
817                mc_deg: 0.0,
818                status: -1,
819            };
820            let ret = unsafe { xalen_houses(2451545.0, 18.52, 73.85, id, &mut h) };
821            assert_eq!(ret, 0, "House system ID {id} should succeed");
822            assert!(
823                h.ascendant_deg >= 0.0 && h.ascendant_deg < 360.0,
824                "House system {id} should produce valid ascendant"
825            );
826        }
827    }
828
829    #[test]
830    fn houses_invalid_system_returns_error() {
831        let mut h = XalenHouses {
832            cusps: [0.0; 12],
833            ascendant_deg: 0.0,
834            mc_deg: 0.0,
835            status: 0,
836        };
837        let ret = unsafe { xalen_houses(2451545.0, 18.52, 73.85, 99, &mut h) };
838        assert_eq!(ret, -2, "Invalid house system should return -2");
839    }
840
841    #[test]
842    fn invalid_body_returns_error() {
843        let mut pos = XalenPosition {
844            longitude_deg: 0.0,
845            latitude_deg: 0.0,
846            distance_au: 0.0,
847            status: 0,
848        };
849        let ret = unsafe { xalen_planet_position(2451545.0, 99, &mut pos) };
850        assert_eq!(ret, -2);
851    }
852
853    #[test]
854    fn null_pointer_returns_error() {
855        let ret = unsafe { xalen_planet_position(2451545.0, 0, std::ptr::null_mut()) };
856        assert_eq!(ret, -1);
857    }
858
859    #[test]
860    fn ayanamsa_at_j2000() {
861        let aya = xalen_ayanamsa(2451545.0, 0);
862        assert!(
863            (aya - 23.85).abs() < 0.1,
864            "Lahiri at J2000 should be ~23.85°, got {aya}°"
865        );
866    }
867
868    #[test]
869    fn ayanamsa_all_ids_valid() {
870        for id in 0..=16 {
871            let aya = xalen_ayanamsa(2451545.0, id);
872            assert!(
873                aya.is_finite() && aya > 0.0,
874                "Ayanamsa ID {id} should produce finite positive value, got {aya}"
875            );
876        }
877    }
878
879    #[test]
880    fn ayanamsa_invalid_id_returns_error() {
881        let aya = xalen_ayanamsa(2451545.0, 99);
882        assert!(
883            aya < 0.0,
884            "Invalid ayanamsa ID should return negative, got {aya}"
885        );
886    }
887
888    #[test]
889    fn ayanamsa_non_finite_jd_returns_error() {
890        // A non-finite JD (with a VALID id) must return the -1.0 sentinel like
891        // the other FFI entrypoints, not a silently non-finite ayanamsa.
892        assert_eq!(xalen_ayanamsa(f64::NAN, 0), -1.0);
893        assert_eq!(xalen_ayanamsa(f64::INFINITY, 0), -1.0);
894    }
895
896    #[test]
897    fn houses_reject_non_finite_and_out_of_range_lat() {
898        xalen_init();
899        // NaN / Inf jd, lat, lon and out-of-range latitude must all return -2
900        // and leave status == -2. Longitude is periodic, so values like 540.0
901        // are NOT rejected by range (only by non-finiteness).
902        let bad_inputs: [(c_double, c_double, c_double); 9] = [
903            (f64::NAN, 18.52, 73.85),              // NaN jd
904            (f64::INFINITY, 18.52, 73.85),         // Inf jd
905            (2451545.0, f64::NAN, 73.85),          // NaN lat
906            (2451545.0, f64::INFINITY, 73.85),     // Inf lat
907            (2451545.0, 18.52, f64::NAN),          // NaN lon
908            (2451545.0, 18.52, f64::INFINITY),     // +Inf lon
909            (2451545.0, 18.52, f64::NEG_INFINITY), // -Inf lon
910            (2451545.0, 91.0, 73.85),              // lat > 90
911            (2451545.0, -90.5, 73.85),             // lat < -90
912        ];
913        for (jd, lat, lon) in bad_inputs {
914            let mut h = XalenHouses {
915                cusps: [0.0; 12],
916                ascendant_deg: 0.0,
917                mc_deg: 0.0,
918                status: 0,
919            };
920            let ret = unsafe { xalen_houses(jd, lat, lon, 0, &mut h) };
921            assert_eq!(
922                ret, -2,
923                "jd={jd} lat={lat} lon={lon} should be rejected with -2"
924            );
925            assert_eq!(
926                h.status, -2,
927                "status should be -2 for jd={jd} lat={lat} lon={lon}"
928            );
929        }
930
931        // Sanity: out-of-[-180,360] longitude is periodic and MUST still succeed.
932        let mut h = XalenHouses {
933            cusps: [0.0; 12],
934            ascendant_deg: 0.0,
935            mc_deg: 0.0,
936            status: -1,
937        };
938        let ret = unsafe { xalen_houses(2451545.0, 18.52, 540.0, 0, &mut h) };
939        assert_eq!(ret, 0, "Periodic longitude 540.0 should be accepted");
940        assert_eq!(h.status, 0);
941    }
942
943    #[test]
944    fn planet_position_rejects_non_finite_jd() {
945        xalen_init();
946        for jd in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
947            let mut pos = XalenPosition {
948                longitude_deg: 0.0,
949                latitude_deg: 0.0,
950                distance_au: 0.0,
951                status: 0,
952            };
953            let ret = unsafe { xalen_planet_position(jd, 0, &mut pos) };
954            assert_eq!(ret, -2, "Non-finite jd={jd} should return -2");
955            assert_eq!(pos.status, -2, "status should be -2 for jd={jd}");
956        }
957        // Ketu (id 13) path must also reject non-finite jd.
958        let mut pos = XalenPosition {
959            longitude_deg: 0.0,
960            latitude_deg: 0.0,
961            distance_au: 0.0,
962            status: 0,
963        };
964        let ret = unsafe { xalen_planet_position(f64::NAN, 13, &mut pos) };
965        assert_eq!(ret, -2, "Non-finite jd on Ketu path should return -2");
966        assert_eq!(pos.status, -2);
967    }
968
969    #[test]
970    fn sidereal_longitude_rejects_non_finite_jd() {
971        xalen_init();
972        for jd in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
973            let lon = xalen_sidereal_longitude(jd, 0, 0);
974            assert_eq!(lon, -2.0, "Non-finite jd={jd} should return -2.0");
975        }
976    }
977
978    #[test]
979    fn panic_sentinels_are_distinct_from_normal_errors() {
980        // The caught-panic sentinels must not collide with the ordinary
981        // invalid-argument sentinels (-1/-2/-3 for ints, -1.0/-2.0 for f64),
982        // so a caught panic is always distinguishable from a normal rejection.
983        assert_ne!(XALEN_FFI_PANIC, -1);
984        assert_ne!(XALEN_FFI_PANIC, -2);
985        assert_ne!(XALEN_FFI_PANIC, -3);
986        // Compare floats by distance to dodge clippy::float_cmp under -D warnings.
987        assert!((XALEN_FFI_PANIC_F64 - (-1.0)).abs() > 0.5);
988        assert!((XALEN_FFI_PANIC_F64 - (-2.0)).abs() > 0.5);
989    }
990
991    #[test]
992    fn catch_unwind_wrappers_preserve_normal_returns() {
993        // Sanity: wrapping the bodies in catch_unwind must not change the happy
994        // path. A valid call still returns 0 / a finite value.
995        assert_eq!(xalen_init(), 0);
996        let mut pos = XalenPosition {
997            longitude_deg: 0.0,
998            latitude_deg: 0.0,
999            distance_au: 0.0,
1000            status: -1,
1001        };
1002        assert_eq!(unsafe { xalen_planet_position(2451545.0, 0, &mut pos) }, 0);
1003        assert_eq!(pos.status, 0);
1004        let aya = xalen_ayanamsa(2451545.0, 0);
1005        assert!(aya.is_finite() && aya > 0.0);
1006        let lon = xalen_sidereal_longitude(2451545.0, 0, 0);
1007        assert!(lon >= 0.0 && lon < 360.0);
1008    }
1009
1010    #[test]
1011    fn version_string() {
1012        let v = xalen_version();
1013        assert!(!v.is_null());
1014        let s = unsafe { CStr::from_ptr(v) }.to_str().unwrap();
1015        assert!(
1016            s.contains("XALEN"),
1017            "Version should contain XALEN, got '{s}'"
1018        );
1019        // The version must track the crate, not the old hard-coded "0.1.0".
1020        assert!(
1021            s.contains(env!("CARGO_PKG_VERSION")),
1022            "Version should contain the crate version {}, got '{s}'",
1023            env!("CARGO_PKG_VERSION")
1024        );
1025    }
1026}