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}