ci: add ci tests, checks (clippy, fmt)
Some checks failed
CI / Check, test, lint (push) Failing after 51s

This commit is contained in:
2026-05-20 11:06:21 +02:00
parent 082323d815
commit 9bf111b417
6 changed files with 333 additions and 35 deletions

32
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,32 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
ci:
name: Check, test, lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- name: Format
run: cargo fmt --check
- name: Build
run: cargo build
- name: Clippy
run: cargo clippy -- -D warnings
- name: Test
run: cargo test

View File

@@ -44,8 +44,7 @@ pub fn execute() -> Result<()> {
let now = chrono::Utc::now().timestamp(); let now = chrono::Utc::now().timestamp();
for &i in indices { for &i in indices {
let id = jobs[i].id.clone(); let id = jobs[i].id.clone();
if let Some(maybe_ec) = results.get(&id) { if let Some(Some(ec)) = results.get(&id) {
if let Some(ec) = maybe_ec {
jobs[i].status = if *ec == 0 { jobs[i].status = if *ec == 0 {
JobStatus::Done JobStatus::Done
} else { } else {
@@ -55,8 +54,6 @@ pub fn execute() -> Result<()> {
jobs[i].finished_at = Some(now); jobs[i].finished_at = Some(now);
db::save(&jobs[i])?; db::save(&jobs[i])?;
} }
// None means still running — no update needed.
}
} }
} }
Err(_) => { Err(_) => {

View File

@@ -72,3 +72,68 @@ impl Config {
} }
} }
} }
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn two_worker_config() -> Config {
Config {
default_worker: Some("beefy".to_string()),
workers: vec![
WorkerConfig {
name: "beefy".to_string(),
connection: "user@192.168.1.50".to_string(),
},
WorkerConfig {
name: "cloud".to_string(),
connection: "user@cloud.example.com".to_string(),
},
],
}
}
#[test]
fn get_worker_found() {
assert!(two_worker_config().get_worker("beefy").is_some());
}
#[test]
fn get_worker_not_found() {
assert!(two_worker_config().get_worker("unknown").is_none());
}
#[test]
fn default_worker_returns_correct_name() {
let cfg = two_worker_config();
assert_eq!(cfg.default_worker().unwrap().name, "beefy");
}
#[test]
fn resolve_worker_by_name() {
let cfg = two_worker_config();
assert_eq!(cfg.resolve_worker(Some("cloud")).unwrap().name, "cloud");
}
#[test]
fn resolve_worker_falls_back_to_default() {
let cfg = two_worker_config();
assert_eq!(cfg.resolve_worker(None).unwrap().name, "beefy");
}
#[test]
fn resolve_worker_unknown_name_errors() {
assert!(two_worker_config().resolve_worker(Some("unknown")).is_err());
}
#[test]
fn resolve_worker_no_default_errors() {
let cfg = Config {
default_worker: None,
workers: vec![],
};
assert!(cfg.resolve_worker(None).is_err());
}
}

View File

@@ -31,19 +31,6 @@ pub fn save(job: &Job) -> Result<()> {
.with_context(|| format!("failed to write job {}", job.id)) .with_context(|| format!("failed to write job {}", job.id))
} }
/// Load a job by exact UUID.
pub fn load(id: &str) -> Result<Option<Job>> {
let path = job_path(id)?;
if !path.exists() {
return Ok(None);
}
let content =
std::fs::read_to_string(&path).with_context(|| format!("failed to read job {}", id))?;
serde_json::from_str(&content)
.with_context(|| format!("failed to parse job {}", id))
.map(Some)
}
/// Find a job by UUID prefix. Errors if the prefix is ambiguous. /// Find a job by UUID prefix. Errors if the prefix is ambiguous.
pub fn find(prefix: &str) -> Result<Option<Job>> { pub fn find(prefix: &str) -> Result<Option<Job>> {
let dir = jobs_dir()?; let dir = jobs_dir()?;
@@ -111,3 +98,58 @@ pub fn delete(id: &str) -> Result<()> {
} }
Ok(()) Ok(())
} }
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use crate::job::{Job, JobStatus};
fn sample_job() -> Job {
Job {
id: "a3f2b091-1234-5678-9abc-000000000000".to_string(),
worker: "beefy".to_string(),
cwd: "/home/user/projects".to_string(),
command: "make".to_string(),
started_at: 1_700_000_000,
finished_at: Some(1_700_000_060),
exit_code: Some(0),
status: JobStatus::Done,
}
}
#[test]
fn job_json_roundtrip() {
let job = sample_job();
let json = serde_json::to_string_pretty(&job).unwrap();
let decoded: Job = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.id, job.id);
assert_eq!(decoded.worker, job.worker);
assert_eq!(decoded.command, job.command);
assert_eq!(decoded.exit_code, job.exit_code);
assert_eq!(decoded.status, job.status);
}
#[test]
fn status_serialises_lowercase() {
let job = sample_job();
let json = serde_json::to_string(&job).unwrap();
assert!(
json.contains("\"done\""),
"status should serialise as lowercase \"done\""
);
}
#[test]
fn running_job_has_no_exit_code_in_json() {
let job = Job {
status: JobStatus::Running,
finished_at: None,
exit_code: None,
..sample_job()
};
let json = serde_json::to_string(&job).unwrap();
assert!(json.contains("\"running\""));
assert!(json.contains("\"exit_code\":null"));
}
}

View File

@@ -35,16 +35,6 @@ impl JobStatus {
Self::Unknown => "unknown", Self::Unknown => "unknown",
} }
} }
pub fn from_str(s: &str) -> Self {
match s {
"running" => Self::Running,
"done" => Self::Done,
"failed" => Self::Failed,
"stopped" => Self::Stopped,
_ => Self::Unknown,
}
}
} }
// ── Display helpers ─────────────────────────────────────────────────────────── // ── Display helpers ───────────────────────────────────────────────────────────
@@ -100,3 +90,112 @@ impl Job {
} }
} }
} }
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
fn sample_job() -> Job {
Job {
id: "a3f2b091-1234-5678-9abc-000000000000".to_string(),
worker: "beefy".to_string(),
cwd: "/home/user/projects/foo".to_string(),
command: "make".to_string(),
started_at: 1_700_000_000,
finished_at: None,
exit_code: None,
status: JobStatus::Running,
}
}
#[test]
fn short_id_is_first_8_chars() {
assert_eq!(sample_job().short_id(), "a3f2b091");
}
#[test]
fn status_display_running() {
assert_eq!(sample_job().status_display(), "running");
}
#[test]
fn status_display_done() {
let job = Job {
status: JobStatus::Done,
exit_code: Some(0),
..sample_job()
};
assert_eq!(job.status_display(), "done [0]");
}
#[test]
fn status_display_failed() {
let job = Job {
status: JobStatus::Failed,
exit_code: Some(2),
..sample_job()
};
assert_eq!(job.status_display(), "failed [2]");
}
#[test]
fn status_display_stopped() {
let job = Job {
status: JobStatus::Stopped,
..sample_job()
};
assert_eq!(job.status_display(), "stopped");
}
#[test]
fn duration_display_minutes_seconds() {
let job = Job {
started_at: 1_000,
finished_at: Some(1_065),
..sample_job()
};
assert_eq!(job.duration_display(), "1:05");
}
#[test]
fn duration_display_hours() {
let job = Job {
started_at: 0,
finished_at: Some(3_661),
..sample_job()
};
assert_eq!(job.duration_display(), "1:01:01");
}
#[test]
fn command_display_no_truncation() {
assert_eq!(sample_job().command_display(50), "make");
}
#[test]
fn command_display_truncated() {
assert_eq!(sample_job().command_display(3), "ma…");
}
#[test]
fn cwd_display_outside_home() {
let job = Job {
cwd: "/var/tmp/something".to_string(),
..sample_job()
};
assert_eq!(job.cwd_display(), "/var/tmp/something");
}
#[test]
fn cwd_display_under_home() {
if let Some(home) = dirs::home_dir() {
let job = Job {
cwd: home.join("projects/foo").to_string_lossy().to_string(),
..sample_job()
};
assert_eq!(job.cwd_display(), "~/projects/foo");
}
}
}

View File

@@ -161,3 +161,66 @@ pub fn poll_jobs(worker: &WorkerConfig, job_ids: &[&str]) -> Result<HashMap<Stri
} }
Ok(map) Ok(map)
} }
// ── Tests ─────────────────────────────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_user_at_host() {
let (uh, port) = parse_connection("user@host.local");
assert_eq!(uh, "user@host.local");
assert_eq!(port, None);
}
#[test]
fn parse_user_at_host_with_port() {
let (uh, port) = parse_connection("user@host.local:2222");
assert_eq!(uh, "user@host.local");
assert_eq!(port, Some(2222));
}
#[test]
fn parse_bare_host() {
let (uh, port) = parse_connection("myserver");
assert_eq!(uh, "myserver");
assert_eq!(port, None);
}
#[test]
fn parse_host_with_port() {
let (uh, port) = parse_connection("192.168.1.1:22");
assert_eq!(uh, "192.168.1.1");
assert_eq!(port, Some(22));
}
#[test]
fn parse_ssh_alias_unchanged() {
// Plain SSH config aliases contain no '@' or ':port' — returned as-is.
let (uh, port) = parse_connection("my-dev-box");
assert_eq!(uh, "my-dev-box");
assert_eq!(port, None);
}
#[test]
fn hostname_strips_user_prefix() {
assert_eq!(hostname_from_connection("user@host.local"), "host.local");
}
#[test]
fn hostname_strips_user_and_port() {
assert_eq!(hostname_from_connection("user@host.local:22"), "host.local");
}
#[test]
fn hostname_from_bare_host() {
assert_eq!(hostname_from_connection("myserver"), "myserver");
}
#[test]
fn hostname_from_ssh_alias() {
assert_eq!(hostname_from_connection("my-dev-box"), "my-dev-box");
}
}