1mod utils;
2use crate::error::{HostCraftError, Result};
3use crate::host::utils::{is_duplicate_entry, parse_line};
4use serde::Serialize;
5use std::{fmt, io, net::IpAddr, result};
6
7#[derive(Debug, Clone, PartialEq, Serialize)]
8pub enum HostStatus {
9 Active,
10 Inactive,
11}
12
13impl fmt::Display for HostStatus {
14 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
15 match self {
16 HostStatus::Active => write!(f, "Active"),
17 HostStatus::Inactive => write!(f, "Inactive"),
18 }
19 }
20}
21
22#[derive(Debug, Clone, PartialEq, Serialize)]
23pub struct HostEntry {
24 pub status: HostStatus,
25 pub ip: IpAddr,
26 pub name: String,
27}
28
29impl HostEntry {
30 pub fn toggle(&mut self) {
31 self.status = match self.status {
32 HostStatus::Active => HostStatus::Inactive,
33 HostStatus::Inactive => HostStatus::Active,
34 };
35 }
36}
37
38impl fmt::Display for HostEntry {
39 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40 write!(f, "{}: {} is {}", self.ip, self.name, self.status)
41 }
42}
43
44pub fn parse_contents(contents: impl Iterator<Item = io::Result<String>>) -> Vec<HostEntry> {
45 contents
46 .map_while(result::Result::ok)
47 .filter_map(|line| parse_line(&line))
48 .collect()
49}
50
51pub fn add_entry(entries: &mut Vec<HostEntry>, ip: IpAddr, name: impl Into<String>) -> Result<()> {
52 let name = name.into();
53 if is_duplicate_entry(entries, ip, &name) {
54 return Err(HostCraftError::DuplicateEntry);
55 }
56 entries.push(HostEntry {
57 status: HostStatus::Active,
58 ip,
59 name,
60 });
61 Ok(())
62}
63
64pub fn edit_entry(
65 entries: &mut Vec<HostEntry>,
66 old_name: impl Into<String>,
67 new_ip: IpAddr,
68 new_name: impl Into<String>,
69) -> Result<()> {
70 let old_name = old_name.into();
71 let new_name = new_name.into();
72
73 let pos = entries
74 .iter()
75 .position(|e| e.name == old_name)
76 .ok_or(HostCraftError::EntryNotFound)?;
77 if entries[pos].ip == new_ip && entries[pos].name == new_name {
78 return Err(HostCraftError::NoChange);
79 }
80 if is_duplicate_entry(entries, new_ip, &new_name) {
81 return Err(HostCraftError::DuplicateEntry);
82 }
83 entries[pos].ip = new_ip;
84 entries[pos].name = new_name.to_string();
85 Ok(())
86}
87
88pub fn remove_entry(entries: &mut Vec<HostEntry>, name: &str) -> Result<()> {
89 let original_len = entries.len();
90 entries.retain(|e| e.name != name);
91 if entries.len() == original_len {
92 return Err(HostCraftError::EntryNotFound);
93 }
94 Ok(())
95}
96
97pub fn remove_entries_matching(entries: &mut Vec<HostEntry>, pattern: &str) -> Result<usize> {
98 let original_len = entries.len();
99 entries.retain(|e| !e.name.contains(pattern));
100 let removed = original_len - entries.len();
101 if removed == 0 {
102 return Err(HostCraftError::EntryNotFound);
103 }
104 Ok(removed)
105}
106
107pub fn toggle_entry(entries: &mut Vec<HostEntry>, name: &str) -> Result<()> {
108 let mut toggled = 0;
109 for entry in entries.iter_mut() {
110 if entry.name == name {
111 entry.toggle();
112 toggled += 1;
113 }
114 }
115 if toggled == 0 {
116 return Err(HostCraftError::EntryNotFound);
117 }
118 Ok(())
119}
120
121pub fn toggle_entries_matching(entries: &mut Vec<HostEntry>, pattern: &str) -> Result<usize> {
122 let mut toggled = 0;
123 for entry in entries.iter_mut() {
124 if entry.name.contains(pattern) {
125 entry.toggle();
126 toggled += 1;
127 }
128 }
129 if toggled == 0 {
130 return Err(HostCraftError::EntryNotFound);
131 }
132 Ok(toggled)
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use std::io;
139 use std::net::{IpAddr, Ipv4Addr};
140
141 fn ip(a: u8, b: u8, c: u8, d: u8) -> IpAddr {
144 IpAddr::V4(Ipv4Addr::new(a, b, c, d))
145 }
146
147 fn make_entry(ip_addr: IpAddr, name: &str, status: HostStatus) -> HostEntry {
148 HostEntry {
149 ip: ip_addr,
150 name: name.to_string(),
151 status,
152 }
153 }
154
155 fn sample_entries() -> Vec<HostEntry> {
156 vec![
157 make_entry(ip(127, 0, 0, 1), "alpha.com", HostStatus::Active),
158 make_entry(ip(192, 168, 0, 1), "beta.com", HostStatus::Inactive),
159 ]
160 }
161
162 fn to_lines(lines: &[&str]) -> std::vec::IntoIter<io::Result<String>> {
163 lines
164 .iter()
165 .map(|l| Ok(l.to_string()))
166 .collect::<Vec<_>>()
167 .into_iter()
168 }
169
170 #[test]
173 fn parse_contents_mixed_lines_returns_correct_entries() {
174 let lines = to_lines(&["127.0.0.1 active.com", "# 192.168.0.1 inactive.com"]);
175 let entries = parse_contents(lines);
176 assert_eq!(entries.len(), 2);
177 assert_eq!(entries[0].ip, ip(127, 0, 0, 1));
178 assert_eq!(entries[0].name, "active.com");
179 assert_eq!(entries[0].status, HostStatus::Active);
180 assert_eq!(entries[1].ip, ip(192, 168, 0, 1));
181 assert_eq!(entries[1].name, "inactive.com");
182 assert_eq!(entries[1].status, HostStatus::Inactive);
183 }
184
185 #[test]
186 fn parse_contents_skips_non_matching_lines() {
187 let lines = to_lines(&[
188 "127.0.0.1 active.com",
189 "# This is a comment",
190 "",
191 "192.168.0.1 another.com",
192 ]);
193 let entries = parse_contents(lines);
194 assert_eq!(entries.len(), 2);
195 }
196
197 #[test]
198 fn parse_contents_empty_input_returns_empty_vec() {
199 let entries = parse_contents(std::iter::empty::<io::Result<String>>());
200 assert!(entries.is_empty());
201 }
202
203 #[test]
206 fn add_entry_new_entry_returns_ok_and_appends() {
207 let mut entries = sample_entries();
208 let result = add_entry(&mut entries, ip(10, 0, 0, 1), "new.com".to_string());
209 assert!(result.is_ok());
210 assert_eq!(entries.len(), 3);
211 assert_eq!(entries[2].ip, ip(10, 0, 0, 1));
212 assert_eq!(entries[2].name, "new.com");
213 assert_eq!(entries[2].status, HostStatus::Active);
214 }
215
216 #[test]
217 fn add_entry_duplicate_returns_err_and_leaves_entries_unchanged() {
218 let mut entries = sample_entries();
219 let result = add_entry(&mut entries, ip(127, 0, 0, 1), "alpha.com".to_string());
220 assert!(matches!(result, Err(HostCraftError::DuplicateEntry)));
221 assert_eq!(entries.len(), 2);
222 }
223 #[test]
225 fn test_edit_updates_ip_and_name() {
226 let mut entries = sample_entries();
227 edit_entry(&mut entries, "alpha.com", ip(10, 0, 0, 1), "new-alpha.com").unwrap();
228 assert_eq!(entries[0].ip, ip(10, 0, 0, 1));
229 assert_eq!(entries[0].name, "new-alpha.com");
230 }
231
232 #[test]
233 fn test_edit_preserves_status_and_position() {
234 let mut entries = sample_entries();
235 edit_entry(&mut entries, "beta.com", ip(10, 0, 0, 2), "new-beta.com").unwrap();
236 assert_eq!(entries[1].name, "new-beta.com");
237 assert_eq!(entries[1].status, HostStatus::Inactive); }
239
240 #[test]
241 fn test_partial_name_does_not_match() {
242 let mut entries = sample_entries();
243 let result = edit_entry(&mut entries, "alpha", ip(10, 0, 0, 1), "new.com");
244 assert!(matches!(result, Err(HostCraftError::EntryNotFound)));
245 }
246
247 #[test]
248 fn test_no_match_returns_not_found() {
249 let mut entries = sample_entries();
250 let result = edit_entry(&mut entries, "notexist.com", ip(10, 0, 0, 1), "new.com");
251 assert!(matches!(result, Err(HostCraftError::EntryNotFound)));
252 }
253
254 #[test]
255 fn test_duplicate_in_different_entry_returns_error() {
256 let mut entries = sample_entries();
257 let result = edit_entry(&mut entries, "alpha.com", ip(192, 168, 0, 1), "beta.com");
258 assert!(matches!(result, Err(HostCraftError::DuplicateEntry)));
259 }
260
261 #[test]
262 fn test_edit_to_same_values_fails() {
263 let mut entries = sample_entries();
264 let result = edit_entry(&mut entries, "alpha.com", ip(127, 0, 0, 1), "alpha.com");
265 assert!(matches!(result, Err(HostCraftError::NoChange)));
266 }
267 #[test]
270 fn remove_entry_exact_name_match_removes_entry() {
271 let mut entries = sample_entries();
272 let result = remove_entry(&mut entries, "alpha.com");
273 assert!(result.is_ok());
274 assert_eq!(entries.len(), 1);
275 assert_eq!(entries[0].name, "beta.com");
276 }
277
278 #[test]
279 fn remove_entry_partial_name_does_not_remove_entries() {
280 let mut entries = vec![
281 make_entry(ip(127, 0, 0, 1), "dev.hostcraft.com", HostStatus::Active),
282 make_entry(
283 ip(192, 168, 0, 1),
284 "staging.hostcraft.com",
285 HostStatus::Active,
286 ),
287 make_entry(ip(10, 0, 0, 1), "unrelated.com", HostStatus::Active),
288 ];
289 let result = remove_entry(&mut entries, "hostcraft");
290 assert!(matches!(result, Err(HostCraftError::EntryNotFound)));
291 assert_eq!(entries.len(), 3);
292 }
293
294 #[test]
295 fn remove_entry_no_match_returns_err_and_leaves_entries_unchanged() {
296 let mut entries = sample_entries();
297 let result = remove_entry(&mut entries, "nonexistent.com");
298 assert!(matches!(result, Err(HostCraftError::EntryNotFound)));
299 assert_eq!(entries.len(), 2);
300 }
301
302 #[test]
303 fn remove_entries_matching_partial_name_removes_all_matching_and_returns_count() {
304 let mut entries = vec![
305 make_entry(ip(127, 0, 0, 1), "dev.hostcraft.com", HostStatus::Active),
306 make_entry(
307 ip(192, 168, 0, 1),
308 "staging.hostcraft.com",
309 HostStatus::Active,
310 ),
311 make_entry(ip(10, 0, 0, 1), "unrelated.com", HostStatus::Active),
312 ];
313 let result = remove_entries_matching(&mut entries, "hostcraft");
314 assert_eq!(result.expect("expected two entries to be removed"), 2);
315 assert_eq!(entries.len(), 1);
316 assert_eq!(entries[0].name, "unrelated.com");
317 }
318
319 #[test]
320 fn remove_entries_matching_no_match_returns_err_and_leaves_entries_unchanged() {
321 let mut entries = sample_entries();
322 let result = remove_entries_matching(&mut entries, "nonexistent.com");
323 assert!(matches!(result, Err(HostCraftError::EntryNotFound)));
324 assert_eq!(entries.len(), 2);
325 }
326
327 #[test]
330 fn toggle_entry_active_becomes_inactive() {
331 let mut entries = sample_entries();
332 let result = toggle_entry(&mut entries, "alpha.com");
333 assert!(result.is_ok());
334 assert_eq!(entries[0].status, HostStatus::Inactive);
335 }
336
337 #[test]
338 fn toggle_entry_inactive_becomes_active() {
339 let mut entries = sample_entries();
340 let result = toggle_entry(&mut entries, "beta.com");
341 assert!(result.is_ok());
342 assert_eq!(entries[1].status, HostStatus::Active);
343 }
344
345 #[test]
346 fn toggle_entry_partial_name_does_not_toggle_entries() {
347 let mut entries = vec![
348 make_entry(ip(127, 0, 0, 1), "dev.hostcraft.com", HostStatus::Active),
349 make_entry(
350 ip(192, 168, 0, 1),
351 "staging.hostcraft.com",
352 HostStatus::Active,
353 ),
354 make_entry(ip(10, 0, 0, 1), "unrelated.com", HostStatus::Active),
355 ];
356 let result = toggle_entry(&mut entries, "hostcraft");
357 assert!(matches!(result, Err(HostCraftError::EntryNotFound)));
358 assert_eq!(entries[0].status, HostStatus::Active);
359 assert_eq!(entries[1].status, HostStatus::Active);
360 assert_eq!(entries[2].status, HostStatus::Active);
361 }
362
363 #[test]
364 fn toggle_entry_no_match_returns_err() {
365 let mut entries = sample_entries();
366 let result = toggle_entry(&mut entries, "nonexistent.com");
367 assert!(matches!(result, Err(HostCraftError::EntryNotFound)));
368 }
369
370 #[test]
371 fn toggle_entries_matching_partial_name_toggles_all_matching_and_returns_count() {
372 let mut entries = vec![
373 make_entry(ip(127, 0, 0, 1), "dev.hostcraft.com", HostStatus::Active),
374 make_entry(
375 ip(192, 168, 0, 1),
376 "staging.hostcraft.com",
377 HostStatus::Active,
378 ),
379 make_entry(ip(10, 0, 0, 1), "unrelated.com", HostStatus::Active),
380 ];
381 let result = toggle_entries_matching(&mut entries, "hostcraft");
382 assert_eq!(result.expect("expected two entries to be toggled"), 2);
383 assert_eq!(entries[0].status, HostStatus::Inactive);
384 assert_eq!(entries[1].status, HostStatus::Inactive);
385 assert_eq!(entries[2].status, HostStatus::Active);
386 }
387
388 #[test]
389 fn toggle_entries_matching_no_match_returns_err() {
390 let mut entries = sample_entries();
391 let result = toggle_entries_matching(&mut entries, "nonexistent.com");
392 assert!(matches!(result, Err(HostCraftError::EntryNotFound)));
393 }
394}