mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
feat(validation): replace rustfmt toggle with cargo check
This commit is contained in:
@@ -919,7 +919,7 @@ impl Session {
|
||||
"markdownlint" => tools.markdownlint = Some(enable),
|
||||
"hadolint" => tools.hadolint = Some(enable),
|
||||
"yamllint" => tools.yamllint = Some(enable),
|
||||
"rustfmt" => tools.rustfmt = Some(enable),
|
||||
"cargo-check" => tools.cargo_check = Some(enable),
|
||||
"shfmt" => tools.shfmt = Some(enable),
|
||||
"prettier" => tools.prettier = Some(enable),
|
||||
_ => {}
|
||||
|
||||
@@ -270,7 +270,8 @@ pub struct ValidationTools {
|
||||
pub markdownlint: Option<bool>,
|
||||
pub hadolint: Option<bool>,
|
||||
pub yamllint: Option<bool>,
|
||||
pub rustfmt: Option<bool>,
|
||||
#[serde(rename = "cargo-check")]
|
||||
pub cargo_check: Option<bool>,
|
||||
pub shfmt: Option<bool>,
|
||||
pub prettier: Option<bool>,
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ use crate::config_types::{GithubConfig, ValidationConfig};
|
||||
use crate::workflow_validation::maybe_run_actionlint;
|
||||
use codex_apply_patch::{ApplyPatchAction, ApplyPatchFileChange};
|
||||
use serde_json as json;
|
||||
use std::collections::{BTreeSet, HashSet};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::ExitStatus;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -36,20 +38,23 @@ pub fn run_patch_harness(
|
||||
|
||||
// 1) Built-in structural parses (JSON/TOML/YAML).
|
||||
for (path, change) in action.changes() {
|
||||
let (ext, contents_opt) = match change {
|
||||
ApplyPatchFileChange::Add { content } => (path.extension().and_then(|e| e.to_str()), Some(content)),
|
||||
ApplyPatchFileChange::Update { new_content, .. } => (path.extension().and_then(|e| e.to_str()), Some(new_content)),
|
||||
ApplyPatchFileChange::Delete { .. } => (path.extension().and_then(|e| e.to_str()), None),
|
||||
let (analysis_path, contents_opt) = match change {
|
||||
ApplyPatchFileChange::Add { content } => (path.as_path(), Some(content)),
|
||||
ApplyPatchFileChange::Update { new_content, move_path, .. } => (
|
||||
move_path.as_ref().map_or(path.as_path(), |dest| dest.as_path()),
|
||||
Some(new_content),
|
||||
),
|
||||
ApplyPatchFileChange::Delete { .. } => (path.as_path(), None),
|
||||
};
|
||||
|
||||
let Some(contents) = contents_opt else { continue };
|
||||
match ext.unwrap_or("") {
|
||||
match analysis_path.extension().and_then(|e| e.to_str()).unwrap_or("") {
|
||||
"json" => {
|
||||
record_ran("json-parse");
|
||||
if let Err(err) = json::from_str::<json::Value>(contents) {
|
||||
findings.push(HarnessFinding {
|
||||
tool: "json-parse".to_string(),
|
||||
file: Some(path.clone()),
|
||||
file: Some(analysis_path.to_path_buf()),
|
||||
message: format!("invalid JSON: {err}"),
|
||||
});
|
||||
}
|
||||
@@ -59,7 +64,7 @@ pub fn run_patch_harness(
|
||||
if let Err(err) = toml::from_str::<toml::Value>(contents) {
|
||||
findings.push(HarnessFinding {
|
||||
tool: "toml-parse".to_string(),
|
||||
file: Some(path.clone()),
|
||||
file: Some(analysis_path.to_path_buf()),
|
||||
message: format!("invalid TOML: {err}"),
|
||||
});
|
||||
}
|
||||
@@ -69,7 +74,7 @@ pub fn run_patch_harness(
|
||||
if let Err(err) = serde_yaml::from_str::<serde_yaml::Value>(contents) {
|
||||
findings.push(HarnessFinding {
|
||||
tool: "yaml-parse".to_string(),
|
||||
file: Some(path.clone()),
|
||||
file: Some(analysis_path.to_path_buf()),
|
||||
message: format!("invalid YAML: {err}"),
|
||||
});
|
||||
}
|
||||
@@ -96,22 +101,32 @@ pub fn run_patch_harness(
|
||||
let temp = TempDir::new().ok()?;
|
||||
let staged_root = temp.path();
|
||||
|
||||
let mut changed_paths: Vec<PathBuf> = Vec::new();
|
||||
for (path, change) in action.changes() {
|
||||
let rel = path.strip_prefix(cwd).unwrap_or(path);
|
||||
let dest = staged_root.join(rel);
|
||||
let _ = fs::create_dir_all(dest.parent().unwrap_or(staged_root));
|
||||
match change {
|
||||
ApplyPatchFileChange::Add { content } | ApplyPatchFileChange::Update { new_content: content, .. } => {
|
||||
if let Ok(mut file) = fs::File::create(&dest) {
|
||||
let _ = file.write_all(content.as_bytes());
|
||||
ApplyPatchFileChange::Add { content } => {
|
||||
if let Some(rel) = stage_file(staged_root, cwd, path, content) {
|
||||
changed_paths.push(rel);
|
||||
}
|
||||
}
|
||||
ApplyPatchFileChange::Update { new_content, move_path, .. } => {
|
||||
let dest_path = move_path.as_ref().unwrap_or(path);
|
||||
if let Some(rel) = stage_file(staged_root, cwd, dest_path, new_content) {
|
||||
changed_paths.push(rel);
|
||||
}
|
||||
if move_path.is_some() && move_path.as_ref().map(|p| p.as_path()) != Some(path.as_path()) {
|
||||
remove_staged_file(staged_root, cwd, path);
|
||||
}
|
||||
}
|
||||
ApplyPatchFileChange::Delete { .. } => {
|
||||
let _ = fs::remove_file(&dest);
|
||||
remove_staged_file(staged_root, cwd, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
changed_paths.sort();
|
||||
changed_paths.dedup();
|
||||
|
||||
let is_allowed = |tool: &str| allow.is_empty() || allow.iter().any(|entry| entry == tool);
|
||||
let run_tool = |tool: &str, args: &[&str], files: &[PathBuf]| -> Vec<HarnessFinding> {
|
||||
if files.is_empty() || !is_allowed(tool) {
|
||||
@@ -122,33 +137,23 @@ pub fn run_patch_harness(
|
||||
cmd.current_dir(staged_root);
|
||||
cmd.args(args);
|
||||
cmd.args(files);
|
||||
let output = run_with_timeout(cmd, timeout);
|
||||
output
|
||||
.into_iter()
|
||||
.flat_map(|(stdout, stderr)| {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
if !stdout.is_empty() {
|
||||
lines.extend(String::from_utf8_lossy(&stdout).lines().map(|s| s.to_string()));
|
||||
}
|
||||
if !stderr.is_empty() {
|
||||
lines.extend(String::from_utf8_lossy(&stderr).lines().map(|s| s.to_string()));
|
||||
}
|
||||
lines
|
||||
})
|
||||
.map(|message| HarnessFinding { tool: tool.to_string(), file: None, message })
|
||||
.collect()
|
||||
match run_with_timeout(cmd, timeout) {
|
||||
Some(output) => collect_output_lines(&output.stdout, &output.stderr)
|
||||
.into_iter()
|
||||
.map(|message| HarnessFinding { tool: tool.to_string(), file: None, message })
|
||||
.collect(),
|
||||
None => vec![HarnessFinding {
|
||||
tool: tool.to_string(),
|
||||
file: None,
|
||||
message: format!("{tool} timed out after {timeout} second(s)"),
|
||||
}],
|
||||
}
|
||||
};
|
||||
|
||||
let changed_paths: Vec<&Path> = action
|
||||
.changes()
|
||||
.iter()
|
||||
.filter_map(|(path, change)| matches!(change, ApplyPatchFileChange::Add { .. } | ApplyPatchFileChange::Update { .. }).then_some(path.as_path()))
|
||||
.collect();
|
||||
|
||||
let shell_scripts: Vec<PathBuf> = changed_paths
|
||||
.iter()
|
||||
.filter(|path| is_shell_script(path))
|
||||
.map(|path| path.strip_prefix(cwd).unwrap_or(path).to_path_buf())
|
||||
.filter(|path| is_shell_script(staged_root, path))
|
||||
.cloned()
|
||||
.collect();
|
||||
if cfg.tools.shellcheck.unwrap_or(true) && !shell_scripts.is_empty() {
|
||||
if which(Path::new("shellcheck")).is_some() {
|
||||
@@ -160,7 +165,7 @@ pub fn run_patch_harness(
|
||||
let markdown_files: Vec<PathBuf> = changed_paths
|
||||
.iter()
|
||||
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("md"))
|
||||
.map(|path| path.strip_prefix(cwd).unwrap_or(path).to_path_buf())
|
||||
.cloned()
|
||||
.collect();
|
||||
if cfg.tools.markdownlint.unwrap_or(true) && !markdown_files.is_empty() {
|
||||
if which(Path::new("markdownlint")).is_some() || which(Path::new("markdownlint-cli2")).is_some() {
|
||||
@@ -176,7 +181,7 @@ pub fn run_patch_harness(
|
||||
let docker_files: Vec<PathBuf> = changed_paths
|
||||
.iter()
|
||||
.filter(|path| is_dockerfile(path))
|
||||
.map(|path| path.strip_prefix(cwd).unwrap_or(path).to_path_buf())
|
||||
.cloned()
|
||||
.collect();
|
||||
if cfg.tools.hadolint.unwrap_or(true) && !docker_files.is_empty() {
|
||||
if which(Path::new("hadolint")).is_some() {
|
||||
@@ -188,7 +193,7 @@ pub fn run_patch_harness(
|
||||
let yaml_files: Vec<PathBuf> = changed_paths
|
||||
.iter()
|
||||
.filter(|path| matches!(path.extension().and_then(|ext| ext.to_str()), Some("yml" | "yaml")))
|
||||
.map(|path| path.strip_prefix(cwd).unwrap_or(path).to_path_buf())
|
||||
.cloned()
|
||||
.collect();
|
||||
if cfg.tools.yamllint.unwrap_or(true) && !yaml_files.is_empty() {
|
||||
if which(Path::new("yamllint")).is_some() {
|
||||
@@ -200,14 +205,8 @@ pub fn run_patch_harness(
|
||||
let rust_files: Vec<PathBuf> = changed_paths
|
||||
.iter()
|
||||
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("rs"))
|
||||
.map(|path| path.strip_prefix(cwd).unwrap_or(path).to_path_buf())
|
||||
.cloned()
|
||||
.collect();
|
||||
if cfg.tools.rustfmt.unwrap_or(true) && !rust_files.is_empty() {
|
||||
if which(Path::new("rustfmt")).is_some() {
|
||||
record_ran("rustfmt");
|
||||
}
|
||||
findings.extend(run_tool("rustfmt", &["--check"], &rust_files));
|
||||
}
|
||||
|
||||
if cfg.tools.shfmt.unwrap_or(true) && !shell_scripts.is_empty() {
|
||||
if which(Path::new("shfmt")).is_some() {
|
||||
@@ -226,7 +225,7 @@ pub fn run_patch_harness(
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| prettier_exts.contains(&ext))
|
||||
.unwrap_or(false))
|
||||
.map(|path| path.strip_prefix(cwd).unwrap_or(path).to_path_buf())
|
||||
.cloned()
|
||||
.collect();
|
||||
if cfg.tools.prettier.unwrap_or(true) && !prettier_files.is_empty() {
|
||||
if which(Path::new("prettier")).is_some() {
|
||||
@@ -235,6 +234,74 @@ pub fn run_patch_harness(
|
||||
findings.extend(run_tool("prettier", &["--check"], &prettier_files));
|
||||
}
|
||||
|
||||
if cfg.tools.cargo_check.unwrap_or(true) && !rust_files.is_empty() {
|
||||
if which(Path::new("cargo")).is_none() {
|
||||
findings.push(HarnessFinding {
|
||||
tool: "cargo-check".to_string(),
|
||||
file: None,
|
||||
message: "cargo executable not found; install the Rust toolchain".to_string(),
|
||||
});
|
||||
} else {
|
||||
match WorkspaceOverlay::apply(action) {
|
||||
Ok(overlay) => {
|
||||
let manifests = collect_rust_manifests(cwd, &rust_files);
|
||||
let rust_timeout = timeout.max(30);
|
||||
for manifest in manifests {
|
||||
let label = manifest
|
||||
.strip_prefix(cwd)
|
||||
.unwrap_or(&manifest)
|
||||
.display()
|
||||
.to_string();
|
||||
let mut cmd = std::process::Command::new("cargo");
|
||||
cmd.current_dir(cwd);
|
||||
cmd.arg("check");
|
||||
cmd.arg("--quiet");
|
||||
cmd.arg("--all-targets");
|
||||
cmd.arg("--manifest-path");
|
||||
cmd.arg(manifest.to_string_lossy().to_string());
|
||||
cmd.env("RUSTFLAGS", "-Dwarnings");
|
||||
|
||||
match run_with_timeout(cmd, rust_timeout) {
|
||||
Some(output) => {
|
||||
record_ran(&format!("cargo-check({label})"));
|
||||
if output.status.map_or(true, |status| !status.success()) {
|
||||
let mut lines = collect_output_lines(&output.stdout, &output.stderr);
|
||||
if lines.is_empty() {
|
||||
lines.push("cargo check failed (no output)".to_string());
|
||||
}
|
||||
for line in lines.into_iter().take(24) {
|
||||
findings.push(HarnessFinding {
|
||||
tool: format!("cargo-check({label})"),
|
||||
file: None,
|
||||
message: line,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
findings.push(HarnessFinding {
|
||||
tool: format!("cargo-check({label})"),
|
||||
file: None,
|
||||
message: format!(
|
||||
"cargo check timed out after {rust_timeout} second(s)"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(overlay);
|
||||
}
|
||||
Err(err) => {
|
||||
findings.push(HarnessFinding {
|
||||
tool: "cargo-check".to_string(),
|
||||
file: None,
|
||||
message: format!("failed to stage workspace for cargo check: {err}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if findings.is_empty() && ran.is_empty() {
|
||||
None
|
||||
} else {
|
||||
@@ -242,11 +309,12 @@ pub fn run_patch_harness(
|
||||
}
|
||||
}
|
||||
|
||||
fn is_shell_script(path: &Path) -> bool {
|
||||
match path.extension().and_then(|ext| ext.to_str()) {
|
||||
fn is_shell_script(staged_root: &Path, relative: &Path) -> bool {
|
||||
match relative.extension().and_then(|ext| ext.to_str()) {
|
||||
Some("sh") => true,
|
||||
_ => {
|
||||
std::fs::read(path)
|
||||
let staged = staged_root.join(relative);
|
||||
std::fs::read(staged)
|
||||
.ok()
|
||||
.and_then(|bytes| String::from_utf8(bytes).ok())
|
||||
.map(|contents| contents.starts_with("#!/"))
|
||||
@@ -277,7 +345,7 @@ fn which(exe: &Path) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
fn run_with_timeout(mut cmd: std::process::Command, timeout_secs: u64) -> Option<(Vec<u8>, Vec<u8>)> {
|
||||
fn run_with_timeout(mut cmd: std::process::Command, timeout_secs: u64) -> Option<CommandCapture> {
|
||||
use std::process::Stdio;
|
||||
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
let Ok(mut child) = cmd.spawn() else { return None };
|
||||
@@ -285,7 +353,6 @@ fn run_with_timeout(mut cmd: std::process::Command, timeout_secs: u64) -> Option
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
if let Some(status) = child.try_wait().ok().flatten() {
|
||||
let _ = status; // success/failure surfaces through captured output
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
if let Some(mut out) = child.stdout.take() {
|
||||
@@ -294,7 +361,7 @@ fn run_with_timeout(mut cmd: std::process::Command, timeout_secs: u64) -> Option
|
||||
if let Some(mut err) = child.stderr.take() {
|
||||
let _ = std::io::Read::read_to_end(&mut err, &mut stderr);
|
||||
}
|
||||
return Some((stdout, stderr));
|
||||
return Some(CommandCapture { status: Some(status), stdout, stderr });
|
||||
}
|
||||
|
||||
if start.elapsed().as_secs() >= timeout_secs {
|
||||
@@ -305,3 +372,184 @@ fn run_with_timeout(mut cmd: std::process::Command, timeout_secs: u64) -> Option
|
||||
std::thread::sleep(std::time::Duration::from_millis(40));
|
||||
}
|
||||
}
|
||||
|
||||
fn stage_file(staged_root: &Path, cwd: &Path, path: &Path, contents: &str) -> Option<PathBuf> {
|
||||
let relative = path.strip_prefix(cwd).ok()?;
|
||||
let dest = staged_root.join(relative);
|
||||
if let Some(parent) = dest.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
if let Ok(mut file) = fs::File::create(&dest) {
|
||||
if file.write_all(contents.as_bytes()).is_ok() {
|
||||
return Some(relative.to_path_buf());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn remove_staged_file(staged_root: &Path, cwd: &Path, path: &Path) {
|
||||
if let Ok(relative) = path.strip_prefix(cwd) {
|
||||
let dest = staged_root.join(relative);
|
||||
let _ = fs::remove_file(dest);
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_output_lines(stdout: &[u8], stderr: &[u8]) -> Vec<String> {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
if !stdout.is_empty() {
|
||||
lines.extend(String::from_utf8_lossy(stdout).lines().map(|s| s.to_string()));
|
||||
}
|
||||
if !stderr.is_empty() {
|
||||
lines.extend(String::from_utf8_lossy(stderr).lines().map(|s| s.to_string()));
|
||||
}
|
||||
lines.retain(|line| !line.trim().is_empty());
|
||||
lines
|
||||
}
|
||||
|
||||
fn collect_rust_manifests(cwd: &Path, rust_files: &[PathBuf]) -> Vec<PathBuf> {
|
||||
let mut manifests = BTreeSet::new();
|
||||
for relative in rust_files {
|
||||
if let Some(manifest) = find_manifest(cwd, relative) {
|
||||
manifests.insert(manifest);
|
||||
}
|
||||
}
|
||||
manifests.into_iter().collect()
|
||||
}
|
||||
|
||||
fn find_manifest(cwd: &Path, relative: &Path) -> Option<PathBuf> {
|
||||
let absolute = cwd.join(relative);
|
||||
let mut current = absolute.parent()?;
|
||||
loop {
|
||||
let candidate = current.join("Cargo.toml");
|
||||
if candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
if current == cwd {
|
||||
break;
|
||||
}
|
||||
current = current.parent()?;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
struct WorkspaceOverlay {
|
||||
backups: Vec<(PathBuf, Option<Vec<u8>>)>,
|
||||
created_dirs: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl WorkspaceOverlay {
|
||||
fn apply(action: &ApplyPatchAction) -> std::io::Result<Self> {
|
||||
let mut overlay = WorkspaceOverlay { backups: Vec::new(), created_dirs: Vec::new() };
|
||||
let mut seen: HashSet<PathBuf> = HashSet::new();
|
||||
|
||||
for (path, change) in action.changes() {
|
||||
match change {
|
||||
ApplyPatchFileChange::Add { content } => {
|
||||
overlay.write_file(path, content, &mut seen)?;
|
||||
}
|
||||
ApplyPatchFileChange::Update { new_content, move_path, .. } => {
|
||||
if let Some(dest) = move_path {
|
||||
overlay.write_file(dest, new_content, &mut seen)?;
|
||||
if dest != path {
|
||||
overlay.remove_file(path, &mut seen)?;
|
||||
}
|
||||
} else {
|
||||
overlay.write_file(path, new_content, &mut seen)?;
|
||||
}
|
||||
}
|
||||
ApplyPatchFileChange::Delete { .. } => {
|
||||
overlay.remove_file(path, &mut seen)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(overlay)
|
||||
}
|
||||
|
||||
fn write_file(
|
||||
&mut self,
|
||||
path: &Path,
|
||||
contents: &str,
|
||||
seen: &mut HashSet<PathBuf>,
|
||||
) -> std::io::Result<()> {
|
||||
self.backup_if_needed(path, seen)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
self.ensure_dir(parent)?;
|
||||
}
|
||||
let mut file = fs::File::create(path)?;
|
||||
file.write_all(contents.as_bytes())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_file(&mut self, path: &Path, seen: &mut HashSet<PathBuf>) -> std::io::Result<()> {
|
||||
self.backup_if_needed(path, seen)?;
|
||||
if let Err(err) = fs::remove_file(path) {
|
||||
if err.kind() != std::io::ErrorKind::NotFound {
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn backup_if_needed(&mut self, path: &Path, seen: &mut HashSet<PathBuf>) -> std::io::Result<()> {
|
||||
if !seen.insert(path.to_path_buf()) {
|
||||
return Ok(());
|
||||
}
|
||||
let original = match fs::read(path) {
|
||||
Ok(bytes) => Some(bytes),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
self.backups.push((path.to_path_buf(), original));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_dir(&mut self, dir: &Path) -> std::io::Result<()> {
|
||||
if dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut to_create: Vec<PathBuf> = Vec::new();
|
||||
let mut current = dir.to_path_buf();
|
||||
while !current.exists() {
|
||||
to_create.push(current.clone());
|
||||
if let Some(parent) = current.parent() {
|
||||
current = parent.to_path_buf();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
for path in to_create.iter().rev() {
|
||||
fs::create_dir(path)?;
|
||||
self.created_dirs.push(path.clone());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WorkspaceOverlay {
|
||||
fn drop(&mut self) {
|
||||
for (path, original) in self.backups.iter().rev() {
|
||||
match original {
|
||||
Some(bytes) => {
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
let _ = fs::File::create(path).and_then(|mut file| file.write_all(bytes));
|
||||
}
|
||||
None => {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for dir in self.created_dirs.iter().rev() {
|
||||
let _ = fs::remove_dir(dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CommandCapture {
|
||||
status: Option<ExitStatus>,
|
||||
stdout: Vec<u8>,
|
||||
stderr: Vec<u8>,
|
||||
}
|
||||
|
||||
@@ -29,6 +29,14 @@ fn is_workflow_path(path: &Path, cwd: &Path) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
fn is_in_github_dir(path: &Path, cwd: &Path) -> bool {
|
||||
let absolute = if path.is_absolute() { path.to_path_buf() } else { cwd.join(path) };
|
||||
if let Ok(relative) = absolute.strip_prefix(cwd) {
|
||||
return relative.starts_with(".github");
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Run `actionlint` against a temporary workspace containing the proposed
|
||||
/// workflow changes. Returns diagnostic lines when the tool is available and
|
||||
/// produced output.
|
||||
@@ -64,29 +72,46 @@ pub fn maybe_run_actionlint(
|
||||
|
||||
let temp = TempDir::new().ok()?;
|
||||
let temp_root = temp.path();
|
||||
let temp_workflows = temp_root.join(".github/workflows");
|
||||
let temp_github = temp_root.join(".github");
|
||||
let temp_workflows = temp_github.join("workflows");
|
||||
let _ = fs::create_dir_all(&temp_workflows);
|
||||
|
||||
let source_workflows = cwd.join(".github/workflows");
|
||||
if source_workflows.exists() {
|
||||
let _ = copy_dir_flat(&source_workflows, &temp_workflows);
|
||||
let source_github = cwd.join(".github");
|
||||
if source_github.exists() {
|
||||
let _ = copy_dir_recursive(&source_github, &temp_github);
|
||||
}
|
||||
|
||||
for (path, change) in action.changes() {
|
||||
if !is_workflow_path(path, cwd) {
|
||||
continue;
|
||||
}
|
||||
let relative = path.strip_prefix(cwd).unwrap_or(path);
|
||||
let destination = temp_root.join(relative);
|
||||
let _ = fs::create_dir_all(destination.parent().unwrap_or(&temp_workflows));
|
||||
let path_in_github = is_in_github_dir(path, cwd);
|
||||
match change {
|
||||
ApplyPatchFileChange::Add { content } | ApplyPatchFileChange::Update { new_content: content, .. } => {
|
||||
if let Ok(mut file) = fs::File::create(&destination) {
|
||||
let _ = file.write_all(content.as_bytes());
|
||||
ApplyPatchFileChange::Add { content } => {
|
||||
if path_in_github {
|
||||
let _ = stage_file_with_contents(temp_root, cwd, path, content);
|
||||
}
|
||||
}
|
||||
ApplyPatchFileChange::Update { new_content, move_path, .. } => {
|
||||
let dest_path = move_path.as_ref().unwrap_or(path);
|
||||
let dest_in_github = is_in_github_dir(dest_path, cwd);
|
||||
if !dest_in_github && !path_in_github {
|
||||
continue;
|
||||
}
|
||||
|
||||
if dest_in_github {
|
||||
let _ = stage_file_with_contents(temp_root, cwd, dest_path, new_content);
|
||||
}
|
||||
|
||||
if move_path.is_none() && path_in_github && !dest_in_github {
|
||||
let _ = stage_file_with_contents(temp_root, cwd, path, new_content);
|
||||
}
|
||||
|
||||
if path_in_github && move_path.is_some() {
|
||||
remove_from_staged(temp_root, cwd, path);
|
||||
}
|
||||
}
|
||||
ApplyPatchFileChange::Delete { .. } => {
|
||||
let _ = fs::remove_file(&destination);
|
||||
if path_in_github {
|
||||
remove_from_staged(temp_root, cwd, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,15 +137,23 @@ pub fn maybe_run_actionlint(
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_dir_flat(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||
if !dst.exists() {
|
||||
fs::create_dir_all(dst)?;
|
||||
}
|
||||
for entry in fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
if entry.file_type()?.is_file() {
|
||||
let target = dst.join(entry.file_name());
|
||||
let _ = fs::copy(entry.path(), target);
|
||||
let file_type = entry.file_type()?;
|
||||
let target = dst.join(entry.file_name());
|
||||
if file_type.is_dir() {
|
||||
copy_dir_recursive(&entry.path(), &target)?;
|
||||
} else if file_type.is_file() {
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let _ = fs::copy(entry.path(), &target);
|
||||
} else if file_type.is_symlink() {
|
||||
copy_symlink(&entry.path(), &target)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -152,3 +185,45 @@ fn is_executable(path: &Path) -> bool {
|
||||
fn is_executable(path: &Path) -> bool {
|
||||
path.is_file()
|
||||
}
|
||||
|
||||
fn stage_file_with_contents(temp_root: &Path, cwd: &Path, target: &Path, contents: &str) -> bool {
|
||||
let relative = match target.strip_prefix(cwd) {
|
||||
Ok(rel) => rel,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let destination = temp_root.join(relative);
|
||||
if let Some(parent) = destination.parent() {
|
||||
if fs::create_dir_all(parent).is_err() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
match fs::File::create(&destination) {
|
||||
Ok(mut file) => file.write_all(contents.as_bytes()).is_ok(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_from_staged(temp_root: &Path, cwd: &Path, target: &Path) {
|
||||
if let Ok(relative) = target.strip_prefix(cwd) {
|
||||
let destination = temp_root.join(relative);
|
||||
let _ = fs::remove_file(&destination);
|
||||
}
|
||||
}
|
||||
|
||||
fn copy_symlink(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let target = std::fs::read_link(src)?;
|
||||
let _ = fs::remove_file(dst);
|
||||
std::os::unix::fs::symlink(target, dst)?;
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
if let Some(parent) = dst.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let _ = fs::copy(src, dst)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,12 +221,16 @@ pub(crate) fn detect_tools() -> Vec<ToolStatus> {
|
||||
("markdownlint", markdownlint_hint()),
|
||||
("hadolint", hadolint_hint()),
|
||||
("yamllint", yamllint_hint()),
|
||||
("rustfmt", rustfmt_hint()),
|
||||
("cargo-check", cargo_check_hint()),
|
||||
("shfmt", shfmt_hint()),
|
||||
("prettier", prettier_hint()),
|
||||
];
|
||||
for (name, hint) in tools.into_iter() {
|
||||
let installed = which(name).is_some();
|
||||
let installed = if name == "cargo-check" {
|
||||
has("cargo")
|
||||
} else {
|
||||
which(name).is_some()
|
||||
};
|
||||
result.push(ToolStatus { name, installed, install_hint: hint });
|
||||
}
|
||||
result
|
||||
@@ -331,11 +335,11 @@ pub fn yamllint_hint() -> String {
|
||||
"https://yamllint.readthedocs.io/".to_string()
|
||||
}
|
||||
|
||||
pub fn rustfmt_hint() -> String {
|
||||
if has("rustup") {
|
||||
return "rustup component add rustfmt".to_string();
|
||||
pub fn cargo_check_hint() -> String {
|
||||
if has("cargo") {
|
||||
return "cargo check --all-targets".to_string();
|
||||
}
|
||||
"Install Rust (rustup) then run: rustup component add rustfmt".to_string()
|
||||
"Install Rust (https://rustup.rs) to enable cargo check".to_string()
|
||||
}
|
||||
|
||||
pub fn shfmt_hint() -> String {
|
||||
|
||||
@@ -9566,7 +9566,7 @@ impl ChatWidget<'_> {
|
||||
"markdownlint" => Some(&mut tools.markdownlint),
|
||||
"hadolint" => Some(&mut tools.hadolint),
|
||||
"yamllint" => Some(&mut tools.yamllint),
|
||||
"rustfmt" => Some(&mut tools.rustfmt),
|
||||
"cargo-check" => Some(&mut tools.cargo_check),
|
||||
"shfmt" => Some(&mut tools.shfmt),
|
||||
"prettier" => Some(&mut tools.prettier),
|
||||
_ => None,
|
||||
@@ -9581,7 +9581,7 @@ impl ChatWidget<'_> {
|
||||
"markdownlint" => tools.markdownlint.unwrap_or(true),
|
||||
"hadolint" => tools.hadolint.unwrap_or(true),
|
||||
"yamllint" => tools.yamllint.unwrap_or(true),
|
||||
"rustfmt" => tools.rustfmt.unwrap_or(true),
|
||||
"cargo-check" => tools.cargo_check.unwrap_or(true),
|
||||
"shfmt" => tools.shfmt.unwrap_or(true),
|
||||
"prettier" => tools.prettier.unwrap_or(true),
|
||||
_ => true,
|
||||
|
||||
+1
-1
@@ -392,7 +392,7 @@ shellcheck = true
|
||||
markdownlint = true
|
||||
hadolint = true
|
||||
yamllint = true
|
||||
rustfmt = true
|
||||
cargo-check = true
|
||||
shfmt = true
|
||||
prettier = true
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user