aquarium_control/food/
feed_schedule_entry.rs

1/* Copyright 2024 Uwe Martin
2
3Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
4
5The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
6
7THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
8*/
9
10//! Defines the application-level representation of a single feed schedule entry.
11//!
12//! This module provides the `FeedScheduleEntry` struct, which is a clean, type-safe
13//! representation of a scheduled feeding event. Its primary role is to act as a
14//! "hydrated" data model, converting raw data retrieved from the database into a
15//! format that the application's business logic can safely and easily work with.
16//!
17//! ## Key Components
18//!
19//! - **`FeedScheduleEntry` Struct**: The core data structure holding a `NaiveDateTime`,
20//!   the associated feed profile ID and name, and a boolean `repeat_daily` flag.
21//!
22//! - **`new()` Constructor**: A fallible constructor that takes a raw `SqlFeedScheduleEntry`
23//!   (as defined in `sql_interface_feed`) and performs the critical conversion of
24//!   the string-based timestamp into a `chrono::NaiveDateTime`. It also converts the
25//!   numeric `repeat_daily` flag into a proper boolean.
26//!
27//! - **`fmt::Display` Implementation**: Provides a simple, human-readable string format
28//!   for the struct, which is useful for logging and debugging purposes.
29//!
30//! ## Design and Architecture
31//!
32//! This module serves as a crucial boundary between the database layer and the
33//! application's core logic.
34//!
35//! - **Data Hydration and Type Safety**: The main purpose of this struct is to "hydrate"
36//!   data from the database. By parsing the timestamp string into a `NaiveDateTime`
37//!   at the earliest opportunity, the rest of the application is protected from
38//!   handling raw strings and can leverage the powerful, type-safe operations
39//!   provided by the `chrono` crate. This prevents a whole class of potential bugs
40//!   related to malformed date strings.
41//!
42//! - **Robust Error Handling**: The `new()` constructor returns a `Result`, explicitly
43//!   handling the possibility that the timestamp data in the database might be
44//!   corrupt or in an unexpected format. This forces the calling code to deal with
45//!   potential data integrity issues gracefully.
46//!
47//! - **Testability**: The inclusion of a `#[cfg(test)]` constructor, `new_for_test`,
48//!   allows unit tests to create instances of `FeedScheduleEntry` easily without needing
49//!   to construct a raw `SqlFeedScheduleEntry` first.
50
51use chrono::NaiveDateTime;
52use mysql::*;
53use std::fmt;
54
55use crate::database::sql_interface_error::SqlInterfaceError::FeedScheduleEntryRepeatDailyOutOfRange;
56use crate::database::{
57    sql_interface_error::SqlInterfaceError, sql_interface_feed::SqlFeedScheduleEntry,
58};
59
60/// Holds the data of a feed schedule entry derived from data read from the database.
61#[derive(Debug, PartialEq, Clone)]
62pub struct FeedScheduleEntry {
63    pub timestamp: NaiveDateTime,
64    pub profile_id: i32,
65    pub profile_name: String,
66    pub repeat_daily: bool,
67}
68
69impl FeedScheduleEntry {
70    /// Creates a new `FeedScheduleEntry` by converting raw data from an `SqlFeedScheduleEntry`.
71    ///
72    /// This constructor takes a raw database entry (`SqlFeedScheduleEntry`) and
73    /// transforms it to `FeedScheduleEntry`. This is a critical step in
74    /// hydrating application-level structs from database records.
75    ///
76    /// # Arguments
77    /// * `sql_feed_schedule_entry` - A reference to a `SqlFeedScheduleEntry` struct,
78    ///   containing data typically read directly from the SQL database.
79    ///
80    /// # Returns
81    /// A `Result` containing a new, fully typed `FeedScheduleEntry` on success.
82    ///
83    /// # Errors
84    /// Returns `SqlInterfaceError::FeedScheduleEntryRepeatDailyOutOfRange` if
85    /// `repeat_daily` is not either zero or one.
86    pub fn new(
87        sql_feed_schedule_entry: &SqlFeedScheduleEntry,
88    ) -> Result<FeedScheduleEntry, SqlInterfaceError> {
89        if (sql_feed_schedule_entry.repeat_daily > 1) || (sql_feed_schedule_entry.repeat_daily < 0)
90        {
91            return Err(FeedScheduleEntryRepeatDailyOutOfRange {
92                location: module_path!().to_string(),
93                repeat_daily: sql_feed_schedule_entry.repeat_daily,
94            });
95        }
96        Ok(FeedScheduleEntry {
97            timestamp: sql_feed_schedule_entry.timestamp,
98            profile_id: sql_feed_schedule_entry.profile_id,
99            profile_name: sql_feed_schedule_entry.profile_name.clone(),
100            repeat_daily: sql_feed_schedule_entry.repeat_daily != 0,
101        })
102    }
103
104    #[cfg(test)]
105    /// Creates a new `FeedScheduleEntry` for use in test environments.
106    ///
107    /// This test-only helper function simplifies the creation of `FeedScheduleEntry`
108    /// instances by accepting primitive types directly.
109    ///
110    /// # Arguments
111    /// * `timestamp_string` - A string slice representing the timestamp in `YYYY-MM-DD HH:MM:SS` format.
112    /// * `profile_id` - The numeric identifier for the feed profile.
113    /// * `profile_name` - A string slice for the name of the feed profile.
114    /// * `repeat_daily` - A boolean indicating if the schedule should repeat daily.
115    ///
116    /// # Returns
117    /// A new `FeedScheduleEntry` instance.
118    ///
119    /// # Panics
120    /// This function will panic if the provided `timestamp_string` cannot be parsed
121    /// into a valid `NaiveDateTime`. This is intentional, as test setup data is
122    /// expected to be valid, and a failure indicates a flaw in the test itself.
123    pub fn new_for_test(
124        timestamp_string: &str,
125        profile_id: i32,
126        profile_name: &str,
127        repeat_daily: bool,
128    ) -> FeedScheduleEntry {
129        FeedScheduleEntry {
130            timestamp: NaiveDateTime::parse_from_str(timestamp_string, "%Y-%m-%d %H:%M:%S")
131                .unwrap(),
132            profile_id,
133            profile_name: profile_name.to_string(),
134            repeat_daily,
135        }
136    }
137}
138
139impl fmt::Display for FeedScheduleEntry {
140    /// Formats the `FeedScheduleEntry` for display.
141    ///
142    /// This implementation provides a concise, single-line string representation
143    /// of the schedule entry, suitable for logging and debugging. It includes the
144    /// timestamp, profile ID, profile name, and daily repetition status.
145    ///
146    /// # Arguments
147    /// * `f` - A mutable reference to the formatter, as required by the `fmt::Display` trait.
148    ///
149    /// # Returns
150    /// A `fmt::Result` containing `Ok(())` if the formatting was successful.
151    ///
152    /// # Errors
153    /// Returns an `Err` if the `write!` macro fails to write to the underlying
154    /// formatter. This is an uncommon error but can occur if the formatter
155    /// itself is in an error state or if the destination (e.g., a file or buffer)
156    /// reports an I/O error.
157    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
158        write!(
159            f,
160            "timestamp={}, profile_id={}, profile_name={}, repeat_daily={}",
161            self.timestamp, self.profile_id, self.profile_name, self.repeat_daily,
162        )
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use chrono::NaiveDate;
170
171    /// Helper function to create a `SqlFeedScheduleEntry` for testing.
172    fn create_sql_entry(repeat_daily: i16) -> SqlFeedScheduleEntry {
173        SqlFeedScheduleEntry {
174            timestamp: NaiveDate::from_ymd_opt(2024, 1, 1)
175                .unwrap()
176                .and_hms_opt(12, 0, 0)
177                .unwrap(),
178            profile_id: 101,
179            profile_name: "Test Profile".to_string(),
180            repeat_daily,
181        }
182    }
183
184    /// Tests the successful creation of a `FeedScheduleEntry` when `repeat_daily` is 0.
185    #[test]
186    fn test_new_with_repeat_daily_zero() {
187        println!("* Testing FeedScheduleEntry::new with repeat_daily = 0...");
188        // --- Setup ---
189        let sql_entry = create_sql_entry(0);
190
191        // --- Execution ---
192        let result = FeedScheduleEntry::new(&sql_entry);
193
194        // --- Assertions ---
195        assert!(result.is_ok(), "Expected Ok, but got Err: {:?}", result);
196        let entry = result.unwrap();
197        assert_eq!(
198            entry.repeat_daily, false,
199            "repeat_daily should be false when the input is 0"
200        );
201        assert_eq!(entry.profile_id, 101);
202        assert_eq!(entry.profile_name, "Test Profile");
203        println!("* Succeeded: Correctly created entry with repeat_daily=false.");
204    }
205
206    /// Tests the successful creation of a `FeedScheduleEntry` when `repeat_daily` is 1.
207    #[test]
208    fn test_new_with_repeat_daily_one() {
209        println!("* Testing FeedScheduleEntry::new with repeat_daily = 1...");
210        // --- Setup ---
211        let sql_entry = create_sql_entry(1);
212
213        // --- Execution ---
214        let result = FeedScheduleEntry::new(&sql_entry);
215
216        // --- Assertions ---
217        assert!(result.is_ok(), "Expected Ok, but got Err: {:?}", result);
218        let entry = result.unwrap();
219        assert_eq!(
220            entry.repeat_daily, true,
221            "repeat_daily should be true when the input is 1"
222        );
223        println!("* Succeeded: Correctly created entry with repeat_daily=true.");
224    }
225
226    /// Tests that `new` returns an error when `repeat_daily` is greater than 1.
227    #[test]
228    fn test_new_with_repeat_daily_out_of_range_positive() {
229        println!("* Testing FeedScheduleEntry::new with repeat_daily > 1...");
230        // --- Setup ---
231        let sql_entry = create_sql_entry(2);
232
233        // --- Execution ---
234        let result = FeedScheduleEntry::new(&sql_entry);
235
236        // --- Assertions ---
237        assert!(result.is_err(), "Expected an error for out-of-range value");
238        let error = result.unwrap_err();
239        assert!(
240            matches!(
241                error,
242                FeedScheduleEntryRepeatDailyOutOfRange {
243                    repeat_daily: 2,
244                    ..
245                }
246            ),
247            "Expected FeedScheduleEntryRepeatDailyOutOfRange error with value 2, but got {:?}",
248            error
249        );
250        println!("* Succeeded: Correctly returned error for repeat_daily=2.");
251    }
252
253    /// Tests that `new` returns an error when `repeat_daily` is less than 0.
254    #[test]
255    fn test_new_with_repeat_daily_out_of_range_negative() {
256        println!("* Testing FeedScheduleEntry::new with repeat_daily < 0...");
257        // --- Setup ---
258        let sql_entry = create_sql_entry(-1);
259
260        // --- Execution ---
261        let result = FeedScheduleEntry::new(&sql_entry);
262
263        // --- Assertions ---
264        assert!(result.is_err(), "Expected an error for out-of-range value");
265        let error = result.unwrap_err();
266        assert!(
267            matches!(
268                error,
269                FeedScheduleEntryRepeatDailyOutOfRange {
270                    repeat_daily: -1,
271                    ..
272                }
273            ),
274            "Expected FeedScheduleEntryRepeatDailyOutOfRange error with value -1, but got {:?}",
275            error
276        );
277        println!("* Succeeded: Correctly returned error for repeat_daily=-1.");
278    }
279}