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 directories::ProjectDirs;
10use std::fs;
11use std::path::{Path, PathBuf};
12
13/// Resolve the TCA data directory path without creating it.
14///
15/// Returns `$XDG_DATA_HOME/tca` on Linux/BSD, or the platform-equivalent on other OS.
16fn resolve_data_dir() -> Result<PathBuf> {
17    let project_dirs =
18        ProjectDirs::from("", "", "tca").context("Failed to determine project directories")?;
19    Ok(project_dirs.data_dir().to_path_buf())
20}
21
22/// Get the TCA data directory path, creating it if it does not exist.
23///
24/// Returns `$XDG_DATA_HOME/tca` on Linux/BSD, or the platform-equivalent on other OS.
25pub fn get_data_dir() -> Result<PathBuf> {
26    let data_dir = resolve_data_dir()?;
27    if !data_dir.exists() {
28        fs::create_dir_all(&data_dir)
29            .with_context(|| format!("Failed to create data directory: {:?}", data_dir))?;
30    }
31    Ok(data_dir)
32}
33
34/// Get the themes directory path, creating it if it does not exist.
35///
36/// Returns `$XDG_DATA_HOME/tca/themes` (or platform equivalent).
37pub fn get_themes_dir() -> Result<PathBuf> {
38    let themes_dir = resolve_data_dir()?.join("themes");
39    if !themes_dir.exists() {
40        fs::create_dir_all(&themes_dir)
41            .with_context(|| format!("Failed to create themes directory: {:?}", themes_dir))?;
42    }
43    Ok(themes_dir)
44}
45
46/// List all available theme files in the shared themes directory.
47///
48/// Returns paths to all `.toml` files in the themes directory.
49pub fn list_themes() -> Result<Vec<PathBuf>> {
50    let themes_dir = get_themes_dir()?;
51
52    let mut themes = Vec::new();
53
54    if let Ok(entries) = fs::read_dir(&themes_dir) {
55        for entry in entries.flatten() {
56            let path = entry.path();
57            if !path.is_file() {
58                continue;
59            }
60            if let Some(ext) = path.extension() {
61                if ext == "toml" {
62                    themes.push(path);
63                }
64            }
65        }
66    }
67
68    themes.sort();
69    Ok(themes)
70}
71
72/// Find a theme by name (with or without `.toml` extension).
73///
74/// Searches for `<name>.toml` in the themes directory.
75/// Returns the full path if found.
76pub fn find_theme(name: &str) -> Result<PathBuf> {
77    let themes_dir = get_themes_dir()?;
78
79    // If no extension, also try with .toml appended
80    let candidate = if !name.ends_with(".toml") {
81        themes_dir.join(format!("{}.toml", name))
82    } else {
83        themes_dir.join(name)
84    };
85
86    if candidate.exists() && candidate.is_file() {
87        return Ok(candidate);
88    }
89
90    Err(anyhow::anyhow!(
91        "Theme '{}' not found in {:?}. Available themes: {:?}",
92        name,
93        themes_dir,
94        list_theme_names()?
95    ))
96}
97
98/// List all theme names (without paths or extensions).
99pub fn list_theme_names() -> Result<Vec<String>> {
100    let themes = list_themes()?;
101
102    Ok(themes
103        .iter()
104        .filter_map(|p| p.file_stem().and_then(|s| s.to_str()).map(String::from))
105        .collect())
106}
107
108/// Load a theme file from one of these locations (searched in order):
109///
110/// 1. Exact path, if the argument resolves to an existing file
111/// 2. Shared themes directory (`$XDG_DATA_HOME/tca/themes/`)
112///
113/// Returns the file contents as a string.
114pub fn load_theme_file(path_or_name: &str) -> Result<String> {
115    let path = Path::new(path_or_name);
116
117    // 1. Try exact path (handles absolute paths and relative paths from cwd)
118    if path.exists() && path.is_file() {
119        return fs::read_to_string(path)
120            .with_context(|| format!("Failed to read theme file: {:?}", path));
121    }
122
123    // 2. Try shared themes directory
124    if let Ok(shared_path) = find_theme(path_or_name) {
125        return fs::read_to_string(&shared_path)
126            .with_context(|| format!("Failed to read theme file: {:?}", shared_path));
127    }
128
129    Err(anyhow::anyhow!(
130        "Theme '{}' not found. Searched:\n\
131         1. Exact path: {:?}\n\
132         2. Shared themes: {:?}\n\
133         Available shared themes: {:?}",
134        path_or_name,
135        path,
136        get_themes_dir()?,
137        list_theme_names()?
138    ))
139}
140
141/// Load all themes from a given directory as raw [`tca_types::Theme`] values.
142///
143/// Entries that cannot be read or parsed are skipped with a message to stderr.
144pub fn load_all_from_dir(dir: &str) -> Result<Vec<tca_types::Theme>> {
145    let mut items: Vec<tca_types::Theme> = vec![];
146    for entry in fs::read_dir(dir)? {
147        let path = match entry {
148            Err(e) => {
149                eprintln!("Could not read dir entry: {}", e);
150                continue;
151            }
152            Ok(e) => e.path(),
153        };
154        if path.is_file() & path.extension().is_some_and(|x| x == "toml") {
155            match fs::read_to_string(&path) {
156                Err(e) => {
157                    eprintln!("Could not read: {:?}.\nError: {}", path, e);
158                    continue;
159                }
160                Ok(theme_str) => match toml::from_str(&theme_str) {
161                    Err(e) => {
162                        eprintln!("Could not parse: {:?}.\nError: {}", path, e);
163                        continue;
164                    }
165                    Ok(item) => items.push(item),
166                },
167            }
168        }
169    }
170    Ok(items)
171}
172
173/// Load all locally installed themes from the shared theme directory.
174///
175/// Returns raw [`tca_types::Theme`] values. Entries that cannot be read or
176/// parsed are skipped with a message to stderr.
177pub fn load_all_from_theme_dir() -> Result<Vec<tca_types::Theme>> {
178    let dir = get_themes_dir()?;
179    let dir_str = dir
180        .to_str()
181        .context("Data directory path is not valid UTF-8")?;
182    load_all_from_dir(dir_str)
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn test_get_data_dir() {
191        let dir = get_data_dir().unwrap();
192        assert!(dir.exists());
193        assert!(dir.to_string_lossy().contains("tca"));
194    }
195
196    #[test]
197    fn test_get_themes_dir() {
198        let dir = get_themes_dir().unwrap();
199        assert!(dir.exists());
200        assert!(dir.ends_with("themes"));
201    }
202
203    #[test]
204    fn test_list_themes() {
205        let themes = list_themes().unwrap();
206        // Verify that all returned paths have toml extension
207        for theme_path in themes {
208            let ext = theme_path.extension().and_then(|s| s.to_str());
209            assert_eq!(ext, Some("toml"));
210        }
211    }
212
213    #[test]
214    fn test_list_theme_names() {
215        let names = list_theme_names().unwrap();
216        // Theme names should not have file extensions
217        for name in names {
218            assert!(!name.contains('.'));
219        }
220    }
221}