use std::path::{Path, PathBuf};
use crate::cmd::route;
use crate::error::CliError;
const CACHE_DIR: &str = "runtime/cache";
const ROUTE_CACHE_FILE: &str = "route_cache.json";
const CONFIG_CACHE_FILE: &str = "config_cache.json";
const CONFIG_DIR: &str = "config";
const SCHEMA_CACHE_FILE: &str = "schema_cache.json";
const SCHEMA_CACHE_PHP_FILE: &str = "schema_cache.php";
const DATABASE_CONFIG_FILE: &str = "database.yml";
const RUNTIME_DIR: &str = "runtime";
pub async fn execute_optimize_route() -> Result<(), CliError> {
let routes = route::collect_routes();
let route_count = routes.len();
let json: Vec<serde_json::Value> = routes
.iter()
.map(|r| {
serde_json::json!({
"method": r.method,
"path": r.path,
"app": r.app,
"controller": r.controller,
"action": r.action,
})
})
.collect();
let content = serde_json::to_string_pretty(&json)
.map_err(|e| CliError::Generic(format!("路由缓存序列化失败: {}", e)))?;
let cache_path = get_route_cache_path();
write_cache_file(&cache_path, &content).await?;
println!(
"Route cache generated: {} route(s) → {}",
route_count,
cache_path.display()
);
Ok(())
}
pub async fn execute_optimize_config() -> Result<(), CliError> {
let config_dir = Path::new(CONFIG_DIR);
if !config_dir.exists() {
return Err(CliError::Generic(format!(
"配置目录不存在: {}(请在项目根目录执行此命令)",
config_dir.display()
)));
}
let mut merged = serde_json::Map::new();
let mut file_count = 0usize;
let mut entries = tokio::fs::read_dir(config_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if !matches!(ext, "php" | "yaml" | "yml" | "json" | "toml") {
continue;
}
let stem = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let content = tokio::fs::read_to_string(&path).await?;
let config_value = parse_config_file(&content, ext)?;
merged.insert(stem, config_value);
file_count += 1;
}
let content = serde_json::to_string_pretty(&serde_json::Value::Object(merged))
.map_err(|e| CliError::Generic(format!("配置缓存序列化失败: {}", e)))?;
let cache_path = get_config_cache_path();
write_cache_file(&cache_path, &content).await?;
println!(
"Config cache generated: {} file(s) → {}",
file_count,
cache_path.display()
);
Ok(())
}
pub async fn execute_route_clear() -> Result<(), CliError> {
let cache_path = get_route_cache_path();
if !cache_path.exists() {
println!("Route cache not found: {}", cache_path.display());
println!("Nothing to clear.");
return Ok(());
}
tokio::fs::remove_file(&cache_path).await?;
println!("Route cache cleared: {}", cache_path.display());
Ok(())
}
pub async fn execute_optimize_schema() -> Result<(), CliError> {
let (default_connection, connections) = read_database_connections().await?;
let connection_count = connections.len();
let generated_at = chrono::Utc::now().to_rfc3339();
let cache = serde_json::json!({
"generated_at": generated_at,
"default_connection": default_connection,
"connections": connections,
"tables": [],
});
let content = serde_json::to_string_pretty(&cache)
.map_err(|e| CliError::Generic(format!("schema 缓存序列化失败: {}", e)))?;
let cache_path = get_schema_cache_path();
write_cache_file(&cache_path, &content).await?;
let php_content = build_php_schema_index(&generated_at, &connections);
let php_path = get_schema_cache_php_path();
write_cache_file(&php_path, &php_content).await?;
println!(
"Schema cache generated: {} connection(s) → {}",
connection_count,
cache_path.display()
);
Ok(())
}
pub fn get_route_cache_path() -> PathBuf {
PathBuf::from(CACHE_DIR).join(ROUTE_CACHE_FILE)
}
pub fn get_config_cache_path() -> PathBuf {
PathBuf::from(CACHE_DIR).join(CONFIG_CACHE_FILE)
}
pub fn get_schema_cache_path() -> PathBuf {
PathBuf::from(RUNTIME_DIR).join(SCHEMA_CACHE_FILE)
}
pub fn get_schema_cache_php_path() -> PathBuf {
PathBuf::from(RUNTIME_DIR).join(SCHEMA_CACHE_PHP_FILE)
}
async fn read_database_connections() -> Result<(String, Vec<serde_json::Value>), CliError> {
let path = Path::new(CONFIG_DIR).join(DATABASE_CONFIG_FILE);
if !path.exists() {
return Ok((String::new(), Vec::new()));
}
let content = tokio::fs::read_to_string(&path).await?;
let yaml: serde_yaml::Value = serde_yaml::from_str(&content)
.map_err(|e| CliError::Generic(format!("数据库配置解析失败: {}", e)))?;
let json = serde_json::to_value(yaml)
.map_err(|e| CliError::Generic(format!("YAML→JSON 转换失败: {}", e)))?;
let default_connection = json
.get("default")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let mut connections: Vec<serde_json::Value> = Vec::new();
if let Some(conns) = json.get("connections").and_then(|c| c.as_object()) {
for (name, info) in conns {
connections.push(serde_json::json!({
"name": name,
"database": info.get("database").and_then(|v| v.as_str()).unwrap_or(""),
"prefix": info.get("prefix").and_then(|v| v.as_str()).unwrap_or(""),
"type": info.get("type").and_then(|v| v.as_str()).unwrap_or(""),
}));
}
}
connections.sort_by(|a, b| {
a["name"]
.as_str()
.unwrap_or("")
.cmp(b["name"].as_str().unwrap_or(""))
});
Ok((default_connection, connections))
}
fn build_php_schema_index(generated_at: &str, connections: &[serde_json::Value]) -> String {
let mut buf = String::new();
buf.push_str("<?php\n");
buf.push_str("// Schema 缓存索引 — 由 sz-rust optimize:schema 生成\n");
buf.push_str("// 生成时间: ");
buf.push_str(generated_at);
buf.push('\n');
buf.push_str("// 业务方运行时通过 SchemaCache::remember_schema() 填充具体字段信息\n\n");
buf.push_str("return [\n");
buf.push_str(" 'generated_at' => '");
buf.push_str(generated_at);
buf.push_str("',\n");
if connections.is_empty() {
buf.push_str(" 'connections' => [],\n");
} else {
buf.push_str(" 'connections' => [\n");
for conn in connections {
let name = conn["name"].as_str().unwrap_or("");
let database = conn["database"].as_str().unwrap_or("");
let prefix = conn["prefix"].as_str().unwrap_or("");
buf.push_str(&format!(
" ['name' => '{}', 'database' => '{}', 'prefix' => '{}'],\n",
name, database, prefix
));
}
buf.push_str(" ],\n");
}
buf.push_str(" 'tables' => [],\n");
buf.push_str("];\n");
buf
}
async fn write_cache_file(path: &Path, content: &str) -> Result<(), CliError> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(path, content).await?;
Ok(())
}
fn parse_config_file(content: &str, ext: &str) -> Result<serde_json::Value, CliError> {
match ext {
"json" => serde_json::from_str(content)
.map_err(|e| CliError::Generic(format!("JSON 配置解析失败: {}", e))),
"yaml" | "yml" => {
let yaml: serde_yaml::Value = serde_yaml::from_str(content)
.map_err(|e| CliError::Generic(format!("YAML 配置解析失败: {}", e)))?;
serde_json::to_value(yaml)
.map_err(|e| CliError::Generic(format!("YAML→JSON 转换失败: {}", e)))
}
"php" | "toml" => {
Ok(serde_json::Value::String(content.to_string()))
}
_ => Ok(serde_json::Value::Null),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_get_route_cache_path() {
let path = get_route_cache_path();
assert!(path.ends_with("runtime/cache/route_cache.json"));
}
#[test]
fn test_get_config_cache_path() {
let path = get_config_cache_path();
assert!(path.ends_with("runtime/cache/config_cache.json"));
}
#[tokio::test]
async fn test_write_cache_file_creates_parent_dirs() {
let temp = tempfile::tempdir().unwrap();
let nested = temp.path().join("nested").join("deep").join("cache.json");
write_cache_file(&nested, r#"{"key":"value"}"#)
.await
.unwrap();
assert!(nested.exists());
let content = std::fs::read_to_string(&nested).unwrap();
assert_eq!(content, r#"{"key":"value"}"#);
}
#[test]
fn test_parse_config_file_json() {
let json = r#"{"name":"app","port":8080}"#;
let value = parse_config_file(json, "json").unwrap();
assert_eq!(value["name"], "app");
assert_eq!(value["port"], 8080);
}
#[test]
fn test_parse_config_file_yaml() {
let yaml = "name: app\nport: 8080\n";
let value = parse_config_file(yaml, "yaml").unwrap();
assert_eq!(value["name"], "app");
assert_eq!(value["port"], 8080);
}
#[test]
fn test_parse_config_file_php_preserves_raw_content() {
let php = "<?php return ['name' => 'app'];";
let value = parse_config_file(php, "php").unwrap();
assert!(value.is_string());
assert!(value.as_str().unwrap().contains("<?php"));
}
#[test]
fn test_parse_config_file_toml_preserves_raw_content() {
let toml = "[server]\nport = 8080\n";
let value = parse_config_file(toml, "toml").unwrap();
assert!(value.is_string());
assert!(value.as_str().unwrap().contains("[server]"));
}
#[test]
fn test_parse_config_file_unsupported_returns_null() {
let value = parse_config_file("content", "txt").unwrap();
assert!(value.is_null());
}
#[test]
fn test_parse_config_file_invalid_json() {
let result = parse_config_file("{invalid}", "json");
assert!(matches!(result, Err(CliError::Generic(_))));
}
#[test]
fn test_parse_config_file_invalid_yaml() {
let result = parse_config_file(":\n : bad", "yaml");
assert!(matches!(result, Err(CliError::Generic(_))));
}
#[tokio::test]
async fn test_execute_optimize_route_creates_cache_file() {
let temp = tempfile::tempdir().unwrap();
let _guard = CwdGuard::switch(temp.path()).unwrap();
execute_optimize_route().await.unwrap();
let cache_path = get_route_cache_path();
assert!(cache_path.exists());
let content = std::fs::read_to_string(&cache_path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(json.is_array());
assert!(!json.as_array().unwrap().is_empty());
}
#[tokio::test]
async fn test_execute_route_clear_removes_cache_file() {
let temp = tempfile::tempdir().unwrap();
let _guard = CwdGuard::switch(temp.path()).unwrap();
execute_optimize_route().await.unwrap();
assert!(get_route_cache_path().exists());
execute_route_clear().await.unwrap();
assert!(!get_route_cache_path().exists());
}
#[tokio::test]
async fn test_execute_route_clear_nonexistent_cache() {
let temp = tempfile::tempdir().unwrap();
let _guard = CwdGuard::switch(temp.path()).unwrap();
let result = execute_route_clear().await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_execute_optimize_config_no_config_dir() {
let temp = tempfile::tempdir().unwrap();
let _guard = CwdGuard::switch(temp.path()).unwrap();
let result = execute_optimize_config().await;
assert!(matches!(result, Err(CliError::Generic(_))));
}
#[tokio::test]
async fn test_execute_optimize_config_with_json_files() {
let temp = tempfile::tempdir().unwrap();
let _guard = CwdGuard::switch(temp.path()).unwrap();
let config_dir = temp.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("app.json"),
r#"{"name":"test","debug":true}"#,
)
.unwrap();
std::fs::write(
config_dir.join("database.json"),
r#"{"host":"localhost","port":5432}"#,
)
.unwrap();
execute_optimize_config().await.unwrap();
let cache_path = get_config_cache_path();
assert!(cache_path.exists());
let content = std::fs::read_to_string(&cache_path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(json["app"]["name"], "test");
assert_eq!(json["app"]["debug"], true);
assert_eq!(json["database"]["host"], "localhost");
assert_eq!(json["database"]["port"], 5432);
}
#[tokio::test]
async fn test_execute_optimize_config_with_yaml_files() {
let temp = tempfile::tempdir().unwrap();
let _guard = CwdGuard::switch(temp.path()).unwrap();
let config_dir = temp.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(config_dir.join("cache.yaml"), "driver: redis\nttl: 3600\n").unwrap();
execute_optimize_config().await.unwrap();
let cache_path = get_config_cache_path();
assert!(cache_path.exists());
let content = std::fs::read_to_string(&cache_path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(json["cache"]["driver"], "redis");
assert_eq!(json["cache"]["ttl"], 3600);
}
#[test]
fn test_get_schema_cache_path() {
let path = get_schema_cache_path();
assert!(path.ends_with("runtime/schema_cache.json"));
}
#[test]
fn test_get_schema_cache_php_path() {
let path = get_schema_cache_php_path();
assert!(path.ends_with("runtime/schema_cache.php"));
}
#[tokio::test]
async fn test_execute_optimize_schema_no_config() {
let temp = tempfile::tempdir().unwrap();
let _guard = CwdGuard::switch(temp.path()).unwrap();
execute_optimize_schema().await.unwrap();
let cache_path = get_schema_cache_path();
assert!(cache_path.exists());
let content = std::fs::read_to_string(&cache_path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(json.is_object());
assert!(json["generated_at"].is_string());
assert!(json["tables"].is_array());
assert_eq!(json["tables"].as_array().unwrap().len(), 0);
assert_eq!(json["default_connection"].as_str(), Some(""));
assert!(json["connections"].is_array());
assert_eq!(json["connections"].as_array().unwrap().len(), 0);
let php_path = get_schema_cache_php_path();
assert!(php_path.exists());
let php_content = std::fs::read_to_string(&php_path).unwrap();
assert!(php_content.starts_with("<?php"));
assert!(php_content.contains("return ["));
assert!(php_content.contains("'tables' => []"));
}
#[tokio::test]
async fn test_optimize_schema_generates_valid_json() {
let temp = tempfile::tempdir().unwrap();
let _guard = CwdGuard::switch(temp.path()).unwrap();
let config_dir = temp.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("database.yml"),
"default: mysql\n\
connections:\n\
\x20 mysql:\n\
\x20 type: mysql\n\
\x20 database: shop\n\
\x20 prefix: sz_\n\
\x20 food:\n\
\x20 type: mysql\n\
\x20 database: food\n\
\x20 prefix: sz_food_\n",
)
.unwrap();
execute_optimize_schema().await.unwrap();
let cache_path = get_schema_cache_path();
assert!(cache_path.exists());
let content = std::fs::read_to_string(&cache_path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert!(json["generated_at"].is_string());
assert_eq!(json["default_connection"].as_str(), Some("mysql"));
assert!(json["tables"].is_array());
assert_eq!(json["tables"].as_array().unwrap().len(), 0);
let conns = json["connections"].as_array().unwrap();
assert_eq!(conns.len(), 2);
assert_eq!(conns[0]["name"].as_str(), Some("food"));
assert_eq!(conns[0]["prefix"].as_str(), Some("sz_food_"));
assert_eq!(conns[1]["name"].as_str(), Some("mysql"));
assert_eq!(conns[1]["database"].as_str(), Some("shop"));
assert_eq!(conns[1]["prefix"].as_str(), Some("sz_"));
let php_path = get_schema_cache_php_path();
let php_content = std::fs::read_to_string(&php_path).unwrap();
assert!(php_content.contains("'name' => 'mysql'"));
assert!(php_content.contains("'prefix' => 'sz_'"));
assert!(php_content.contains("'name' => 'food'"));
assert!(php_content.contains("'database' => 'shop'"));
}
#[tokio::test]
async fn test_read_database_connections_missing_file() {
let temp = tempfile::tempdir().unwrap();
let _guard = CwdGuard::switch(temp.path()).unwrap();
let (default, conns) = read_database_connections().await.unwrap();
assert_eq!(default, "");
assert!(conns.is_empty());
}
#[tokio::test]
async fn test_read_database_connections_invalid_yaml() {
let temp = tempfile::tempdir().unwrap();
let _guard = CwdGuard::switch(temp.path()).unwrap();
let config_dir = temp.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(config_dir.join("database.yml"), ":\n : bad").unwrap();
let result = read_database_connections().await;
assert!(matches!(result, Err(CliError::Generic(_))));
}
#[test]
fn test_build_php_schema_index_empty() {
let content = build_php_schema_index("2026-07-31T00:00:00+00:00", &[]);
assert!(content.starts_with("<?php"));
assert!(content.contains("'generated_at' => '2026-07-31T00:00:00+00:00'"));
assert!(content.contains("'connections' => []"));
assert!(content.contains("'tables' => []"));
}
#[test]
fn test_build_php_schema_index_with_connections() {
let connections = vec![
serde_json::json!({"name": "mysql", "database": "shop", "prefix": "sz_", "type": "mysql"}),
serde_json::json!({"name": "food", "database": "food", "prefix": "sz_food_", "type": "mysql"}),
];
let content = build_php_schema_index("2026-07-31T00:00:00+00:00", &connections);
assert!(content.contains("'name' => 'mysql'"));
assert!(content.contains("'database' => 'shop'"));
assert!(content.contains("'prefix' => 'sz_'"));
assert!(content.contains("'name' => 'food'"));
assert!(content.contains("'prefix' => 'sz_food_'"));
}
#[tokio::test]
async fn test_execute_optimize_config_skips_unsupported_files() {
let temp = tempfile::tempdir().unwrap();
let _guard = CwdGuard::switch(temp.path()).unwrap();
let config_dir = temp.path().join("config");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(config_dir.join("app.json"), r#"{"name":"test"}"#).unwrap();
std::fs::write(config_dir.join("readme.txt"), "not a config").unwrap();
execute_optimize_config().await.unwrap();
let cache_path = get_config_cache_path();
let content = std::fs::read_to_string(&cache_path).unwrap();
let json: serde_json::Value = serde_json::from_str(&content).unwrap();
assert_eq!(json.as_object().unwrap().len(), 1);
assert!(json.get("app").is_some());
assert!(json.get("readme").is_none());
}
use std::sync::MutexGuard;
struct CwdGuard {
original: Option<PathBuf>,
_lock: MutexGuard<'static, ()>,
}
impl CwdGuard {
fn switch(new_dir: &Path) -> std::io::Result<Self> {
let lock = super::super::test_support::acquire_global_lock();
let original = std::env::current_dir().ok();
std::env::set_current_dir(new_dir)?;
Ok(Self {
original,
_lock: lock,
})
}
}
impl Drop for CwdGuard {
fn drop(&mut self) {
if let Some(ref orig) = self.original {
let _ = std::env::set_current_dir(orig);
}
}
}
}