1// SPDX-License-Identifier: GPL-2.0-only
2// Copyright (c) 2025-present FERS Contributors (see AUTHORS.md).
4//! # Safe Rust FFI Wrapper for `libfers`
6//! This module provides a safe, idiomatic Rust interface to the `libfers` C++
7//! simulation library. It bridges the gap between the raw C-style FFI (generated
8//! by `bindgen`) and the higher-level Rust code used in the Tauri application.
10//! ## Safety Guarantees
12//! The module ensures the following safety properties:
14//! 1. **Automatic Resource Management**: All C-allocated resources (contexts, strings)
15//! are wrapped in RAII types that guarantee cleanup via `Drop`.
16//! 2. **Memory Safety**: Raw pointers from C are only dereferenced within `unsafe`
17//! blocks with documented safety invariants.
18//! 3. **String Conversion**: All C strings are converted to Rust `String`s with
19//! proper UTF-8 validation and null-termination handling.
20//! 4. **Thread Safety**: The `FersContext` is marked as `Send + Sync` because it
21//! will be protected by a `Mutex` in the application layer.
25//! All fallible operations return `Result<T, String>`, where the error string
26//! contains a human-readable message retrieved from the C library's thread-local
29use std::ffi::{c_void, CStr, CString};
30use std::os::raw::c_char;
31use std::sync::atomic::{AtomicU64, Ordering};
32use tauri::{AppHandle, Emitter};
34/// Raw FFI bindings generated by `bindgen` from `libfers/api.h`.
36/// This inner module is kept private to prevent direct access to unsafe FFI
37/// functions. It contains the raw C function declarations and opaque struct types
38/// that mirror the C-API header.
42/// All items in this module are `unsafe` to use directly. They require:
43/// * Valid, non-null pointers for all context handles.
44/// * Proper null-termination for all C strings.
45/// * Manual memory management (allocation/deallocation).
47/// The parent module (`fers_api`) provides safe wrappers that enforce these invariants.
49 #![allow(non_upper_case_globals)]
50 #![allow(non_camel_case_types)]
51 #![allow(non_snake_case)]
53 include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
56static LOG_SEQUENCE: AtomicU64 = AtomicU64::new(1);
58/// A smart pointer wrapper for C-allocated strings returned from `libfers`.
60/// This type ensures that the memory allocated by the C library (via `strdup`)
61/// is properly freed when the wrapper goes out of scope, preventing memory leaks.
63/// # Memory Management
65/// The wrapped pointer must have been allocated by a `libfers` API function that
66/// transfers ownership to the caller (e.g., `fers_get_scenario_as_json`). The
67/// `Drop` implementation calls `fers_free_string` to release the memory back to
72/// If the pointer is null, the wrapper treats it as an empty string. This simplifies
73/// error handling in cases where null indicates "no data" rather than an error.
74struct FersOwnedString(*mut c_char);
76impl Drop for FersOwnedString {
77 /// Frees the underlying C string by calling `fers_free_string`.
79 /// This is automatically invoked when the wrapper goes out of scope,
80 /// ensuring that no manual cleanup is required by the caller.
84 /// The pointer must have been allocated by `libfers` (typically via `strdup`)
85 /// and must not have been freed already. The `Drop` trait ensures this is
86 /// called exactly once per instance.
88 if !self.0.is_null() {
89 // SAFETY: The pointer was allocated by `libfers` and is valid until we call `fers_free_string`.
90 // We are the sole owner of this pointer.
91 unsafe { ffi::fers_free_string(self.0) };
97 /// Converts the owned C string to a Rust `String`, consuming the wrapper.
99 /// This method performs UTF-8 validation and copies the string data into a
100 /// Rust-managed `String`. The C-allocated memory is freed after the conversion.
104 /// * `Ok(String)` - The converted string if valid UTF-8.
105 /// * `Err(std::str::Utf8Error)` - If the C string contains invalid UTF-8 bytes.
110 /// let owned = FersOwnedString(some_c_string_ptr);
111 /// match owned.into_string() {
112 /// Ok(s) => println!("Got string: {}", s),
113 /// Err(e) => eprintln!("Invalid UTF-8: {}", e),
116 fn into_string(self) -> Result<String, std::str::Utf8Error> {
117 if self.0.is_null() {
118 return Ok(String::new());
120 // SAFETY: `self.0` is a valid, null-terminated C string from `libfers`.
121 // The `CStr::from_ptr` is safe as long as the pointer is valid.
122 let c_str = unsafe { CStr::from_ptr(self.0) };
123 c_str.to_str().map(|s| s.to_string())
127/// A safe, RAII-style wrapper for the `fers_context_t*` C handle.
129/// This struct encapsulates the lifetime and ownership of a simulation context
130/// created by the `libfers` C++ library. It ensures that:
131/// * The context is created via `fers_context_create` on initialization.
132/// * The context is destroyed via `fers_context_destroy` when dropped.
133/// * The context is never null after successful creation.
137/// This type implements `Send` and `Sync` because the underlying C++ context will
138/// be protected by a `Mutex` in the Tauri application. The C++ `FersContext` class
139/// is not thread-safe, but by serializing all access through Rust's `Mutex`, we
140/// ensure that only one thread can call methods on the context at a time.
145/// use fers_api::FersContext;
147/// let context = FersContext::new().expect("Failed to create context");
148/// context.load_scenario_from_xml_file("scenario.xml")?;
149/// let json = context.get_scenario_as_json()?;
150/// // Context is automatically destroyed when it goes out of scope
152pub struct FersContext {
153 /// The raw pointer to the C++ context object.
155 /// This must be a raw pointer because `fers_context_t` is an opaque struct.
156 /// The `Send` and `Sync` traits are manually implemented because we'll wrap this
157 /// context in a Mutex, ensuring that access to the non-thread-safe C++ object
158 /// is properly synchronized.
159 ptr: *mut ffi::fers_context_t,
162// SAFETY: The FersContext will be protected by a Mutex. All C-API calls on a single
163// context are not guaranteed to be thread-safe by themselves, but by enforcing
164// serialized access through a Mutex, we make its usage safe across threads.
165unsafe impl Send for FersContext {}
166unsafe impl Sync for FersContext {}
168impl Drop for FersContext {
169 /// Destroys the underlying C++ context and frees all associated resources.
171 /// This method is automatically called when the `FersContext` goes out of scope.
172 /// It delegates to `fers_context_destroy`, which is responsible for cleaning up
173 /// the C++ `FersContext` object and its owned `World`.
177 /// The pointer must be valid and non-null. The `Drop` trait guarantees this is
178 /// called exactly once, preventing double-free errors.
180 if !self.ptr.is_null() {
181 // SAFETY: `self.ptr` is a valid handle created by `fers_context_create`.
182 // The `Drop` trait ensures this is called exactly once.
183 unsafe { ffi::fers_context_destroy(self.ptr) };
188/// Retrieves and formats the last error message from the `libfers` C-API.
190/// This helper function is called whenever a C-API function returns an error code.
191/// It queries the thread-local error storage via `fers_get_last_error_message`,
192/// converts the C string to a Rust `String`, and ensures the memory is freed.
196/// A human-readable error message. If no error message is available (null pointer),
197/// a default message is returned. If the error message contains invalid UTF-8, an
198/// error describing the UTF-8 issue is returned instead.
202/// This function is thread-safe because the error storage in `libfers` is thread-local.
203/// Each thread has its own error message buffer.
204fn get_last_error() -> String {
205 // SAFETY: `fers_get_last_error_message` is a thread-safe FFI function.
206 let error_ptr = unsafe { ffi::fers_get_last_error_message() };
207 if error_ptr.is_null() {
208 "An unknown FFI error occurred.".to_string()
210 // The FersOwnedString wrapper ensures the memory is freed.
211 FersOwnedString(error_ptr)
213 .unwrap_or_else(|e| format!("FFI error message contained invalid UTF-8: {}", e))
217fn take_last_warnings() -> Result<Vec<String>, String> {
218 let warnings_ptr = unsafe { ffi::fers_get_last_warning_messages_json() };
219 if warnings_ptr.is_null() {
223 let warnings_json = FersOwnedString(warnings_ptr).into_string().map_err(|e| e.to_string())?;
224 serde_json::from_str(&warnings_json)
225 .map_err(|e| format!("Failed to decode warning payload from libfers: {}", e))
228/// Data structure for simulation progress events emitted to the frontend.
229#[derive(serde::Serialize, Clone)]
230struct ProgressPayload {
236/// Data structure for raw FERS log lines emitted to the frontend.
237#[derive(serde::Serialize, Clone)]
244#[derive(serde::Serialize, serde::Deserialize, Clone, Copy, Debug, Eq, PartialEq)]
245#[serde(rename_all = "UPPERCASE")]
257 fn to_ffi(self) -> ffi::fers_log_level_t {
269 fn from_ffi(level: ffi::fers_log_level_t) -> Self {
283fn log_level_label(level: ffi::fers_log_level_t) -> &'static str {
296pub fn get_log_level() -> LogLevel {
297 let level = unsafe { ffi::fers_get_log_level() };
298 LogLevel::from_ffi(level)
301pub fn set_log_level(level: LogLevel) -> Result<(), String> {
302 let result = unsafe { ffi::fers_configure_logging(level.to_ffi(), std::ptr::null()) };
306 Err(get_last_error())
310/// The C-style callback function registered with the libfers logger.
311extern "C" fn fers_log_callback(
312 level: ffi::fers_log_level_t,
314 user_data: *mut c_void,
316 if user_data.is_null() || line.is_null() {
320 // SAFETY: `user_data` is a leaked AppHandle pointer for the lifetime of the Tauri app.
321 let app_handle = unsafe { &*(user_data as *const AppHandle) };
323 // SAFETY: `line` is guaranteed by the C-API callback contract to be valid for this call.
324 let line_str = unsafe { CStr::from_ptr(line) }.to_string_lossy().into_owned();
326 let payload = LogPayload {
327 sequence: LOG_SEQUENCE.fetch_add(1, Ordering::Relaxed),
328 level: log_level_label(level),
332 let _ = app_handle.emit("fers-log", payload);
335pub fn register_log_callback(app_handle: AppHandle) {
336 let app_handle_ptr = Box::into_raw(Box::new(app_handle)) as *mut c_void;
338 // SAFETY: The callback and leaked AppHandle pointer remain valid for the Tauri app lifetime.
340 ffi::fers_set_log_callback(Some(fers_log_callback), app_handle_ptr);
344/// The C-style callback function passed to `fers_run_simulation`.
346/// This function is invoked by the C++ core to report progress. It reconstructs the
347/// `AppHandle` from the `user_data` pointer and emits a Tauri event to the frontend.
351/// This function is marked `unsafe` because it dereferences raw pointers (`message`, `user_data`).
352/// The caller (the C++ library) must guarantee that `message` is a valid, null-terminated
353/// UTF-8 string and that `user_data` is a valid pointer to an `AppHandle`. The pointer
354/// is only valid for the duration of the `fers_run_simulation` call.
355#[allow(clippy::similar_names)]
356extern "C" fn simulation_progress_callback(
357 message: *const c_char,
360 user_data: *mut c_void,
362 if user_data.is_null() {
365 // SAFETY: This is safe because we know `user_data` is a pointer to the AppHandle,
366 // which is guaranteed to be valid for the lifetime of the simulation call.
367 let app_handle = unsafe { &*(user_data as *const AppHandle) };
369 // SAFETY: `message` is guaranteed by the C-API to be a valid, null-terminated string.
370 let message_str = unsafe { CStr::from_ptr(message) }.to_string_lossy().into_owned();
372 let payload = ProgressPayload { message: message_str, current, total };
374 // Emit the event to the frontend. If this fails, there's little we can do
375 // from the callback, so we just let it panic in debug builds.
377 .emit("simulation-progress", payload)
378 .expect("Failed to emit simulation-progress event");
381/// A safe RAII wrapper for the antenna pattern data returned by the C-API.
382struct FersAntennaPatternData(*mut ffi::fers_antenna_pattern_data_t);
383impl Drop for FersAntennaPatternData {
385 if !self.0.is_null() {
386 // SAFETY: The pointer is valid and owned by this struct.
387 unsafe { ffi::fers_free_antenna_pattern_data(self.0) };
392/// Data structure for antenna pattern data sent to the frontend.
394/// This struct flattens the 2D gain data into a 1D vector for easy serialization.
395#[derive(serde::Serialize, std::fmt::Debug)]
396pub struct AntennaPatternData {
397 /// Flattened array of linear gain values (normalized 0.0 to 1.0).
398 /// Ordered row-major: Elevation rows, then Azimuth columns.
400 /// Number of samples along the azimuth axis (360 degrees).
402 /// Number of samples along the elevation axis (180 degrees).
404 /// The peak linear gain found in the pattern, used for normalization.
408// Helper wrapper for the visual link list
409struct FersVisualLinkList(*mut ffi::fers_visual_link_list_t);
411impl Drop for FersVisualLinkList {
413 if !self.0.is_null() {
414 unsafe { ffi::fers_free_preview_links(self.0) };
419/// Represents a visual link segment for 3D rendering.
421/// This struct maps C-style enums to integers for consumption by the TypeScript frontend.
422#[derive(serde::Serialize, std::fmt::Debug)]
423pub struct VisualLink {
424 /// The type of radio link.
425 /// * `0`: Monostatic (Tx -> Tgt -> Rx, where Tx==Rx)
426 /// * `1`: Bistatic Illuminator (Tx -> Tgt)
427 /// * `2`: Bistatic Scattered (Tgt -> Rx)
428 /// * `3`: Direct Interference (Tx -> Rx)
430 /// The radiometric quality of the link.
431 /// * `0`: Strong (SNR > 0 dB)
432 /// * `1`: Weak (SNR < 0 dB, visible but sub-noise)
434 /// A pre-formatted label string (e.g., "-95 dBm").
436 /// The ID of the start component for this segment.
437 pub source_id: String,
438 /// The ID of the end component for this segment.
440 /// The ID of the original transmitter (useful for scattered paths).
441 pub origin_id: String,
445 /// Creates a new `FersContext` by calling the C-API constructor.
447 /// This function allocates a new C++ `FersContext` object on the heap via
448 /// `fers_context_create`. The returned context is initially empty and must be
449 /// populated by loading a scenario (via `load_scenario_from_xml_file` or
450 /// `update_scenario_from_json`).
454 /// * `Some(FersContext)` - If the context was successfully created.
455 /// * `None` - If allocation failed (e.g., out of memory) or if the C++ constructor
456 /// threw an exception.
461 /// let context = FersContext::new().expect("Failed to create FERS context");
463 pub fn new() -> Option<Self> {
464 // SAFETY: `fers_context_create` is a simple constructor function with no preconditions.
465 let ptr = unsafe { ffi::fers_context_create() };
473 /// Sets the output directory for simulation results.
477 /// * `dir` - A UTF-8 string containing the path to the output directory.
481 /// * `Ok(())` - If the directory was successfully set.
482 /// * `Err(String)` - If an error occurred.
483 pub fn set_output_directory(&self, dir: &str) -> Result<(), String> {
484 let c_dir = CString::new(dir).map_err(|e| e.to_string())?;
485 // SAFETY: We pass a valid context pointer and a null-terminated C string.
486 let result = unsafe { ffi::fers_set_output_directory(self.ptr, c_dir.as_ptr()) };
490 Err(get_last_error())
494 /// Loads a FERS scenario from an XML file into the context.
496 /// This method replaces any existing scenario in the context with the one parsed
497 /// from the specified file. The XML is validated against the FERS schema if
498 /// validation is enabled in the C++ library.
502 /// * `filepath` - A UTF-8 string containing the absolute or relative path to the
503 /// FERS XML scenario file.
507 /// * `Ok(())` - If the scenario was successfully loaded and parsed.
508 /// * `Err(String)` - If the file could not be read, the XML was invalid, or a
509 /// C++ exception was thrown. The error string contains details.
514 /// context.load_scenario_from_xml_file("/path/to/scenario.xml")?;
516 pub fn load_scenario_from_xml_file(&self, filepath: &str) -> Result<Vec<String>, String> {
517 let c_filepath = CString::new(filepath).map_err(|e| e.to_string())?;
518 // SAFETY: We pass a valid context pointer and a null-terminated C string.
519 // The function returns 0 on success.
521 unsafe { ffi::fers_load_scenario_from_xml_file(self.ptr, c_filepath.as_ptr(), 1) };
525 Err(get_last_error())
529 /// Retrieves the current in-memory scenario as a JSON string.
531 /// This method serializes the C++ `World` object into JSON format, which mirrors
532 /// the structure used by the frontend. It is typically used to populate the UI
533 /// after loading a scenario from XML.
537 /// * `Ok(String)` - The JSON representation of the scenario.
538 /// * `Err(String)` - If serialization failed or the JSON contains invalid UTF-8.
540 /// # Memory Management
542 /// The returned string is a Rust-owned `String`. The underlying C-allocated memory
543 /// is automatically freed by the `FersOwnedString` wrapper.
548 /// let json = context.get_scenario_as_json()?;
549 /// let scenario: serde_json::Value = serde_json::from_str(&json)?;
551 pub fn get_scenario_as_json(&self) -> Result<String, String> {
552 // SAFETY: We pass a valid context pointer. The function returns a C string
553 // that we must free.
554 let json_ptr = unsafe { ffi::fers_get_scenario_as_json(self.ptr) };
555 if json_ptr.is_null() {
556 return Err(get_last_error());
558 // FersOwnedString takes ownership and will free the memory on drop.
559 FersOwnedString(json_ptr).into_string().map_err(|e| e.to_string())
562 /// Retrieves the current in-memory scenario as a FERS XML string.
564 /// This method serializes the C++ `World` object into the standard FERS XML format.
565 /// It is typically used when the user wants to export the scenario (potentially
566 /// modified in the UI) back to a file.
570 /// * `Ok(String)` - The XML representation of the scenario.
571 /// * `Err(String)` - If serialization failed or the XML contains invalid UTF-8.
573 /// # Memory Management
575 /// The returned string is a Rust-owned `String`. The underlying C-allocated memory
576 /// is automatically freed by the `FersOwnedString` wrapper.
581 /// let xml = context.get_scenario_as_xml()?;
582 /// std::fs::write("exported_scenario.xml", xml)?;
584 pub fn get_scenario_as_xml(&self) -> Result<String, String> {
585 // SAFETY: We pass a valid context pointer. The function returns a C string
586 // that we must free.
587 let xml_ptr = unsafe { ffi::fers_get_scenario_as_xml(self.ptr) };
588 if xml_ptr.is_null() {
589 return Err(get_last_error());
591 // FersOwnedString takes ownership and will free the memory on drop.
592 FersOwnedString(xml_ptr).into_string().map_err(|e| e.to_string())
595 /// Updates the in-memory scenario from a JSON string.
597 /// This method is the primary way for the UI to push modified scenario data back
598 /// to the C++ simulation engine. It deserializes the JSON and rebuilds the internal
599 /// `World` object, replacing any existing scenario.
603 /// * `json` - A UTF-8 JSON string representing the scenario. The structure must
604 /// match the schema expected by `libfers` (the same format returned by
605 /// `get_scenario_as_json`).
609 /// * `Ok(())` - If the scenario was successfully deserialized and loaded.
610 /// * `Err(String)` - If the JSON was malformed, contained invalid data, or a C++
611 /// exception was thrown. The error string contains details.
616 /// let modified_json = /* JSON from UI */;
617 /// context.update_scenario_from_json(&modified_json)?;
619 pub fn update_scenario_from_json(&self, json: &str) -> Result<Vec<String>, String> {
620 let c_json = CString::new(json).map_err(|e| e.to_string())?;
621 // SAFETY: We pass a valid context pointer and a null-terminated C string.
622 // The function returns 0 on success.
623 let result = unsafe { ffi::fers_update_scenario_from_json(self.ptr, c_json.as_ptr()) };
627 Err(get_last_error())
631 /// Updates a single platform's paths and name from JSON.
632 pub fn update_platform_from_json(
636 ) -> Result<Vec<String>, String> {
637 let id = id_str.parse::<u64>().map_err(|e| format!("Invalid ID: {}", e))?;
638 let c_json = CString::new(json).map_err(|e| e.to_string())?;
639 let result = unsafe { ffi::fers_update_platform_from_json(self.ptr, id, c_json.as_ptr()) };
643 Err(get_last_error())
647 /// Updates the global simulation parameters from JSON.
648 pub fn update_parameters_from_json(&self, json: &str) -> Result<Vec<String>, String> {
649 let c_json = CString::new(json).map_err(|e| e.to_string())?;
650 let result = unsafe { ffi::fers_update_parameters_from_json(self.ptr, c_json.as_ptr()) };
654 Err(get_last_error())
658 /// Updates a single antenna from JSON.
659 pub fn update_antenna_from_json(&self, json: &str) -> Result<(), String> {
660 let c_json = CString::new(json).map_err(|e| e.to_string())?;
661 let result = unsafe { ffi::fers_update_antenna_from_json(self.ptr, c_json.as_ptr()) };
665 Err(get_last_error())
669 /// Updates a single waveform from JSON.
670 pub fn update_waveform_from_json(&self, json: &str) -> Result<(), String> {
671 let c_json = CString::new(json).map_err(|e| e.to_string())?;
672 let result = unsafe { ffi::fers_update_waveform_from_json(self.ptr, c_json.as_ptr()) };
676 Err(get_last_error())
680 /// Updates a single transmitter from JSON.
681 pub fn update_transmitter_from_json(&self, id_str: &str, json: &str) -> Result<(), String> {
682 let id = id_str.parse::<u64>().map_err(|e| format!("Invalid ID: {}", e))?;
683 let c_json = CString::new(json).map_err(|e| e.to_string())?;
685 unsafe { ffi::fers_update_transmitter_from_json(self.ptr, id, c_json.as_ptr()) };
689 Err(get_last_error())
693 /// Updates a single receiver from JSON.
694 pub fn update_receiver_from_json(&self, id_str: &str, json: &str) -> Result<(), String> {
695 let id = id_str.parse::<u64>().map_err(|e| format!("Invalid ID: {}", e))?;
696 let c_json = CString::new(json).map_err(|e| e.to_string())?;
697 let result = unsafe { ffi::fers_update_receiver_from_json(self.ptr, id, c_json.as_ptr()) };
701 Err(get_last_error())
705 /// Updates a single target from JSON.
706 pub fn update_target_from_json(&self, id_str: &str, json: &str) -> Result<(), String> {
707 let id = id_str.parse::<u64>().map_err(|e| format!("Invalid ID: {}", e))?;
708 let c_json = CString::new(json).map_err(|e| e.to_string())?;
709 let result = unsafe { ffi::fers_update_target_from_json(self.ptr, id, c_json.as_ptr()) };
713 Err(get_last_error())
717 /// Updates a monostatic radar from JSON.
718 pub fn update_monostatic_from_json(&self, json: &str) -> Result<(), String> {
719 let c_json = CString::new(json).map_err(|e| e.to_string())?;
720 let result = unsafe { ffi::fers_update_monostatic_from_json(self.ptr, c_json.as_ptr()) };
724 Err(get_last_error())
728 /// Updates a single timing source from JSON.
729 pub fn update_timing_from_json(&self, id_str: &str, json: &str) -> Result<(), String> {
730 let id = id_str.parse::<u64>().map_err(|e| format!("Invalid ID: {}", e))?;
731 let c_json = CString::new(json).map_err(|e| e.to_string())?;
732 let result = unsafe { ffi::fers_update_timing_from_json(self.ptr, id, c_json.as_ptr()) };
736 Err(get_last_error())
740 /// Runs the simulation defined in the context.
742 /// This is a blocking call that executes the simulation on a separate thread pool
743 /// managed by the C++ core. It accepts a Tauri `AppHandle` to enable progress
744 /// reporting via events.
748 /// * `app_handle` - A reference to the Tauri application handle, used for emitting events.
752 /// * `Ok(String)` - If the simulation completed successfully, containing output metadata JSON.
753 /// * `Err(String)` - If the simulation failed.
754 pub fn run_simulation(&self, app_handle: &AppHandle) -> Result<String, String> {
755 // The AppHandle is passed as a raw pointer through the `user_data` argument.
756 // This is safe because this function is blocking, and the app_handle reference
757 // will be valid for the entire duration of the C++ call.
758 let user_data_ptr = app_handle as *const _ as *mut c_void;
760 // SAFETY: We pass a valid context pointer, a valid function pointer for the callback,
761 // and a valid user_data pointer that points to the AppHandle.
762 let result = unsafe {
763 ffi::fers_run_simulation(self.ptr, Some(simulation_progress_callback), user_data_ptr)
767 self.get_last_output_metadata_json()
769 Err(get_last_error())
773 /// Retrieves JSON metadata for the most recent simulation output files.
774 pub fn get_last_output_metadata_json(&self) -> Result<String, String> {
775 // SAFETY: We pass a valid context pointer. The returned C string is owned by the caller.
776 let metadata_ptr = unsafe { ffi::fers_get_last_output_metadata_json(self.ptr) };
777 if metadata_ptr.is_null() {
778 return Err(get_last_error());
780 FersOwnedString(metadata_ptr).into_string().map_err(|e| e.to_string())
783 /// Generates a KML file for the current scenario.
787 /// * `output_path` - The path where the KML file will be saved.
791 /// * `Ok(())` - If the KML file was generated successfully.
792 /// * `Err(String)` - If KML generation failed.
793 pub fn generate_kml(&self, output_path: &str) -> Result<(), String> {
794 let c_output_path = CString::new(output_path).map_err(|e| e.to_string())?;
795 // SAFETY: We pass a valid context pointer and a null-terminated C string for the path.
796 let result = unsafe { ffi::fers_generate_kml(self.ptr, c_output_path.as_ptr()) };
800 Err(get_last_error())
804 /// Retrieves a sampled gain pattern for a specified antenna.
808 /// * `antenna_id` - The ID of the antenna asset to sample.
809 /// * `az_samples` - The resolution along the azimuth axis.
810 /// * `el_samples` - The resolution along the elevation axis.
811 /// * `frequency` - The frequency in Hz to use for calculation.
815 /// * `Ok(AntennaPatternData)` - If the pattern was successfully sampled.
816 /// * `Err(String)` - If the antenna was not found or an error occurred.
817 pub fn get_antenna_pattern(
823 ) -> Result<AntennaPatternData, String> {
824 let antenna_id_val = antenna_id
826 .map_err(|e| format!("Invalid antenna ID '{antenna_id}': {e}"))?;
827 // SAFETY: We pass a valid context pointer and valid arguments.
828 let result_ptr = unsafe {
829 ffi::fers_get_antenna_pattern(
838 if result_ptr.is_null() {
839 return Err(get_last_error());
842 let owned_data = FersAntennaPatternData(result_ptr);
844 // SAFETY: Dereferencing the non-null pointer returned by the FFI.
845 // The data is valid for the lifetime of `owned_data`.
846 let (gains_ptr, az_count, el_count, max_gain) = unsafe {
848 (*owned_data.0).gains,
849 (*owned_data.0).az_count,
850 (*owned_data.0).el_count,
851 (*owned_data.0).max_gain,
854 let total_samples = az_count * el_count;
856 // SAFETY: The gains_ptr is valid for `total_samples` elements.
857 let gains_slice = unsafe { std::slice::from_raw_parts(gains_ptr, total_samples) };
859 Ok(AntennaPatternData { gains: gains_slice.to_vec(), az_count, el_count, max_gain })
862 pub fn calculate_preview_links(&self, time: f64) -> Result<Vec<VisualLink>, String> {
863 let list_ptr = unsafe { ffi::fers_calculate_preview_links(self.ptr, time) };
864 if list_ptr.is_null() {
865 let err_msg = get_last_error();
866 // If we receive a NULL ptr but no error message, it might be a logic error
867 // or an unhandled edge case in C++. We default to the retrieved message.
871 let owned_list = FersVisualLinkList(list_ptr);
872 let count = unsafe { (*owned_list.0).count };
873 let links_ptr = unsafe { (*owned_list.0).links };
875 let mut result = Vec::with_capacity(count);
876 if count > 0 && !links_ptr.is_null() {
877 let slice = unsafe { std::slice::from_raw_parts(links_ptr, count) };
879 let link_type_val = match l.type_ {
880 ffi::fers_link_type_t_FERS_LINK_MONOSTATIC => 0,
881 ffi::fers_link_type_t_FERS_LINK_BISTATIC_TX_TGT => 1,
882 ffi::fers_link_type_t_FERS_LINK_BISTATIC_TGT_RX => 2,
883 ffi::fers_link_type_t_FERS_LINK_DIRECT_TX_RX => 3,
887 let quality_val = match l.quality {
888 ffi::fers_link_quality_t_FERS_LINK_STRONG => 0,
893 unsafe { CStr::from_ptr(l.label.as_ptr()) }.to_string_lossy().into_owned();
894 let source_id = l.source_id.to_string();
895 let dest_id = l.dest_id.to_string();
896 let origin_id = l.origin_id.to_string();
898 result.push(VisualLink {
899 link_type: link_type_val,
900 quality: quality_val,
912/// A safe wrapper for the stateless `fers_get_interpolated_motion_path` C-API function.
914/// This function converts Rust-native waypoint data into C-compatible types,
915/// calls the FFI function, and then converts the result back into a `Vec` of points,
916/// ensuring that all C-allocated memory is properly freed.
919/// * `waypoints` - A vector of motion waypoints from the frontend.
920/// * `interp_type` - The interpolation algorithm to use.
921/// * `num_points` - The desired number of points in the output path.
924/// * `Ok(Vec<InterpolatedPoint>)` - A vector of points representing the calculated path.
925/// * `Err(String)` - An error message if the FFI call failed.
926pub fn get_interpolated_motion_path(
927 waypoints: Vec<crate::MotionWaypoint>,
928 interp_type: crate::InterpolationType,
930) -> Result<Vec<crate::InterpolatedMotionPoint>, String> {
931 if waypoints.is_empty() || num_points == 0 {
932 return Ok(Vec::new());
935 let c_waypoints: Vec<ffi::fers_motion_waypoint_t> = waypoints
937 .map(|wp| ffi::fers_motion_waypoint_t { time: wp.time, x: wp.x, y: wp.y, z: wp.altitude })
940 let c_interp_type = match interp_type {
941 crate::InterpolationType::Static => ffi::fers_interp_type_t_FERS_INTERP_STATIC,
942 crate::InterpolationType::Linear => ffi::fers_interp_type_t_FERS_INTERP_LINEAR,
943 crate::InterpolationType::Cubic => ffi::fers_interp_type_t_FERS_INTERP_CUBIC,
946 // SAFETY: We are calling the stateless FFI function with valid, well-formed arguments.
947 // The pointer returned is owned by us and must be freed.
948 let result_ptr = unsafe {
949 ffi::fers_get_interpolated_motion_path(
950 c_waypoints.as_ptr(),
957 if result_ptr.is_null() {
958 return Err(get_last_error());
961 // RAII wrapper to ensure the C-allocated path is freed.
962 struct FersInterpolatedMotionPath(*mut ffi::fers_interpolated_path_t);
964 impl Drop for FersInterpolatedMotionPath {
966 if !self.0.is_null() {
967 // SAFETY: The pointer is valid and owned by this struct.
968 unsafe { ffi::fers_free_interpolated_motion_path(self.0) };
973 let owned_path = FersInterpolatedMotionPath(result_ptr);
975 // SAFETY: We are accessing the fields of a non-null pointer returned by the FFI.
976 // The `count` and `points` fields are guaranteed to be valid for the lifetime of `owned_path`.
978 unsafe { std::slice::from_raw_parts((*owned_path.0).points, (*owned_path.0).count) };
980 let points: Vec<crate::InterpolatedMotionPoint> = result_slice
982 .map(|p| crate::InterpolatedMotionPoint {
995/// A safe wrapper for the stateless `fers_get_interpolated_rotation_path` C-API function.
997/// This function converts Rust-native rotation waypoints into C-compatible types,
998/// calls the FFI function, and converts the result back into a `Vec` of points.
999/// It handles the mapping between Rust enums and C enums for interpolation types
1000/// and ensures that the C-allocated array is properly freed.
1003/// * `waypoints` - A vector of `RotationWaypoint`s.
1004/// * `interp_type` - The interpolation algorithm to use.
1005/// * `num_points` - The desired number of points in the output path.
1008/// * `Ok(Vec<InterpolatedRotationPoint>)` - A vector of interpolated points.
1009/// * `Err(String)` - An error message if the FFI call failed.
1010pub fn get_interpolated_rotation_path(
1011 waypoints: Vec<crate::RotationWaypoint>,
1012 interp_type: crate::InterpolationType,
1013 angle_unit: crate::RotationAngleUnit,
1015) -> Result<Vec<crate::InterpolatedRotationPoint>, String> {
1016 if waypoints.is_empty() || num_points == 0 {
1017 return Ok(Vec::new());
1020 let c_waypoints: Vec<ffi::fers_rotation_waypoint_t> = waypoints
1022 .map(|wp| ffi::fers_rotation_waypoint_t {
1024 azimuth: wp.azimuth,
1025 elevation: wp.elevation,
1029 let c_interp_type = match interp_type {
1030 crate::InterpolationType::Static => ffi::fers_interp_type_t_FERS_INTERP_STATIC,
1031 crate::InterpolationType::Linear => ffi::fers_interp_type_t_FERS_INTERP_LINEAR,
1032 crate::InterpolationType::Cubic => ffi::fers_interp_type_t_FERS_INTERP_CUBIC,
1034 let c_angle_unit = match angle_unit {
1035 crate::RotationAngleUnit::Deg => ffi::fers_angle_unit_t_FERS_ANGLE_UNIT_DEG,
1036 crate::RotationAngleUnit::Rad => ffi::fers_angle_unit_t_FERS_ANGLE_UNIT_RAD,
1039 let result_ptr = unsafe {
1040 ffi::fers_get_interpolated_rotation_path(
1041 c_waypoints.as_ptr(),
1049 if result_ptr.is_null() {
1050 return Err(get_last_error());
1053 struct FersInterpolatedRotationPath(*mut ffi::fers_interpolated_rotation_path_t);
1055 impl Drop for FersInterpolatedRotationPath {
1056 fn drop(&mut self) {
1057 if !self.0.is_null() {
1058 unsafe { ffi::fers_free_interpolated_rotation_path(self.0) };
1063 let owned_path = FersInterpolatedRotationPath(result_ptr);
1066 unsafe { std::slice::from_raw_parts((*owned_path.0).points, (*owned_path.0).count) };
1068 let points: Vec<crate::InterpolatedRotationPoint> = result_slice
1070 .map(|p| crate::InterpolatedRotationPoint { azimuth: p.azimuth, elevation: p.elevation })
1080 use tempfile::NamedTempFile;
1082 /// A minimal, valid FERS JSON scenario that the core engine can parse.
1083 fn minimal_valid_json() -> &'static str {
1090 "origin": { "latitude": 0.0, "longitude": 0.0, "altitude": 0.0 },
1091 "coordinatesystem": { "frame": "ENU" }
1097 /// A scenario with assets and platforms for testing granular updates.
1098 fn scenario_with_assets_json() -> &'static str {
1102 "starttime": 0.0, "endtime": 1.0, "rate": 1000.0,
1103 "origin": { "latitude": 0.0, "longitude": 0.0, "altitude": 0.0 },
1104 "coordinatesystem": { "frame": "ENU" }
1107 { "id": 10, "name": "w1", "power": 1000.0, "carrier_frequency": 1e9, "cw": {} }
1110 { "id": 20, "name": "a1", "pattern": "isotropic" }
1114 "id": 100, "name": "tx_plat",
1115 "motionpath": { "interpolation": "static", "positionwaypoints": [ { "time": 0.0, "x": 0.0, "y": 0.0, "altitude": 0.0 } ] },
1116 "rotationpath": { "interpolation": "static", "rotationwaypoints": [ { "time": 0.0, "azimuth": 0.0, "elevation": 0.0 } ] },
1125 fn test_context_creation_and_destruction() {
1126 // Implicitly tests FersContext::new() and Drop (via fers_context_destroy)
1127 let context = FersContext::new();
1128 assert!(context.is_some(), "Failed to create FersContext via FFI");
1132 fn test_update_scenario_from_invalid_json() {
1133 let context = FersContext::new().unwrap();
1134 let result = context.update_scenario_from_json("{ malformed_json...");
1135 assert!(result.is_err());
1136 let err_msg = result.unwrap_err();
1138 err_msg.contains("JSON parsing/deserialization error"),
1139 "Unexpected error message: {}",
1145 fn test_update_and_get_scenario() {
1146 let context = FersContext::new().unwrap();
1147 let result = context.update_scenario_from_json(minimal_valid_json());
1148 assert!(result.is_ok(), "Failed to update scenario from valid JSON");
1150 // Ensure retrieving it back as JSON works and memory is managed
1151 let json_back = context.get_scenario_as_json().unwrap();
1152 assert!(json_back.contains("parameters"), "JSON export missing expected data");
1154 // Ensure retrieving it back as XML works
1155 let xml_back = context.get_scenario_as_xml().unwrap();
1156 assert!(xml_back.contains("<simulation"), "XML export missing root element");
1160 fn test_update_scenario_returns_rotation_unit_warnings() {
1161 let context = FersContext::new().unwrap();
1162 let mut scenario: serde_json::Value =
1163 serde_json::from_str(scenario_with_assets_json()).unwrap();
1164 scenario["simulation"]["parameters"]["rotationangleunit"] =
1165 serde_json::Value::String("rad".into());
1166 scenario["simulation"]["platforms"][0]["rotationpath"]["rotationwaypoints"][0]["azimuth"] =
1167 serde_json::Value::from(90.0);
1169 let warnings = context
1170 .update_scenario_from_json(&scenario.to_string())
1171 .expect("Scenario update with warnings should still succeed");
1173 assert_eq!(warnings.len(), 1);
1174 assert!(warnings[0].contains("rotation waypoint 0"));
1175 assert!(warnings[0].contains("'azimuth'"));
1179 fn test_load_scenario_from_xml_file() {
1180 let context = FersContext::new().unwrap();
1181 let mut temp_file = NamedTempFile::new().unwrap();
1182 let minimal_xml = r#"<simulation name="TestAPI"><parameters><starttime>0</starttime><endtime>1</endtime><rate>1000</rate><origin latitude="0" longitude="0" altitude="0"/><coordinatesystem frame="ENU"/></parameters><antenna name="api_iso" pattern="isotropic"/></simulation>"#;
1183 temp_file.write_all(minimal_xml.as_bytes()).unwrap();
1185 let result = context.load_scenario_from_xml_file(temp_file.path().to_str().unwrap());
1186 assert!(result.is_ok(), "Failed to load minimal valid XML from file");
1190 fn test_generate_kml() {
1191 let context = FersContext::new().unwrap();
1192 context.update_scenario_from_json(minimal_valid_json()).unwrap();
1194 let temp_kml = NamedTempFile::new().unwrap();
1195 let result = context.generate_kml(temp_kml.path().to_str().unwrap());
1196 assert!(result.is_ok(), "KML generation failed");
1198 // Verify data was actually written to the file by the C++ core
1199 let metadata = std::fs::metadata(temp_kml.path()).unwrap();
1200 assert!(metadata.len() > 0, "KML file was created but is empty");
1204 fn test_get_interpolated_motion_path() {
1205 let waypoints = vec![
1206 crate::MotionWaypoint { time: 0.0, x: 0.0, y: 0.0, altitude: 0.0 },
1207 crate::MotionWaypoint { time: 10.0, x: 10.0, y: 20.0, altitude: 30.0 },
1211 get_interpolated_motion_path(waypoints, crate::InterpolationType::Linear, 3).unwrap();
1213 assert_eq!(points.len(), 3);
1214 // Linear interpolation midpoint logic check
1215 assert_eq!(points[1].x, 5.0);
1216 assert_eq!(points[1].y, 10.0);
1217 assert_eq!(points[1].z, 15.0);
1218 assert_eq!(points[1].vx, 1.0);
1219 assert_eq!(points[1].vy, 2.0);
1220 assert_eq!(points[1].vz, 3.0);
1224 fn test_get_interpolated_rotation_path() {
1225 let waypoints = vec![
1226 crate::RotationWaypoint { time: 0.0, azimuth: 0.0, elevation: 0.0 },
1227 crate::RotationWaypoint { time: 10.0, azimuth: 90.0, elevation: 20.0 },
1230 let points = get_interpolated_rotation_path(
1232 crate::InterpolationType::Linear,
1233 crate::RotationAngleUnit::Deg,
1238 assert_eq!(points.len(), 3);
1239 // Linear interpolation midpoint logic check
1240 assert_eq!(points[1].azimuth, 45.0);
1241 assert_eq!(points[1].elevation, 10.0);
1245 fn test_get_antenna_pattern_invalid_id() {
1246 let context = FersContext::new().unwrap();
1247 context.update_scenario_from_json(minimal_valid_json()).unwrap();
1249 // The minimal JSON has no antennas, so ID "99" should definitively fail.
1250 let result = context.get_antenna_pattern("99", 10, 10, 1e9);
1251 assert!(result.is_err());
1252 assert!(result.unwrap_err().contains("not found"));
1256 fn test_calculate_preview_links_empty() {
1257 let context = FersContext::new().unwrap();
1258 context.update_scenario_from_json(minimal_valid_json()).unwrap();
1260 // Run on an empty scenario without platforms, shouldn't crash, should return empty vec.
1261 let links = context.calculate_preview_links(0.0).unwrap();
1262 assert_eq!(links.len(), 0);
1265 fn test_fers_owned_string_null() {
1266 // Test that a null pointer safely converts to an empty string
1267 let null_str = FersOwnedString(std::ptr::null_mut());
1268 let result = null_str.into_string();
1269 assert_eq!(result.unwrap(), "");
1271 // Test that dropping a null pointer doesn't panic or segfault
1272 let drop_str = FersOwnedString(std::ptr::null_mut());
1277 fn test_raii_null_drops() {
1278 // Test that dropping null pointers in our internal RAII wrappers is safe
1279 let ant_data = FersAntennaPatternData(std::ptr::null_mut());
1282 let link_list = FersVisualLinkList(std::ptr::null_mut());
1287 fn test_get_interpolated_paths_empty() {
1288 // Test empty waypoints
1290 get_interpolated_motion_path(vec![], crate::InterpolationType::Linear, 5).unwrap();
1291 assert!(motion.is_empty());
1293 let rotation = get_interpolated_rotation_path(
1295 crate::InterpolationType::Linear,
1296 crate::RotationAngleUnit::Deg,
1300 assert!(rotation.is_empty());
1302 // Test zero num_points
1303 let wp_m = vec![crate::MotionWaypoint { time: 0.0, x: 0.0, y: 0.0, altitude: 0.0 }];
1305 get_interpolated_motion_path(wp_m, crate::InterpolationType::Linear, 0).unwrap();
1306 assert!(motion2.is_empty());
1308 let wp_r = vec![crate::RotationWaypoint { time: 0.0, azimuth: 0.0, elevation: 0.0 }];
1309 let rotation2 = get_interpolated_rotation_path(
1311 crate::InterpolationType::Linear,
1312 crate::RotationAngleUnit::Deg,
1316 assert!(rotation2.is_empty());
1320 fn test_granular_updates() {
1321 let context = FersContext::new().unwrap();
1322 context.update_scenario_from_json(scenario_with_assets_json()).unwrap();
1325 let plat_json = r#"{
1327 "name": "UpdatedPlatform",
1328 "motionpath": { "interpolation": "static", "positionwaypoints": [] },
1329 "rotationpath": { "interpolation": "static", "rotationwaypoints": [] }
1331 let result = context.update_platform_from_json("100", plat_json);
1332 assert!(result.is_ok(), "Failed to update platform: {:?}", result.err());
1336 r#"{ "id": 20, "name": "UpdatedAntenna", "pattern": "isotropic", "efficiency": 0.5 }"#;
1337 let result = context.update_antenna_from_json(ant_json);
1338 assert!(result.is_ok(), "Failed to update antenna: {:?}", result.err());
1341 let wf_json = r#"{ "id": 10, "name": "UpdatedWaveform", "power": 500.0, "carrier_frequency": 1e9, "cw": {} }"#;
1342 let result = context.update_waveform_from_json(wf_json);
1343 assert!(result.is_ok(), "Failed to update waveform: {:?}", result.err());
1346 let updated_scenario = context.get_scenario_as_json().unwrap();
1347 assert!(updated_scenario.contains("UpdatedPlatform"));
1348 assert!(updated_scenario.contains("UpdatedAntenna"));
1349 assert!(updated_scenario.contains("UpdatedWaveform"));
1353 fn test_granular_updates_invalid() {
1354 let context = FersContext::new().unwrap();
1355 context.update_scenario_from_json(scenario_with_assets_json()).unwrap();
1357 assert!(context.update_platform_from_json("999", "{}").is_err());
1358 assert!(context.update_antenna_from_json("{bad").is_err());
1359 assert!(context.update_waveform_from_json("{bad").is_err());
1363 fn test_get_antenna_pattern_success() {
1364 let context = FersContext::new().unwrap();
1368 "starttime": 0.0, "endtime": 1.0, "rate": 1000.0,
1369 "origin": { "latitude": 0.0, "longitude": 0.0, "altitude": 0.0 },
1370 "coordinatesystem": { "frame": "ENU" }
1373 { "id": 1, "name": "iso", "pattern": "isotropic" }
1377 context.update_scenario_from_json(scenario).unwrap();
1379 let pattern = context.get_antenna_pattern("1", 4, 4, 1e9).unwrap();
1380 assert_eq!(pattern.az_count, 4);
1381 assert_eq!(pattern.el_count, 4);
1382 assert_eq!(pattern.gains.len(), 16);
1383 assert!(pattern.max_gain > 0.0);
1387 fn test_calculate_preview_links_success() {
1388 let context = FersContext::new().unwrap();
1392 "starttime": 0.0, "endtime": 1.0, "rate": 1000.0,
1393 "origin": { "latitude": 0.0, "longitude": 0.0, "altitude": 0.0 },
1394 "coordinatesystem": { "frame": "ENU" }
1397 { "id": 10, "name": "w1", "power": 1000.0, "carrier_frequency": 1e9, "cw": {} }
1400 { "id": 20, "name": "a1", "pattern": "isotropic" }
1403 { "id": 30, "name": "t1", "frequency": 1e6 }
1407 "id": 100, "name": "tx_plat",
1408 "motionpath": { "interpolation": "static", "positionwaypoints": [ { "time": 0.0, "x": 0.0, "y": 0.0, "altitude": 0.0 } ] },
1409 "rotationpath": { "interpolation": "static", "rotationwaypoints": [ { "time": 0.0, "azimuth": 0.0, "elevation": 0.0 } ] },
1410 "components": [ { "transmitter": { "id": 101, "name": "tx1", "waveform": 10, "antenna": 20, "timing": 30, "cw_mode": {} } } ]
1413 "id": 200, "name": "rx_plat",
1414 "motionpath": { "interpolation": "static", "positionwaypoints": [ { "time": 0.0, "x": 100.0, "y": 0.0, "altitude": 0.0 } ] },
1415 "rotationpath": { "interpolation": "static", "rotationwaypoints": [ { "time": 0.0, "azimuth": 0.0, "elevation": 0.0 } ] },
1416 "components": [ { "receiver": { "id": 201, "name": "rx1", "antenna": 20, "timing": 30, "cw_mode": {}, "noise_temp": 290.0 } } ]
1421 context.update_scenario_from_json(scenario).unwrap();
1423 let links = context.calculate_preview_links(0.0).unwrap();
1424 assert!(!links.is_empty());
1427 links.iter().find(|l| l.link_type == 3).expect("Expected a direct interference link");
1428 assert_eq!(direct_link.source_id, "101");
1429 assert_eq!(direct_link.dest_id, "201");