use std::ffi::OsStr;
use std::fs;
use std::io;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode, Stdio};
use std::thread;
use std::time::{Duration, Instant};
use anyhow::{bail, Result};
use serde::Serialize;
use crate::output::CommandReport;
pub(crate) const COMMANDS_DIR: &str = ".ccd/commands";
pub(crate) const CHECK_PREFIX: &str = "check-";
pub(crate) const REPO_NATIVE_CHECK_TIMEOUT_ENV: &str = "CCD_REPO_NATIVE_CHECK_TIMEOUT";
const DEFAULT_REPO_NATIVE_CHECK_TIMEOUT: Duration = Duration::from_secs(30);
const REPO_NATIVE_CHECK_POLL_INTERVAL: Duration = Duration::from_millis(25);
#[derive(Serialize)]
pub struct CheckReport {
pub(crate) command: &'static str,
pub(crate) ok: bool,
pub(crate) repo_root: String,
pub(crate) surface: CheckSurfaceView,
pub(crate) failures: usize,
pub(crate) checks: Vec<RegisteredCheckReport>,
}
#[derive(Serialize)]
pub(crate) struct CheckSurfaceView {
pub(crate) path: String,
pub(crate) status: &'static str,
pub(crate) prefix: &'static str,
pub(crate) entries: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) note: Option<String>,
}
#[derive(Serialize)]
pub(crate) struct RegisteredCheckReport {
pub(crate) id: String,
pub(crate) path: String,
pub(crate) status: &'static str,
pub(crate) severity: &'static str,
pub(crate) message: String,
pub(crate) duration_ms: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub(crate) exit_code: Option<i32>,
pub(crate) stdout: String,
pub(crate) stderr: String,
}
struct RegisteredCheck {
id: String,
relative_path: String,
absolute_path: PathBuf,
}
struct CheckRunOptions {
timeout: Duration,
}
enum DiscoverChecksResult {
Registered(Vec<RegisteredCheck>),
Invalid(RegisteredCheckReport),
}
impl CommandReport for CheckReport {
fn exit_code(&self) -> ExitCode {
if self.failures > 0 {
ExitCode::from(1)
} else {
ExitCode::SUCCESS
}
}
fn render_text(&self) {
if let Some(note) = &self.surface.note {
println!("{note}");
}
for check in &self.checks {
let label = match check.status {
"pass" => "PASS",
"warn" => "WARN",
"fail" => "FAIL",
_ => "INFO",
};
println!("[{label}] {}", check.message);
if check.status == "fail" {
let stdout = check.stdout.trim_end();
if !stdout.is_empty() {
println!("stdout:");
println!("{stdout}");
}
let stderr = check.stderr.trim_end();
if !stderr.is_empty() {
println!("stderr:");
println!("{stderr}");
}
}
}
println!(
"Check summary: {} failure(s), {} registered check(s).",
self.failures,
self.surface.entries.len()
);
}
}
pub fn run(repo_root: &Path) -> Result<CheckReport> {
let options = CheckRunOptions::from_env()?;
let surface_path = repo_root.join(COMMANDS_DIR);
if !surface_path.exists() {
return Ok(CheckReport {
command: "check",
ok: true,
repo_root: repo_root.display().to_string(),
surface: CheckSurfaceView {
path: surface_path.display().to_string(),
status: "absent",
prefix: CHECK_PREFIX,
entries: Vec::new(),
note: Some(format!(
"No repo-native `{CHECK_PREFIX}` commands registered under {COMMANDS_DIR}."
)),
},
failures: 0,
checks: Vec::new(),
});
}
let surface_metadata = fs::symlink_metadata(&surface_path)?;
if !surface_metadata.file_type().is_dir() {
let note = format!(
"Repo-native command surface must be a directory at {}; found a non-directory entry instead.",
surface_path.display()
);
return Ok(CheckReport {
command: "check",
ok: false,
repo_root: repo_root.display().to_string(),
surface: CheckSurfaceView {
path: surface_path.display().to_string(),
status: "invalid",
prefix: CHECK_PREFIX,
entries: Vec::new(),
note: None,
},
failures: 1,
checks: vec![RegisteredCheckReport {
id: "surface".to_owned(),
path: surface_path.display().to_string(),
status: "fail",
severity: "error",
message: note,
duration_ms: 0,
exit_code: None,
stdout: String::new(),
stderr: String::new(),
}],
});
}
let registered = match discover_checks(repo_root, &surface_path)? {
DiscoverChecksResult::Registered(registered) => registered,
DiscoverChecksResult::Invalid(failure) => {
return Ok(CheckReport {
command: "check",
ok: false,
repo_root: repo_root.display().to_string(),
surface: CheckSurfaceView {
path: surface_path.display().to_string(),
status: "invalid",
prefix: CHECK_PREFIX,
entries: Vec::new(),
note: None,
},
failures: 1,
checks: vec![failure],
});
}
};
let mut checks = Vec::new();
for entry in ®istered {
checks.push(run_registered_check(repo_root, entry, options.timeout));
}
let failures = checks
.iter()
.filter(|check| check.severity == "error")
.count();
let note = if registered.is_empty() {
Some(format!(
"No repo-native `{CHECK_PREFIX}` commands registered under {COMMANDS_DIR}."
))
} else {
None
};
Ok(CheckReport {
command: "check",
ok: failures == 0,
repo_root: repo_root.display().to_string(),
surface: CheckSurfaceView {
path: surface_path.display().to_string(),
status: "loaded",
prefix: CHECK_PREFIX,
entries: registered
.iter()
.map(|entry| entry.relative_path.clone())
.collect(),
note,
},
failures,
checks,
})
}
fn discover_checks(repo_root: &Path, surface_path: &Path) -> Result<DiscoverChecksResult> {
let mut registered = Vec::new();
for entry in fs::read_dir(surface_path)? {
let entry = entry?;
let file_name = entry.file_name();
match classify_check_entry_name(&file_name) {
CheckEntryName::Ignore => continue,
CheckEntryName::Invalid => {
let absolute_path = entry.path();
let relative_path = path_relative_to(repo_root, &absolute_path);
return Ok(DiscoverChecksResult::Invalid(fail_report(
"surface",
&relative_path,
format!(
"Registered check filenames under {COMMANDS_DIR} must be valid UTF-8 without control characters; rename the invalid entry and rerun."
),
)));
}
CheckEntryName::Valid(id) => {
let absolute_path = entry.path();
let relative_path = path_relative_to(repo_root, &absolute_path);
registered.push(RegisteredCheck {
id,
relative_path,
absolute_path,
});
}
}
}
registered.sort_by(|left, right| left.id.cmp(&right.id));
Ok(DiscoverChecksResult::Registered(registered))
}
fn run_registered_check(
repo_root: &Path,
entry: &RegisteredCheck,
timeout: Duration,
) -> RegisteredCheckReport {
match fs::symlink_metadata(&entry.absolute_path) {
Ok(metadata) => {
if !metadata.file_type().is_file() {
return fail_report(
&entry.id,
&entry.relative_path,
format!(
"Registered check `{}` must be a regular executable file under {COMMANDS_DIR}.",
entry.id
),
);
}
if metadata.permissions().mode() & 0o111 == 0 {
return fail_report(
&entry.id,
&entry.relative_path,
format!("Registered check `{}` is not executable.", entry.id),
);
}
let started = Instant::now();
match run_check_process(repo_root, entry, timeout, started) {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
if output.status.success() {
RegisteredCheckReport {
id: entry.id.clone(),
path: entry.relative_path.clone(),
status: "pass",
severity: "info",
message: format!(
"Repo-native check `{}` passed ({} ms).",
entry.id, output.duration_ms
),
duration_ms: output.duration_ms,
exit_code: output.status.code(),
stdout,
stderr,
}
} else {
let status_suffix = match output.status.code() {
Some(code) => format!("exit code {code}"),
None => "termination without an exit code".to_owned(),
};
RegisteredCheckReport {
id: entry.id.clone(),
path: entry.relative_path.clone(),
status: "fail",
severity: "error",
message: format!(
"Repo-native check `{}` failed with {} ({} ms).",
entry.id, status_suffix, output.duration_ms
),
duration_ms: output.duration_ms,
exit_code: output.status.code(),
stdout,
stderr,
}
}
}
Err(CheckExecutionError::TimedOut {
output,
duration_ms,
}) => RegisteredCheckReport {
id: entry.id.clone(),
path: entry.relative_path.clone(),
status: "fail",
severity: "error",
message: format!(
"Repo-native check `{}` timed out after {} ({} ms).",
entry.id,
format_duration(timeout),
duration_ms
),
duration_ms,
exit_code: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
},
Err(CheckExecutionError::Io(error)) => fail_report(
&entry.id,
&entry.relative_path,
format!(
"Failed to execute repo-native check `{}`: {error}",
entry.id
),
),
}
}
Err(error) => fail_report(
&entry.id,
&entry.relative_path,
format!(
"Failed to inspect repo-native check `{}`: {error}",
entry.id
),
),
}
}
fn fail_report(id: &str, path: &str, message: String) -> RegisteredCheckReport {
RegisteredCheckReport {
id: id.to_owned(),
path: path.to_owned(),
status: "fail",
severity: "error",
message,
duration_ms: 0,
exit_code: None,
stdout: String::new(),
stderr: String::new(),
}
}
fn path_relative_to(repo_root: &Path, path: &Path) -> String {
path.strip_prefix(repo_root)
.unwrap_or(path)
.display()
.to_string()
}
enum CheckEntryName {
Ignore,
Valid(String),
Invalid,
}
fn classify_check_entry_name(file_name: &OsStr) -> CheckEntryName {
let bytes = file_name.as_bytes();
if bytes.starts_with(b".") || !bytes.starts_with(CHECK_PREFIX.as_bytes()) {
return CheckEntryName::Ignore;
}
match std::str::from_utf8(bytes) {
Ok(value) if value.chars().any(|ch| ch.is_control()) => CheckEntryName::Invalid,
Ok(value) => CheckEntryName::Valid(value.to_owned()),
Err(_) => CheckEntryName::Invalid,
}
}
impl CheckRunOptions {
fn from_env() -> Result<Self> {
Ok(Self {
timeout: repo_native_check_timeout()?,
})
}
}
struct CompletedCheckOutput {
status: std::process::ExitStatus,
stdout: Vec<u8>,
stderr: Vec<u8>,
duration_ms: u64,
}
enum CheckExecutionError {
Io(io::Error),
TimedOut {
output: std::process::Output,
duration_ms: u64,
},
}
fn repo_native_check_timeout() -> Result<Duration> {
let raw = match std::env::var(REPO_NATIVE_CHECK_TIMEOUT_ENV) {
Ok(value) => value,
Err(std::env::VarError::NotPresent) => return Ok(DEFAULT_REPO_NATIVE_CHECK_TIMEOUT),
Err(std::env::VarError::NotUnicode(_)) => {
bail!(
"invalid {REPO_NATIVE_CHECK_TIMEOUT_ENV}: expected valid UTF-8 with an optional `ms`, `s`, or `m` suffix"
)
}
};
parse_repo_native_check_timeout(&raw)
}
fn parse_repo_native_check_timeout(raw: &str) -> Result<Duration> {
let value = raw.trim();
if value.is_empty() {
bail!(
"invalid {REPO_NATIVE_CHECK_TIMEOUT_ENV}: expected a positive integer with an optional `ms`, `s`, or `m` suffix"
);
}
let (digits, unit) = if let Some(stripped) = value.strip_suffix("ms") {
(stripped, "ms")
} else if let Some(stripped) = value.strip_suffix('s') {
(stripped, "s")
} else if let Some(stripped) = value.strip_suffix('m') {
(stripped, "m")
} else {
(value, "s")
};
let amount = digits.parse::<u64>().map_err(|_| {
anyhow::anyhow!(
"invalid {REPO_NATIVE_CHECK_TIMEOUT_ENV} value `{value}`: expected a positive integer with an optional `ms`, `s`, or `m` suffix"
)
})?;
if amount == 0 {
bail!(
"invalid {REPO_NATIVE_CHECK_TIMEOUT_ENV} value `{value}`: timeout must be greater than zero"
);
}
match unit {
"ms" => Ok(Duration::from_millis(amount)),
"s" => Ok(Duration::from_secs(amount)),
"m" => Ok(Duration::from_secs(amount.checked_mul(60).ok_or_else(
|| {
anyhow::anyhow!(
"invalid {REPO_NATIVE_CHECK_TIMEOUT_ENV} value `{value}`: timeout is too large"
)
},
)?)),
_ => unreachable!(),
}
}
fn run_check_process(
repo_root: &Path,
entry: &RegisteredCheck,
timeout: Duration,
started: Instant,
) -> std::result::Result<CompletedCheckOutput, CheckExecutionError> {
let mut child = Command::new(&entry.absolute_path)
.current_dir(repo_root)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(CheckExecutionError::Io)?;
loop {
match child.try_wait() {
Ok(Some(_)) => {
let output = child.wait_with_output().map_err(CheckExecutionError::Io)?;
return Ok(CompletedCheckOutput {
status: output.status,
stdout: output.stdout,
stderr: output.stderr,
duration_ms: elapsed_ms(started),
});
}
Ok(None) if started.elapsed() >= timeout => {
let _ = child.kill();
let output = child.wait_with_output().map_err(CheckExecutionError::Io)?;
return Err(CheckExecutionError::TimedOut {
output,
duration_ms: elapsed_ms(started),
});
}
Ok(None) => thread::sleep(next_poll_interval(started.elapsed(), timeout)),
Err(error) => return Err(CheckExecutionError::Io(error)),
}
}
}
fn next_poll_interval(elapsed: Duration, timeout: Duration) -> Duration {
timeout
.saturating_sub(elapsed)
.min(REPO_NATIVE_CHECK_POLL_INTERVAL)
}
fn elapsed_ms(started: Instant) -> u64 {
started.elapsed().as_millis().try_into().unwrap_or(u64::MAX)
}
fn format_duration(duration: Duration) -> String {
if duration.subsec_nanos() == 0 && duration.as_secs() > 0 {
if duration.as_secs().is_multiple_of(60) {
format!("{}m", duration.as_secs() / 60)
} else {
format!("{}s", duration.as_secs())
}
} else {
format!("{}ms", duration.as_millis())
}
}