Skip to content

Commit 26f5b87

Browse files
author
Alex
committed
feat(config): implement project-level config discovery for .terraphim/
- Add project.rs with discover() function that walks up from cwd to find .terraphim/ - Change global_shortcut from String to Option<String> in Config - Add merge_with() and with_project() methods to ConfigBuilder - Update TuiService::new to accept no_project_config parameter - Add merge_project_config() helper that discovers and merges project config - Update all call sites and tests to use new signatures Refs #1674
1 parent 0b1ebf9 commit 26f5b87

11 files changed

Lines changed: 307 additions & 39 deletions

File tree

crates/terraphim_agent/src/main.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2049,7 +2049,7 @@ async fn run_offline_command(
20492049
return run_learn_command(sub).await;
20502050
}
20512051

2052-
let service = TuiService::new(config_path).await?;
2052+
let service = TuiService::new(config_path, false).await?;
20532053

20542054
match command {
20552055
Command::Search {
@@ -5087,7 +5087,7 @@ fn ui_loop(
50875087

50885088
#[cfg(not(feature = "server"))]
50895089
let backend = {
5090-
let service = rt.block_on(async { TuiService::new(None).await })?;
5090+
let service = rt.block_on(async { TuiService::new(None, false).await })?;
50915091
crate::tui_backend::TuiBackend::Local(service)
50925092
};
50935093

crates/terraphim_agent/src/service.rs

Lines changed: 58 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use anyhow::Result;
2+
use std::path::PathBuf;
23
use std::sync::Arc;
34
use terraphim_config::{Config, ConfigBuilder, ConfigId, ConfigState};
45
use 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;

crates/terraphim_agent/tests/tui_service_tests.rs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ use terraphim_types::RoleName;
1212
/// Test that TuiService can be created and basic methods work
1313
#[tokio::test]
1414
async fn test_tui_service_creation() -> Result<()> {
15-
let service = TuiService::new_with_embedded_defaults().await?;
15+
let service = TuiService::new_with_embedded_defaults(true).await?;
1616

1717
// Get the current config
1818
let config = service.get_config().await;
@@ -63,7 +63,7 @@ async fn test_tui_service_new_uses_host_settings_path() -> Result<()> {
6363
/// Test the search method with default role
6464
#[tokio::test]
6565
async fn test_tui_service_search() -> Result<()> {
66-
let service = TuiService::new_with_embedded_defaults().await?;
66+
let service = TuiService::new_with_embedded_defaults(true).await?;
6767

6868
// Search with the selected role
6969
let selected_role = service.get_selected_role().await;
@@ -88,7 +88,7 @@ async fn test_tui_service_search() -> Result<()> {
8888
/// Test autocomplete method
8989
#[tokio::test]
9090
async fn test_tui_service_autocomplete() -> Result<()> {
91-
let service = TuiService::new_with_embedded_defaults().await?;
91+
let service = TuiService::new_with_embedded_defaults(true).await?;
9292
let role_name = service.get_selected_role().await;
9393

9494
// Autocomplete may fail if no thesaurus is loaded, which is expected
@@ -113,7 +113,7 @@ async fn test_tui_service_autocomplete() -> Result<()> {
113113
/// Test replace_matches method
114114
#[tokio::test]
115115
async fn test_tui_service_replace_matches() -> Result<()> {
116-
let service = TuiService::new_with_embedded_defaults().await?;
116+
let service = TuiService::new_with_embedded_defaults(true).await?;
117117
let role_name = service.get_selected_role().await;
118118

119119
let text = "This is a test with some terms to replace.";
@@ -141,7 +141,7 @@ async fn test_tui_service_replace_matches() -> Result<()> {
141141
/// Test summarize method
142142
#[tokio::test]
143143
async fn test_tui_service_summarize() -> Result<()> {
144-
let service = TuiService::new_with_embedded_defaults().await?;
144+
let service = TuiService::new_with_embedded_defaults(true).await?;
145145
let role_name = service.get_selected_role().await;
146146

147147
let content = "This is a test paragraph that needs to be summarized. It contains multiple sentences with various topics and information that should be condensed.";
@@ -171,7 +171,7 @@ async fn test_tui_service_summarize() -> Result<()> {
171171
/// Test list roles with info
172172
#[tokio::test]
173173
async fn test_tui_service_list_roles_with_info() -> Result<()> {
174-
let service = TuiService::new_with_embedded_defaults().await?;
174+
let service = TuiService::new_with_embedded_defaults(true).await?;
175175

176176
let roles = service.list_roles_with_info().await;
177177

@@ -186,7 +186,7 @@ async fn test_tui_service_list_roles_with_info() -> Result<()> {
186186
/// Test find_matches method
187187
#[tokio::test]
188188
async fn test_tui_service_find_matches() -> Result<()> {
189-
let service = TuiService::new_with_embedded_defaults().await?;
189+
let service = TuiService::new_with_embedded_defaults(true).await?;
190190
let role_name = service.get_selected_role().await;
191191

192192
let text = "This is a test paragraph with some terms to match.";
@@ -212,7 +212,7 @@ async fn test_tui_service_find_matches() -> Result<()> {
212212
/// Test that role discovery works with shortnames and case-insensitive lookups
213213
#[tokio::test]
214214
async fn test_tui_service_find_role_by_shortname() -> Result<()> {
215-
let service = TuiService::new_with_embedded_defaults().await?;
215+
let service = TuiService::new_with_embedded_defaults(true).await?;
216216
let roles = service.list_roles_with_info().await;
217217

218218
let (role_name, shortname) = roles
@@ -241,7 +241,7 @@ async fn test_tui_service_find_role_by_shortname() -> Result<()> {
241241
/// Test that updating the selected role persists across service queries
242242
#[tokio::test]
243243
async fn test_tui_service_update_selected_role() -> Result<()> {
244-
let service = TuiService::new_with_embedded_defaults().await?;
244+
let service = TuiService::new_with_embedded_defaults(true).await?;
245245
let current_role = service.get_selected_role().await;
246246

247247
let new_role = service

crates/terraphim_agent/tests/unit_test.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,10 @@ fn test_config_response_deserialization() {
121121
let config_response = response.unwrap();
122122
assert_eq!(config_response.status, "success");
123123
assert_eq!(config_response.config.selected_role.to_string(), "Default");
124-
assert_eq!(config_response.config.global_shortcut, "Ctrl+Space");
124+
assert_eq!(
125+
config_response.config.global_shortcut,
126+
Some("Ctrl+Space".to_string())
127+
);
125128
assert!(
126129
config_response
127130
.config

crates/terraphim_config/src/lib.rs

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ use crate::llm_router::LlmRouterConfig;
4646
// LLM Router configuration
4747
pub mod llm_router;
4848

49+
// Project-level configuration discovery
50+
pub mod project;
51+
4952
/// Convenience alias for `Result<T, TerraphimConfigError>` used throughout this crate.
5053
pub type Result<T> = std::result::Result<T, TerraphimConfigError>;
5154

@@ -846,7 +849,37 @@ impl ConfigBuilder {
846849

847850
/// Set the global shortcut for the config
848851
pub fn global_shortcut(mut self, global_shortcut: &str) -> Self {
849-
self.config.global_shortcut = global_shortcut.to_string();
852+
self.config.global_shortcut = Some(global_shortcut.to_string());
853+
self
854+
}
855+
856+
/// Merge with a project config.
857+
///
858+
/// Project roles fully replace global roles (by RoleName), not deep-merge.
859+
/// Project global_shortcut, if present, overrides the global one.
860+
pub fn merge_with(mut self, project_config: &crate::project::ProjectConfig) -> Self {
861+
if let Some(ref shortcut) = project_config.global_shortcut {
862+
self.config.global_shortcut = Some(shortcut.clone());
863+
}
864+
for (name, role) in &project_config.roles {
865+
let role_name = RoleName::new(name);
866+
self.config.roles.insert(role_name, role.clone());
867+
}
868+
self
869+
}
870+
871+
/// Apply project config discovery and merge if found.
872+
///
873+
/// Returns self unchanged if no project config is found.
874+
pub fn with_project(self) -> Self {
875+
if let Ok(Some(path)) = crate::project::discover(None) {
876+
let config_path = path.join("config.json");
877+
if config_path.is_file() {
878+
if let Ok(project_config) = crate::project::ProjectConfig::from_file(&config_path) {
879+
return self.merge_with(&project_config);
880+
}
881+
}
882+
}
850883
self
851884
}
852885

@@ -915,7 +948,8 @@ pub struct Config {
915948
/// Identifier for the config
916949
pub id: ConfigId,
917950
/// Global shortcut for activating terraphim desktop
918-
pub global_shortcut: String,
951+
#[schemars(default)]
952+
pub global_shortcut: Option<String>,
919953
/// User roles with their respective settings
920954
#[schemars(skip)]
921955
pub roles: AHashMap<RoleName, Role>,
@@ -928,7 +962,7 @@ impl Config {
928962
fn empty() -> Self {
929963
Self {
930964
id: ConfigId::Server, // Default to Server
931-
global_shortcut: "Ctrl+X".to_string(),
965+
global_shortcut: None,
932966
roles: AHashMap::new(),
933967
default_role: RoleName::new("Default"),
934968
selected_role: RoleName::new("Default"),
@@ -1516,15 +1550,15 @@ mod tests {
15161550
.add_role("dummy", dummy_role())
15171551
.build()
15181552
.unwrap();
1519-
assert_eq!(config.global_shortcut, "Ctrl+X");
1553+
assert_eq!(config.global_shortcut, None);
15201554
let device_settings = DeviceSettings::new();
15211555
let settings_path = PathBuf::from(".");
15221556
let new_config = ConfigBuilder::from_config(config, device_settings, settings_path)
15231557
.global_shortcut("Ctrl+/")
15241558
.build()
15251559
.unwrap();
15261560

1527-
assert_eq!(new_config.global_shortcut, "Ctrl+/");
1561+
assert_eq!(new_config.global_shortcut, Some("Ctrl+/".to_string()));
15281562
}
15291563

15301564
fn dummy_role() -> Role {

0 commit comments

Comments
 (0)