use crate::spacetrack::types::{OutputFormat, RequestClass, RequestController, SortOrder};
fn encode_path_value(s: &str) -> String {
let mut result = String::with_capacity(s.len());
for c in s.chars() {
match c {
'>' => result.push_str("%3E"),
'<' => result.push_str("%3C"),
'^' => result.push_str("%5E"),
_ => result.push(c),
}
}
result
}
#[derive(Debug, Clone)]
struct Filter {
field: String,
value: String,
}
#[derive(Debug, Clone)]
struct OrderBy {
field: String,
order: SortOrder,
}
#[derive(Debug, Clone)]
pub struct SpaceTrackQuery {
controller: RequestController,
class: RequestClass,
filters: Vec<Filter>,
order_by: Vec<OrderBy>,
limit_count: Option<u32>,
limit_offset: Option<u32>,
output_format: Option<OutputFormat>,
predicates: Vec<String>,
metadata: bool,
distinct: bool,
empty_result: bool,
favorites: Option<String>,
}
impl SpaceTrackQuery {
pub fn new(class: RequestClass) -> Self {
SpaceTrackQuery {
controller: class.default_controller(),
class,
filters: Vec::new(),
order_by: Vec::new(),
limit_count: None,
limit_offset: None,
output_format: None,
predicates: Vec::new(),
metadata: false,
distinct: false,
empty_result: false,
favorites: None,
}
}
pub fn controller(mut self, controller: RequestController) -> Self {
self.controller = controller;
self
}
pub fn filter(mut self, field: &str, value: &str) -> Self {
self.filters.push(Filter {
field: field.to_string(),
value: value.to_string(),
});
self
}
pub fn order_by(mut self, field: &str, order: SortOrder) -> Self {
self.order_by.push(OrderBy {
field: field.to_string(),
order,
});
self
}
pub fn limit(mut self, count: u32) -> Self {
self.limit_count = Some(count);
self
}
pub fn limit_offset(mut self, count: u32, offset: u32) -> Self {
self.limit_count = Some(count);
self.limit_offset = Some(offset);
self
}
pub fn format(mut self, format: OutputFormat) -> Self {
self.output_format = Some(format);
self
}
pub fn predicates_filter(mut self, fields: &[&str]) -> Self {
self.predicates = fields.iter().map(|s| s.to_string()).collect();
self
}
pub fn metadata(mut self, enabled: bool) -> Self {
self.metadata = enabled;
self
}
pub fn distinct(mut self, enabled: bool) -> Self {
self.distinct = enabled;
self
}
pub fn empty_result(mut self, enabled: bool) -> Self {
self.empty_result = enabled;
self
}
pub fn favorites(mut self, favorites: &str) -> Self {
self.favorites = Some(favorites.to_string());
self
}
pub fn build(&self) -> String {
let mut parts = Vec::new();
parts.push(format!(
"/{}/query/class/{}",
self.controller.as_str(),
self.class.as_str()
));
for filter in &self.filters {
parts.push(format!(
"/{}/{}",
filter.field,
encode_path_value(&filter.value)
));
}
if !self.order_by.is_empty() {
let order_str: Vec<String> = self
.order_by
.iter()
.map(|o| format!("{}%20{}", o.field, o.order.as_str()))
.collect();
parts.push(format!("/orderby/{}", order_str.join(",")));
}
if let Some(count) = self.limit_count {
if let Some(offset) = self.limit_offset {
parts.push(format!("/limit/{},{}", count, offset));
} else {
parts.push(format!("/limit/{}", count));
}
}
if !self.predicates.is_empty() {
parts.push(format!("/predicates/{}", self.predicates.join(",")));
}
if self.metadata {
parts.push("/metadata/true".to_string());
}
if self.distinct {
parts.push("/distinct/true".to_string());
}
if self.empty_result {
parts.push("/emptyresult/show".to_string());
}
if let Some(ref fav) = self.favorites {
parts.push(format!("/favorites/{}", fav));
}
let format = self.output_format.unwrap_or(OutputFormat::JSON);
parts.push(format!("/format/{}", format.as_str()));
parts.concat()
}
pub fn output_format(&self) -> OutputFormat {
self.output_format.unwrap_or(OutputFormat::JSON)
}
pub fn request_class(&self) -> RequestClass {
self.class
}
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use super::*;
use crate::spacetrack::operators;
#[test]
fn test_basic_query() {
let query = SpaceTrackQuery::new(RequestClass::GP);
let path = query.build();
assert_eq!(path, "/basicspacedata/query/class/gp/format/json");
}
#[test]
fn test_query_with_filter() {
let query = SpaceTrackQuery::new(RequestClass::GP).filter("NORAD_CAT_ID", "25544");
let path = query.build();
assert_eq!(
path,
"/basicspacedata/query/class/gp/NORAD_CAT_ID/25544/format/json"
);
}
#[test]
fn test_query_with_multiple_filters() {
let query = SpaceTrackQuery::new(RequestClass::GP)
.filter("NORAD_CAT_ID", "25544")
.filter("EPOCH", &operators::greater_than("2024-01-01"));
let path = query.build();
assert!(path.contains("/NORAD_CAT_ID/25544/"));
assert!(path.contains("/EPOCH/%3E2024-01-01/"));
}
#[test]
fn test_query_with_order_by() {
let query = SpaceTrackQuery::new(RequestClass::GP)
.filter("NORAD_CAT_ID", "25544")
.order_by("EPOCH", SortOrder::Desc);
let path = query.build();
assert!(path.contains("/orderby/EPOCH%20desc/"));
}
#[test]
fn test_query_with_multiple_order_by() {
let query = SpaceTrackQuery::new(RequestClass::GP)
.order_by("EPOCH", SortOrder::Desc)
.order_by("NORAD_CAT_ID", SortOrder::Asc);
let path = query.build();
assert!(path.contains("/orderby/EPOCH%20desc,NORAD_CAT_ID%20asc/"));
}
#[test]
fn test_query_with_limit() {
let query = SpaceTrackQuery::new(RequestClass::GP)
.filter("NORAD_CAT_ID", "25544")
.limit(5);
let path = query.build();
assert!(path.contains("/limit/5/"));
}
#[test]
fn test_query_with_limit_offset() {
let query = SpaceTrackQuery::new(RequestClass::GP)
.filter("NORAD_CAT_ID", "25544")
.limit_offset(10, 20);
let path = query.build();
assert!(path.contains("/limit/10,20/"));
}
#[test]
fn test_query_with_format() {
let query = SpaceTrackQuery::new(RequestClass::GP)
.filter("NORAD_CAT_ID", "25544")
.format(OutputFormat::TLE);
let path = query.build();
assert!(path.ends_with("/format/tle"));
}
#[test]
fn test_query_with_3le_format() {
let query = SpaceTrackQuery::new(RequestClass::GP)
.filter("NORAD_CAT_ID", "25544")
.format(OutputFormat::ThreeLe);
let path = query.build();
assert!(path.ends_with("/format/3le"));
}
#[test]
fn test_query_with_predicates() {
let query = SpaceTrackQuery::new(RequestClass::GP)
.filter("NORAD_CAT_ID", "25544")
.predicates_filter(&["NORAD_CAT_ID", "OBJECT_NAME", "EPOCH"]);
let path = query.build();
assert!(path.contains("/predicates/NORAD_CAT_ID,OBJECT_NAME,EPOCH/"));
}
#[test]
fn test_query_with_metadata() {
let query = SpaceTrackQuery::new(RequestClass::GP).metadata(true);
let path = query.build();
assert!(path.contains("/metadata/true"));
}
#[test]
fn test_query_with_distinct() {
let query = SpaceTrackQuery::new(RequestClass::GP).distinct(true);
let path = query.build();
assert!(path.contains("/distinct/true"));
}
#[test]
fn test_query_with_empty_result() {
let query = SpaceTrackQuery::new(RequestClass::GP).empty_result(true);
let path = query.build();
assert!(path.contains("/emptyresult/show"));
}
#[test]
fn test_query_with_favorites() {
let query = SpaceTrackQuery::new(RequestClass::GP).favorites("my_favorites");
let path = query.build();
assert!(path.contains("/favorites/my_favorites/"));
}
#[test]
fn test_query_with_custom_controller() {
let query =
SpaceTrackQuery::new(RequestClass::GP).controller(RequestController::ExpandedSpaceData);
let path = query.build();
assert!(path.starts_with("/expandedspacedata/"));
}
#[test]
fn test_query_cdm_public_default_controller() {
let query = SpaceTrackQuery::new(RequestClass::CDMPublic);
let path = query.build();
assert!(path.starts_with("/basicspacedata/"));
}
#[test]
fn test_query_satcat() {
let query = SpaceTrackQuery::new(RequestClass::SATCAT)
.filter("NORAD_CAT_ID", "25544")
.limit(1);
let path = query.build();
assert_eq!(
path,
"/basicspacedata/query/class/satcat/NORAD_CAT_ID/25544/limit/1/format/json"
);
}
#[test]
fn test_full_query() {
let query = SpaceTrackQuery::new(RequestClass::GP)
.filter("NORAD_CAT_ID", "25544")
.order_by("EPOCH", SortOrder::Desc)
.limit(1);
let path = query.build();
assert_eq!(
path,
"/basicspacedata/query/class/gp/NORAD_CAT_ID/25544/orderby/EPOCH%20desc/limit/1/format/json"
);
}
#[test]
fn test_query_with_operators() {
let query = SpaceTrackQuery::new(RequestClass::GP)
.filter("EPOCH", &operators::greater_than(operators::now_offset(-7)))
.filter("ECCENTRICITY", &operators::less_than("0.01"))
.filter("OBJECT_TYPE", &operators::not_equal("DEBRIS"))
.filter("NORAD_CAT_ID", &operators::inclusive_range(25544, 25600))
.format(OutputFormat::JSON);
let path = query.build();
assert!(path.contains("/EPOCH/%3Enow-7/"));
assert!(path.contains("/ECCENTRICITY/%3C0.01/"));
assert!(path.contains("/OBJECT_TYPE/%3C%3EDEBRIS/"));
assert!(path.contains("/NORAD_CAT_ID/25544--25600/"));
}
#[test]
fn test_query_output_format_accessor() {
let query = SpaceTrackQuery::new(RequestClass::GP);
assert_eq!(query.output_format(), OutputFormat::JSON);
let query = SpaceTrackQuery::new(RequestClass::GP).format(OutputFormat::TLE);
assert_eq!(query.output_format(), OutputFormat::TLE);
}
#[test]
fn test_query_request_class_accessor() {
let query = SpaceTrackQuery::new(RequestClass::GP);
assert_eq!(query.request_class(), RequestClass::GP);
let query = SpaceTrackQuery::new(RequestClass::SATCAT);
assert_eq!(query.request_class(), RequestClass::SATCAT);
}
#[test]
fn test_query_clone() {
let query = SpaceTrackQuery::new(RequestClass::GP)
.filter("NORAD_CAT_ID", "25544")
.limit(1);
let cloned = query.clone();
assert_eq!(query.build(), cloned.build());
}
#[test]
fn test_query_metadata_false_not_in_url() {
let query = SpaceTrackQuery::new(RequestClass::GP).metadata(false);
let path = query.build();
assert!(!path.contains("metadata"));
}
#[test]
fn test_query_distinct_false_not_in_url() {
let query = SpaceTrackQuery::new(RequestClass::GP).distinct(false);
let path = query.build();
assert!(!path.contains("distinct"));
}
#[test]
fn test_query_empty_result_false_not_in_url() {
let query = SpaceTrackQuery::new(RequestClass::GP).empty_result(false);
let path = query.build();
assert!(!path.contains("emptyresult"));
}
#[test]
fn test_all_request_classes() {
let classes = vec![
RequestClass::GP,
RequestClass::GPHistory,
RequestClass::SATCAT,
RequestClass::SATCATChange,
RequestClass::SATCATDebut,
RequestClass::Decay,
RequestClass::TIP,
RequestClass::CDMPublic,
RequestClass::Boxscore,
RequestClass::Announcement,
RequestClass::LaunchSite,
];
for class in classes {
let query = SpaceTrackQuery::new(class);
let path = query.build();
assert!(path.contains(&format!("/class/{}/", class.as_str())));
}
}
#[test]
fn test_query_with_xml_format() {
let query = SpaceTrackQuery::new(RequestClass::GP).format(OutputFormat::XML);
let path = query.build();
assert!(path.ends_with("/format/xml"));
}
#[test]
fn test_query_with_html_format() {
let query = SpaceTrackQuery::new(RequestClass::GP).format(OutputFormat::HTML);
let path = query.build();
assert!(path.ends_with("/format/html"));
}
#[test]
fn test_query_with_csv_format() {
let query = SpaceTrackQuery::new(RequestClass::GP).format(OutputFormat::CSV);
let path = query.build();
assert!(path.ends_with("/format/csv"));
}
#[test]
fn test_query_with_kvn_format() {
let query = SpaceTrackQuery::new(RequestClass::GP).format(OutputFormat::KVN);
let path = query.build();
assert!(path.ends_with("/format/kvn"));
}
#[test]
fn test_query_with_json_format_explicit() {
let query = SpaceTrackQuery::new(RequestClass::GP).format(OutputFormat::JSON);
let path = query.build();
assert!(path.ends_with("/format/json"));
}
#[test]
fn test_query_with_all_controllers() {
let controllers = vec![
(RequestController::BasicSpaceData, "basicspacedata"),
(RequestController::ExpandedSpaceData, "expandedspacedata"),
(RequestController::FileShare, "fileshare"),
(RequestController::PublicFiles, "publicfiles"),
];
for (controller, expected) in controllers {
let query = SpaceTrackQuery::new(RequestClass::GP).controller(controller);
let path = query.build();
assert!(
path.starts_with(&format!("/{}/", expected)),
"Expected path to start with /{expected}/, got: {path}"
);
}
}
}