subjective/
lib.rs

1//! Subjective's Rust library.
2//! Use this in your applications to interface with Subjective's data.
3#![warn(clippy::pedantic, clippy::nursery, clippy::cargo, missing_docs)]
4#![allow(clippy::multiple_crate_versions)]
5
6use std::{
7    fs::File,
8    io::{self, Read},
9    path::{Path, PathBuf},
10};
11
12use chrono::{Datelike, NaiveDate, NaiveDateTime};
13use school::{bells::BellTime, Day, School};
14use serde::{Deserialize, Serialize};
15use serde_json::from_str;
16use subjects::Subject;
17/// Colors used for subjects.
18pub mod color;
19/// School related structures.
20pub mod school;
21/// Subject related structures.
22pub mod subjects;
23
24use thiserror::Error;
25use uuid::Uuid;
26
27/// Errors that can occur when loading Subjective data.
28#[derive(Error, Debug)]
29pub enum LoadDataError {
30    /// The Subjective data file was not found.
31    #[error("Couldn't find Subjective data file, which was expected at {0:?}.")]
32    DataFileNotFound(PathBuf, io::Error),
33    /// The Subjective data file could not be read.
34    #[error("Failed to read Subjective data file.")]
35    DataFileReadError(io::Error),
36    /// The Subjective data file could not be parsed.
37    #[error("Failed to parse Subjective data file. This may be due to invalid or outdated data. Try re-exporting your data again.")]
38    DataFileParseError(serde_json::Error),
39}
40
41/// Errors that can occur when retrieving bells.
42#[derive(Error, Debug)]
43pub enum FindBellError {
44    /// The specified weekday is out of range.
45    #[error("The ISO 8601 weekday number {0} is out of the range `1..=5`.")]
46    WeekdayOutOfRange(usize),
47    /// No bell was found.
48    #[error("No bell was found.")]
49    NoBellFound,
50}
51
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
53/// Structure of a Subjective data file.
54pub struct Subjective {
55    /// School data.
56    pub school: School,
57    /// Subject data.
58    pub subjects: Vec<Subject>,
59}
60
61impl Subjective {
62    /// Load Subjective data from a config directory.
63    ///
64    /// # Errors
65    /// This function will return an error if the data file is not found, cannot be read, or cannot be parsed.
66    pub fn from_config(config_directory: &Path) -> Result<Self, LoadDataError> {
67        let timetable_path = config_directory.join(".subjective");
68        let mut timetable = File::open(timetable_path.clone())
69            .map_err(|error| LoadDataError::DataFileNotFound(timetable_path, error))?;
70        let mut raw = String::new();
71        timetable
72            .read_to_string(&mut raw)
73            .map_err(LoadDataError::DataFileReadError)?;
74        let data = from_str(&raw).map_err(LoadDataError::DataFileParseError)?;
75        Ok(data)
76    }
77
78    #[must_use]
79    /// Create a new Subjective data structure.
80    pub fn new(school: School, subjects: Vec<Subject>) -> Self {
81        Self { school, subjects }
82    }
83
84    #[must_use]
85    /// Create a new Subjective data structure from a school and an empty subject list.
86    pub fn from_school(school: School) -> Self {
87        Self {
88            school,
89            subjects: Vec::new(),
90        }
91    }
92
93    /// Find all bells after a given time, on a specified weekday.
94    /// Searches are not continued over days, so if the time is after the last bell on the specified day, it does not search the next day.
95    /// The bells are returned in ascending order.
96    ///
97    /// # Errors
98    ///
99    /// This function will return an error if the weekday is out of range ([`FindBellError::WeekdayOutOfRange`]).
100    /// If no bells are found, because there are no bell times after the given time for the specified day, it returns ([`FindBellError::NoBellFound`]).
101    pub fn find_all_after(
102        &self,
103        date_time: NaiveDateTime,
104        variant_offset: usize,
105    ) -> Result<Vec<&BellTime>, FindBellError> {
106        let day = self.get_day(date_time.date(), variant_offset)?;
107        let time = date_time.time();
108        let bells: Vec<&BellTime> = day
109            .iter()
110            .filter(|bell| bell.time >= time && bell.enabled)
111            .collect();
112        if bells.is_empty() {
113            return Err(FindBellError::NoBellFound);
114        }
115        Ok(bells)
116    }
117
118    /// Find all bells before a given time, on a specified weekday.
119    /// Searches are not continued over days, so if the time is before the first bell on the
120    /// specified day, it does not search the previous day.
121    /// The bells are returned in descending order.
122    ///
123    /// # Errors
124    ///
125    /// This function will return an error if the weekday is out of range
126    /// ([`FindBellError::WeekdayOutOfRange`]).
127    /// If no bells are found, because there are no bell times before the given time for the
128    /// specified day, it returns ([`FindBellError::NoBellFound`]).
129    pub fn find_all_before(
130        &self,
131        date_time: NaiveDateTime,
132        variant_offset: usize,
133    ) -> Result<Vec<&BellTime>, FindBellError> {
134        let day = self.get_day(date_time.date(), variant_offset)?;
135        let time = date_time.time();
136        let bells: Vec<&BellTime> = day
137            .iter()
138            .rev()
139            .filter(|bell| bell.time <= time && bell.enabled)
140            .collect();
141        if bells.is_empty() {
142            return Err(FindBellError::NoBellFound);
143        }
144        Ok(bells)
145    }
146
147    /// Find the first bell after a given time, on a specified weekday.
148    /// Searches are not continued over days, so if the time is after the last bell on the specified
149    /// day, it does not search the next day.
150    ///
151    /// # Errors
152    ///
153    /// This function will return an error if the weekday is out of range
154    /// ([`FindBellError::WeekdayOutOfRange`]).
155    /// If no bell is found, because there are no bell times after the given time for the specified
156    /// day, it returns ([`FindBellError::NoBellFound`]).
157    pub fn find_first_after(
158        &self,
159        date_time: NaiveDateTime,
160        variant_offset: usize,
161    ) -> Result<&BellTime, FindBellError> {
162        let day = self.get_day(date_time.date(), variant_offset)?;
163        let time = date_time.time();
164        day.iter()
165            .find(|bell| bell.time >= time && bell.enabled)
166            .ok_or(FindBellError::NoBellFound)
167    }
168
169    /// Find the first bell before a given time, on a specified weekday.
170    /// Searches are not continued over days, so if the time is before the first bell on the
171    /// specified day, it does not search the previous day.
172    ///
173    /// # Errors
174    ///
175    /// This function will return an error if the weekday is out of range
176    /// ([`FindBellError::WeekdayOutOfRange`]).
177    /// If no bell is found, because there are no bell times before the given time for the specified
178    /// day, it returns ([`FindBellError::NoBellFound`]).
179    pub fn find_first_before(
180        &self,
181        date_time: NaiveDateTime,
182        variant_offset: usize,
183    ) -> Result<&BellTime, FindBellError> {
184        let day = self.get_day(date_time.date(), variant_offset)?;
185        let time = date_time.time();
186        day.iter()
187            .rev()
188            .find(|bell| bell.time <= time && bell.enabled)
189            .ok_or(FindBellError::NoBellFound)
190    }
191
192    /// Get the day for a given date, calculating the current variant using
193    ///
194    /// `current_variant = (week_number + variant_offset) % weeks`.
195    ///
196    /// # Errors
197    /// This function will return an error if the weekday is out of range ([`FindBellError::WeekdayOutOfRange`]).
198    #[allow(clippy::cast_sign_loss)]
199    pub fn get_day(&self, date: NaiveDate, variant_offset: usize) -> Result<&Day, FindBellError> {
200        let weekday = date.weekday().number_from_monday() as usize;
201        let current_variant =
202            get_current_variant(date, variant_offset, self.school.bell_times.len());
203        let bell_times = &self.school.bell_times[current_variant].1;
204        let day = bell_times
205            .get(weekday)
206            .ok_or(FindBellError::WeekdayOutOfRange(weekday))?;
207        Ok(day)
208    }
209
210    #[must_use]
211    /// Get the subject with the given ID.
212    ///
213    /// # Errors
214    /// This function will return [`None`] if no subject with the given ID is found.
215    pub fn get_subject(&self, subject_id: Uuid) -> Option<&Subject> {
216        self.subjects
217            .iter()
218            .find(|subject| subject.id == subject_id)
219    }
220}
221
222/// Get the current variant for a given date, variant offset, and number of variants.
223#[allow(clippy::cast_possible_truncation, clippy::cast_possible_wrap)]
224#[must_use]
225pub fn get_current_variant(date: NaiveDate, variant_offset: usize, variants: usize) -> usize {
226    let weeks = variants;
227    let week_number = date.iso_week().week() as usize;
228    (week_number + variant_offset) % weeks
229}