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>, partial_name: &str) -> Result<()> {
89 let original_len = entries.len();
90 entries.retain(|e| !e.name.contains(partial_name));
91 if entries.len() == original_len {
92 return Err(HostCraftError::EntryNotFound);
93 }
94 Ok(())
95}
96
97pub fn toggle_entry(entries: &mut Vec<HostEntry>, partial_name: &str) -> Result<()> {
98 let mut found = false;
99 for entry in entries.iter_mut() {
100 if entry.name.contains(partial_name) {
101 entry.toggle();
102 found = true;
103 }
104 }
105 if !found {
106 return Err(HostCraftError::EntryNotFound);
107 }
108 Ok(())
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use std::io;
115 use std::net::{IpAddr, Ipv4Addr};
116
117 fn ip(a: u8, b: u8, c: u8, d: u8) -> IpAddr {
120 IpAddr::V4(Ipv4Addr::new(a, b, c, d))
121 }
122
123 fn make_entry(ip_addr: IpAddr, name: &str, status: HostStatus) -> HostEntry {
124 HostEntry {
125 ip: ip_addr,
126 name: name.to_string(),
127 status,
128 }
129 }
130
131 fn sample_entries() -> Vec<HostEntry> {
132 vec![
133 make_entry(ip(127, 0, 0, 1), "alpha.com", HostStatus::Active),
134 make_entry(ip(192, 168, 0, 1), "beta.com", HostStatus::Inactive),
135 ]
136 }
137
138 fn to_lines(lines: &[&str]) -> std::vec::IntoIter<io::Result<String>> {
139 lines
140 .iter()
141 .map(|l| Ok(l.to_string()))
142 .collect::<Vec<_>>()
143 .into_iter()
144 }
145
146 #[test]
149 fn parse_contents_mixed_lines_returns_correct_entries() {
150 let lines = to_lines(&["127.0.0.1 active.com", "# 192.168.0.1 inactive.com"]);
151 let entries = parse_contents(lines);
152 assert_eq!(entries.len(), 2);
153 assert_eq!(entries[0].ip, ip(127, 0, 0, 1));
154 assert_eq!(entries[0].name, "active.com");
155 assert_eq!(entries[0].status, HostStatus::Active);
156 assert_eq!(entries[1].ip, ip(192, 168, 0, 1));
157 assert_eq!(entries[1].name, "inactive.com");
158 assert_eq!(entries[1].status, HostStatus::Inactive);
159 }
160
161 #[test]
162 fn parse_contents_skips_non_matching_lines() {
163 let lines = to_lines(&[
164 "127.0.0.1 active.com",
165 "# This is a comment",
166 "",
167 "192.168.0.1 another.com",
168 ]);
169 let entries = parse_contents(lines);
170 assert_eq!(entries.len(), 2);
171 }
172
173 #[test]
174 fn parse_contents_empty_input_returns_empty_vec() {
175 let entries = parse_contents(std::iter::empty::<io::Result<String>>());
176 assert!(entries.is_empty());
177 }
178
179 #[test]
182 fn add_entry_new_entry_returns_ok_and_appends() {
183 let mut entries = sample_entries();
184 let result = add_entry(&mut entries, ip(10, 0, 0, 1), "new.com".to_string());
185 assert!(result.is_ok());
186 assert_eq!(entries.len(), 3);
187 assert_eq!(entries[2].ip, ip(10, 0, 0, 1));
188 assert_eq!(entries[2].name, "new.com");
189 assert_eq!(entries[2].status, HostStatus::Active);
190 }
191
192 #[test]
193 fn add_entry_duplicate_returns_err_and_leaves_entries_unchanged() {
194 let mut entries = sample_entries();
195 let result = add_entry(&mut entries, ip(127, 0, 0, 1), "alpha.com".to_string());
196 assert!(matches!(result, Err(HostCraftError::DuplicateEntry)));
197 assert_eq!(entries.len(), 2);
198 }
199 #[test]
201 fn test_edit_updates_ip_and_name() {
202 let mut entries = sample_entries();
203 edit_entry(&mut entries, "alpha.com", ip(10, 0, 0, 1), "new-alpha.com").unwrap();
204 assert_eq!(entries[0].ip, ip(10, 0, 0, 1));
205 assert_eq!(entries[0].name, "new-alpha.com");
206 }
207
208 #[test]
209 fn test_edit_preserves_status_and_position() {
210 let mut entries = sample_entries();
211 edit_entry(&mut entries, "beta.com", ip(10, 0, 0, 2), "new-beta.com").unwrap();
212 assert_eq!(entries[1].name, "new-beta.com");
213 assert_eq!(entries[1].status, HostStatus::Inactive); }
215
216 #[test]
217 fn test_partial_name_does_not_match() {
218 let mut entries = sample_entries();
219 let result = edit_entry(&mut entries, "alpha", ip(10, 0, 0, 1), "new.com");
220 assert!(matches!(result, Err(HostCraftError::EntryNotFound)));
221 }
222
223 #[test]
224 fn test_no_match_returns_not_found() {
225 let mut entries = sample_entries();
226 let result = edit_entry(&mut entries, "notexist.com", ip(10, 0, 0, 1), "new.com");
227 assert!(matches!(result, Err(HostCraftError::EntryNotFound)));
228 }
229
230 #[test]
231 fn test_duplicate_in_different_entry_returns_error() {
232 let mut entries = sample_entries();
233 let result = edit_entry(&mut entries, "alpha.com", ip(192, 168, 0, 1), "beta.com");
234 assert!(matches!(result, Err(HostCraftError::DuplicateEntry)));
235 }
236
237 #[test]
238 fn test_edit_to_same_values_fails() {
239 let mut entries = sample_entries();
240 let result = edit_entry(&mut entries, "alpha.com", ip(127, 0, 0, 1), "alpha.com");
241 assert!(matches!(result, Err(HostCraftError::NoChange)));
242 }
243 #[test]
246 fn remove_entry_exact_name_match_removes_entry() {
247 let mut entries = sample_entries();
248 let result = remove_entry(&mut entries, "alpha.com");
249 assert!(result.is_ok());
250 assert_eq!(entries.len(), 1);
251 assert_eq!(entries[0].name, "beta.com");
252 }
253
254 #[test]
255 fn remove_entry_partial_name_match_removes_all_matching() {
256 let mut entries = vec![
257 make_entry(ip(127, 0, 0, 1), "dev.hostcraft.com", HostStatus::Active),
258 make_entry(
259 ip(192, 168, 0, 1),
260 "staging.hostcraft.com",
261 HostStatus::Active,
262 ),
263 make_entry(ip(10, 0, 0, 1), "unrelated.com", HostStatus::Active),
264 ];
265 let result = remove_entry(&mut entries, "hostcraft");
266 assert!(result.is_ok());
267 assert_eq!(entries.len(), 1);
268 assert_eq!(entries[0].name, "unrelated.com");
269 }
270
271 #[test]
272 fn remove_entry_no_match_returns_err_and_leaves_entries_unchanged() {
273 let mut entries = sample_entries();
274 let result = remove_entry(&mut entries, "nonexistent.com");
275 assert!(matches!(result, Err(HostCraftError::EntryNotFound)));
276 assert_eq!(entries.len(), 2);
277 }
278
279 #[test]
282 fn toggle_entry_active_becomes_inactive() {
283 let mut entries = sample_entries();
284 let result = toggle_entry(&mut entries, "alpha.com");
285 assert!(result.is_ok());
286 assert_eq!(entries[0].status, HostStatus::Inactive);
287 }
288
289 #[test]
290 fn toggle_entry_inactive_becomes_active() {
291 let mut entries = sample_entries();
292 let result = toggle_entry(&mut entries, "beta.com");
293 assert!(result.is_ok());
294 assert_eq!(entries[1].status, HostStatus::Active);
295 }
296
297 #[test]
298 fn toggle_entry_partial_match_toggles_all_matching_only() {
299 let mut entries = vec![
300 make_entry(ip(127, 0, 0, 1), "dev.hostcraft.com", HostStatus::Active),
301 make_entry(
302 ip(192, 168, 0, 1),
303 "staging.hostcraft.com",
304 HostStatus::Active,
305 ),
306 make_entry(ip(10, 0, 0, 1), "unrelated.com", HostStatus::Active),
307 ];
308 let result = toggle_entry(&mut entries, "hostcraft");
309 assert!(result.is_ok());
310 assert_eq!(entries[0].status, HostStatus::Inactive);
311 assert_eq!(entries[1].status, HostStatus::Inactive);
312 assert_eq!(entries[2].status, HostStatus::Active);
313 }
314
315 #[test]
316 fn toggle_entry_no_match_returns_err() {
317 let mut entries = sample_entries();
318 let result = toggle_entry(&mut entries, "nonexistent.com");
319 assert!(matches!(result, Err(HostCraftError::EntryNotFound)));
320 }
321}