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 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)?;
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 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 entries = std::fs::read_dir(config_dir)?;
130    for entry in entries {
131        let entry = entry?;
132        let path = entry.path();
133
134        // 仅处理文件(跳过子目录)
135        if !path.is_file() {
136            continue;
137        }
138
139        // 仅处理支持的配置文件格式
140        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
141        if !matches!(ext, "php" | "yaml" | "yml" | "json" | "toml") {
142            continue;
143        }
144
145        // 以文件名(不含扩展名)为 key
146        let stem = path
147            .file_stem()
148            .and_then(|s| s.to_str())
149            .unwrap_or("unknown")
150            .to_string();
151
152        let content = std::fs::read_to_string(&path)?;
153        let config_value = parse_config_file(&content, ext)?;
154        merged.insert(stem, config_value);
155        file_count += 1;
156    }
157
158    let content = serde_json::to_string_pretty(&serde_json::Value::Object(merged))
159        .map_err(|e| CliError::Generic(format!("配置缓存序列化失败: {}", e)))?;
160
161    let cache_path = get_config_cache_path();
162    write_cache_file(&cache_path, &content)?;
163
164    println!(
165        "Config cache generated: {} file(s) → {}",
166        file_count,
167        cache_path.display()
168    );
169    Ok(())
170}
171
172/// 执行 route:clear 命令
173///
174/// 删除路由缓存文件 `runtime/cache/route_cache.json`。
175/// 若文件不存在,输出提示但不报错。
176pub fn execute_route_clear() -> Result<(), CliError> {
177    let cache_path = get_route_cache_path();
178
179    if !cache_path.exists() {
180        println!("Route cache not found: {}", cache_path.display());
181        println!("Nothing to clear.");
182        return Ok(());
183    }
184
185    std::fs::remove_file(&cache_path)?;
186    println!("Route cache cleared: {}", cache_path.display());
187    Ok(())
188}
189
190/// 执行 optimize:schema 命令 — 生成数据表字段缓存文件
191///
192/// 对齐 PHP `think optimize:schema`:
193/// - 扫描 `config/database.yml` 读取数据库连接配置与表前缀
194/// - 生成 schema 缓存索引文件(`runtime/schema_cache.json`)
195/// - 同时生成 PHP 兼容的 schema 缓存索引文件(`runtime/schema_cache.php`)
196/// - 业务方运行时通过 `SchemaCache::remember_schema()` 填充具体字段信息
197///
198/// # 流程
199///
200/// 1. 读取 `config/database.yml`,提取所有连接名、数据库与表前缀
201/// 2. 序列化为美化格式 JSON(含生成时间戳、连接列表、空 tables 数组)
202/// 3. 写入 `runtime/schema_cache.json`
203/// 4. 生成 PHP 兼容索引文件 `runtime/schema_cache.php`
204/// 5. 输出统计信息(连接数量、文件路径)
205///
206/// # 说明
207///
208/// Rust 无法运行时反射获取所有 `Model` 实现类型,因此本命令生成的是 schema
209/// 缓存索引占位文件(`tables` 为空数组),具体字段元数据由运行时
210/// `SchemaCache::remember_schema()` 在首次访问表时回源加载并填充。
211pub fn execute_optimize_schema() -> Result<(), CliError> {
212    let (default_connection, connections) = read_database_connections()?;
213    let connection_count = connections.len();
214    let generated_at = chrono::Utc::now().to_rfc3339();
215
216    // 生成 JSON 缓存索引(tables 为空,运行时由 SchemaCache 填充)
217    let cache = serde_json::json!({
218        "generated_at": generated_at,
219        "default_connection": default_connection,
220        "connections": connections,
221        "tables": [],
222    });
223
224    let content = serde_json::to_string_pretty(&cache)
225        .map_err(|e| CliError::Generic(format!("schema 缓存序列化失败: {}", e)))?;
226
227    let cache_path = get_schema_cache_path();
228    write_cache_file(&cache_path, &content)?;
229
230    // 生成 PHP 兼容索引文件(对齐 PHP schema 缓存文件格式)
231    let php_content = build_php_schema_index(&generated_at, &connections);
232    let php_path = get_schema_cache_php_path();
233    write_cache_file(&php_path, &php_content)?;
234
235    println!(
236        "Schema cache generated: {} connection(s) → {}",
237        connection_count,
238        cache_path.display()
239    );
240    Ok(())
241}
242
243// ============================================================================
244// 辅助函数
245// ============================================================================
246
247/// 获取路由缓存文件路径
248pub fn get_route_cache_path() -> PathBuf {
249    PathBuf::from(CACHE_DIR).join(ROUTE_CACHE_FILE)
250}
251
252/// 获取配置缓存文件路径
253pub fn get_config_cache_path() -> PathBuf {
254    PathBuf::from(CACHE_DIR).join(CONFIG_CACHE_FILE)
255}
256
257/// 获取 schema 缓存文件路径(`runtime/schema_cache.json`)
258pub fn get_schema_cache_path() -> PathBuf {
259    PathBuf::from(RUNTIME_DIR).join(SCHEMA_CACHE_FILE)
260}
261
262/// 获取 PHP 兼容 schema 缓存索引文件路径(`runtime/schema_cache.php`)
263pub fn get_schema_cache_php_path() -> PathBuf {
264    PathBuf::from(RUNTIME_DIR).join(SCHEMA_CACHE_PHP_FILE)
265}
266
267/// 读取数据库连接配置
268///
269/// 扫描 `config/database.yml`,提取所有连接名、数据库名与表前缀。
270/// 返回 `(默认连接名, 连接信息列表)`。
271///
272/// 若配置文件不存在,返回空列表(生成空 schema 缓存索引,不报错)。
273fn read_database_connections() -> Result<(String, Vec<serde_json::Value>), CliError> {
274    let path = Path::new(CONFIG_DIR).join(DATABASE_CONFIG_FILE);
275
276    if !path.exists() {
277        // 配置文件不存在,返回空连接列表(对齐:optimize:schema 即使无配置也生成占位缓存)
278        return Ok((String::new(), Vec::new()));
279    }
280
281    let content = std::fs::read_to_string(&path)?;
282    let yaml: serde_yml::Value = serde_yml::from_str(&content)
283        .map_err(|e| CliError::Generic(format!("数据库配置解析失败: {}", e)))?;
284    let json = serde_json::to_value(yaml)
285        .map_err(|e| CliError::Generic(format!("YAML→JSON 转换失败: {}", e)))?;
286
287    let default_connection = json
288        .get("default")
289        .and_then(|v| v.as_str())
290        .unwrap_or("")
291        .to_string();
292
293    let mut connections: Vec<serde_json::Value> = Vec::new();
294    if let Some(conns) = json.get("connections").and_then(|c| c.as_object()) {
295        for (name, info) in conns {
296            connections.push(serde_json::json!({
297                "name": name,
298                "database": info.get("database").and_then(|v| v.as_str()).unwrap_or(""),
299                "prefix": info.get("prefix").and_then(|v| v.as_str()).unwrap_or(""),
300                "type": info.get("type").and_then(|v| v.as_str()).unwrap_or(""),
301            }));
302        }
303    }
304
305    // 按连接名排序,保证输出稳定(便于人工审查与测试断言)
306    connections.sort_by(|a, b| {
307        a["name"]
308            .as_str()
309            .unwrap_or("")
310            .cmp(b["name"].as_str().unwrap_or(""))
311    });
312
313    Ok((default_connection, connections))
314}
315
316/// 构建 PHP 兼容的 schema 缓存索引文件内容
317///
318/// 对齐 PHP `think optimize:schema` 输出的 schema 缓存文件格式
319/// (`<?php return [...]`),作为索引占位,运行时由 `SchemaCache` 填充。
320fn build_php_schema_index(generated_at: &str, connections: &[serde_json::Value]) -> String {
321    let mut buf = String::new();
322    buf.push_str("<?php\n");
323    buf.push_str("// Schema 缓存索引 — 由 sz-rust optimize:schema 生成\n");
324    buf.push_str("// 生成时间: ");
325    buf.push_str(generated_at);
326    buf.push('\n');
327    buf.push_str("// 业务方运行时通过 SchemaCache::remember_schema() 填充具体字段信息\n\n");
328
329    buf.push_str("return [\n");
330    buf.push_str("    'generated_at' => '");
331    buf.push_str(generated_at);
332    buf.push_str("',\n");
333
334    // 连接列表(空时紧凑输出 `[]`,对齐 PHP 空数组风格)
335    if connections.is_empty() {
336        buf.push_str("    'connections' => [],\n");
337    } else {
338        buf.push_str("    'connections' => [\n");
339        for conn in connections {
340            let name = conn["name"].as_str().unwrap_or("");
341            let database = conn["database"].as_str().unwrap_or("");
342            let prefix = conn["prefix"].as_str().unwrap_or("");
343            buf.push_str(&format!(
344                "        ['name' => '{}', 'database' => '{}', 'prefix' => '{}'],\n",
345                name, database, prefix
346            ));
347        }
348        buf.push_str("    ],\n");
349    }
350
351    // 表字段缓存占位(运行时填充)
352    buf.push_str("    'tables' => [],\n");
353    buf.push_str("];\n");
354
355    buf
356}
357
358/// 写入缓存文件(自动创建父目录)
359fn write_cache_file(path: &Path, content: &str) -> Result<(), CliError> {
360    if let Some(parent) = path.parent() {
361        std::fs::create_dir_all(parent)?;
362    }
363    std::fs::write(path, content)?;
364    Ok(())
365}
366
367/// 根据扩展名解析配置文件内容为 JSON Value
368///
369/// 支持格式:
370/// - `json` — 直接解析
371/// - `yaml`/`yml` — YAML 转 JSON
372/// - `php`/`toml` — 提取为字符串(无法在 CLI 中安全解析 PHP,保留原始内容)
373fn parse_config_file(content: &str, ext: &str) -> Result<serde_json::Value, CliError> {
374    match ext {
375        "json" => serde_json::from_str(content)
376            .map_err(|e| CliError::Generic(format!("JSON 配置解析失败: {}", e))),
377        "yaml" | "yml" => {
378            // YAML 解析:使用 serde_yml 转 JSON
379            let yaml: serde_yml::Value = serde_yml::from_str(content)
380                .map_err(|e| CliError::Generic(format!("YAML 配置解析失败: {}", e)))?;
381            serde_json::to_value(yaml)
382                .map_err(|e| CliError::Generic(format!("YAML→JSON 转换失败: {}", e)))
383        }
384        "php" | "toml" => {
385            // PHP/TOML 配置无法在 CLI 中安全解析,保留为原始字符串
386            // 运行时由应用自行解析
387            Ok(serde_json::Value::String(content.to_string()))
388        }
389        _ => Ok(serde_json::Value::Null),
390    }
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396
397    #[test]
398    fn test_get_route_cache_path() {
399        let path = get_route_cache_path();
400        assert!(path.ends_with("runtime/cache/route_cache.json"));
401    }
402
403    #[test]
404    fn test_get_config_cache_path() {
405        let path = get_config_cache_path();
406        assert!(path.ends_with("runtime/cache/config_cache.json"));
407    }
408
409    #[test]
410    fn test_write_cache_file_creates_parent_dirs() {
411        let temp = tempfile::tempdir().unwrap();
412        let nested = temp.path().join("nested").join("deep").join("cache.json");
413
414        write_cache_file(&nested, r#"{"key":"value"}"#).unwrap();
415
416        assert!(nested.exists());
417        let content = std::fs::read_to_string(&nested).unwrap();
418        assert_eq!(content, r#"{"key":"value"}"#);
419    }
420
421    #[test]
422    fn test_parse_config_file_json() {
423        let json = r#"{"name":"app","port":8080}"#;
424        let value = parse_config_file(json, "json").unwrap();
425        assert_eq!(value["name"], "app");
426        assert_eq!(value["port"], 8080);
427    }
428
429    #[test]
430    fn test_parse_config_file_yaml() {
431        let yaml = "name: app\nport: 8080\n";
432        let value = parse_config_file(yaml, "yaml").unwrap();
433        assert_eq!(value["name"], "app");
434        assert_eq!(value["port"], 8080);
435    }
436
437    #[test]
438    fn test_parse_config_file_php_preserves_raw_content() {
439        let php = "<?php return ['name' => 'app'];";
440        let value = parse_config_file(php, "php").unwrap();
441        assert!(value.is_string());
442        assert!(value.as_str().unwrap().contains("<?php"));
443    }
444
445    #[test]
446    fn test_parse_config_file_toml_preserves_raw_content() {
447        let toml = "[server]\nport = 8080\n";
448        let value = parse_config_file(toml, "toml").unwrap();
449        assert!(value.is_string());
450        assert!(value.as_str().unwrap().contains("[server]"));
451    }
452
453    #[test]
454    fn test_parse_config_file_unsupported_returns_null() {
455        let value = parse_config_file("content", "txt").unwrap();
456        assert!(value.is_null());
457    }
458
459    #[test]
460    fn test_parse_config_file_invalid_json() {
461        let result = parse_config_file("{invalid}", "json");
462        assert!(matches!(result, Err(CliError::Generic(_))));
463    }
464
465    #[test]
466    fn test_parse_config_file_invalid_yaml() {
467        let result = parse_config_file(":\n : bad", "yaml");
468        assert!(matches!(result, Err(CliError::Generic(_))));
469    }
470
471    #[test]
472    fn test_execute_optimize_route_creates_cache_file() {
473        let temp = tempfile::tempdir().unwrap();
474        let _guard = CwdGuard::switch(temp.path()).unwrap();
475
476        execute_optimize_route().unwrap();
477
478        let cache_path = get_route_cache_path();
479        assert!(cache_path.exists());
480
481        let content = std::fs::read_to_string(&cache_path).unwrap();
482        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
483        assert!(json.is_array());
484        assert!(!json.as_array().unwrap().is_empty());
485    }
486
487    #[test]
488    fn test_execute_route_clear_removes_cache_file() {
489        let temp = tempfile::tempdir().unwrap();
490        let _guard = CwdGuard::switch(temp.path()).unwrap();
491
492        // 先生成缓存
493        execute_optimize_route().unwrap();
494        assert!(get_route_cache_path().exists());
495
496        // 清除缓存
497        execute_route_clear().unwrap();
498        assert!(!get_route_cache_path().exists());
499    }
500
501    #[test]
502    fn test_execute_route_clear_nonexistent_cache() {
503        let temp = tempfile::tempdir().unwrap();
504        let _guard = CwdGuard::switch(temp.path()).unwrap();
505
506        // 缓存不存在时应返回 Ok
507        let result = execute_route_clear();
508        assert!(result.is_ok());
509    }
510
511    #[test]
512    fn test_execute_optimize_config_no_config_dir() {
513        let temp = tempfile::tempdir().unwrap();
514        let _guard = CwdGuard::switch(temp.path()).unwrap();
515
516        // config/ 目录不存在时应返回错误
517        let result = execute_optimize_config();
518        assert!(matches!(result, Err(CliError::Generic(_))));
519    }
520
521    #[test]
522    fn test_execute_optimize_config_with_json_files() {
523        let temp = tempfile::tempdir().unwrap();
524        let _guard = CwdGuard::switch(temp.path()).unwrap();
525
526        // 创建 config/ 目录及配置文件
527        let config_dir = temp.path().join("config");
528        std::fs::create_dir_all(&config_dir).unwrap();
529        std::fs::write(
530            config_dir.join("app.json"),
531            r#"{"name":"test","debug":true}"#,
532        )
533        .unwrap();
534        std::fs::write(
535            config_dir.join("database.json"),
536            r#"{"host":"localhost","port":5432}"#,
537        )
538        .unwrap();
539
540        execute_optimize_config().unwrap();
541
542        let cache_path = get_config_cache_path();
543        assert!(cache_path.exists());
544
545        let content = std::fs::read_to_string(&cache_path).unwrap();
546        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
547        assert_eq!(json["app"]["name"], "test");
548        assert_eq!(json["app"]["debug"], true);
549        assert_eq!(json["database"]["host"], "localhost");
550        assert_eq!(json["database"]["port"], 5432);
551    }
552
553    #[test]
554    fn test_execute_optimize_config_with_yaml_files() {
555        let temp = tempfile::tempdir().unwrap();
556        let _guard = CwdGuard::switch(temp.path()).unwrap();
557
558        let config_dir = temp.path().join("config");
559        std::fs::create_dir_all(&config_dir).unwrap();
560        std::fs::write(config_dir.join("cache.yaml"), "driver: redis\nttl: 3600\n").unwrap();
561
562        execute_optimize_config().unwrap();
563
564        let cache_path = get_config_cache_path();
565        assert!(cache_path.exists());
566
567        let content = std::fs::read_to_string(&cache_path).unwrap();
568        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
569        assert_eq!(json["cache"]["driver"], "redis");
570        assert_eq!(json["cache"]["ttl"], 3600);
571    }
572
573    // ---------- optimize:schema 测试 ----------
574
575    #[test]
576    fn test_get_schema_cache_path() {
577        let path = get_schema_cache_path();
578        assert!(path.ends_with("runtime/schema_cache.json"));
579    }
580
581    #[test]
582    fn test_get_schema_cache_php_path() {
583        let path = get_schema_cache_php_path();
584        assert!(path.ends_with("runtime/schema_cache.php"));
585    }
586
587    #[test]
588    fn test_execute_optimize_schema_no_config() {
589        let temp = tempfile::tempdir().unwrap();
590        let _guard = CwdGuard::switch(temp.path()).unwrap();
591
592        // 无 config/database.yml 时也应成功生成空 schema 缓存索引
593        execute_optimize_schema().unwrap();
594
595        let cache_path = get_schema_cache_path();
596        assert!(cache_path.exists());
597
598        let content = std::fs::read_to_string(&cache_path).unwrap();
599        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
600        assert!(json.is_object());
601        assert!(json["generated_at"].is_string());
602        assert!(json["tables"].is_array());
603        assert_eq!(json["tables"].as_array().unwrap().len(), 0);
604        // 无配置时默认连接名为空、连接列表为空数组
605        assert_eq!(json["default_connection"].as_str(), Some(""));
606        assert!(json["connections"].is_array());
607        assert_eq!(json["connections"].as_array().unwrap().len(), 0);
608
609        // PHP 兼容索引文件也应生成
610        let php_path = get_schema_cache_php_path();
611        assert!(php_path.exists());
612        let php_content = std::fs::read_to_string(&php_path).unwrap();
613        assert!(php_content.starts_with("<?php"));
614        assert!(php_content.contains("return ["));
615        assert!(php_content.contains("'tables' => []"));
616    }
617
618    #[test]
619    fn test_optimize_schema_generates_valid_json() {
620        let temp = tempfile::tempdir().unwrap();
621        let _guard = CwdGuard::switch(temp.path()).unwrap();
622
623        // 创建 config/database.yml(含两个连接)
624        let config_dir = temp.path().join("config");
625        std::fs::create_dir_all(&config_dir).unwrap();
626        std::fs::write(
627            config_dir.join("database.yml"),
628            "default: mysql\n\
629             connections:\n\
630             \x20 mysql:\n\
631             \x20   type: mysql\n\
632             \x20   database: shop\n\
633             \x20   prefix: sz_\n\
634             \x20 food:\n\
635             \x20   type: mysql\n\
636             \x20   database: food\n\
637             \x20   prefix: sz_food_\n",
638        )
639        .unwrap();
640
641        execute_optimize_schema().unwrap();
642
643        let cache_path = get_schema_cache_path();
644        assert!(cache_path.exists());
645
646        let content = std::fs::read_to_string(&cache_path).unwrap();
647        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
648
649        // 基本字段校验
650        assert!(json["generated_at"].is_string());
651        assert_eq!(json["default_connection"].as_str(), Some("mysql"));
652        assert!(json["tables"].is_array());
653        assert_eq!(json["tables"].as_array().unwrap().len(), 0);
654
655        // 连接列表按名称排序:food 在 mysql 之前
656        let conns = json["connections"].as_array().unwrap();
657        assert_eq!(conns.len(), 2);
658        assert_eq!(conns[0]["name"].as_str(), Some("food"));
659        assert_eq!(conns[0]["prefix"].as_str(), Some("sz_food_"));
660        assert_eq!(conns[1]["name"].as_str(), Some("mysql"));
661        assert_eq!(conns[1]["database"].as_str(), Some("shop"));
662        assert_eq!(conns[1]["prefix"].as_str(), Some("sz_"));
663
664        // PHP 索引文件包含连接信息
665        let php_path = get_schema_cache_php_path();
666        let php_content = std::fs::read_to_string(&php_path).unwrap();
667        assert!(php_content.contains("'name' => 'mysql'"));
668        assert!(php_content.contains("'prefix' => 'sz_'"));
669        assert!(php_content.contains("'name' => 'food'"));
670        assert!(php_content.contains("'database' => 'shop'"));
671    }
672
673    #[test]
674    fn test_read_database_connections_missing_file() {
675        let temp = tempfile::tempdir().unwrap();
676        let _guard = CwdGuard::switch(temp.path()).unwrap();
677
678        // 配置文件不存在时返回空列表
679        let (default, conns) = read_database_connections().unwrap();
680        assert_eq!(default, "");
681        assert!(conns.is_empty());
682    }
683
684    #[test]
685    fn test_read_database_connections_invalid_yaml() {
686        let temp = tempfile::tempdir().unwrap();
687        let _guard = CwdGuard::switch(temp.path()).unwrap();
688
689        let config_dir = temp.path().join("config");
690        std::fs::create_dir_all(&config_dir).unwrap();
691        std::fs::write(config_dir.join("database.yml"), ":\n : bad").unwrap();
692
693        let result = read_database_connections();
694        assert!(matches!(result, Err(CliError::Generic(_))));
695    }
696
697    #[test]
698    fn test_build_php_schema_index_empty() {
699        let content = build_php_schema_index("2026-07-31T00:00:00+00:00", &[]);
700        assert!(content.starts_with("<?php"));
701        assert!(content.contains("'generated_at' => '2026-07-31T00:00:00+00:00'"));
702        assert!(content.contains("'connections' => []"));
703        assert!(content.contains("'tables' => []"));
704    }
705
706    #[test]
707    fn test_build_php_schema_index_with_connections() {
708        let connections = vec![
709            serde_json::json!({"name": "mysql", "database": "shop", "prefix": "sz_", "type": "mysql"}),
710            serde_json::json!({"name": "food", "database": "food", "prefix": "sz_food_", "type": "mysql"}),
711        ];
712        let content = build_php_schema_index("2026-07-31T00:00:00+00:00", &connections);
713        assert!(content.contains("'name' => 'mysql'"));
714        assert!(content.contains("'database' => 'shop'"));
715        assert!(content.contains("'prefix' => 'sz_'"));
716        assert!(content.contains("'name' => 'food'"));
717        assert!(content.contains("'prefix' => 'sz_food_'"));
718    }
719
720    #[test]
721    fn test_execute_optimize_config_skips_unsupported_files() {
722        let temp = tempfile::tempdir().unwrap();
723        let _guard = CwdGuard::switch(temp.path()).unwrap();
724
725        let config_dir = temp.path().join("config");
726        std::fs::create_dir_all(&config_dir).unwrap();
727        std::fs::write(config_dir.join("app.json"), r#"{"name":"test"}"#).unwrap();
728        // .txt 文件应被跳过
729        std::fs::write(config_dir.join("readme.txt"), "not a config").unwrap();
730
731        execute_optimize_config().unwrap();
732
733        let cache_path = get_config_cache_path();
734        let content = std::fs::read_to_string(&cache_path).unwrap();
735        let json: serde_json::Value = serde_json::from_str(&content).unwrap();
736        // 只应有 app 这一个配置项
737        assert_eq!(json.as_object().unwrap().len(), 1);
738        assert!(json.get("app").is_some());
739        assert!(json.get("readme").is_none());
740    }
741
742    // ---------- 辅助:CwdGuard(避免并行测试工作目录污染) ----------
743
744    use std::sync::MutexGuard;
745
746    /// RAII 守卫:在作用域结束时恢复原始工作目录并释放锁
747    struct CwdGuard {
748        original: Option<PathBuf>,
749        _lock: MutexGuard<'static, ()>,
750    }
751
752    impl CwdGuard {
753        fn switch(new_dir: &Path) -> std::io::Result<Self> {
754            // 使用全局互斥锁,避免与 make 模块测试的 set_current_dir 并行竞态
755            let lock = super::super::test_support::acquire_global_lock();
756            let original = std::env::current_dir().ok();
757            std::env::set_current_dir(new_dir)?;
758            Ok(Self {
759                original,
760                _lock: lock,
761            })
762        }
763    }
764
765    impl Drop for CwdGuard {
766        fn drop(&mut self) {
767            if let Some(ref orig) = self.original {
768                let _ = std::env::set_current_dir(orig);
769            }
770        }
771    }
772}