Skip to main content

hostcraft_core/host/
mod.rs

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    // --- Helpers ---
142
143    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    // --- parse_contents ---
171
172    #[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    // --- add_entry ---
204
205    #[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    // --- edit entry ---
224    #[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); // status untouched
238    }
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    // --- remove_entry ---
268
269    #[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    // --- toggle_entry ---
328
329    #[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}