1use 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#[derive(Debug, Error)]
33pub enum MigrationError {
34 #[error("Failed to load legacy config from {path}: {source}")]
36 LegacyLoadError {
37 path: PathBuf,
39 #[source]
41 source: super::project_config::ConfigError,
42 },
43
44 #[error("Failed to initialize new config store: {0}")]
46 StoreInitError(String),
47
48 #[error("Failed to save migrated config: {0}")]
50 SaveError(String),
51
52 #[error("IO error at {path}: {source}")]
54 IoError {
55 path: PathBuf,
57 #[source]
59 source: std::io::Error,
60 },
61}
62
63pub type MigrationResult<T> = Result<T, MigrationError>;
65
66#[derive(Debug, Clone, Default)]
68pub struct MigrationReport {
69 pub migrated: bool,
71
72 pub legacy_path: Option<PathBuf>,
74
75 pub new_path: Option<PathBuf>,
77
78 pub warnings: Vec<String>,
80
81 pub migrated_settings: Vec<String>,
83}
84
85impl MigrationReport {
86 #[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 pub fn add_warning(&mut self, warning: impl Into<String>) {
100 self.warnings.push(warning.into());
101 }
102
103 pub fn add_migrated_setting(&mut self, setting: impl Into<String>) {
105 self.migrated_settings.push(setting.into());
106 }
107}
108
109pub 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 match current.parent() {
132 Some(parent) if !parent.as_os_str().is_empty() => {
133 current = parent;
134 }
135 _ => break, }
137 }
138
139 None
140}
141
142pub 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
153pub 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 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 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 let legacy_config =
199 ProjectConfig::load(&legacy_path).map_err(|e| MigrationError::LegacyLoadError {
200 path: legacy_path.clone(),
201 source: e,
202 })?;
203
204 let new_config = convert_project_config_to_graph_config(&legacy_config, &mut report);
206
207 let store = GraphConfigStore::new(project_root).map_err(|e| {
209 MigrationError::StoreInitError(format!("Failed to create config store: {e}"))
210 })?;
211
212 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 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 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
249fn 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 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 config.ignore = legacy.ignore.clone();
280 report.add_migrated_setting(format!(
281 "ignore.patterns = [{} patterns]",
282 legacy.ignore.patterns.len()
283 ));
284
285 config.include = legacy.include.clone();
287 report.add_migrated_setting(format!(
288 "include.patterns = [{} patterns]",
289 legacy.include.patterns.len()
290 ));
291
292 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 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 config_file
312}
313
314pub fn log_deprecation_warning_if_legacy_exists<P: AsRef<Path>>(project_root: P) {
319 let project_root = project_root.as_ref();
320
321 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 let nested = temp.path().join("nested/deep");
364 std::fs::create_dir_all(&nested).unwrap();
365
366 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 let config_dir = temp.path().join(".sqry/graph/config");
384 std::fs::create_dir_all(&config_dir).unwrap();
385
386 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; 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 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 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 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 let new_config_path = result.new_path.unwrap();
464 assert!(new_config_path.exists());
465
466 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 let legacy_path = temp.path().join(CONFIG_FILE_NAME);
482 std::fs::write(&legacy_path, "[indexing]\nmax_depth = 50").unwrap();
483
484 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 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}