// Copyright (C) 2026 Elias Wendland // // 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 . 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 } } #[tracing::instrument(skip(plugins))] pub fn find_best_path<'a>( plugins: &'a [Box], from_format: &'a str, to_format: &str, priority: &str, banned_plugins: &[&str], ) -> Option> { 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::>>)); 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); 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::>()); } 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, 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); }