Skip to main content

tca_loader/
lib.rs

1//! XDG-compliant theme loader for Terminal Colors Architecture.
2//!
3//! Provides filesystem operations for discovering and loading TCA themes
4//! from XDG data directories.
5
6#![warn(missing_docs)]
7
8use anyhow::{Context, Result};
9use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
10use serde::{Deserialize, Serialize};
11use std::fs;
12use std::path::{Path, PathBuf};
13
14/// Configuration for TCA user preferences.
15#[derive(Default, Debug, Serialize, Deserialize)]
16pub struct TcaConfig {
17    /// The general default theme. Used if mode can't be detected or other options
18    /// aren't defined.
19    pub default_theme: Option<String>,
20    /// Default dark mode theme.
21    pub default_dark_theme: Option<String>,
22    /// Default light mode theme.
23    pub default_light_theme: Option<String>,
24}
25
26/// Returns the path to the TCA config file (`tca.toml` in the app config dir).
27fn config_file_path() -> Result<PathBuf> {
28    let strategy = choose_app_strategy(AppStrategyArgs {
29        top_level_domain: "org".to_string(),
30        author: "TCA".to_string(),
31        app_name: "tca".to_string(),
32    })?;
33    Ok(strategy.config_dir().join("tca.toml"))
34}
35
36impl TcaConfig {
37    /// Load the user's configuration preferences.
38    ///
39    /// Returns [`Default`] if the config file doesn't exist or cannot be parsed.
40    pub fn load() -> Self {
41        let Ok(path) = config_file_path() else {
42            return Self::default();
43        };
44        let Ok(content) = fs::read_to_string(path) else {
45            return Self::default();
46        };
47        toml::from_str(&content).unwrap_or_default()
48    }
49
50    /// Save the user's configuration preferences.
51    pub fn store(&self) {
52        let path = config_file_path().expect("Could not determine TCA config path.");
53        if let Some(parent) = path.parent() {
54            fs::create_dir_all(parent).expect("Could not create TCA config directory.");
55        }
56        let content = toml::to_string(self).expect("Could not serialize TCA config.");
57        fs::write(&path, content).expect("Could not save TCA config.");
58    }
59
60    /// Get the best default theme, based on user preference and current terminal
61    /// color mode.
62    pub fn mode_aware_theme(&self) -> Option<String> {
63        // Fallback order:
64        // Mode preference - if None or mode can't be determined then default
65        use terminal_colorsaurus::{theme_mode, QueryOptions, ThemeMode};
66        match theme_mode(QueryOptions::default()).ok() {
67            Some(ThemeMode::Dark) => self
68                .default_dark_theme
69                .clone()
70                .or(self.default_theme.clone()),
71            Some(ThemeMode::Light) => self
72                .default_light_theme
73                .clone()
74                .or(self.default_theme.clone()),
75            None => self.default_theme.clone(),
76        }
77    }
78}
79
80/// Get the themes directory path, creating it if it does not exist.
81///
82/// Returns `$XDG_DATA_HOME/tca-themes` (or platform equivalent).
83pub fn get_themes_dir() -> Result<PathBuf> {
84    let strategy = choose_app_strategy(AppStrategyArgs {
85        top_level_domain: "org".to_string(),
86        author: "TCA".to_string(),
87        app_name: "tca-themes".to_string(),
88    })
89    .unwrap();
90    let data_dir = strategy.data_dir();
91    fs::create_dir_all(&data_dir)?;
92
93    Ok(data_dir)
94}
95
96/// List all available theme files in the shared themes directory.
97///
98/// Returns paths to all `.toml` files in the themes directory.
99pub fn list_themes() -> Result<Vec<PathBuf>> {
100    let themes_dir = get_themes_dir()?;
101
102    let mut themes = Vec::new();
103
104    if let Ok(entries) = fs::read_dir(&themes_dir) {
105        for entry in entries.flatten() {
106            let path = entry.path();
107            if !path.is_file() {
108                continue;
109            }
110            if let Some(ext) = path.extension() {
111                if ext == "toml" {
112                    themes.push(path);
113                }
114            }
115        }
116    }
117
118    themes.sort();
119    Ok(themes)
120}
121
122/// Find a theme by name.
123///
124/// Converts name to kebab-case and searches for `<name>.toml` in the
125/// themes directory.
126/// Returns the full path if found.
127pub fn find_theme(name: &str) -> Result<PathBuf> {
128    let themes_dir = get_themes_dir()?;
129
130    let name = convert_case::ccase!(kebab, name);
131    // If no extension, also try with .toml appended
132    let candidate = if !name.ends_with(".toml") {
133        themes_dir.join(format!("{}.toml", name))
134    } else {
135        themes_dir.join(&name)
136    };
137    if candidate.exists() && candidate.is_file() {
138        return Ok(candidate);
139    }
140
141    Err(anyhow::anyhow!(
142        "Theme '{}' not found in {:?}.",
143        name,
144        themes_dir,
145    ))
146}
147
148/// List all theme names (without paths or extensions).
149pub fn list_theme_names() -> Result<Vec<String>> {
150    let themes = list_themes()?;
151
152    Ok(themes
153        .iter()
154        .filter_map(|p| p.file_stem().and_then(|s| s.to_str()).map(String::from))
155        .collect())
156}
157
158/// Load a theme file from one of these locations (searched in order):
159///
160/// 1. Exact path, if the argument resolves to an existing file
161/// 2. Shared themes directory (`$XDG_DATA_HOME/tca/themes/`)
162///
163/// Returns the file contents as a string.
164pub fn load_theme_file(path_or_name: &str) -> Result<String> {
165    let path = Path::new(path_or_name);
166
167    // 1. Try exact path (handles absolute paths and relative paths from cwd)
168    if path.exists() && path.is_file() {
169        return fs::read_to_string(path)
170            .with_context(|| format!("Failed to read theme file: {:?}", path));
171    }
172
173    // 2. Try shared themes directory
174    if let Ok(shared_path) = find_theme(path_or_name) {
175        return fs::read_to_string(&shared_path)
176            .with_context(|| format!("Failed to read theme file: {:?}", shared_path));
177    }
178
179    Err(anyhow::anyhow!(
180        "Theme '{}' not found. Searched:\n\
181         1. Exact path: {:?}\n\
182         2. Shared themes: {:?}\n",
183        path_or_name,
184        path,
185        get_themes_dir()?,
186    ))
187}
188
189/// Load all themes from a given directory as raw [`tca_types::Theme`] values.
190///
191/// Entries that cannot be read or parsed are skipped with a message to stderr.
192pub fn load_all_from_dir(dir: &str) -> Result<Vec<tca_types::Theme>> {
193    let mut items: Vec<tca_types::Theme> = vec![];
194    for entry in fs::read_dir(dir)? {
195        let path = match entry {
196            Err(e) => {
197                eprintln!("Could not read dir entry: {}", e);
198                continue;
199            }
200            Ok(e) => e.path(),
201        };
202        if path.is_file() & path.extension().is_some_and(|x| x == "toml") {
203            match fs::read_to_string(&path) {
204                Err(e) => {
205                    eprintln!("Could not read: {:?}.\nError: {}", path, e);
206                    continue;
207                }
208                Ok(theme_str) => match toml::from_str(&theme_str) {
209                    Err(e) => {
210                        eprintln!("Could not parse: {:?}.\nError: {}", path, e);
211                        continue;
212                    }
213                    Ok(item) => items.push(item),
214                },
215            }
216        }
217    }
218    Ok(items)
219}
220
221/// Load all locally installed themes from the shared theme directory.
222///
223/// Returns raw [`tca_types::Theme`] values. Entries that cannot be read or
224/// parsed are skipped with a message to stderr.
225pub fn load_all_from_theme_dir() -> Result<Vec<tca_types::Theme>> {
226    let dir = get_themes_dir()?;
227    let dir_str = dir
228        .to_str()
229        .context("Data directory path is not valid UTF-8")?;
230    load_all_from_dir(dir_str)
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn test_get_themes_dir() {
239        let dir = get_themes_dir().unwrap();
240        assert!(dir.exists());
241        assert!(dir.ends_with("tca-themes"));
242    }
243
244    #[test]
245    fn test_list_themes() {
246        let themes = list_themes().unwrap();
247        // Verify that all returned paths have toml extension
248        for theme_path in themes {
249            let ext = theme_path.extension().and_then(|s| s.to_str());
250            assert_eq!(ext, Some("toml"));
251        }
252    }
253
254    #[test]
255    fn test_list_theme_names() {
256        let names = list_theme_names().unwrap();
257        // Theme names should not have file extensions
258        for name in names {
259            assert!(!name.contains('.'));
260        }
261    }
262}