Skip to main content

rippy_cli/
git_styles.rs

1//! Git workflow styles — named rule bundles for git permissiveness.
2//!
3//! Styles are predefined sets of config rules that control how permissive
4//! rippy is with git operations. They are expanded into `ConfigDirective`s
5//! during config loading, slotting between stdlib and user rules.
6
7use std::path::Path;
8
9use crate::condition::Condition;
10use crate::config::ConfigDirective;
11use crate::toml_config::TomlGit;
12
13const CAUTIOUS_TOML: &str = include_str!("stdlib/git_cautious.toml");
14const STANDARD_TOML: &str = include_str!("stdlib/git_standard.toml");
15const PERMISSIVE_TOML: &str = include_str!("stdlib/git_permissive.toml");
16
17/// A named git workflow style.
18#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum GitStyle {
20    Cautious,
21    Standard,
22    Permissive,
23}
24
25impl GitStyle {
26    /// Parse a style name from a string.
27    ///
28    /// # Errors
29    ///
30    /// Returns an error if the name is not recognized.
31    pub fn parse(s: &str) -> Result<Self, String> {
32        match s {
33            "cautious" => Ok(Self::Cautious),
34            "standard" => Ok(Self::Standard),
35            "permissive" => Ok(Self::Permissive),
36            other => Err(format!(
37                "unknown git style: {other} (expected cautious, standard, or permissive)"
38            )),
39        }
40    }
41
42    const fn toml_source(self) -> &'static str {
43        match self {
44            Self::Cautious => CAUTIOUS_TOML,
45            Self::Standard => STANDARD_TOML,
46            Self::Permissive => PERMISSIVE_TOML,
47        }
48    }
49
50    const fn label(self) -> &'static str {
51        match self {
52            Self::Cautious => "cautious",
53            Self::Standard => "standard",
54            Self::Permissive => "permissive",
55        }
56    }
57}
58
59/// Expand a `[git]` config section into directives.
60///
61/// The default style's rules are emitted first (unconditional), then
62/// branch-specific overrides with `BranchMatch` conditions. This ensures
63/// last-match-wins semantics: branch overrides beat the default style.
64///
65/// # Errors
66///
67/// Returns an error if a style name is unrecognized or a style TOML
68/// fails to parse.
69pub fn expand_git_config(git: &TomlGit) -> Result<Vec<ConfigDirective>, String> {
70    let mut directives = Vec::new();
71
72    if let Some(style_name) = &git.style {
73        let style = GitStyle::parse(style_name)?;
74        directives.extend(parse_style_rules(style)?);
75    }
76
77    for branch in &git.branches {
78        if branch.pattern.is_empty() {
79            return Err("git.branches entry has empty pattern".into());
80        }
81        let style = GitStyle::parse(&branch.style)?;
82        let rules = parse_style_rules(style)?;
83        directives.extend(add_branch_condition(rules, &branch.pattern));
84    }
85
86    Ok(directives)
87}
88
89/// Parse a style's embedded TOML into directives.
90fn parse_style_rules(style: GitStyle) -> Result<Vec<ConfigDirective>, String> {
91    let source = style.toml_source();
92    let label = format!("(git-style:{style_name})", style_name = style.label());
93    crate::toml_config::parse_toml_config(source, Path::new(&label))
94        .map_err(|e| format!("error parsing git style {}: {e}", style.label()))
95}
96
97/// Append a `BranchMatch` condition to every rule in the directive list.
98fn add_branch_condition(directives: Vec<ConfigDirective>, pattern: &str) -> Vec<ConfigDirective> {
99    directives
100        .into_iter()
101        .map(|d| match d {
102            ConfigDirective::Rule(mut rule) => {
103                rule.conditions
104                    .push(Condition::BranchMatch(pattern.to_string()));
105                ConfigDirective::Rule(rule)
106            }
107            other => other,
108        })
109        .collect()
110}
111
112#[cfg(test)]
113#[allow(clippy::unwrap_used)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn cautious_toml_parses() {
119        let rules = parse_style_rules(GitStyle::Cautious).unwrap();
120        assert!(!rules.is_empty());
121    }
122
123    #[test]
124    fn standard_toml_parses() {
125        let rules = parse_style_rules(GitStyle::Standard).unwrap();
126        assert!(!rules.is_empty());
127    }
128
129    #[test]
130    fn permissive_toml_parses() {
131        let rules = parse_style_rules(GitStyle::Permissive).unwrap();
132        assert!(!rules.is_empty());
133    }
134
135    #[test]
136    fn unknown_style_errors() {
137        assert!(GitStyle::parse("yolo").is_err());
138    }
139
140    #[test]
141    fn expand_default_style_only() {
142        let git = TomlGit {
143            style: Some("standard".into()),
144            branches: vec![],
145        };
146        let directives = expand_git_config(&git).unwrap();
147        assert!(!directives.is_empty());
148        // No branch conditions on default style rules
149        for d in &directives {
150            if let ConfigDirective::Rule(r) = d {
151                assert!(r.conditions.is_empty());
152            }
153        }
154    }
155
156    #[test]
157    fn expand_branch_override_adds_conditions() {
158        let git = TomlGit {
159            style: None,
160            branches: vec![crate::toml_config::TomlGitBranch {
161                pattern: "agent/*".into(),
162                style: "permissive".into(),
163            }],
164        };
165        let directives = expand_git_config(&git).unwrap();
166        assert!(!directives.is_empty());
167        for d in &directives {
168            if let ConfigDirective::Rule(r) = d {
169                assert!(
170                    r.conditions
171                        .iter()
172                        .any(|c| matches!(c, Condition::BranchMatch(p) if p == "agent/*")),
173                    "expected BranchMatch condition on rule"
174                );
175            }
176        }
177    }
178
179    #[test]
180    fn expand_default_plus_branch_override() {
181        let git = TomlGit {
182            style: Some("standard".into()),
183            branches: vec![crate::toml_config::TomlGitBranch {
184                pattern: "main".into(),
185                style: "cautious".into(),
186            }],
187        };
188        let directives = expand_git_config(&git).unwrap();
189        // Should have both unconditional (standard) and conditioned (cautious) rules
190        let unconditional = directives
191            .iter()
192            .filter(|d| matches!(d, ConfigDirective::Rule(r) if r.conditions.is_empty()))
193            .count();
194        let conditioned = directives
195            .iter()
196            .filter(|d| matches!(d, ConfigDirective::Rule(r) if !r.conditions.is_empty()))
197            .count();
198        assert!(unconditional > 0, "expected unconditional standard rules");
199        assert!(conditioned > 0, "expected conditioned cautious rules");
200    }
201
202    #[test]
203    fn empty_git_section_produces_no_directives() {
204        let git = TomlGit {
205            style: None,
206            branches: vec![],
207        };
208        let directives = expand_git_config(&git).unwrap();
209        assert!(directives.is_empty());
210    }
211
212    #[test]
213    fn empty_branch_pattern_errors() {
214        let git = TomlGit {
215            style: None,
216            branches: vec![crate::toml_config::TomlGitBranch {
217                pattern: String::new(),
218                style: "standard".into(),
219            }],
220        };
221        assert!(expand_git_config(&git).is_err());
222    }
223}