Skip to main content

user_startup/
lib.rs

1#![warn(clippy::cargo)]
2#![allow(clippy::multiple_crate_versions)] // windows-sys
3
4pub mod utils;
5use std::{
6    fs,
7    path::{Path, PathBuf},
8    process::Command,
9};
10
11use log::{debug, error, info, warn};
12
13/// Execute a command and log itself.
14pub fn exec(cmd: &str) -> std::io::Result<()> {
15    let mut parts = cmd.split_whitespace();
16    let true_cmd = parts.next().expect("Command cannot be empty");
17    let args: Vec<&str> = parts.collect();
18    debug!("Executing `{cmd}`");
19    Command::new(true_cmd).args(args).status()?;
20    Ok(())
21}
22
23/// Read the first not-empty line of a file.
24pub fn read_first_line(path: &Path) -> std::io::Result<String> {
25    debug!("Reading first line of `{}`", path.display());
26    let content = fs::read_to_string(path)?;
27    for line in content.lines() {
28        let stripped = line.trim();
29        if !stripped.is_empty() && !stripped.starts_with("#!") {
30            return Ok(stripped.to_string());
31        }
32    }
33    Err(std::io::Error::other("File is empty"))
34}
35
36/// Extract the script name from a command.
37///
38/// # Examples
39///
40/// ```rust
41/// use user_startup::extract_name_from_cmd;
42/// assert_eq!(extract_name_from_cmd("test.cmd"), "test");
43/// assert_eq!(extract_name_from_cmd("D:\\no_install_software\\syncthing\\syncthing.exe"), "syncthing");
44/// ```
45pub fn extract_name_from_cmd(cmd: &str) -> String {
46    cmd.split_whitespace()
47        .find(|x| !x.is_empty())
48        .expect("Command is empty!")
49        .split(['/', '\\'])
50        .rev()
51        .find(|x| !x.is_empty())
52        .expect("cannot extract name from invalid command")
53        .split('.')
54        .find(|x| !x.is_empty())
55        .expect("cannot extract name from invalid command")
56        .into()
57}
58
59/// Find a writable path for a startup command. Because the command may be not
60/// unique, it will try to find the first available filename like this:
61///
62/// test, test1, test2, test3, test4, test5 ... test1000.
63pub fn find_writable_path(name: impl AsRef<str>) -> PathBuf {
64    let name = name.as_ref();
65    debug!("Finding writable path for `{name}`");
66    let base_path = &utils::CONFIG_PATH;
67    let ext = utils::FILE_EXT;
68
69    let initial_path = base_path.join(format!("{name}{ext}"));
70    if !initial_path.exists() {
71        return initial_path;
72    }
73
74    for i in 1..1000 {
75        let path = base_path.join(format!("{name}{i}{ext}"));
76        if !path.exists() {
77            debug!(
78                "Found writable path `{}`",
79                path.file_name().unwrap_or_default().to_string_lossy()
80            );
81            return path;
82        }
83    }
84    panic!("TOO MANY ITEMS OF SAME NAME!");
85}
86
87/// Add a new startup command.
88pub fn add_item(cmd: &str, name: Option<&str>, stdout: Option<&str>, stderr: Option<&str>) {
89    if cfg!(target_os = "linux") && (stdout.is_some() || stderr.is_some()) {
90        warn!("--stdout and --stderr are not supported for linux startup scripts");
91    }
92
93    let path = if let Some(name) = name {
94        find_writable_path(name)
95    } else {
96        find_writable_path(extract_name_from_cmd(cmd))
97    };
98
99    fs::write(&path, utils::format(cmd, None, stdout, stderr))
100        .expect("Failed to write config file");
101
102    info!("Added `{}` to `{}`", cmd, path.display());
103
104    // Reload the daemon and enable the service
105    #[cfg(target_os = "linux")]
106    {
107        exec("systemctl daemon-reload --user").expect("daemon reloading error");
108        exec(
109            format!(
110                "systemctl enable {} --user",
111                path.file_name().unwrap().to_string_lossy()
112            )
113            .as_str(),
114        )
115        .expect("daemon enabling error");
116    }
117}
118
119/// Get a list of startup commands.
120///
121/// # Returns
122///
123/// A vector of tuples, where the first element is the id of the command and the
124/// second element is the command itself.
125pub fn get_items_list() -> Vec<(String, String)> {
126    let config_path = utils::CONFIG_PATH.as_os_str();
127    debug!(
128        "Finding config files in `{}` with extension `{}`",
129        config_path.to_string_lossy(),
130        utils::FILE_EXT
131    );
132    let mut res = vec![];
133
134    for entry in fs::read_dir(config_path)
135        .expect("Failed to read config directory")
136        .flatten()
137    {
138        let path = entry.path();
139        if path
140            .extension()
141            .is_some_and(|ext| ext == utils::FILE_EXT.trim_start_matches('.'))
142            && let Ok(first_line) = read_first_line(&path)
143        {
144            let id = path.file_stem().unwrap().to_string_lossy().into_owned();
145            if first_line.starts_with(utils::COMMENT_PREFIX) {
146                let command = first_line.trim_start_matches(utils::COMMENT_PREFIX).trim();
147                res.push((id, command.to_string()));
148            }
149        }
150    }
151    res
152}
153
154/// Remove startup commands.
155pub fn remove_items(ids: Vec<String>) {
156    for id in ids {
157        let path = utils::CONFIG_PATH.join(format!("{}{}", id, utils::FILE_EXT));
158        if path.exists() {
159            // Disable the service
160            #[cfg(target_os = "linux")]
161            {
162                exec(
163                    format!(
164                        "systemctl disable {} --user",
165                        path.file_name().unwrap().to_string_lossy()
166                    )
167                    .as_str(),
168                )
169                .expect("daemon disabling error");
170            }
171            fs::remove_file(&path)
172                .unwrap_or_else(|e| panic!("Failed to remove file `{}`: {}", path.display(), e));
173            info!("Removed id `{id}`");
174        } else {
175            error!("Config file id `{id}` not found");
176        }
177    }
178}
179
180/// Open the startup folder.
181pub fn open_config_folder() {
182    Command::new(utils::OPEN_COMMAND)
183        .arg(utils::CONFIG_PATH.as_os_str())
184        .spawn()
185        .expect("Failed to open config folder")
186        .wait()
187        .expect("Failed to open config folder");
188}
189
190#[cfg(test)]
191mod tests {
192    #[cfg(windows)]
193    use super::*;
194
195    #[test]
196    #[cfg(windows)]
197    fn test_find_writable_path() {
198        let path = find_writable_path("test");
199        assert_eq!(path, utils::CONFIG_PATH.join("test.cmd"));
200        add_item("test", None, None, None);
201        let path = find_writable_path("test");
202        assert_eq!(path, utils::CONFIG_PATH.join("test1.cmd"));
203        remove_items(vec!["test".to_string()]);
204    }
205}