33// SPDX-License-Identifier: GPL-3.0-only
44
55use directories:: ProjectDirs ;
6+ use figment:: Figment ;
7+ use figment:: providers:: { Env , Format , Serialized , Toml } ;
68use serde:: { Deserialize , Serialize } ;
79use std:: fs;
810use std:: path:: PathBuf ;
@@ -70,7 +72,7 @@ pub struct Config {
7072 #[ serde( default = "default_ollama_host" ) ]
7173 pub ollama_host : String ,
7274
73- #[ serde( skip ) ]
75+ #[ serde( default ) ]
7476 pub api_key : Option < String > ,
7577
7678 #[ serde( default = "default_max_diff_lines" ) ]
@@ -151,10 +153,43 @@ impl Default for Config {
151153}
152154
153155impl Config {
154- /// Load with priority: CLI > ENV > file > defaults
156+ /// Load with priority: CLI > ENV > user config > project config > defaults
155157 pub fn load ( cli : & Cli ) -> Result < Self > {
156- let mut config = Self :: load_from_file ( ) ?;
157- config. apply_env ( ) ;
158+ let mut figment = Figment :: new ( ) . merge ( Serialized :: defaults ( Config :: default ( ) ) ) ;
159+
160+ // Project-level config (.commitbee.toml in repo root)
161+ if let Ok ( cwd) = std:: env:: current_dir ( ) {
162+ let project_config = cwd. join ( ".commitbee.toml" ) ;
163+ if project_config. exists ( ) {
164+ figment = figment. merge ( Toml :: file ( & project_config) ) ;
165+ }
166+ }
167+
168+ // User-level config
169+ if let Some ( path) = Self :: config_path ( ) {
170+ if path. exists ( ) {
171+ figment = figment. merge ( Toml :: file ( & path) ) ;
172+ }
173+ }
174+
175+ // Environment variables (COMMITBEE_MODEL, COMMITBEE_PROVIDER, etc.)
176+ // Use __ separator for nested keys (e.g., COMMITBEE_FORMAT__INCLUDE_BODY)
177+ figment = figment. merge ( Env :: prefixed ( "COMMITBEE_" ) . split ( "__" ) ) ;
178+
179+ let mut config: Config = figment
180+ . extract ( )
181+ . map_err ( |e| Error :: Config ( e. to_string ( ) ) ) ?;
182+
183+ // Provider-specific API key fallback
184+ if config. api_key . is_none ( ) {
185+ config. api_key = match config. provider {
186+ Provider :: OpenAI => std:: env:: var ( "OPENAI_API_KEY" ) . ok ( ) ,
187+ Provider :: Anthropic => std:: env:: var ( "ANTHROPIC_API_KEY" ) . ok ( ) ,
188+ Provider :: Ollama => None ,
189+ } ;
190+ }
191+
192+ // CLI overrides (highest priority)
158193 config. apply_cli ( cli) ;
159194 config. validate ( ) ?;
160195 Ok ( config)
@@ -168,46 +203,6 @@ impl Config {
168203 Self :: config_dir ( ) . map ( |d| d. join ( "config.toml" ) )
169204 }
170205
171- fn load_from_file ( ) -> Result < Self > {
172- let Some ( path) = Self :: config_path ( ) else {
173- return Ok ( Self :: default ( ) ) ;
174- } ;
175-
176- if !path. exists ( ) {
177- return Ok ( Self :: default ( ) ) ;
178- }
179-
180- let content = fs:: read_to_string ( & path) ?;
181- toml:: from_str ( & content) . map_err ( |e| Error :: Config ( e. to_string ( ) ) )
182- }
183-
184- fn apply_env ( & mut self ) {
185- if let Ok ( p) = std:: env:: var ( "COMMITBEE_PROVIDER" ) {
186- self . provider = match p. to_lowercase ( ) . as_str ( ) {
187- "openai" => Provider :: OpenAI ,
188- "anthropic" => Provider :: Anthropic ,
189- _ => Provider :: Ollama ,
190- } ;
191- }
192-
193- if let Ok ( m) = std:: env:: var ( "COMMITBEE_MODEL" ) {
194- self . model = m;
195- }
196-
197- if let Ok ( h) = std:: env:: var ( "COMMITBEE_OLLAMA_HOST" ) {
198- self . ollama_host = h;
199- }
200-
201- // API key: COMMITBEE_API_KEY > provider-specific
202- self . api_key = std:: env:: var ( "COMMITBEE_API_KEY" )
203- . or_else ( |_| match self . provider {
204- Provider :: OpenAI => std:: env:: var ( "OPENAI_API_KEY" ) ,
205- Provider :: Anthropic => std:: env:: var ( "ANTHROPIC_API_KEY" ) ,
206- Provider :: Ollama => Err ( std:: env:: VarError :: NotPresent ) ,
207- } )
208- . ok ( ) ;
209- }
210-
211206 fn apply_cli ( & mut self , cli : & Cli ) {
212207 if let Some ( ref p) = cli. provider {
213208 self . provider = match p. to_lowercase ( ) . as_str ( ) {
0 commit comments