Skip to main content

pueue_lib/
settings.rs

1//! Pueue's configuration file representation.
2use std::{
3    collections::HashMap,
4    fs::{File, create_dir_all},
5    io::{BufReader, prelude::*},
6    path::{Path, PathBuf},
7};
8
9use serde::{Deserialize, Serialize};
10use shellexpand::tilde;
11
12use crate::{error::Error, internal_prelude::*, setting_defaults::*};
13
14/// The environment variable that can be set to overwrite pueue's config path.
15pub const PUEUE_CONFIG_PATH_ENV: &str = "PUEUE_CONFIG_PATH";
16
17/// All settings which are used by both, the client and the daemon
18#[derive(PartialEq, Eq, Clone, Debug, Deserialize, Serialize)]
19pub struct Shared {
20    /// Don't access this property directly, but rather use the getter with the same name.
21    /// It's only public to allow proper integration testing.
22    ///
23    /// The directory that is used for all of pueue's state. \
24    /// I.e. task logs, state dumps, etc.
25    pub pueue_directory: Option<PathBuf>,
26    /// Don't access this property directly, but rather use the getter with the same name.
27    /// It's only public to allow proper integration testing.
28    ///
29    /// The location where runtime related files will be placed.
30    /// Defaults to `pueue_directory` unless `$XDG_RUNTIME_DIR` is set.
31    pub runtime_directory: Option<PathBuf>,
32    /// Don't access this property directly, but rather use the getter with the same name.
33    /// It's only public to allow proper integration testing.
34    ///
35    /// The location of the alias file used by the daemon/client when working with
36    /// aliases.
37    pub alias_file: Option<PathBuf>,
38
39    /// If this is set to true, unix sockets will be used.
40    /// Otherwise we default to TCP+TLS
41    #[cfg(not(target_os = "windows"))]
42    #[serde(default = "default_true")]
43    pub use_unix_socket: bool,
44    /// Don't access this property directly, but rather use the getter with the same name.
45    /// It's only public to allow proper integration testing.
46    ///
47    /// The path to the unix socket.
48    #[cfg(not(target_os = "windows"))]
49    pub unix_socket_path: Option<PathBuf>,
50    /// Unix socket permissions. Typically specified as an octal number and
51    /// defaults to `0o700` which grants only the current user access to the
52    /// socket. For a client to connect to the daemon, the client must have
53    /// read/write permissions.
54    #[cfg(not(target_os = "windows"))]
55    pub unix_socket_permissions: Option<u32>,
56
57    /// The TCP hostname/ip address.
58    #[serde(default = "default_host")]
59    pub host: String,
60    /// The TCP port.
61    #[serde(default = "default_port")]
62    pub port: String,
63
64    /// The path where the daemon's PID is located.
65    /// This is by default in `runtime_directory/pueue.pid`.
66    pub pid_path: Option<PathBuf>,
67
68    /// Don't access this property directly, but rather use the getter with the same name.
69    /// It's only public to allow proper integration testing.
70    ///
71    /// The path to the TLS certificate used by the daemon. \
72    /// This is also used by the client to verify the daemon's identity.
73    pub daemon_cert: Option<PathBuf>,
74    /// Don't access this property directly, but rather use the getter with the same name.
75    /// It's only public to allow proper integration testing.
76    ///
77    /// The path to the TLS key used by the daemon.
78    pub daemon_key: Option<PathBuf>,
79    /// Don't access this property directly, but rather use the getter with the same name.
80    /// It's only public to allow proper integration testing.
81    ///
82    /// The path to the file containing the shared secret used to authenticate the client.
83    pub shared_secret_path: Option<PathBuf>,
84}
85
86/// The mode in which the client should edit tasks.
87#[derive(PartialEq, Eq, Clone, Debug, Deserialize, Serialize, Default)]
88#[serde(rename_all = "lowercase")]
89pub enum EditMode {
90    /// Edit by having one large file with all tasks to be edited inside at the same time
91    #[default]
92    Toml,
93    /// Edit by creating a folder for each task to be edited, where each property is a single file.
94    Files,
95}
96
97/// All settings which are used by the client
98#[derive(PartialEq, Eq, Clone, Debug, Deserialize, Serialize)]
99pub struct Client {
100    /// If set to true, all tasks will be restart in place, instead of creating a new task.
101    /// False is the default, as you'll lose the logs of the previously failed tasks when
102    /// restarting tasks in place.
103    #[serde(default = "Default::default")]
104    pub restart_in_place: bool,
105    /// Whether the client should read the logs directly from disk or whether it should
106    /// request the data from the daemon via socket.
107    #[serde(default = "default_true")]
108    pub read_local_logs: bool,
109    /// Whether the client should show a confirmation question on potential dangerous actions.
110    #[serde(default = "Default::default")]
111    pub show_confirmation_questions: bool,
112    /// Whether the client should show a confirmation question on potential dangerous actions.
113    #[serde(default = "Default::default")]
114    pub edit_mode: EditMode,
115    /// Whether aliases specified in `pueue_aliases.yml` should be expanded in the `pueue status`
116    /// or shown in their short form.
117    #[serde(default = "Default::default")]
118    pub show_expanded_aliases: bool,
119    /// Whether the client should use dark shades instead of regular colors.
120    #[serde(default = "Default::default")]
121    pub dark_mode: bool,
122    /// The max amount of lines each task get's in the `pueue status` view.
123    pub max_status_lines: Option<usize>,
124    /// The format that will be used to display time formats in `pueue status`.
125    #[serde(default = "default_status_time_format")]
126    pub status_time_format: String,
127    /// The format that will be used to display datetime formats in `pueue status`.
128    #[serde(default = "default_status_datetime_format")]
129    pub status_datetime_format: String,
130}
131
132/// All settings which are used by the daemon
133#[derive(PartialEq, Eq, Clone, Debug, Deserialize, Serialize)]
134pub struct Daemon {
135    /// Whether a group should be paused as soon as a single task fails
136    #[serde(default = "Default::default")]
137    pub pause_group_on_failure: bool,
138    /// Whether the daemon (and all groups) should be paused as soon as a single task fails
139    #[serde(default = "Default::default")]
140    pub pause_all_on_failure: bool,
141    /// If this is set to `true`, the status file will be compressed, which usually results in a
142    /// significantly smaller file. This is particularily useful for I/O starved or embedded
143    /// environments, as it trades a bit of CPU power for I/O ops.
144    ///
145    /// The state tends to be quite large for many tasks, as the whole environment is copied every
146    /// time. You can expect a ~10 compression ratio.
147    #[serde(default = "Default::default")]
148    pub compress_state_file: bool,
149    /// The callback that's called whenever a task finishes.
150    pub callback: Option<String>,
151    /// Environment variables that can be will be injected into all executed processes.
152    #[serde(default = "Default::default")]
153    pub env_vars: HashMap<String, String>,
154    /// The amount of log lines from stdout/stderr that are passed to the callback command.
155    #[serde(default = "default_callback_log_lines")]
156    pub callback_log_lines: usize,
157    /// The command that should be used for task and callback execution.
158    /// The following are the only officially supported modi for Pueue.
159    ///
160    /// Unix default:
161    /// ```
162    /// vec!["sh", "-c", "{{ pueue_command_string }}"];
163    /// ````
164    ///
165    /// Windows default:
166    /// ```
167    /// vec![
168    ///     "powershell",
169    ///     "-c",
170    ///     "[Console]::OutputEncoding = [Text.UTF8Encoding]::UTF8; {{ pueue_command_string }}",
171    /// ];
172    /// ```
173    pub shell_command: Option<Vec<String>>,
174}
175
176impl Default for Shared {
177    fn default() -> Self {
178        Shared {
179            pueue_directory: None,
180            runtime_directory: None,
181            alias_file: None,
182
183            #[cfg(not(target_os = "windows"))]
184            unix_socket_path: None,
185            #[cfg(not(target_os = "windows"))]
186            use_unix_socket: true,
187            #[cfg(not(target_os = "windows"))]
188            unix_socket_permissions: Some(0o700),
189            host: default_host(),
190            port: default_port(),
191
192            pid_path: None,
193            daemon_cert: None,
194            daemon_key: None,
195            shared_secret_path: None,
196        }
197    }
198}
199
200impl Default for Client {
201    fn default() -> Self {
202        Client {
203            restart_in_place: false,
204            read_local_logs: true,
205            show_confirmation_questions: false,
206            show_expanded_aliases: false,
207            edit_mode: Default::default(),
208            dark_mode: false,
209            max_status_lines: None,
210            status_time_format: default_status_time_format(),
211            status_datetime_format: default_status_datetime_format(),
212        }
213    }
214}
215
216impl Default for Daemon {
217    fn default() -> Self {
218        Daemon {
219            pause_group_on_failure: false,
220            pause_all_on_failure: false,
221            callback: None,
222            callback_log_lines: default_callback_log_lines(),
223            compress_state_file: false,
224            shell_command: None,
225            env_vars: HashMap::new(),
226        }
227    }
228}
229
230/// The parent settings struct. \
231/// This contains all other setting structs.
232#[derive(PartialEq, Eq, Clone, Default, Debug, Deserialize, Serialize)]
233pub struct Settings {
234    #[serde(default = "Default::default")]
235    pub client: Client,
236    #[serde(default = "Default::default")]
237    pub daemon: Daemon,
238    #[serde(default = "Default::default")]
239    pub shared: Shared,
240    #[serde(default = "HashMap::new")]
241    pub profiles: HashMap<String, NestedSettings>,
242}
243
244/// The nested settings struct for profiles. \
245/// In contrast to the normal `Settings` struct, this struct doesn't allow profiles.
246/// That way we prevent nested profiles and problems with self-referencing structs.
247#[derive(PartialEq, Eq, Clone, Debug, Deserialize, Serialize)]
248pub struct NestedSettings {
249    #[serde(default = "Default::default")]
250    pub client: Client,
251    #[serde(default = "Default::default")]
252    pub daemon: Daemon,
253    #[serde(default = "Default::default")]
254    pub shared: Shared,
255}
256
257pub fn default_configuration_directory() -> Option<PathBuf> {
258    dirs::config_dir().map(|dir| dir.join("pueue"))
259}
260
261/// Get the default config directory.
262/// If no config can be found, fallback to the current directory.
263pub fn configuration_directories() -> Vec<PathBuf> {
264    if let Some(config_dir) = default_configuration_directory() {
265        vec![config_dir, PathBuf::from(".")]
266    } else {
267        vec![PathBuf::from(".")]
268    }
269}
270
271/// Little helper which expands a given path's `~` characters to a fully qualified path.
272pub fn expand_home(old_path: &Path) -> PathBuf {
273    PathBuf::from(tilde(&old_path.to_string_lossy()).into_owned())
274}
275
276impl Shared {
277    pub fn pueue_directory(&self) -> PathBuf {
278        if let Some(path) = &self.pueue_directory {
279            expand_home(path)
280        } else if let Some(path) = dirs::data_local_dir() {
281            path.join("pueue")
282        } else {
283            PathBuf::from("./pueue")
284        }
285    }
286
287    /// Get the current runtime directory in the following precedence.
288    /// 1. Config value
289    /// 2. Environment configuration
290    /// 3. Pueue directory
291    pub fn runtime_directory(&self) -> PathBuf {
292        if let Some(path) = &self.runtime_directory {
293            expand_home(path)
294        } else if let Some(path) = dirs::runtime_dir() {
295            path
296        } else {
297            self.pueue_directory()
298        }
299    }
300
301    /// The unix socket path can either be explicitly specified or it's simply placed in the
302    /// current runtime directory.
303    #[cfg(not(target_os = "windows"))]
304    pub fn unix_socket_path(&self) -> Result<PathBuf, Error> {
305        if let Some(path) = &self.unix_socket_path {
306            Ok(expand_home(path))
307        } else {
308            let username = whoami::username().map_err(|_| {
309                Error::NoUsername(
310                    "Specify an explicit unix socket path in your config to fix this error."
311                        .to_string(),
312                )
313            })?;
314            Ok(self
315                .runtime_directory()
316                .join(format!("pueue_{username}.socket")))
317        }
318    }
319
320    /// The location of the alias file used by the daemon/client when working with
321    /// task aliases.
322    pub fn alias_file(&self) -> PathBuf {
323        if let Some(path) = &self.alias_file {
324            expand_home(path)
325        } else if let Some(config_dir) = default_configuration_directory() {
326            config_dir.join("pueue_aliases.yml")
327        } else {
328            PathBuf::from("pueue_aliases.yml")
329        }
330    }
331
332    /// The daemon's pid path can either be explicitly specified or it's simply placed in the
333    /// current runtime directory.
334    pub fn pid_path(&self) -> PathBuf {
335        if let Some(path) = &self.pid_path {
336            expand_home(path)
337        } else {
338            self.runtime_directory().join("pueue.pid")
339        }
340    }
341
342    pub fn daemon_cert(&self) -> PathBuf {
343        if let Some(path) = &self.daemon_cert {
344            expand_home(path)
345        } else {
346            self.pueue_directory().join("certs").join("daemon.cert")
347        }
348    }
349
350    pub fn daemon_key(&self) -> PathBuf {
351        if let Some(path) = &self.daemon_key {
352            expand_home(path)
353        } else {
354            self.pueue_directory().join("certs").join("daemon.key")
355        }
356    }
357
358    pub fn shared_secret_path(&self) -> PathBuf {
359        if let Some(path) = &self.shared_secret_path {
360            expand_home(path)
361        } else {
362            self.pueue_directory().join("shared_secret")
363        }
364    }
365}
366
367impl Settings {
368    /// Try to read existing config files, while using default values for non-existing fields.
369    /// If successful, this will return a full config as well as a boolean on whether we found an
370    /// existing configuration file or not.
371    ///
372    /// The default local config locations depends on the current target.
373    pub fn read(from_file: &Option<PathBuf>) -> Result<(Settings, bool), Error> {
374        // If no explicit path is provided, we look for the PUEUE_CONFIG_PATH env variable.
375        let from_file = from_file
376            .clone()
377            .or_else(|| std::env::var(PUEUE_CONFIG_PATH_ENV).map(PathBuf::from).ok());
378
379        // Load the config from a very specific file path
380        if let Some(path) = &from_file {
381            // Open the file in read-only mode with buffer.
382            let file = File::open(path)
383                .map_err(|err| Error::IoPathError(path.clone(), "opening config file", err))?;
384            let reader = BufReader::new(file);
385
386            let settings = serde_yaml::from_reader(reader)
387                .map_err(|err| Error::ConfigDeserialization(err.to_string()))?;
388            return Ok((settings, true));
389        };
390
391        info!("Parsing config files");
392
393        let config_dirs = configuration_directories();
394        for directory in config_dirs.into_iter() {
395            let path = directory.join("pueue.yml");
396            info!("Checking path: {path:?}");
397
398            // Check if the file exists and parse it.
399            if path.exists() && path.is_file() {
400                info!("Found config file at: {path:?}");
401
402                // Open the file in read-only mode with buffer.
403                let file = File::open(&path)
404                    .map_err(|err| Error::IoPathError(path, "opening config file.", err))?;
405                let reader = BufReader::new(file);
406
407                let settings = serde_yaml::from_reader(reader)
408                    .map_err(|err| Error::ConfigDeserialization(err.to_string()))?;
409                return Ok((settings, true));
410            }
411        }
412
413        info!("No config file found. Use default config.");
414        // Return a default configuration if we couldn't find a file.
415        Ok((Settings::default(), false))
416    }
417
418    /// Save the current configuration as a file to the given path. \
419    /// If no path is given, the default configuration path will be used. \
420    /// The file is then written to the main configuration directory of the respective OS.
421    pub fn save(&self, path: &Option<PathBuf>) -> Result<(), Error> {
422        let config_path = if let Some(path) = path {
423            path.clone()
424        } else if let Ok(path) = std::env::var(PUEUE_CONFIG_PATH_ENV) {
425            PathBuf::from(path)
426        } else if let Some(path) = dirs::config_dir() {
427            let path = path.join("pueue");
428            path.join("pueue.yml")
429        } else {
430            return Err(Error::Generic(
431                "Failed to resolve default config directory. User home cannot be determined."
432                    .into(),
433            ));
434        };
435        let config_dir = config_path
436            .parent()
437            .ok_or_else(|| Error::InvalidPath("Couldn't resolve config directory".into()))?;
438
439        // Create the config dir, if it doesn't exist yet
440        if !config_dir.exists() {
441            create_dir_all(config_dir).map_err(|err| {
442                Error::IoPathError(config_dir.to_path_buf(), "creating config dir", err)
443            })?;
444        }
445
446        let content = match serde_yaml::to_string(self) {
447            Ok(content) => content,
448            Err(error) => {
449                return Err(Error::Generic(format!(
450                    "Configuration file serialization failed:\n{error}"
451                )));
452            }
453        };
454        let mut file = File::create(&config_path).map_err(|err| {
455            Error::IoPathError(config_dir.to_path_buf(), "creating settings file", err)
456        })?;
457        file.write_all(content.as_bytes()).map_err(|err| {
458            Error::IoPathError(config_dir.to_path_buf(), "writing settings file", err)
459        })?;
460
461        Ok(())
462    }
463
464    /// Try to load a profile. Error if it doesn't exist.
465    pub fn load_profile(&mut self, profile: &str) -> Result<(), Error> {
466        let profile = self.profiles.remove(profile).ok_or_else(|| {
467            Error::ConfigDeserialization(format!("Couldn't find profile with name \"{profile}\""))
468        })?;
469
470        self.client = profile.client;
471        self.daemon = profile.daemon;
472        self.shared = profile.shared;
473
474        Ok(())
475    }
476}
477
478#[cfg(test)]
479mod test {
480    use super::*;
481
482    /// Check if profiles get loaded correctly.
483    #[test]
484    fn test_load_profile() {
485        // Create some default settings and ensure that default values are loaded.
486        let mut settings = Settings::default();
487        assert_eq!(
488            settings.client.status_time_format,
489            default_status_time_format()
490        );
491        assert_eq!(
492            settings.daemon.callback_log_lines,
493            default_callback_log_lines()
494        );
495        assert_eq!(settings.shared.host, default_host());
496
497        // Crate a new profile with slightly different values.
498        let mut profile = Settings::default();
499        profile.client.status_time_format = "test".to_string();
500        profile.daemon.callback_log_lines = 100_000;
501        profile.shared.host = "quatschhost".to_string();
502        let profile = NestedSettings {
503            client: profile.client,
504            daemon: profile.daemon,
505            shared: profile.shared,
506        };
507
508        settings.profiles.insert("testprofile".to_string(), profile);
509
510        // Load the profile and ensure the new values are now loaded.
511        settings
512            .load_profile("testprofile")
513            .expect("We just added the profile");
514
515        assert_eq!(settings.client.status_time_format, "test");
516        assert_eq!(settings.daemon.callback_log_lines, 100_000);
517        assert_eq!(settings.shared.host, "quatschhost");
518    }
519
520    /// A proper pueue [Error] should be thrown if the profile cannot be found.
521    #[test]
522    fn test_error_on_missing_profile() {
523        let mut settings = Settings::default();
524
525        let result = settings.load_profile("doesn't exist");
526        let expected_error_message = "Couldn't find profile with name \"doesn't exist\"";
527        if let Err(Error::ConfigDeserialization(error_message)) = result {
528            assert_eq!(error_message, expected_error_message);
529            return;
530        }
531
532        panic!("Got unexpected result when expecting missing profile error: {result:?}");
533    }
534}