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>>, 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; 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 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 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}