Skip to main content

sz_rust_cli/
cargo_checker.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2024-2026 SZ-Rust Team
3//
4//! cargo check 编译验证模块
5//!
6//! 对应 design.md 第 2.2.2.4 节,异步执行 `cargo check` 验证生成的插件骨架。
7
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11use crate::error::CliError;
12
13/// cargo check 执行结果
14#[derive(Debug, Clone)]
15pub struct CargoCheckResult {
16    /// 编译是否成功
17    pub success: bool,
18    /// 编译错误列表
19    pub errors: Vec<String>,
20    /// 编译警告列表
21    pub warnings: Vec<String>,
22}
23
24/// cargo check 执行器
25pub struct CargoChecker;
26
27/// cargo check 超时时间(30 秒,对齐铁律 5)
28const CHECK_TIMEOUT: Duration = Duration::from_secs(30);
29
30impl CargoChecker {
31    /// 对指定目录执行 `cargo check`
32    ///
33    /// 使用 `tokio::process::Command` 异步执行,超时 30 秒。
34    ///
35    /// # 错误
36    ///
37    /// - `CliError::Generic("cargo not found")`:cargo 命令不存在
38    /// - `CliError::Generic("cargo check timeout")`:执行超时
39    pub async fn check(plugin_root: &Path) -> Result<CargoCheckResult, CliError> {
40        let output = tokio::time::timeout(
41            CHECK_TIMEOUT,
42            tokio::process::Command::new("cargo")
43                .arg("check")
44                .current_dir(plugin_root)
45                .output(),
46        )
47        .await
48        .map_err(|_| CliError::Generic("cargo check timeout".to_string()))?
49        .map_err(|e| {
50            if e.kind() == std::io::ErrorKind::NotFound {
51                CliError::Generic("cargo not found".to_string())
52            } else {
53                CliError::Io(e)
54            }
55        })?;
56
57        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
58        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
59
60        let mut errors = Vec::new();
61        let mut warnings = Vec::new();
62
63        for line in stderr.lines() {
64            if line.starts_with("error") || line.contains("error[") {
65                errors.push(line.to_string());
66            } else if line.starts_with("warning") {
67                warnings.push(line.to_string());
68            }
69        }
70
71        for line in stdout.lines() {
72            if line.starts_with("error") || line.contains("error[") {
73                errors.push(line.to_string());
74            } else if line.starts_with("warning") {
75                warnings.push(line.to_string());
76            }
77        }
78
79        let success = output.status.success() && errors.is_empty();
80
81        Ok(CargoCheckResult {
82            success,
83            errors,
84            warnings,
85        })
86    }
87
88    /// 回滚已写入的文件
89    ///
90    /// 遍历文件列表逐个删除,删除失败不阻塞流程(记录警告日志)。
91    pub async fn rollback(files: &[PathBuf]) -> Vec<(PathBuf, std::io::Error)> {
92        let mut failures = Vec::new();
93        for file in files {
94            if let Err(e) = tokio::fs::remove_file(file).await {
95                eprintln!("Warning: failed to remove {}: {e}", file.display());
96                failures.push((file.clone(), e));
97            }
98        }
99        failures
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106
107    #[tokio::test]
108    async fn test_check_nonexistent_dir() {
109        let result = CargoChecker::check(Path::new("/nonexistent/path/12345")).await;
110        assert!(result.is_err());
111    }
112
113    #[tokio::test]
114    #[ignore = "需要完整 workspace + sz-orm 路径依赖,CI 中由 Check job 覆盖"]
115    async fn test_check_current_workspace() {
116        let workspace_root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
117            .parent()
118            .unwrap()
119            .to_path_buf();
120        let result = CargoChecker::check(&workspace_root).await;
121        assert!(
122            result.is_ok(),
123            "cargo check should succeed: {:?}",
124            result.err()
125        );
126        let check_result = result.unwrap();
127        assert!(check_result.success, "workspace should compile");
128    }
129
130    #[tokio::test]
131    async fn test_rollback_empty_list() {
132        let failures = CargoChecker::rollback(&[]).await;
133        assert!(failures.is_empty());
134    }
135
136    #[tokio::test]
137    async fn test_rollback_nonexistent_file() {
138        let temp = tempfile::tempdir().expect("tempdir failed");
139        let nonexistent = temp.path().join("nonexistent.rs");
140        let failures = CargoChecker::rollback(&[nonexistent]).await;
141        assert_eq!(failures.len(), 1);
142    }
143
144    #[tokio::test]
145    async fn test_rollback_existing_file() {
146        let temp = tempfile::tempdir().expect("tempdir failed");
147        let file = temp.path().join("test.txt");
148        tokio::fs::write(&file, "test content")
149            .await
150            .expect("write failed");
151        assert!(file.exists());
152
153        let failures = CargoChecker::rollback(std::slice::from_ref(&file)).await;
154        assert!(failures.is_empty());
155        assert!(!file.exists());
156    }
157
158    #[tokio::test]
159    async fn test_check_simple_project_success() {
160        let temp = tempfile::tempdir().expect("tempdir failed");
161        tokio::fs::write(
162            temp.path().join("Cargo.toml"),
163            "[package]\nname = \"test_proj\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\n",
164        )
165        .await
166        .unwrap();
167        tokio::fs::create_dir_all(temp.path().join("src"))
168            .await
169            .unwrap();
170        tokio::fs::write(
171            temp.path().join("src/main.rs"),
172            "fn main() { println!(\"hello\"); }\n",
173        )
174        .await
175        .unwrap();
176
177        let result = CargoChecker::check(temp.path()).await;
178        assert!(result.is_ok(), "cargo check 应成功: {:?}", result.err());
179        let check_result = result.unwrap();
180        assert!(check_result.success, "简单项目应编译成功");
181    }
182
183    #[tokio::test]
184    async fn test_check_project_with_compile_error() {
185        let temp = tempfile::tempdir().expect("tempdir failed");
186        tokio::fs::write(
187            temp.path().join("Cargo.toml"),
188            "[package]\nname = \"test_err\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\n",
189        )
190        .await
191        .unwrap();
192        tokio::fs::create_dir_all(temp.path().join("src"))
193            .await
194            .unwrap();
195        tokio::fs::write(
196            temp.path().join("src/main.rs"),
197            "fn main() { let x: i32 = \"not a number\"; }\n",
198        )
199        .await
200        .unwrap();
201
202        let result = CargoChecker::check(temp.path()).await;
203        assert!(result.is_ok(), "cargo check 应返回结果: {:?}", result.err());
204        let check_result = result.unwrap();
205        assert!(!check_result.success, "有编译错误时 success 应为 false");
206        assert!(!check_result.errors.is_empty(), "应捕获编译错误");
207    }
208}