1use std::fmt;
18
19use libm::{floor, log10, pow};
20
21use crate::astro::sgp4::{self, ElementSet};
22use crate::validate;
23
24const MAX_LINE_LEN: usize = 69;
27const MAX_ASCII: u32 = 127;
29const LINE1_MIN_LEN: usize = 64;
31const LINE2_MIN_LEN: usize = 68;
33const CHECKSUM_COL: usize = 68;
35const YEAR_PIVOT: i32 = 57;
38const BODY_LEN: usize = 68;
40
41const EPOCH_DAY_DECIMALS: usize = 8;
43const EPOCH_DAY_WIDTH: usize = 12;
45const NDOT_DECIMALS: usize = 8;
47const NDOT_WIDTH: usize = 9;
49const ASSUMED_DECIMAL_MANTISSA_DECIMALS: usize = 5;
51const ASSUMED_DECIMAL_MANTISSA_DIGITS: usize = 5;
53const ECCENTRICITY_DECIMALS: usize = 7;
55const ECCENTRICITY_DIGITS: usize = 7;
57const ANGLE_DECIMALS: usize = 4;
59const ANGLE_WIDTH: usize = 8;
61const MEAN_MOTION_DECIMALS: usize = 8;
63const MEAN_MOTION_WIDTH: usize = 11;
65const ELSET_WIDTH: usize = 4;
67const REV_WIDTH: usize = 5;
69const CATALOG_WIDTH: usize = 5;
71const INTL_DESIGNATOR_WIDTH: usize = 8;
73const EPOCH_YEAR_WIDTH: usize = 2;
75pub const MAX_TLE_CATALOG_NUMBER: u32 = 339_999;
77pub const MAX_NUMERIC_TLE_CATALOG_NUMBER: u32 = 99_999;
79const ALPHA5_LETTERS: &str = "ABCDEFGHJKLMNPQRSTUVWXYZ";
81const ALPHA5_SUFFIX_MODULUS: u32 = 10_000;
83
84#[derive(Debug, Clone, PartialEq)]
90pub struct TleElements {
91 pub catalog_number: String,
98 pub classification: String,
99 pub international_designator: String,
100 pub epoch_year: i32,
101 pub epoch_day_of_year: f64,
102 pub mean_motion_dot: f64,
103 pub mean_motion_double_dot: f64,
104 pub bstar: f64,
105 pub ephemeris_type: i32,
106 pub elset_number: i32,
107 pub inclination_deg: f64,
108 pub raan_deg: f64,
109 pub eccentricity: f64,
110 pub arg_perigee_deg: f64,
111 pub mean_anomaly_deg: f64,
112 pub mean_motion: f64,
113 pub rev_number: i32,
114}
115
116impl TleElements {
117 pub fn to_element_set(&self) -> Result<ElementSet, TleError> {
137 validate_tle_bridge(self)?;
138 Ok(ElementSet {
139 epoch: sgp4::sgp4_julian_date_from_day_of_year(self.epoch_year, self.epoch_day_of_year),
140 bstar: self.bstar,
141 mean_motion_dot: self.mean_motion_dot,
142 mean_motion_double_dot: self.mean_motion_double_dot,
143 eccentricity: self.eccentricity,
144 argument_of_perigee_deg: self.arg_perigee_deg,
145 inclination_deg: self.inclination_deg,
146 mean_anomaly_deg: self.mean_anomaly_deg,
147 mean_motion_rev_per_day: self.mean_motion,
148 right_ascension_deg: self.raan_deg,
149 catalog_number: decode_catalog_number(&self.catalog_number)?,
150 })
151 }
152}
153
154#[derive(Debug, Clone, PartialEq, Eq)]
157pub struct ChecksumWarning {
158 pub line_label: &'static str,
160 pub expected: u8,
162 pub computed: u8,
164}
165
166#[derive(Debug, Clone, PartialEq)]
168pub struct ParsedTle {
169 pub elements: TleElements,
170 pub checksum_warnings: Vec<ChecksumWarning>,
171}
172
173#[derive(Debug, Clone, PartialEq, Eq)]
175pub enum TleError {
176 NonAscii,
178 Format,
180 SatelliteMismatch,
182 InvalidCatalogNumber {
184 value: String,
186 reason: &'static str,
188 },
189 CatalogNumberOutOfRange {
191 catalog_number: u32,
193 },
194 InvalidField {
196 field: &'static str,
198 reason: &'static str,
200 },
201 Field(String),
203}
204
205impl fmt::Display for TleError {
206 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207 match self {
208 TleError::NonAscii => write!(f, "TLE lines contain non-ASCII characters"),
209 TleError::Format => write!(
210 f,
211 "TLE format error: line does not match the Two-Line Element fixed-width format"
212 ),
213 TleError::SatelliteMismatch => {
214 write!(f, "Satellite numbers in lines 1 and 2 do not match")
215 }
216 TleError::InvalidCatalogNumber { value, reason } => {
217 write!(f, "TLE invalid catalog number {value:?}: {reason}")
218 }
219 TleError::CatalogNumberOutOfRange { catalog_number } => write!(
220 f,
221 "TLE catalog number {catalog_number} cannot be encoded in a five-character field"
222 ),
223 TleError::InvalidField { field, reason } => {
224 write!(f, "TLE invalid field {field}: {reason}")
225 }
226 TleError::Field(msg) => write!(f, "TLE parse error: {msg}"),
227 }
228 }
229}
230
231impl std::error::Error for TleError {}
232
233fn validate_tle_bridge(elements: &TleElements) -> Result<(), TleError> {
234 validate::finite(elements.epoch_day_of_year, "epoch_day_of_year").map_err(map_tle_field)?;
235 validate::finite(elements.bstar, "bstar").map_err(map_tle_field)?;
236 validate::finite(elements.mean_motion_dot, "mean_motion_dot").map_err(map_tle_field)?;
237 validate::finite(elements.mean_motion_double_dot, "mean_motion_double_dot")
238 .map_err(map_tle_field)?;
239 validate::finite_in_range_exclusive_upper(elements.eccentricity, 0.0, 1.0, "eccentricity")
240 .map_err(map_tle_field)?;
241 validate::finite(elements.arg_perigee_deg, "arg_perigee_deg").map_err(map_tle_field)?;
242 validate::finite(elements.inclination_deg, "inclination_deg").map_err(map_tle_field)?;
243 validate::finite(elements.mean_anomaly_deg, "mean_anomaly_deg").map_err(map_tle_field)?;
244 validate::finite_positive(elements.mean_motion, "mean_motion").map_err(map_tle_field)?;
245 validate::finite(elements.raan_deg, "raan_deg").map_err(map_tle_field)?;
246 Ok(())
247}
248
249fn map_tle_field(error: validate::FieldError) -> TleError {
250 TleError::InvalidField {
251 field: error.field(),
252 reason: error.reason(),
253 }
254}
255
256pub fn parse(line1: &str, line2: &str) -> Result<ParsedTle, TleError> {
262 if !is_ascii(line1) || !is_ascii(line2) {
263 return Err(TleError::NonAscii);
264 }
265
266 let line1 = clean_line(line1);
267 let line2 = clean_line(line2);
268
269 validate_format(&line1, &line2)?;
270 let elements = extract_fields(&line1, &line2)?;
271 let checksum_warnings = checksum_warnings(&line1, &line2);
272
273 Ok(ParsedTle {
274 elements,
275 checksum_warnings,
276 })
277}
278
279pub fn encode_catalog_number(catalog_number: u32) -> Result<String, TleError> {
287 if catalog_number <= MAX_NUMERIC_TLE_CATALOG_NUMBER {
288 return Ok(format!("{catalog_number:05}"));
289 }
290 if catalog_number > MAX_TLE_CATALOG_NUMBER {
291 return Err(TleError::CatalogNumberOutOfRange { catalog_number });
292 }
293
294 let prefix = catalog_number / ALPHA5_SUFFIX_MODULUS;
295 let suffix = catalog_number % ALPHA5_SUFFIX_MODULUS;
296 let letter = alpha5_letter_for_value(prefix)
297 .ok_or(TleError::CatalogNumberOutOfRange { catalog_number })?;
298 Ok(format!("{letter}{suffix:04}"))
299}
300
301pub fn decode_catalog_number(field: &str) -> Result<u32, TleError> {
306 let field = field.trim();
307 if field.is_empty() {
308 return Err(TleError::InvalidCatalogNumber {
309 value: field.to_string(),
310 reason: "empty field",
311 });
312 }
313
314 if field.bytes().all(|b| b.is_ascii_digit()) {
315 if field.len() > CATALOG_WIDTH {
316 return Err(TleError::InvalidCatalogNumber {
317 value: field.to_string(),
318 reason: "numeric TLE field is wider than five digits",
319 });
320 }
321 return field
322 .parse::<u32>()
323 .map_err(|_| TleError::InvalidCatalogNumber {
324 value: field.to_string(),
325 reason: "invalid numeric field",
326 });
327 }
328
329 if field.len() != CATALOG_WIDTH {
330 return Err(TleError::InvalidCatalogNumber {
331 value: field.to_string(),
332 reason: "Alpha-5 field must be one letter followed by four digits",
333 });
334 }
335
336 let mut chars = field.chars();
337 let letter = chars.next().expect("non-empty field");
338 let prefix = alpha5_value_for_letter(letter).ok_or_else(|| TleError::InvalidCatalogNumber {
339 value: field.to_string(),
340 reason: "invalid Alpha-5 leading letter",
341 })?;
342 let suffix = chars.as_str();
343 if !suffix.bytes().all(|b| b.is_ascii_digit()) {
344 return Err(TleError::InvalidCatalogNumber {
345 value: field.to_string(),
346 reason: "Alpha-5 suffix must be four digits",
347 });
348 }
349 let suffix = suffix
350 .parse::<u32>()
351 .map_err(|_| TleError::InvalidCatalogNumber {
352 value: field.to_string(),
353 reason: "invalid Alpha-5 suffix",
354 })?;
355 Ok(prefix * ALPHA5_SUFFIX_MODULUS + suffix)
356}
357
358pub fn encode(el: &TleElements) -> Result<(String, String), TleError> {
364 let cat = encode_catalog_number_text(&el.catalog_number)?;
365 let cls = &el.classification;
366 let intl = pad_trailing(&el.international_designator, INTL_DESIGNATOR_WIDTH);
367
368 let epoch_two_digit = el.epoch_year.rem_euclid(100);
369
370 let l1_body = format!(
371 "1 {cat}{cls} {intl} {epoch} {ndot} {nddot} {bstar} {ephtype} {elnum}",
372 epoch = fmt_epoch(epoch_two_digit, el.epoch_day_of_year),
373 ndot = fmt_ndot(el.mean_motion_dot),
374 nddot = fmt_assumed_decimal(el.mean_motion_double_dot),
375 bstar = fmt_assumed_decimal(el.bstar),
376 ephtype = el.ephemeris_type,
377 elnum = pad_leading(&el.elset_number.to_string(), ELSET_WIDTH),
378 );
379 let line1 = pad_and_checksum(&l1_body);
380
381 let l2_body = format!(
382 "2 {cat} {inclo} {raan} {ecc} {argp} {mo} {mm}{revnum}",
383 inclo = fmt_angle(el.inclination_deg),
384 raan = fmt_angle(el.raan_deg),
385 ecc = fmt_eccentricity(el.eccentricity),
386 argp = fmt_angle(el.arg_perigee_deg),
387 mo = fmt_angle(el.mean_anomaly_deg),
388 mm = fmt_mean_motion(el.mean_motion),
389 revnum = pad_leading(&el.rev_number.to_string(), REV_WIDTH),
390 );
391 let line2 = pad_and_checksum(&l2_body);
392
393 Ok((line1, line2))
394}
395
396fn is_ascii(line: &str) -> bool {
399 line.chars().all(|c| (c as u32) <= MAX_ASCII)
400}
401
402fn clean_line(line: &str) -> String {
404 let trimmed = line.trim_end();
405 if trimmed.len() > MAX_LINE_LEN {
406 trimmed[..MAX_LINE_LEN].to_string()
407 } else {
408 trimmed.to_string()
409 }
410}
411
412fn validate_format(line1: &str, line2: &str) -> Result<(), TleError> {
413 validate_line(line1, '1', LINE1_MIN_LEN, &LINE1_POSITIONS)?;
414 validate_line(line2, '2', LINE2_MIN_LEN, &LINE2_POSITIONS)?;
415 if slice_inclusive(line1, 2, 6) == slice_inclusive(line2, 2, 6) {
416 Ok(())
417 } else {
418 Err(TleError::SatelliteMismatch)
419 }
420}
421
422fn validate_line(
423 line: &str,
424 prefix: char,
425 min_len: usize,
426 positions: &[(usize, char)],
427) -> Result<(), TleError> {
428 let len = line.chars().count();
429 if len < min_len {
430 return Err(TleError::Format);
431 }
432 let mut start = String::with_capacity(2);
433 start.push(prefix);
434 start.push(' ');
435 if !line.starts_with(&start) {
436 return Err(TleError::Format);
437 }
438 if positions
439 .iter()
440 .all(|&(pos, ch)| char_at(line, pos) == Some(ch))
441 {
442 Ok(())
443 } else {
444 Err(TleError::Format)
445 }
446}
447
448const LINE1_POSITIONS: [(usize, char); 8] = [
449 (8, ' '),
450 (23, '.'),
451 (32, ' '),
452 (34, '.'),
453 (43, ' '),
454 (52, ' '),
455 (61, ' '),
456 (63, ' '),
457];
458
459const LINE2_POSITIONS: [(usize, char); 10] = [
460 (7, ' '),
461 (11, '.'),
462 (16, ' '),
463 (20, '.'),
464 (25, ' '),
465 (33, ' '),
466 (37, '.'),
467 (42, ' '),
468 (46, '.'),
469 (51, ' '),
470];
471
472fn extract_fields(line1: &str, line2: &str) -> Result<TleElements, TleError> {
473 let catalog_number = slice_inclusive(line1, 2, 6).trim().to_string();
474 decode_catalog_number(&catalog_number)?;
475
476 let two_digit_year = parse_int(slice_inclusive(line1, 18, 19).trim())?;
477 let epoch_year = if two_digit_year < YEAR_PIVOT {
478 2000 + two_digit_year
479 } else {
480 1900 + two_digit_year
481 };
482
483 Ok(TleElements {
484 catalog_number,
485 classification: char_at(line1, 7).unwrap_or('U').to_string(),
486 international_designator: slice_inclusive(line1, 9, 16).trim_end().to_string(),
487 epoch_year,
488 epoch_day_of_year: parse_float(slice_inclusive(line1, 20, 31))?,
489 mean_motion_dot: parse_float(slice_inclusive(line1, 33, 42))?,
490 mean_motion_double_dot: parse_assumed_decimal(line1, 44, 45, 49, 50, 51)?,
491 bstar: parse_assumed_decimal(line1, 53, 54, 58, 59, 60)?,
492 ephemeris_type: parse_int_or_default(
493 char_at(line1, 62)
494 .map(|c| c.to_string())
495 .unwrap_or_default()
496 .trim(),
497 0,
498 )?,
499 elset_number: parse_int_or_default(slice_inclusive(line1, 64, 67).trim(), 0)?,
500 inclination_deg: parse_float(slice_inclusive(line2, 8, 15))?,
501 raan_deg: parse_float(slice_inclusive(line2, 17, 24))?,
502 eccentricity: parse_eccentricity(slice_inclusive(line2, 26, 32))?,
503 arg_perigee_deg: parse_float(slice_inclusive(line2, 34, 41))?,
504 mean_anomaly_deg: parse_float(slice_inclusive(line2, 43, 50))?,
505 mean_motion: parse_float(slice_inclusive(line2, 52, 62))?,
506 rev_number: parse_int_or_default(slice_inclusive(line2, 63, 67).trim(), 0)?,
507 })
508}
509
510fn parse_assumed_decimal(
513 line: &str,
514 sign_pos: usize,
515 mant_start: usize,
516 mant_end: usize,
517 exp_start: usize,
518 exp_end: usize,
519) -> Result<f64, TleError> {
520 let sign = if char_at(line, sign_pos) == Some('-') {
521 -1.0
522 } else {
523 1.0
524 };
525 let mantissa_field = format!("0.{}", slice_inclusive(line, mant_start, mant_end));
526 let mantissa = parse_float_raw(mantissa_field.trim())?;
527 let exp = parse_int(slice_inclusive(line, exp_start, exp_end).trim())?;
528 Ok(sign * mantissa * 10.0_f64.powi(exp))
533}
534
535fn parse_eccentricity(field: &str) -> Result<f64, TleError> {
537 let digits = field.replace(' ', "0");
538 parse_float_raw(&format!("0.{digits}"))
539}
540
541fn parse_float(field: &str) -> Result<f64, TleError> {
544 let trimmed = field.trim();
545 let without_plus = trimmed.strip_prefix('+').unwrap_or(trimmed);
546 let normalized = if let Some(rest) = without_plus.strip_prefix("-.") {
547 format!("-0.{rest}")
548 } else if let Some(rest) = without_plus.strip_prefix('.') {
549 format!("0.{rest}")
550 } else {
551 without_plus.to_string()
552 };
553 parse_float_raw(&normalized)
554}
555
556fn parse_float_raw(text: &str) -> Result<f64, TleError> {
559 if !text.contains('.') {
560 return Err(TleError::Field(format!("invalid float {text:?}")));
561 }
562 let body = text.strip_prefix('-').unwrap_or(text);
563 if body.starts_with('.') || body.ends_with('.') {
564 return Err(TleError::Field(format!("invalid float {text:?}")));
565 }
566 text.parse::<f64>()
567 .map_err(|_| TleError::Field(format!("invalid float {text:?}")))
568}
569
570fn parse_int(text: &str) -> Result<i32, TleError> {
571 text.parse::<i32>()
572 .map_err(|_| TleError::Field(format!("invalid integer {text:?}")))
573}
574
575fn parse_int_or_default(text: &str, default: i32) -> Result<i32, TleError> {
580 if text.is_empty() {
581 Ok(default)
582 } else {
583 parse_int(text)
584 }
585}
586
587fn checksum_warnings(line1: &str, line2: &str) -> Vec<ChecksumWarning> {
588 [("line 1", line1), ("line 2", line2)]
589 .into_iter()
590 .filter_map(|(label, line)| check_one(label, line))
591 .collect()
592}
593
594fn check_one(label: &'static str, line: &str) -> Option<ChecksumWarning> {
595 if line.chars().count() < MAX_LINE_LEN {
596 return None;
597 }
598 let expected = char_at(line, CHECKSUM_COL)
599 .and_then(|c| c.to_digit(10))
600 .map(|d| d as u8)?;
601 let computed = compute_checksum(line);
602 if expected == computed {
603 None
604 } else {
605 Some(ChecksumWarning {
606 line_label: label,
607 expected,
608 computed,
609 })
610 }
611}
612
613fn compute_checksum(line: &str) -> u8 {
616 let sum: u32 = line
617 .chars()
618 .take(BODY_LEN)
619 .map(|c| match c {
620 '0'..='9' => c as u32 - '0' as u32,
621 '-' => 1,
622 _ => 0,
623 })
624 .sum();
625 (sum % 10) as u8
626}
627
628fn slice_inclusive(s: &str, start: usize, end_inclusive: usize) -> &str {
633 let len = s.len();
634 if start >= len {
635 return "";
636 }
637 let end = (end_inclusive + 1).min(len);
638 &s[start..end]
639}
640
641fn char_at(s: &str, index: usize) -> Option<char> {
642 s.as_bytes().get(index).map(|&b| b as char)
643}
644
645fn alpha5_value_for_letter(letter: char) -> Option<u32> {
646 ALPHA5_LETTERS
647 .chars()
648 .position(|candidate| candidate == letter)
649 .map(|index| 10 + index as u32)
650}
651
652fn alpha5_letter_for_value(value: u32) -> Option<char> {
653 if value < 10 {
654 return None;
655 }
656 ALPHA5_LETTERS.chars().nth((value - 10) as usize)
657}
658
659fn encode_catalog_number_text(text: &str) -> Result<String, TleError> {
660 let trimmed = text.trim();
661 if trimmed.bytes().all(|b| b.is_ascii_digit()) {
662 let catalog_number =
663 trimmed
664 .parse::<u32>()
665 .map_err(|_| TleError::InvalidCatalogNumber {
666 value: trimmed.to_string(),
667 reason: "invalid numeric field",
668 })?;
669 encode_catalog_number(catalog_number)
670 } else {
671 let catalog_number = decode_catalog_number(trimmed)?;
672 encode_catalog_number(catalog_number)
673 }
674}
675
676pub(crate) fn assumed_decimal_quantize(value: f64) -> f64 {
687 if value == 0.0 {
688 return 0.0;
689 }
690 decode_assumed_decimal_field(&fmt_assumed_decimal(value))
691}
692
693fn decode_assumed_decimal_field(field: &str) -> f64 {
696 let sign = if field.starts_with('-') { -1.0 } else { 1.0 };
697 let body = &field[1..];
698 let mantissa_digits = &body[..ASSUMED_DECIMAL_MANTISSA_DIGITS];
699 let exp_field = &body[ASSUMED_DECIMAL_MANTISSA_DIGITS..];
700 let exp_field = exp_field.strip_prefix('+').unwrap_or(exp_field);
701 let mantissa: f64 = format!("0.{mantissa_digits}").parse().unwrap_or(0.0);
702 let exp: i32 = exp_field.parse().unwrap_or(0);
703 sign * mantissa * 10.0_f64.powi(exp)
704}
705
706fn fmt_epoch(year_two_digit: i32, day_of_year: f64) -> String {
709 let yr = pad_leading_zeros(&year_two_digit.to_string(), EPOCH_YEAR_WIDTH);
710 let days = fixed_decimals(day_of_year, EPOCH_DAY_DECIMALS);
711 format!("{yr}{}", pad_leading_zeros(&days, EPOCH_DAY_WIDTH))
712}
713
714fn fmt_ndot(val: f64) -> String {
715 let sign = if val < 0.0 { '-' } else { ' ' };
716 let mut digits = fixed_decimals(val.abs(), NDOT_DECIMALS);
717 if let Some(rest) = digits.strip_prefix('0') {
718 digits = rest.to_string();
719 }
720 format!("{sign}{}", pad_leading(&digits, NDOT_WIDTH))
721}
722
723fn fmt_assumed_decimal(val: f64) -> String {
725 if val == 0.0 {
726 return " 00000-0".to_string();
727 }
728 let sign = if val < 0.0 { '-' } else { ' ' };
729 let av = val.abs();
730 let raw_exp = floor(log10(av)) as i32;
731 let mut exp = raw_exp + 1;
732 let mantissa = av / pow(10.0, exp as f64);
733 let mut mant_full = fixed_decimals(mantissa, ASSUMED_DECIMAL_MANTISSA_DECIMALS);
734 if mant_full.starts_with("1.") {
735 exp += 1;
736 mant_full = fixed_decimals(mantissa / 10.0, ASSUMED_DECIMAL_MANTISSA_DECIMALS);
737 }
738 let mant_str: String = mant_full
739 .chars()
740 .skip(2)
741 .take(ASSUMED_DECIMAL_MANTISSA_DIGITS)
742 .collect();
743 let exp_sign = if exp >= 0 { '+' } else { '-' };
744 format!("{sign}{mant_str}{exp_sign}{}", exp.abs())
745}
746
747fn fmt_eccentricity(ecc: f64) -> String {
748 let formatted = fixed_decimals(ecc, ECCENTRICITY_DECIMALS);
749 let digits = formatted.strip_prefix("0.").unwrap_or(&formatted);
750 pad_leading_zeros(digits, ECCENTRICITY_DIGITS)
751}
752
753fn fmt_angle(val: f64) -> String {
754 pad_leading(&fixed_decimals(val, ANGLE_DECIMALS), ANGLE_WIDTH)
755}
756
757fn fmt_mean_motion(val: f64) -> String {
758 pad_leading(
759 &fixed_decimals(val, MEAN_MOTION_DECIMALS),
760 MEAN_MOTION_WIDTH,
761 )
762}
763
764fn pad_and_checksum(body: &str) -> String {
765 let clamped: String = body.chars().take(BODY_LEN).collect();
766 let padded = pad_trailing(&clamped, BODY_LEN);
767 let checksum = compute_checksum(&padded);
768 format!("{padded}{checksum}")
769}
770
771fn fixed_decimals(value: f64, decimals: usize) -> String {
774 format!("{value:.decimals$}")
775}
776
777fn pad_leading(s: &str, width: usize) -> String {
778 pad_leading_with(s, width, ' ')
779}
780
781fn pad_leading_zeros(s: &str, width: usize) -> String {
782 pad_leading_with(s, width, '0')
783}
784
785fn pad_leading_with(s: &str, width: usize, fill: char) -> String {
786 let len = s.chars().count();
787 if len >= width {
788 s.to_string()
789 } else {
790 let mut out: String = std::iter::repeat_n(fill, width - len).collect();
791 out.push_str(s);
792 out
793 }
794}
795
796fn pad_trailing(s: &str, width: usize) -> String {
797 let len = s.chars().count();
798 if len >= width {
799 s.to_string()
800 } else {
801 let mut out = s.to_string();
802 out.extend(std::iter::repeat_n(' ', width - len));
803 out
804 }
805}
806
807#[cfg(test)]
808mod tests {
809 use super::*;
810
811 const ISS_L1: &str = "1 25544U 98067A 18184.80969102 .00001614 00000-0 31745-4 0 9993";
812 const ISS_L2: &str = "2 25544 51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
813
814 #[test]
815 fn parses_iss_fields() {
816 let parsed = parse(ISS_L1, ISS_L2).unwrap();
817 let el = parsed.elements;
818 assert_eq!(el.catalog_number, "25544");
819 assert_eq!(el.classification, "U");
820 assert_eq!(el.international_designator, "98067A");
821 assert_eq!(el.epoch_year, 2018);
822 assert_eq!(el.epoch_day_of_year, 184.80969102);
823 assert_eq!(el.inclination_deg, 51.6414);
824 assert_eq!(el.eccentricity, 0.0003435);
825 assert_eq!(el.mean_motion, 15.54005638);
826 assert_eq!(el.rev_number, 12110);
827 assert!(parsed.checksum_warnings.is_empty());
828 }
829
830 #[test]
831 fn round_trips_iss_character_exact() {
832 let parsed = parse(ISS_L1, ISS_L2).unwrap();
833 let (l1, l2) = encode(&parsed.elements).unwrap();
834 assert_eq!(l1, ISS_L1);
835 assert_eq!(l2, ISS_L2);
836 }
837
838 #[test]
839 fn alpha5_catalog_examples_match_published_table() {
840 for (field, value) in [
841 ("A0000", 100_000),
842 ("E8493", 148_493),
843 ("H6932", 176_932),
844 ("J2931", 182_931),
845 ("P4018", 234_018),
846 ("W1928", 301_928),
847 ("Z9999", 339_999),
848 ] {
849 assert_eq!(decode_catalog_number(field), Ok(value));
850 assert_eq!(encode_catalog_number(value), Ok(field.to_string()));
851 }
852 }
853
854 #[test]
855 fn alpha5_catalog_round_trips_exhaustive_letter_alphabet() {
856 for letter in ALPHA5_LETTERS.chars() {
857 for suffix in [0, 1, 9998, 9999] {
858 let field = format!("{letter}{suffix:04}");
859 let value = decode_catalog_number(&field).unwrap();
860 assert_eq!(encode_catalog_number(value).unwrap(), field);
861 }
862 }
863 assert!(decode_catalog_number("I0000").is_err());
864 assert!(decode_catalog_number("O0000").is_err());
865 assert!(decode_catalog_number("a0000").is_err());
866 }
867
868 #[test]
869 fn alpha5_tle_bridge_preserves_numeric_catalog_id() {
870 let parsed = parse(ISS_L1, ISS_L2).unwrap();
871 let mut el = parsed.elements;
872 el.catalog_number = "A0000".to_string();
873
874 let (line1, line2) = encode(&el).unwrap();
875 assert_eq!(slice_inclusive(&line1, 2, 6), "A0000");
876 assert_eq!(slice_inclusive(&line2, 2, 6), "A0000");
877
878 let parsed = parse(&line1, &line2).unwrap();
879 assert_eq!(parsed.elements.catalog_number, "A0000");
880 assert_eq!(
881 parsed.elements.to_element_set().unwrap().catalog_number,
882 100_000
883 );
884 }
885
886 #[test]
887 fn tle_encode_rejects_catalog_numbers_outside_alpha5_range() {
888 let parsed = parse(ISS_L1, ISS_L2).unwrap();
889 let mut el = parsed.elements;
890 el.catalog_number = "340000".to_string();
891 assert_eq!(
892 encode(&el),
893 Err(TleError::CatalogNumberOutOfRange {
894 catalog_number: 340_000
895 })
896 );
897 }
898
899 #[test]
900 fn low_catalog_numbers_keep_leading_zeros() {
901 let l1 = "1 00005U 58002B 00179.78495062 .00000023 00000-0 28098-4 0 4753";
902 let l2 = "2 00005 34.2682 348.7242 1859667 331.7664 19.3264 10.82419157413667";
903 let parsed = parse(l1, l2).unwrap();
904 assert_eq!(parsed.elements.catalog_number, "00005");
905 assert_eq!(parsed.elements.epoch_year, 2000);
906 }
907
908 #[test]
909 fn rejects_empty_lines() {
910 assert!(parse("", "").is_err());
911 }
912
913 #[test]
914 fn rejects_non_tle_text() {
915 assert!(matches!(
916 parse("hello world", "goodbye world"),
917 Err(TleError::Format)
918 ));
919 }
920
921 #[test]
922 fn rejects_swapped_lines() {
923 assert!(parse(ISS_L2, ISS_L1).is_err());
924 }
925
926 #[test]
927 fn rejects_non_ascii() {
928 assert_eq!(
929 parse("1 25544\u{fc} test", "2 25544\u{fc} test"),
930 Err(TleError::NonAscii)
931 );
932 }
933
934 #[test]
935 fn rejects_mismatched_satellite_numbers() {
936 let l1 = "1 25544U 98067A 18184.80969102 .00001614 00000-0 31745-4 0 9993";
937 let l2 = "2 25545 51.6414 295.8524 0003435 262.6267 204.2868 15.54005638121106";
938 assert_eq!(parse(l1, l2), Err(TleError::SatelliteMismatch));
939 }
940
941 #[test]
942 fn parses_negative_drag_terms() {
943 let parsed = parse(ISS_L1, ISS_L2).unwrap();
945 assert!(parsed.elements.bstar > 0.0);
946 assert_eq!(parsed.elements.mean_motion_double_dot, 0.0);
947 }
948
949 #[test]
950 fn element_bridge_rejects_invalid_values() {
951 let mut el = parse(ISS_L1, ISS_L2).unwrap().elements;
952 el.mean_motion = f64::NAN;
953 assert_eq!(
954 el.to_element_set(),
955 Err(TleError::InvalidField {
956 field: "mean_motion",
957 reason: "not finite"
958 })
959 );
960
961 let mut el = parse(ISS_L1, ISS_L2).unwrap().elements;
962 el.eccentricity = 1.0;
963 assert_eq!(
964 el.to_element_set(),
965 Err(TleError::InvalidField {
966 field: "eccentricity",
967 reason: "out of range"
968 })
969 );
970 }
971
972 #[test]
973 fn assumed_decimal_rounding_carry_bumps_exponent() {
974 let mut el = parse(ISS_L1, ISS_L2).unwrap().elements;
975 el.mean_motion_double_dot = 9.999996e-5;
976 el.bstar = 9.999996e-5;
977
978 let (line1, line2) = encode(&el).unwrap();
979 assert_eq!(slice_inclusive(&line1, 44, 51), " 10000-3");
980 assert_eq!(slice_inclusive(&line1, 53, 60), " 10000-3");
981
982 let parsed = parse(&line1, &line2).unwrap().elements;
983 assert_eq!(parsed.mean_motion_double_dot, 1.0e-4);
984 assert_eq!(parsed.bstar, 1.0e-4);
985
986 let (round_trip_line1, round_trip_line2) = encode(&parsed).unwrap();
987 assert_eq!(round_trip_line1, line1);
988 assert_eq!(round_trip_line2, line2);
989 }
990
991 #[test]
992 fn checksum_mismatch_is_reported_not_rejected() {
993 let bad_l1 = "1 25544U 98067A 18184.80969102 .00001614 00000-0 31745-4 0 9990";
995 let parsed = parse(bad_l1, ISS_L2).unwrap();
996 assert_eq!(parsed.checksum_warnings.len(), 1);
997 assert_eq!(parsed.checksum_warnings[0].line_label, "line 1");
998 assert_eq!(parsed.checksum_warnings[0].expected, 0);
999 assert_eq!(parsed.checksum_warnings[0].computed, 3);
1000 }
1001}