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