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;
97mod path_display;
98
99pub use path_display::shorten_path_for_display;
100
101// ============================================================================
102// Infrastructure HTTP Headers
103// ============================================================================
104// These headers pass infrastructure context from kodegen stdio server to HTTP
105// backend servers. Used for CWD tracking, git root detection, and connection-
106// scoped resource isolation.
107
108/// Header containing the connection ID for this stdio connection instance.
109/// Used by backend servers for connection-scoped resource isolation (terminals, browsers, etc.)
110pub const X_KODEGEN_CONNECTION_ID: &str = "x-kodegen-connection-id";
111
112/// Header containing the current working directory from which kodegen was spawned.
113/// Used by backend servers for path resolution and as default CWD for operations.
114pub const X_KODEGEN_PWD: &str = "x-kodegen-pwd";
115
116/// Header containing the git repository root directory.
117/// Used for repository-aware operations and path resolution.
118pub const X_KODEGEN_GITROOT: &str = "x-kodegen-gitroot";
119
120/// Try to resolve a file within a directory with TOCTOU-resistant canonicalization
121///
122/// This function eliminates the TOCTOU race condition by avoiding explicit `.exists()`
123/// checks. Instead, it attempts to canonicalize the file path, which:
124/// 1. Implicitly checks existence (fails if file doesn't exist)
125/// 2. Resolves all symlinks (prevents symlink attacks)
126/// 3. Returns absolute, normalized path
127///
128/// After canonicalization, the function validates that the resolved path is within
129/// the expected directory to prevent symlink-based directory traversal attacks.
130///
131/// # Arguments
132///
133/// * `base_dir` - The base configuration directory (local or user)
134/// * `subdir` - Subdirectory within base (e.g., "toolset", "" for root)
135/// * `filename` - The filename to resolve
136///
137/// # Returns
138///
139/// * `Some(PathBuf)` - Canonical path if file exists and is within expected directory
140/// * `None` - If file doesn't exist, can't be accessed, or is outside expected directory
141///
142/// # Security
143///
144/// - **TOCTOU Mitigation**: No explicit existence check - canonicalize() implicitly validates
145/// - **Symlink Protection**: All symlinks are resolved and validated against base directory
146/// - **Path Traversal Prevention**: Canonical path must start with canonical base directory
147///
148/// # Notes
149///
150/// A small TOCTOU window still exists between this function returning the canonical path
151/// and the caller actually accessing the file. This is unavoidable given the API design
152/// that returns a path for later use. Callers should handle potential I/O errors gracefully.
153pub(crate) fn try_resolve_in_dir(base_dir: &Path, subdir: &str, filename: &str) -> Option<PathBuf> {
154 // Build the full path to search
155 let search_path = if subdir.is_empty() {
156 base_dir.join(filename)
157 } else {
158 base_dir.join(subdir).join(filename)
159 };
160
161 // Build the base directory for bounds checking
162 let base_path = if subdir.is_empty() {
163 base_dir.to_path_buf()
164 } else {
165 base_dir.join(subdir)
166 };
167
168 // Try to canonicalize the file path
169 // This fails if:
170 // - File doesn't exist
171 // - File is inaccessible (permissions)
172 // - Any parent directory doesn't exist
173 // This implicitly checks existence, eliminating the TOCTOU .exists() call
174 let canonical_file = search_path.canonicalize().ok()?;
175
176 // Try to canonicalize the base directory for secure comparison
177 // If this fails, the directory doesn't exist, so the file can't be valid
178 let canonical_base = base_path.canonicalize().ok()?;
179
180 // Verify the canonical file path is within the canonical base directory
181 // This prevents symlink attacks where the file is actually outside the expected directory
182 if canonical_file.starts_with(&canonical_base) {
183 Some(canonical_file)
184 } else {
185 // File resolved to a location outside the expected directory
186 // This indicates a symlink attack or path traversal attempt
187 None
188 }
189}
190
191/// Main configuration path resolver
192pub struct KodegenConfig;
193
194impl KodegenConfig {
195 /// Get user-global config directory (XDG-compliant)
196 ///
197 /// **Platform paths**:
198 /// - Unix/Linux: `$XDG_CONFIG_HOME/kodegen` (default: `~/.config/kodegen`)
199 /// - macOS: `~/Library/Application Support/kodegen`
200 /// - Windows: `%APPDATA%\kodegen`
201 ///
202 /// This ALWAYS returns the user-global config directory, never the local `.kodegen/`.
203 pub fn user_config_dir() -> Result<PathBuf> {
204 platform::user_config_dir()
205 }
206
207 /// Get git workspace-local config directory
208 ///
209 /// **Returns**: `${git_root}/.kodegen`
210 ///
211 /// This ONLY returns the local `.kodegen/` directory, never the user config.
212 ///
213 /// # Errors
214 ///
215 /// Returns an error if:
216 /// - Not in a git repository
217 /// - Current directory cannot be determined
218 /// - Git repository is invalid or corrupted
219 pub fn local_config_dir() -> Result<PathBuf> {
220 git::find_git_root().map(|root| root.join(".kodegen"))
221 }
222
223 /// Get state directory (for PIDs, sockets, runtime state)
224 ///
225 /// **Note**: Log files should use `log_dir()` instead.
226 ///
227 /// **Platform paths**:
228 /// - Unix/Linux: `$XDG_STATE_HOME/kodegen` (default: `~/.local/state/kodegen`)
229 /// - macOS: `~/Library/Application Support/kodegen/state`
230 /// - Windows: `%LOCALAPPDATA%\kodegen\state`
231 ///
232 /// State ALWAYS uses user-global directories (never local `.kodegen/`).
233 pub fn state_dir() -> Result<PathBuf> {
234 platform::state_dir()
235 }
236
237 /// Get log directory (for .log files only)
238 ///
239 /// **Platform paths**:
240 /// - Unix/Linux: `$XDG_STATE_HOME/kodegen/logs` (default: `~/.local/state/kodegen/logs`)
241 /// - macOS: `~/Library/Logs/kodegen`
242 /// - Windows: `%LOCALAPPDATA%\kodegen\logs`
243 ///
244 /// Logs ALWAYS use user-global directories (never local `.kodegen/`).
245 pub fn log_dir() -> Result<PathBuf> {
246 platform::log_dir()
247 }
248
249 /// Get data directory (for databases, stats, caches)
250 ///
251 /// **Platform paths**:
252 /// - Unix/Linux: `$XDG_DATA_HOME/kodegen` (default: `~/.local/share/kodegen`)
253 /// - macOS: `~/Library/Application Support/kodegen/data`
254 /// - Windows: `%LOCALAPPDATA%\kodegen\data`
255 ///
256 /// Data ALWAYS uses user-global directories (never local `.kodegen/`).
257 pub fn data_dir() -> Result<PathBuf> {
258 platform::data_dir()
259 }
260
261 /// Resolve toolset file path with local > user precedence
262 ///
263 /// **Search order**:
264 /// 1. `${git_root}/.kodegen/toolset/{name}.json`
265 /// 2. `$XDG_CONFIG_HOME/kodegen/toolset/{name}.json`
266 ///
267 /// # Errors
268 ///
269 /// Returns an error if the toolset file is not found in either location.
270 /// The error message includes all searched paths to aid debugging.
271 pub fn resolve_toolset(name: &str) -> Result<PathBuf> {
272 toolset::resolve(name)
273 }
274
275 /// Resolve config file path with local > user precedence
276 ///
277 /// **Search order**:
278 /// 1. `${git_root}/.kodegen/{filename}`
279 /// 2. `$XDG_CONFIG_HOME/kodegen/{filename}`
280 ///
281 /// # Errors
282 ///
283 /// Returns an error if the config file is not found in either location.
284 /// The error message includes all searched paths to aid debugging.
285 pub fn resolve_config_file(filename: &str) -> Result<PathBuf> {
286 let mut searched_paths = Vec::new();
287
288 // Check local first
289 if let Ok(local_dir) = Self::local_config_dir() {
290 let local_path = local_dir.join(filename);
291 searched_paths.push(local_path.display().to_string());
292 if let Some(path) = try_resolve_in_dir(&local_dir, "", filename) {
293 return Ok(path);
294 }
295 }
296
297 // Check user global
298 let user_dir = Self::user_config_dir()?;
299 let user_path = user_dir.join(filename);
300 searched_paths.push(user_path.display().to_string());
301 if let Some(path) = try_resolve_in_dir(&user_dir, "", filename) {
302 return Ok(path);
303 }
304
305 Err(anyhow::anyhow!(
306 "Config file '{}' not found. Searched:\n {}",
307 filename,
308 searched_paths.join("\n ")
309 ))
310 }
311
312 /// Initialize directory structures for both local and user config
313 ///
314 /// Creates:
315 /// - User config: `toolset/`, `claude/` subdirectories + `.gitignore`
316 /// - User state: `logs/` subdirectory
317 /// - User data: `stats/`, `memory/` subdirectories
318 /// - Local config (if in git repo): `toolset/`, `claude/` + adds to `.gitignore`
319 pub fn init_structure() -> Result<()> {
320 init::create_directory_structure()
321 }
322}