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