Skip to main content

opi_coding_agent/
theme_discovery.rs

1//! Theme progressive discovery, registry, and loading.
2//!
3//! Provides the discovery and registry system for themes that are progressively
4//! loaded from project, user, explicit, and package resources. Theme metadata
5//! (name, description) is available without parsing all color tokens; the full
6//! [`Theme`] is constructed on demand when needed.
7//!
8//! # Theme File Format
9//!
10//! Each theme is a directory containing a `theme.toml` file:
11//!
12//! ```toml
13//! name = "my-theme"
14//! description = "A warm theme for late-night coding."
15//!
16//! [colors]
17//! role_user = "Green"
18//! role_assistant = "#66d9ef"
19//! status_bg = "#1a1a2e"
20//! ```
21//!
22//! Colors may be specified as named colors (`"Red"`, `"DarkGray"`, etc.) or
23//! hex RGB (`"#rrggbb"`). Unspecified tokens inherit from the default theme.
24//!
25//! # Discovery Precedence
26//!
27//! Themes use the same precedence-based discovery as extensions and skills
28//! (see [`crate::resource`]). Higher precedence values override lower ones
29//! when theme names collide.
30//!
31//! # Unstable
32//!
33//! This module is part of the **unstable 0.x extension API**. Breaking changes
34//! may occur between minor versions without a major version bump.
35
36use std::collections::HashMap;
37use std::path::{Path, PathBuf};
38
39use opi_tui::theme::{Theme, is_valid_token, parse_color};
40use ratatui::style::Color;
41use serde::Deserialize;
42
43// ---------------------------------------------------------------------------
44// Error types
45// ---------------------------------------------------------------------------
46
47/// Errors from theme discovery, manifest parsing, and loading.
48#[derive(Debug, thiserror::Error)]
49pub enum ThemeDiscoveryError {
50    /// The theme.toml file could not be parsed as valid TOML.
51    #[error("invalid theme manifest at {path}: {reason}")]
52    InvalidManifest { path: PathBuf, reason: String },
53    /// A required field is missing or empty in the manifest.
54    #[error("missing required field '{field}' in theme at {path}")]
55    MissingField { field: String, path: PathBuf },
56    /// Two themes in the same precedence layer use the same name.
57    #[error("duplicate theme name '{name}' in discovery layer at {path}")]
58    DuplicateName { name: String, path: PathBuf },
59    /// The theme name is invalid (bad characters or too long).
60    #[error("invalid theme name in {path}: {reason}")]
61    InvalidName { path: PathBuf, reason: String },
62    /// The description is invalid (too long).
63    #[error("invalid description in theme at {path}: {reason}")]
64    InvalidDescription { path: PathBuf, reason: String },
65    /// A color token value is not a valid color.
66    #[error("invalid color for token '{token}' in theme at {path}: {reason}")]
67    InvalidColor {
68        token: String,
69        path: PathBuf,
70        reason: String,
71    },
72    /// A color token name is not recognized.
73    #[error("unknown color token '{token}' in theme at {path}")]
74    UnknownToken { token: String, path: PathBuf },
75    /// An I/O error occurred during discovery or loading.
76    #[error("I/O error discovering themes: {0}")]
77    Io(#[from] std::io::Error),
78}
79
80// ---------------------------------------------------------------------------
81// Constants
82// ---------------------------------------------------------------------------
83
84/// Maximum allowed length for a theme name.
85const MAX_NAME_LEN: usize = 64;
86
87/// Maximum allowed length for a theme description.
88const MAX_DESCRIPTION_LEN: usize = 1024;
89
90// ---------------------------------------------------------------------------
91// Manifest types
92// ---------------------------------------------------------------------------
93
94/// Parsed theme manifest from `theme.toml`.
95#[derive(Debug, Clone, PartialEq)]
96pub struct ThemeManifest {
97    /// Theme name. Required, non-empty. Lowercase ASCII letters, digits,
98    /// and hyphens. Maximum 64 characters.
99    pub name: String,
100    /// Human-readable description. Required, non-empty. Maximum 1024
101    /// characters.
102    pub description: String,
103}
104
105/// Top-level TOML structure for theme files.
106#[derive(Debug, Clone, Deserialize)]
107struct TomlThemeFile {
108    name: Option<String>,
109    description: Option<String>,
110    colors: Option<HashMap<String, String>>,
111}
112
113impl ThemeManifest {
114    /// Parse a manifest from TOML content, validating required fields.
115    ///
116    /// Only validates metadata (name, description); color tokens are not
117    /// parsed at this stage (progressive disclosure).
118    pub fn from_toml(content: &str, path: &Path) -> Result<Self, ThemeDiscoveryError> {
119        let file: TomlThemeFile =
120            toml::from_str(content).map_err(|e| ThemeDiscoveryError::InvalidManifest {
121                path: path.to_path_buf(),
122                reason: e.to_string(),
123            })?;
124
125        let name = file.name.filter(|n| !n.trim().is_empty()).ok_or_else(|| {
126            ThemeDiscoveryError::MissingField {
127                field: "name".into(),
128                path: path.to_path_buf(),
129            }
130        })?;
131
132        validate_theme_name(&name, path)?;
133
134        let description = file
135            .description
136            .filter(|d| !d.trim().is_empty())
137            .ok_or_else(|| ThemeDiscoveryError::MissingField {
138                field: "description".into(),
139                path: path.to_path_buf(),
140            })?;
141
142        validate_description(&description, path)?;
143
144        Ok(Self { name, description })
145    }
146}
147
148// ---------------------------------------------------------------------------
149// Validation helpers
150// ---------------------------------------------------------------------------
151
152/// Validate that a theme name contains only allowed characters and is within
153/// length bounds.
154fn validate_theme_name(name: &str, path: &Path) -> Result<(), ThemeDiscoveryError> {
155    if name.len() > MAX_NAME_LEN {
156        return Err(ThemeDiscoveryError::InvalidName {
157            path: path.to_path_buf(),
158            reason: format!(
159                "name exceeds maximum length of {MAX_NAME_LEN} characters ({} found)",
160                name.len()
161            ),
162        });
163    }
164
165    for ch in name.chars() {
166        let valid = ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-';
167        if !valid {
168            return Err(ThemeDiscoveryError::InvalidName {
169                path: path.to_path_buf(),
170                reason: format!(
171                    "name contains invalid character '{ch}': \
172                     only lowercase a-z, 0-9, and hyphens are allowed"
173                ),
174            });
175        }
176    }
177
178    Ok(())
179}
180
181/// Validate that a description is within length bounds.
182fn validate_description(desc: &str, path: &Path) -> Result<(), ThemeDiscoveryError> {
183    if desc.len() > MAX_DESCRIPTION_LEN {
184        return Err(ThemeDiscoveryError::InvalidDescription {
185            path: path.to_path_buf(),
186            reason: format!(
187                "description exceeds maximum length of {MAX_DESCRIPTION_LEN} characters \
188                 ({} found)",
189                desc.len()
190            ),
191        });
192    }
193    Ok(())
194}
195
196// ---------------------------------------------------------------------------
197// Discovery types
198// ---------------------------------------------------------------------------
199
200/// A discovered theme resource with its manifest, filesystem path, and layer
201/// precedence.
202///
203/// The manifest metadata is available immediately. The full [`Theme`] can be
204/// constructed on demand via [`load_theme`](ThemeResource::load_theme).
205#[derive(Debug, Clone)]
206pub struct ThemeResource {
207    /// The parsed theme manifest (metadata only).
208    pub manifest: ThemeManifest,
209    /// Absolute path to the theme directory.
210    pub path: PathBuf,
211    /// Path to the `theme.toml` file for on-demand color loading.
212    pub theme_toml_path: PathBuf,
213    /// Precedence value of the discovery layer that produced this resource.
214    pub layer_precedence: u32,
215}
216
217impl ThemeResource {
218    /// Load the full theme from the TOML file on demand.
219    ///
220    /// Reads the theme.toml, parses all color tokens, validates against the
221    /// theme token schema, and constructs a [`Theme`]. Missing tokens inherit
222    /// from the default theme.
223    pub fn load_theme(&self) -> Result<Theme, ThemeDiscoveryError> {
224        let content = std::fs::read_to_string(&self.theme_toml_path)?;
225        let file: TomlThemeFile =
226            toml::from_str(&content).map_err(|e| ThemeDiscoveryError::InvalidManifest {
227                path: self.theme_toml_path.clone(),
228                reason: e.to_string(),
229            })?;
230
231        let mut colors: HashMap<String, Color> = HashMap::new();
232        if let Some(raw_colors) = file.colors {
233            for (token, value) in &raw_colors {
234                if !is_valid_token(token) {
235                    return Err(ThemeDiscoveryError::UnknownToken {
236                        token: token.clone(),
237                        path: self.theme_toml_path.clone(),
238                    });
239                }
240
241                let color = parse_color(value).map_err(|e| ThemeDiscoveryError::InvalidColor {
242                    token: token.clone(),
243                    path: self.theme_toml_path.clone(),
244                    reason: e.to_string(),
245                })?;
246
247                colors.insert(token.clone(), color);
248            }
249        }
250
251        Theme::from_color_map(self.manifest.name.clone(), &colors).map_err(|e| {
252            ThemeDiscoveryError::InvalidColor {
253                token: String::new(),
254                path: self.theme_toml_path.clone(),
255                reason: e.to_string(),
256            }
257        })
258    }
259}
260
261// ---------------------------------------------------------------------------
262// Discovery
263// ---------------------------------------------------------------------------
264
265/// Discover themes across multiple layers with precedence-based
266/// deduplication.
267///
268/// Each layer's scan directory is enumerated for subdirectories containing
269/// `theme.toml` files. When multiple layers produce themes with the same
270/// name, the one with the highest `precedence` value is kept. Duplicate names
271/// within the same precedence layer are reported as an error.
272///
273/// Returns the deduplicated list of discovered theme resources, sorted by
274/// name. Missing scan directories are silently skipped.
275pub fn discover_themes(
276    layers: &[crate::resource::DiscoveryLayer],
277) -> Result<Vec<ThemeResource>, ThemeDiscoveryError> {
278    let mut seen: HashMap<String, ThemeResource> = HashMap::new();
279
280    for layer in layers {
281        let scan_dir = layer.scan_dir();
282        if !scan_dir.is_dir() {
283            continue;
284        }
285
286        if scan_dir.join("theme.toml").exists() {
287            discover_theme_dir(&scan_dir, layer, &mut seen)?;
288            continue;
289        }
290
291        let entries = match std::fs::read_dir(&scan_dir) {
292            Ok(entries) => entries,
293            Err(e) => return Err(ThemeDiscoveryError::Io(e)),
294        };
295
296        for entry in entries {
297            let entry = entry?;
298            let path = entry.path();
299
300            if !path.is_dir() {
301                continue;
302            }
303
304            let theme_toml = path.join("theme.toml");
305            if !theme_toml.exists() {
306                continue;
307            }
308
309            discover_theme_dir(&path, layer, &mut seen)?;
310        }
311    }
312
313    let mut resources: Vec<ThemeResource> = seen.into_values().collect();
314    resources.sort_by(|a, b| a.manifest.name.cmp(&b.manifest.name));
315    Ok(resources)
316}
317
318fn discover_theme_dir(
319    path: &Path,
320    layer: &crate::resource::DiscoveryLayer,
321    seen: &mut HashMap<String, ThemeResource>,
322) -> Result<(), ThemeDiscoveryError> {
323    let theme_toml = path.join("theme.toml");
324    let content = std::fs::read_to_string(&theme_toml)?;
325    let manifest = ThemeManifest::from_toml(&content, &theme_toml)?;
326
327    let canonical = path.canonicalize()?;
328
329    match seen.get(&manifest.name) {
330        Some(existing) if layer.precedence == existing.layer_precedence => {
331            return Err(ThemeDiscoveryError::DuplicateName {
332                name: manifest.name,
333                path: canonical,
334            });
335        }
336        Some(existing) if layer.precedence < existing.layer_precedence => return Ok(()),
337        Some(_) | None => {
338            seen.insert(
339                manifest.name.clone(),
340                ThemeResource {
341                    manifest,
342                    path: canonical,
343                    theme_toml_path: theme_toml,
344                    layer_precedence: layer.precedence,
345                },
346            );
347        }
348    }
349
350    Ok(())
351}
352
353// ---------------------------------------------------------------------------
354// Registry
355// ---------------------------------------------------------------------------
356
357/// A registry of discovered themes supporting progressive disclosure and
358/// active theme resolution.
359pub struct ThemeRegistry {
360    resources: Vec<ThemeResource>,
361}
362
363impl ThemeRegistry {
364    /// Build a registry from discovered theme resources.
365    pub fn from_resources(resources: Vec<ThemeResource>) -> Self {
366        Self { resources }
367    }
368
369    /// Return sorted list of all theme names.
370    pub fn names(&self) -> Vec<&str> {
371        self.resources
372            .iter()
373            .map(|r| r.manifest.name.as_str())
374            .collect()
375    }
376
377    /// Look up a theme by name, returning its resource (metadata only).
378    pub fn get(&self, name: &str) -> Option<&ThemeResource> {
379        self.resources.iter().find(|r| r.manifest.name == name)
380    }
381
382    /// Load the full theme by name.
383    ///
384    /// Returns `None` if the theme is not found, or `Some(Err(...))` if the
385    /// theme file cannot be loaded or parsed.
386    pub fn load_theme(&self, name: &str) -> Option<Result<Theme, ThemeDiscoveryError>> {
387        self.get(name).map(|r| r.load_theme())
388    }
389
390    /// Resolve a theme by name, checking discovered themes first, then
391    /// built-in themes ("default", "monokai"), then falling back to default.
392    pub fn resolve_theme(&self, name: &str) -> Result<Theme, ThemeDiscoveryError> {
393        // Check discovered themes first
394        if let Some(result) = self.load_theme(name) {
395            return result;
396        }
397
398        // Fall back to built-in themes
399        Ok(opi_tui::theme::resolve_theme(name))
400    }
401
402    /// Format all theme metadata as a string suitable for inclusion in a
403    /// system prompt or command listing.
404    pub fn format_for_prompt(&self) -> String {
405        if self.resources.is_empty() {
406            return String::new();
407        }
408
409        let parts: Vec<String> = self
410            .resources
411            .iter()
412            .map(|r| format!("- {}: {}", r.manifest.name, r.manifest.description))
413            .collect();
414        parts.join("\n")
415    }
416}