1use std::process::Output;
7use std::time::Instant;
8use tokio::process::Command;
9use tracing::{debug, info};
10
11use crate::logging::{log_timed, LogLevel};
12use crate::utils::command_exists;
13
14pub async fn run_setup(debug: bool) -> Result<(), String> {
19 let (program, args, description) = build_setup_command()?;
20 if debug {
21 debug!("Running setup command: {} {}", program, args.join(" "));
22 }
23 let start: Instant = Instant::now();
24 let output: Output = Command::new(&program)
25 .args(&args)
26 .output()
27 .await
28 .map_err(|e| format!("Failed to execute setup command: {}", e))?;
29
30 let elapsed = start.elapsed();
31
32 if debug {
33 debug!("Setup command output: {:?}", output);
34 debug!("Setup command took: {:.2?}", elapsed);
35 }
36
37 if !output.status.success() {
38 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
39 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
40 let details = if !stderr.is_empty() { stderr } else { stdout };
41 return Err(format!(
42 "Setup failed while running '{} {}': {}",
43 program,
44 args.join(" "),
45 details
46 ));
47 }
48
49 let _ = log_timed(
50 LogLevel::Success,
51 "setup",
52 &format!("Setup completed with {}", description),
53 elapsed.as_millis() as u64,
54 )
55 .await;
56
57 if !output.stdout.is_empty() {
58 info!("Setup output: {}", String::from_utf8_lossy(&output.stdout));
59 }
60
61 info!("Setup completed successfully!");
62 Ok(())
63}
64
65fn build_setup_command() -> Result<(String, Vec<String>, String), String> {
66 let target_os = if cfg!(target_os = "macos") {
67 "macos"
68 } else if cfg!(target_os = "linux") {
69 "linux"
70 } else if cfg!(target_os = "windows") {
71 "windows"
72 } else {
73 "unsupported"
74 };
75
76 build_setup_command_for(target_os, command_exists)
77}
78
79fn build_setup_command_for<F>(
80 target_os: &str,
81 command_exists: F,
82) -> Result<(String, Vec<String>, String), String>
83where
84 F: Fn(&str) -> bool,
85{
86 if target_os == "macos" {
87 if !command_exists("brew") {
88 return Err(
89 "Homebrew is required on macOS. Install it first from https://brew.sh/."
90 .to_string(),
91 );
92 }
93
94 return Ok((
95 "brew".to_string(),
96 vec![
97 "install".to_string(),
98 "nginx".to_string(),
99 "pkg-config".to_string(),
100 "openssl@3".to_string(),
101 "findutils".to_string(),
102 "inetutils".to_string(),
103 "certbot".to_string(),
104 "python".to_string(),
105 ],
106 "Homebrew".to_string(),
107 ));
108 }
109
110 if target_os == "linux" {
111 let package_manager = if command_exists("apt-get") {
112 "apt-get"
113 } else if command_exists("apt") {
114 "apt"
115 } else {
116 return Err(
117 "Unsupported Linux package manager. Install dependencies manually or add apt/apt-get.".to_string(),
118 );
119 };
120
121 let mut args = Vec::new();
122 let program = if command_exists("sudo") {
123 args.push(package_manager.to_string());
124 "sudo".to_string()
125 } else {
126 package_manager.to_string()
127 };
128
129 args.extend(
130 [
131 "install",
132 "-y",
133 "net-tools",
134 "nginx",
135 "pkg-config",
136 "libssl-dev",
137 "build-essential",
138 "plocate",
139 "sshpass",
140 "neofetch",
141 "certbot",
142 "python3-certbot-nginx",
143 ]
144 .into_iter()
145 .map(str::to_string),
146 );
147
148 return Ok((program, args, package_manager.to_string()));
149 }
150
151 if target_os == "windows" {
152 if command_exists("winget") {
153 let args = vec![
154 "install".to_string(),
155 "Git.Git".to_string(),
156 "OpenJS.NodeJS.LTS".to_string(),
157 "Python.Python.3.12".to_string(),
158 "ShiningLight.OpenSSL".to_string(),
159 "nginx.nginx".to_string(),
160 "--accept-package-agreements".to_string(),
161 "--accept-source-agreements".to_string(),
162 ];
163 return Ok(("winget".to_string(), args, "winget".to_string()));
164 }
165 if command_exists("choco") {
166 let packages = ["git", "nodejs-lts", "python", "openssl", "nginx"];
167 let mut args = vec!["install".to_string(), "-y".to_string()];
168 args.extend(packages.iter().map(|s| s.to_string()));
169 return Ok(("choco".to_string(), args, "Chocolatey".to_string()));
170 }
171 return Err(
172 "Windows setup requires winget (built-in on Windows 10/11) or Chocolatey.\n\
173 Install Chocolatey from https://chocolatey.org/ if winget is unavailable."
174 .to_string(),
175 );
176 }
177
178 Err("Setup is currently supported on Linux (apt), macOS (Homebrew), and Windows (winget/Chocolatey).".to_string())
179}
180
181#[cfg(test)]
182mod tests {
183 use super::build_setup_command_for;
184
185 #[test]
186 fn linux_prefers_apt_get_and_sudo_when_available() {
187 let command = build_setup_command_for("linux", |cmd| matches!(cmd, "apt-get" | "sudo"))
188 .expect("linux setup command should build");
189
190 assert_eq!(command.0, "sudo");
191 assert_eq!(command.1[0], "apt-get");
192 assert_eq!(command.1[1], "install");
193 assert_eq!(command.2, "apt-get");
194 }
195
196 #[test]
197 fn linux_falls_back_to_apt_without_sudo() {
198 let command =
199 build_setup_command_for("linux", |cmd| cmd == "apt").expect("apt should be accepted");
200
201 assert_eq!(command.0, "apt");
202 assert_eq!(command.1[0], "install");
203 assert_eq!(command.2, "apt");
204 }
205
206 #[test]
207 fn linux_errors_without_supported_package_manager() {
208 let error = build_setup_command_for("linux", |_| false)
209 .expect_err("linux setup should fail without apt");
210
211 assert!(error.contains("Unsupported Linux package manager"));
212 }
213
214 #[test]
215 fn macos_requires_brew() {
216 let error = build_setup_command_for("macos", |_| false)
217 .expect_err("macOS setup should require brew");
218
219 assert!(error.contains("Homebrew is required on macOS"));
220 }
221
222 #[test]
223 fn macos_uses_brew_install_command() {
224 let command =
225 build_setup_command_for("macos", |cmd| cmd == "brew").expect("brew should be accepted");
226
227 assert_eq!(command.0, "brew");
228 assert_eq!(command.1[0], "install");
229 assert!(command.1.iter().any(|arg| arg == "python"));
230 assert_eq!(command.2, "Homebrew");
231 }
232}