11use anyhow:: Result ;
2+ use std:: path:: PathBuf ;
23use std:: sync:: Arc ;
34use terraphim_config:: { Config , ConfigBuilder , ConfigId , ConfigState } ;
45use terraphim_persistence:: Persistable ;
@@ -24,7 +25,10 @@ impl TuiService {
2425 /// JSON and saves to persistence; subsequent runs use persistence so CLI changes stick)
2526 /// 3. Persistence layer (SQLite)
2627 /// 4. Embedded defaults (hardcoded roles)
27- pub async fn new ( config_path : Option < String > ) -> Result < Self > {
28+ ///
29+ /// If `no_project_config` is false, project-level `.terraphim/config.json` is discovered
30+ /// and merged on top of the loaded configuration.
31+ pub async fn new ( config_path : Option < String > , no_project_config : bool ) -> Result < Self > {
2832 // Initialize logging
2933 terraphim_service:: logging:: init_logging (
3034 terraphim_service:: logging:: detect_logging_config ( ) ,
@@ -36,7 +40,10 @@ impl TuiService {
3640 if let Some ( ref path) = config_path {
3741 log:: info!( "Loading config from --config flag: '{}'" , path) ;
3842 match Config :: load_from_json_file ( path) {
39- Ok ( config) => {
43+ Ok ( mut config) => {
44+ if !no_project_config {
45+ Self :: merge_project_config ( & mut config) ;
46+ }
4047 return Self :: from_config ( config) . await ;
4148 }
4249 Err ( e) => {
@@ -71,7 +78,12 @@ impl TuiService {
7178 // Priority 2: role_config in settings.toml (bootstrap-then-persistence)
7279 if let Some ( ref role_config_path) = device_settings. role_config {
7380 log:: info!( "Found role_config in settings.toml: '{}'" , role_config_path) ;
74- return Self :: load_with_role_config ( role_config_path, & device_settings) . await ;
81+ return Self :: load_with_role_config (
82+ role_config_path,
83+ & device_settings,
84+ no_project_config,
85+ )
86+ . await ;
7587 }
7688
7789 // Priority 3 & 4: Persistence -> embedded defaults (existing behavior)
@@ -84,15 +96,19 @@ impl TuiService {
8496 }
8597 Err ( _) => {
8698 log:: debug!( "No saved config found, using default embedded" ) ;
87- return Self :: new_with_embedded_defaults ( ) . await ;
99+ return Self :: new_with_embedded_defaults ( no_project_config ) . await ;
88100 }
89101 } ,
90102 Err ( e) => {
91103 log:: warn!( "Failed to build config: {:?}, using default" , e) ;
92- return Self :: new_with_embedded_defaults ( ) . await ;
104+ return Self :: new_with_embedded_defaults ( no_project_config ) . await ;
93105 }
94106 } ;
95107
108+ let mut config = config;
109+ if !no_project_config {
110+ Self :: merge_project_config ( & mut config) ;
111+ }
96112 Self :: from_config ( config) . await
97113 }
98114
@@ -104,6 +120,7 @@ impl TuiService {
104120 async fn load_with_role_config (
105121 role_config_path : & str ,
106122 device_settings : & DeviceSettings ,
123+ no_project_config : bool ,
107124 ) -> Result < Self > {
108125 // Try persistence first (preserves runtime changes like `config set`)
109126 if let Ok ( mut empty_config) = ConfigBuilder :: new_with_id ( ConfigId :: Embedded ) . build ( ) {
@@ -113,7 +130,11 @@ impl TuiService {
113130 "Loaded {} role(s) from persistence (role_config bootstrap already done)" ,
114131 persisted. roles. len( )
115132 ) ;
116- return Self :: from_config ( persisted) . await ;
133+ let mut config = persisted;
134+ if !no_project_config {
135+ Self :: merge_project_config ( & mut config) ;
136+ }
137+ return Self :: from_config ( config) . await ;
117138 }
118139 }
119140 }
@@ -153,6 +174,9 @@ impl TuiService {
153174 log:: warn!( "Failed to save bootstrapped config to persistence: {:?}" , e) ;
154175 }
155176
177+ if !no_project_config {
178+ Self :: merge_project_config ( & mut config) ;
179+ }
156180 Self :: from_config ( config) . await
157181 }
158182 Err ( e) => {
@@ -161,18 +185,21 @@ impl TuiService {
161185 role_config_path,
162186 e
163187 ) ;
164- Self :: new_with_embedded_defaults ( ) . await
188+ Self :: new_with_embedded_defaults ( no_project_config ) . await
165189 }
166190 }
167191 }
168192
169193 /// Initialize service strictly from the embedded default configuration.
170194 ///
171195 /// This constructor avoids touching host-specific config/state and is used by tests.
172- pub async fn new_with_embedded_defaults ( ) -> Result < Self > {
173- let config = ConfigBuilder :: new_with_id ( ConfigId :: Embedded )
196+ pub async fn new_with_embedded_defaults ( no_project_config : bool ) -> Result < Self > {
197+ let mut config = ConfigBuilder :: new_with_id ( ConfigId :: Embedded )
174198 . build_default_embedded ( )
175199 . build ( ) ?;
200+ if !no_project_config {
201+ Self :: merge_project_config ( & mut config) ;
202+ }
176203 Self :: from_config ( config) . await
177204 }
178205
@@ -186,6 +213,28 @@ impl TuiService {
186213 } )
187214 }
188215
216+ fn merge_project_config ( config : & mut Config ) {
217+ if let Ok ( Some ( path) ) = terraphim_config:: project:: discover ( None ) {
218+ let config_path = path. join ( "config.json" ) ;
219+ if config_path. is_file ( ) {
220+ if let Ok ( project_config) =
221+ terraphim_config:: project:: ProjectConfig :: from_file ( & config_path)
222+ {
223+ log:: info!( "Merging project config from '{}'" , config_path. display( ) ) ;
224+ let builder = ConfigBuilder :: from_config (
225+ config. clone ( ) ,
226+ DeviceSettings :: new ( ) ,
227+ PathBuf :: new ( ) ,
228+ ) ;
229+ * config = builder
230+ . merge_with ( & project_config)
231+ . build ( )
232+ . unwrap_or_else ( |_| config. clone ( ) ) ;
233+ }
234+ }
235+ }
236+ }
237+
189238 /// Get the current configuration
190239 pub async fn get_config ( & self ) -> terraphim_config:: Config {
191240 let config = self . config_state . config . lock ( ) . await ;
0 commit comments