Skip to main content

libplasmoid_updater/
config.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2
3use std::collections::HashMap;
4use std::sync::LazyLock;
5
6/// Default embedded widgets-id mapping file provided by Apdatifier.
7///
8/// This file maps component directory names to KDE Store content IDs
9/// and is used as a fallback when other resolution methods fail.
10const DEFAULT_WIDGETS_ID: &str = include_str!("../widgets-id");
11
12static DEFAULT_WIDGETS_TABLE: LazyLock<HashMap<String, u64>> =
13    LazyLock::new(|| Config::parse_widgets_id(DEFAULT_WIDGETS_ID));
14
15/// Controls plasmashell restart behavior after updates.
16#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
17pub enum RestartBehavior {
18    /// Never restart plasmashell (default).
19    #[default]
20    Never,
21    /// Always restart plasmashell after successful updates that require it.
22    Always,
23    /// Prompt the user interactively. Falls back to [`Never`](Self::Never) if
24    /// stdin is not a terminal.
25    Prompt,
26}
27
28/// Configuration for libplasmoid-updater operations.
29///
30/// This struct contains all configuration options used by the library.
31/// Library consumers (like topgrade or other automation tools) can construct
32/// this directly without needing config file parsing.
33///
34/// # Examples
35///
36/// ## Basic Configuration
37///
38/// ```rust
39/// use libplasmoid_updater::Config;
40///
41/// let config = Config::new();
42/// ```
43///
44/// ## With Custom Settings
45///
46/// ```rust
47/// use libplasmoid_updater::{Config, RestartBehavior};
48/// use std::collections::HashMap;
49///
50/// let mut widgets_table = HashMap::new();
51/// widgets_table.insert("com.example.widget".to_string(), 123456);
52///
53/// let config = Config::new()
54///     .with_excluded_packages(vec!["problematic-widget".to_string()])
55///     .with_widgets_id_table(widgets_table)
56///     .with_restart(RestartBehavior::Always);
57/// ```
58#[derive(Debug, Clone, Default)]
59pub struct Config {
60    /// If `true`, operate on system-wide components (in `/usr/share`).
61    /// If `false` (default), operate on user components (in `~/.local/share`).
62    /// System operations require root privileges.
63    pub system: bool,
64
65    /// Packages to exclude from updates.
66    ///
67    /// Can match either directory names (e.g., "org.kde.plasma.systemmonitor")
68    /// or display names (e.g., "System Monitor"). Components in this list
69    /// will be skipped during update operations.
70    pub excluded_packages: Vec<String>,
71
72    /// Widget ID fallback table mapping directory names to KDE Store content IDs.
73    ///
74    /// This table is used as a fallback when content ID resolution via KNewStuff
75    /// registry or exact name matching fails. The library uses a three-tier
76    /// resolution strategy:
77    ///
78    /// 1. KNewStuff registry lookup (most reliable)
79    /// 2. Exact name match from KDE Store API
80    /// 3. Fallback to this widgets_id_table
81    ///
82    /// # Format
83    ///
84    /// - Key: Component directory name (e.g., "org.kde.plasma.systemmonitor")
85    /// - Value: KDE Store content ID (numeric)
86    ///
87    /// The CLI application loads this from a `widgets-id` file, but library
88    /// consumers can provide it programmatically or leave it empty.
89    pub widgets_id_table: HashMap<String, u64>,
90
91    /// Controls plasmashell restart behavior after successful updates.
92    pub restart: RestartBehavior,
93
94    /// When `true`, skip interactive prompts and apply all non-excluded updates
95    /// automatically. Has no effect without the `cli` feature.
96    pub auto_confirm: bool,
97
98    /// Maximum number of parallel installation threads.
99    ///
100    /// `None` (default) uses the number of logical CPU threads available.
101    /// `Some(n)` pins the pool to exactly `n` threads.
102    pub threads: Option<usize>,
103
104    /// When `true`, skip KDE Plasma environment detection and proceed regardless.
105    pub skip_plasma_detection: bool,
106
107    /// When `true` (default), inhibit system idle/sleep/shutdown during installs.
108    ///
109    /// Uses a 3-tier fallback: logind DBus → `systemd-inhibit` subprocess → no-op.
110    /// Set to `false` if the caller handles its own power management inhibition.
111    pub inhibit_idle: bool,
112}
113
114impl Config {
115    /// Creates a new configuration with default values.
116    ///
117    /// Default values:
118    /// - `system`: false (user components)
119    /// - `excluded_packages`: empty
120    /// - `widgets_id_table`: loaded from embedded widgets-id file
121    /// - `restart`: [`RestartBehavior::Never`]
122    ///
123    /// The embedded widgets-id table provides fallback content ID mappings
124    /// for components that cannot be resolved via KNewStuff registry or
125    /// exact name matching.
126    pub fn new() -> Self {
127        Self {
128            widgets_id_table: DEFAULT_WIDGETS_TABLE.clone(),
129            inhibit_idle: true,
130            ..Default::default()
131        }
132    }
133
134    /// Sets whether to operate on system-wide components.
135    ///
136    /// When true, the library scans and updates components in `/usr/share`
137    /// instead of `~/.local/share`. System operations require root privileges.
138    ///
139    /// # Example
140    ///
141    /// ```rust
142    /// use libplasmoid_updater::Config;
143    ///
144    /// let config = Config::new().with_system(true);
145    /// ```
146    pub fn with_system(mut self, system: bool) -> Self {
147        self.system = system;
148        self
149    }
150
151    /// Sets the widgets ID fallback table.
152    ///
153    /// This table maps component directory names to KDE Store content IDs
154    /// and is used as a fallback when other resolution methods fail.
155    ///
156    /// # Arguments
157    ///
158    /// * `table` - HashMap mapping directory names to content IDs
159    ///
160    /// # Example
161    ///
162    /// ```rust
163    /// use libplasmoid_updater::Config;
164    /// use std::collections::HashMap;
165    ///
166    /// let mut table = HashMap::new();
167    /// table.insert("org.kde.plasma.systemmonitor".to_string(), 998890);
168    ///
169    /// let config = Config::new().with_widgets_id_table(table);
170    /// ```
171    pub fn with_widgets_id_table(mut self, table: HashMap<String, u64>) -> Self {
172        self.widgets_id_table = table;
173        self
174    }
175
176    /// Sets the list of Plasmoids to exclude from updates.
177    ///
178    /// Components in this list will be skipped during updates.
179    /// The list can contain either directory names or display names.
180    ///
181    /// # Arguments
182    ///
183    /// * `packages` - Vector of package names to exclude
184    ///
185    /// # Example
186    ///
187    /// ```rust
188    /// use libplasmoid_updater::Config;
189    ///
190    /// let config = Config::new()
191    ///     .with_excluded_packages(vec![
192    ///         "org.kde.plasma.systemmonitor".to_string(),
193    ///         "Problematic Widget".to_string(),
194    ///     ]);
195    /// ```
196    pub fn with_excluded_packages(mut self, packages: Vec<String>) -> Self {
197        self.excluded_packages = packages;
198        self
199    }
200
201    /// Sets the plasmashell restart behavior after updates.
202    ///
203    /// # Example
204    ///
205    /// ```rust
206    /// use libplasmoid_updater::{Config, RestartBehavior};
207    ///
208    /// let config = Config::new().with_restart(RestartBehavior::Always);
209    /// ```
210    pub fn with_restart(mut self, restart: RestartBehavior) -> Self {
211        self.restart = restart;
212        self
213    }
214
215    /// Parses a widgets-id table from a string.
216    ///
217    /// The format is one entry per line: `content_id directory_name`
218    /// Lines starting with `#` are comments.
219    pub fn parse_widgets_id(content: &str) -> HashMap<String, u64> {
220        let mut table = HashMap::with_capacity(content.lines().count());
221        for line in content.lines() {
222            if let Some((id, name)) = parse_widgets_id_line(line) {
223                table.insert(name, id);
224            }
225        }
226        table
227    }
228
229    /// Sets whether to skip interactive prompts and apply all non-excluded
230    /// updates automatically.
231    ///
232    /// Has no effect without the `cli` feature enabled.
233    ///
234    /// # Example
235    ///
236    /// ```rust
237    /// use libplasmoid_updater::Config;
238    ///
239    /// let config = Config::new().with_auto_confirm(true);
240    /// ```
241    pub fn with_auto_confirm(mut self, auto_confirm: bool) -> Self {
242        self.auto_confirm = auto_confirm;
243        self
244    }
245
246    /// Sets the maximum number of parallel installation threads.
247    ///
248    /// By default (`None`), the library uses the number of logical CPUs.
249    /// Setting this to a specific value pins the thread pool to exactly
250    /// that many threads.
251    ///
252    /// # Example
253    ///
254    /// ```rust
255    /// use libplasmoid_updater::Config;
256    ///
257    /// let config = Config::new().with_threads(4);
258    /// ```
259    pub fn with_threads(mut self, threads: usize) -> Self {
260        self.threads = Some(threads);
261        self
262    }
263
264    /// Sets whether to skip KDE Plasma environment detection.
265    ///
266    /// When `true`, the library proceeds without checking for the KNewStuff3
267    /// registry directory. Useful for CI, testing, or non-standard KDE setups.
268    ///
269    /// # Example
270    ///
271    /// ```rust
272    /// use libplasmoid_updater::Config;
273    ///
274    /// let config = Config::new().with_skip_plasma_detection(true);
275    /// ```
276    pub fn with_skip_plasma_detection(mut self, skip: bool) -> Self {
277        self.skip_plasma_detection = skip;
278        self
279    }
280
281    /// Sets whether to inhibit system idle/sleep/shutdown during installs.
282    ///
283    /// Defaults to `true`. Set to `false` if the calling application handles
284    /// its own power management inhibition (e.g., a GUI app using DBus directly).
285    ///
286    /// # Example
287    ///
288    /// ```rust
289    /// use libplasmoid_updater::Config;
290    ///
291    /// let config = Config::new().with_inhibit_idle(false);
292    /// assert!(!config.inhibit_idle);
293    /// ```
294    pub fn with_inhibit_idle(mut self, inhibit: bool) -> Self {
295        self.inhibit_idle = inhibit;
296        self
297    }
298}
299
300pub(crate) fn parse_widgets_id_line(line: &str) -> Option<(u64, String)> {
301    let line = line.trim();
302    if line.is_empty() || line.starts_with('#') {
303        return None;
304    }
305
306    let mut parts = line.splitn(2, ' ');
307    let id = parts.next()?.parse::<u64>().ok()?;
308    let name = parts.next()?.trim();
309    if name.is_empty() {
310        return None;
311    }
312    Some((id, name.to_string()))
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318
319    #[test]
320    fn test_parse_widgets_id_line_valid() {
321        let line = "998890 com.bxabi.bumblebee-indicator";
322        let result = parse_widgets_id_line(line);
323        assert_eq!(
324            result,
325            Some((998890, "com.bxabi.bumblebee-indicator".to_string()))
326        );
327    }
328
329    #[test]
330    fn test_parse_widgets_id_line_comment() {
331        let line = "#2182964 adhe.menu.11 #Ignored, not a unique ID";
332        let result = parse_widgets_id_line(line);
333        assert_eq!(result, None);
334    }
335
336    #[test]
337    fn test_parse_widgets_id_line_empty() {
338        let line = "";
339        let result = parse_widgets_id_line(line);
340        assert_eq!(result, None);
341    }
342
343    #[test]
344    fn test_parse_widgets_id_table() {
345        let content = "998890 com.bxabi.bumblebee-indicator\n\
346                       998913 org.kde.plasma.awesomewidget\n\
347                       # Comment line\n\
348                       1155946 com.dschopf.plasma.qalculate\n";
349        let table = Config::parse_widgets_id(content);
350        assert_eq!(table.len(), 3);
351        assert_eq!(table.get("com.bxabi.bumblebee-indicator"), Some(&998890));
352        assert_eq!(table.get("org.kde.plasma.awesomewidget"), Some(&998913));
353        assert_eq!(table.get("com.dschopf.plasma.qalculate"), Some(&1155946));
354    }
355
356    #[test]
357    fn test_default_widgets_id_table_loads() {
358        let config = Config::new();
359        // Verify the embedded file is loaded and contains expected entries
360        assert!(
361            !config.widgets_id_table.is_empty(),
362            "Default widgets_id_table should not be empty"
363        );
364        // Check for a few known entries from the widgets-id file
365        assert_eq!(
366            config.widgets_id_table.get("com.bxabi.bumblebee-indicator"),
367            Some(&998890)
368        );
369        assert_eq!(
370            config.widgets_id_table.get("org.kde.plasma.awesomewidget"),
371            Some(&998913)
372        );
373    }
374
375    #[test]
376    fn test_config_with_custom_widgets_id_table() {
377        let mut custom_table = HashMap::new();
378        custom_table.insert("custom.widget".to_string(), 123456);
379
380        let config = Config::new().with_widgets_id_table(custom_table.clone());
381
382        // Should use custom table, not default
383        assert_eq!(config.widgets_id_table, custom_table);
384        assert_eq!(config.widgets_id_table.get("custom.widget"), Some(&123456));
385        // Default entry should not be present
386        assert_eq!(
387            config.widgets_id_table.get("com.bxabi.bumblebee-indicator"),
388            None
389        );
390    }
391
392    #[test]
393    fn default_widgets_table_is_cached() {
394        // Creating multiple configs should all have the same table
395        let config1 = Config::new();
396        let config2 = Config::new();
397        assert_eq!(config1.widgets_id_table, config2.widgets_id_table);
398        assert!(!config1.widgets_id_table.is_empty());
399    }
400
401    #[test]
402    fn cached_table_matches_fresh_parse() {
403        let cached = &*DEFAULT_WIDGETS_TABLE;
404        let fresh = Config::parse_widgets_id(DEFAULT_WIDGETS_ID);
405        assert_eq!(cached, &fresh);
406    }
407}