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}