Skip to main content

sqry_core/config/
migration.rs

1//! Configuration migration from legacy `.sqry-config.toml` to unified graph config.
2//!
3//! Implements Step 6 of the Unified Graph Config Partition feature:
4//! - Detects legacy `.sqry-config.toml` files
5//! - Migrates settings to `.sqry/graph/config/config.json`
6//! - Preserves user settings during migration
7//! - Logs deprecation warnings when legacy config is used
8//!
9//! # Migration Strategy
10//!
11//! When a legacy config file is detected:
12//! 1. Load the legacy `.sqry-config.toml`
13//! 2. Convert to new `GraphConfigFile` format
14//! 3. Initialize new config store at `.sqry/graph/config/`
15//! 4. Save converted config atomically
16//! 5. Leave legacy file in place (user can remove manually)
17//! 6. Log migration summary
18//!
19//! # Design
20//!
21//! See: `docs/development/unified-graph-config-partition/02_DESIGN.md`
22
23use std::path::{Path, PathBuf};
24use thiserror::Error;
25
26use super::ConfigPersistence;
27use super::graph_config_schema::GraphConfigFile;
28use super::graph_config_store::GraphConfigStore;
29use super::project_config::{CONFIG_FILE_NAME, ProjectConfig};
30
31/// Errors that can occur during migration
32#[derive(Debug, Error)]
33pub enum MigrationError {
34    /// Failed to load legacy config
35    #[error("Failed to load legacy config from {path}: {source}")]
36    LegacyLoadError {
37        /// Path to the legacy config file
38        path: PathBuf,
39        /// Underlying config error
40        #[source]
41        source: super::project_config::ConfigError,
42    },
43
44    /// Failed to initialize new config store
45    #[error("Failed to initialize new config store: {0}")]
46    StoreInitError(String),
47
48    /// Failed to save migrated config
49    #[error("Failed to save migrated config: {0}")]
50    SaveError(String),
51
52    /// IO error
53    #[error("IO error at {path}: {source}")]
54    IoError {
55        /// Path where IO error occurred
56        path: PathBuf,
57        /// Underlying IO error
58        #[source]
59        source: std::io::Error,
60    },
61}
62
63/// Result type for migration operations
64pub type MigrationResult<T> = Result<T, MigrationError>;
65
66/// Result of a migration attempt
67#[derive(Debug, Clone, Default)]
68pub struct MigrationReport {
69    /// Whether migration was performed
70    pub migrated: bool,
71
72    /// Path to legacy config file (if found)
73    pub legacy_path: Option<PathBuf>,
74
75    /// Path to new config file (if created)
76    pub new_path: Option<PathBuf>,
77
78    /// Warnings generated during migration
79    pub warnings: Vec<String>,
80
81    /// Summary of settings migrated
82    pub migrated_settings: Vec<String>,
83}
84
85impl MigrationReport {
86    /// Create a report for when no migration was needed
87    #[must_use]
88    pub fn no_migration_needed() -> Self {
89        Self {
90            migrated: false,
91            legacy_path: None,
92            new_path: None,
93            warnings: Vec::new(),
94            migrated_settings: Vec::new(),
95        }
96    }
97
98    /// Add a warning message
99    pub fn add_warning(&mut self, warning: impl Into<String>) {
100        self.warnings.push(warning.into());
101    }
102
103    /// Add a migrated setting
104    pub fn add_migrated_setting(&mut self, setting: impl Into<String>) {
105        self.migrated_settings.push(setting.into());
106    }
107}
108
109/// Detect if a legacy config file exists in the project root or its ancestors
110///
111/// Searches from `project_root` upward through parent directories for `.sqry-config.toml`.
112///
113/// # Arguments
114///
115/// * `project_root` - Starting directory for config search
116///
117/// # Returns
118///
119/// Returns `Some(path)` if legacy config found, `None` otherwise.
120pub fn detect_legacy_config<P: AsRef<Path>>(project_root: P) -> Option<PathBuf> {
121    let mut current = project_root.as_ref();
122
123    loop {
124        let config_path = current.join(CONFIG_FILE_NAME);
125
126        if config_path.exists() && config_path.is_file() {
127            return Some(config_path);
128        }
129
130        // Move to parent directory
131        match current.parent() {
132            Some(parent) if !parent.as_os_str().is_empty() => {
133                current = parent;
134            }
135            _ => break, // Reached filesystem root
136        }
137    }
138
139    None
140}
141
142/// Check if new config system is already initialized
143///
144/// Returns `true` if `.sqry/graph/config/config.json` exists.
145pub fn is_new_config_initialized<P: AsRef<Path>>(project_root: P) -> bool {
146    let Ok(store) = GraphConfigStore::new(project_root) else {
147        return false;
148    };
149
150    store.paths().config_file_exists()
151}
152
153/// Migrate legacy config to new format
154///
155/// Detects and migrates legacy `.sqry-config.toml` if present.
156/// Returns a report of migration actions taken.
157///
158/// # Arguments
159///
160/// * `project_root` - Project root directory
161///
162/// # Returns
163///
164/// Returns a migration report with status and warnings.
165///
166/// # Errors
167///
168/// Returns error if migration fails (legacy config corrupt, write fails, etc.)
169pub fn migrate_legacy_config<P: AsRef<Path>>(project_root: P) -> MigrationResult<MigrationReport> {
170    let project_root = project_root.as_ref();
171    let mut report = MigrationReport::default();
172
173    // Check if new config already exists
174    if is_new_config_initialized(project_root) {
175        log::debug!(
176            "New config already initialized at {}, skipping migration",
177            project_root.display()
178        );
179        return Ok(MigrationReport::no_migration_needed());
180    }
181
182    // Detect legacy config
183    let Some(legacy_path) = detect_legacy_config(project_root) else {
184        log::debug!(
185            "No legacy config found in {}, skipping migration",
186            project_root.display()
187        );
188        return Ok(MigrationReport::no_migration_needed());
189    };
190    log::info!(
191        "Migrating legacy config from {} to new format",
192        legacy_path.display()
193    );
194
195    report.legacy_path = Some(legacy_path.clone());
196
197    // Load legacy config
198    let legacy_config =
199        ProjectConfig::load(&legacy_path).map_err(|e| MigrationError::LegacyLoadError {
200            path: legacy_path.clone(),
201            source: e,
202        })?;
203
204    // Convert to new format
205    let new_config = convert_project_config_to_graph_config(&legacy_config, &mut report);
206
207    // Initialize new config store
208    let store = GraphConfigStore::new(project_root).map_err(|e| {
209        MigrationError::StoreInitError(format!("Failed to create config store: {e}"))
210    })?;
211
212    // Ensure config directory exists
213    let config_dir = store.paths().config_dir();
214    if !config_dir.exists() {
215        std::fs::create_dir_all(&config_dir).map_err(|e| MigrationError::IoError {
216            path: config_dir.clone(),
217            source: e,
218        })?;
219    }
220
221    // Save migrated config
222    let persistence = ConfigPersistence::from_paths(store.paths().clone());
223    let mut new_config_mut = new_config;
224    persistence
225        .save(&mut new_config_mut, 5000, "migration")
226        .map_err(|e| MigrationError::SaveError(format!("Failed to save config: {e}")))?;
227
228    report.new_path = Some(store.paths().config_file());
229    report.migrated = true;
230
231    // Log deprecation warning
232    report.add_warning(format!(
233        "Legacy config file detected at {}. Migrated to {}. \
234         Consider removing the legacy file after verifying the migration.",
235        legacy_path.display(),
236        store.paths().config_file().display()
237    ));
238
239    log::warn!(
240        "Migrated legacy config from {} to {}. {} settings migrated.",
241        legacy_path.display(),
242        store.paths().config_file().display(),
243        report.migrated_settings.len()
244    );
245
246    Ok(report)
247}
248
249/// Convert legacy `ProjectConfig` to new `GraphConfigFile`
250///
251/// Maps legacy settings to new config structure, tracking what was migrated.
252fn convert_project_config_to_graph_config(
253    legacy: &ProjectConfig,
254    report: &mut MigrationReport,
255) -> GraphConfigFile {
256    let mut config_file = GraphConfigFile::default();
257    let config = &mut config_file.config;
258
259    // Migrate indexing settings directly (GraphConfig has indexing field from ProjectConfig)
260    config.indexing = legacy.indexing.clone();
261    report.add_migrated_setting(format!(
262        "indexing.max_file_size = {}",
263        legacy.indexing.max_file_size
264    ));
265    report.add_migrated_setting(format!(
266        "indexing.max_depth = {}",
267        legacy.indexing.max_depth
268    ));
269    report.add_migrated_setting(format!(
270        "indexing.enable_scope_extraction = {}",
271        legacy.indexing.enable_scope_extraction
272    ));
273    report.add_migrated_setting(format!(
274        "indexing.enable_relation_extraction = {}",
275        legacy.indexing.enable_relation_extraction
276    ));
277
278    // Migrate ignore patterns
279    config.ignore = legacy.ignore.clone();
280    report.add_migrated_setting(format!(
281        "ignore.patterns = [{} patterns]",
282        legacy.ignore.patterns.len()
283    ));
284
285    // Migrate include patterns
286    config.include = legacy.include.clone();
287    report.add_migrated_setting(format!(
288        "include.patterns = [{} patterns]",
289        legacy.include.patterns.len()
290    ));
291
292    // Migrate language mappings
293    config.languages = legacy.languages.clone();
294    report.add_migrated_setting(format!(
295        "languages.extensions = [{} mappings]",
296        legacy.languages.extensions.len()
297    ));
298    report.add_migrated_setting(format!(
299        "languages.files = [{} mappings]",
300        legacy.languages.files.len()
301    ));
302
303    // Migrate cache settings
304    config.cache = legacy.cache.clone();
305    report.add_migrated_setting(format!("cache.directory = {}", legacy.cache.directory));
306    report.add_migrated_setting(format!("cache.persistent = {}", legacy.cache.persistent));
307
308    // Note: Buffer sizes and other runtime settings remain environment-variable controlled
309    // and are not migrated from the legacy config
310
311    config_file
312}
313
314/// Log a deprecation warning if legacy config is detected but not migrated
315///
316/// This is used when the system detects a legacy config but the new config
317/// already exists (manual migration or previous auto-migration).
318pub fn log_deprecation_warning_if_legacy_exists<P: AsRef<Path>>(project_root: P) {
319    let project_root = project_root.as_ref();
320
321    // Only warn if legacy exists AND new config exists (coexistence)
322    if let Some(legacy_path) = detect_legacy_config(project_root)
323        && is_new_config_initialized(project_root)
324    {
325        log::warn!(
326            "Legacy config file detected at {}. The new config system is active. \
327                 Consider removing the legacy file to avoid confusion.",
328            legacy_path.display()
329        );
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336    use tempfile::TempDir;
337
338    #[test]
339    fn test_detect_legacy_config_not_found() {
340        let temp = TempDir::new().unwrap();
341        let result = detect_legacy_config(temp.path());
342        assert!(result.is_none());
343    }
344
345    #[test]
346    fn test_detect_legacy_config_found_in_root() {
347        let temp = TempDir::new().unwrap();
348        let config_path = temp.path().join(CONFIG_FILE_NAME);
349        std::fs::write(&config_path, "[indexing]\nmax_depth = 50").unwrap();
350
351        let result = detect_legacy_config(temp.path());
352        assert!(result.is_some());
353        assert_eq!(result.unwrap(), config_path);
354    }
355
356    #[test]
357    fn test_detect_legacy_config_found_in_ancestor() {
358        let temp = TempDir::new().unwrap();
359        let config_path = temp.path().join(CONFIG_FILE_NAME);
360        std::fs::write(&config_path, "[indexing]\nmax_depth = 50").unwrap();
361
362        // Create nested directory
363        let nested = temp.path().join("nested/deep");
364        std::fs::create_dir_all(&nested).unwrap();
365
366        // Should find config in ancestor
367        let result = detect_legacy_config(&nested);
368        assert!(result.is_some());
369        assert_eq!(result.unwrap(), config_path);
370    }
371
372    #[test]
373    fn test_is_new_config_initialized_false() {
374        let temp = TempDir::new().unwrap();
375        assert!(!is_new_config_initialized(temp.path()));
376    }
377
378    #[test]
379    fn test_is_new_config_initialized_true() {
380        let temp = TempDir::new().unwrap();
381
382        // Create .sqry/graph/config directory
383        let config_dir = temp.path().join(".sqry/graph/config");
384        std::fs::create_dir_all(&config_dir).unwrap();
385
386        // Create config.json
387        let config_file = config_dir.join("config.json");
388        let default_config = GraphConfigFile::default();
389        let json = serde_json::to_string_pretty(&default_config).unwrap();
390        std::fs::write(&config_file, json).unwrap();
391
392        assert!(is_new_config_initialized(temp.path()));
393    }
394
395    #[test]
396    fn test_convert_project_config_to_graph_config() {
397        let mut legacy = ProjectConfig::default();
398        legacy.indexing.max_file_size = 20_971_520; // 20 MB
399        legacy.indexing.max_depth = 75;
400        legacy.indexing.enable_scope_extraction = false;
401        legacy.cache.directory = ".custom-cache".to_string();
402        legacy.cache.persistent = false;
403
404        let mut report = MigrationReport::default();
405        let new_config = convert_project_config_to_graph_config(&legacy, &mut report);
406
407        assert_eq!(new_config.config.indexing.max_file_size, 20_971_520);
408        assert_eq!(new_config.config.indexing.max_depth, 75);
409        assert!(!new_config.config.indexing.enable_scope_extraction);
410        assert_eq!(new_config.config.cache.directory, ".custom-cache");
411        assert!(!new_config.config.cache.persistent);
412
413        // Should have recorded migrations
414        assert!(!report.migrated_settings.is_empty());
415        assert!(
416            report
417                .migrated_settings
418                .iter()
419                .any(|s| s.contains("max_file_size"))
420        );
421    }
422
423    #[test]
424    fn test_migrate_legacy_config_no_legacy_found() {
425        let temp = TempDir::new().unwrap();
426        let result = migrate_legacy_config(temp.path()).unwrap();
427
428        assert!(!result.migrated);
429        assert!(result.legacy_path.is_none());
430        assert!(result.new_path.is_none());
431    }
432
433    #[test]
434    fn test_migrate_legacy_config_success() {
435        let temp = TempDir::new().unwrap();
436
437        // Create legacy config
438        let legacy_path = temp.path().join(CONFIG_FILE_NAME);
439        std::fs::write(
440            &legacy_path,
441            r#"
442[indexing]
443max_file_size = 15728640
444max_depth = 60
445
446[cache]
447directory = ".my-cache"
448persistent = true
449"#,
450        )
451        .unwrap();
452
453        // Run migration
454        let result = migrate_legacy_config(temp.path()).unwrap();
455
456        assert!(result.migrated);
457        assert_eq!(result.legacy_path, Some(legacy_path.clone()));
458        assert!(result.new_path.is_some());
459        assert!(!result.warnings.is_empty());
460        assert!(!result.migrated_settings.is_empty());
461
462        // Verify new config was created
463        let new_config_path = result.new_path.unwrap();
464        assert!(new_config_path.exists());
465
466        // Verify migrated values
467        let content = std::fs::read_to_string(&new_config_path).unwrap();
468        let loaded: GraphConfigFile = serde_json::from_str(&content).unwrap();
469
470        assert_eq!(loaded.config.indexing.max_file_size, 15_728_640);
471        assert_eq!(loaded.config.indexing.max_depth, 60);
472        assert_eq!(loaded.config.cache.directory, ".my-cache");
473        assert!(loaded.config.cache.persistent);
474    }
475
476    #[test]
477    fn test_migrate_legacy_config_skips_if_new_exists() {
478        let temp = TempDir::new().unwrap();
479
480        // Create legacy config
481        let legacy_path = temp.path().join(CONFIG_FILE_NAME);
482        std::fs::write(&legacy_path, "[indexing]\nmax_depth = 50").unwrap();
483
484        // Create new config
485        let config_dir = temp.path().join(".sqry/graph/config");
486        std::fs::create_dir_all(&config_dir).unwrap();
487        let new_config_path = config_dir.join("config.json");
488        let default_config = GraphConfigFile::default();
489        std::fs::write(
490            &new_config_path,
491            serde_json::to_string_pretty(&default_config).unwrap(),
492        )
493        .unwrap();
494
495        // Migration should skip
496        let result = migrate_legacy_config(temp.path()).unwrap();
497
498        assert!(!result.migrated);
499    }
500
501    #[test]
502    fn test_migration_report_default() {
503        let report = MigrationReport::default();
504        assert!(!report.migrated);
505        assert!(report.legacy_path.is_none());
506        assert!(report.new_path.is_none());
507        assert!(report.warnings.is_empty());
508        assert!(report.migrated_settings.is_empty());
509    }
510
511    #[test]
512    fn test_migration_report_add_warning() {
513        let mut report = MigrationReport::default();
514        report.add_warning("Test warning");
515        assert_eq!(report.warnings.len(), 1);
516        assert_eq!(report.warnings[0], "Test warning");
517    }
518
519    #[test]
520    fn test_migration_report_add_migrated_setting() {
521        let mut report = MigrationReport::default();
522        report.add_migrated_setting("setting.key = value");
523        assert_eq!(report.migrated_settings.len(), 1);
524        assert_eq!(report.migrated_settings[0], "setting.key = value");
525    }
526
527    #[test]
528    fn test_convert_preserves_ignore_patterns() {
529        let mut legacy = ProjectConfig::default();
530        legacy.ignore.patterns = vec!["custom/**".to_string(), "*.tmp".to_string()];
531
532        let mut report = MigrationReport::default();
533        let new_config = convert_project_config_to_graph_config(&legacy, &mut report);
534
535        assert_eq!(
536            new_config.config.ignore.patterns,
537            vec!["custom/**".to_string(), "*.tmp".to_string()]
538        );
539    }
540
541    #[test]
542    fn test_convert_preserves_language_mappings() {
543        let mut legacy = ProjectConfig::default();
544        legacy
545            .languages
546            .extensions
547            .insert("jsx".to_string(), "javascript".to_string());
548        legacy
549            .languages
550            .files
551            .insert("Jenkinsfile".to_string(), "groovy".to_string());
552
553        let mut report = MigrationReport::default();
554        let new_config = convert_project_config_to_graph_config(&legacy, &mut report);
555
556        assert_eq!(
557            new_config
558                .config
559                .languages
560                .extensions
561                .get("jsx")
562                .map(String::as_str),
563            Some("javascript")
564        );
565        assert_eq!(
566            new_config
567                .config
568                .languages
569                .files
570                .get("Jenkinsfile")
571                .map(String::as_str),
572            Some("groovy")
573        );
574    }
575}