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>, 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    // --- Helpers ---
118
119    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    // --- parse_contents ---
147
148    #[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    // --- add_entry ---
180
181    #[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    // --- edit entry ---
200    #[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); // status untouched
214    }
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    // --- remove_entry ---
244
245    #[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    // --- toggle_entry ---
280
281    #[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}