From e030513d574b0efbe0f1340555cddfb4dcb5f3c7 Mon Sep 17 00:00:00 2001 From: Elias Wendland <193786789+eliaswen@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:50:24 +0200 Subject: [PATCH] Initial commit --- .github/workflows/ci.yml | 38 +++++ .github/workflows/release.yml | 119 ++++++++++++++++ .gitignore | 4 + Cargo.toml | 26 ++++ build.rs | 91 ++++++++++++ plugins/jpeg_to_png.rs | 23 ++++ plugins/png_to_jpeg.rs | 23 ++++ src/args.rs | 69 ++++++++++ src/identifier.rs | 47 +++++++ src/identifier_tests.rs | 0 src/main.rs | 126 +++++++++++++++++ src/pathfinder.rs | 253 ++++++++++++++++++++++++++++++++++ src/plugin.rs | 13 ++ src/runner.rs | 96 +++++++++++++ 14 files changed, 928 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 build.rs create mode 100644 plugins/jpeg_to_png.rs create mode 100644 plugins/png_to_jpeg.rs create mode 100644 src/args.rs create mode 100644 src/identifier.rs create mode 100644 src/identifier_tests.rs create mode 100644 src/main.rs create mode 100644 src/pathfinder.rs create mode 100644 src/plugin.rs create mode 100644 src/runner.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..60623e8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..3467d2c --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3afd140 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +AGENT.md +ARCHITECTURE.md +Cargo.lock \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..a826f9b --- /dev/null +++ b/Cargo.toml @@ -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 " +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" diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..e1a02fb --- /dev/null +++ b/build.rs @@ -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 { + 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> {{ + let mut registry: Vec> = 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(); +} diff --git a/plugins/jpeg_to_png.rs b/plugins/jpeg_to_png.rs new file mode 100644 index 0000000..4a7552b --- /dev/null +++ b/plugins/jpeg_to_png.rs @@ -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, 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) + } +} diff --git a/plugins/png_to_jpeg.rs b/plugins/png_to_jpeg.rs new file mode 100644 index 0000000..2593d2a --- /dev/null +++ b/plugins/png_to_jpeg.rs @@ -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, 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) + } +} diff --git a/src/args.rs b/src/args.rs new file mode 100644 index 0000000..3149696 --- /dev/null +++ b/src/args.rs @@ -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, +} + +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); +} diff --git a/src/identifier.rs b/src/identifier.rs new file mode 100644 index 0000000..dd0dc9f --- /dev/null +++ b/src/identifier.rs @@ -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); +} diff --git a/src/identifier_tests.rs b/src/identifier_tests.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..433b603 --- /dev/null +++ b/src/main.rs @@ -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); + } + } +} diff --git a/src/pathfinder.rs b/src/pathfinder.rs new file mode 100644 index 0000000..b69662e --- /dev/null +++ b/src/pathfinder.rs @@ -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>>, +} + +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], + from_format: &'a str, + to_format: &str, + priority: &str, +) -> Option> { + 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::>>)); + + let mut min_length = None; + let mut best_paths: Vec> = 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, String> { Ok(vec![]) } + } + + #[test] + fn test_shortest_path() { + let plugins: Vec> = 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> = 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> = 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> = 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); +} diff --git a/src/plugin.rs b/src/plugin.rs new file mode 100644 index 0000000..193526d --- /dev/null +++ b/src/plugin.rs @@ -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, String>; +} diff --git a/src/runner.rs b/src/runner.rs new file mode 100644 index 0000000..3aa503e --- /dev/null +++ b/src/runner.rs @@ -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, 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, 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); +}