1use std::path::{Path, PathBuf};
33
34use crate::cmd::route;
35use crate::error::CliError;
36
37const CACHE_DIR: &str = "runtime/cache";
39
40const ROUTE_CACHE_FILE: &str = "route_cache.json";
42
43const CONFIG_CACHE_FILE: &str = "config_cache.json";
45
46const CONFIG_DIR: &str = "config";
48
49const SCHEMA_CACHE_FILE: &str = "schema_cache.json";
51
52const SCHEMA_CACHE_PHP_FILE: &str = "schema_cache.php";
54
55const DATABASE_CONFIG_FILE: &str = "database.yml";
57
58const RUNTIME_DIR: &str = "runtime";
60
61pub fn execute_optimize_route() -> Result<(), CliError> {
72 let routes = route::collect_routes();
73 let route_count = routes.len();
74
75 let json: Vec<serde_json::Value> = routes
77 .iter()
78 .map(|r| {
79 serde_json::json!({
80 "method": r.method,
81 "path": r.path,
82 "app": r.app,
83 "controller": r.controller,
84 "action": r.action,
85 })
86 })
87 .collect();
88
89 let content = serde_json::to_string_pretty(&json)
90 .map_err(|e| CliError::Generic(format!("路由缓存序列化失败: {}", e)))?;
91
92 let cache_path = get_route_cache_path();
93 write_cache_file(&cache_path, &content)?;
94
95 println!(
96 "Route cache generated: {} route(s) → {}",
97 route_count,
98 cache_path.display()
99 );
100 Ok(())
101}
102
103pub fn execute_optimize_config() -> Result<(), CliError> {
117 let config_dir = Path::new(CONFIG_DIR);
118
119 if !config_dir.exists() {
120 return Err(CliError::Generic(format!(
121 "配置目录不存在: {}(请在项目根目录执行此命令)",
122 config_dir.display()
123 )));
124 }
125
126 let mut merged = serde_json::Map::new();
127 let mut file_count = 0usize;
128
129 let entries = std::fs::read_dir(config_dir)?;
130 for entry in entries {
131 let entry = entry?;
132 let path = entry.path();
133
134 if !path.is_file() {
136 continue;
137 }
138
139 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
141 if !matches!(ext, "php" | "yaml" | "yml" | "json" | "toml") {
142 continue;
143 }
144
145 let stem = path
147 .file_stem()
148 .and_then(|s| s.to_str())
149 .unwrap_or("unknown")
150 .to_string();
151
152 let content = std::fs::read_to_string(&path)?;
153 let config_value = parse_config_file(&content, ext)?;
154 merged.insert(stem, config_value);
155 file_count += 1;
156 }
157
158 let content = serde_json::to_string_pretty(&serde_json::Value::Object(merged))
159 .map_err(|e| CliError::Generic(format!("配置缓存序列化失败: {}", e)))?;
160
161 let cache_path = get_config_cache_path();
162 write_cache_file(&cache_path, &content)?;
163
164 println!(
165 "Config cache generated: {} file(s) → {}",
166 file_count,
167 cache_path.display()
168 );
169 Ok(())
170}
171
172pub fn execute_route_clear() -> Result<(), CliError> {
177 let cache_path = get_route_cache_path();
178
179 if !cache_path.exists() {
180 println!("Route cache not found: {}", cache_path.display());
181 println!("Nothing to clear.");
182 return Ok(());
183 }
184
185 std::fs::remove_file(&cache_path)?;
186 println!("Route cache cleared: {}", cache_path.display());
187 Ok(())
188}
189
190pub fn execute_optimize_schema() -> Result<(), CliError> {
212 let (default_connection, connections) = read_database_connections()?;
213 let connection_count = connections.len();
214 let generated_at = chrono::Utc::now().to_rfc3339();
215
216 let cache = serde_json::json!({
218 "generated_at": generated_at,
219 "default_connection": default_connection,
220 "connections": connections,
221 "tables": [],
222 });
223
224 let content = serde_json::to_string_pretty(&cache)
225 .map_err(|e| CliError::Generic(format!("schema 缓存序列化失败: {}", e)))?;
226
227 let cache_path = get_schema_cache_path();
228 write_cache_file(&cache_path, &content)?;
229
230 let php_content = build_php_schema_index(&generated_at, &connections);
232 let php_path = get_schema_cache_php_path();
233 write_cache_file(&php_path, &php_content)?;
234
235 println!(
236 "Schema cache generated: {} connection(s) → {}",
237 connection_count,
238 cache_path.display()
239 );
240 Ok(())
241}
242
243pub fn get_route_cache_path() -> PathBuf {
249 PathBuf::from(CACHE_DIR).join(ROUTE_CACHE_FILE)
250}
251
252pub fn get_config_cache_path() -> PathBuf {
254 PathBuf::from(CACHE_DIR).join(CONFIG_CACHE_FILE)
255}
256
257pub fn get_schema_cache_path() -> PathBuf {
259 PathBuf::from(RUNTIME_DIR).join(SCHEMA_CACHE_FILE)
260}
261
262pub fn get_schema_cache_php_path() -> PathBuf {
264 PathBuf::from(RUNTIME_DIR).join(SCHEMA_CACHE_PHP_FILE)
265}
266
267fn read_database_connections() -> Result<(String, Vec<serde_json::Value>), CliError> {
274 let path = Path::new(CONFIG_DIR).join(DATABASE_CONFIG_FILE);
275
276 if !path.exists() {
277 return Ok((String::new(), Vec::new()));
279 }
280
281 let content = std::fs::read_to_string(&path)?;
282 let yaml: serde_yml::Value = serde_yml::from_str(&content)
283 .map_err(|e| CliError::Generic(format!("数据库配置解析失败: {}", e)))?;
284 let json = serde_json::to_value(yaml)
285 .map_err(|e| CliError::Generic(format!("YAML→JSON 转换失败: {}", e)))?;
286
287 let default_connection = json
288 .get("default")
289 .and_then(|v| v.as_str())
290 .unwrap_or("")
291 .to_string();
292
293 let mut connections: Vec<serde_json::Value> = Vec::new();
294 if let Some(conns) = json.get("connections").and_then(|c| c.as_object()) {
295 for (name, info) in conns {
296 connections.push(serde_json::json!({
297 "name": name,
298 "database": info.get("database").and_then(|v| v.as_str()).unwrap_or(""),
299 "prefix": info.get("prefix").and_then(|v| v.as_str()).unwrap_or(""),
300 "type": info.get("type").and_then(|v| v.as_str()).unwrap_or(""),
301 }));
302 }
303 }
304
305 connections.sort_by(|a, b| {
307 a["name"]
308 .as_str()
309 .unwrap_or("")
310 .cmp(b["name"].as_str().unwrap_or(""))
311 });
312
313 Ok((default_connection, connections))
314}
315
316fn build_php_schema_index(generated_at: &str, connections: &[serde_json::Value]) -> String {
321 let mut buf = String::new();
322 buf.push_str("<?php\n");
323 buf.push_str("// Schema 缓存索引 — 由 sz-rust optimize:schema 生成\n");
324 buf.push_str("// 生成时间: ");
325 buf.push_str(generated_at);
326 buf.push('\n');
327 buf.push_str("// 业务方运行时通过 SchemaCache::remember_schema() 填充具体字段信息\n\n");
328
329 buf.push_str("return [\n");
330 buf.push_str(" 'generated_at' => '");
331 buf.push_str(generated_at);
332 buf.push_str("',\n");
333
334 if connections.is_empty() {
336 buf.push_str(" 'connections' => [],\n");
337 } else {
338 buf.push_str(" 'connections' => [\n");
339 for conn in connections {
340 let name = conn["name"].as_str().unwrap_or("");
341 let database = conn["database"].as_str().unwrap_or("");
342 let prefix = conn["prefix"].as_str().unwrap_or("");
343 buf.push_str(&format!(
344 " ['name' => '{}', 'database' => '{}', 'prefix' => '{}'],\n",
345 name, database, prefix
346 ));
347 }
348 buf.push_str(" ],\n");
349 }
350
351 buf.push_str(" 'tables' => [],\n");
353 buf.push_str("];\n");
354
355 buf
356}
357
358fn write_cache_file(path: &Path, content: &str) -> Result<(), CliError> {
360 if let Some(parent) = path.parent() {
361 std::fs::create_dir_all(parent)?;
362 }
363 std::fs::write(path, content)?;
364 Ok(())
365}
366
367fn parse_config_file(content: &str, ext: &str) -> Result<serde_json::Value, CliError> {
374 match ext {
375 "json" => serde_json::from_str(content)
376 .map_err(|e| CliError::Generic(format!("JSON 配置解析失败: {}", e))),
377 "yaml" | "yml" => {
378 let yaml: serde_yml::Value = serde_yml::from_str(content)
380 .map_err(|e| CliError::Generic(format!("YAML 配置解析失败: {}", e)))?;
381 serde_json::to_value(yaml)
382 .map_err(|e| CliError::Generic(format!("YAML→JSON 转换失败: {}", e)))
383 }
384 "php" | "toml" => {
385 Ok(serde_json::Value::String(content.to_string()))
388 }
389 _ => Ok(serde_json::Value::Null),
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn test_get_route_cache_path() {
399 let path = get_route_cache_path();
400 assert!(path.ends_with("runtime/cache/route_cache.json"));
401 }
402
403 #[test]
404 fn test_get_config_cache_path() {
405 let path = get_config_cache_path();
406 assert!(path.ends_with("runtime/cache/config_cache.json"));
407 }
408
409 #[test]
410 fn test_write_cache_file_creates_parent_dirs() {
411 let temp = tempfile::tempdir().unwrap();
412 let nested = temp.path().join("nested").join("deep").join("cache.json");
413
414 write_cache_file(&nested, r#"{"key":"value"}"#).unwrap();
415
416 assert!(nested.exists());
417 let content = std::fs::read_to_string(&nested).unwrap();
418 assert_eq!(content, r#"{"key":"value"}"#);
419 }
420
421 #[test]
422 fn test_parse_config_file_json() {
423 let json = r#"{"name":"app","port":8080}"#;
424 let value = parse_config_file(json, "json").unwrap();
425 assert_eq!(value["name"], "app");
426 assert_eq!(value["port"], 8080);
427 }
428
429 #[test]
430 fn test_parse_config_file_yaml() {
431 let yaml = "name: app\nport: 8080\n";
432 let value = parse_config_file(yaml, "yaml").unwrap();
433 assert_eq!(value["name"], "app");
434 assert_eq!(value["port"], 8080);
435 }
436
437 #[test]
438 fn test_parse_config_file_php_preserves_raw_content() {
439 let php = "<?php return ['name' => 'app'];";
440 let value = parse_config_file(php, "php").unwrap();
441 assert!(value.is_string());
442 assert!(value.as_str().unwrap().contains("<?php"));
443 }
444
445 #[test]
446 fn test_parse_config_file_toml_preserves_raw_content() {
447 let toml = "[server]\nport = 8080\n";
448 let value = parse_config_file(toml, "toml").unwrap();
449 assert!(value.is_string());
450 assert!(value.as_str().unwrap().contains("[server]"));
451 }
452
453 #[test]
454 fn test_parse_config_file_unsupported_returns_null() {
455 let value = parse_config_file("content", "txt").unwrap();
456 assert!(value.is_null());
457 }
458
459 #[test]
460 fn test_parse_config_file_invalid_json() {
461 let result = parse_config_file("{invalid}", "json");
462 assert!(matches!(result, Err(CliError::Generic(_))));
463 }
464
465 #[test]
466 fn test_parse_config_file_invalid_yaml() {
467 let result = parse_config_file(":\n : bad", "yaml");
468 assert!(matches!(result, Err(CliError::Generic(_))));
469 }
470
471 #[test]
472 fn test_execute_optimize_route_creates_cache_file() {
473 let temp = tempfile::tempdir().unwrap();
474 let _guard = CwdGuard::switch(temp.path()).unwrap();
475
476 execute_optimize_route().unwrap();
477
478 let cache_path = get_route_cache_path();
479 assert!(cache_path.exists());
480
481 let content = std::fs::read_to_string(&cache_path).unwrap();
482 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
483 assert!(json.is_array());
484 assert!(!json.as_array().unwrap().is_empty());
485 }
486
487 #[test]
488 fn test_execute_route_clear_removes_cache_file() {
489 let temp = tempfile::tempdir().unwrap();
490 let _guard = CwdGuard::switch(temp.path()).unwrap();
491
492 execute_optimize_route().unwrap();
494 assert!(get_route_cache_path().exists());
495
496 execute_route_clear().unwrap();
498 assert!(!get_route_cache_path().exists());
499 }
500
501 #[test]
502 fn test_execute_route_clear_nonexistent_cache() {
503 let temp = tempfile::tempdir().unwrap();
504 let _guard = CwdGuard::switch(temp.path()).unwrap();
505
506 let result = execute_route_clear();
508 assert!(result.is_ok());
509 }
510
511 #[test]
512 fn test_execute_optimize_config_no_config_dir() {
513 let temp = tempfile::tempdir().unwrap();
514 let _guard = CwdGuard::switch(temp.path()).unwrap();
515
516 let result = execute_optimize_config();
518 assert!(matches!(result, Err(CliError::Generic(_))));
519 }
520
521 #[test]
522 fn test_execute_optimize_config_with_json_files() {
523 let temp = tempfile::tempdir().unwrap();
524 let _guard = CwdGuard::switch(temp.path()).unwrap();
525
526 let config_dir = temp.path().join("config");
528 std::fs::create_dir_all(&config_dir).unwrap();
529 std::fs::write(
530 config_dir.join("app.json"),
531 r#"{"name":"test","debug":true}"#,
532 )
533 .unwrap();
534 std::fs::write(
535 config_dir.join("database.json"),
536 r#"{"host":"localhost","port":5432}"#,
537 )
538 .unwrap();
539
540 execute_optimize_config().unwrap();
541
542 let cache_path = get_config_cache_path();
543 assert!(cache_path.exists());
544
545 let content = std::fs::read_to_string(&cache_path).unwrap();
546 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
547 assert_eq!(json["app"]["name"], "test");
548 assert_eq!(json["app"]["debug"], true);
549 assert_eq!(json["database"]["host"], "localhost");
550 assert_eq!(json["database"]["port"], 5432);
551 }
552
553 #[test]
554 fn test_execute_optimize_config_with_yaml_files() {
555 let temp = tempfile::tempdir().unwrap();
556 let _guard = CwdGuard::switch(temp.path()).unwrap();
557
558 let config_dir = temp.path().join("config");
559 std::fs::create_dir_all(&config_dir).unwrap();
560 std::fs::write(config_dir.join("cache.yaml"), "driver: redis\nttl: 3600\n").unwrap();
561
562 execute_optimize_config().unwrap();
563
564 let cache_path = get_config_cache_path();
565 assert!(cache_path.exists());
566
567 let content = std::fs::read_to_string(&cache_path).unwrap();
568 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
569 assert_eq!(json["cache"]["driver"], "redis");
570 assert_eq!(json["cache"]["ttl"], 3600);
571 }
572
573 #[test]
576 fn test_get_schema_cache_path() {
577 let path = get_schema_cache_path();
578 assert!(path.ends_with("runtime/schema_cache.json"));
579 }
580
581 #[test]
582 fn test_get_schema_cache_php_path() {
583 let path = get_schema_cache_php_path();
584 assert!(path.ends_with("runtime/schema_cache.php"));
585 }
586
587 #[test]
588 fn test_execute_optimize_schema_no_config() {
589 let temp = tempfile::tempdir().unwrap();
590 let _guard = CwdGuard::switch(temp.path()).unwrap();
591
592 execute_optimize_schema().unwrap();
594
595 let cache_path = get_schema_cache_path();
596 assert!(cache_path.exists());
597
598 let content = std::fs::read_to_string(&cache_path).unwrap();
599 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
600 assert!(json.is_object());
601 assert!(json["generated_at"].is_string());
602 assert!(json["tables"].is_array());
603 assert_eq!(json["tables"].as_array().unwrap().len(), 0);
604 assert_eq!(json["default_connection"].as_str(), Some(""));
606 assert!(json["connections"].is_array());
607 assert_eq!(json["connections"].as_array().unwrap().len(), 0);
608
609 let php_path = get_schema_cache_php_path();
611 assert!(php_path.exists());
612 let php_content = std::fs::read_to_string(&php_path).unwrap();
613 assert!(php_content.starts_with("<?php"));
614 assert!(php_content.contains("return ["));
615 assert!(php_content.contains("'tables' => []"));
616 }
617
618 #[test]
619 fn test_optimize_schema_generates_valid_json() {
620 let temp = tempfile::tempdir().unwrap();
621 let _guard = CwdGuard::switch(temp.path()).unwrap();
622
623 let config_dir = temp.path().join("config");
625 std::fs::create_dir_all(&config_dir).unwrap();
626 std::fs::write(
627 config_dir.join("database.yml"),
628 "default: mysql\n\
629 connections:\n\
630 \x20 mysql:\n\
631 \x20 type: mysql\n\
632 \x20 database: shop\n\
633 \x20 prefix: sz_\n\
634 \x20 food:\n\
635 \x20 type: mysql\n\
636 \x20 database: food\n\
637 \x20 prefix: sz_food_\n",
638 )
639 .unwrap();
640
641 execute_optimize_schema().unwrap();
642
643 let cache_path = get_schema_cache_path();
644 assert!(cache_path.exists());
645
646 let content = std::fs::read_to_string(&cache_path).unwrap();
647 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
648
649 assert!(json["generated_at"].is_string());
651 assert_eq!(json["default_connection"].as_str(), Some("mysql"));
652 assert!(json["tables"].is_array());
653 assert_eq!(json["tables"].as_array().unwrap().len(), 0);
654
655 let conns = json["connections"].as_array().unwrap();
657 assert_eq!(conns.len(), 2);
658 assert_eq!(conns[0]["name"].as_str(), Some("food"));
659 assert_eq!(conns[0]["prefix"].as_str(), Some("sz_food_"));
660 assert_eq!(conns[1]["name"].as_str(), Some("mysql"));
661 assert_eq!(conns[1]["database"].as_str(), Some("shop"));
662 assert_eq!(conns[1]["prefix"].as_str(), Some("sz_"));
663
664 let php_path = get_schema_cache_php_path();
666 let php_content = std::fs::read_to_string(&php_path).unwrap();
667 assert!(php_content.contains("'name' => 'mysql'"));
668 assert!(php_content.contains("'prefix' => 'sz_'"));
669 assert!(php_content.contains("'name' => 'food'"));
670 assert!(php_content.contains("'database' => 'shop'"));
671 }
672
673 #[test]
674 fn test_read_database_connections_missing_file() {
675 let temp = tempfile::tempdir().unwrap();
676 let _guard = CwdGuard::switch(temp.path()).unwrap();
677
678 let (default, conns) = read_database_connections().unwrap();
680 assert_eq!(default, "");
681 assert!(conns.is_empty());
682 }
683
684 #[test]
685 fn test_read_database_connections_invalid_yaml() {
686 let temp = tempfile::tempdir().unwrap();
687 let _guard = CwdGuard::switch(temp.path()).unwrap();
688
689 let config_dir = temp.path().join("config");
690 std::fs::create_dir_all(&config_dir).unwrap();
691 std::fs::write(config_dir.join("database.yml"), ":\n : bad").unwrap();
692
693 let result = read_database_connections();
694 assert!(matches!(result, Err(CliError::Generic(_))));
695 }
696
697 #[test]
698 fn test_build_php_schema_index_empty() {
699 let content = build_php_schema_index("2026-07-31T00:00:00+00:00", &[]);
700 assert!(content.starts_with("<?php"));
701 assert!(content.contains("'generated_at' => '2026-07-31T00:00:00+00:00'"));
702 assert!(content.contains("'connections' => []"));
703 assert!(content.contains("'tables' => []"));
704 }
705
706 #[test]
707 fn test_build_php_schema_index_with_connections() {
708 let connections = vec![
709 serde_json::json!({"name": "mysql", "database": "shop", "prefix": "sz_", "type": "mysql"}),
710 serde_json::json!({"name": "food", "database": "food", "prefix": "sz_food_", "type": "mysql"}),
711 ];
712 let content = build_php_schema_index("2026-07-31T00:00:00+00:00", &connections);
713 assert!(content.contains("'name' => 'mysql'"));
714 assert!(content.contains("'database' => 'shop'"));
715 assert!(content.contains("'prefix' => 'sz_'"));
716 assert!(content.contains("'name' => 'food'"));
717 assert!(content.contains("'prefix' => 'sz_food_'"));
718 }
719
720 #[test]
721 fn test_execute_optimize_config_skips_unsupported_files() {
722 let temp = tempfile::tempdir().unwrap();
723 let _guard = CwdGuard::switch(temp.path()).unwrap();
724
725 let config_dir = temp.path().join("config");
726 std::fs::create_dir_all(&config_dir).unwrap();
727 std::fs::write(config_dir.join("app.json"), r#"{"name":"test"}"#).unwrap();
728 std::fs::write(config_dir.join("readme.txt"), "not a config").unwrap();
730
731 execute_optimize_config().unwrap();
732
733 let cache_path = get_config_cache_path();
734 let content = std::fs::read_to_string(&cache_path).unwrap();
735 let json: serde_json::Value = serde_json::from_str(&content).unwrap();
736 assert_eq!(json.as_object().unwrap().len(), 1);
738 assert!(json.get("app").is_some());
739 assert!(json.get("readme").is_none());
740 }
741
742 use std::sync::MutexGuard;
745
746 struct CwdGuard {
748 original: Option<PathBuf>,
749 _lock: MutexGuard<'static, ()>,
750 }
751
752 impl CwdGuard {
753 fn switch(new_dir: &Path) -> std::io::Result<Self> {
754 let lock = super::super::test_support::acquire_global_lock();
756 let original = std::env::current_dir().ok();
757 std::env::set_current_dir(new_dir)?;
758 Ok(Self {
759 original,
760 _lock: lock,
761 })
762 }
763 }
764
765 impl Drop for CwdGuard {
766 fn drop(&mut self) {
767 if let Some(ref orig) = self.original {
768 let _ = std::env::set_current_dir(orig);
769 }
770 }
771 }
772}