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