together_rs/
config.rs

1use std::{
2    borrow::Cow,
3    collections::HashSet,
4    path::{Path, PathBuf},
5};
6
7use clap::CommandFactory;
8
9use crate::{
10    errors::{TogetherError, TogetherResult},
11    log, log_err, t_println, terminal,
12};
13
14#[derive(Debug, Clone)]
15pub struct StartTogetherOptions {
16    pub config: TogetherConfigFile,
17    pub working_directory: Option<String>,
18    pub active_recipes: Option<Vec<String>>,
19    pub config_path: Option<std::path::PathBuf>,
20}
21
22pub fn to_start_options(command_args: terminal::TogetherArgs) -> StartTogetherOptions {
23    #[derive(Default)]
24    struct StartMeta {
25        config_path: Option<std::path::PathBuf>,
26        recipes: Option<Vec<String>>,
27    }
28    let (config, meta) = match command_args.command {
29        Some(terminal::ArgsCommands::Run(run_opts)) => {
30            let mut config_start_opts: commands::ConfigFileStartOptions = run_opts.into();
31            let meta = StartMeta::default();
32            config_start_opts.init_only = command_args.init_only;
33            config_start_opts.no_init = command_args.no_init;
34            config_start_opts.quiet_startup = command_args.quiet_startup;
35            (TogetherConfigFile::new(config_start_opts), meta)
36        }
37
38        Some(terminal::ArgsCommands::Rerun(_)) => {
39            if command_args.no_config {
40                log_err!("To use rerun, you must have a configuration file");
41                std::process::exit(1);
42            }
43            let config = load();
44            let config = config
45                .map_err(|e| {
46                    log_err!("Failed to load configuration: {}", e);
47                    std::process::exit(1);
48                })
49                .unwrap();
50            let config_path: PathBuf = path_or_default();
51            let meta = StartMeta {
52                config_path: Some(config_path),
53                ..StartMeta::default()
54            };
55            (config, meta)
56        }
57
58        Some(terminal::ArgsCommands::Load(load)) => {
59            if command_args.no_config {
60                log_err!("To use rerun, you must have a configuration file");
61                std::process::exit(1);
62            }
63            let config = load_from(&load.path);
64            let mut config = config
65                .map_err(|e| {
66                    log_err!("Failed to load configuration from '{}': {}", load.path, e);
67                    std::process::exit(1);
68                })
69                .unwrap();
70            let config_path: PathBuf = load.path.into();
71            config.start_options.init_only = load.init_only;
72            config.start_options.no_init = load.no_init;
73            config.start_options.quiet_startup = command_args.quiet_startup;
74            let meta = StartMeta {
75                config_path: Some(config_path),
76                recipes: load.recipes,
77            };
78            (config, meta)
79        }
80
81        None => (!command_args.no_config)
82            .then_some(())
83            .and_then(|()| path(None))
84            .and_then(|path| load_from(&path).ok().map(|config| (config, path)))
85            .map_or_else(
86                || {
87                    _ = terminal::TogetherArgs::command().print_long_help();
88                    std::process::exit(1);
89                },
90                |(mut config, config_path)| {
91                    let config_start_opts = &mut config.start_options;
92                    config_start_opts.init_only = command_args.init_only;
93                    config_start_opts.no_init = command_args.no_init;
94                    config_start_opts.quiet_startup = command_args.quiet_startup;
95                    let meta = StartMeta {
96                        config_path: Some(config_path.into()),
97                        recipes: command_args.recipes,
98                    };
99                    (config, meta)
100                },
101            ),
102    };
103
104    StartTogetherOptions {
105        config,
106        working_directory: command_args.working_directory,
107        active_recipes: meta.recipes,
108        config_path: meta.config_path,
109    }
110}
111
112#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
113pub struct TogetherConfigFile {
114    #[serde(flatten)]
115    pub start_options: commands::ConfigFileStartOptions,
116    pub running: Option<Vec<commands::CommandIndex>>,
117    pub startup: Option<Vec<commands::CommandIndex>>,
118    pub version: Option<String>,
119}
120
121impl TogetherConfigFile {
122    fn new(start_options: commands::ConfigFileStartOptions) -> Self {
123        Self {
124            start_options,
125            running: None,
126            startup: None,
127            version: Some(env!("CARGO_PKG_VERSION").to_string()),
128        }
129    }
130
131    pub fn with_running(self, running: &[impl AsRef<str>]) -> Self {
132        let running = running
133            .iter()
134            .map(|c| {
135                self.start_options
136                    .commands
137                    .iter()
138                    .position(|x| x.matches(c.as_ref()))
139                    .unwrap()
140                    .into()
141            })
142            .collect();
143
144        Self {
145            running: Some(running),
146            ..self
147        }
148    }
149
150    pub fn running_commands(&self) -> Option<Vec<&str>> {
151        let running = self
152            .running
153            .iter()
154            .flatten()
155            .flat_map(|index| index.retrieve(&self.start_options.commands))
156            .chain(self.start_options.commands.iter().filter(|c| c.is_active()))
157            .fold(vec![], |mut acc, c| {
158                if !acc.contains(&c) {
159                    acc.push(c);
160                }
161                acc
162            });
163
164        if running.is_empty() {
165            None
166        } else {
167            Some(running.into_iter().map(|c| c.as_str()).collect())
168        }
169    }
170}
171
172enum ConfigFileType {
173    Toml,
174    Yaml,
175}
176
177impl TryFrom<&std::path::Path> for ConfigFileType {
178    type Error = TogetherError;
179
180    fn try_from(value: &std::path::Path) -> Result<Self, Self::Error> {
181        match value.extension().and_then(|ext| ext.to_str()) {
182            Some("toml") => Ok(Self::Toml),
183            Some("yaml") | Some("yml") => Ok(Self::Yaml),
184            _ => Err(TogetherError::InternalError(
185                crate::errors::TogetherInternalError::InvalidConfigExtension,
186            )),
187        }
188    }
189}
190
191pub fn load_from(config_path: impl AsRef<std::path::Path>) -> TogetherResult<TogetherConfigFile> {
192    let config_path = config_path.as_ref();
193    let config = std::fs::read_to_string(config_path)?;
194    let config: TogetherConfigFile = match config_path.try_into()? {
195        ConfigFileType::Toml => toml::from_str(&config)?,
196        ConfigFileType::Yaml => serde_yml::from_str(&config)?,
197    };
198    check_version(&config);
199    Ok(config)
200}
201
202pub fn load() -> TogetherResult<TogetherConfigFile> {
203    let config_path = path_or_default();
204    log!("Loading configuration from: {:?}", config_path);
205    load_from(config_path)
206}
207
208pub fn save(
209    config: &TogetherConfigFile,
210    config_path: Option<&std::path::Path>,
211) -> TogetherResult<()> {
212    let config_path = config_path
213        .map(Cow::from)
214        .unwrap_or_else(|| path_or_default().into());
215    log!("Saving configuration to: {:?}", config_path);
216    let config = match config_path.as_ref().try_into()? {
217        ConfigFileType::Toml => toml::to_string(config)?,
218        ConfigFileType::Yaml => serde_yml::to_string(config)?,
219    };
220    std::fs::write(config_path, config)?;
221    Ok(())
222}
223
224pub fn dump(config: &TogetherConfigFile) -> TogetherResult<()> {
225    let config = serde_yml::to_string(config)?;
226    t_println!("Configuration:");
227    t_println!();
228    t_println!("{}", config);
229    Ok(())
230}
231
232pub fn get_running_commands(
233    config: &TogetherConfigFile,
234    running: &[commands::CommandIndex],
235) -> Vec<String> {
236    let commands: Vec<String> = running
237        .iter()
238        .filter_map(|index| {
239            index
240                .retrieve(&config.start_options.commands)
241                .map(|c| c.as_str().to_string())
242        })
243        .collect();
244    commands
245}
246
247pub fn get_unique_recipes(start_options: &commands::ConfigFileStartOptions) -> HashSet<&String> {
248    start_options
249        .commands
250        .iter()
251        .flat_map(|c| c.recipes())
252        .collect::<HashSet<_>>()
253}
254
255pub fn collect_commands_by_recipes(
256    start_options: &commands::ConfigFileStartOptions,
257    recipes: &[impl AsRef<str>],
258) -> Vec<String> {
259    let selected_commands = start_options
260        .commands
261        .iter()
262        .filter(|c| recipes.iter().any(|r| c.contains_recipe(r.as_ref())))
263        .map(|c| c.as_str().to_string())
264        .collect();
265    selected_commands
266}
267
268fn path_or_default() -> std::path::PathBuf {
269    let dir_path = dirs::config_dir().unwrap();
270    match path(Some(&dir_path)) {
271        Some(path) => path,
272        None => dir_path.join("together.yml"),
273    }
274}
275
276fn path(dir: Option<&Path>) -> Option<std::path::PathBuf> {
277    let files = ["together.yml", "together.yaml", "together.toml"];
278    files.iter().find_map(|f| {
279        let path = match &dir {
280            Some(dir) => dir.join(f),
281            None => f.into(),
282        };
283
284        path.exists().then_some(path)
285    })
286}
287
288fn check_version(config: &TogetherConfigFile) {
289    let Some(version) = &config.version else {
290        log_err!(
291            "The configuration file was created with a different version of together. \
292            Please update together to the latest version."
293        );
294        std::process::exit(1);
295    };
296    let current_version = env!("CARGO_PKG_VERSION");
297    let current_version = semver::Version::parse(current_version).unwrap();
298    let config_version = semver::Version::parse(version).unwrap();
299    if current_version.major < config_version.major {
300        log_err!(
301            "The configuration file was created with a more recent version of together (>={config_version}). \
302            Please update together to the latest version."
303        );
304        std::process::exit(1);
305    }
306
307    if current_version.minor < config_version.minor {
308        log!(
309            "Using configuration file created with a more recent version of together (>={config_version}). \
310            Some features may not be available."
311        );
312    }
313}
314
315pub mod commands {
316    use serde::{Deserialize, Serialize};
317
318    use crate::terminal;
319
320    #[derive(Debug, Clone, Serialize, Deserialize)]
321    pub struct ConfigFileStartOptions {
322        pub commands: Vec<CommandConfig>,
323        #[serde(default)]
324        pub all: bool,
325        #[serde(default)]
326        pub exit_on_error: bool,
327        #[serde(default)]
328        pub quit_on_completion: bool,
329        #[serde(default)]
330        pub quiet_startup: bool,
331        #[serde(default = "defaults::true_value")]
332        pub raw: bool,
333        #[serde(skip)]
334        pub init_only: bool,
335        #[serde(skip)]
336        pub no_init: bool,
337    }
338
339    mod defaults {
340        pub fn true_value() -> bool {
341            true
342        }
343    }
344
345    impl From<terminal::RunCommand> for ConfigFileStartOptions {
346        fn from(args: terminal::RunCommand) -> Self {
347            Self {
348                commands: args.commands.iter().map(|c| c.as_str().into()).collect(),
349                all: args.all,
350                exit_on_error: args.exit_on_error,
351                quit_on_completion: args.quit_on_completion,
352                quiet_startup: false,
353                raw: args.raw,
354                init_only: args.init_only,
355                no_init: args.no_init,
356            }
357        }
358    }
359
360    impl From<ConfigFileStartOptions> for terminal::RunCommand {
361        fn from(config: ConfigFileStartOptions) -> Self {
362            Self {
363                commands: config
364                    .commands
365                    .iter()
366                    .map(|c| c.as_str().to_string())
367                    .collect(),
368                all: config.all,
369                exit_on_error: config.exit_on_error,
370                quit_on_completion: config.quit_on_completion,
371                raw: config.raw,
372                init_only: config.init_only,
373                no_init: config.no_init,
374            }
375        }
376    }
377
378    impl ConfigFileStartOptions {
379        pub fn as_commands(&self) -> Vec<String> {
380            self.commands
381                .iter()
382                .map(|c| c.as_str().to_string())
383                .collect()
384        }
385    }
386
387    #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
388    #[serde(untagged)]
389    pub enum CommandConfig {
390        Simple(String),
391        Detailed {
392            command: String,
393            alias: Option<String>,
394            #[serde(alias = "default")]
395            active: Option<bool>,
396            recipes: Option<Vec<String>>,
397        },
398    }
399
400    impl CommandConfig {
401        pub fn as_str(&self) -> &str {
402            match self {
403                Self::Simple(s) => s,
404                Self::Detailed { command, .. } => command,
405            }
406        }
407
408        pub fn alias(&self) -> Option<&str> {
409            match self {
410                Self::Simple(_) => None,
411                Self::Detailed { alias, .. } => alias.as_deref(),
412            }
413        }
414
415        pub fn is_active(&self) -> bool {
416            match self {
417                Self::Simple(_) => false,
418                Self::Detailed { active, .. } => active.unwrap_or(false),
419            }
420        }
421
422        pub fn matches(&self, other: &str) -> bool {
423            self.as_str() == other || self.alias().map_or(false, |a| a == other)
424        }
425
426        pub fn recipes(&self) -> &[String] {
427            match self {
428                Self::Simple(_) => &[],
429                Self::Detailed { recipes, .. } => recipes.as_deref().unwrap_or(&[]),
430            }
431        }
432
433        pub fn contains_recipe(&self, recipe: &str) -> bool {
434            let recipe = recipe.trim();
435            match self {
436                Self::Simple(_) => false,
437                Self::Detailed { recipes, .. } => recipes
438                    .as_ref()
439                    .map_or(false, |r| r.iter().any(|x| x.eq_ignore_ascii_case(recipe))),
440            }
441        }
442    }
443
444    impl From<&str> for CommandConfig {
445        fn from(v: &str) -> Self {
446            Self::Simple(v.to_string())
447        }
448    }
449
450    #[derive(Debug, Clone, Serialize, Deserialize)]
451    #[serde(untagged)]
452    pub enum CommandIndex {
453        Simple(usize),
454        Alias(String),
455    }
456
457    impl CommandIndex {
458        pub fn retrieve<'a>(&self, commands: &'a [CommandConfig]) -> Option<&'a CommandConfig> {
459            match self {
460                Self::Simple(i) => commands.get(*i),
461                Self::Alias(alias) => commands
462                    .iter()
463                    .find(|c| c.alias() == Some(alias))
464                    .or_else(|| commands.iter().find(|c| c.as_str() == alias)),
465            }
466        }
467    }
468
469    impl From<usize> for CommandIndex {
470        fn from(v: usize) -> Self {
471            Self::Simple(v)
472        }
473    }
474
475    impl From<&str> for CommandIndex {
476        fn from(v: &str) -> Self {
477            Self::Alias(v.to_string())
478        }
479    }
480}