kodegen_config/lib.rs
1//! # kodegen-config
2//!
3//! Centralized configuration path resolution for KODEGEN.ᴀɪ
4//!
5//! ## Features
6//!
7//! - **Cross-platform**: Windows, macOS, Unix/Linux support via XDG Base Directory spec
8//! - **Dual config support**: Git-local (`.kodegen/`) and user-global (`~/.config/kodegen/`)
9//! - **Per-file precedence**: Config files resolved by checking local first, then user
10//! - **Auto-initialization**: Creates directory structures on first use
11//! - **Rich error context**: All operations return `Result<T>` with detailed error messages
12//!
13//! ## Error Handling Pattern
14//!
15//! All path resolution functions return `Result<PathBuf>` for consistency:
16//!
17//! - [`user_config_dir()`](KodegenConfig::user_config_dir) - User-global config directory
18//! - [`local_config_dir()`](KodegenConfig::local_config_dir) - Git workspace-local config directory
19//! - [`state_dir()`](KodegenConfig::state_dir) - Runtime state directory
20//! - [`data_dir()`](KodegenConfig::data_dir) - Application data directory
21//! - [`resolve_toolset()`](KodegenConfig::resolve_toolset) - Resolve toolset file with precedence
22//! - [`resolve_config_file()`](KodegenConfig::resolve_config_file) - Resolve config file with precedence
23//!
24//! This uniform `Result` pattern provides:
25//! 1. **Consistency** - All similar operations use the same error handling pattern
26//! 2. **Rich error context** - Errors explain what failed and where the system searched
27//! 3. **Flexible handling** - Callers can propagate (`?`), unwrap, or convert to `Option` via `.ok()`
28//!
29//! ## Usage Examples
30//!
31//! ### Error Propagation (Recommended)
32//!
33//! ```rust
34//! use kodegen_config::KodegenConfig;
35//! use anyhow::Result;
36//!
37//! fn my_function() -> Result<()> {
38//! // Propagate errors with ?
39//! let user_config = KodegenConfig::user_config_dir()?;
40//! let local_config = KodegenConfig::local_config_dir()?;
41//! let toolset = KodegenConfig::resolve_toolset("core")?;
42//!
43//! println!("User config: {}", user_config.display());
44//! println!("Local config: {}", local_config.display());
45//! println!("Toolset: {}", toolset.display());
46//! Ok(())
47//! }
48//! ```
49//!
50//! ### Handling Missing Files as Non-Errors
51//!
52//! ```rust
53//! use kodegen_config::KodegenConfig;
54//! use anyhow::Result;
55//!
56//! fn try_load_local_config() -> Result<()> {
57//! // Convert Result to Option if you want to treat "not found" as non-error
58//! if let Ok(local_dir) = KodegenConfig::local_config_dir() {
59//! println!("In git repo, local config: {}", local_dir.display());
60//! } else {
61//! println!("Not in git repo, that's fine");
62//! }
63//!
64//! // Or use unwrap_or for fallback
65//! let config_dir = KodegenConfig::local_config_dir()
66//! .unwrap_or_else(|_| KodegenConfig::user_config_dir().unwrap());
67//!
68//! Ok(())
69//! }
70//! ```
71//!
72//! ### Inspecting Error Details
73//!
74//! ```rust
75//! use kodegen_config::KodegenConfig;
76//!
77//! match KodegenConfig::resolve_toolset("nonexistent") {
78//! Ok(path) => println!("Found: {}", path.display()),
79//! Err(e) => {
80//! // Error message includes all searched paths
81//! eprintln!("Error: {}", e);
82//! // Output: "Toolset 'nonexistent' not found. Searched:
83//! // /repo/.kodegen/toolset/nonexistent.json
84//! // /home/user/.config/kodegen/toolset/nonexistent.json"
85//! }
86//! }
87//! ```
88
89use anyhow::Result;
90use std::path::{Path, PathBuf};
91
92mod validation;
93mod git;
94mod init;
95mod platform;
96mod toolset;
97
98// ============================================================================
99// Infrastructure HTTP Headers
100// ============================================================================
101// These headers pass infrastructure context from kodegen stdio server to HTTP
102// backend servers. Used for CWD tracking, git root detection, and connection-
103// scoped resource isolation.
104
105/// Header containing the connection ID for this stdio connection instance.
106/// Used by backend servers for connection-scoped resource isolation (terminals, browsers, etc.)
107pub const X_KODEGEN_CONNECTION_ID: &str = "x-kodegen-connection-id";
108
109/// Header containing the current working directory from which kodegen was spawned.
110/// Used by backend servers for path resolution and as default CWD for operations.
111pub const X_KODEGEN_PWD: &str = "x-kodegen-pwd";
112
113/// Header containing the git repository root directory.
114/// Used for repository-aware operations and path resolution.
115pub const X_KODEGEN_GITROOT: &str = "x-kodegen-gitroot";
116
117/// Try to resolve a file within a directory with TOCTOU-resistant canonicalization
118///
119/// This function eliminates the TOCTOU race condition by avoiding explicit `.exists()`
120/// checks. Instead, it attempts to canonicalize the file path, which:
121/// 1. Implicitly checks existence (fails if file doesn't exist)
122/// 2. Resolves all symlinks (prevents symlink attacks)
123/// 3. Returns absolute, normalized path
124///
125/// After canonicalization, the function validates that the resolved path is within
126/// the expected directory to prevent symlink-based directory traversal attacks.
127///
128/// # Arguments
129///
130/// * `base_dir` - The base configuration directory (local or user)
131/// * `subdir` - Subdirectory within base (e.g., "toolset", "" for root)
132/// * `filename` - The filename to resolve
133///
134/// # Returns
135///
136/// * `Some(PathBuf)` - Canonical path if file exists and is within expected directory
137/// * `None` - If file doesn't exist, can't be accessed, or is outside expected directory
138///
139/// # Security
140///
141/// - **TOCTOU Mitigation**: No explicit existence check - canonicalize() implicitly validates
142/// - **Symlink Protection**: All symlinks are resolved and validated against base directory
143/// - **Path Traversal Prevention**: Canonical path must start with canonical base directory
144///
145/// # Notes
146///
147/// A small TOCTOU window still exists between this function returning the canonical path
148/// and the caller actually accessing the file. This is unavoidable given the API design
149/// that returns a path for later use. Callers should handle potential I/O errors gracefully.
150pub(crate) fn try_resolve_in_dir(base_dir: &Path, subdir: &str, filename: &str) -> Option<PathBuf> {
151 // Build the full path to search
152 let search_path = if subdir.is_empty() {
153 base_dir.join(filename)
154 } else {
155 base_dir.join(subdir).join(filename)
156 };
157
158 // Build the base directory for bounds checking
159 let base_path = if subdir.is_empty() {
160 base_dir.to_path_buf()
161 } else {
162 base_dir.join(subdir)
163 };
164
165 // Try to canonicalize the file path
166 // This fails if:
167 // - File doesn't exist
168 // - File is inaccessible (permissions)
169 // - Any parent directory doesn't exist
170 // This implicitly checks existence, eliminating the TOCTOU .exists() call
171 let canonical_file = search_path.canonicalize().ok()?;
172
173 // Try to canonicalize the base directory for secure comparison
174 // If this fails, the directory doesn't exist, so the file can't be valid
175 let canonical_base = base_path.canonicalize().ok()?;
176
177 // Verify the canonical file path is within the canonical base directory
178 // This prevents symlink attacks where the file is actually outside the expected directory
179 if canonical_file.starts_with(&canonical_base) {
180 Some(canonical_file)
181 } else {
182 // File resolved to a location outside the expected directory
183 // This indicates a symlink attack or path traversal attempt
184 None
185 }
186}
187
188/// Main configuration path resolver
189pub struct KodegenConfig;
190
191impl KodegenConfig {
192 /// Get user-global config directory (XDG-compliant)
193 ///
194 /// **Platform paths**:
195 /// - Unix/Linux: `$XDG_CONFIG_HOME/kodegen` (default: `~/.config/kodegen`)
196 /// - macOS: `~/Library/Application Support/kodegen`
197 /// - Windows: `%APPDATA%\kodegen`
198 ///
199 /// This ALWAYS returns the user-global config directory, never the local `.kodegen/`.
200 pub fn user_config_dir() -> Result<PathBuf> {
201 platform::user_config_dir()
202 }
203
204 /// Get git workspace-local config directory
205 ///
206 /// **Returns**: `${git_root}/.kodegen`
207 ///
208 /// This ONLY returns the local `.kodegen/` directory, never the user config.
209 ///
210 /// # Errors
211 ///
212 /// Returns an error if:
213 /// - Not in a git repository
214 /// - Current directory cannot be determined
215 /// - Git repository is invalid or corrupted
216 pub fn local_config_dir() -> Result<PathBuf> {
217 git::find_git_root().map(|root| root.join(".kodegen"))
218 }
219
220 /// Get state directory (for PIDs, sockets, runtime state)
221 ///
222 /// **Note**: Log files should use `log_dir()` instead.
223 ///
224 /// **Platform paths**:
225 /// - Unix/Linux: `$XDG_STATE_HOME/kodegen` (default: `~/.local/state/kodegen`)
226 /// - macOS: `~/Library/Application Support/kodegen/state`
227 /// - Windows: `%LOCALAPPDATA%\kodegen\state`
228 ///
229 /// State ALWAYS uses user-global directories (never local `.kodegen/`).
230 pub fn state_dir() -> Result<PathBuf> {
231 platform::state_dir()
232 }
233
234 /// Get log directory (for .log files only)
235 ///
236 /// **Platform paths**:
237 /// - Unix/Linux: `$XDG_STATE_HOME/kodegen/logs` (default: `~/.local/state/kodegen/logs`)
238 /// - macOS: `~/Library/Logs/kodegen`
239 /// - Windows: `%LOCALAPPDATA%\kodegen\logs`
240 ///
241 /// Logs ALWAYS use user-global directories (never local `.kodegen/`).
242 pub fn log_dir() -> Result<PathBuf> {
243 platform::log_dir()
244 }
245
246 /// Get data directory (for databases, stats, caches)
247 ///
248 /// **Platform paths**:
249 /// - Unix/Linux: `$XDG_DATA_HOME/kodegen` (default: `~/.local/share/kodegen`)
250 /// - macOS: `~/Library/Application Support/kodegen/data`
251 /// - Windows: `%LOCALAPPDATA%\kodegen\data`
252 ///
253 /// Data ALWAYS uses user-global directories (never local `.kodegen/`).
254 pub fn data_dir() -> Result<PathBuf> {
255 platform::data_dir()
256 }
257
258 /// Resolve toolset file path with local > user precedence
259 ///
260 /// **Search order**:
261 /// 1. `${git_root}/.kodegen/toolset/{name}.json`
262 /// 2. `$XDG_CONFIG_HOME/kodegen/toolset/{name}.json`
263 ///
264 /// # Errors
265 ///
266 /// Returns an error if the toolset file is not found in either location.
267 /// The error message includes all searched paths to aid debugging.
268 pub fn resolve_toolset(name: &str) -> Result<PathBuf> {
269 toolset::resolve(name)
270 }
271
272 /// Resolve config file path with local > user precedence
273 ///
274 /// **Search order**:
275 /// 1. `${git_root}/.kodegen/{filename}`
276 /// 2. `$XDG_CONFIG_HOME/kodegen/{filename}`
277 ///
278 /// # Errors
279 ///
280 /// Returns an error if the config file is not found in either location.
281 /// The error message includes all searched paths to aid debugging.
282 pub fn resolve_config_file(filename: &str) -> Result<PathBuf> {
283 let mut searched_paths = Vec::new();
284
285 // Check local first
286 if let Ok(local_dir) = Self::local_config_dir() {
287 let local_path = local_dir.join(filename);
288 searched_paths.push(local_path.display().to_string());
289 if let Some(path) = try_resolve_in_dir(&local_dir, "", filename) {
290 return Ok(path);
291 }
292 }
293
294 // Check user global
295 let user_dir = Self::user_config_dir()?;
296 let user_path = user_dir.join(filename);
297 searched_paths.push(user_path.display().to_string());
298 if let Some(path) = try_resolve_in_dir(&user_dir, "", filename) {
299 return Ok(path);
300 }
301
302 Err(anyhow::anyhow!(
303 "Config file '{}' not found. Searched:\n {}",
304 filename,
305 searched_paths.join("\n ")
306 ))
307 }
308
309 /// Initialize directory structures for both local and user config
310 ///
311 /// Creates:
312 /// - User config: `toolset/`, `claude/` subdirectories + `.gitignore`
313 /// - User state: `logs/` subdirectory
314 /// - User data: `stats/`, `memory/` subdirectories
315 /// - Local config (if in git repo): `toolset/`, `claude/` + adds to `.gitignore`
316 pub fn init_structure() -> Result<()> {
317 init::create_directory_structure()
318 }
319}