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) -> PathBuf {
305        if let Some(path) = &self.unix_socket_path {
306            expand_home(path)
307        } else {
308            self.runtime_directory()
309                .join(format!("pueue_{}.socket", whoami::username()))
310        }
311    }
312
313    /// The location of the alias file used by the daemon/client when working with
314    /// task aliases.
315    pub fn alias_file(&self) -> PathBuf {
316        if let Some(path) = &self.alias_file {
317            expand_home(path)
318        } else if let Some(config_dir) = default_configuration_directory() {
319            config_dir.join("pueue_aliases.yml")
320        } else {
321            PathBuf::from("pueue_aliases.yml")
322        }
323    }
324
325    /// The daemon's pid path can either be explicitly specified or it's simply placed in the
326    /// current runtime directory.
327    pub fn pid_path(&self) -> PathBuf {
328        if let Some(path) = &self.pid_path {
329            expand_home(path)
330        } else {
331            self.runtime_directory().join("pueue.pid")
332        }
333    }
334
335    pub fn daemon_cert(&self) -> PathBuf {
336        if let Some(path) = &self.daemon_cert {
337            expand_home(path)
338        } else {
339            self.pueue_directory().join("certs").join("daemon.cert")
340        }
341    }
342
343    pub fn daemon_key(&self) -> PathBuf {
344        if let Some(path) = &self.daemon_key {
345            expand_home(path)
346        } else {
347            self.pueue_directory().join("certs").join("daemon.key")
348        }
349    }
350
351    pub fn shared_secret_path(&self) -> PathBuf {
352        if let Some(path) = &self.shared_secret_path {
353            expand_home(path)
354        } else {
355            self.pueue_directory().join("shared_secret")
356        }
357    }
358}
359
360impl Settings {
361    /// Try to read existing config files, while using default values for non-existing fields.
362    /// If successful, this will return a full config as well as a boolean on whether we found an
363    /// existing configuration file or not.
364    ///
365    /// The default local config locations depends on the current target.
366    pub fn read(from_file: &Option<PathBuf>) -> Result<(Settings, bool), Error> {
367        // If no explicit path is provided, we look for the PUEUE_CONFIG_PATH env variable.
368        let from_file = from_file
369            .clone()
370            .or_else(|| std::env::var(PUEUE_CONFIG_PATH_ENV).map(PathBuf::from).ok());
371
372        // Load the config from a very specific file path
373        if let Some(path) = &from_file {
374            // Open the file in read-only mode with buffer.
375            let file = File::open(path)
376                .map_err(|err| Error::IoPathError(path.clone(), "opening config file", err))?;
377            let reader = BufReader::new(file);
378
379            let settings = serde_yaml::from_reader(reader)
380                .map_err(|err| Error::ConfigDeserialization(err.to_string()))?;
381            return Ok((settings, true));
382        };
383
384        info!("Parsing config files");
385
386        let config_dirs = configuration_directories();
387        for directory in config_dirs.into_iter() {
388            let path = directory.join("pueue.yml");
389            info!("Checking path: {path:?}");
390
391            // Check if the file exists and parse it.
392            if path.exists() && path.is_file() {
393                info!("Found config file at: {path:?}");
394
395                // Open the file in read-only mode with buffer.
396                let file = File::open(&path)
397                    .map_err(|err| Error::IoPathError(path, "opening config file.", err))?;
398                let reader = BufReader::new(file);
399
400                let settings = serde_yaml::from_reader(reader)
401                    .map_err(|err| Error::ConfigDeserialization(err.to_string()))?;
402                return Ok((settings, true));
403            }
404        }
405
406        info!("No config file found. Use default config.");
407        // Return a default configuration if we couldn't find a file.
408        Ok((Settings::default(), false))
409    }
410
411    /// Save the current configuration as a file to the given path. \
412    /// If no path is given, the default configuration path will be used. \
413    /// The file is then written to the main configuration directory of the respective OS.
414    pub fn save(&self, path: &Option<PathBuf>) -> Result<(), Error> {
415        let config_path = if let Some(path) = path {
416            path.clone()
417        } else if let Ok(path) = std::env::var(PUEUE_CONFIG_PATH_ENV) {
418            PathBuf::from(path)
419        } else if let Some(path) = dirs::config_dir() {
420            let path = path.join("pueue");
421            path.join("pueue.yml")
422        } else {
423            return Err(Error::Generic(
424                "Failed to resolve default config directory. User home cannot be determined."
425                    .into(),
426            ));
427        };
428        let config_dir = config_path
429            .parent()
430            .ok_or_else(|| Error::InvalidPath("Couldn't resolve config directory".into()))?;
431
432        // Create the config dir, if it doesn't exist yet
433        if !config_dir.exists() {
434            create_dir_all(config_dir).map_err(|err| {
435                Error::IoPathError(config_dir.to_path_buf(), "creating config dir", err)
436            })?;
437        }
438
439        let content = match serde_yaml::to_string(self) {
440            Ok(content) => content,
441            Err(error) => {
442                return Err(Error::Generic(format!(
443                    "Configuration file serialization failed:\n{error}"
444                )));
445            }
446        };
447        let mut file = File::create(&config_path).map_err(|err| {
448            Error::IoPathError(config_dir.to_path_buf(), "creating settings file", err)
449        })?;
450        file.write_all(content.as_bytes()).map_err(|err| {
451            Error::IoPathError(config_dir.to_path_buf(), "writing settings file", err)
452        })?;
453
454        Ok(())
455    }
456
457    /// Try to load a profile. Error if it doesn't exist.
458    pub fn load_profile(&mut self, profile: &str) -> Result<(), Error> {
459        let profile = self.profiles.remove(profile).ok_or_else(|| {
460            Error::ConfigDeserialization(format!("Couldn't find profile with name \"{profile}\""))
461        })?;
462
463        self.client = profile.client;
464        self.daemon = profile.daemon;
465        self.shared = profile.shared;
466
467        Ok(())
468    }
469}
470
471#[cfg(test)]
472mod test {
473    use super::*;
474
475    /// Check if profiles get loaded correctly.
476    #[test]
477    fn test_load_profile() {
478        // Create some default settings and ensure that default values are loaded.
479        let mut settings = Settings::default();
480        assert_eq!(
481            settings.client.status_time_format,
482            default_status_time_format()
483        );
484        assert_eq!(
485            settings.daemon.callback_log_lines,
486            default_callback_log_lines()
487        );
488        assert_eq!(settings.shared.host, default_host());
489
490        // Crate a new profile with slightly different values.
491        let mut profile = Settings::default();
492        profile.client.status_time_format = "test".to_string();
493        profile.daemon.callback_log_lines = 100_000;
494        profile.shared.host = "quatschhost".to_string();
495        let profile = NestedSettings {
496            client: profile.client,
497            daemon: profile.daemon,
498            shared: profile.shared,
499        };
500
501        settings.profiles.insert("testprofile".to_string(), profile);
502
503        // Load the profile and ensure the new values are now loaded.
504        settings
505            .load_profile("testprofile")
506            .expect("We just added the profile");
507
508        assert_eq!(settings.client.status_time_format, "test");
509        assert_eq!(settings.daemon.callback_log_lines, 100_000);
510        assert_eq!(settings.shared.host, "quatschhost");
511    }
512
513    /// A proper pueue [Error] should be thrown if the profile cannot be found.
514    #[test]
515    fn test_error_on_missing_profile() {
516        let mut settings = Settings::default();
517
518        let result = settings.load_profile("doesn't exist");
519        let expected_error_message = "Couldn't find profile with name \"doesn't exist\"";
520        if let Err(Error::ConfigDeserialization(error_message)) = result {
521            assert_eq!(error_message, expected_error_message);
522            return;
523        }
524
525        panic!("Got unexpected result when expecting missing profile error: {result:?}");
526    }
527}