livesplit_core/run/parser/
time_split_tracker.rs

1//! Provides the parser for Time Split Tracker splits files.
2
3use super::super::ComparisonError;
4use crate::{
5    comparison::RACE_COMPARISON_PREFIX,
6    platform::{
7        path::{Path, PathBuf},
8        prelude::*,
9    },
10    timing, RealTime, Run, Segment, Time, TimeSpan,
11};
12#[cfg(feature = "std")]
13use crate::{settings::Image, AtomicDateTime};
14use alloc::borrow::Cow;
15use core::{fmt::Write, num::ParseIntError, result::Result as StdResult};
16use snafu::{OptionExt, ResultExt};
17
18/// The Error type for splits files that couldn't be parsed by the Time
19/// Split Tracker Parser.
20#[derive(Debug, snafu::Snafu)]
21#[snafu(context(suffix(false)))]
22pub enum Error {
23    /// An empty splits file was provided.
24    Empty,
25    /// Expected the attempt count, but didn't find it.
26    ExpectedAttemptCount,
27    /// Failed to parse the attempt count.
28    ParseAttemptCount {
29        /// The underlying error.
30        source: ParseIntError,
31    },
32    /// Expected the start time offset, but didn't find it.
33    ExpectedOffset,
34    /// Failed to parse the start time offset.
35    ParseOffset {
36        /// The underlying error.
37        source: timing::ParseError,
38    },
39    /// Expected the line containing the title, but didn't find it.
40    ExpectedTitleLine,
41    /// Expected the name of the category, but didn't find it.
42    ExpectedCategoryName,
43    /// Expected the name of a segment, but didn't find it.
44    ExpectedSegmentName,
45    /// Expected the best segment time of a segment, but didn't find it.
46    ExpectedBestSegment,
47    /// Failed to parse the best segment time of a segment.
48    ParseBestSegment {
49        /// The underlying error.
50        source: timing::ParseError,
51    },
52    /// Expected the time for a comparison of a segment, but didn't find it.
53    ExpectedComparisonTime,
54    /// Failed to parse the time for a comparison of a segment.
55    ParseComparisonTime {
56        /// The underlying error.
57        source: timing::ParseError,
58    },
59    /// Expected a line containing the icon of a segment, but didn't find it.
60    ExpectedIconLine,
61}
62
63/// The Result type for the Time Split Tracker parser.
64pub type Result<T> = StdResult<T, Error>;
65
66fn parse_time_optional(time: &str) -> StdResult<Option<TimeSpan>, timing::ParseError> {
67    let time: TimeSpan = time.parse()?;
68    if time == TimeSpan::zero() {
69        Ok(None)
70    } else {
71        Ok(Some(time))
72    }
73}
74
75/// Attempts to parse a Time Split Tracker splits file. In addition to the
76/// source to parse, you can specify the path of the splits file, which is then
77/// use to load the run log file from the file system. This is entirely
78/// optional. If you are using livesplit-core in a server-like environment, set
79/// this to `None`. Only client-side applications should provide the path here.
80pub fn parse(
81    source: &str,
82    #[allow(unused)] path_for_loading_other_files: Option<&Path>,
83) -> Result<Run> {
84    let mut run = Run::new();
85    #[cfg(feature = "std")]
86    let mut buf = Vec::new();
87
88    let mut lines = source.lines();
89
90    let line = lines.next().context(Empty)?;
91    let mut splits = line.split('\t');
92
93    let attempt_count = splits.next().context(ExpectedAttemptCount)?;
94    if !attempt_count.is_empty() {
95        run.set_attempt_count(attempt_count.parse().context(ParseAttemptCount)?);
96    }
97
98    run.set_offset(
99        splits
100            .next()
101            .context(ExpectedOffset)?
102            .parse()
103            .context(ParseOffset)?,
104    );
105
106    #[cfg(feature = "std")]
107    let mut path = path_for_loading_other_files.map(Path::to_path_buf);
108
109    #[cfg(feature = "std")]
110    catch! {
111        let path = path.as_mut()?;
112        path.set_file_name(splits.next()?);
113        let image = Image::from_file(path, &mut buf).ok()?;
114        run.set_game_icon(image);
115    };
116
117    let line = lines.next().context(ExpectedTitleLine)?;
118    let mut splits = line.split('\t');
119    run.set_category_name(splits.next().context(ExpectedCategoryName)?);
120    splits.next(); // Skip one element
121
122    let mut comparisons = splits.map(Cow::Borrowed).collect::<Vec<_>>();
123
124    for comparison in &mut comparisons {
125        let orig_len = comparison.len();
126        let mut number = 2;
127        loop {
128            match run.add_custom_comparison(&**comparison) {
129                Ok(_) => break,
130                Err(ComparisonError::DuplicateName) => {
131                    let comparison = comparison.to_mut();
132                    comparison.drain(orig_len..);
133                    let _ = write!(comparison, " {number}");
134                    number += 1;
135                }
136                Err(ComparisonError::NameStartsWithRace) => {
137                    let comparison = comparison.to_mut();
138                    // After removing the `[Race]`, there might be some
139                    // whitespace we want to trim too.
140                    let len_after_trimming = comparison[RACE_COMPARISON_PREFIX.len()..]
141                        .trim_start()
142                        .len();
143                    let shrunk_by = comparison.len() - len_after_trimming;
144                    comparison.drain(..shrunk_by);
145                }
146            }
147        }
148    }
149
150    while let Some(line) = lines.next() {
151        if line.is_empty() {
152            continue;
153        }
154
155        let mut splits = line.split('\t');
156        let mut segment = Segment::new(splits.next().context(ExpectedSegmentName)?);
157        let best_segment = parse_time_optional(splits.next().context(ExpectedBestSegment)?)
158            .context(ParseBestSegment)?;
159        segment.set_best_segment_time(RealTime(best_segment).into());
160
161        let mut pb_time = Time::new();
162        for comparison in &comparisons {
163            let time = segment.comparison_mut(comparison);
164            pb_time.real_time = parse_time_optional(splits.next().context(ExpectedComparisonTime)?)
165                .context(ParseComparisonTime)?;
166            time.real_time = pb_time.real_time;
167        }
168        segment.set_personal_best_split_time(pb_time);
169
170        let _line = lines.next().context(ExpectedIconLine)?;
171
172        #[cfg(feature = "std")]
173        catch! {
174            let file = _line.trim_end();
175            if !file.is_empty() {
176                let path = path.as_mut()?;
177                path.set_file_name(file);
178                let image = Image::from_file(path, &mut buf).ok()?;
179                segment.set_icon(image);
180            }
181        };
182
183        run.push_segment(segment);
184    }
185
186    #[cfg(feature = "std")]
187    parse_history(&mut run, path).ok();
188
189    Ok(run)
190}
191
192#[cfg(feature = "std")]
193fn parse_history(run: &mut Run, path: Option<PathBuf>) -> StdResult<(), ()> {
194    if let Some(mut path) = path {
195        path.set_extension("");
196        let mut path = path.into_os_string();
197        path.push("-RunLog.txt");
198        let path = PathBuf::from(path);
199
200        let file = std::fs::read_to_string(path).map_err(drop)?;
201        let mut lines = file.lines();
202        let mut attempt_id = 1;
203
204        lines.next(); // Skip the first line
205
206        for line in lines {
207            let mut splits = line.split('\t');
208            let time_stamp = splits.next().ok_or(())?;
209            let started = parse_date(time_stamp).ok_or(())?;
210            let completed = splits.next().ok_or(())? == "C";
211            let split_times: Vec<_> = splits
212                .map(parse_time_optional)
213                .collect::<StdResult<_, _>>()
214                .map_err(drop)?;
215            let mut final_time = Time::default();
216            let mut ended = None;
217            if completed {
218                catch! {
219                    let last_split_time = split_times.last()?;
220                    final_time.real_time = *last_split_time;
221                    let final_time = final_time.real_time?;
222                    let ended_date = started + final_time.to_duration();
223                    ended = Some(AtomicDateTime::new(ended_date, false));
224                };
225            }
226
227            run.add_attempt_with_index(
228                final_time,
229                attempt_id,
230                Some(AtomicDateTime::new(started, false)),
231                ended,
232                None,
233            );
234
235            let mut last_split = TimeSpan::zero();
236            for (segment, current_split) in
237                run.segments_mut().iter_mut().zip(split_times.into_iter())
238            {
239                let mut segment_time = Time::default();
240                if let Some(current_split) = current_split {
241                    segment_time.real_time = Some(current_split - last_split);
242                    last_split = current_split;
243                }
244
245                segment
246                    .segment_history_mut()
247                    .insert(attempt_id, segment_time);
248
249                if catch! {
250                    segment_time.real_time? < segment.best_segment_time().real_time?
251                }
252                .unwrap_or(false)
253                {
254                    segment.set_best_segment_time(segment_time);
255                }
256            }
257
258            attempt_id += 1;
259        }
260    }
261    Ok(())
262}
263
264#[cfg(feature = "std")]
265fn parse_date(text: &str) -> Option<crate::DateTime> {
266    let (year, rem) = text.split_once('/')?;
267    let (month, rem) = rem.split_once('/')?;
268    let (day, rem) = rem.split_once(' ')?;
269    let (hour, minute) = rem.split_once(':')?;
270    Some(
271        time::PrimitiveDateTime::new(
272            time::Date::from_calendar_date(
273                year.parse().ok()?,
274                month.parse::<u8>().ok()?.try_into().ok()?,
275                day.parse().ok()?,
276            )
277            .ok()?,
278            time::Time::from_hms(hour.parse().ok()?, minute.parse().ok()?, 0).ok()?,
279        )
280        .assume_utc(),
281    )
282}