Skip to main content

sz_rust_cli/cmd/
optimize.rs

1//! `optimize:route` / `optimize:config` / `optimize:schema` / `route:clear` 命令
2//!
3//! 对齐 PHP `think optimize:route` / `think optimize:config` / `think optimize:schema` / `think route:clear`。
4//!
5//! ## PHP 对齐
6//!
7//! PHP `optimize:route` 将路由配置编译为缓存文件(`runtime/route.php`),
8//! 启动时直接加载缓存,跳过路由解析,加速分发。
9//!
10//! PHP `optimize:config` 将所有配置文件合并编译为缓存文件(`runtime/config.php`),
11//! 启动时直接加载缓存,跳过逐文件扫描。
12//!
13//! PHP `optimize:schema` 扫描所有 Model,将数据表字段元数据缓存到文件
14//! (`runtime/schema/`),避免运行时反复 `SHOW COLUMNS FROM` 查询。
15//!
16//! PHP `route:clear` 删除路由缓存文件,使下次启动重新解析路由。
17//!
18//! ## Rust 实现
19//!
20//! Rust 端路由通过代码注册(编译期确定),运行时无需解析配置文件。
21//! 但为对齐 PHP 命令体系,本模块实现:
22//!
23//! 1. `optimize:route` — 收集路由元数据,序列化为 JSON 缓存文件
24//!    (`runtime/cache/route_cache.json`),供 `route:list` 等命令快速查询
25//! 2. `optimize:config` — 扫描配置目录,合并所有配置,序列化为 JSON 缓存文件
26//!    (`runtime/cache/config_cache.json`),供运行时快速加载
27//! 3. `optimize:schema` — 读取数据库连接配置,生成 schema 缓存索引文件
28//!    (`runtime/schema_cache.json` + `runtime/schema_cache.php`),
29//!    业务方运行时通过 `SchemaCache::remember_schema()` 填充具体字段信息
30//! 4. `route:clear` — 删除路由缓存文件
31
32use std::path::{Path, PathBuf};
33
34use crate::cmd::route;
35use crate::error::CliError;
36
37/// 缓存目录(对齐 PHP `runtime/cache`)
38const CACHE_DIR: &str = "runtime/cache";
39
40/// 路由缓存文件名
41const ROUTE_CACHE_FILE: &str = "route_cache.json";
42
43/// 配置缓存文件名
44const CONFIG_CACHE_FILE: &str = "config_cache.json";
45
46/// 配置目录(对齐 PHP `config/`)
47const CONFIG_DIR: &str = "config";
48
49/// Schema 缓存文件名(对齐 PHP `think optimize:schema` 输出)
50const SCHEMA_CACHE_FILE: &str = "schema_cache.json";
51
52/// PHP 兼容的 schema 缓存索引文件名
53const SCHEMA_CACHE_PHP_FILE: &str = "schema_cache.php";
54
55/// 数据库配置文件名(对齐 PHP `config/database.php`,项目实际为 YAML)
56const DATABASE_CONFIG_FILE: &str = "database.yml";
57
58/// Schema 缓存运行时目录(对齐 PHP `runtime/`)
59const RUNTIME_DIR: &str = "runtime";
60
61/// 执行 optimize:route 命令
62///
63/// 收集路由元数据,序列化为 JSON 写入 `runtime/cache/route_cache.json`。
64///
65/// # 流程
66///
67/// 1. 收集预定义路由(复用 `route::collect_routes()`)
68/// 2. 序列化为美化格式 JSON
69/// 3. 写入缓存文件(自动创建父目录)
70/// 4. 输出统计信息(路由数量、文件路径)
71pub async fn execute_optimize_route() -> Result<(), CliError> {
72    let routes = route::collect_routes();
73    let route_count = routes.len();
74
75    // 序列化为 JSON(美化格式,便于人工审查)
76    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
103/// 执行 optimize:config 命令
104///
105/// 扫描 `config/` 目录,合并所有 `.php`/`.yaml`/`.json` 配置,
106/// 序列化为 JSON 写入 `runtime/cache/config_cache.json`。
107///
108/// # 流程
109///
110/// 1. 扫描 `config/` 目录
111/// 2. 读取每个配置文件,解析为 JSON Value
112/// 3. 以文件名(不含扩展名)为 key 合并到统一对象
113/// 4. 序列化为美化格式 JSON
114/// 5. 写入缓存文件
115/// 6. 输出统计信息(配置项数量、文件路径)
116pub 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        // 仅处理文件(跳过子目录)
134        if !path.is_file() {
135            continue;
136        }
137
138        // 仅处理支持的配置文件格式
139        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        // 以文件名(不含扩展名)为 key
145        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
171/// 执行 route:clear 命令
172///
173/// 删除路由缓存文件 `runtime/cache/route_cache.json`。
174/// 若文件不存在,输出提示但不报错。
175pub 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
189/// 执行 optimize:schema 命令 — 生成数据表字段缓存文件
190///
191/// 对齐 PHP `think optimize:schema`:
192/// - 扫描 `config/database.yml` 读取数据库连接配置与表前缀
193/// - 生成 schema 缓存索引文件(`runtime/schema_cache.json`)
194/// - 同时生成 PHP 兼容的 schema 缓存索引文件(`runtime/schema_cache.php`)
195/// - 业务方运行时通过 `SchemaCache::remember_schema()` 填充具体字段信息
196///
197/// # 流程
198///
199/// 1. 读取 `config/database.yml`,提取所有连接名、数据库与表前缀
200/// 2. 序列化为美化格式 JSON(含生成时间戳、连接列表、空 tables 数组)
201/// 3. 写入 `runtime/schema_cache.json`
202/// 4. 生成 PHP 兼容索引文件 `runtime/schema_cache.php`
203/// 5. 输出统计信息(连接数量、文件路径)
204///
205/// # 说明
206///
207/// Rust 无法运行时反射获取所有 `Model` 实现类型,因此本命令生成的是 schema
208/// 缓存索引占位文件(`tables` 为空数组),具体字段元数据由运行时
209/// `SchemaCache::remember_schema()` 在首次访问表时回源加载并填充。
210pub 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    // 生成 JSON 缓存索引(tables 为空,运行时由 SchemaCache 填充)
216    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    // 生成 PHP 兼容索引文件(对齐 PHP schema 缓存文件格式)
230    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
242// ============================================================================
243// 辅助函数
244// ============================================================================
245
246/// 获取路由缓存文件路径
247pub fn get_route_cache_path() -> PathBuf {
248    PathBuf::from(CACHE_DIR).join(ROUTE_CACHE_FILE)
249}
250
251/// 获取配置缓存文件路径
252pub fn get_config_cache_path() -> PathBuf {
253    PathBuf::from(CACHE_DIR).join(CONFIG_CACHE_FILE)
254}
255
256/// 获取 schema 缓存文件路径(`runtime/schema_cache.json`)
257pub fn get_schema_cache_path() -> PathBuf {
258    PathBuf::from(RUNTIME_DIR).join(SCHEMA_CACHE_FILE)
259}
260
261/// 获取 PHP 兼容 schema 缓存索引文件路径(`runtime/schema_cache.php`)
262pub fn get_schema_cache_php_path() -> PathBuf {
263    PathBuf::from(RUNTIME_DIR).join(SCHEMA_CACHE_PHP_FILE)
264}
265
266/// 读取数据库连接配置
267///
268/// 扫描 `config/database.yml`,提取所有连接名、数据库名与表前缀。
269/// 返回 `(默认连接名, 连接信息列表)`。
270///
271/// 若配置文件不存在,返回空列表(生成空 schema 缓存索引,不报错)。
272async 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        // 配置文件不存在,返回空连接列表(对齐:optimize:schema 即使无配置也生成占位缓存)
277        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    // 按连接名排序,保证输出稳定(便于人工审查与测试断言)
305    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
315/// 构建 PHP 兼容的 schema 缓存索引文件内容
316///
317/// 对齐 PHP `think optimize:schema` 输出的 schema 缓存文件格式
318/// (`<?php return [...]`),作为索引占位,运行时由 `SchemaCache` 填充。
319fn 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    // 连接列表(空时紧凑输出 `[]`,对齐 PHP 空数组风格)
334    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    // 表字段缓存占位(运行时填充)
351    buf.push_str("    'tables' => [],\n");
352    buf.push_str("];\n");
353
354    buf
355}
356
357/// 写入缓存文件(自动创建父目录)
358async 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
366/// 根据扩展名解析配置文件内容为 JSON Value
367///
368/// 支持格式:
369/// - `json` — 直接解析
370/// - `yaml`/`yml` — YAML 转 JSON
371/// - `php`/`toml` — 提取为字符串(无法在 CLI 中安全解析 PHP,保留原始内容)
372fn 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            // YAML 解析:使用 serde_yaml 转 JSON
378            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            // PHP/TOML 配置无法在 CLI 中安全解析,保留为原始字符串
385            // 运行时由应用自行解析
386            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        // 先生成缓存
494        execute_optimize_route().await.unwrap();
495        assert!(get_route_cache_path().exists());
496
497        // 清除缓存
498        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        // 缓存不存在时应返回 Ok
508        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        // config/ 目录不存在时应返回错误
518        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        // 创建 config/ 目录及配置文件
528        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    // ---------- optimize:schema 测试 ----------
575
576    #[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        // 无 config/database.yml 时也应成功生成空 schema 缓存索引
594        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        // 无配置时默认连接名为空、连接列表为空数组
606        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        // PHP 兼容索引文件也应生成
611        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        // 创建 config/database.yml(含两个连接)
625        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        // 基本字段校验
651        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        // 连接列表按名称排序:food 在 mysql 之前
657        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        // PHP 索引文件包含连接信息
666        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        // 配置文件不存在时返回空列表
680        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        // .txt 文件应被跳过
730        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        // 只应有 app 这一个配置项
738        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    // ---------- 辅助:CwdGuard(避免并行测试工作目录污染) ----------
744
745    use std::sync::MutexGuard;
746
747    /// RAII 守卫:在作用域结束时恢复原始工作目录并释放锁
748    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            // 使用全局互斥锁,避免与 make 模块测试的 set_current_dir 并行竞态
756            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}