Skip to main content

git_atomic/config/
types.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3use std::fmt;
4use std::str::FromStr;
5
6/// Root configuration loaded from `.atomic.toml`.
7#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
8pub struct Config {
9    #[serde(default)]
10    pub settings: Settings,
11
12    /// Ordered list of component definitions.
13    /// Order determines match priority (first-match-wins per ADR-003).
14    /// Order guaranteed by TOML array-of-tables spec (ADR-007).
15    #[serde(default)]
16    pub components: Vec<Component>,
17}
18
19/// Global settings with defaults.
20#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
21pub struct Settings {
22    /// Base branch for atomic branches.
23    #[serde(default = "default_base_branch")]
24    pub base_branch: String,
25
26    /// Branch naming template. `{component}` is replaced at runtime.
27    #[serde(default = "default_branch_template")]
28    pub branch_template: String,
29
30    /// Policy when files match no component.
31    #[serde(default)]
32    pub unmatched_files: UnmatchedPolicy,
33
34    /// Default conventional-commit type.
35    pub default_commit_type: Option<String>,
36}
37
38/// Policy for files that match no component glob.
39#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
40#[serde(rename_all = "lowercase")]
41pub enum UnmatchedPolicy {
42    /// Fail with exit code 4 (default).
43    #[default]
44    Error,
45    /// Log a warning but continue.
46    Warn,
47    /// Silently skip unmatched files.
48    Ignore,
49}
50
51impl FromStr for UnmatchedPolicy {
52    type Err = String;
53
54    fn from_str(s: &str) -> Result<Self, Self::Err> {
55        match s.to_lowercase().as_str() {
56            "error" => Ok(UnmatchedPolicy::Error),
57            "warn" => Ok(UnmatchedPolicy::Warn),
58            "ignore" => Ok(UnmatchedPolicy::Ignore),
59            other => Err(format!(
60                "invalid unmatched_files policy: {other:?} (expected \"error\", \"warn\", or \"ignore\")"
61            )),
62        }
63    }
64}
65
66impl fmt::Display for UnmatchedPolicy {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        match self {
69            UnmatchedPolicy::Error => f.write_str("error"),
70            UnmatchedPolicy::Warn => f.write_str("warn"),
71            UnmatchedPolicy::Ignore => f.write_str("ignore"),
72        }
73    }
74}
75
76/// A single component definition.
77#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
78pub struct Component {
79    /// Component name (previously the map key in `[components.<name>]`).
80    pub name: String,
81
82    /// Glob patterns that claim files for this component.
83    pub globs: Vec<String>,
84
85    /// Override the conventional-commit type for this component.
86    pub commit_type: Option<String>,
87
88    /// Override the branch name for this component.
89    pub branch: Option<String>,
90}
91
92fn default_base_branch() -> String {
93    "main".into()
94}
95
96fn default_branch_template() -> String {
97    "atomic/{component}".into()
98}
99
100impl Default for Settings {
101    fn default() -> Self {
102        Self {
103            base_branch: default_base_branch(),
104            branch_template: default_branch_template(),
105            unmatched_files: UnmatchedPolicy::default(),
106            default_commit_type: None,
107        }
108    }
109}
110
111impl Config {
112    /// Build a sample config with default settings and example components.
113    /// Used by `init` to generate `.atomic.toml` from the real types.
114    pub fn sample() -> Self {
115        Self {
116            settings: Settings::default(),
117            components: vec![
118                Component {
119                    name: "frontend".into(),
120                    globs: vec!["src/ui/**".into(), "src/components/**".into()],
121                    commit_type: None,
122                    branch: None,
123                },
124                Component {
125                    name: "backend".into(),
126                    globs: vec!["src/api/**".into(), "src/db/**".into()],
127                    commit_type: Some("fix".into()),
128                    branch: None,
129                },
130            ],
131        }
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn unmatched_policy_from_str() {
141        assert_eq!(
142            UnmatchedPolicy::from_str("error").unwrap(),
143            UnmatchedPolicy::Error
144        );
145        assert_eq!(
146            UnmatchedPolicy::from_str("WARN").unwrap(),
147            UnmatchedPolicy::Warn
148        );
149        assert_eq!(
150            UnmatchedPolicy::from_str("Ignore").unwrap(),
151            UnmatchedPolicy::Ignore
152        );
153        assert!(UnmatchedPolicy::from_str("invalid").is_err());
154    }
155
156    #[test]
157    fn unmatched_policy_display() {
158        assert_eq!(UnmatchedPolicy::Error.to_string(), "error");
159        assert_eq!(UnmatchedPolicy::Warn.to_string(), "warn");
160        assert_eq!(UnmatchedPolicy::Ignore.to_string(), "ignore");
161    }
162
163    #[test]
164    fn config_serde_round_trip() {
165        let original = Config::sample();
166        let toml_str = toml::to_string(&original).expect("serialize to TOML");
167        let deserialized: Config = toml::from_str(&toml_str).expect("deserialize from TOML");
168
169        assert_eq!(
170            deserialized.settings.base_branch,
171            original.settings.base_branch
172        );
173        assert_eq!(
174            deserialized.settings.branch_template,
175            original.settings.branch_template
176        );
177        assert_eq!(
178            deserialized.settings.unmatched_files,
179            original.settings.unmatched_files
180        );
181        assert_eq!(
182            deserialized.settings.default_commit_type,
183            original.settings.default_commit_type
184        );
185        assert_eq!(deserialized.components.len(), original.components.len());
186        for (d, o) in deserialized
187            .components
188            .iter()
189            .zip(original.components.iter())
190        {
191            assert_eq!(d.name, o.name);
192            assert_eq!(d.globs, o.globs);
193            assert_eq!(d.commit_type, o.commit_type);
194            assert_eq!(d.branch, o.branch);
195        }
196    }
197
198    #[test]
199    fn unmatched_policy_serde_round_trip() {
200        #[derive(Debug, Serialize, Deserialize, PartialEq)]
201        struct Wrapper {
202            policy: UnmatchedPolicy,
203        }
204        for policy in [
205            UnmatchedPolicy::Error,
206            UnmatchedPolicy::Warn,
207            UnmatchedPolicy::Ignore,
208        ] {
209            let w = Wrapper {
210                policy: policy.clone(),
211            };
212            let serialized = toml::to_string(&w).expect("serialize");
213            let deserialized: Wrapper = toml::from_str(&serialized).expect("deserialize");
214            assert_eq!(deserialized.policy, policy);
215        }
216    }
217
218    #[test]
219    fn empty_string_from_str_returns_error() {
220        let result = UnmatchedPolicy::from_str("");
221        assert!(result.is_err());
222        assert!(
223            result
224                .unwrap_err()
225                .contains("invalid unmatched_files policy")
226        );
227    }
228}