cargo_prosa/package/
install.rs

1use std::{
2    env, fmt, fs,
3    io::{self, Write as _},
4    path::{Path, PathBuf},
5};
6
7use clap::ArgMatches;
8use tera::Tera;
9use toml_edit::DocumentMut;
10
11use crate::cargo::CargoMetadata;
12
13#[cfg(target_os = "macos")]
14const ASSETS_LAUNCHD_J2: &str =
15    include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/assets/launchd.j2"));
16
17/// Struct to handle ProSA instance installation
18pub struct InstanceInstall {
19    name: String,
20    bin_name: String,
21    install_bin_dir: String,
22    install_config_dir: String,
23    install_service_dir: String,
24    ctx: tera::Context,
25    j2_service_asset: &'static str,
26}
27
28impl InstanceInstall {
29    /// Create an instance install builder to install the ProSA instance locally
30    pub fn new(args: &ArgMatches) -> io::Result<InstanceInstall> {
31        let current_path = env::current_dir()?;
32        let path = current_path.as_path();
33        let name = args
34            .get_one::<String>("name")
35            .map(|n| n.as_str())
36            .or(path.file_name().and_then(|p| p.to_str()))
37            .unwrap_or("prosa")
38            .to_lowercase()
39            .replace(['/', ' '], "_");
40
41        // Get all path for system or home installation
42        let (install_bin_dir, install_config_dir, install_service_dir) = if args.get_flag("system")
43        {
44            #[cfg(target_os = "linux")]
45            {
46                (
47                    "/usr/local/bin".to_string(),
48                    "/etc/prosa".to_string(),
49                    "/etc/systemd/system".to_string(),
50                )
51            }
52            #[cfg(target_os = "macos")]
53            {
54                (
55                    "/usr/local/bin".to_string(),
56                    "/etc/prosa".to_string(),
57                    "/Library/LaunchDaemons".to_string(),
58                )
59            }
60        } else {
61            let install_dir = env::var("HOME").map_err(|ve| {
62                io::Error::new(
63                    io::ErrorKind::InvalidInput,
64                    format!(
65                        "Can't determine the $HOME folder where to install the ProSA {name}: {ve}"
66                    ),
67                )
68            })?;
69
70            // Avoid empty or root dir
71            if install_dir.len() < 2 {
72                return Err(io::Error::new(
73                    io::ErrorKind::InvalidInput,
74                    format!("Can't install with an empty or root $HOME: {install_dir}"),
75                ));
76            }
77
78            #[cfg(target_os = "linux")]
79            {
80                (
81                    format!("{install_dir}/.local/bin"),
82                    format!("{install_dir}/.config/prosa"),
83                    format!("{install_dir}/.config/systemd/user"),
84                )
85            }
86            #[cfg(target_os = "macos")]
87            {
88                (
89                    format!("{install_dir}/.local/bin"),
90                    format!("{install_dir}/.config/prosa"),
91                    format!("{install_dir}/Library/LaunchAgents"),
92                )
93            }
94        };
95
96        let package_metadata = CargoMetadata::load_package_metadata()?;
97        let mut ctx = tera::Context::new();
98        package_metadata.j2_context(&mut ctx);
99        ctx.insert("name", &name);
100
101        let bin_name = package_metadata
102            .get_targets("bin")
103            .and_then(|b| b.first().cloned())
104            .ok_or(io::Error::new(
105                io::ErrorKind::InvalidInput,
106                "Can't find the ProSA binary from the project",
107            ))?;
108        ctx.insert("bin", &format!("{install_bin_dir}/{bin_name}"));
109        ctx.insert(
110            "config",
111            &format!("{install_config_dir}/{}/prosa.toml", name),
112        );
113
114        // Add a description if it don't exist (it's mandatory for launchd)
115        if !ctx.contains_key("description") {
116            ctx.insert("description", "Local ProSA instance");
117        }
118
119        Ok(InstanceInstall {
120            name,
121            bin_name,
122            install_bin_dir,
123            install_config_dir,
124            install_service_dir,
125            ctx,
126            #[cfg(target_os = "linux")]
127            j2_service_asset: super::ASSETS_SYSTEMD_J2,
128            #[cfg(target_os = "macos")]
129            j2_service_asset: ASSETS_LAUNCHD_J2,
130        })
131    }
132
133    fn get_install_bin_dir(&self) -> PathBuf {
134        PathBuf::from(self.install_bin_dir.clone())
135    }
136
137    fn get_install_config_path(&self) -> PathBuf {
138        PathBuf::from(self.install_config_dir.clone())
139    }
140
141    fn get_install_service_path(&self) -> PathBuf {
142        PathBuf::from(self.install_service_dir.clone())
143    }
144
145    #[cfg(target_os = "linux")]
146    fn get_service_filename(&self) -> String {
147        format!("{}.service", self.name)
148    }
149
150    #[cfg(target_os = "macos")]
151    fn get_service_filename(&self) -> String {
152        format!("com.prosa.{}.plist", self.name)
153    }
154
155    fn create_service_file(&self) -> tera::Result<()> {
156        let service_name = self.get_service_filename();
157        let service_path = self.get_install_service_path();
158        let service_file_path = service_path.join(&service_name);
159
160        let mut tera_build = Tera::default();
161        tera_build.add_raw_template(&service_name, self.j2_service_asset)?;
162
163        fs::create_dir_all(&service_path)?;
164        let service_file = fs::File::create(service_file_path)?;
165        tera_build.render_to(&service_name, &self.ctx, service_file)
166    }
167
168    fn copy_binary(&self, release: bool) -> io::Result<u64> {
169        let binary_path = if release {
170            format!("target/release/{}", self.bin_name)
171        } else {
172            format!("target/debug/{}", self.bin_name)
173        };
174
175        // If the binary don't exist, compile the Rust project with cargo
176        match fs::exists(Path::new(&binary_path)) {
177            Ok(true) => {}
178            _ => {
179                let build_args = if release {
180                    vec!["build", "--release"]
181                } else {
182                    vec!["build"]
183                };
184
185                let cargo_build = std::process::Command::new("cargo")
186                    .args(build_args)
187                    .output()?;
188                io::stdout().write_all(&cargo_build.stdout).unwrap();
189                io::stderr().write_all(&cargo_build.stderr).unwrap();
190
191                if !cargo_build.status.success() {
192                    return Err(io::Error::new(
193                        io::ErrorKind::InvalidData,
194                        "Error during ProSA build",
195                    ));
196                }
197            }
198        }
199
200        // Copy the binary to the local output directory
201        let binary_output_path = self.get_install_bin_dir();
202        fs::create_dir_all(&binary_output_path)?;
203        fs::copy(
204            binary_path,
205            binary_output_path.join(Path::new(&self.bin_name)),
206        )
207    }
208
209    fn gen_config(&self) -> io::Result<u64> {
210        let config_dir = self.get_install_config_path().join(&self.name);
211        fs::create_dir_all(&config_dir)?;
212
213        // If the configuration file already exist, only add the missing parts to avoid removing some already configured things
214        let config_path = config_dir.join("prosa.toml");
215        if let Ok(true) = fs::exists(&config_path)
216            && let Ok(new_config_toml) =
217                fs::read_to_string("target/config.toml")?.parse::<DocumentMut>()
218            && let Ok(mut config_toml) = fs::read_to_string(&config_path)?.parse::<DocumentMut>()
219        {
220            let mut modified = false;
221            let config_table = config_toml.as_table_mut();
222            for (new_config_key, new_config_item) in new_config_toml.as_table() {
223                if !config_table.contains_key(new_config_key) {
224                    config_table.insert(new_config_key, new_config_item.clone());
225                    modified = true;
226                }
227            }
228
229            // Override with the new configuration if any value have changed
230            if modified {
231                let mut config_toml_file = fs::File::create(config_path)?;
232                let config_toml_str = config_toml.to_string();
233                config_toml_file.write_all(config_toml_str.as_bytes())?;
234                Ok(config_toml_str.len() as u64)
235            } else {
236                Ok(0)
237            }
238        } else {
239            fs::copy("target/config.toml", config_path)
240        }
241    }
242
243    /// Method to install ProSA on the system
244    /// - Create a service file
245    /// - Copy ProSA binary
246    /// - Generate configuration
247    pub fn install(&self, release: bool) -> io::Result<u64> {
248        print!("Copying binary ");
249        let mut file_size = self.copy_binary(release)?;
250        println!("OK");
251        print!("Generating configuration ");
252        file_size += self.gen_config()?;
253        println!("OK");
254        print!("Creating service ");
255        self.create_service_file()
256            .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
257        println!("OK");
258        Ok(file_size)
259    }
260
261    /// Method to remove ProSA from the system
262    /// Let the configuration file as it is to avoid losing any configuration
263    pub fn uninstall(&self, purge: bool) -> io::Result<()> {
264        if purge {
265            print!("Purge configuration file ");
266            fs::remove_dir_all(self.get_install_config_path().join(&self.name))?;
267            println!("OK");
268        }
269
270        print!("Remove service ");
271        fs::remove_file(
272            self.get_install_service_path()
273                .join(self.get_service_filename()),
274        )?;
275        println!("OK");
276
277        print!("Remove binary ");
278        fs::remove_file(self.get_install_bin_dir().join(&self.bin_name))?;
279        println!("OK");
280        Ok(())
281    }
282}
283
284impl fmt::Display for InstanceInstall {
285    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
286        writeln!(f, "ProSA `{}`", self.name)?;
287        writeln!(
288            f,
289            "Binary file : {}/{}",
290            self.install_bin_dir, self.bin_name
291        )?;
292        writeln!(
293            f,
294            "Config file : {}/{}/prosa.toml",
295            self.install_config_dir, self.name
296        )?;
297        writeln!(
298            f,
299            "Service file: {}/{}",
300            self.install_service_dir,
301            self.get_service_filename()
302        )
303    }
304}