rush_sync_server/commands/theme/
mod.rs

1use crate::core::prelude::*;
2use std::collections::HashMap;
3
4pub mod command;
5pub use command::ThemeCommand;
6
7#[derive(Debug, Clone)]
8pub struct ThemeDefinition {
9    pub input_text: String,
10    pub input_bg: String,
11    pub output_text: String,
12    pub output_bg: String,
13    pub input_cursor_prefix: String,
14    pub input_cursor_color: String,
15    pub input_cursor: String,
16    pub output_cursor: String,
17    pub output_cursor_color: String,
18}
19
20#[derive(Debug)]
21pub struct ThemeSystem {
22    themes: HashMap<String, ThemeDefinition>,
23    current_name: String,
24    config_paths: Vec<std::path::PathBuf>,
25}
26
27impl ThemeSystem {
28    pub fn load() -> Result<Self> {
29        let config_paths = crate::setup::setup_toml::get_config_paths();
30        let themes = Self::load_themes_from_paths(&config_paths)?;
31        let current_name = Self::load_current_theme_name(&config_paths).unwrap_or_else(|| {
32            themes
33                .keys()
34                .next()
35                .cloned()
36                .unwrap_or_else(|| "default".to_string())
37        });
38
39        log::info!(
40            "āœ… {} Themes loaded: {}",
41            themes.len(),
42            themes.keys().cloned().collect::<Vec<_>>().join(", ")
43        );
44
45        Ok(Self {
46            themes,
47            current_name,
48            config_paths,
49        })
50    }
51
52    pub fn show_status(&self) -> String {
53        if self.themes.is_empty() {
54            return "āŒ No themes available! Add [theme.xyz] sections to rush.toml.".to_string();
55        }
56        format!(
57            "Current theme: {} (from TOML)\nAvailable: {}",
58            self.current_name.to_uppercase(),
59            self.themes.keys().cloned().collect::<Vec<_>>().join(", ")
60        )
61    }
62
63    pub fn change_theme(&mut self, theme_name: &str) -> Result<String> {
64        let theme_name_lower = theme_name.to_lowercase();
65
66        if !self.themes.contains_key(&theme_name_lower) {
67            return Ok(if self.themes.is_empty() {
68                "āŒ No themes available! Add [theme.xyz] sections to rush.toml.".to_string()
69            } else {
70                format!(
71                    "āŒ Theme '{}' not found. Available: {}",
72                    theme_name,
73                    self.themes.keys().cloned().collect::<Vec<_>>().join(", ")
74                )
75            });
76        }
77
78        self.current_name = theme_name_lower.clone();
79
80        // Log cursor details
81        if let Some(theme_def) = self.themes.get(&theme_name_lower) {
82            log::info!(
83                "šŸŽØ Theme '{}': input_cursor='{}' ({}), output_cursor='{}' ({}), prefix='{}'",
84                theme_name_lower.to_uppercase(),
85                theme_def.input_cursor,
86                theme_def.input_cursor_color,
87                theme_def.output_cursor,
88                theme_def.output_cursor_color,
89                theme_def.input_cursor_prefix
90            );
91        }
92
93        // Async save
94        let name_clone = theme_name_lower.clone();
95        let paths_clone = self.config_paths.clone();
96        tokio::spawn(async move {
97            if let Err(e) = Self::save_current_theme_to_config(&paths_clone, &name_clone).await {
98                log::error!("Failed to save theme: {}", e);
99            }
100        });
101
102        Ok(format!(
103            "__LIVE_THEME_UPDATE__{}__MESSAGE__šŸŽØ Theme changed to: {} ✨",
104            theme_name_lower,
105            theme_name_lower.to_uppercase()
106        ))
107    }
108
109    pub fn preview_theme(&self, theme_name: &str) -> Result<String> {
110        let theme_name_lower = theme_name.to_lowercase();
111
112        if let Some(theme_def) = self.themes.get(&theme_name_lower) {
113            Ok(format!("šŸŽØ Theme '{}' Preview:\nInput: {} on {}\nOutput: {} on {}\nCursor Prefix: '{}' in {}\nInput Cursor: {}\nOutput Cursor: {} in {}\n\nšŸ“ Source: [theme.{}] in rush.toml",
114                theme_name_lower.to_uppercase(), theme_def.input_text, theme_def.input_bg,
115                theme_def.output_text, theme_def.output_bg, theme_def.input_cursor_prefix,
116                theme_def.input_cursor_color, theme_def.input_cursor, theme_def.output_cursor,
117                theme_def.output_cursor_color, theme_name_lower))
118        } else {
119            Ok(format!(
120                "āŒ Theme '{}' not found. Available: {}",
121                theme_name,
122                self.themes.keys().cloned().collect::<Vec<_>>().join(", ")
123            ))
124        }
125    }
126
127    pub fn debug_theme_details(&self, theme_name: &str) -> String {
128        if let Some(theme_def) = self.themes.get(&theme_name.to_lowercase()) {
129            format!("šŸ” Theme '{}':\ninput_text: '{}'\ninput_bg: '{}'\noutput_text: '{}'\noutput_bg: '{}'\ninput_cursor_prefix: '{}'\ninput_cursor_color: '{}'\ninput_cursor: '{}'\noutput_cursor: '{}'\noutput_cursor_color: '{}'",
130                theme_name.to_uppercase(), theme_def.input_text, theme_def.input_bg,
131                theme_def.output_text, theme_def.output_bg, theme_def.input_cursor_prefix,
132                theme_def.input_cursor_color, theme_def.input_cursor, theme_def.output_cursor,
133                theme_def.output_cursor_color)
134        } else {
135            format!("āŒ Theme '{}' not found!", theme_name)
136        }
137    }
138
139    // Getters
140    pub fn theme_exists(&self, theme_name: &str) -> bool {
141        self.themes.contains_key(&theme_name.to_lowercase())
142    }
143    pub fn get_theme(&self, theme_name: &str) -> Option<&ThemeDefinition> {
144        self.themes.get(&theme_name.to_lowercase())
145    }
146    pub fn get_available_names(&self) -> Vec<String> {
147        let mut names: Vec<_> = self.themes.keys().cloned().collect();
148        names.sort();
149        names
150    }
151    pub fn get_current_name(&self) -> &str {
152        &self.current_name
153    }
154
155    fn load_themes_from_paths(
156        config_paths: &[std::path::PathBuf],
157    ) -> Result<HashMap<String, ThemeDefinition>> {
158        for path in config_paths {
159            if path.exists() {
160                if let Ok(content) = std::fs::read_to_string(path) {
161                    if let Ok(themes) = Self::parse_themes_from_toml(&content) {
162                        return Ok(themes);
163                    }
164                }
165            }
166        }
167        Ok(HashMap::new())
168    }
169
170    fn parse_themes_from_toml(content: &str) -> Result<HashMap<String, ThemeDefinition>> {
171        let mut themes = HashMap::new();
172        let mut current_theme: Option<String> = None;
173        let mut current_data = HashMap::new();
174
175        for line in content.lines() {
176            let trimmed = line.trim();
177            if trimmed.is_empty() || trimmed.starts_with('#') {
178                continue;
179            }
180
181            if let Some(theme_name) = trimmed
182                .strip_prefix("[theme.")
183                .and_then(|s| s.strip_suffix(']'))
184            {
185                Self::finalize_theme(&mut themes, current_theme.take(), &mut current_data);
186                current_theme = Some(theme_name.to_lowercase());
187            } else if trimmed.starts_with('[') && !trimmed.starts_with("[theme.") {
188                Self::finalize_theme(&mut themes, current_theme.take(), &mut current_data);
189            } else if current_theme.is_some() && trimmed.contains('=') {
190                if let Some((key, value)) = trimmed.split_once('=') {
191                    let clean_value = value.trim().trim_matches('"').trim_matches('\'');
192                    if !clean_value.is_empty() {
193                        current_data.insert(key.trim().to_string(), clean_value.to_string());
194                    }
195                }
196            }
197        }
198        Self::finalize_theme(&mut themes, current_theme, &mut current_data);
199        Ok(themes)
200    }
201
202    fn finalize_theme(
203        themes: &mut HashMap<String, ThemeDefinition>,
204        theme_name: Option<String>,
205        data: &mut HashMap<String, String>,
206    ) {
207        if let Some(name) = theme_name {
208            if let Some(theme_def) = Self::build_theme_from_data(data) {
209                themes.insert(name, theme_def);
210            }
211            data.clear();
212        }
213    }
214
215    fn build_theme_from_data(data: &HashMap<String, String>) -> Option<ThemeDefinition> {
216        Some(ThemeDefinition {
217            input_text: data.get("input_text")?.clone(),
218            input_bg: data.get("input_bg")?.clone(),
219            output_text: data.get("output_text")?.clone(),
220            output_bg: data.get("output_bg")?.clone(),
221            input_cursor_prefix: data
222                .get("input_cursor_prefix")
223                .or(data.get("prompt_text"))
224                .unwrap_or(&"/// ".to_string())
225                .clone(),
226            input_cursor_color: data
227                .get("input_cursor_color")
228                .or(data.get("prompt_color"))
229                .unwrap_or(&"LightBlue".to_string())
230                .clone(),
231            input_cursor: data
232                .get("input_cursor")
233                .or(data.get("prompt_cursor"))
234                .unwrap_or(&"PIPE".to_string())
235                .clone(),
236            output_cursor: data
237                .get("output_cursor")
238                .unwrap_or(&"PIPE".to_string())
239                .clone(),
240            output_cursor_color: data
241                .get("output_cursor_color")
242                .or(data.get("output_color"))
243                .unwrap_or(&"White".to_string())
244                .clone(),
245        })
246    }
247
248    fn load_current_theme_name(config_paths: &[std::path::PathBuf]) -> Option<String> {
249        for path in config_paths {
250            if path.exists() {
251                if let Ok(content) = std::fs::read_to_string(path) {
252                    if let Some(theme) = Self::extract_current_theme_from_toml(&content) {
253                        return Some(theme);
254                    }
255                }
256            }
257        }
258        None
259    }
260
261    fn extract_current_theme_from_toml(content: &str) -> Option<String> {
262        let mut in_general = false;
263        for line in content.lines() {
264            let trimmed = line.trim();
265            if trimmed == "[general]" {
266                in_general = true;
267            } else if trimmed.starts_with('[') {
268                in_general = false;
269            } else if in_general && trimmed.starts_with("current_theme") {
270                return trimmed
271                    .split('=')
272                    .nth(1)?
273                    .trim()
274                    .trim_matches('"')
275                    .trim_matches('\'')
276                    .to_string()
277                    .into();
278            }
279        }
280        None
281    }
282
283    async fn save_current_theme_to_config(
284        config_paths: &[std::path::PathBuf],
285        theme_name: &str,
286    ) -> Result<()> {
287        for path in config_paths {
288            if path.exists() {
289                let content = tokio::fs::read_to_string(path)
290                    .await
291                    .map_err(AppError::Io)?;
292                let updated = Self::update_current_theme_in_toml(&content, theme_name)?;
293                tokio::fs::write(path, updated)
294                    .await
295                    .map_err(AppError::Io)?;
296                return Ok(());
297            }
298        }
299        Err(AppError::Validation("No config file found".to_string()))
300    }
301
302    fn update_current_theme_in_toml(content: &str, theme_name: &str) -> Result<String> {
303        let mut lines: Vec<String> = content.lines().map(|l| l.to_string()).collect();
304        let mut in_general = false;
305        let mut updated = false;
306
307        for line in lines.iter_mut() {
308            let trimmed = line.trim();
309            if trimmed == "[general]" {
310                in_general = true;
311            } else if trimmed.starts_with('[') {
312                in_general = false;
313            } else if in_general && trimmed.starts_with("current_theme") {
314                *line = format!("current_theme = \"{}\"", theme_name);
315                updated = true;
316            }
317        }
318
319        if !updated {
320            if let Some(general_idx) = lines.iter().position(|line| line.trim() == "[general]") {
321                let insert_idx = lines
322                    .iter()
323                    .enumerate()
324                    .skip(general_idx + 1)
325                    .find(|(_, line)| line.trim().starts_with('['))
326                    .map(|(idx, _)| idx)
327                    .unwrap_or(lines.len());
328                lines.insert(insert_idx, format!("current_theme = \"{}\"", theme_name));
329            }
330        }
331        Ok(lines.join("\n"))
332    }
333}