use std::path::{Path, PathBuf};
use std::process::Command;
fn mock_script(stdout: &str, stderr: &str, exit_code: i32) -> String {
format!(
"#!/bin/sh\ncat <<'ENDMOCK'\n{stdout}\nENDMOCK\ncat <<'ENDMOCK' >&2\n{stderr}\nENDMOCK\nexit {exit_code}\n",
)
}
fn mock_not_found() -> String {
"#!/bin/sh\nexit 127\n".into()
}
fn mock_custom(body: &str) -> String {
format!("#!/bin/sh\n{body}\n")
}
fn with_mock_env<F: FnOnce() -> R, R>(scripts: &[(&str, &str)], f: F) -> R {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
let _guard = LOCK
.get_or_init(|| std::sync::Mutex::new(()))
.lock()
.unwrap();
let dir = tempfile::tempdir().expect("创建 temp dir");
let bin = dir.path().join("bin");
std::fs::create_dir(&bin).expect("创建 bin/");
for (name, body) in scripts {
let path = bin.join(name);
std::fs::write(&path, body).unwrap_or_else(|e| panic!("写入 mock {name}: {e}"));
#[cfg(unix)]
Command::new("chmod")
.args(["+x", path.to_str().unwrap()])
.output()
.expect("chmod +x");
}
let old_path = std::env::var("PATH").unwrap_or_default();
std::env::set_var("PATH", format!("{}:{}", bin.display(), old_path));
let result = f();
std::env::set_var("PATH", &old_path);
result
}
fn git_init(path: &Path) {
Command::new("git")
.args(["init", "-b", "main"])
.current_dir(path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "mock@test"])
.current_dir(path)
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "Mock"])
.current_dir(path)
.output()
.unwrap();
std::fs::write(path.join(".gitkeep"), "").unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(path)
.output()
.unwrap();
}
fn setup_repo() -> (tempfile::TempDir, PathBuf) {
let d = tempfile::tempdir().expect("temp dir");
let path = d.path().to_path_buf();
git_init(&path);
(d, path)
}
fn setup_repo_with_contract() -> (tempfile::TempDir, PathBuf) {
let d = tempfile::tempdir().expect("temp dir");
let path = d.path().to_path_buf();
git_init(&path);
let contract_dir = path.join(".quanttide/devops");
std::fs::create_dir_all(&contract_dir).unwrap();
std::fs::write(
contract_dir.join("contract.yaml"),
"stages:\n build:\n command: cargo build\n test:\n command: cargo test\n threshold: 80\n release:\n changelog: CHANGELOG.md\nplatform:\n source_control: github\n pipeline: github_actions\n artifact_registry: crates\nsources:\n version:\n type: cargo\nscopes:\n cli:\n dir: .\n language: rust\n build_tool: cargo\n registry: crates\n",
)
.unwrap();
Command::new("git")
.args(["add", "."])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "add contract"])
.current_dir(&path)
.output()
.unwrap();
(d, path)
}
#[test]
fn test_build_status_gh_not_found() {
let (_d, path) = setup_repo_with_contract();
with_mock_env(&[("gh", &mock_not_found())], || {
qtcloud_devops_cli::build::status(&path);
});
}
#[test]
fn test_build_status_gh_empty_array() {
let (_d, path) = setup_repo_with_contract();
with_mock_env(&[("gh", &mock_script("[]", "", 0))], || {
qtcloud_devops_cli::build::status(&path);
});
}
#[test]
fn test_build_status_gh_success_run() {
let (_d, path) = setup_repo_with_contract();
let gh_out =
r#"[{"conclusion":"success","displayTitle":"CI","headBranch":"main","number":42}]"#;
with_mock_env(&[("gh", &mock_script(gh_out, "", 0))], || {
qtcloud_devops_cli::build::status(&path);
});
}
#[test]
fn test_build_status_gh_failed_run() {
let (_d, path) = setup_repo_with_contract();
let gh_out =
r#"[{"conclusion":"failure","displayTitle":"Build","headBranch":"feat/x","number":7}]"#;
with_mock_env(&[("gh", &mock_script(gh_out, "", 0))], || {
qtcloud_devops_cli::build::status(&path);
});
}
#[test]
fn test_build_status_gh_cancelled_run() {
let (_d, path) = setup_repo_with_contract();
let gh_out =
r#"[{"conclusion":"cancelled","displayTitle":"CI","headBranch":"main","number":99}]"#;
with_mock_env(&[("gh", &mock_script(gh_out, "", 0))], || {
qtcloud_devops_cli::build::status(&path);
});
}
#[test]
fn test_build_status_gh_unknown_conclusion() {
let (_d, path) = setup_repo_with_contract();
let gh_out =
r#"[{"conclusion":"neutral","displayTitle":"Check","headBranch":"main","number":1}]"#;
with_mock_env(&[("gh", &mock_script(gh_out, "", 0))], || {
qtcloud_devops_cli::build::status(&path);
});
}
#[test]
fn test_build_status_cargo_check_success() {
let (_d, path) = setup_repo();
std::fs::write(
path.join("Cargo.toml"),
"[package]\nname = \"test\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let cargo_ok = mock_custom("exit 0");
with_mock_env(&[("cargo", &cargo_ok)], || {
qtcloud_devops_cli::build::status(&path);
});
}
#[test]
fn test_build_status_cargo_check_failure() {
let (_d, path) = setup_repo();
std::fs::write(
path.join("Cargo.toml"),
"[package]\nname = \"test\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let cargo_fail = mock_custom("exit 1");
with_mock_env(&[("cargo", &cargo_fail)], || {
qtcloud_devops_cli::build::status(&path);
});
}
#[test]
fn test_build_status_no_manifest_skips_cargo() {
let (_d, path) = setup_repo();
qtcloud_devops_cli::build::status(&path);
}
#[test]
fn test_test_run_rust_coverage_handles_tests() {
let (_d, path) = setup_repo();
std::fs::write(
path.join("Cargo.toml"),
"[package]\nname = \"test\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let cargo_ok = mock_custom("exit 0");
with_mock_env(&[("cargo", &cargo_ok)], || {
let result = qtcloud_devops_cli::test::run(&path);
assert!(result.is_ok(), "llvm-cov 成功 → run 应返回 Ok");
});
}
#[test]
fn test_test_run_rust_coverage_fails_propagates_error() {
let (_d, path) = setup_repo();
std::fs::write(
path.join("Cargo.toml"),
"[package]\nname = \"test\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let cargo_fail = mock_custom("exit 1");
with_mock_env(&[("cargo", &cargo_fail)], || {
let result = qtcloud_devops_cli::test::run(&path);
assert!(result.is_err(), "llvm-cov 失败 → run 应返回 Err");
let err = result.unwrap_err();
assert!(err.contains("cargo"), "错误信息应包含命令名: {}", err);
});
}
#[test]
fn test_test_run_python_separate_test_and_coverage() {
let (_d, path) = setup_repo();
std::fs::write(path.join("pyproject.toml"), "[project]\nname = \"test\"\n").unwrap();
let py_ok = mock_custom("exit 0");
let coverage_ok = mock_custom("exit 0");
with_mock_env(&[("python", &py_ok), ("coverage", &coverage_ok)], || {
let result = qtcloud_devops_cli::test::run(&path);
assert!(result.is_ok(), "Python test+coverage 均应通过");
});
}
#[test]
fn test_test_run_python_test_fails() {
let (_d, path) = setup_repo();
std::fs::write(path.join("pyproject.toml"), "[project]\nname = \"test\"\n").unwrap();
let py_fail = mock_custom("exit 1");
let coverage_ok = mock_custom("exit 0");
with_mock_env(&[("python", &py_fail), ("coverage", &coverage_ok)], || {
let result = qtcloud_devops_cli::test::run(&path);
assert!(result.is_err(), "pytest 失败 → run 应返回 Err");
});
}
#[test]
fn test_test_run_empty_dir() {
let (_d, path) = setup_repo();
let result = qtcloud_devops_cli::test::run(&path);
assert!(result.is_ok(), "空目录应返回 Ok");
}
#[test]
fn test_test_run_unknown_lang() {
let (_d, path) = setup_repo();
std::fs::write(path.join("README.md"), "").unwrap();
let result = qtcloud_devops_cli::test::run(&path);
assert!(result.is_ok(), "未知语言应返回 Ok");
}
#[test]
fn test_test_run_multiple_scopes() {
let (_d, path) = setup_repo();
let cli_dir = path.join("packages/cli");
std::fs::create_dir_all(&cli_dir).unwrap();
std::fs::write(
cli_dir.join("Cargo.toml"),
"[package]\nname = \"cli\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let py_dir = path.join("packages/sdk");
std::fs::create_dir_all(&py_dir).unwrap();
std::fs::write(py_dir.join("pyproject.toml"), "[project]\nname = \"sdk\"\n").unwrap();
let contract_dir = path.join(".quanttide/devops");
std::fs::create_dir_all(&contract_dir).unwrap();
std::fs::write(
contract_dir.join("contract.yaml"),
"scopes:\n cli:\n dir: packages/cli\n language: rust\n sdk:\n dir: packages/sdk\n language: python\n",
)
.unwrap();
let cargo_ok = mock_custom("exit 0");
let py_ok = mock_custom("exit 0");
let coverage_ok = mock_custom("exit 0");
with_mock_env(
&[
("cargo", &cargo_ok),
("python", &py_ok),
("coverage", &coverage_ok),
],
|| {
let result = qtcloud_devops_cli::test::run(&path);
assert!(result.is_ok(), "多 scope 全通过");
},
);
}
#[test]
fn test_test_run_scoped_by_cwd() {
let (_d, path) = setup_repo();
let cli_dir = path.join("packages/cli");
let sdk_dir = path.join("packages/sdk");
std::fs::create_dir_all(&cli_dir).unwrap();
std::fs::create_dir_all(&sdk_dir).unwrap();
std::fs::write(
cli_dir.join("Cargo.toml"),
"[package]\nname = \"cli\"\nversion = \"0.1.0\"\n",
)
.unwrap();
std::fs::write(
sdk_dir.join("Cargo.toml"),
"[package]\nname = \"sdk\"\nversion = \"0.2.0\"\n",
)
.unwrap();
let contract_dir = path.join(".quanttide/devops");
std::fs::create_dir_all(&contract_dir).unwrap();
std::fs::write(
contract_dir.join("contract.yaml"),
"scopes:\n cli:\n dir: packages/cli\n language: rust\n sdk:\n dir: packages/sdk\n language: rust\n",
)
.unwrap();
let sentinel = path.join(".cov_sentinel");
let sentinel_path = sentinel.to_string_lossy().to_string();
let cargo_mock = format!("#!/bin/sh\necho \"$PWD\" >> {}\nexit 0\n", sentinel_path);
let old_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&cli_dir).unwrap();
with_mock_env(&[("cargo", &cargo_mock)], || {
let result = qtcloud_devops_cli::test::run(&path);
assert!(result.is_ok(), "test run 应成功");
});
std::env::set_current_dir(&old_cwd).unwrap();
let recorded = std::fs::read_to_string(&sentinel).unwrap_or_default();
let calls: Vec<&str> = recorded.lines().collect();
assert_eq!(calls.len(), 1, "应只触发一次 cargo 调用,实际: {:?}", calls);
assert!(
calls[0].ends_with("packages/cli") || calls[0].ends_with("packages/cli/"),
"cargo 应在 packages/cli 目录运行,实际: {}",
calls[0]
);
}
#[test]
fn test_test_run_captures_cargo_args() {
let (_d, path) = setup_repo();
std::fs::write(
path.join("Cargo.toml"),
"[package]\nname = \"test\"\nversion = \"0.1.0\"\n",
)
.unwrap();
std::fs::create_dir_all(path.join("src")).unwrap();
std::fs::write(path.join("src/lib.rs"), "#[test] fn it_works() {}\n").unwrap();
let sentinel = path.join(".args_sentinel");
let s = sentinel.to_string_lossy().to_string();
let cargo_mock = format!("#!/bin/sh\necho \"$@\" >> {}\nexit 0\n", s);
with_mock_env(&[("cargo", &cargo_mock)], || {
let result = qtcloud_devops_cli::test::run(&path);
assert!(result.is_ok(), "test run 应成功");
});
let recorded = std::fs::read_to_string(&sentinel).unwrap_or_default();
assert!(!recorded.is_empty(), "cargo 应被调用");
assert!(
recorded.contains("llvm-cov"),
"参数应包含 llvm-cov,实际: {}",
recorded
);
assert!(
recorded.contains("--lcov"),
"参数应包含 --lcov,实际: {}",
recorded
);
assert!(
recorded.contains("--output-path"),
"参数应包含 --output-path,实际: {}",
recorded
);
}
#[test]
fn test_test_run_rust_coverage_not_found() {
let (_d, path) = setup_repo();
std::fs::write(
path.join("Cargo.toml"),
"[package]\nname = \"test\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let result = qtcloud_devops_cli::test::run(&path);
assert!(result.is_err(), "cargo 缺失 → 启动失败");
}
#[test]
fn test_test_status_no_contract_empty_dir() {
let (_d, path) = setup_repo();
let c = qtcloud_devops_cli::contract::load(&path);
qtcloud_devops_cli::test::status(&path, &c);
}
#[test]
fn test_test_status_cargo_test_success() {
let (_d, path) = setup_repo();
std::fs::write(
path.join("Cargo.toml"),
"[package]\nname = \"test\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let cargo_out = mock_script("test result: ok. 20 passed; 0 failed; 0 ignored", "", 0);
let c = qtcloud_devops_cli::contract::load(&path);
with_mock_env(&[("cargo", &cargo_out)], || {
qtcloud_devops_cli::test::status(&path, &c);
});
}
#[test]
fn test_test_status_cargo_test_failed() {
let (_d, path) = setup_repo();
std::fs::write(
path.join("Cargo.toml"),
"[package]\nname = \"test\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let cargo_out = mock_script("test result: FAILED. 8 passed; 3 failed; 1 ignored", "", 0);
let c = qtcloud_devops_cli::contract::load(&path);
with_mock_env(&[("cargo", &cargo_out)], || {
qtcloud_devops_cli::test::status(&path, &c);
});
}
#[test]
fn test_release_status_with_tags() {
let (_d, path) = setup_repo_with_contract();
Command::new("git")
.args(["tag", "v1.0.0"])
.current_dir(&path)
.output()
.unwrap();
Command::new("git")
.args(["tag", "cli/v0.2.0"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("CHANGELOG.md"),
"# CHANGELOG\n\n## [0.2.0]\n\ncontent\n",
)
.unwrap();
qtcloud_devops_cli::release::status(&path);
}
#[test]
fn test_release_status_no_tags() {
let (_d, path) = setup_repo_with_contract();
qtcloud_devops_cli::release::status(&path);
}
#[test]
fn test_release_status_non_git_dir() {
let d = tempfile::tempdir().unwrap();
qtcloud_devops_cli::release::status(d.path());
}
#[test]
fn test_create_release_scenarios() {
with_mock_env(&[("gh", &mock_custom("exit 0"))], || {
assert!(qtcloud_devops_cli::release::create_release(
"v1.0.0",
"notes",
"owner/repo"
));
});
with_mock_env(
&[("gh", &mock_custom("echo 'already exists' >&2; exit 1"))],
|| {
assert!(qtcloud_devops_cli::release::create_release(
"v1.0.0", "", "o/r"
));
},
);
with_mock_env(
&[("gh", &mock_custom("echo 'unexpected' >&2; exit 1"))],
|| {
assert!(!qtcloud_devops_cli::release::create_release(
"v1.0.0", "", "o/r"
));
},
);
}
#[test]
fn test_release_status_gh_view_matches() {
let (_d, path) = setup_repo_with_contract();
Command::new("git")
.args(["tag", "v1.0.0"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("CHANGELOG.md"),
"# CHANGELOG\n\n## [1.0.0]\n\ncontent\n",
)
.unwrap();
Command::new("git")
.args([
"remote",
"add",
"origin",
"https://github.com/owner/repo.git",
])
.current_dir(&path)
.output()
.unwrap();
let gh = mock_custom(r#"case "$1/$2" in release/view) echo "content";; *) exit 1;; esac"#);
with_mock_env(&[("gh", &gh)], || {
qtcloud_devops_cli::release::status(&path);
});
}
#[test]
fn test_release_status_gh_view_empty_body() {
let (_d, path) = setup_repo_with_contract();
Command::new("git")
.args(["tag", "v1.0.0"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("CHANGELOG.md"),
"# CHANGELOG\n\n## [1.0.0]\n\ncontent\n",
)
.unwrap();
Command::new("git")
.args([
"remote",
"add",
"origin",
"https://github.com/owner/repo.git",
])
.current_dir(&path)
.output()
.unwrap();
let gh = mock_custom(r#"case "$1/$2" in release/view) echo "";; *) exit 1;; esac"#);
with_mock_env(&[("gh", &gh)], || {
qtcloud_devops_cli::release::status(&path);
});
}
#[test]
fn test_release_status_gh_not_found() {
let (_d, path) = setup_repo_with_contract();
Command::new("git")
.args(["tag", "v1.0.0"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("CHANGELOG.md"),
"# CHANGELOG\n\n## [1.0.0]\n\ncontent\n",
)
.unwrap();
Command::new("git")
.args([
"remote",
"add",
"origin",
"https://github.com/owner/repo.git",
])
.current_dir(&path)
.output()
.unwrap();
with_mock_env(&[("gh", &mock_not_found())], || {
qtcloud_devops_cli::release::status(&path);
});
}
#[test]
fn test_release_status_gh_view_different() {
let (_d, path) = setup_repo_with_contract();
Command::new("git")
.args(["tag", "v1.0.0"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("CHANGELOG.md"),
"# CHANGELOG\n\n## [1.0.0]\n\n原始内容\n",
)
.unwrap();
Command::new("git")
.args([
"remote",
"add",
"origin",
"https://github.com/owner/repo.git",
])
.current_dir(&path)
.output()
.unwrap();
let gh = mock_custom(r#"case "$1/$2" in release/view) echo "不同步的body";; *) exit 1;; esac"#);
with_mock_env(&[("gh", &gh)], || {
qtcloud_devops_cli::release::status(&path);
});
}
#[test]
fn test_release_status_gh_view_no_changelog_entry() {
let (_d, path) = setup_repo_with_contract();
Command::new("git")
.args(["tag", "v2.0.0"])
.current_dir(&path)
.output()
.unwrap();
std::fs::write(
path.join("CHANGELOG.md"),
"# CHANGELOG\n\n## [1.0.0]\n\ncontent\n",
)
.unwrap();
Command::new("git")
.args([
"remote",
"add",
"origin",
"https://github.com/owner/repo.git",
])
.current_dir(&path)
.output()
.unwrap();
let gh = mock_custom(r#"case "$1/$2" in release/view) echo "release body";; *) exit 1;; esac"#);
with_mock_env(&[("gh", &gh)], || {
qtcloud_devops_cli::release::status(&path);
});
}