Skip to main content

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, picker::InputPosition};
9
10type Result<T> = error_stack::Result<T, ConfigError>;
11
12#[derive(Debug)]
13pub enum ConfigError {
14    NoDefaultSearchPath,
15    NoValidSearchPath,
16    LoadError,
17    TomlError,
18    FileWriteError,
19    IoError,
20}
21
22impl std::error::Error for ConfigError {}
23
24impl Display for ConfigError {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        match self {
27            Self::NoDefaultSearchPath => write!(f, "No default search path was found"),
28            Self::NoValidSearchPath => write!(f, "No valid search path was found"),
29            Self::TomlError => write!(f, "Could not serialize config to TOML"),
30            Self::FileWriteError => write!(f, "Could not write to config file"),
31            Self::LoadError => write!(f, "Could not load configuration"),
32            Self::IoError => write!(f, "IO error"),
33        }
34    }
35}
36
37#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
38pub struct Config {
39    pub default_session: Option<String>,
40    pub display_full_path: Option<bool>,
41    pub search_submodules: Option<bool>,
42    pub recursive_submodules: Option<bool>,
43    pub switch_filter_unknown: Option<bool>,
44    pub session_sort_order: Option<SessionSortOrderConfig>,
45    pub excluded_dirs: Option<Vec<String>>,
46    pub search_paths: Option<Vec<String>>, // old format, deprecated
47    pub search_dirs: Option<Vec<SearchDirectory>>,
48    pub sessions: Option<Vec<Session>>,
49    pub picker_colors: Option<PickerColorConfig>,
50    pub input_position: Option<InputPosition>,
51    pub shortcuts: Option<Keymap>,
52    pub bookmarks: Option<Vec<String>>,
53    pub session_configs: Option<HashMap<String, SessionConfig>>,
54    pub marks: Option<HashMap<String, String>>,
55    pub clone_repo_switch: Option<CloneRepoSwitchConfig>,
56    pub vcs_providers: Option<Vec<VcsProviders>>,
57}
58
59pub const DEFAULT_VCS_PROVIDERS: &[VcsProviders] = &[VcsProviders::Git];
60
61#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
62#[serde(rename_all = "lowercase")]
63pub enum VcsProviders {
64    Git,
65    #[serde(alias = "jj")]
66    Jujutsu,
67}
68
69#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
70pub struct ConfigExport {
71    pub default_session: Option<String>,
72    pub display_full_path: bool,
73    pub search_submodules: bool,
74    pub recursive_submodules: bool,
75    pub switch_filter_unknown: bool,
76    pub session_sort_order: SessionSortOrderConfig,
77    pub excluded_dirs: Vec<String>,
78    pub search_dirs: Vec<SearchDirectory>,
79    pub sessions: Vec<Session>,
80    pub picker_colors: PickerColorConfig,
81    pub shortcuts: Keymap,
82    pub bookmarks: Vec<String>,
83    pub session_configs: HashMap<String, SessionConfig>,
84    pub marks: HashMap<String, String>,
85    pub clone_repo_switch: CloneRepoSwitchConfig,
86    pub vcs_providers: Vec<VcsProviders>,
87}
88
89impl From<Config> for ConfigExport {
90    fn from(value: Config) -> Self {
91        Self {
92            default_session: value.default_session,
93            display_full_path: value.display_full_path.unwrap_or_default(),
94            search_submodules: value.search_submodules.unwrap_or_default(),
95            recursive_submodules: value.recursive_submodules.unwrap_or_default(),
96            switch_filter_unknown: value.switch_filter_unknown.unwrap_or_default(),
97            session_sort_order: value.session_sort_order.unwrap_or_default(),
98            excluded_dirs: value.excluded_dirs.unwrap_or_default(),
99            search_dirs: value.search_dirs.unwrap_or_default(),
100            sessions: value.sessions.unwrap_or_default(),
101            picker_colors: PickerColorConfig::with_defaults(
102                value.picker_colors.unwrap_or_default(),
103            ),
104            shortcuts: value
105                .shortcuts
106                .as_ref()
107                .map(Keymap::with_defaults)
108                .unwrap_or_default(),
109            bookmarks: value.bookmarks.unwrap_or_default(),
110            session_configs: value.session_configs.unwrap_or_default(),
111            marks: value.marks.unwrap_or_default(),
112            clone_repo_switch: value.clone_repo_switch.unwrap_or_default(),
113            vcs_providers: value.vcs_providers.unwrap_or(DEFAULT_VCS_PROVIDERS.into()),
114        }
115    }
116}
117
118impl Config {
119    pub(crate) fn new() -> Result<Self> {
120        let config_builder = match env::var("TMS_CONFIG_FILE") {
121            Ok(path) => {
122                config::Config::builder().add_source(config::File::with_name(&path).required(false))
123            }
124            Err(e) => match e {
125                env::VarError::NotPresent => {
126                    let mut builder = config::Config::builder();
127                    let mut config_found = false; // Stores whether a valid config file was found
128                    if let Some(home_path) = dirs::home_dir() {
129                        config_found = true;
130                        let path = home_path.as_path().join(".config/tms/config.toml");
131                        builder = builder.add_source(config::File::from(path).required(false));
132                    }
133                    if let Some(config_path) = dirs::config_dir() {
134                        config_found = true;
135                        let path = config_path.as_path().join("tms/config.toml");
136                        builder = builder.add_source(config::File::from(path).required(false));
137                    }
138                    if !config_found {
139                        return Err(ConfigError::LoadError)
140                            .attach_printable("Could not find a valid location for config file (both home and config dirs cannot be found)")
141                            .attach(Suggestion("Try specifying a config file with the TMS_CONFIG_FILE environment variable."));
142                    }
143                    builder
144                }
145                env::VarError::NotUnicode(_) => {
146                    return Err(ConfigError::LoadError).attach_printable(
147                        "Invalid non-unicode value for TMS_CONFIG_FILE env variable",
148                    );
149                }
150            },
151        };
152        let config = config_builder
153            .build()
154            .change_context(ConfigError::LoadError)
155            .attach_printable("Could not parse configuration")?;
156        config
157            .try_deserialize()
158            .change_context(ConfigError::LoadError)
159            .attach_printable("Could not deserialize configuration")
160    }
161
162    pub(crate) fn save(&self) -> Result<()> {
163        let toml_pretty = toml::to_string_pretty(self)
164            .change_context(ConfigError::TomlError)?
165            .into_bytes();
166        // The TMS_CONFIG_FILE envvar should be set, either by the user or when the config is
167        // loaded. However, there is a possibility it becomes unset between loading and saving
168        // the config. In this case, it will fall back to the platform-specific config folder, and
169        // if that can't be found then it's good old ~/.config
170        let path = match env::var("TMS_CONFIG_FILE") {
171            Ok(path) => PathBuf::from(path),
172            Err(_) => {
173                if let Some(config_path) = dirs::config_dir() {
174                    config_path.as_path().join("tms/config.toml")
175                } else if let Some(home_path) = dirs::home_dir() {
176                    home_path.as_path().join(".config/tms/config.toml")
177                } else {
178                    return Err(ConfigError::LoadError)
179                        .attach_printable("Could not find a valid location to write config file (both home and config dirs cannot be found)")
180                        .attach(Suggestion("Try specifying a config file with the TMS_CONFIG_FILE environment variable."));
181                }
182            }
183        };
184        let parent = path
185            .parent()
186            .ok_or(ConfigError::FileWriteError)
187            .attach_printable(format!(
188                "Unable to determine parent directory of specified tms config file: {}",
189                path.to_str()
190                    .unwrap_or("(path could not be converted to string)")
191            ))?;
192        std::fs::create_dir_all(parent)
193            .change_context(ConfigError::FileWriteError)
194            .attach_printable("Unable to create tms config folder")?;
195        let mut file = std::fs::File::create(path).change_context(ConfigError::FileWriteError)?;
196        file.write_all(&toml_pretty)
197            .change_context(ConfigError::FileWriteError)?;
198        Ok(())
199    }
200
201    pub fn search_dirs(&self) -> Result<Vec<SearchDirectory>> {
202        if self.search_dirs.as_ref().is_none_or(Vec::is_empty)
203            && self.search_paths.as_ref().is_none_or(Vec::is_empty)
204        {
205            return Err(ConfigError::NoDefaultSearchPath)
206            .attach_printable(
207                "You must configure at least one default search path with the `config` subcommand. E.g `tms config` ",
208            );
209        }
210
211        let mut search_dirs = if let Some(search_dirs) = self.search_dirs.as_ref() {
212            search_dirs
213                .iter()
214                .filter_map(|search_dir| {
215                    let expanded_path = shellexpand::full(&search_dir.path.to_string_lossy())
216                        .ok()?
217                        .to_string();
218
219                    let path = canonicalize(expanded_path).ok()?;
220
221                    Some(SearchDirectory::new(path, search_dir.depth))
222                })
223                .collect()
224        } else {
225            Vec::new()
226        };
227
228        // merge old search paths with new search directories
229        if let Some(search_paths) = self.search_paths.as_ref() {
230            if !search_paths.is_empty() {
231                search_dirs.extend(search_paths.iter().filter_map(|path| {
232                    let expanded_path = shellexpand::full(&path).ok()?.to_string();
233                    let path = canonicalize(expanded_path).ok()?;
234
235                    Some(SearchDirectory::new(path, 10))
236                }));
237            }
238        }
239
240        if search_dirs.is_empty() {
241            return Err(ConfigError::NoValidSearchPath)
242            .attach_printable(
243                "You must configure at least one valid search path with the `config` subcommand. E.g `tms config` "
244            );
245        }
246
247        Ok(search_dirs)
248    }
249
250    pub fn add_bookmark(&mut self, path: String) {
251        let bookmarks = &mut self.bookmarks;
252        match bookmarks {
253            Some(ref mut bookmarks) => {
254                if !bookmarks.contains(&path) {
255                    bookmarks.push(path);
256                }
257            }
258            None => {
259                self.bookmarks = Some(vec![path]);
260            }
261        }
262    }
263
264    pub fn delete_bookmark(&mut self, path: String) {
265        if let Some(ref mut bookmarks) = self.bookmarks {
266            if let Some(idx) = bookmarks.iter().position(|bookmark| *bookmark == path) {
267                bookmarks.remove(idx);
268            }
269        }
270    }
271
272    pub fn bookmark_paths(&self) -> Vec<PathBuf> {
273        if let Some(bookmarks) = &self.bookmarks {
274            bookmarks
275                .iter()
276                .filter_map(|b| {
277                    if let Ok(expanded) = shellexpand::full(b) {
278                        PathBuf::from(expanded.to_string()).canonicalize().ok()
279                    } else {
280                        None
281                    }
282                })
283                .collect()
284        } else {
285            Vec::new()
286        }
287    }
288
289    pub fn add_mark(&mut self, path: String, index: usize) {
290        let marks = &mut self.marks;
291        match marks {
292            Some(ref mut marks) => {
293                marks.insert(index.to_string(), path);
294            }
295            None => {
296                self.marks = Some(HashMap::from([(index.to_string(), path)]));
297            }
298        }
299    }
300
301    pub fn delete_mark(&mut self, index: usize) {
302        if let Some(ref mut marks) = self.marks {
303            marks.remove(&index.to_string());
304        }
305    }
306
307    pub fn clear_marks(&mut self) {
308        self.marks = None;
309    }
310}
311
312#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
313pub struct SearchDirectory {
314    pub path: PathBuf,
315    pub depth: usize,
316}
317
318impl SearchDirectory {
319    pub fn new(path: PathBuf, depth: usize) -> Self {
320        SearchDirectory { path, depth }
321    }
322}
323
324#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
325pub struct Session {
326    pub name: Option<String>,
327    pub path: Option<String>,
328    pub windows: Option<Vec<Window>>,
329}
330
331#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
332pub struct Window {
333    pub name: Option<String>,
334    pub path: Option<String>,
335    pub panes: Option<Vec<Pane>>,
336    pub command: Option<String>,
337}
338
339#[derive(Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
340pub struct Pane {}
341
342#[derive(Clone, Default, Debug, Serialize, Deserialize, PartialEq, Eq)]
343pub struct PickerColorConfig {
344    pub highlight_color: Option<Color>,
345    pub highlight_text_color: Option<Color>,
346    pub border_color: Option<Color>,
347    pub info_color: Option<Color>,
348    pub prompt_color: Option<Color>,
349}
350
351const HIGHLIGHT_COLOR_DEFAULT: Color = Color::LightBlue;
352const HIGHLIGHT_TEXT_COLOR_DEFAULT: Color = Color::Black;
353const BORDER_COLOR_DEFAULT: Color = Color::DarkGray;
354const INFO_COLOR_DEFAULT: Color = Color::LightYellow;
355const PROMPT_COLOR_DEFAULT: Color = Color::LightGreen;
356
357impl PickerColorConfig {
358    pub fn default_colors() -> Self {
359        PickerColorConfig {
360            highlight_color: Some(HIGHLIGHT_COLOR_DEFAULT),
361            highlight_text_color: Some(HIGHLIGHT_TEXT_COLOR_DEFAULT),
362            border_color: Some(BORDER_COLOR_DEFAULT),
363            info_color: Some(INFO_COLOR_DEFAULT),
364            prompt_color: Some(PROMPT_COLOR_DEFAULT),
365        }
366    }
367
368    pub fn with_defaults(self) -> Self {
369        PickerColorConfig {
370            highlight_color: self.highlight_color.or(Some(HIGHLIGHT_COLOR_DEFAULT)),
371            highlight_text_color: self
372                .highlight_text_color
373                .or(Some(HIGHLIGHT_TEXT_COLOR_DEFAULT)),
374            border_color: self.border_color.or(Some(BORDER_COLOR_DEFAULT)),
375            info_color: self.info_color.or(Some(INFO_COLOR_DEFAULT)),
376            prompt_color: self.prompt_color.or(Some(PROMPT_COLOR_DEFAULT)),
377        }
378    }
379
380    pub fn highlight_style(&self) -> Style {
381        let mut style = Style::default()
382            .bg(HIGHLIGHT_COLOR_DEFAULT)
383            .fg(HIGHLIGHT_TEXT_COLOR_DEFAULT)
384            .bold();
385
386        if let Some(color) = self.highlight_color {
387            style = style.bg(color);
388        }
389
390        if let Some(color) = self.highlight_text_color {
391            style = style.fg(color);
392        }
393
394        style
395    }
396
397    pub fn border_color(&self) -> Color {
398        if let Some(color) = self.border_color {
399            color
400        } else {
401            BORDER_COLOR_DEFAULT
402        }
403    }
404
405    pub fn info_color(&self) -> Color {
406        if let Some(color) = self.info_color {
407            color
408        } else {
409            INFO_COLOR_DEFAULT
410        }
411    }
412
413    pub fn prompt_color(&self) -> Color {
414        if let Some(color) = self.prompt_color {
415            color
416        } else {
417            PROMPT_COLOR_DEFAULT
418        }
419    }
420}
421
422#[derive(Debug, Serialize, Deserialize, Default, Clone, PartialEq, Eq)]
423pub enum SessionSortOrderConfig {
424    #[default]
425    Alphabetical,
426    LastAttached,
427}
428
429impl ValueEnum for SessionSortOrderConfig {
430    fn value_variants<'a>() -> &'a [Self] {
431        &[Self::Alphabetical, Self::LastAttached]
432    }
433
434    fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
435        match self {
436            SessionSortOrderConfig::Alphabetical => {
437                Some(clap::builder::PossibleValue::new("Alphabetical"))
438            }
439            SessionSortOrderConfig::LastAttached => {
440                Some(clap::builder::PossibleValue::new("LastAttached"))
441            }
442        }
443    }
444}
445
446#[derive(Debug, Default, Serialize, Deserialize, Copy, Clone, PartialEq, Eq)]
447pub enum CloneRepoSwitchConfig {
448    #[default]
449    Always,
450    Never,
451    Foreground,
452}
453
454impl ValueEnum for CloneRepoSwitchConfig {
455    fn value_variants<'a>() -> &'a [Self] {
456        &[Self::Always, Self::Never, Self::Foreground]
457    }
458
459    fn to_possible_value(&self) -> Option<clap::builder::PossibleValue> {
460        match self {
461            CloneRepoSwitchConfig::Always => Some(clap::builder::PossibleValue::new("Always")),
462            CloneRepoSwitchConfig::Never => Some(clap::builder::PossibleValue::new("Never")),
463            CloneRepoSwitchConfig::Foreground => {
464                Some(clap::builder::PossibleValue::new("Foreground"))
465            }
466        }
467    }
468}
469
470#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
471pub struct SessionConfig {
472    pub create_script: Option<PathBuf>,
473}