Skip to main content

hostcraft_core/host/
mod.rs

1mod utils;
2use crate::host::utils::{is_duplicate_entry, parse_line};
3use serde::Serialize;
4use std::{fmt, io, net::IpAddr};
5
6#[derive(Debug, Clone, PartialEq, Serialize)]
7pub enum HostStatus {
8    Active,
9    Inactive,
10}
11
12impl fmt::Display for HostStatus {
13    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
14        match self {
15            HostStatus::Active => write!(f, "Active"),
16            HostStatus::Inactive => write!(f, "Inactive"),
17        }
18    }
19}
20
21#[derive(Debug, Clone, PartialEq, Serialize)]
22pub struct HostEntry {
23    pub status: HostStatus,
24    pub ip: IpAddr,
25    pub name: String,
26}
27
28impl HostEntry {
29    pub fn toggle(&mut self) {
30        self.status = match self.status {
31            HostStatus::Active => HostStatus::Inactive,
32            HostStatus::Inactive => HostStatus::Active,
33        };
34    }
35}
36
37impl fmt::Display for HostEntry {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        write!(f, "{}: {} is {}", self.ip, self.name, self.status)
40    }
41}
42
43#[derive(Debug)]
44pub enum HostError {
45    DuplicateEntry,
46    EntryNotFound,
47}
48
49impl fmt::Display for HostError {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        match self {
52            HostError::DuplicateEntry => write!(f, "You have inserted a duplicate entry."),
53            HostError::EntryNotFound => write!(f, "Please check the name and try again."),
54        }
55    }
56}
57
58impl std::error::Error for HostError {}
59
60pub fn parse_contents(contents: impl Iterator<Item = io::Result<String>>) -> Vec<HostEntry> {
61    contents
62        .map_while(Result::ok)
63        .filter_map(|line| parse_line(&line))
64        .collect()
65}
66
67pub fn add_entry(
68    entries: &mut Vec<HostEntry>,
69    ip: IpAddr,
70    name: impl Into<String>,
71) -> Result<(), HostError> {
72    let name = name.into();
73    if is_duplicate_entry(entries, ip, &name) {
74        return Err(HostError::DuplicateEntry);
75    }
76    entries.push(HostEntry {
77        status: HostStatus::Active,
78        ip,
79        name,
80    });
81    Ok(())
82}
83
84pub fn remove_entry(entries: &mut Vec<HostEntry>, partial_name: &str) -> Result<(), HostError> {
85    let original_len = entries.len();
86    entries.retain(|e| !e.name.contains(partial_name));
87    if entries.len() == original_len {
88        return Err(HostError::EntryNotFound);
89    }
90    Ok(())
91}
92
93pub fn toggle_entry(entries: &mut Vec<HostEntry>, partial_name: &str) -> Result<(), HostError> {
94    let mut found = false;
95    for entry in entries.iter_mut() {
96        if entry.name.contains(partial_name) {
97            entry.toggle();
98            found = true;
99        }
100    }
101    if !found {
102        return Err(HostError::EntryNotFound);
103    }
104    Ok(())
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use std::io;
111    use std::net::{IpAddr, Ipv4Addr};
112
113    // --- Helpers ---
114
115    fn ip(a: u8, b: u8, c: u8, d: u8) -> IpAddr {
116        IpAddr::V4(Ipv4Addr::new(a, b, c, d))
117    }
118
119    fn make_entry(ip_addr: IpAddr, name: &str, status: HostStatus) -> HostEntry {
120        HostEntry {
121            ip: ip_addr,
122            name: name.to_string(),
123            status,
124        }
125    }
126
127    fn sample_entries() -> Vec<HostEntry> {
128        vec![
129            make_entry(ip(127, 0, 0, 1), "alpha.com", HostStatus::Active),
130            make_entry(ip(192, 168, 0, 1), "beta.com", HostStatus::Inactive),
131        ]
132    }
133
134    fn to_lines(lines: &[&str]) -> std::vec::IntoIter<io::Result<String>> {
135        lines
136            .iter()
137            .map(|l| Ok(l.to_string()))
138            .collect::<Vec<_>>()
139            .into_iter()
140    }
141
142    // --- parse_contents ---
143
144    #[test]
145    fn parse_contents_mixed_lines_returns_correct_entries() {
146        let lines = to_lines(&["127.0.0.1 active.com", "# 192.168.0.1 inactive.com"]);
147        let entries = parse_contents(lines);
148        assert_eq!(entries.len(), 2);
149        assert_eq!(entries[0].ip, ip(127, 0, 0, 1));
150        assert_eq!(entries[0].name, "active.com");
151        assert_eq!(entries[0].status, HostStatus::Active);
152        assert_eq!(entries[1].ip, ip(192, 168, 0, 1));
153        assert_eq!(entries[1].name, "inactive.com");
154        assert_eq!(entries[1].status, HostStatus::Inactive);
155    }
156
157    #[test]
158    fn parse_contents_skips_non_matching_lines() {
159        let lines = to_lines(&[
160            "127.0.0.1 active.com",
161            "# This is a comment",
162            "",
163            "192.168.0.1 another.com",
164        ]);
165        let entries = parse_contents(lines);
166        assert_eq!(entries.len(), 2);
167    }
168
169    #[test]
170    fn parse_contents_empty_input_returns_empty_vec() {
171        let entries = parse_contents(std::iter::empty::<io::Result<String>>());
172        assert!(entries.is_empty());
173    }
174
175    // --- add_entry ---
176
177    #[test]
178    fn add_entry_new_entry_returns_ok_and_appends() {
179        let mut entries = sample_entries();
180        let result = add_entry(&mut entries, ip(10, 0, 0, 1), "new.com".to_string());
181        assert!(result.is_ok());
182        assert_eq!(entries.len(), 3);
183        assert_eq!(entries[2].ip, ip(10, 0, 0, 1));
184        assert_eq!(entries[2].name, "new.com");
185        assert_eq!(entries[2].status, HostStatus::Active);
186    }
187
188    #[test]
189    fn add_entry_duplicate_returns_err_and_leaves_entries_unchanged() {
190        let mut entries = sample_entries();
191        let result = add_entry(&mut entries, ip(127, 0, 0, 1), "alpha.com".to_string());
192        assert!(matches!(result, Err(HostError::DuplicateEntry)));
193        assert_eq!(entries.len(), 2);
194    }
195
196    // --- remove_entry ---
197
198    #[test]
199    fn remove_entry_exact_name_match_removes_entry() {
200        let mut entries = sample_entries();
201        let result = remove_entry(&mut entries, "alpha.com");
202        assert!(result.is_ok());
203        assert_eq!(entries.len(), 1);
204        assert_eq!(entries[0].name, "beta.com");
205    }
206
207    #[test]
208    fn remove_entry_partial_name_match_removes_all_matching() {
209        let mut entries = vec![
210            make_entry(ip(127, 0, 0, 1), "dev.hostcraft.com", HostStatus::Active),
211            make_entry(
212                ip(192, 168, 0, 1),
213                "staging.hostcraft.com",
214                HostStatus::Active,
215            ),
216            make_entry(ip(10, 0, 0, 1), "unrelated.com", HostStatus::Active),
217        ];
218        let result = remove_entry(&mut entries, "hostcraft");
219        assert!(result.is_ok());
220        assert_eq!(entries.len(), 1);
221        assert_eq!(entries[0].name, "unrelated.com");
222    }
223
224    #[test]
225    fn remove_entry_no_match_returns_err_and_leaves_entries_unchanged() {
226        let mut entries = sample_entries();
227        let result = remove_entry(&mut entries, "nonexistent.com");
228        assert!(matches!(result, Err(HostError::EntryNotFound)));
229        assert_eq!(entries.len(), 2);
230    }
231
232    // --- toggle_entry ---
233
234    #[test]
235    fn toggle_entry_active_becomes_inactive() {
236        let mut entries = sample_entries();
237        let result = toggle_entry(&mut entries, "alpha.com");
238        assert!(result.is_ok());
239        assert_eq!(entries[0].status, HostStatus::Inactive);
240    }
241
242    #[test]
243    fn toggle_entry_inactive_becomes_active() {
244        let mut entries = sample_entries();
245        let result = toggle_entry(&mut entries, "beta.com");
246        assert!(result.is_ok());
247        assert_eq!(entries[1].status, HostStatus::Active);
248    }
249
250    #[test]
251    fn toggle_entry_partial_match_toggles_all_matching_only() {
252        let mut entries = vec![
253            make_entry(ip(127, 0, 0, 1), "dev.hostcraft.com", HostStatus::Active),
254            make_entry(
255                ip(192, 168, 0, 1),
256                "staging.hostcraft.com",
257                HostStatus::Active,
258            ),
259            make_entry(ip(10, 0, 0, 1), "unrelated.com", HostStatus::Active),
260        ];
261        let result = toggle_entry(&mut entries, "hostcraft");
262        assert!(result.is_ok());
263        assert_eq!(entries[0].status, HostStatus::Inactive);
264        assert_eq!(entries[1].status, HostStatus::Inactive);
265        assert_eq!(entries[2].status, HostStatus::Active);
266    }
267
268    #[test]
269    fn toggle_entry_no_match_returns_err() {
270        let mut entries = sample_entries();
271        let result = toggle_entry(&mut entries, "nonexistent.com");
272        assert!(matches!(result, Err(HostError::EntryNotFound)));
273    }
274}