Skip to main content

debtmap/config/
loader.rs

1use std::fs;
2use std::io::{BufReader, Read};
3use std::path::{Path, PathBuf};
4
5use super::core::DebtmapConfig;
6use super::scoring::ScoringWeights;
7use super::validation::validate_config;
8use crate::effects::{run_validation, validation_failure, validation_success, AnalysisValidation};
9use crate::errors::AnalysisError;
10
11/// Load configuration from .debtmap.toml if it exists
12/// Pure function to read and parse config file contents
13pub(crate) fn read_config_file(path: &Path) -> Result<String, std::io::Error> {
14    let file = fs::File::open(path)?;
15    let mut reader = BufReader::new(file);
16    let mut contents = String::new();
17    reader.read_to_string(&mut contents)?;
18    Ok(contents)
19}
20
21/// Pure function to parse and validate config from TOML string
22pub fn parse_and_validate_config(contents: &str) -> Result<DebtmapConfig, String> {
23    parse_and_validate_config_impl(contents)
24}
25
26pub(crate) fn parse_and_validate_config_impl(contents: &str) -> Result<DebtmapConfig, String> {
27    let mut config = toml::from_str::<DebtmapConfig>(contents)
28        .map_err(|e| format!("Failed to parse .debtmap.toml: {}", e))?;
29
30    // Validate and normalize scoring weights if present
31    if let Some(ref mut scoring) = config.scoring {
32        if let Err(e) = scoring.validate() {
33            eprintln!("Warning: Invalid scoring weights: {}. Using defaults.", e);
34            config.scoring = Some(ScoringWeights::default());
35        } else {
36            scoring.normalize(); // Ensure exact sum of 1.0
37        }
38    }
39
40    Ok(config)
41}
42
43/// Pure function to try loading config from a specific path
44pub(crate) fn try_load_config_from_path(config_path: &Path) -> Option<DebtmapConfig> {
45    let contents = match read_config_file(config_path) {
46        Ok(contents) => contents,
47        Err(e) => {
48            handle_read_error(config_path, &e);
49            return None;
50        }
51    };
52
53    match parse_and_validate_config_impl(&contents) {
54        Ok(config) => {
55            log::debug!("Loaded config from {}", config_path.display());
56            Some(config)
57        }
58        Err(e) => {
59            eprintln!("Warning: {}. Using defaults.", e);
60            None
61        }
62    }
63}
64
65/// Handle file read errors with appropriate logging
66pub(crate) fn handle_read_error(config_path: &Path, error: &std::io::Error) {
67    // Only log actual errors, not "file not found"
68    if error.kind() != std::io::ErrorKind::NotFound {
69        log::warn!(
70            "Failed to read config file {}: {}",
71            config_path.display(),
72            error
73        );
74    }
75}
76
77/// Pure function to generate directory ancestors up to a depth limit
78pub fn directory_ancestors(start: PathBuf, max_depth: usize) -> impl Iterator<Item = PathBuf> {
79    directory_ancestors_impl(start, max_depth)
80}
81
82pub(crate) fn directory_ancestors_impl(
83    start: PathBuf,
84    max_depth: usize,
85) -> impl Iterator<Item = PathBuf> {
86    std::iter::successors(Some(start), |dir| {
87        let mut parent = dir.clone();
88        if parent.pop() {
89            Some(parent)
90        } else {
91            None
92        }
93    })
94    .take(max_depth)
95}
96
97pub fn load_config() -> DebtmapConfig {
98    const MAX_TRAVERSAL_DEPTH: usize = 10;
99
100    // Get current directory or return default
101    let current = match std::env::current_dir() {
102        Ok(dir) => dir,
103        Err(e) => {
104            log::warn!(
105                "Failed to get current directory: {}. Using default config.",
106                e
107            );
108            return DebtmapConfig::default();
109        }
110    };
111
112    // Search for config file in directory hierarchy
113    directory_ancestors_impl(current, MAX_TRAVERSAL_DEPTH)
114        .map(|dir| dir.join(".debtmap.toml"))
115        .find_map(|path| try_load_config_from_path(&path))
116        .unwrap_or_else(|| {
117            log::debug!(
118                "No config found after checking {} directories. Using default config.",
119                MAX_TRAVERSAL_DEPTH
120            );
121            DebtmapConfig::default()
122        })
123}
124
125// ============================================================================
126// Validation-based config loading (Spec 197)
127// ============================================================================
128
129/// Load and validate config using error accumulation.
130///
131/// This function reads the config file and validates it, accumulating
132/// ALL errors instead of failing at the first one.
133///
134/// # Example
135///
136/// ```rust,ignore
137/// use debtmap::config::loader::load_config_validated;
138/// use std::path::Path;
139///
140/// let validation = load_config_validated(Path::new("project/"));
141///
142/// // Check all errors at once
143/// match validation {
144///     stillwater::Validation::Success(config) => {
145///         // Use config
146///     }
147///     stillwater::Validation::Failure(errors) => {
148///         // Show ALL errors to user
149///         for error in errors {
150///             eprintln!("  - {}", error);
151///         }
152///     }
153/// }
154/// ```
155pub fn load_config_validated(start_dir: &Path) -> AnalysisValidation<DebtmapConfig> {
156    const MAX_TRAVERSAL_DEPTH: usize = 10;
157
158    // Find config file
159    let config_path = directory_ancestors_impl(start_dir.to_path_buf(), MAX_TRAVERSAL_DEPTH)
160        .map(|dir| dir.join(".debtmap.toml"))
161        .find(|path| path.exists());
162
163    let Some(config_path) = config_path else {
164        // No config file found - return default (this is not an error)
165        return validation_success(DebtmapConfig::default());
166    };
167
168    // Read config file
169    let contents = match read_config_file(&config_path) {
170        Ok(contents) => contents,
171        Err(e) => {
172            return validation_failure(AnalysisError::io_with_path(
173                format!("Cannot read config file: {}", e),
174                &config_path,
175            ));
176        }
177    };
178
179    // Parse config
180    let config = match toml::from_str::<DebtmapConfig>(&contents) {
181        Ok(config) => config,
182        Err(e) => {
183            return validation_failure(AnalysisError::config_with_path(
184                format!("Failed to parse config: {}", e),
185                &config_path,
186            ));
187        }
188    };
189
190    // Validate config (accumulates ALL validation errors)
191    match validate_config(&config) {
192        stillwater::Validation::Success(_) => validation_success(config),
193        stillwater::Validation::Failure(errors) => stillwater::Validation::Failure(errors),
194    }
195}
196
197/// Load and validate config with backwards-compatible Result API.
198///
199/// This wraps `load_config_validated` to return `anyhow::Result` for use
200/// with existing code that expects fail-fast error handling.
201pub fn load_config_validated_result(start_dir: &Path) -> anyhow::Result<DebtmapConfig> {
202    run_validation(load_config_validated(start_dir))
203}
204
205/// Load config from a specific path with validation.
206///
207/// Unlike `load_config_validated`, this requires the config file to exist
208/// at the specified path.
209pub fn load_config_from_path_validated(config_path: &Path) -> AnalysisValidation<DebtmapConfig> {
210    // Check file exists
211    if !config_path.exists() {
212        return validation_failure(AnalysisError::config_with_path(
213            format!("Config file not found: {}", config_path.display()),
214            config_path,
215        ));
216    }
217
218    // Read config file
219    let contents = match read_config_file(config_path) {
220        Ok(contents) => contents,
221        Err(e) => {
222            return validation_failure(AnalysisError::io_with_path(
223                format!("Cannot read config file: {}", e),
224                config_path,
225            ));
226        }
227    };
228
229    // Parse config
230    let config = match toml::from_str::<DebtmapConfig>(&contents) {
231        Ok(config) => config,
232        Err(e) => {
233            return validation_failure(AnalysisError::config_with_path(
234                format!("Failed to parse config: {}", e),
235                config_path,
236            ));
237        }
238    };
239
240    // Validate config (accumulates ALL validation errors)
241    match validate_config(&config) {
242        stillwater::Validation::Success(_) => validation_success(config),
243        stillwater::Validation::Failure(errors) => stillwater::Validation::Failure(errors),
244    }
245}
246
247/// Load config from a specific path with backwards-compatible Result API.
248pub fn load_config_from_path_result(config_path: &Path) -> anyhow::Result<DebtmapConfig> {
249    run_validation(load_config_from_path_validated(config_path))
250}