Skip to main content

toasty_cli/
config.rs

1use crate::migration::MigrationConfig;
2use anyhow::{Context, Result};
3use serde::{Deserialize, Serialize};
4use std::fs;
5use std::path::Path;
6
7/// Configuration for Toasty CLI operations.
8///
9/// Holds all settings that control how the CLI behaves. Currently this is
10/// limited to [`MigrationConfig`]. A `Config` can be built programmatically
11/// with the builder methods or loaded from a `Toasty.toml` file via
12/// [`Config::load`].
13///
14/// # Examples
15///
16/// ```
17/// use toasty_cli::{Config, MigrationConfig, MigrationPrefixStyle};
18///
19/// let config = Config::new()
20///     .migration(
21///         MigrationConfig::new()
22///             .path("db")
23///             .prefix_style(MigrationPrefixStyle::Timestamp),
24///     );
25/// assert_eq!(
26///     config.migration.get_migrations_dir(),
27///     std::path::PathBuf::from("db/migrations"),
28/// );
29/// ```
30#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq)]
31pub struct Config {
32    /// Migration-related configuration
33    pub migration: MigrationConfig,
34}
35
36impl Config {
37    /// Create a new Config with default values
38    pub fn new() -> Self {
39        Self::default()
40    }
41
42    /// Load configuration from Toasty.toml in the project root
43    pub fn load() -> Result<Self> {
44        Self::load_from(Path::new("Toasty.toml"))
45    }
46
47    /// Load configuration from a specific path.
48    pub fn load_from(path: &Path) -> Result<Self> {
49        let contents = fs::read_to_string(path).with_context(|| {
50            format!(
51                "failed to read Toasty config file at `{}` — check that the file exists \
52                 at this path relative to the working directory (a common cause in Docker \
53                 multi-stage builds is forgetting to copy it into the final image)",
54                path.display()
55            )
56        })?;
57        let config: Config = toml::from_str(&contents).with_context(|| {
58            format!("failed to parse Toasty config file at `{}`", path.display())
59        })?;
60        Ok(config)
61    }
62
63    /// Load configuration from `<project_root>/Toasty.toml`, creating it with
64    /// default contents if the file does not exist.
65    pub fn load_or_default(project_root: &Path) -> Result<Self> {
66        let path = project_root.join("Toasty.toml");
67        if path.exists() {
68            Self::load_from(&path)
69        } else {
70            let config = Self::default();
71            let toml = toml::to_string_pretty(&config)?;
72            fs::write(&path, toml)?;
73            Ok(config)
74        }
75    }
76
77    /// Set the migration configuration
78    pub fn migration(mut self, migration: MigrationConfig) -> Self {
79        self.migration = migration;
80        self
81    }
82}
83
84#[cfg(test)]
85mod tests {
86    use super::*;
87    use tempfile::tempdir;
88
89    #[test]
90    fn load_or_default_creates_toasty_toml_when_missing() {
91        let dir = tempdir().unwrap();
92        let path = dir.path().join("Toasty.toml");
93        assert!(!path.exists());
94
95        Config::load_or_default(dir.path()).unwrap();
96
97        assert!(path.exists(), "Toasty.toml should be created on first load");
98        let contents = fs::read_to_string(&path).unwrap();
99        let reparsed: Config = toml::from_str(&contents).unwrap();
100        let default = Config::default();
101
102        assert_eq!(reparsed, default);
103    }
104
105    #[test]
106    fn load_from_missing_file_reports_path() {
107        let dir = tempdir().unwrap();
108        let path = dir.path().join("Toasty.toml");
109
110        let err = Config::load_from(&path).unwrap_err();
111
112        let message = format!("{err:#}");
113        assert!(
114            message.contains(&path.display().to_string()),
115            "error message should mention the missing path: {message}"
116        );
117    }
118}