|
| 1 | +# Implementation Plan: Local .terraphim/ Config as First-Priority Source |
| 2 | + |
| 3 | +**Status**: Draft |
| 4 | +**Research Doc**: `.docs/research-1862-local-terraphim-config.md` |
| 5 | +**Author**: opencode (GLM-5.1) |
| 6 | +**Date**: 2026-05-25 |
| 7 | +**Issue**: #1862 |
| 8 | +**Estimated Effort**: 4-6 hours |
| 9 | + |
| 10 | +## Overview |
| 11 | + |
| 12 | +### Summary |
| 13 | +Extend `ProjectConfig` to auto-discover individual `role-*.json` and `thesaurus-*.json` files from `.terraphim/`, then wire this discovery into all three CLI tools. |
| 14 | + |
| 15 | +### Approach |
| 16 | +Extend the existing `project::discover()` + `ProjectConfig` pattern rather than creating new infrastructure. Add filesystem scanning for role/thesaurus files, then consume in each tool. |
| 17 | + |
| 18 | +### Scope |
| 19 | +**In Scope:** |
| 20 | +- Extend `ProjectConfig` with `load_from_dir()` to scan `role-*.json` files |
| 21 | +- Add `discover_thesaurus()` to find `thesaurus-*.json` by role name |
| 22 | +- Wire discovery into `terraphim_grep`, `terraphim_agent`, `terraphim_mcp_server` |
| 23 | +- Tests for new discovery functions |
| 24 | + |
| 25 | +**Out of Scope:** |
| 26 | +- TOML config support |
| 27 | +- Config file watching |
| 28 | +- `terraphim_server` changes |
| 29 | +- Global `~/.terraphim/` config merge |
| 30 | + |
| 31 | +**Avoid At All Cost:** |
| 32 | +- Adding new config file formats or manifest files |
| 33 | +- Changing existing CLI flag behaviour (flags must still work) |
| 34 | +- Modifying DeviceSettings persistence |
| 35 | +- Creating new crates |
| 36 | + |
| 37 | +## Architecture |
| 38 | + |
| 39 | +### Component Diagram |
| 40 | +``` |
| 41 | +.terraphim/ terraphim_config::project |
| 42 | + role-*.json ----discover---> ProjectConfig::load_from_dir() |
| 43 | + thesaurus-*.json | |
| 44 | + kg/ +---> terraphim_grep (thesaurus + role + kg) |
| 45 | + +---> terraphim_agent (role merge) |
| 46 | + +---> terraphim_mcp_server (full config) |
| 47 | +``` |
| 48 | + |
| 49 | +### Data Flow |
| 50 | +``` |
| 51 | +CLI invocation |
| 52 | + -> project::discover(None) -- walk up from CWD |
| 53 | + -> found .terraphim/ |
| 54 | + -> ProjectConfig::load_from_dir(path) -- scan role-*.json, thesaurus-*.json |
| 55 | + -> merge with CLI flag overrides |
| 56 | + -> run tool with merged config |
| 57 | +``` |
| 58 | + |
| 59 | +### Key Design Decisions |
| 60 | + |
| 61 | +| Decision | Rationale | Alternatives Rejected | |
| 62 | +|----------|-----------|----------------------| |
| 63 | +| Scan filesystem for role-*.json | Simple, no manifest needed | Require config.json with role list | |
| 64 | +| Role name from filename | `role-devops.json` -> "devops" role | Embed role name in JSON content | |
| 65 | +| Thesaurus matched by role name | `thesaurus-devops.json` matches "devops" | Single thesaurus for all roles | |
| 66 | +| CLI flags override project config | Escape hatch for developers | Project config always wins | |
| 67 | + |
| 68 | +### Simplicity Check |
| 69 | + |
| 70 | +**What if this could be easy?** It IS easy. We just need: |
| 71 | +1. A function that lists `role-*.json` files and parses them |
| 72 | +2. A function that finds `thesaurus-<role>.json` for a given role |
| 73 | +3. Calling these functions in main.rs of each tool |
| 74 | + |
| 75 | +No new types, no new crates, no new formats. Just extending what exists. |
| 76 | + |
| 77 | +## File Changes |
| 78 | + |
| 79 | +### Modified Files |
| 80 | +| File | Changes | |
| 81 | +|------|---------| |
| 82 | +| `crates/terraphim_config/src/project.rs` | Add `load_from_dir()`, `discover_thesaurus_for_role()`, `discover_kg_path()` | |
| 83 | +| `crates/terraphim_grep/src/main.rs` | Add `.terraphim/` discovery to `find_default_thesaurus()`, role config, kg path | |
| 84 | +| `crates/terraphim_agent/src/service.rs` | Extend `merge_project_config()` to load individual role files | |
| 85 | +| `crates/terraphim_mcp_server/src/main.rs` | Add project config discovery before hardcoded profile | |
| 86 | + |
| 87 | +### No New Files Needed |
| 88 | + |
| 89 | +## API Design |
| 90 | + |
| 91 | +### New Public Functions in `project.rs` |
| 92 | + |
| 93 | +```rust |
| 94 | +/// Load a ProjectConfig by scanning .terraphim/ for role-*.json files. |
| 95 | +/// |
| 96 | +/// If config.json exists, loads it first (backward compat). |
| 97 | +/// Then scans for role-*.json files and merges them in. |
| 98 | +/// Role name is derived from filename: role-devops.json -> "devops" |
| 99 | +pub fn load_from_dir(dir: &Path) -> Result<ProjectConfig, ProjectDiscoveryError> |
| 100 | + |
| 101 | +/// Find the thesaurus file for a given role in .terraphim/ |
| 102 | +/// |
| 103 | +/// Looks for .terraphim/thesaurus-<role>.json |
| 104 | +/// Returns None if not found |
| 105 | +pub fn discover_thesaurus(dir: &Path, role_name: &str) -> Option<PathBuf> |
| 106 | + |
| 107 | +/// Find the KG directory within .terraphim/ |
| 108 | +/// |
| 109 | +/// Looks for .terraphim/kg/ |
| 110 | +/// Returns None if not found |
| 111 | +pub fn discover_kg_path(dir: &Path) -> Option<PathBuf> |
| 112 | +``` |
| 113 | + |
| 114 | +### No New Types Needed |
| 115 | +`ProjectConfig` already has `roles: HashMap<String, Role>` -- we just populate it from individual files. |
| 116 | + |
| 117 | +## Test Strategy |
| 118 | + |
| 119 | +### Unit Tests (in project.rs) |
| 120 | +| Test | Purpose | |
| 121 | +|------|---------| |
| 122 | +| `test_load_from_dir_reads_role_files` | Scans role-devops.json, role-rust-engineer.json | |
| 123 | +| `test_load_from_dir_merges_with_config_json` | config.json + role-*.json both present | |
| 124 | +| `test_load_from_dir_empty_is_ok` | No role files -> empty config | |
| 125 | +| `test_discover_thesaurus_found` | Returns thesaurus-devops.json for "devops" | |
| 126 | +| `test_discover_thesaurus_not_found` | Returns None for unknown role | |
| 127 | +| `test_discover_kg_path_found` | Returns .terraphim/kg/ | |
| 128 | +| `test_discover_kg_path_not_found` | Returns None | |
| 129 | + |
| 130 | +### Integration Tests |
| 131 | +| Test | Location | Purpose | |
| 132 | +|------|----------|---------| |
| 133 | +| `test_grep_uses_project_thesaurus` | terraphim_grep tests | End-to-end: grep finds thesaurus from .terraphim/ | |
| 134 | +| `test_agent_merges_project_roles` | terraphim_agent tests | Agent loads role-*.json from project | |
| 135 | + |
| 136 | +## Implementation Steps |
| 137 | + |
| 138 | +### Step 1: Extend ProjectConfig (project.rs) |
| 139 | +**Files:** `crates/terraphim_config/src/project.rs` |
| 140 | +**Description:** Add `load_from_dir()`, `discover_thesaurus()`, `discover_kg_path()` |
| 141 | +**Tests:** 7 unit tests above |
| 142 | +**Estimated:** 1.5 hours |
| 143 | + |
| 144 | +Key implementation: |
| 145 | +```rust |
| 146 | +pub fn load_from_dir(dir: &Path) -> Result<Self, ProjectDiscoveryError> { |
| 147 | + let mut config = Self { |
| 148 | + global_shortcut: None, |
| 149 | + roles: std::collections::HashMap::new(), |
| 150 | + }; |
| 151 | + // Backward compat: load config.json if present |
| 152 | + let config_json = dir.join("config.json"); |
| 153 | + if config_json.is_file() { |
| 154 | + config = Self::from_file(&config_json)?; |
| 155 | + } |
| 156 | + // Scan for role-*.json files |
| 157 | + for entry in std::fs::read_dir(dir)? { |
| 158 | + let entry = entry?; |
| 159 | + let name = entry.file_name().to_string_lossy().to_string(); |
| 160 | + if name.starts_with("role-") && name.ends_with(".json") { |
| 161 | + let role_name = name.trim_start_matches("role-") |
| 162 | + .trim_end_matches(".json") |
| 163 | + .to_string(); |
| 164 | + let role: Role = serde_json::from_str(&std::fs::read_to_string(entry.path())?)?; |
| 165 | + config.roles.insert(role_name, role); |
| 166 | + } |
| 167 | + } |
| 168 | + Ok(config) |
| 169 | +} |
| 170 | +``` |
| 171 | + |
| 172 | +### Step 2: Wire terraphim_grep (main.rs) |
| 173 | +**Files:** `crates/terraphim_grep/src/main.rs` |
| 174 | +**Description:** Extend `find_default_thesaurus()` to search `.terraphim/`, add role/kg discovery |
| 175 | +**Tests:** Existing tests still pass; manual verification |
| 176 | +**Dependencies:** Step 1 |
| 177 | +**Estimated:** 1 hour |
| 178 | + |
| 179 | +Changes to `find_default_thesaurus()`: |
| 180 | +```rust |
| 181 | +fn find_default_thesaurus() -> Option<PathBuf> { |
| 182 | + // Priority 1: .terraphim/ project config |
| 183 | + if let Ok(Some(dir)) = terraphim_config::project::discover(None) { |
| 184 | + // Use role name to find matching thesaurus |
| 185 | + if let Some(path) = terraphim_config::project::discover_thesaurus(&dir, &role_name) { |
| 186 | + return Some(path); |
| 187 | + } |
| 188 | + } |
| 189 | + // Priority 2: filesystem heuristic (existing code) |
| 190 | + // ... |
| 191 | +} |
| 192 | +``` |
| 193 | + |
| 194 | +Also add project discovery for `--role-config` and `--kg-path` defaults. |
| 195 | + |
| 196 | +### Step 3: Wire terraphim_agent (service.rs) |
| 197 | +**Files:** `crates/terraphim_agent/src/service.rs` |
| 198 | +**Description:** Extend `merge_project_config()` to use `ProjectConfig::load_from_dir()` |
| 199 | +**Tests:** Existing tests still pass |
| 200 | +**Dependencies:** Step 1 |
| 201 | +**Estimated:** 0.5 hours |
| 202 | + |
| 203 | +Change `merge_project_config()`: |
| 204 | +```rust |
| 205 | +fn merge_project_config(config: &mut Config) { |
| 206 | + if let Ok(Some(path)) = terraphim_config::project::discover(None) { |
| 207 | + // Try load_from_dir first (scans role-*.json) |
| 208 | + let project_config = terraphim_config::project::ProjectConfig::load_from_dir(&path) |
| 209 | + .unwrap_or_else(|_| { |
| 210 | + // Fallback to config.json only |
| 211 | + let config_path = path.join("config.json"); |
| 212 | + if config_path.is_file() { |
| 213 | + terraphim_config::project::ProjectConfig::from_file(&config_path) |
| 214 | + .unwrap_or_default() |
| 215 | + } else { |
| 216 | + Default::default() |
| 217 | + } |
| 218 | + }); |
| 219 | + if !project_config.roles.is_empty() { |
| 220 | + log::info!("Merging {} project roles from '{}'", |
| 221 | + project_config.roles.len(), path.display()); |
| 222 | + let builder = ConfigBuilder::from_config( |
| 223 | + config.clone(), DeviceSettings::new(), PathBuf::new(), |
| 224 | + ); |
| 225 | + *config = builder.merge_with(&project_config) |
| 226 | + .build().unwrap_or_else(|_| config.clone()); |
| 227 | + } |
| 228 | + } |
| 229 | +} |
| 230 | +``` |
| 231 | + |
| 232 | +### Step 4: Wire terraphim_mcp_server (main.rs) |
| 233 | +**Files:** `crates/terraphim_mcp_server/src/main.rs` |
| 234 | +**Description:** Add project config discovery before hardcoded profiles |
| 235 | +**Tests:** Existing tests still pass |
| 236 | +**Dependencies:** Step 1 |
| 237 | +**Estimated:** 0.5 hours |
| 238 | + |
| 239 | +```rust |
| 240 | +// Before profile selection, try project config |
| 241 | +if let Ok(Some(project_dir)) = terraphim_config::project::discover(None) { |
| 242 | + if let Ok(project_config) = ProjectConfig::load_from_dir(&project_dir) { |
| 243 | + if !project_config.roles.is_empty() { |
| 244 | + // Build Config from project roles instead of hardcoded profile |
| 245 | + // ... |
| 246 | + } |
| 247 | + } |
| 248 | +} |
| 249 | +// Fall back to --profile flag |
| 250 | +``` |
| 251 | + |
| 252 | +### Step 5: Build, Test, Clippy |
| 253 | +**Estimated:** 0.5 hours |
| 254 | + |
| 255 | +## Rollback Plan |
| 256 | +Each step is additive. If any step breaks, revert the single commit for that step. No database migrations, no breaking changes. |
| 257 | + |
| 258 | +## Open Items |
| 259 | +None -- research phase resolved all questions. |
0 commit comments