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}