Skip to main content

commit_wizard/engine/config/
env.rs

1/// Build a `BaseConfig` from environment variables (SRS §4).
2///
3/// This layer sits between CLI args and repo config in the resolution stack:
4///   CLI args > ENV > repo config > registry config > global config > defaults
5///
6/// Only fields with a corresponding set ENV variable are populated; everything
7/// else remains `None` so that lower-priority layers can still apply.
8use std::env;
9
10use crate::engine::{
11    config::base::{
12        BaseConfig, BranchConfig, BranchNamingConfig, CheckCommitsConfig, CheckConfig,
13        CommitConfig, CommitScopesConfig, PrBranchConfig, PrConfig, PrTitleConfig, PushAllowConfig,
14        PushCheckConfig, PushConfig, TicketConfig, VersioningConfig,
15    },
16    constants::env::*,
17    models::policy::enforcement::{CommitEnforcementScope, ScopeMode, TicketSource},
18};
19
20// ---------------------------------------------------------------------------
21// Primitive helpers
22// ---------------------------------------------------------------------------
23
24// These helpers use eprintln! for parse warnings rather than the app logger
25// because the env module is called before the runtime and logger are available.
26// The [warn] prefix matches the app's log format for grep-ability.
27
28/// Returns `Some(value)` only if at least one of the provided fields is `Some`.
29/// Removes the repetitive "if all fields are None, return None" pattern across builders.
30macro_rules! some_if_any {
31    ([$($field:expr),+ $(,)?] => $value:expr) => {
32        if $($field.is_some())||+ {
33            Some($value)
34        } else {
35            None
36        }
37    };
38}
39
40fn get_string(key: &str) -> Option<String> {
41    env::var(key).ok().filter(|s| !s.is_empty())
42}
43
44fn get_bool(key: &str) -> Option<bool> {
45    get_string(key).and_then(|val| parse_bool_val(&val, key))
46}
47
48fn parse_bool_val(val: &str, key: &str) -> Option<bool> {
49    match val.to_lowercase().as_str() {
50        "true" | "1" | "yes" | "on" => Some(true),
51        "false" | "0" | "no" | "off" => Some(false),
52        _ => {
53            eprintln!(
54                "[warn] CW: invalid boolean for {key}: {val:?} — expected true/false/1/0/yes/no/on/off"
55            );
56            None
57        }
58    }
59}
60
61fn get_u32(key: &str) -> Option<u32> {
62    get_string(key).and_then(|val| {
63        val.parse::<u32>()
64            .map_err(|_| {
65                eprintln!("[warn] CW: invalid integer for {key}: {val:?}");
66            })
67            .ok()
68    })
69}
70
71fn get_list(key: &str) -> Option<Vec<String>> {
72    get_string(key).map(|val| {
73        val.split(',')
74            .map(|s| s.trim().to_string())
75            .filter(|s| !s.is_empty())
76            .collect()
77    })
78}
79
80// ---------------------------------------------------------------------------
81// Enum parsers
82// ---------------------------------------------------------------------------
83
84fn parse_scope_mode(val: &str, key: &str) -> Option<ScopeMode> {
85    match val.to_lowercase().as_str() {
86        "disabled" => Some(ScopeMode::Disabled),
87        "optional" => Some(ScopeMode::Optional),
88        "required" => Some(ScopeMode::Required),
89        _ => {
90            eprintln!(
91                "[warn] CW: invalid ScopeMode for {key}: {val:?} — expected disabled/optional/required"
92            );
93            None
94        }
95    }
96}
97
98fn parse_ticket_source(val: &str, key: &str) -> Option<TicketSource> {
99    match val.to_lowercase().as_str() {
100        "branch" => Some(TicketSource::Branch),
101        "prompt" => Some(TicketSource::Prompt),
102        "branch_or_prompt" => Some(TicketSource::BranchOrPrompt),
103        "disabled" => Some(TicketSource::Disabled),
104        _ => {
105            eprintln!(
106                "[warn] CW: invalid TicketSource for {key}: {val:?} — expected branch/prompt/branch_or_prompt/disabled"
107            );
108            None
109        }
110    }
111}
112
113fn parse_enforcement_scope(val: &str, key: &str) -> Option<CommitEnforcementScope> {
114    match val.to_lowercase().as_str() {
115        "all_branches" | "all" => Some(CommitEnforcementScope::AllBranches),
116        "protected_branches" | "protected" => Some(CommitEnforcementScope::ProtectedBranches),
117        "none" => Some(CommitEnforcementScope::None),
118        _ => {
119            eprintln!(
120                "[warn] CW: invalid CommitEnforcementScope for {key}: {val:?} — expected all_branches/protected_branches/none"
121            );
122            None
123        }
124    }
125}
126
127// ---------------------------------------------------------------------------
128// Public entry point
129// ---------------------------------------------------------------------------
130
131/// Build a `BaseConfig` from environment variables. Returns `None` when no
132/// relevant `CW_*` variables are set (so lower-priority layers still apply).
133///
134/// Meta-flag `CW_ALLOW_ENV_OVERRIDE=false` disables this layer entirely.
135pub fn build_env_config() -> Option<BaseConfig> {
136    // Honour global opt-out flag.
137    if let Some(val) = get_string(ENV_ALLOW_ENV_OVERRIDE)
138        && !parse_bool_val(&val, ENV_ALLOW_ENV_OVERRIDE).unwrap_or(true)
139    {
140        return None;
141    }
142
143    let commit = build_commit();
144    let branch = build_branch();
145    let pr = build_pr();
146    let check = build_check();
147    let push = build_push();
148    let versioning = build_versioning();
149
150    if commit.is_none()
151        && branch.is_none()
152        && pr.is_none()
153        && check.is_none()
154        && push.is_none()
155        && versioning.is_none()
156    {
157        return None;
158    }
159
160    Some(BaseConfig {
161        commit,
162        branch,
163        pr,
164        check,
165        push,
166        versioning,
167        changelog: None,
168        release: None,
169        ai: None,
170        hooks: None,
171        registry: None,
172        registries: None,
173    })
174}
175
176// ---------------------------------------------------------------------------
177// Section builders
178// ---------------------------------------------------------------------------
179
180fn build_commit() -> Option<CommitConfig> {
181    let subject_max_length = get_u32(ENV_COMMIT_SUBJECT_MAX_LENGTH);
182    let scopes = {
183        let mode = get_string(ENV_COMMIT_SCOPES_MODE)
184            .and_then(|v| parse_scope_mode(&v, ENV_COMMIT_SCOPES_MODE));
185        let restrict_to_defined = get_bool(ENV_COMMIT_SCOPES_RESTRICT_TO_DEFINED);
186        some_if_any!([mode, restrict_to_defined] =>
187            CommitScopesConfig { mode, restrict_to_defined, definitions: None })
188    };
189    let ticket = {
190        let required = get_bool(ENV_COMMIT_TICKET_REQUIRED);
191        let pattern = get_string(ENV_COMMIT_TICKET_PATTERN);
192        let source = get_string(ENV_COMMIT_TICKET_SOURCE)
193            .and_then(|v| parse_ticket_source(&v, ENV_COMMIT_TICKET_SOURCE));
194        some_if_any!([required, pattern, source] =>
195            TicketConfig { required, pattern, source, header_format: None })
196    };
197    some_if_any!([subject_max_length, scopes, ticket] =>
198    CommitConfig {
199        subject_max_length,
200        use_emojis: None,
201        types: None,
202        scopes,
203        breaking: None,
204        protected: None,
205        ticket,
206    })
207}
208
209fn build_branch() -> Option<BranchConfig> {
210    let remote = get_string(ENV_BRANCH_REMOTE);
211    let protected = get_list(ENV_BRANCH_PROTECTED);
212    let naming = get_string(ENV_BRANCH_NAMING_PATTERN).map(|pattern| BranchNamingConfig {
213        pattern: Some(pattern),
214    });
215    some_if_any!([remote, protected, naming] => BranchConfig { remote, protected, naming })
216}
217
218fn build_pr() -> Option<PrConfig> {
219    let title = {
220        let require_conventional = get_bool(ENV_PR_TITLE_REQUIRE_CONVENTIONAL);
221        let require_ticket = get_bool(ENV_PR_TITLE_REQUIRE_TICKET);
222        let scope_mode = get_string(ENV_PR_TITLE_SCOPE_MODE)
223            .and_then(|v| parse_scope_mode(&v, ENV_PR_TITLE_SCOPE_MODE));
224        some_if_any!([require_conventional, require_ticket, scope_mode] =>
225            PrTitleConfig { require_conventional, require_ticket, scope_mode })
226    };
227    let branch = {
228        let source_pattern = get_string(ENV_PR_BRANCH_SOURCE_PATTERN);
229        let target_allowed = get_list(ENV_PR_BRANCH_TARGET_ALLOWED);
230        some_if_any!([source_pattern, target_allowed] =>
231        PrBranchConfig {
232            check_source: None,
233            check_target: None,
234            source_pattern,
235            target_allowed,
236        })
237    };
238    some_if_any!([title, branch] => PrConfig { enabled: None, title, branch })
239}
240
241fn build_check() -> Option<CheckConfig> {
242    let require_conventional = get_bool(ENV_CHECK_REQUIRE_CONVENTIONAL);
243    let commits = {
244        let enabled = get_bool(ENV_CHECK_COMMITS_ENABLED);
245        let enforce_on = get_string(ENV_CHECK_COMMITS_ENFORCE_ON)
246            .and_then(|v| parse_enforcement_scope(&v, ENV_CHECK_COMMITS_ENFORCE_ON));
247        some_if_any!([enabled, enforce_on] => CheckCommitsConfig { enabled, enforce_on })
248    };
249    some_if_any!([require_conventional, commits] => CheckConfig { require_conventional, commits })
250}
251
252fn build_push() -> Option<PushConfig> {
253    let allow = {
254        let protected = get_bool(ENV_PUSH_ALLOW_PROTECTED);
255        let force = get_bool(ENV_PUSH_ALLOW_FORCE);
256        some_if_any!([protected, force] => PushAllowConfig { protected, force })
257    };
258    let check = {
259        let commits = get_bool(ENV_PUSH_CHECK_COMMITS);
260        let branch_policy = get_bool(ENV_PUSH_CHECK_BRANCH_POLICY);
261        some_if_any!([commits, branch_policy] => PushCheckConfig { commits, branch_policy })
262    };
263    some_if_any!([allow, check] => PushConfig { allow, check })
264}
265
266fn build_versioning() -> Option<VersioningConfig> {
267    get_string(ENV_VERSIONING_TAG_PREFIX).map(|tag_prefix| VersioningConfig {
268        tag_prefix: Some(tag_prefix),
269    })
270}
271
272// ---------------------------------------------------------------------------
273// Registry selection helpers (used by registry resolver)
274// ---------------------------------------------------------------------------
275
276/// Registry parameters sourced from ENV variables (SRS §6).
277pub struct EnvRegistryParams {
278    pub url: Option<String>,
279    pub r#ref: Option<String>,
280    pub section: Option<String>,
281}
282
283pub fn get_env_registry_params() -> EnvRegistryParams {
284    EnvRegistryParams {
285        url: get_string(ENV_REGISTRY_URL),
286        r#ref: get_string(ENV_REGISTRY_REF),
287        section: get_string(ENV_REGISTRY_SECTION),
288    }
289}