Skip to main content

leenfetch_core/config/
mod.rs

1pub mod defaults;
2pub mod settings;
3
4use self::{
5    defaults::{
6        CLASSIC_NEOFETCH_CONFIG, CLEAN_MONO_CONFIG, DEFAULT_CONFIG, HARDWARE_HEAVY_CONFIG,
7        MINIMAL_CONFIG, NEOFETCH_CONFIG, SCREENSHOT_CONFIG, SYSTEM_ADMIN_CONFIG, TINY_CONFIG,
8    },
9    settings::{Config, Flags, LayoutItem},
10};
11use dirs::config_dir;
12use json5;
13use once_cell::sync::Lazy;
14use std::io::Write;
15use std::path::PathBuf;
16use std::{
17    collections::HashMap,
18    fs::{self, File},
19};
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ConfigPreset {
23    Default,
24    Neofetch,
25    ClassicNeofetch,
26    Minimal,
27    Screenshot,
28    HardwareHeavy,
29    SystemAdmin,
30    CleanMono,
31    Tiny,
32}
33
34static DEFAULT_CONFIG_CACHE: Lazy<Config> = Lazy::new(|| {
35    json5::from_str(DEFAULT_CONFIG)
36        .unwrap_or_else(|e| panic!("Built-in default config is invalid JSON: {e}"))
37});
38
39static NEOFETCH_CONFIG_CACHE: Lazy<Config> = Lazy::new(|| {
40    json5::from_str(NEOFETCH_CONFIG)
41        .unwrap_or_else(|e| panic!("Built-in neofetch config is invalid JSON: {e}"))
42});
43
44static CLASSIC_NEOFETCH_CONFIG_CACHE: Lazy<Config> = Lazy::new(|| {
45    json5::from_str(CLASSIC_NEOFETCH_CONFIG)
46        .unwrap_or_else(|e| panic!("Built-in classic neofetch config is invalid JSON: {e}"))
47});
48
49static MINIMAL_CONFIG_CACHE: Lazy<Config> = Lazy::new(|| {
50    json5::from_str(MINIMAL_CONFIG)
51        .unwrap_or_else(|e| panic!("Built-in minimal config is invalid JSON: {e}"))
52});
53
54static SCREENSHOT_CONFIG_CACHE: Lazy<Config> = Lazy::new(|| {
55    json5::from_str(SCREENSHOT_CONFIG)
56        .unwrap_or_else(|e| panic!("Built-in screenshot config is invalid JSON: {e}"))
57});
58
59static HARDWARE_HEAVY_CONFIG_CACHE: Lazy<Config> = Lazy::new(|| {
60    json5::from_str(HARDWARE_HEAVY_CONFIG)
61        .unwrap_or_else(|e| panic!("Built-in hardware-heavy config is invalid JSON: {e}"))
62});
63
64static SYSTEM_ADMIN_CONFIG_CACHE: Lazy<Config> = Lazy::new(|| {
65    json5::from_str(SYSTEM_ADMIN_CONFIG)
66        .unwrap_or_else(|e| panic!("Built-in system-admin config is invalid JSON: {e}"))
67});
68
69static CLEAN_MONO_CONFIG_CACHE: Lazy<Config> = Lazy::new(|| {
70    json5::from_str(CLEAN_MONO_CONFIG)
71        .unwrap_or_else(|e| panic!("Built-in clean mono config is invalid JSON: {e}"))
72});
73
74static TINY_CONFIG_CACHE: Lazy<Config> = Lazy::new(|| {
75    json5::from_str(TINY_CONFIG)
76        .unwrap_or_else(|e| panic!("Built-in tiny config is invalid JSON: {e}"))
77});
78
79/// Loads the unified configuration from `config.jsonc`.
80fn load_config() -> Result<Config, String> {
81    let path = config_file("config.jsonc");
82    let data = fs::read_to_string(&path)
83        .map_err(|e| format!("Failed to read config.jsonc ({}): {}", path.display(), e))?;
84    load_config_from_str(&data)
85}
86
87fn load_config_from_str(data: &str) -> Result<Config, String> {
88    json5::from_str(data).map_err(|e| format!("Invalid JSONC in config.jsonc: {}", e))
89}
90
91pub fn load_preset_config(preset: ConfigPreset) -> Config {
92    match preset {
93        ConfigPreset::Default => default_config(),
94        ConfigPreset::Neofetch => NEOFETCH_CONFIG_CACHE.clone(),
95        ConfigPreset::ClassicNeofetch => CLASSIC_NEOFETCH_CONFIG_CACHE.clone(),
96        ConfigPreset::Minimal => MINIMAL_CONFIG_CACHE.clone(),
97        ConfigPreset::Screenshot => SCREENSHOT_CONFIG_CACHE.clone(),
98        ConfigPreset::HardwareHeavy => HARDWARE_HEAVY_CONFIG_CACHE.clone(),
99        ConfigPreset::SystemAdmin => SYSTEM_ADMIN_CONFIG_CACHE.clone(),
100        ConfigPreset::CleanMono => CLEAN_MONO_CONFIG_CACHE.clone(),
101        ConfigPreset::Tiny => TINY_CONFIG_CACHE.clone(),
102    }
103}
104
105pub fn preset_label(preset: ConfigPreset) -> &'static str {
106    match preset {
107        ConfigPreset::Default => "leenfetch default",
108        ConfigPreset::Neofetch => "neofetch-compatible",
109        ConfigPreset::ClassicNeofetch => "classic neofetch",
110        ConfigPreset::Minimal => "minimal",
111        ConfigPreset::Screenshot => "screenshot",
112        ConfigPreset::HardwareHeavy => "hardware-heavy",
113        ConfigPreset::SystemAdmin => "system-admin",
114        ConfigPreset::CleanMono => "clean mono",
115        ConfigPreset::Tiny => "tiny",
116    }
117}
118
119pub fn preset_content(preset: ConfigPreset) -> &'static str {
120    match preset {
121        ConfigPreset::Default => DEFAULT_CONFIG,
122        ConfigPreset::Neofetch => NEOFETCH_CONFIG,
123        ConfigPreset::ClassicNeofetch => CLASSIC_NEOFETCH_CONFIG,
124        ConfigPreset::Minimal => MINIMAL_CONFIG,
125        ConfigPreset::Screenshot => SCREENSHOT_CONFIG,
126        ConfigPreset::HardwareHeavy => HARDWARE_HEAVY_CONFIG,
127        ConfigPreset::SystemAdmin => SYSTEM_ADMIN_CONFIG,
128        ConfigPreset::CleanMono => CLEAN_MONO_CONFIG,
129        ConfigPreset::Tiny => TINY_CONFIG,
130    }
131}
132
133pub fn config_path() -> PathBuf {
134    config_file("config.jsonc")
135}
136
137pub fn config_file_path(name: &str) -> PathBuf {
138    config_file(name)
139}
140
141pub fn config_exists() -> bool {
142    config_path().exists()
143}
144
145/// Loads configuration from a custom path when provided.
146pub fn load_config_at(path: Option<&str>) -> Result<Config, String> {
147    match path {
148        Some(custom_path) => {
149            let data = fs::read_to_string(custom_path)
150                .map_err(|err| format!("Failed to read config at {}: {}", custom_path, err))?;
151            load_config_from_str(&data)
152        }
153        None => load_config(),
154    }
155}
156
157/// Returns the built-in default configuration.
158pub fn default_config() -> Config {
159    DEFAULT_CONFIG_CACHE.clone()
160}
161
162/// Returns the built-in default layout section.
163pub fn default_layout() -> Vec<LayoutItem> {
164    default_config().layout
165}
166
167/// Loads the effective configuration, falling back to built-in defaults on any error.
168pub fn load_effective_config_at(path: Option<&str>) -> Config {
169    let mut config = load_config_at(path).unwrap_or_else(|_| default_config());
170    if config.layout.is_empty() {
171        config.layout = default_layout();
172    }
173    config
174}
175
176/// Loads the effective configuration from the default path, falling back to built-in defaults.
177pub fn load_effective_config() -> Config {
178    load_effective_config_at(None)
179}
180
181/// Loads the modules section from `config.jsonc`.
182///
183/// # Returns
184///
185/// A `Vec<LayoutItem>` containing the loaded module configuration.
186pub fn load_print_layout() -> Vec<LayoutItem> {
187    let layout = load_config()
188        .unwrap_or_else(|e| {
189            eprintln!("leenfetch: config error: {e}; using defaults");
190            default_config()
191        })
192        .layout;
193    if layout.is_empty() {
194        default_layout()
195    } else {
196        layout
197    }
198}
199
200/// Loads the configuration flags from `config.jsonc`.
201///
202/// # Returns
203///
204/// A `Flags` struct containing the loaded configuration.
205pub fn load_flags() -> Flags {
206    load_config()
207        .unwrap_or_else(|e| {
208            eprintln!("leenfetch: config error: {e}; using defaults");
209            default_config()
210        })
211        .flags
212}
213
214/// Generates the default unified configuration file.
215///
216/// Writes `config.jsonc` with the default contents. Returns a map with the filename
217/// and whether the operation succeeded, matching the previous multi-file API.
218pub fn generate_config_files() -> HashMap<String, bool> {
219    let mut results = HashMap::new();
220
221    let result = write_config_preset(ConfigPreset::Default).is_ok();
222    results.insert("config.jsonc".to_string(), result);
223
224    results
225}
226
227pub fn write_config_preset(preset: ConfigPreset) -> std::io::Result<()> {
228    save_to_config_file("config.jsonc", preset_content(preset))
229}
230
231/// Saves the provided content to a configuration file with the specified file name.
232///
233/// The function ensures that the directory for the file exists, creating it if necessary.
234/// It then writes the content to the file, overwriting any existing content.
235///
236/// # Arguments
237///
238/// * `file_name` - A string slice that holds the name of the file to be created or overwritten.
239/// * `content` - A string slice containing the content to write to the file.
240///
241/// # Returns
242///
243/// A `Result` which is:
244///
245/// * `Ok(())` if the operation is successful.
246/// * `Err` if an error occurs during directory creation or file writing.
247fn save_to_config_file(file_name: &str, content: &str) -> std::io::Result<()> {
248    let path = config_file(file_name);
249
250    if let Some(parent) = path.parent() {
251        fs::create_dir_all(parent)?;
252    }
253
254    let mut file = File::create(&path)?;
255    file.write_all(content.as_bytes())?;
256
257    Ok(())
258}
259
260/// Deletes the generated configuration file.
261///
262/// This function is used in the `--clean-config` flag, and it removes the default config file.
263/// It returns a HashMap where the key is the config file name and the value indicates
264/// whether the file was deleted.
265pub fn delete_config_files() -> HashMap<String, bool> {
266    let mut results = HashMap::new();
267
268    let file = "config.jsonc";
269    let result = delete_config_file(file).is_ok();
270    results.insert(file.to_string(), result);
271
272    results
273}
274
275/// Deletes the given configuration file, returning an error if the operation fails.
276///
277/// This function is used by `delete_config_files` to remove the generated configuration.
278/// It does not report an error if the file does not exist.
279fn delete_config_file(file_name: &str) -> std::io::Result<()> {
280    let path = config_file(file_name);
281
282    if path.exists() {
283        std::fs::remove_file(path)?;
284    }
285
286    Ok(())
287}
288
289/// Returns a `PathBuf` for the configuration file with the given `name`.
290///
291/// If `XDG_CONFIG_HOME` is set, the function will return a path in that directory.
292/// If `XDG_CONFIG_HOME` is not set, the function will return a path in the current directory.
293///
294/// The returned path will have the "leenfetch" directory as its parent, and the given `name` as its file name.
295fn config_file(name: &str) -> PathBuf {
296    config_dir()
297        .unwrap_or_else(|| PathBuf::from("."))
298        .join("leenfetch")
299        .join(name)
300}
301
302/// Ensures that the configuration file exists.
303///
304/// This function creates the `leenfetch` directory and the config file if they do not exist.
305/// It will not overwrite an existing config file.
306///
307/// Returns a `HashMap` where the key is the config file name and the value indicates
308/// whether the file was created.
309pub fn ensure_config_files_exist() -> HashMap<String, bool> {
310    let mut results = HashMap::new();
311
312    let filename = "config.jsonc";
313    let created = ensure_config_file_exists(filename, DEFAULT_CONFIG).unwrap_or(false);
314    results.insert(filename.to_string(), created);
315
316    results
317}
318
319/// Ensures that the configuration file with the given `file_name` exists.
320///
321/// If the file does not exist, the function will create it with the given `default_content`.
322/// If the file already exists, the function will return `false` without modifying it.
323///
324/// # Returns
325///
326/// A `Result` which is `Ok(true)` if the file was created, or `Ok(false)` if the file already existed.
327/// If an error occurs while attempting to create the file, the function will return `Err`.
328fn ensure_config_file_exists(file_name: &str, default_content: &str) -> std::io::Result<bool> {
329    let path = config_file(file_name);
330
331    if path.exists() {
332        return Ok(false); // Already exists
333    }
334
335    save_to_config_file(file_name, default_content)?;
336    Ok(true) // Created
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::test_utils::EnvLock;
343    use std::fs;
344    use std::time::{SystemTime, UNIX_EPOCH};
345
346    #[test]
347    fn load_effective_config_falls_back_to_defaults_for_invalid_custom_file() {
348        let unique = SystemTime::now()
349            .duration_since(UNIX_EPOCH)
350            .unwrap()
351            .as_nanos();
352        let path = std::env::temp_dir().join(format!("leenfetch_invalid_config_{unique}.jsonc"));
353        fs::write(&path, "{ invalid jsonc").unwrap();
354
355        let config = load_effective_config_at(path.to_str());
356        assert!(!config.layout.is_empty());
357        assert_eq!(
358            config.flags.ascii_distro,
359            default_config().flags.ascii_distro
360        );
361
362        fs::remove_file(path).unwrap();
363    }
364
365    #[test]
366    fn preset_configs_parse_successfully() {
367        for preset in [
368            ConfigPreset::Default,
369            ConfigPreset::Neofetch,
370            ConfigPreset::ClassicNeofetch,
371            ConfigPreset::Minimal,
372            ConfigPreset::Screenshot,
373            ConfigPreset::HardwareHeavy,
374            ConfigPreset::SystemAdmin,
375            ConfigPreset::CleanMono,
376            ConfigPreset::Tiny,
377        ] {
378            let config = load_preset_config(preset);
379            assert!(
380                !config.layout.is_empty(),
381                "{} had an empty layout",
382                preset_label(preset)
383            );
384        }
385    }
386
387    #[test]
388    fn write_config_preset_overwrites_existing_file() {
389        let unique = SystemTime::now()
390            .duration_since(UNIX_EPOCH)
391            .unwrap()
392            .as_nanos();
393        let temp_dir = std::env::temp_dir().join(format!("leenfetch_config_write_{unique}"));
394        fs::create_dir_all(&temp_dir).unwrap();
395
396        let env = EnvLock::acquire(&["XDG_CONFIG_HOME"]);
397        env.set_var("XDG_CONFIG_HOME", temp_dir.to_str().unwrap());
398
399        write_config_preset(ConfigPreset::Tiny).unwrap();
400
401        let written = fs::read_to_string(config_path()).unwrap();
402        assert!(written.contains("\"ascii_distro\": \"off\""));
403        assert!(written.contains("\"modules\""));
404
405        fs::remove_dir_all(temp_dir).unwrap();
406    }
407}