Release / check-release (push) Successful in 15s
Release / build_gnu (push) Skipped
Release / build_musl (push) Skipped
Release / build_windows (push) Skipped
Release / package_gnu (deb) (push) Skipped
Release / package_gnu (rpm) (push) Skipped
Release / package_musl (deb) (push) Skipped
Release / package_musl (rpm) (push) Skipped
Release / publish-release (push) Skipped
CI / Test (push) Successful in 1m43s
CI / Build Linux (push) Successful in 1m56s
452 lines
15 KiB
Rust
452 lines
15 KiB
Rust
// Copyright (C) 2026 Elias Wendland <eliaswendland@pm.me>
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU General Public License as published by
|
|
// the Free Software Foundation, version 3 exclusively.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
#[tracing::instrument(skip(plugins))]
|
|
pub fn find_best_path<'a>(
|
|
plugins: &'a [Box<dyn Plugin>],
|
|
from_format: &'a str,
|
|
to_format: &str,
|
|
priority: &str,
|
|
banned_plugins: &[&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 {
|
|
if banned_plugins.contains(&p.name()) {
|
|
tracing::trace!("Skipping banned plugin: {}", p.name());
|
|
continue;
|
|
}
|
|
if !p.is_available() {
|
|
tracing::trace!("Skipping unavailable plugin: {}", p.name());
|
|
continue;
|
|
}
|
|
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);
|
|
tracing::trace!("Visiting format node: {} at depth {}", curr_format, current_depth);
|
|
|
|
if let Some(min_len) = min_length {
|
|
if current_depth > min_len {
|
|
tracing::trace!("Pruning path exploration at depth {} (min_len={})", 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() {
|
|
tracing::debug!("No valid paths found from {} to {}.", from_format, to_format);
|
|
return None;
|
|
}
|
|
|
|
tracing::debug!("Found {} path(s) of minimum length {}. Evaluating priority: '{}'", best_paths.len(), min_length.unwrap_or(0), priority);
|
|
|
|
// Evaluate based on priority (maximize score)
|
|
let best = best_paths
|
|
.into_iter()
|
|
.max_by(|path_a, path_b| compare_paths(path_a, path_b, priority));
|
|
|
|
if let Some(ref path) = best {
|
|
tracing::debug!("Selected path with length {}: {:?}", path.len(), path.iter().map(|p| p.0.name()).collect::<Vec<_>>());
|
|
}
|
|
best
|
|
}
|
|
|
|
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, from, to)| p.familiarity(*from, *to) as u32)
|
|
.sum();
|
|
let score_b: u32 = path_b
|
|
.iter()
|
|
.map(|(p, from, to)| p.familiarity(*from, *to) 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, from, to)| p.quality(*from, *to) as u32)
|
|
.sum();
|
|
let score_b: u32 = path_b
|
|
.iter()
|
|
.map(|(p, from, to)| p.quality(*from, *to) 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, from, to)| p.speed(*from, *to) as u32)
|
|
.sum();
|
|
let score_b: u32 = path_b
|
|
.iter()
|
|
.map(|(p, from, to)| p.speed(*from, *to) 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, _from: &str, _to: &str) -> u8 {
|
|
self.f
|
|
}
|
|
fn quality(&self, _from: &str, _to: &str) -> u8 {
|
|
self.q
|
|
}
|
|
fn speed(&self, _from: &str, _to: &str) -> u8 {
|
|
self.s
|
|
}
|
|
fn convert(
|
|
&self,
|
|
_input: &[u8],
|
|
_from: &str,
|
|
_to: &str,
|
|
_temp_dir: &std::path::Path,
|
|
) -> 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);
|
|
}
|