Skip to main content

sz_rust_cli/cmd/
optimize.rs

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