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}