Initial commit
This commit is contained in:
@@ -0,0 +1,38 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main", "master" ]
|
||||||
|
pull_request:
|
||||||
|
branches: [ "main", "master" ]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- name: Run tests
|
||||||
|
run: cargo test
|
||||||
|
|
||||||
|
build-linux:
|
||||||
|
name: Build Linux
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- name: Build
|
||||||
|
run: cargo build --release
|
||||||
|
|
||||||
|
build-windows:
|
||||||
|
name: Build Windows
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- name: Build
|
||||||
|
run: cargo build --release
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ "main", "master" ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
match: ${{ steps.check.outputs.match }}
|
||||||
|
version: ${{ steps.check.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- name: Check commit message
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
COMMIT_MSG="${{ github.event.head_commit.message }}"
|
||||||
|
if echo "$COMMIT_MSG" | grep -Eq 'Release [vV]?[0-9]+\.[0-9]+\.[0-9]+'; then
|
||||||
|
VERSION=$(echo "$COMMIT_MSG" | grep -Eo 'Release [vV]?[0-9]+\.[0-9]+\.[0-9]+' | head -n1 | sed -E 's/Release //')
|
||||||
|
echo "match=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "match=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
build-release-assets:
|
||||||
|
needs: check-release
|
||||||
|
if: needs.check-release.outputs.match == 'true'
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: ubuntu-latest
|
||||||
|
target: x86_64-unknown-linux-gnu
|
||||||
|
- os: windows-latest
|
||||||
|
target: x86_64-pc-windows-msvc
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: cargo build --release
|
||||||
|
|
||||||
|
- name: Build deb and rpm
|
||||||
|
if: matrix.os == 'ubuntu-latest'
|
||||||
|
run: |
|
||||||
|
cargo install cargo-deb cargo-generate-rpm
|
||||||
|
cargo deb
|
||||||
|
cargo generate-rpm
|
||||||
|
cp target/debian/*.deb convertis.deb
|
||||||
|
cp target/generate-rpm/*.rpm convertis.rpm
|
||||||
|
|
||||||
|
- name: Rename exe
|
||||||
|
if: matrix.os == 'windows-latest'
|
||||||
|
run: copy target\release\convertis.exe convertis.exe
|
||||||
|
|
||||||
|
- name: Upload Linux Assets
|
||||||
|
if: matrix.os == 'ubuntu-latest'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: linux-assets
|
||||||
|
path: |
|
||||||
|
convertis.deb
|
||||||
|
convertis.rpm
|
||||||
|
|
||||||
|
- name: Upload Windows Assets
|
||||||
|
if: matrix.os == 'windows-latest'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: windows-assets
|
||||||
|
path: convertis.exe
|
||||||
|
|
||||||
|
publish-release:
|
||||||
|
needs: [check-release, build-release-assets]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Download Linux Assets
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: linux-assets
|
||||||
|
|
||||||
|
- name: Download Windows Assets
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: windows-assets
|
||||||
|
|
||||||
|
- name: Generate Changelog
|
||||||
|
run: |
|
||||||
|
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || git rev-list --max-parents=0 HEAD)
|
||||||
|
git log ${LAST_TAG}..HEAD --pretty=format:"- %s (%an)" > commits.txt
|
||||||
|
|
||||||
|
echo "## Changelog" > changelog.md
|
||||||
|
|
||||||
|
echo "### Features" >> changelog.md
|
||||||
|
grep -i "^- feat:" commits.txt >> changelog.md || echo "No new features" >> changelog.md
|
||||||
|
|
||||||
|
echo "### Fixes" >> changelog.md
|
||||||
|
grep -i "^- fix:" commits.txt >> changelog.md || echo "No fixes" >> changelog.md
|
||||||
|
|
||||||
|
echo "### Refactoring & Chores" >> changelog.md
|
||||||
|
grep -i "^- refactor:\|^- chore:\|^- style:" commits.txt >> changelog.md || echo "No refactoring or chores" >> changelog.md
|
||||||
|
|
||||||
|
echo "### Others" >> changelog.md
|
||||||
|
grep -vi "^- feat:\|^- fix:\|^- refactor:\|^- chore:\|^- style:" commits.txt >> changelog.md || echo "No other changes" >> changelog.md
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
gh release create "v${{ needs.check-release.outputs.version }}" \
|
||||||
|
--title "Release v${{ needs.check-release.outputs.version }}" \
|
||||||
|
--notes-file changelog.md \
|
||||||
|
convertis.deb convertis.rpm convertis.exe
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
/target
|
||||||
|
AGENT.md
|
||||||
|
ARCHITECTURE.md
|
||||||
|
Cargo.lock
|
||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
[package]
|
||||||
|
name = "convertis"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "A file converter program."
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://github.com/ewenlau/convertis"
|
||||||
|
|
||||||
|
[package.metadata.deb]
|
||||||
|
maintainer = "Ewen Lau <ewen@example.com>"
|
||||||
|
copyright = "2026, Ewen Lau"
|
||||||
|
extended-description = "A file converter program."
|
||||||
|
depends = "$auto"
|
||||||
|
section = "utility"
|
||||||
|
priority = "optional"
|
||||||
|
|
||||||
|
[package.metadata.generate-rpm]
|
||||||
|
assets = [
|
||||||
|
{ source = "target/release/convertis", dest = "/usr/bin/convertis", mode = "755" }
|
||||||
|
]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
clap = { version = "4.6.1", features = ["derive"] }
|
||||||
|
image = "0.25.10"
|
||||||
|
petgraph = "0.8.3"
|
||||||
|
thiserror = "2.0.18"
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
use std::env;
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn run_command(cmd: &str, args: &[&str]) -> Option<String> {
|
||||||
|
let output = Command::new(cmd).args(args).output().ok()?;
|
||||||
|
if output.status.success() {
|
||||||
|
let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
if !s.is_empty() {
|
||||||
|
return Some(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let out_dir = env::var_os("OUT_DIR").unwrap();
|
||||||
|
let dest_path = Path::new(&out_dir).join("plugins_gen.rs");
|
||||||
|
|
||||||
|
// Read the plugins directory
|
||||||
|
let plugins_dir = Path::new("plugins");
|
||||||
|
|
||||||
|
let mut plugins_code = String::new();
|
||||||
|
let mut registry_add_code = String::new();
|
||||||
|
|
||||||
|
if plugins_dir.exists() {
|
||||||
|
for entry in fs::read_dir(plugins_dir).unwrap() {
|
||||||
|
let entry = entry.unwrap();
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().and_then(|s| s.to_str()) == Some("rs") {
|
||||||
|
let stem = path.file_stem().unwrap().to_str().unwrap();
|
||||||
|
|
||||||
|
// We'll use include! macro to include the file content directly.
|
||||||
|
// However, `include!` requires a valid path. The generated file is in OUT_DIR,
|
||||||
|
// so we need an absolute path or relative to the generated file.
|
||||||
|
let abs_path = path.canonicalize().unwrap();
|
||||||
|
let abs_path_str = abs_path.to_str().unwrap();
|
||||||
|
|
||||||
|
plugins_code.push_str(&format!(
|
||||||
|
"pub mod {} {{\n include!({:?});\n}}\n",
|
||||||
|
stem, abs_path_str
|
||||||
|
));
|
||||||
|
registry_add_code.push_str(&format!(
|
||||||
|
"registry.push(Box::new({}::PluginImpl));\n",
|
||||||
|
stem
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let full_code = format!(
|
||||||
|
"
|
||||||
|
{}
|
||||||
|
pub fn get_plugins() -> Vec<Box<dyn crate::plugin::Plugin>> {{
|
||||||
|
let mut registry: Vec<Box<dyn crate::plugin::Plugin>> = Vec::new();
|
||||||
|
{}
|
||||||
|
registry
|
||||||
|
}}
|
||||||
|
",
|
||||||
|
plugins_code, registry_add_code
|
||||||
|
);
|
||||||
|
|
||||||
|
fs::write(&dest_path, full_code).unwrap();
|
||||||
|
println!("cargo:rerun-if-changed=plugins");
|
||||||
|
|
||||||
|
// Add build metadata for versioning
|
||||||
|
let version = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.1.0".to_string());
|
||||||
|
|
||||||
|
let build_time = run_command("date", &["-u", "+%Y-%m-%d %H:%M:%S UTC"])
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
|
||||||
|
let git_commit = run_command("git", &["rev-parse", "--short", "HEAD"])
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
|
||||||
|
let target = env::var("TARGET").unwrap_or_else(|_| "Unknown".to_string());
|
||||||
|
let profile = env::var("PROFILE").unwrap_or_else(|_| "Unknown".to_string());
|
||||||
|
|
||||||
|
let rustc_path = env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string());
|
||||||
|
let rustc_version = run_command(&rustc_path, &["--version"])
|
||||||
|
.map(|s| s.split('\n').next().unwrap_or("").to_string())
|
||||||
|
.unwrap_or_else(|| "Unknown".to_string());
|
||||||
|
|
||||||
|
let version_string = format!(
|
||||||
|
"{}\nbuild-time: {}\ncommit: {}\ntarget: {}\nprofile: {}\nrustc: {}",
|
||||||
|
version, build_time, git_commit, target, profile, rustc_version
|
||||||
|
);
|
||||||
|
|
||||||
|
let version_path = Path::new(&out_dir).join("version.txt");
|
||||||
|
fs::write(&version_path, &version_string).unwrap();
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
use crate::plugin::Plugin;
|
||||||
|
use image::ImageFormat;
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
pub struct PluginImpl;
|
||||||
|
|
||||||
|
impl Plugin for PluginImpl {
|
||||||
|
fn name(&self) -> &'static str { "jpeg_to_png" }
|
||||||
|
fn from_formats(&self) -> Vec<&'static str> { vec!["jpeg"] }
|
||||||
|
fn to_formats(&self) -> Vec<&'static str> { vec!["png"] }
|
||||||
|
fn familiarity(&self) -> u8 { 255 }
|
||||||
|
fn quality(&self) -> u8 { 255 }
|
||||||
|
fn speed(&self) -> u8 { 200 }
|
||||||
|
|
||||||
|
fn convert(&self, input: &[u8], _from: &str, _to: &str) -> Result<Vec<u8>, String> {
|
||||||
|
let img = image::load_from_memory_with_format(input, ImageFormat::Jpeg)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Png)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
use crate::plugin::Plugin;
|
||||||
|
use image::ImageFormat;
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
pub struct PluginImpl;
|
||||||
|
|
||||||
|
impl Plugin for PluginImpl {
|
||||||
|
fn name(&self) -> &'static str { "png_to_jpeg" }
|
||||||
|
fn from_formats(&self) -> Vec<&'static str> { vec!["png"] }
|
||||||
|
fn to_formats(&self) -> Vec<&'static str> { vec!["jpeg"] }
|
||||||
|
fn familiarity(&self) -> u8 { 255 }
|
||||||
|
fn quality(&self) -> u8 { 200 } // JPEG has some compression loss
|
||||||
|
fn speed(&self) -> u8 { 220 }
|
||||||
|
|
||||||
|
fn convert(&self, input: &[u8], _from: &str, _to: &str) -> Result<Vec<u8>, String> {
|
||||||
|
let img = image::load_from_memory_with_format(input, ImageFormat::Png)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
img.write_to(&mut Cursor::new(&mut buf), ImageFormat::Jpeg)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(buf)
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -0,0 +1,69 @@
|
|||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
pub const VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/version.txt"));
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(version = env!("CARGO_PKG_VERSION"), long_version = VERSION, about = "A file converter program.", long_about = None)]
|
||||||
|
pub struct Args {
|
||||||
|
#[arg(short = 'v', action = clap::ArgAction::Count, help = "Output information about the conversion. Use -v for path taken, -vv for step info, -vvv for timing, and -vvvv for plugin logs.")]
|
||||||
|
pub verbose: u8,
|
||||||
|
|
||||||
|
#[arg(short, long, help = "Test the conversion process without actually converting the file.")]
|
||||||
|
pub test: bool,
|
||||||
|
|
||||||
|
#[arg(short, long, help = "Output nothing to the console, silently fail on errors.")]
|
||||||
|
pub quiet: bool,
|
||||||
|
|
||||||
|
#[arg(short = 'c', long, help = "Get rid of the warning message when not piping the file.")]
|
||||||
|
pub write_to_console: bool,
|
||||||
|
|
||||||
|
#[arg(short, long, default_value = "fqs", help = "Set the priority of the conversion process (e.g. -p fqs means \"familiarity, speed, quality\").")]
|
||||||
|
pub priority: String,
|
||||||
|
|
||||||
|
#[arg(help = "The input file path.")]
|
||||||
|
pub input_path: String,
|
||||||
|
|
||||||
|
#[arg(help = "The output file path (optional).")]
|
||||||
|
pub output_path: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Args {
|
||||||
|
pub fn verbosity_level(&self) -> u8 {
|
||||||
|
self.verbose
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
macro_rules! test_args_verbosity {
|
||||||
|
($name:ident, $verbose:expr, $expected:expr) => {
|
||||||
|
#[test]
|
||||||
|
fn $name() {
|
||||||
|
let args = Args {
|
||||||
|
verbose: $verbose,
|
||||||
|
test: false,
|
||||||
|
quiet: false,
|
||||||
|
write_to_console: false,
|
||||||
|
priority: "fqs".to_string(),
|
||||||
|
input_path: "test".to_string(),
|
||||||
|
output_path: None,
|
||||||
|
};
|
||||||
|
assert_eq!(args.verbosity_level(), $expected);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test_args_verbosity!(test_verbosity_0, 0, 0);
|
||||||
|
test_args_verbosity!(test_verbosity_1, 1, 1);
|
||||||
|
test_args_verbosity!(test_verbosity_2, 2, 2);
|
||||||
|
test_args_verbosity!(test_verbosity_3, 3, 3);
|
||||||
|
test_args_verbosity!(test_verbosity_4, 4, 4);
|
||||||
|
test_args_verbosity!(test_verbosity_5, 5, 5);
|
||||||
|
test_args_verbosity!(test_verbosity_6, 10, 10);
|
||||||
|
test_args_verbosity!(test_verbosity_7, 100, 100);
|
||||||
|
test_args_verbosity!(test_verbosity_8, 255, 255);
|
||||||
|
test_args_verbosity!(test_verbosity_9, 128, 128);
|
||||||
|
test_args_verbosity!(test_verbosity_10, 50, 50);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
pub fn identify_format(path: &str) -> Option<&'static str> {
|
||||||
|
let p = Path::new(path);
|
||||||
|
let ext = p.extension().and_then(|s| s.to_str()).unwrap_or(path).to_lowercase();
|
||||||
|
match ext.as_str() {
|
||||||
|
"jpg" | "jpeg" => Some("jpeg"),
|
||||||
|
"png" => Some("png"),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
macro_rules! test_ident {
|
||||||
|
($name:ident, $ext:expr, $expected:expr) => {
|
||||||
|
#[test]
|
||||||
|
fn $name() {
|
||||||
|
assert_eq!(identify_format($ext), $expected);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test_ident!(test_identify_format_1, "test.jpg", Some("jpeg"));
|
||||||
|
test_ident!(test_identify_format_2, "test.jpeg", Some("jpeg"));
|
||||||
|
test_ident!(test_identify_format_3, "test.png", Some("png"));
|
||||||
|
test_ident!(test_identify_format_4, "png", Some("png"));
|
||||||
|
test_ident!(test_identify_format_5, "jpeg", Some("jpeg"));
|
||||||
|
test_ident!(test_identify_format_6, "test.txt", None);
|
||||||
|
test_ident!(test_identify_format_7, "FILE.JPG", Some("jpeg"));
|
||||||
|
test_ident!(test_identify_format_8, "file.PnG", Some("png"));
|
||||||
|
test_ident!(test_identify_format_9, "no_ext", None);
|
||||||
|
test_ident!(test_identify_format_10, "test.bmp", None);
|
||||||
|
test_ident!(test_identify_format_11, "file.JPEG", Some("jpeg"));
|
||||||
|
test_ident!(test_identify_format_12, "file.PNG", Some("png"));
|
||||||
|
test_ident!(test_identify_format_13, "complex.file.name.jpg", Some("jpeg"));
|
||||||
|
test_ident!(test_identify_format_14, ".hidden.png", Some("png"));
|
||||||
|
test_ident!(test_identify_format_15, "jpg", Some("jpeg")); // "jpg" passed without dot, treats as ext if no dot in path but path is "jpg", ext becomes "jpg"
|
||||||
|
test_ident!(test_identify_format_16, "a.jpg.txt", None);
|
||||||
|
test_ident!(test_identify_format_17, "a.png.bak", None);
|
||||||
|
test_ident!(test_identify_format_18, "a.b.c.JPEG", Some("jpeg"));
|
||||||
|
test_ident!(test_identify_format_19, "test_file_without_extension", None);
|
||||||
|
test_ident!(test_identify_format_20, ".jpg", None);
|
||||||
|
test_ident!(test_identify_format_21, "", None);
|
||||||
|
}
|
||||||
+126
@@ -0,0 +1,126 @@
|
|||||||
|
pub mod args;
|
||||||
|
pub mod identifier;
|
||||||
|
pub mod pathfinder;
|
||||||
|
pub mod plugin;
|
||||||
|
pub mod runner;
|
||||||
|
|
||||||
|
include!(concat!(env!("OUT_DIR"), "/plugins_gen.rs"));
|
||||||
|
|
||||||
|
use args::Args;
|
||||||
|
use clap::Parser;
|
||||||
|
use std::fs;
|
||||||
|
use std::io::{self, IsTerminal, Write};
|
||||||
|
use std::process;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let args = Args::parse();
|
||||||
|
|
||||||
|
// Read input file
|
||||||
|
let input_bytes = match fs::read(&args.input_path) {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
if !args.quiet {
|
||||||
|
eprintln!("Error reading input file: {}", e);
|
||||||
|
}
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let from_format = match identifier::identify_format(&args.input_path) {
|
||||||
|
Some(f) => f,
|
||||||
|
None => {
|
||||||
|
if !args.quiet {
|
||||||
|
eprintln!("Error: Unknown input format.");
|
||||||
|
}
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let plugins = get_plugins();
|
||||||
|
|
||||||
|
let to_format = match &args.output_path {
|
||||||
|
Some(out) => match identifier::identify_format(out) {
|
||||||
|
Some(f) => f,
|
||||||
|
None => {
|
||||||
|
if !args.quiet {
|
||||||
|
eprintln!("Error: Unknown output format.");
|
||||||
|
}
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
// Pick a default target format based on the first available conversion
|
||||||
|
let default_target = plugins.iter().find_map(|p| {
|
||||||
|
if p.from_formats().contains(&from_format) {
|
||||||
|
p.to_formats().first().copied()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
});
|
||||||
|
match default_target {
|
||||||
|
Some(t) => t,
|
||||||
|
None => {
|
||||||
|
if !args.quiet {
|
||||||
|
eprintln!("Error: No output path provided, and no available conversions found.");
|
||||||
|
}
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let path = match pathfinder::find_best_path(&plugins, from_format, to_format, &args.priority) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
if !args.quiet {
|
||||||
|
eprintln!("Error: No conversion path found from {} to {}.", from_format, to_format);
|
||||||
|
}
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if args.test {
|
||||||
|
if !args.quiet {
|
||||||
|
let path_str: Vec<_> = path.iter().map(|(p, _, _)| p.name()).collect();
|
||||||
|
println!("Test successful. Path: {}", path_str.join(" -> "));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let verbosity = args.verbosity_level();
|
||||||
|
let result = runner::run_conversion(&path, &input_bytes, verbosity);
|
||||||
|
|
||||||
|
let output_bytes = match result {
|
||||||
|
Ok(b) => b,
|
||||||
|
Err(e) => {
|
||||||
|
if !args.quiet {
|
||||||
|
eprintln!("Conversion error: {}", e);
|
||||||
|
}
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(out_path) = &args.output_path {
|
||||||
|
if let Err(e) = fs::write(out_path, &output_bytes) {
|
||||||
|
if !args.quiet {
|
||||||
|
eprintln!("Error writing output file: {}", e);
|
||||||
|
}
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let is_piped = !io::stdout().is_terminal();
|
||||||
|
if !is_piped && !args.write_to_console {
|
||||||
|
if !args.quiet {
|
||||||
|
eprintln!("Warning: Outputting directly to console. Use -c to suppress this warning, or redirect to a file.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut stdout = io::stdout();
|
||||||
|
if let Err(e) = stdout.write_all(&output_bytes) {
|
||||||
|
if !args.quiet {
|
||||||
|
eprintln!("Error writing to console: {}", e);
|
||||||
|
}
|
||||||
|
process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
use crate::plugin::Plugin;
|
||||||
|
use std::collections::{HashMap, VecDeque};
|
||||||
|
use std::rc::Rc;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct PathNode<'a> {
|
||||||
|
pub plugin: &'a dyn Plugin,
|
||||||
|
pub from_format: &'a str,
|
||||||
|
pub to_format: &'a str,
|
||||||
|
pub prev: Option<Rc<PathNode<'a>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> PathNode<'a> {
|
||||||
|
pub fn get_path(&self) -> Vec<(&'a dyn Plugin, &'a str, &'a str)> {
|
||||||
|
let mut path = Vec::new();
|
||||||
|
path.push((self.plugin, self.from_format, self.to_format));
|
||||||
|
|
||||||
|
let mut curr = self.prev.clone();
|
||||||
|
while let Some(node) = curr {
|
||||||
|
path.push((node.plugin, node.from_format, node.to_format));
|
||||||
|
curr = node.prev.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
path.reverse();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_best_path<'a>(
|
||||||
|
plugins: &'a [Box<dyn Plugin>],
|
||||||
|
from_format: &'a str,
|
||||||
|
to_format: &str,
|
||||||
|
priority: &str,
|
||||||
|
) -> Option<Vec<(&'a dyn Plugin, &'a str, &'a str)>> {
|
||||||
|
let mut adj_list: HashMap<&str, Vec<&'a dyn Plugin>> = HashMap::new();
|
||||||
|
for p in plugins {
|
||||||
|
for &from in &p.from_formats() {
|
||||||
|
adj_list.entry(from).or_default().push(p.as_ref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BFS to find paths with least amount of conversions
|
||||||
|
let mut queue = VecDeque::new();
|
||||||
|
queue.push_back((from_format, None::<Rc<PathNode<'a>>>));
|
||||||
|
|
||||||
|
let mut min_length = None;
|
||||||
|
let mut best_paths: Vec<Vec<(&'a dyn Plugin, &'a str, &'a str)>> = Vec::new();
|
||||||
|
|
||||||
|
let mut visited_depth = HashMap::new();
|
||||||
|
visited_depth.insert(from_format, 0);
|
||||||
|
|
||||||
|
while let Some((curr_format, prev_node)) = queue.pop_front() {
|
||||||
|
let current_depth = visited_depth.get(curr_format).copied().unwrap_or(0);
|
||||||
|
|
||||||
|
if let Some(min_len) = min_length {
|
||||||
|
if current_depth > min_len {
|
||||||
|
break; // We've moved beyond the shortest paths
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if curr_format == to_format && prev_node.is_some() {
|
||||||
|
if min_length.is_none() {
|
||||||
|
min_length = Some(current_depth);
|
||||||
|
}
|
||||||
|
if min_length == Some(current_depth) {
|
||||||
|
best_paths.push(prev_node.unwrap().get_path());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(neighbors) = adj_list.get(curr_format) {
|
||||||
|
for plugin in neighbors {
|
||||||
|
for &next_format in &plugin.to_formats() {
|
||||||
|
let next_depth = current_depth + 1;
|
||||||
|
|
||||||
|
let prev_depth = visited_depth.get(next_format).copied().unwrap_or(usize::MAX);
|
||||||
|
|
||||||
|
if next_depth <= prev_depth {
|
||||||
|
visited_depth.insert(next_format, next_depth);
|
||||||
|
|
||||||
|
let new_node = Rc::new(PathNode {
|
||||||
|
plugin: *plugin,
|
||||||
|
from_format: curr_format,
|
||||||
|
to_format: next_format,
|
||||||
|
prev: prev_node.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
queue.push_back((next_format, Some(new_node)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if best_paths.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate based on priority (maximize score)
|
||||||
|
best_paths.into_iter().max_by(|path_a, path_b| {
|
||||||
|
compare_paths(path_a, path_b, priority)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn compare_paths(path_a: &[(&dyn Plugin, &str, &str)], path_b: &[(&dyn Plugin, &str, &str)], priority: &str) -> std::cmp::Ordering {
|
||||||
|
for ch in priority.chars() {
|
||||||
|
match ch {
|
||||||
|
'f' | 'F' => {
|
||||||
|
let score_a: u32 = path_a.iter().map(|(p, _, _)| p.familiarity() as u32).sum();
|
||||||
|
let score_b: u32 = path_b.iter().map(|(p, _, _)| p.familiarity() as u32).sum();
|
||||||
|
if score_a != score_b {
|
||||||
|
return score_a.cmp(&score_b);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'q' | 'Q' => {
|
||||||
|
let score_a: u32 = path_a.iter().map(|(p, _, _)| p.quality() as u32).sum();
|
||||||
|
let score_b: u32 = path_b.iter().map(|(p, _, _)| p.quality() as u32).sum();
|
||||||
|
if score_a != score_b {
|
||||||
|
return score_a.cmp(&score_b);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
's' | 'S' => {
|
||||||
|
let score_a: u32 = path_a.iter().map(|(p, _, _)| p.speed() as u32).sum();
|
||||||
|
let score_b: u32 = path_b.iter().map(|(p, _, _)| p.speed() as u32).sum();
|
||||||
|
if score_a != score_b {
|
||||||
|
return score_a.cmp(&score_b);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::cmp::Ordering::Equal
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct MockPlugin {
|
||||||
|
name: &'static str,
|
||||||
|
from: Vec<&'static str>,
|
||||||
|
to: Vec<&'static str>,
|
||||||
|
f: u8,
|
||||||
|
q: u8,
|
||||||
|
s: u8,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Plugin for MockPlugin {
|
||||||
|
fn name(&self) -> &'static str { self.name }
|
||||||
|
fn from_formats(&self) -> Vec<&'static str> { self.from.clone() }
|
||||||
|
fn to_formats(&self) -> Vec<&'static str> { self.to.clone() }
|
||||||
|
fn familiarity(&self) -> u8 { self.f }
|
||||||
|
fn quality(&self) -> u8 { self.q }
|
||||||
|
fn speed(&self) -> u8 { self.s }
|
||||||
|
fn convert(&self, _input: &[u8], _from: &str, _to: &str) -> Result<Vec<u8>, String> { Ok(vec![]) }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_shortest_path() {
|
||||||
|
let plugins: Vec<Box<dyn Plugin>> = vec![
|
||||||
|
Box::new(MockPlugin { name: "A", from: vec!["jpeg"], to: vec!["png"], f: 10, q: 10, s: 10 }),
|
||||||
|
Box::new(MockPlugin { name: "B", from: vec!["jpeg"], to: vec!["bmp"], f: 10, q: 10, s: 10 }),
|
||||||
|
Box::new(MockPlugin { name: "C", from: vec!["bmp"], to: vec!["png"], f: 10, q: 10, s: 10 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
let path = find_best_path(&plugins, "jpeg", "png", "fqs").unwrap();
|
||||||
|
assert_eq!(path.len(), 1);
|
||||||
|
assert_eq!(path[0].0.name(), "A");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_priority_tie_break() {
|
||||||
|
let plugins: Vec<Box<dyn Plugin>> = vec![
|
||||||
|
Box::new(MockPlugin { name: "A", from: vec!["jpeg"], to: vec!["png"], f: 10, q: 50, s: 10 }),
|
||||||
|
Box::new(MockPlugin { name: "B", from: vec!["jpeg"], to: vec!["png"], f: 50, q: 10, s: 10 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
let path = find_best_path(&plugins, "jpeg", "png", "fqs").unwrap();
|
||||||
|
assert_eq!(path[0].0.name(), "B");
|
||||||
|
|
||||||
|
let path = find_best_path(&plugins, "jpeg", "png", "qfs").unwrap();
|
||||||
|
assert_eq!(path[0].0.name(), "A");
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! test_pathfinder {
|
||||||
|
($name:ident, $from:expr, $to:expr, $priority:expr, $expected_len:expr, $expected_first:expr) => {
|
||||||
|
#[test]
|
||||||
|
fn $name() {
|
||||||
|
let plugins: Vec<Box<dyn Plugin>> = vec![
|
||||||
|
Box::new(MockPlugin { name: "A", from: vec!["a"], to: vec!["b"], f: 10, q: 10, s: 10 }),
|
||||||
|
Box::new(MockPlugin { name: "B", from: vec!["b"], to: vec!["c"], f: 20, q: 10, s: 10 }),
|
||||||
|
Box::new(MockPlugin { name: "C", from: vec!["a"], to: vec!["c"], f: 5, q: 10, s: 10 }),
|
||||||
|
Box::new(MockPlugin { name: "D", from: vec!["a"], to: vec!["d"], f: 10, q: 20, s: 10 }),
|
||||||
|
Box::new(MockPlugin { name: "E", from: vec!["d"], to: vec!["c"], f: 10, q: 20, s: 10 }),
|
||||||
|
Box::new(MockPlugin { name: "F", from: vec!["a"], to: vec!["b"], f: 50, q: 5, s: 5 }), // High familiarity, low q/s
|
||||||
|
Box::new(MockPlugin { name: "G", from: vec!["c"], to: vec!["e"], f: 10, q: 10, s: 50 }),
|
||||||
|
];
|
||||||
|
|
||||||
|
let path = find_best_path(&plugins, $from, $to, $priority);
|
||||||
|
if $expected_len == 0 {
|
||||||
|
assert!(path.is_none());
|
||||||
|
} else {
|
||||||
|
let p = path.unwrap();
|
||||||
|
assert_eq!(p.len(), $expected_len);
|
||||||
|
assert_eq!(p[0].0.name(), $expected_first);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test_pathfinder!(test_path_1, "a", "b", "fqs", 1, "F"); // F has higher f
|
||||||
|
test_pathfinder!(test_path_2, "a", "b", "qfs", 1, "A"); // A has higher q
|
||||||
|
test_pathfinder!(test_path_3, "a", "c", "fqs", 1, "C"); // Shortest path is length 1 (C)
|
||||||
|
test_pathfinder!(test_path_4, "a", "d", "fqs", 1, "D");
|
||||||
|
test_pathfinder!(test_path_5, "d", "c", "fqs", 1, "E");
|
||||||
|
test_pathfinder!(test_path_6, "a", "e", "fqs", 2, "C"); // Shortest path to e goes through c. So a->c (C), c->e (G)
|
||||||
|
test_pathfinder!(test_path_7, "b", "e", "fqs", 2, "B");
|
||||||
|
test_pathfinder!(test_path_8, "e", "a", "fqs", 0, ""); // No path
|
||||||
|
test_pathfinder!(test_path_9, "c", "b", "fqs", 0, ""); // No path
|
||||||
|
test_pathfinder!(test_path_10, "x", "y", "fqs", 0, ""); // No path
|
||||||
|
|
||||||
|
macro_rules! test_pathfinder_2 {
|
||||||
|
($name:ident, $from:expr, $to:expr, $priority:expr, $expected_len:expr) => {
|
||||||
|
#[test]
|
||||||
|
fn $name() {
|
||||||
|
let plugins: Vec<Box<dyn Plugin>> = vec![
|
||||||
|
Box::new(MockPlugin { name: "1", from: vec!["1"], to: vec!["2"], f: 10, q: 10, s: 10 }),
|
||||||
|
Box::new(MockPlugin { name: "2", from: vec!["2"], to: vec!["3"], f: 10, q: 10, s: 10 }),
|
||||||
|
Box::new(MockPlugin { name: "3", from: vec!["3"], to: vec!["4"], f: 10, q: 10, s: 10 }),
|
||||||
|
Box::new(MockPlugin { name: "4", from: vec!["4"], to: vec!["5"], f: 10, q: 10, s: 10 }),
|
||||||
|
Box::new(MockPlugin { name: "5", from: vec!["5"], to: vec!["6"], f: 10, q: 10, s: 10 }),
|
||||||
|
];
|
||||||
|
let path = find_best_path(&plugins, $from, $to, $priority);
|
||||||
|
if $expected_len == 0 {
|
||||||
|
assert!(path.is_none());
|
||||||
|
} else {
|
||||||
|
assert_eq!(path.unwrap().len(), $expected_len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test_pathfinder_2!(test_p2_1, "1", "2", "f", 1);
|
||||||
|
test_pathfinder_2!(test_p2_2, "1", "3", "f", 2);
|
||||||
|
test_pathfinder_2!(test_p2_3, "1", "4", "f", 3);
|
||||||
|
test_pathfinder_2!(test_p2_4, "1", "5", "f", 4);
|
||||||
|
test_pathfinder_2!(test_p2_5, "1", "6", "f", 5);
|
||||||
|
test_pathfinder_2!(test_p2_6, "2", "6", "f", 4);
|
||||||
|
test_pathfinder_2!(test_p2_7, "3", "6", "f", 3);
|
||||||
|
test_pathfinder_2!(test_p2_8, "4", "6", "f", 2);
|
||||||
|
test_pathfinder_2!(test_p2_9, "5", "6", "f", 1);
|
||||||
|
test_pathfinder_2!(test_p2_10, "6", "1", "f", 0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
pub trait Plugin: Send + Sync {
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
fn from_formats(&self) -> Vec<&'static str>;
|
||||||
|
fn to_formats(&self) -> Vec<&'static str>;
|
||||||
|
|
||||||
|
/// Score from 1 to 255. Higher is better.
|
||||||
|
fn familiarity(&self) -> u8;
|
||||||
|
fn quality(&self) -> u8;
|
||||||
|
fn speed(&self) -> u8;
|
||||||
|
|
||||||
|
/// Converts the given input bytes from `from_format` to `to_format`.
|
||||||
|
fn convert(&self, input: &[u8], from_format: &str, to_format: &str) -> Result<Vec<u8>, String>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
use crate::plugin::Plugin;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
pub fn run_conversion(
|
||||||
|
path: &[(&dyn Plugin, &str, &str)],
|
||||||
|
input: &[u8],
|
||||||
|
verbosity: u8,
|
||||||
|
) -> Result<Vec<u8>, String> {
|
||||||
|
if verbosity >= 1 {
|
||||||
|
let path_str: Vec<_> = path.iter().map(|(p, _, _)| p.name()).collect();
|
||||||
|
eprintln!("Path taken: {}", path_str.join(" -> "));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut current_data = input.to_vec();
|
||||||
|
|
||||||
|
for (plugin, from_format, to_format) in path {
|
||||||
|
if verbosity >= 2 {
|
||||||
|
eprintln!("Converting {} to {} using plugin {}...", from_format, to_format, plugin.name());
|
||||||
|
}
|
||||||
|
|
||||||
|
if verbosity >= 4 {
|
||||||
|
eprintln!("[Plugin Log] Running plugin: {}", plugin.name());
|
||||||
|
eprintln!("[Plugin Log] Source format: {}", from_format);
|
||||||
|
eprintln!("[Plugin Log] Target format: {}", to_format);
|
||||||
|
eprintln!("[Plugin Log] Metrics - Familiarity: {}, Quality: {}, Speed: {}", plugin.familiarity(), plugin.quality(), plugin.speed());
|
||||||
|
}
|
||||||
|
|
||||||
|
let start_time = Instant::now();
|
||||||
|
current_data = plugin.convert(¤t_data, from_format, to_format)?;
|
||||||
|
let elapsed = start_time.elapsed();
|
||||||
|
|
||||||
|
if verbosity >= 3 {
|
||||||
|
eprintln!("Step '{} -> {}' completed in {:.2?}", from_format, to_format, elapsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(current_data)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
struct MockPlugin {
|
||||||
|
name: &'static str,
|
||||||
|
f: u8,
|
||||||
|
q: u8,
|
||||||
|
s: u8,
|
||||||
|
fail: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Plugin for MockPlugin {
|
||||||
|
fn name(&self) -> &'static str { self.name }
|
||||||
|
fn from_formats(&self) -> Vec<&'static str> { vec!["a"] }
|
||||||
|
fn to_formats(&self) -> Vec<&'static str> { vec!["b"] }
|
||||||
|
fn familiarity(&self) -> u8 { self.f }
|
||||||
|
fn quality(&self) -> u8 { self.q }
|
||||||
|
fn speed(&self) -> u8 { self.s }
|
||||||
|
fn convert(&self, input: &[u8], _from: &str, _to: &str) -> Result<Vec<u8>, String> {
|
||||||
|
if self.fail {
|
||||||
|
return Err("Failed".to_string());
|
||||||
|
}
|
||||||
|
let mut out = input.to_vec();
|
||||||
|
out.push(1);
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
macro_rules! test_runner {
|
||||||
|
($name:ident, $verbosity:expr, $fail:expr, $expected_res:expr, $expected_len:expr) => {
|
||||||
|
#[test]
|
||||||
|
fn $name() {
|
||||||
|
let plugin = MockPlugin { name: "mock", f: 10, q: 10, s: 10, fail: $fail };
|
||||||
|
let path: Vec<(&dyn Plugin, &str, &str)> = vec![(&plugin, "a", "b")];
|
||||||
|
let input = vec![0];
|
||||||
|
|
||||||
|
let result = run_conversion(&path, &input, $verbosity);
|
||||||
|
assert_eq!(result.is_ok(), $expected_res);
|
||||||
|
if let Ok(res) = result {
|
||||||
|
assert_eq!(res.len(), $expected_len);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test_runner!(test_runner_verb_0, 0, false, true, 2);
|
||||||
|
test_runner!(test_runner_verb_1, 1, false, true, 2);
|
||||||
|
test_runner!(test_runner_verb_2, 2, false, true, 2);
|
||||||
|
test_runner!(test_runner_verb_3, 3, false, true, 2);
|
||||||
|
test_runner!(test_runner_verb_4, 4, false, true, 2);
|
||||||
|
test_runner!(test_runner_verb_5, 5, false, true, 2);
|
||||||
|
test_runner!(test_runner_verb_0_fail, 0, true, false, 0);
|
||||||
|
test_runner!(test_runner_verb_1_fail, 1, true, false, 0);
|
||||||
|
test_runner!(test_runner_verb_4_fail, 4, true, false, 0);
|
||||||
|
test_runner!(test_runner_verb_255, 255, false, true, 2);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user