tms/
configs.rs

1use clap::ValueEnum;
2use error_stack::ResultExt;
3use serde_derive::{Deserialize, Serialize};
4use std::{collections::HashMap, env, fmt::Display, fs::canonicalize, io::Write, path::PathBuf};
5
6use ratatui::style::{Color, Style, Stylize};
7
8use crate::{error::Suggestion, keymap::Keymap};
9
10type Result<T> = error_stack::Result<T, ConfigError>;
11
12#[derive(Debug)]
13pub enum ConfigError {
14    NoDefaultSearchPath,
15    LoadError,
16    TomlError,
17    FileWriteError,
18    IoError,
19}
20
21impl std::error::Error for ConfigError {}
22
23impl Display for ConfigError {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        match self {
26            Self::NoDefaultSearchPath => write!(f, "No default search path was found"),
27            Self::TomlError => write!(f, "Could not serialize config to TOML"),
28            Self::FileWriteError => write!(f, "Could not write to config file"),
29            Self::LoadError => write!(f, "Could not load configuration"),
30            Self::IoError => write!(f, "IO error"),
31        }
32    }
33}
34
35#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
36pub struct Config {
37    pub default_session: Option<String>,
38    pub display_full_path: Option<bool>,
39    pub search_submodules: Option<bool>,
40    pub recursive_submodules: Option<bool>,
41    pub switch_filter_unknown: Option<bool>,
42    pub session_sort_order: Option<SessionSortOrderConfig>,
43    pub excluded_dirs: Option<Vec<String>>,
44    pub search_paths: Option<Vec<String>>, // old format, deprecated
45    pub search_dirs: Option<Vec<SearchDirectory>>,
46    pub sessions: Option<Vec<Session>>,
47    pub picker_colors: Option<PickerColorConfig>,
48    pub shortcuts: Option<Keymap>,
49    pub bookmarks: Option<Vec<String>>,
50    pub session_configs: Option<HashMap<String, SessionConfig>>,
51}
52
53impl Config {
54    pub(crate) fn new() -> Result<Self> {
55        let config_builder = match env::var("TMS_CONFIG_FILE") {
56            Ok(path) => {
57                config::Config::builder().add_source(config::File::with_name(&path).required(false))
58            }
59            Err(e) => match e {
60                env::VarError::NotPresent => {
61                    let mut builder = config::Config::builder();
62                    let mut config_found = false; // Stores whether a valid config file was found
63                    if let Some(home_path) = dirs::home_dir() {
64                        config_found = true;
65                        let path = home_path.as_path().join(".config/tms/config.toml");
66                        builder = builder.add_source(config::File::from(path).required(false));
67                    }
68                    if let Some(config_path) = dirs::config_dir() {
69                        config_found = true;
70                        let path = config_path.as_path().join("tms/config.toml");
71                        builder = builder.add_source(config::File::from(path).required(false));
72                    }
73                    if !config_found {
74                        return Err(ConfigError::LoadError)
75                            .attach_printable("Could not find a valid location for config file (both home and config dirs cannot be found)")
76                            .attach(Suggestion("Try specifying a config file with the TMS_CONFIG_FILE environment variable."));
77                    }
78                    builder
79                }
80                env::VarError::NotUnicode(_) => {
81                    return Err(ConfigError::LoadError).attach_printable(
82                        "Invalid non-unicode value for TMS_CONFIG_FILE env variable",
83                    );
84                }
85            },
86        };
87        let config = config_builder
88            .build()
89            .change_context(ConfigError::LoadError)
90            .attach_printable("Could not parse configuration")?;
91        config
92            .try_deserialize()
93            .change_context(ConfigError::LoadError)
94            .attach_printable("Could not deserialize configuration")
95    }
96
97    pub(crate) fn save(&self) -> Result<()> {
98        let toml_pretty = toml::to_string_pretty(self)
99            .change_context(ConfigError::TomlError)?
100            .into_bytes();
101        // The TMS_CONFIG_FILE envvar should be set, either by the user or when the config is
102        // loaded. However, there is a possibility it becomes unset between loading and saving
103        // the config. In this case, it will fall back to the platform-specific config folder, and
104        // if that can't be found then it's good old ~/.config
105        let path = match env::var("TMS_CONFIG_FILE") {
106            Ok(path) => PathBuf::from(path),
107            Err(_) => {
108                if let Some(config_path) = dirs::config_dir() {
109                    config_path.as_path().join("tms/config.toml")
110                } else if let Some(home_path) = dirs::home_dir() {
111                    home_path.as_path().join(".config/tms/config.toml")
112                } else {
113                    return Err(ConfigError::LoadError)
114                        .attach_printable("Could not find a valid location to write config file (both home and config dirs cannot be found)")
115                        .attach(Suggestion("Try specifying a config file with the TMS_CONFIG_FILE environment variable."));
116                }
117            }
118        };
119        let parent = path
120            .parent()
121            .ok_or(ConfigError::FileWriteError)
122            .attach_printable(format!(
123                "Unable to determine parent directory of specified tms config file: {}",
124                path.to_str()
125                    .unwrap_or("(path could not be converted to string)")
126            ))?;
127        std::fs::create_dir_all(parent)
128            .change_context(ConfigError::FileWriteError)
129            .attach_printable("Unable to create tms config folder")?;
130        let mut file = std::fs::File::create(path).change_context(ConfigError::FileWriteError)?;
131        file.write_all(&toml_pretty)
132            .change_context(ConfigError::FileWriteError)?;
133        Ok(())
134    }
135
136    pub fn search_dirs(&self) -> Result<Vec<SearchDirectory>> {
137        let mut search_dirs = if let Some(search_dirs) = self.search_dirs.as_ref() {
138            search_dirs
139                .iter()
140                .map(|search_dir| {
141                    let expanded_path = shellexpand::full(&search_dir.path.to_string_lossy())
142                        .change_context(ConfigError::IoError)?
143                        .to_string();
144
145                    let path = canonicalize(expanded_path).change_context(ConfigError::IoError)?;
146
147                    Ok(SearchDirectory::new(path, search_dir.depth))
148                })
149                .collect::<Result<_>>()
150        } else {
151            Ok(Vec::new())
152        }?;
153
154        // merge old search paths with new search directories
155        if let Some(search_paths) = self.search_paths.as_ref() {
156            if !search_paths.is_empty() {
157                search_dirs.extend(search_paths.iter().map(|path| {
158                    SearchDirectory::new(
159                        canonicalize(
160                            shellexpand::full(&path)
161                                .change_context(ConfigError::IoError)
162                                .unwrap()
163                                .to_string(),
164                        )
165                        .change_context(ConfigError::IoError)
166                        .unwrap(),
167                        10,
168                    )
169                }));
170            }
171        }
172
173        if search_dirs.is_empty() {
174            return Err(ConfigError::NoDefaultSearchPath)
175            .attach_printable(
176                "You must configure at least one default search path with the `config` subcommand. E.g `tms config` ",
177            );
178        }
179
180        Ok(search_dirs)
181    }
182
183    pub fn add_bookmark(&mut self, path: String) {
184        let bookmarks = &mut self.bookmarks;
185        match bookmarks {
186            Some(ref mut bookmarks) => {
187                if !bookmarks.contains(&path) {
188                    bookmarks.push(path);
189                }
190            }
191            None => {
192                self.bookmarks = Some(vec![path]);
193            }
194        }
195    }
196
197    pub fn delete_bookmark(&mut self, path: String) {
198        if let Some(ref mut bookmarks) = self.bookmarks {
199            if let Some(idx) = bookmarks.iter().position(|bookmark| *bookmark == path) {
200                bookmarks.remove(idx);
201            }
202        }
203    }
204
205    pub fn bookmark_paths(&self) -> Vec<PathBuf> {
206        if let Some(bookmarks) = &self.bookmarks {
207            bookmarks
208                .iter()
209                .filter_map(|b| {
210                    if let Ok(expanded) = shellexpand::full(b) {
211                        if let Ok(path) = PathBuf::from(expanded.to_string()).canonicalize() {
212                            Some(path)
213                        } else {
214                            None
215                        }
216                    } else {
217                        None
218                    }
219                })
220                .collect()
221        } else {
222            Vec::new()
223        }
224    }
225}
226
227#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
228pub struct SearchDirectory {
229    pub path: PathBuf,
230    pub depth: usize,
231}
232
233impl SearchDirectory {
234    pub fn new(path: PathBuf, depth: usize) -> Self {
235        SearchDirectory { path, depth }
236    }
237}
238
239#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
240pub struct Session {
241    pub name: Option<String>,
242    pub path: Option<String>,
243    pub windows: Option<Vec<Window>>,
244}
245
246#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
247pub struct Window {
248    pub name: Option<String>,
249    pub path: Option<String>,
250    pub panes: Option<Vec<Pane>>,
251    pub command: Option<String>,
252}
253
254#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
255pub struct Pane {}
256
257#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
258pub struct PickerColorConfig {
259    pub highlight_color: Option<Color>,
260    pub highlight_text_color: Option<Color>,
261    pub border_color: Option<Color>,
262    pub info_color: Option<Color>,
263    pub prompt_color: Option<Color>,
264}
265
266const HIGHLIGHT_COLOR_DEFAULT: Color = Color::LightBlue;
267const HIGHLIGHT_TEXT_COLOR_DEFAULT: Color = Color::Black;
268const BORDER_COLOR_DEFAULT: Color = Color::DarkGray;
269const INFO_COLOR_DEFAULT: Color = Color::LightYellow;
270const PROMPT_COLOR_DEFAULT: Color = Color::LightGreen;
271
272impl PickerColorConfig {
273    pub fn default_colors() -> Self {
274        PickerColorConfig {
275            highlight_color: Some(HIGHLIGHT_COLOR_DEFAULT),
276            highlight_text_color: Some(HIGHLIGHT_TEXT_COLOR_DEFAULT),
277            border_color: Some(BORDER_COLOR_DEFAULT),
278            info_color: Some(INFO_COLOR_DEFAULT),
279            prompt_color: Some(PROMPT_COLOR_DEFAULT),
280        }
281    }
282
283    pub fn highlight_style(&self) -> Style {
284        let mut style = Style::default()
285            .bg(HIGHLIGHT_COLOR_DEFAULT)
286            .fg(HIGHLIGHT_TEXT_COLOR_DEFAULT)
287            .bold();
288
289        if let Some(color) = self.highlight_color {
290            style = style.bg(color);
291        }
292
293        if let Some(color) = self.highlight_text_color {
294            style = style.fg(color);
295        }
296
297        style
298    }
299
300    pub fn border_color(&self) -> Color {
301        if let Some(color) = self.border_color {
302            color
303        } else {
304            BORDER_COLOR_DEFAULT
305        }
306    }
307
308    pub fn info_color(&self) -> Color {
309        if let Some(color) = self.info_color {
310            color
311        } else {
312            INFO_COLOR_DEFAULT
313        }
314    }
315
316    pub fn prompt_color(&self) -> Color {
317        if let Some(color) = self.prompt_color {
318            color
319        } else {
320            PROMPT_COLOR_DEFAULT
321        }
322    }
323}
324
325#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
326pub enum SessionSortOrderConfig {
327    Alphabetical,
328    LastAttached,
329}
330
331impl ValueEnum for SessionSortOrderConfig {
332    fn value_variants<'a>() -> &'a [Self] {
333        &[Self::Alphabetical, Self::LastAttached]
334    }
335
336    fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
337        match self {
338            SessionSortOrderConfig::Alphabetical => {
339                Some(clap::builder::PossibleValue::new("Alphabetical"))
340            }
341            SessionSortOrderConfig::LastAttached => {
342                Some(clap::builder::PossibleValue::new("LastAttached"))
343            }
344        }
345    }
346}
347
348#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
349pub struct SessionConfig {
350    pub create_script: Option<PathBuf>,
351}