diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..569ce8d --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/p/src/commands/ls.rs b/p/src/commands/ls.rs index 3bf1ebb..a851287 100644 --- a/p/src/commands/ls.rs +++ b/p/src/commands/ls.rs @@ -44,18 +44,15 @@ pub fn execute() -> Result<()> { let now = chrono::Utc::now().timestamp(); for &i in indices { let id = jobs[i].id.clone(); - if let Some(maybe_ec) = results.get(&id) { - if let Some(ec) = maybe_ec { - jobs[i].status = if *ec == 0 { - JobStatus::Done - } else { - JobStatus::Failed - }; - jobs[i].exit_code = Some(*ec); - jobs[i].finished_at = Some(now); - db::save(&jobs[i])?; - } - // None means still running — no update needed. + if let Some(Some(ec)) = results.get(&id) { + jobs[i].status = if *ec == 0 { + JobStatus::Done + } else { + JobStatus::Failed + }; + jobs[i].exit_code = Some(*ec); + jobs[i].finished_at = Some(now); + db::save(&jobs[i])?; } } } diff --git a/p/src/config.rs b/p/src/config.rs index ba9e4c4..4e7f560 100644 --- a/p/src/config.rs +++ b/p/src/config.rs @@ -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()); + } +} diff --git a/p/src/db.rs b/p/src/db.rs index 5f59a85..c243685 100644 --- a/p/src/db.rs +++ b/p/src/db.rs @@ -31,19 +31,6 @@ pub fn save(job: &Job) -> Result<()> { .with_context(|| format!("failed to write job {}", job.id)) } -/// Load a job by exact UUID. -pub fn load(id: &str) -> Result> { - 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. pub fn find(prefix: &str) -> Result> { let dir = jobs_dir()?; @@ -111,3 +98,58 @@ pub fn delete(id: &str) -> Result<()> { } 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")); + } +} diff --git a/p/src/job.rs b/p/src/job.rs index f3893fb..25da123 100644 --- a/p/src/job.rs +++ b/p/src/job.rs @@ -35,16 +35,6 @@ impl JobStatus { 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 ─────────────────────────────────────────────────────────── @@ -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"); + } + } +} diff --git a/p/src/ssh.rs b/p/src/ssh.rs index 8afad35..d2d51a8 100644 --- a/p/src/ssh.rs +++ b/p/src/ssh.rs @@ -161,3 +161,66 @@ pub fn poll_jobs(worker: &WorkerConfig, job_ids: &[&str]) -> Result