Files
convertis/src/pathfinder.rs
T
Elias Wendland b65d5eb97c
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
refactor: improve logging
2026-07-16 14:43:58 +02:00

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);
}