diff --git a/segkit/Cargo.lock b/segkit/Cargo.lock index 194a0c6..97b453d 100644 --- a/segkit/Cargo.lock +++ b/segkit/Cargo.lock @@ -220,6 +220,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -230,6 +236,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -245,6 +257,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "futures-core" version = "0.3.32" @@ -269,6 +287,34 @@ dependencies = [ "slab", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "heck" version = "0.5.0" @@ -299,6 +345,24 @@ dependencies = [ "cc", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -323,6 +387,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.186" @@ -410,6 +480,16 @@ dependencies = [ "termtree", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -428,6 +508,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "regex" version = "1.12.3" @@ -487,9 +573,16 @@ dependencies = [ "predicates", "serde", "serde_json", + "tempfile", "which", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -562,6 +655,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "termtree" version = "0.5.1" @@ -574,6 +680,12 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "utf8parse" version = "0.2.2" @@ -589,6 +701,24 @@ dependencies = [ "libc", ] +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + [[package]] name = "wasm-bindgen" version = "0.2.120" @@ -634,6 +764,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "which" version = "7.0.3" @@ -720,6 +884,100 @@ version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/segkit/Cargo.toml b/segkit/Cargo.toml index 0bf6725..b3f4338 100644 --- a/segkit/Cargo.toml +++ b/segkit/Cargo.toml @@ -21,3 +21,4 @@ which = "7" [dev-dependencies] assert_cmd = "2" predicates = "3" +tempfile = "3" diff --git a/segkit/src/ci.rs b/segkit/src/ci.rs new file mode 100644 index 0000000..088b817 --- /dev/null +++ b/segkit/src/ci.rs @@ -0,0 +1,301 @@ +use std::io::Write; +use std::process::{Command, ExitCode, Stdio}; +use std::time::Instant; + +use anyhow::{Context, Result}; +use chrono::Utc; + +fn reports_dir() -> String { + std::env::var("REPORTS_DIR").unwrap_or_else(|_| "reports".into()) +} + +pub fn wrap(command: &[String]) -> ExitCode { + if command.is_empty() { + eprintln!("segkit ci wrap: no command specified"); + return ExitCode::from(1); + } + + let label = command.join(" "); + let start = Instant::now(); + + let mut child = match Command::new(&command[0]) + .args(&command[1..]) + .stderr(Stdio::piped()) + .spawn() + { + Ok(c) => c, + Err(e) => { + eprintln!("segkit ci wrap: failed to execute '{}': {e}", command[0]); + let duration_ms = start.elapsed().as_millis(); + write_timing(&label, duration_ms, 127, ""); + write_error(&label, 127, &e.to_string()); + return ExitCode::from(127); + } + }; + + let stderr_handle = child.stderr.take(); + + let stderr_output = std::thread::spawn(move || { + use std::io::{BufRead, BufReader}; + let Some(stderr) = stderr_handle else { + return String::new(); + }; + let reader = BufReader::new(stderr); + let mut captured = String::new(); + for line in reader.lines() { + match line { + Ok(l) => { + eprintln!("{l}"); + captured.push_str(&l); + captured.push('\n'); + } + Err(_) => break, + } + } + captured + }); + + let status = child.wait(); + let duration_ms = start.elapsed().as_millis(); + let stderr_text = stderr_output.join().unwrap_or_default(); + + let stderr_tail: String = stderr_text + .lines() + .rev() + .take(20) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join("\n"); + + match status { + Ok(s) => { + let code = s.code().unwrap_or(1); + write_timing(&label, duration_ms, code, &stderr_tail); + if code != 0 { + write_error(&label, code, &stderr_tail); + } + ExitCode::from(code as u8) + } + Err(e) => { + eprintln!("segkit ci wrap: wait failed: {e}"); + write_timing(&label, duration_ms, 1, &stderr_tail); + write_error(&label, 1, &format!("{e}\n{stderr_tail}")); + ExitCode::from(1) + } + } +} + +fn write_timing(label: &str, duration_ms: u128, exit_code: i32, stderr_tail: &str) { + let dir = reports_dir(); + let _ = std::fs::create_dir_all(&dir); + + let entry = serde_json::json!({ + "ts": Utc::now().to_rfc3339(), + "command": label, + "duration_ms": duration_ms, + "exit_code": exit_code, + "stderr_tail": stderr_tail, + }); + + let path = format!("{dir}/segkit-timing.jsonl"); + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + { + let _ = writeln!(f, "{}", entry); + } +} + +fn write_error(label: &str, exit_code: i32, stderr_tail: &str) { + let dir = reports_dir(); + let _ = std::fs::create_dir_all(&dir); + + let entry = serde_json::json!({ + "ts": Utc::now().to_rfc3339(), + "command": label, + "exit_code": exit_code, + "stderr_tail": stderr_tail, + }); + + let path = format!("{dir}/segkit-errors.jsonl"); + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + { + let _ = writeln!(f, "{}", entry); + } +} + +#[derive(Debug)] +struct TimingEntry { + command: String, + duration_ms: u128, + exit_code: i32, +} + +fn read_timing_entries() -> Result> { + let path = format!("{}/segkit-timing.jsonl", reports_dir()); + let content = std::fs::read_to_string(&path) + .with_context(|| format!("no timing data found at {path}"))?; + + let mut entries = Vec::new(); + for line in content.lines() { + if line.trim().is_empty() { + continue; + } + if let Ok(v) = serde_json::from_str::(line) { + entries.push(TimingEntry { + command: v["command"].as_str().unwrap_or("?").to_string(), + duration_ms: v["duration_ms"].as_u64().unwrap_or(0) as u128, + exit_code: v["exit_code"].as_i64().unwrap_or(-1) as i32, + }); + } + } + Ok(entries) +} + +#[derive(Debug)] +struct ErrorEntry { + ts: String, + command: String, + exit_code: i32, + stderr_tail: String, +} + +fn read_error_entries() -> Vec { + let path = format!("{}/segkit-errors.jsonl", reports_dir()); + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + let mut entries = Vec::new(); + for line in content.lines() { + if line.trim().is_empty() { + continue; + } + if let Ok(v) = serde_json::from_str::(line) { + entries.push(ErrorEntry { + ts: v["ts"].as_str().unwrap_or("").to_string(), + command: v["command"].as_str().unwrap_or("?").to_string(), + exit_code: v["exit_code"].as_i64().unwrap_or(-1) as i32, + stderr_tail: v["stderr_tail"].as_str().unwrap_or("").to_string(), + }); + } + } + entries +} + +pub fn summary(platform: Option<&str>, device: Option<&str>) -> ExitCode { + let entries = match read_timing_entries() { + Ok(e) if !e.is_empty() => e, + Ok(_) => { + eprintln!("segkit ci summary: no timing entries found"); + return ExitCode::from(1); + } + Err(e) => { + eprintln!("segkit ci summary: {e:#}"); + return ExitCode::from(1); + } + }; + + let errors = read_error_entries(); + + let mut md = String::new(); + + if let (Some(p), Some(d)) = (platform, device) { + md.push_str(&format!("## {p} — {d}\n\n")); + } else if let Some(p) = platform { + md.push_str(&format!("## {p}\n\n")); + } else { + md.push_str("## CI Step Timing\n\n"); + } + + md.push_str("| Step | Duration | Status |\n"); + md.push_str("|------|----------|--------|\n"); + + let mut total_ms: u128 = 0; + let mut any_failed = false; + + for entry in &entries { + let status = if entry.exit_code == 0 { + "pass".to_string() + } else { + any_failed = true; + format!("FAIL (exit {})", entry.exit_code) + }; + let duration = format_duration(entry.duration_ms); + md.push_str(&format!("| {} | {} | {} |\n", entry.command, duration, status)); + total_ms += entry.duration_ms; + } + + md.push_str(&format!( + "| **Total** | **{}** | {} |\n", + format_duration(total_ms), + if any_failed { "**FAIL**" } else { "pass" } + )); + + if !errors.is_empty() { + md.push_str("\n### Errors\n\n"); + for err in &errors { + md.push_str(&format!( + "
{} (exit {}, {})\n\n```\n{}\n```\n\n
\n\n", + err.command, err.exit_code, err.ts, err.stderr_tail + )); + } + } + + print!("{md}"); + + if let Ok(summary_path) = std::env::var("GITHUB_STEP_SUMMARY") { + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&summary_path) + { + let _ = write!(f, "{md}"); + } + } + + if any_failed { + ExitCode::from(1) + } else { + ExitCode::SUCCESS + } +} + +fn format_duration(ms: u128) -> String { + if ms < 1_000 { + format!("{ms}ms") + } else if ms < 60_000 { + format!("{:.1}s", ms as f64 / 1_000.0) + } else { + let mins = ms / 60_000; + let secs = (ms % 60_000) / 1_000; + format!("{mins}m{secs:02}s") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_duration_millis() { + assert_eq!(format_duration(500), "500ms"); + } + + #[test] + fn format_duration_seconds() { + assert_eq!(format_duration(3_500), "3.5s"); + } + + #[test] + fn format_duration_minutes() { + assert_eq!(format_duration(125_000), "2m05s"); + } +} diff --git a/segkit/src/main.rs b/segkit/src/main.rs index 465b825..1274e8d 100644 --- a/segkit/src/main.rs +++ b/segkit/src/main.rs @@ -2,6 +2,7 @@ use std::process::ExitCode; use clap::{Parser, Subcommand}; +mod ci; mod delegate; mod setup; @@ -34,10 +35,33 @@ enum Commands { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, + /// CI helper commands (wrap, summary) + Ci { + #[command(subcommand)] + command: CiCommands, + }, /// Check and install required dependencies (devbox) Setup, } +#[derive(Subcommand)] +enum CiCommands { + /// Run a command and capture timing/exit/stderr to reports + Wrap { + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + command: Vec, + }, + /// Generate markdown summary from timing/error artifacts + Summary { + /// Platform name for the summary header + #[arg(long)] + platform: Option, + /// Device name for the summary header + #[arg(long)] + device: Option, + }, +} + fn main() -> ExitCode { let cli = Cli::parse(); @@ -46,6 +70,12 @@ fn main() -> ExitCode { Some(Commands::Ios { args }) => delegate::run("ios.sh", &args), Some(Commands::Rn { args }) => delegate::run("rn.sh", &args), Some(Commands::Metro { args }) => delegate::run("metro.sh", &args), + Some(Commands::Ci { command }) => match command { + CiCommands::Wrap { command } => ci::wrap(&command), + CiCommands::Summary { platform, device } => { + ci::summary(platform.as_deref(), device.as_deref()) + } + }, Some(Commands::Setup) => setup::run(), None => { println!("segkit {}", env!("CARGO_PKG_VERSION")); diff --git a/segkit/tests/cli.rs b/segkit/tests/cli.rs index 2d3f418..58a8255 100644 --- a/segkit/tests/cli.rs +++ b/segkit/tests/cli.rs @@ -87,3 +87,103 @@ fn setup_detects_devbox() { .assert() .stdout(predicate::str::contains("devbox:")); } + +#[test] +fn ci_wrap_runs_command_and_writes_timing() { + let dir = tempfile::tempdir().unwrap(); + segkit() + .args(["ci", "wrap", "--", "echo", "hello"]) + .env("REPORTS_DIR", dir.path()) + .assert() + .success() + .stdout(predicate::str::contains("hello")); + + let timing = std::fs::read_to_string(dir.path().join("segkit-timing.jsonl")).unwrap(); + assert!(timing.contains("echo hello")); + assert!(timing.contains("\"exit_code\":0")); +} + +#[test] +fn ci_wrap_captures_nonzero_exit() { + let dir = tempfile::tempdir().unwrap(); + segkit() + .args(["ci", "wrap", "--", "sh", "-c", "exit 42"]) + .env("REPORTS_DIR", dir.path()) + .assert() + .code(42); + + let timing = std::fs::read_to_string(dir.path().join("segkit-timing.jsonl")).unwrap(); + assert!(timing.contains("\"exit_code\":42")); + + let errors = std::fs::read_to_string(dir.path().join("segkit-errors.jsonl")).unwrap(); + assert!(errors.contains("\"exit_code\":42")); +} + +#[test] +fn ci_wrap_no_command_fails() { + segkit() + .args(["ci", "wrap"]) + .assert() + .failure() + .stderr(predicate::str::contains("no command specified")); +} + +#[test] +fn ci_summary_with_timing_data() { + let dir = tempfile::tempdir().unwrap(); + let timing_path = dir.path().join("segkit-timing.jsonl"); + std::fs::write( + &timing_path, + r#"{"ts":"2026-01-01T00:00:00Z","command":"build app","duration_ms":5000,"exit_code":0,"stderr_tail":""} +{"ts":"2026-01-01T00:01:00Z","command":"deploy app","duration_ms":3200,"exit_code":0,"stderr_tail":""} +"#, + ) + .unwrap(); + + segkit() + .args(["ci", "summary", "--platform", "android", "--device", "max"]) + .env("REPORTS_DIR", dir.path()) + .assert() + .success() + .stdout(predicate::str::contains("android — max")) + .stdout(predicate::str::contains("build app")) + .stdout(predicate::str::contains("5.0s")) + .stdout(predicate::str::contains("deploy app")) + .stdout(predicate::str::contains("Total")); +} + +#[test] +fn ci_summary_no_data_fails() { + let dir = tempfile::tempdir().unwrap(); + segkit() + .args(["ci", "summary"]) + .env("REPORTS_DIR", dir.path()) + .assert() + .failure() + .stderr(predicate::str::contains("no timing data found")); +} + +#[test] +fn ci_summary_shows_errors() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("segkit-timing.jsonl"), + r#"{"ts":"2026-01-01T00:00:00Z","command":"build","duration_ms":1000,"exit_code":1,"stderr_tail":""} +"#, + ) + .unwrap(); + std::fs::write( + dir.path().join("segkit-errors.jsonl"), + r#"{"ts":"2026-01-01T00:00:00Z","command":"build","exit_code":1,"stderr_tail":"build failed"} +"#, + ) + .unwrap(); + + segkit() + .args(["ci", "summary"]) + .env("REPORTS_DIR", dir.path()) + .assert() + .code(1) + .stdout(predicate::str::contains("FAIL (exit 1)")) + .stdout(predicate::str::contains("build failed")); +}