diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..5c6ecc6 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,3 @@ +[alias] +x = "run --package try_v2_xtasks --" +stage = "run --package try_v2_xtasks -- add" \ No newline at end of file diff --git a/.github/workflows/rust-stability.yml b/.github/workflows/rust-stability.yml index 2319ccb..1c934a6 100644 --- a/.github/workflows/rust-stability.yml +++ b/.github/workflows/rust-stability.yml @@ -26,9 +26,9 @@ jobs: steps: - uses: actions/checkout@v6 - name: update rust - run: rustup update + run: rustup set profile default && rustup update && rustup default ${{ matrix.channel }} - name: Run tests - run: cargo +${{ matrix.channel }} test --no-fail-fast + run: cargo test --no-fail-fast lint: strategy: @@ -39,9 +39,9 @@ jobs: steps: - uses: actions/checkout@v6 - name: update rust - run: rustup update && rustup component add --toolchain ${{ matrix.channel }} clippy + run: rustup set profile default && rustup update && rustup default ${{ matrix.channel }} - name: clippy - run: cargo +${{ matrix.channel }} clippy -- --deny warnings + run: cargo clippy -- --deny warnings report: if: ${{ always() }} diff --git a/Cargo.toml b/Cargo.toml index c26429d..9bcdf64 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,8 @@ trybuild = "1.0.116" autocfg = {workspace = true } [workspace] -members = ["tests/compilation"] +members = ["tests/compilation", "xtask"] +default-members = ["xtask"] [workspace.dependencies] try_v2 = { path = "."} diff --git a/build.rs b/build.rs index 15af04d..129592d 100644 --- a/build.rs +++ b/build.rs @@ -15,15 +15,22 @@ fn main() { fn stable_feature(ac: &AutoCfg, feature: &'static str) { let cfg = format!("stable_{feature}"); - let code = format!( + let deny = format!( r#" #![deny(stable_features)] #![feature({feature})] "# ); + let allow = format!( + r#" + #![allow(stable_features)] + #![feature({feature})] + "# + ); + autocfg::emit_possibility(&cfg); - if ac.probe_raw(&code).is_err() { + if ac.probe_raw(&deny).is_err() && ac.probe_raw(&allow).is_ok() { autocfg::emit(&cfg); } } diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml new file mode 100644 index 0000000..ebabac0 --- /dev/null +++ b/xtask/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "try_v2_xtasks" +version = "0.0.1" +edition = "2024" +publish = false + +[dependencies] +exit_safely = "0.2.1" +try_v2 = { workspace = true } +clap = { version = "4.6.0", features = ["derive"] } + +[dev-dependencies] +dircpy = "0.3.20" +tempfile = "3.27.0" + +[build-dependencies] +autocfg = { workspace = true } diff --git a/xtask/src/commands.rs b/xtask/src/commands.rs new file mode 100644 index 0000000..0dccfd9 --- /dev/null +++ b/xtask/src/commands.rs @@ -0,0 +1,54 @@ +use std::{ + path::Path, + process::{Command, Stdio}, +}; + +use crate::{Cmd, CmdExt as _, Spawned, SpawnedExt as _}; + +pub fn fmt(root: &Path) -> Cmd { + Command::new("cargo") + .current_dir(root) + .arg("fmt") + .output() + .into_cmd("fmt") +} + +pub fn git_add(root: &Path) -> Cmd { + Command::new("git") + .current_dir(root) + .arg("add") + .arg(".") + .output() + .into_cmd("git add") +} + +pub fn clippy(root: &Path) -> Spawned { + Command::new("cargo") + .current_dir(root) + .arg("clippy") + .stderr(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .into_spawned("clippy") +} + +pub fn clippy_tests(root: &Path) -> Spawned { + Command::new("cargo") + .current_dir(root) + .arg("clippy") + .arg("--tests") + .stderr(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .into_spawned("clippy the tests") +} + +pub fn test(root: &Path) -> Spawned { + Command::new("cargo") + .current_dir(root) + .arg("test") + .stderr(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .into_spawned("tests") +} diff --git a/xtask/src/lib.rs b/xtask/src/lib.rs new file mode 100644 index 0000000..59f03f7 --- /dev/null +++ b/xtask/src/lib.rs @@ -0,0 +1,179 @@ +#![feature(never_type)] +#![feature(try_trait_v2)] +#![feature(try_trait_v2_residual)] + +use std::{ + fmt::Debug, + io, + process::{Child, Output, Termination as _T}, +}; + +use exit_safely::Termination; +use try_v2::{Try, Try_ConvertResult}; + +pub mod commands; + +#[derive(Debug, Termination, Try, Try_ConvertResult, PartialEq, PartialOrd, Eq, Ord)] +#[repr(u8)] +#[must_use] +pub enum Exit { + Ok(T) = 0, + Error(String) = 1, + InvocationError(String) = 2, + IO(String) = 3, +} + +impl Exit<()> { + fn message(&self) -> &str { + match self { + Exit::Ok(_) => "", + Exit::Error(m) => m, + Exit::InvocationError(m) => m, + Exit::IO(m) => m, + } + } + + fn replace_message(self, msg: String) -> Option { + match self { + Exit::Ok(_) => None, + Exit::Error(_) => Some(Exit::Error(msg)), + Exit::InvocationError(_) => Some(Exit::InvocationError(msg)), + Exit::IO(_) => Some(Exit::IO(msg)), + } + } +} + +impl FromIterator> for Exit<()> { + fn from_iter>>(iter: I) -> Self { + let mut msg = String::new(); + iter.into_iter() + .filter_map(|e| { + if let Exit::Ok(_) = e { + None + } else { + msg.push_str(e.message()); + msg.push('\n'); + Some(e) + } + }) + .min() + .and_then(|e| e.replace_message(msg)) + .unwrap_or(Exit::Ok(())) + } +} + +impl From for Exit { + fn from(e: clap::Error) -> Self { + Self::InvocationError(e.to_string()) + } +} + +#[derive(Debug)] +pub struct Cmd { + pub name: &'static str, + pub result: Result, +} + +trait CmdExt { + fn into_cmd(self, name: &'static str) -> Cmd; +} + +impl CmdExt for Result { + fn into_cmd(self, name: &'static str) -> Cmd { + Cmd { name, result: self } + } +} + +impl From for Exit<()> { + fn from(cmd: Cmd) -> Self { + match cmd.result { + Ok(output) => { + if output.status.success() { + println!("{}: OK", cmd.name); + Self::Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + Self::Error(stderr.to_string()) + } + } + Err(e) => { + let msg = format!("{} failed: {}", cmd.name, e); + Self::IO(msg) + } + } + } +} + +#[derive(Debug)] +pub struct Spawned { + pub name: &'static str, + pub child: Result, +} + +impl Spawned { + pub fn wait(self) -> Cmd { + match self.child { + Ok(child) => child.wait_with_output().into_cmd(self.name), + Err(e) => Cmd { + name: self.name, + result: Err(e), + }, + } + } +} + +trait SpawnedExt { + fn into_spawned(self, name: &'static str) -> Spawned; +} + +impl SpawnedExt for Result { + fn into_spawned(self, name: &'static str) -> Spawned { + Spawned { name, child: self } + } +} + +impl From> for Exit<()> { + fn from(spawns: Vec) -> Self { + spawns + .into_iter() + .map(|spawn| spawn.wait()) + .map(Exit::from) + .collect() + } +} + +#[cfg(test)] +mod tests { + use std::process::Command; + + use super::*; + + #[test] + fn exit_from_404() { + let splat: Cmd = Command::new("splat").output().into_cmd("splat"); + assert_eq!(splat.name, "splat"); + assert!( + matches!(splat.result, Result::Err(ref e) if matches!(e.kind(), io::ErrorKind::NotFound)) + ); + let exit: Exit<()> = Exit::from(splat); + let Exit::IO(ref msg) = exit else { + panic!("not an IO2") + }; + eprintln!("{}", msg); + assert!(msg.starts_with("splat failed: ")); + } + + #[test] + fn collect_exit() { + let exits = [ + Exit::Ok(()), + Exit::IO("one".to_string()), + Exit::Error("two".to_string()), + Exit::Error("three".to_string()), + ]; + let exit: Exit<()> = exits.into_iter().collect(); + let expected = "one\ntwo\nthree\n"; + dbg!(&exit); + assert!(matches!(exit, Exit::Error(s) if s == expected)); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs new file mode 100644 index 0000000..26726c6 --- /dev/null +++ b/xtask/src/main.rs @@ -0,0 +1,39 @@ +use std::path::Path; + +use clap::{Parser, Subcommand}; +use try_v2_xtasks::{ + Exit, + commands::{clippy, clippy_tests, fmt, git_add, test}, +}; + +#[derive(Parser)] +#[command(version)] +struct XTask { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// git add if all is good + Add, +} + +fn main() -> Exit<()> { + let xtask = XTask::try_parse()?; + + match &xtask.command { + Command::Add => { + let root = Path::new("."); + let fmt = fmt(root); + Exit::from(fmt)?; + let clippy = clippy(root); + let clippy_tests = clippy_tests(root); + let tests = test(root); + let checks = vec![clippy, clippy_tests, tests]; + Exit::from(checks)?; + let git = git_add(root); + Exit::from(git) + } + } +} diff --git a/xtask/tests/fixture/Cargo.toml b/xtask/tests/fixture/Cargo.toml new file mode 100644 index 0000000..94b3224 --- /dev/null +++ b/xtask/tests/fixture/Cargo.toml @@ -0,0 +1,5 @@ +[package] +name = "try_v2_xtasks_fixture" +version = "0.0.1" +edition = "2024" +publish = false diff --git a/xtask/tests/fixture/src/lib.rs b/xtask/tests/fixture/src/lib.rs new file mode 100644 index 0000000..66e8ee2 --- /dev/null +++ b/xtask/tests/fixture/src/lib.rs @@ -0,0 +1 @@ +enum foo { bar } \ No newline at end of file diff --git a/xtask/tests/test_fmt.rs b/xtask/tests/test_fmt.rs new file mode 100644 index 0000000..2372989 --- /dev/null +++ b/xtask/tests/test_fmt.rs @@ -0,0 +1,25 @@ +use std::fs; + +use dircpy::copy_dir; +use tempfile::tempdir; +use try_v2_xtasks::commands::fmt; + +#[test] +fn fmt_fixture() { + let tmp = tempdir().expect("couldn't create temp dir for test"); + copy_dir("tests/fixture", tmp.path()).expect("couldn't copy fixture"); + let original = fs::read_to_string("tests/fixture/src/lib.rs").unwrap(); + let copied = fs::read_to_string(tmp.path().join("src/lib.rs")).unwrap(); + assert_eq!(original, copied); + let cmd = fmt(tmp.path()); + let output = cmd.result.expect("`cargo fmt` failed to run"); + assert!( + output.status.success(), + "`cargo fmt` exited with status {:?}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stderr), + ); + let formatted = fs::read_to_string(tmp.path().join("src/lib.rs")).unwrap(); + assert_ne!(original, formatted); + dbg!(tmp.path()); +}