Skip to main content

hostcraft_cli/command/
mod.rs

1use crate::display::{print_entries, print_success};
2use clap::{Parser, Subcommand};
3use hostcraft_core::{HostCraftError, file, host, platform::write_hosts_to};
4use std::{error::Error, net::IpAddr, path::PathBuf};
5
6#[derive(Parser)]
7#[command(
8    name = "hostcraft",
9    about = "Manage your system hosts file from the terminal",
10    version
11)]
12pub struct Cli {
13    /// Path to the hosts file (override for testing or non-standard setups)
14    #[arg(long)]
15    pub file: Option<String>,
16
17    #[command(subcommand)]
18    pub command: Command,
19}
20
21#[derive(Subcommand)]
22pub enum Command {
23    /// List all host entries
24    List,
25
26    /// Add a new host entry
27    Add {
28        /// The hostname to add (e.g. myapp.local)
29        name: String,
30
31        /// The IP address to associate with the hostname (e.g. 127.0.0.1)
32        ip: IpAddr,
33    },
34
35    /// Edit a host entry (exact match supported)
36    Edit {
37        /// The hostname to edit (e.g. myapp.local)
38        old_name: String,
39
40        /// The new ip address (e.g. 127.0.0.1)
41        #[arg(long)]
42        new_ip: Option<IpAddr>,
43
44        /// The new hostname (e.g. myapp.local)
45        #[arg(long)]
46        new_name: Option<String>,
47    },
48
49    /// Remove a host entry by name (exact match by default)
50    Remove {
51        /// The hostname to remove
52        name: String,
53
54        /// Match by substring and remove all matching entries
55        #[arg(long)]
56        partial: bool,
57    },
58
59    /// Toggle a host entry on or off by name (exact match by default)
60    Toggle {
61        /// The hostname to toggle
62        name: String,
63
64        /// Match by substring and toggle all matching entries
65        #[arg(long)]
66        partial: bool,
67    },
68
69    /// Check for a newer version and update if one is available
70    Update,
71}
72
73pub fn run(cli: Cli) -> Result<(), Box<dyn Error>> {
74    if matches!(cli.command, Command::Update) {
75        return crate::update::handle_update();
76    }
77
78    let path = match cli.file {
79        Some(ref p) => PathBuf::from(p),
80        None => hostcraft_core::platform::get_hosts_path().map_err(|e| e.to_string())?,
81    };
82
83    let lines = file::read_file(&path)
84        .map_err(|e| format!("Failed to read hosts file '{}': {}", path.display(), e))?;
85
86    let mut entries = host::parse_contents(lines);
87
88    match cli.command {
89        Command::List => {
90            print_entries(&entries);
91        }
92
93        Command::Add { name, ip } => {
94            host::add_entry(&mut entries, ip, name.as_str()).map_err(|e| match e {
95                HostCraftError::DuplicateEntry => format!("Entry already exists. {}", e),
96                _ => format!("Failed to add entry: {}", e),
97            })?;
98
99            write_hosts_to(&path, &entries).map_err(|e| e.to_string())?;
100
101            print_success(&format!("Added '{}'", name));
102            print_entries(&entries);
103        }
104
105        Command::Edit {
106            old_name,
107            new_ip,
108            new_name,
109        } => {
110            if new_ip.is_none() && new_name.is_none() {
111                return Err("Provide at least one of --new_ip or --new_name".into());
112            }
113
114            let (current_ip, current_name) = entries
115                .iter()
116                .find(|e| e.name == old_name)
117                .map(|e| (e.ip, e.name.clone()))
118                .ok_or_else(|| format!("No entry found with exact name '{}'.", old_name))?;
119
120            let resolved_ip = new_ip.unwrap_or(current_ip);
121            let resolved_name = new_name.as_deref().unwrap_or(&current_name);
122
123            host::edit_entry(&mut entries, &old_name, resolved_ip, resolved_name).map_err(|e| {
124                match e {
125                    HostCraftError::EntryNotFound => {
126                        format!("No entry found with exact name '{}'. {}", old_name, e)
127                    }
128                    HostCraftError::DuplicateEntry => format!("Entry already exists. {}", e),
129                    HostCraftError::NoChange => format!("No changes made to entry '{}'.", old_name),
130                    _ => format!("Failed to edit entry: {}", e),
131                }
132            })?;
133
134            write_hosts_to(&path, &entries).map_err(|e| e.to_string())?;
135
136            print_success(&format!("Edited '{}'", old_name));
137            print_entries(&entries);
138        }
139
140        Command::Remove { name, partial } => {
141            let removed_count = if partial {
142                host::remove_entries_matching(&mut entries, &name).map_err(|e| match e {
143                    HostCraftError::EntryNotFound => {
144                        format!("No entries found containing '{}'. {}", name, e)
145                    }
146                    _ => format!("Failed to remove entry: {}", e),
147                })?
148            } else {
149                host::remove_entry(&mut entries, &name).map_err(|e| match e {
150                    HostCraftError::EntryNotFound => {
151                        format!("No entry found with exact name '{}'. {}", name, e)
152                    }
153                    _ => format!("Failed to remove entry: {}", e),
154                })?;
155                1
156            };
157
158            write_hosts_to(&path, &entries).map_err(|e| e.to_string())?;
159
160            if partial {
161                print_success(&format!(
162                    "Removed {} {} containing '{}'",
163                    removed_count,
164                    if removed_count == 1 {
165                        "entry"
166                    } else {
167                        "entries"
168                    },
169                    name
170                ));
171            } else {
172                print_success(&format!("Removed '{}'", name));
173            }
174            print_entries(&entries);
175        }
176
177        Command::Toggle { name, partial } => {
178            let toggled_count = if partial {
179                host::toggle_entries_matching(&mut entries, &name).map_err(|e| match e {
180                    HostCraftError::EntryNotFound => {
181                        format!("No entries found containing '{}'. {}", name, e)
182                    }
183                    _ => format!("Failed to toggle entry: {}", e),
184                })?
185            } else {
186                host::toggle_entry(&mut entries, &name).map_err(|e| match e {
187                    HostCraftError::EntryNotFound => {
188                        format!("No entry found with exact name '{}'. {}", name, e)
189                    }
190                    _ => format!("Failed to toggle entry: {}", e),
191                })?;
192                1
193            };
194
195            write_hosts_to(&path, &entries).map_err(|e| e.to_string())?;
196
197            if partial {
198                print_success(&format!(
199                    "Toggled {} {} containing '{}'",
200                    toggled_count,
201                    if toggled_count == 1 {
202                        "entry"
203                    } else {
204                        "entries"
205                    },
206                    name
207                ));
208            } else {
209                print_success(&format!("Toggled '{}'", name));
210            }
211            print_entries(&entries);
212        }
213
214        Command::Update => unreachable!("handled above"),
215    }
216
217    Ok(())
218}