feat: implement protocol v2 for plugin system and add comprehensive tracing support
Release / check-release (push) Successful in 16s
Release / build (push) Skipped
Release / package (deb) (push) Skipped
Release / package (rpm) (push) Skipped
Release / publish (push) Skipped
CI / test (push) Successful in 1m58s

This commit is contained in:
Elias Wendland committed 2026-07-17 17:46:14 +02:00
1 parent 590c616d6f
commit f07f54d4c1
12 files changed
+983 -143

No files matched your search

Generated
+19 -11
View File
@@ -313,7 +313,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]] [[package]]
name = "convertis" name = "convertis"
version = "0.3.0" version = "0.3.0-dev"
dependencies = [ dependencies = [
"clap", "clap",
"clap_mangen", "clap_mangen",
@@ -321,6 +321,7 @@ dependencies = [
"gif", "gif",
"infer", "infer",
"libloading", "libloading",
"semver",
"serde", "serde",
"serde_json", "serde_json",
"tempfile", "tempfile",
@@ -330,14 +331,14 @@ dependencies = [
[[package]] [[package]]
name = "convertis-ffmpeg-audio" name = "convertis-ffmpeg-audio"
version = "0.3.0" version = "0.3.0-dev"
dependencies = [ dependencies = [
"convertis-plugin-api", "convertis-plugin-api",
] ]
[[package]] [[package]]
name = "convertis-ffmpeg-frames-to-video" name = "convertis-ffmpeg-frames-to-video"
version = "0.3.0" version = "0.3.0-dev"
dependencies = [ dependencies = [
"convertis-plugin-api", "convertis-plugin-api",
"serde", "serde",
@@ -346,14 +347,14 @@ dependencies = [
[[package]] [[package]]
name = "convertis-ffmpeg-video" name = "convertis-ffmpeg-video"
version = "0.3.0" version = "0.3.0-dev"
dependencies = [ dependencies = [
"convertis-plugin-api", "convertis-plugin-api",
] ]
[[package]] [[package]]
name = "convertis-ffmpeg-video-to-frames" name = "convertis-ffmpeg-video-to-frames"
version = "0.3.0" version = "0.3.0-dev"
dependencies = [ dependencies = [
"convertis-plugin-api", "convertis-plugin-api",
"serde", "serde",
@@ -362,14 +363,14 @@ dependencies = [
[[package]] [[package]]
name = "convertis-graphicsmagick" name = "convertis-graphicsmagick"
version = "0.3.0" version = "0.3.0-dev"
dependencies = [ dependencies = [
"convertis-plugin-api", "convertis-plugin-api",
] ]
[[package]] [[package]]
name = "convertis-html" name = "convertis-html"
version = "0.3.0" version = "0.3.0-dev"
dependencies = [ dependencies = [
"base64", "base64",
"convertis-plugin-api", "convertis-plugin-api",
@@ -378,7 +379,7 @@ dependencies = [
[[package]] [[package]]
name = "convertis-image-ascii" name = "convertis-image-ascii"
version = "0.3.0" version = "0.3.0-dev"
dependencies = [ dependencies = [
"convertis-plugin-api", "convertis-plugin-api",
"image", "image",
@@ -386,14 +387,14 @@ dependencies = [
[[package]] [[package]]
name = "convertis-imagemagick" name = "convertis-imagemagick"
version = "0.3.0" version = "0.3.0-dev"
dependencies = [ dependencies = [
"convertis-plugin-api", "convertis-plugin-api",
] ]
[[package]] [[package]]
name = "convertis-native-image" name = "convertis-native-image"
version = "0.3.0" version = "0.3.0-dev"
dependencies = [ dependencies = [
"convertis-plugin-api", "convertis-plugin-api",
"image", "image",
@@ -401,9 +402,10 @@ dependencies = [
[[package]] [[package]]
name = "convertis-plugin-api" name = "convertis-plugin-api"
version = "0.3.0" version = "0.3.0-dev"
dependencies = [ dependencies = [
"serde", "serde",
"serde_json",
] ]
[[package]] [[package]]
@@ -1135,6 +1137,12 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.228"
+2 -1
View File
@@ -22,7 +22,7 @@ license = "GPL-3.0-only"
repository = "https://git.ewenlau.net/ewenlau/convertis" repository = "https://git.ewenlau.net/ewenlau/convertis"
[workspace.dependencies] [workspace.dependencies]
convertis-plugin-api = { path = "crates/convertis-plugin-api", version = "=0.3.0" } convertis-plugin-api = { path = "crates/convertis-plugin-api", version = "=0.3.0-dev" }
base64 = "0.22" base64 = "0.22"
image = "0.25.10" image = "0.25.10"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
@@ -55,6 +55,7 @@ gif = "0.14"
libloading = "0.8" libloading = "0.8"
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
semver = "1"
tempfile.workspace = true tempfile.workspace = true
tracing = "0.1.44" tracing = "0.1.44"
tracing-subscriber = "0.3.23" tracing-subscriber = "0.3.23"
+22 -2
View File
@@ -86,7 +86,9 @@ When no installed route can perform a conversion, Convertis prints the individua
The release page contains a raw `x86_64-unknown-linux-gnu` executable and one ZIP containing all official `.so` plugins. Extract its `plugins/` directory beside the executable. Plugins can also be placed directly beside the executable, in `~/.local/lib/convertis/plugins`, or in `/usr/lib/convertis/plugins`. The release page contains a raw `x86_64-unknown-linux-gnu` executable and one ZIP containing all official `.so` plugins. Extract its `plugins/` directory beside the executable. Plugins can also be placed directly beside the executable, in `~/.local/lib/convertis/plugins`, or in `/usr/lib/convertis/plugins`.
Plugins use an exact-version Rust ABI. A plugin must have been built for the same Convertis release; incompatible libraries are rejected before loading. Plugins use a version-negotiated wire protocol. A plugin advertises the protocol versions it speaks and an open-ended engine requirement (for example `>=0.3.0-dev`); the engine selects the newest protocol adapter both sides support. Engine releases are therefore decoupled from plugin releases, and a protocol-v2 plugin built today remains usable by future engines that retain the v2 adapter.
Plugins built for the original protocol v1 are still recognized by a permanent legacy adapter. Because v1 passed Rust trait objects across the dynamic-library boundary, those old binaries must also match the engine's Rust compiler and target. Protocol v2 uses JSON over a C ABI and has no Rust compiler-version coupling.
## Usage ## Usage
@@ -109,6 +111,14 @@ convertis --list-formats
convertis --help convertis --help
``` ```
Logging is controlled with `--verbose LEVEL` (or `-v LEVEL`). `trace` records plugin-directory discovery, manifest parsing and protocol negotiation, every format-identification rule, graph construction and route-search decision, option resolution, each plugin request, and filesystem staging/install operations:
```sh
convertis -v trace input.png output.webp
```
Logs go to standard error, so converted bytes written to standard output remain clean. `--quiet` disables logging.
Use `--no-default-plugins` with explicit `--plugin-dir` arguments to run in an isolated plugin environment. Use `--no-default-plugins` with explicit `--plugin-dir` arguments to run in an isolated plugin environment.
Plugin settings use repeatable `--option key=value` arguments. A plugin-qualified key such as `ffmpeg-frames-to-video.fps=24` can disambiguate settings in a multi-plugin route. Plugin settings use repeatable `--option key=value` arguments. A plugin-qualified key such as `ffmpeg-frames-to-video.fps=24` can disambiguate settings in a multi-plugin route.
@@ -125,7 +135,17 @@ The engine is `target/release/convertis`; plugins are `target/release/libconvert
## Plugin API ## Plugin API
The workspace crate `convertis-plugin-api` defines the exact-version Rust trait used by official plugins. Plugin metadata is checked through a small C-compatible entry point before the Rust factory is called. The official plugin crates are the reference examples; ABI compatibility across Convertis releases is not promised. The workspace crate `convertis-plugin-api` defines the plugin authoring trait and exports it through stable protocol v2. Its unversioned manifest entry point advertises:
- the manifest schema version;
- every wire-protocol version implemented by the plugin;
- the plugin ID and release version;
- an open-ended semantic-version requirement for the engine;
- the target platform.
Metadata, availability checks, and conversion requests cross the library boundary as owned JSON messages through versioned C entry points. Returned strings are released by the plugin's matching deallocator, and plugin panics are converted to protocol errors instead of unwinding across the ABI boundary.
Protocol versions are compatibility contracts, not Convertis release numbers. Existing adapters are retained by future engines; incompatible protocol evolution is introduced under a new version and selected through manifest negotiation. New optional JSON fields may be added without requiring a protocol bump. The official plugin crates are the reference implementations through the `export_plugin!` macro.
## Platforms ## Platforms
+1
View File
@@ -8,3 +8,4 @@ repository.workspace = true
[dependencies] [dependencies]
serde.workspace = true serde.workspace = true
serde_json.workspace = true
+153 -11
View File
@@ -13,12 +13,25 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{collections::BTreeMap, path::PathBuf}; use std::{
collections::BTreeMap,
ffi::{CStr, CString, c_char},
path::PathBuf,
};
pub const API_VERSION: u32 = 1; /// The newest stable wire protocol implemented by this SDK.
pub const API_VERSION: u32 = 2;
pub const ENGINE_VERSION: &str = "0.3.0"; pub const ENGINE_VERSION: &str = "0.3.0";
pub const MANIFEST_SYMBOL: &[u8] = b"convertis_plugin_manifest_v1\0"; pub const MANIFEST_SYMBOL: &[u8] = b"convertis_plugin_manifest\0";
pub const FACTORY_SYMBOL: &[u8] = b"convertis_plugin_create_v1\0"; pub const METADATA_SYMBOL_V2: &[u8] = b"convertis_plugin_metadata_v2\0";
pub const AVAILABILITY_SYMBOL_V2: &[u8] = b"convertis_plugin_availability_v2\0";
pub const CONVERT_SYMBOL_V2: &[u8] = b"convertis_plugin_convert_v2\0";
pub const FREE_SYMBOL_V2: &[u8] = b"convertis_plugin_free_string_v2\0";
// Protocol v1 crossed the Rust ABI boundary. These names are kept forever so
// engines can retain an adapter for plugins built before the stable protocol.
pub const LEGACY_MANIFEST_SYMBOL_V1: &[u8] = b"convertis_plugin_manifest_v1\0";
pub const LEGACY_FACTORY_SYMBOL_V1: &[u8] = b"convertis_plugin_create_v1\0";
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] #[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum ArtifactKind { pub enum ArtifactKind {
@@ -79,7 +92,7 @@ pub struct PluginMetadata {
pub options: Vec<OptionSpec>, pub options: Vec<OptionSpec>,
} }
#[derive(Clone, Debug)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ConversionRequest { pub struct ConversionRequest {
pub input: PathBuf, pub input: PathBuf,
pub output: PathBuf, pub output: PathBuf,
@@ -98,19 +111,77 @@ pub trait Plugin: Send + Sync {
pub type PluginFactory = unsafe fn() -> Box<dyn Plugin>; pub type PluginFactory = unsafe fn() -> Box<dyn Plugin>;
pub type PluginManifest = unsafe extern "C" fn() -> *const std::ffi::c_char; pub type PluginManifest = unsafe extern "C" fn() -> *const std::ffi::c_char;
pub type PluginJsonCall = unsafe extern "C" fn() -> *mut c_char;
pub type PluginConvertCall = unsafe extern "C" fn(*const c_char) -> *mut c_char;
pub type PluginStringFree = unsafe extern "C" fn(*mut c_char);
#[derive(Debug, Serialize, Deserialize)]
pub struct WireResponse<T> {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<T>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl<T> WireResponse<T> {
pub fn success(result: T) -> Self {
Self {
ok: true,
result: Some(result),
error: None,
}
}
pub fn failure(error: impl Into<String>) -> Self {
Self {
ok: false,
result: None,
error: Some(error.into()),
}
}
}
#[doc(hidden)]
pub fn wire_json<T: Serialize>(response: WireResponse<T>) -> *mut c_char {
let json = serde_json::to_string(&response).unwrap_or_else(|_| {
"{\"ok\":false,\"error\":\"plugin response serialization failed\"}".to_owned()
});
CString::new(json)
.expect("JSON serialization cannot contain a NUL byte")
.into_raw()
}
#[doc(hidden)]
pub unsafe fn request_from_json(pointer: *const c_char) -> Result<ConversionRequest, String> {
if pointer.is_null() {
return Err("conversion request was null".to_owned());
}
let json = unsafe { CStr::from_ptr(pointer) }
.to_str()
.map_err(|error| format!("conversion request was not UTF-8: {error}"))?;
serde_json::from_str(json).map_err(|error| format!("invalid conversion request: {error}"))
}
#[doc(hidden)]
pub unsafe fn free_wire_string(pointer: *mut c_char) {
if !pointer.is_null() {
drop(unsafe { CString::from_raw(pointer) });
}
}
#[macro_export] #[macro_export]
macro_rules! export_plugin { macro_rules! export_plugin {
($plugin:expr, $id:literal) => { ($plugin:expr, $id:literal) => {
#[unsafe(no_mangle)] #[unsafe(no_mangle)]
pub extern "C" fn convertis_plugin_manifest_v1() -> *const std::ffi::c_char { pub extern "C" fn convertis_plugin_manifest() -> *const std::ffi::c_char {
static MANIFEST: &str = concat!( static MANIFEST: &str = concat!(
"{\"api_version\":1,\"engine_version\":\"", "{\"manifest_version\":1,\"protocol_versions\":[2],\"engine_requirement\":\">=",
env!("CARGO_PKG_VERSION"), env!("CARGO_PKG_VERSION"),
"\",\"plugin_id\":\"", "\",\"plugin_id\":\"",
$id, $id,
"\",\"rustc_version\":\"", "\",\"plugin_version\":\"",
env!("CONVERTIS_RUSTC_VERSION"), env!("CARGO_PKG_VERSION"),
"\",\"target\":\"", "\",\"target\":\"",
env!("CONVERTIS_TARGET"), env!("CONVERTIS_TARGET"),
"\"}\0" "\"}\0"
@@ -119,8 +190,79 @@ macro_rules! export_plugin {
} }
#[unsafe(no_mangle)] #[unsafe(no_mangle)]
pub fn convertis_plugin_create_v1() -> Box<dyn $crate::Plugin> { pub extern "C" fn convertis_plugin_metadata_v2() -> *mut std::ffi::c_char {
Box::new($plugin) let response = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
$crate::Plugin::metadata(&$plugin)
}))
.map($crate::WireResponse::success)
.unwrap_or_else(|_| {
$crate::WireResponse::failure("plugin panicked while returning metadata")
});
$crate::wire_json(response)
}
#[unsafe(no_mangle)]
pub extern "C" fn convertis_plugin_availability_v2() -> *mut std::ffi::c_char {
let response = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
$crate::Plugin::availability(&$plugin)
}));
let response = match response {
Ok(Ok(())) => $crate::WireResponse::success(()),
Ok(Err(error)) => $crate::WireResponse::failure(error),
Err(_) => {
$crate::WireResponse::failure("plugin panicked while checking availability")
}
};
$crate::wire_json(response)
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn convertis_plugin_convert_v2(
request: *const std::ffi::c_char,
) -> *mut std::ffi::c_char {
let response = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| unsafe {
$crate::request_from_json(request)
.and_then(|request| $crate::Plugin::convert(&$plugin, &request))
}));
let response = match response {
Ok(Ok(())) => $crate::WireResponse::success(()),
Ok(Err(error)) => $crate::WireResponse::failure(error),
Err(_) => $crate::WireResponse::failure("plugin panicked during conversion"),
};
$crate::wire_json(response)
}
#[unsafe(no_mangle)]
pub unsafe extern "C" fn convertis_plugin_free_string_v2(pointer: *mut std::ffi::c_char) {
unsafe { $crate::free_wire_string(pointer) }
} }
}; };
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn conversion_requests_round_trip_through_json() {
let request = ConversionRequest {
input: "/tmp/input.png".into(),
output: "/tmp/output.jpg".into(),
from: "png".into(),
to: "jpeg".into(),
options: BTreeMap::from([("quality".into(), "90".into())]),
};
let json = CString::new(serde_json::to_string(&request).unwrap()).unwrap();
let decoded = unsafe { request_from_json(json.as_ptr()) }.unwrap();
assert_eq!(decoded.input, request.input);
assert_eq!(decoded.options, request.options);
}
#[test]
fn unit_success_is_explicit_even_though_json_result_is_null() {
let json = serde_json::to_string(&WireResponse::success(())).unwrap();
let decoded: WireResponse<()> = serde_json::from_str(&json).unwrap();
assert!(decoded.ok);
assert!(decoded.error.is_none());
}
}
+2 -2
View File
@@ -21,14 +21,14 @@ pub struct Args {
#[arg( #[arg(
long, long,
help = "List formats supported by installed plugins.", help = "List formats supported by installed plugins.",
exclusive = true conflicts_with = "list_plugins"
)] )]
pub list_formats: bool, pub list_formats: bool,
#[arg( #[arg(
long, long,
help = "List installed and known official plugins.", help = "List installed and known official plugins.",
exclusive = true conflicts_with = "list_formats"
)] )]
pub list_plugins: bool, pub list_plugins: bool,
+43
View File
@@ -56,6 +56,11 @@ const MAGICK_IMAGES: &[&str] = &[
]; ];
fn all_pairs(formats: &[&str], scores: (u8, u8, u8)) -> Vec<Conversion> { fn all_pairs(formats: &[&str], scores: (u8, u8, u8)) -> Vec<Conversion> {
tracing::trace!(
format_count = formats.len(),
?scores,
"building all-pairs conversion catalog"
);
formats formats
.iter() .iter()
.flat_map(|from| { .flat_map(|from| {
@@ -68,6 +73,12 @@ fn all_pairs(formats: &[&str], scores: (u8, u8, u8)) -> Vec<Conversion> {
} }
fn cross_pairs(inputs: &[&str], outputs: &[&str], scores: (u8, u8, u8)) -> Vec<Conversion> { fn cross_pairs(inputs: &[&str], outputs: &[&str], scores: (u8, u8, u8)) -> Vec<Conversion> {
tracing::trace!(
input_count = inputs.len(),
output_count = outputs.len(),
?scores,
"building cross-pair conversion catalog"
);
inputs inputs
.iter() .iter()
.flat_map(|from| { .flat_map(|from| {
@@ -85,6 +96,12 @@ fn metadata(
description: &str, description: &str,
conversions: Vec<Conversion>, conversions: Vec<Conversion>,
) -> PluginMetadata { ) -> PluginMetadata {
tracing::trace!(
id,
package,
conversion_count = conversions.len(),
"building official plugin metadata"
);
PluginMetadata { PluginMetadata {
id: id.to_owned(), id: id.to_owned(),
package: package.to_owned(), package: package.to_owned(),
@@ -95,6 +112,7 @@ fn metadata(
} }
pub fn official_plugins() -> Vec<PluginMetadata> { pub fn official_plugins() -> Vec<PluginMetadata> {
tracing::trace!("building official plugin catalog");
let mut plugins = vec![ let mut plugins = vec![
metadata( metadata(
"ffmpeg-audio", "ffmpeg-audio",
@@ -195,13 +213,23 @@ pub fn official_plugins() -> Vec<PluginMetadata> {
help: "Override frame rate; folders without metadata default to 30".into(), help: "Override frame rate; folders without metadata default to 30".into(),
default: None, default: None,
}]; }];
tracing::trace!(
plugin_count = plugins.len(),
"official plugin catalog built"
);
plugins plugins
} }
pub fn recommend_packages(from: &str, to: &str) -> Vec<String> { pub fn recommend_packages(from: &str, to: &str) -> Vec<String> {
tracing::debug!(
from,
to,
"searching official catalog for package recommendation"
);
let plugins = official_plugins(); let plugins = official_plugins();
let mut edges: HashMap<&str, Vec<(usize, &str)>> = HashMap::new(); let mut edges: HashMap<&str, Vec<(usize, &str)>> = HashMap::new();
for (index, plugin) in plugins.iter().enumerate() { for (index, plugin) in plugins.iter().enumerate() {
tracing::trace!(plugin = %plugin.id, conversion_count = plugin.conversions.len(), "adding official plugin to recommendation graph");
for conversion in &plugin.conversions { for conversion in &plugin.conversions {
edges edges
.entry(&conversion.from) .entry(&conversion.from)
@@ -212,26 +240,41 @@ pub fn recommend_packages(from: &str, to: &str) -> Vec<String> {
let mut queue = VecDeque::from([(from, Vec::<usize>::new())]); let mut queue = VecDeque::from([(from, Vec::<usize>::new())]);
let mut visited = HashSet::from([from]); let mut visited = HashSet::from([from]);
while let Some((current, path)) = queue.pop_front() { while let Some((current, path)) = queue.pop_front() {
tracing::trace!(
current,
depth = path.len(),
queue_length = queue.len(),
"visiting package recommendation state"
);
if current == to { if current == to {
let mut packages = Vec::new(); let mut packages = Vec::new();
for index in path { for index in path {
let package = plugins[index].package.clone(); let package = plugins[index].package.clone();
if !packages.contains(&package) { if !packages.contains(&package) {
tracing::trace!(package, "adding package to recommendation");
packages.push(package); packages.push(package);
} }
} }
tracing::debug!(?packages, "found package recommendation");
return packages; return packages;
} }
if let Some(next_edges) = edges.get(current) { if let Some(next_edges) = edges.get(current) {
for &(plugin, next) in next_edges { for &(plugin, next) in next_edges {
tracing::trace!(current, next, plugin = %plugins[plugin].id, "considering recommendation graph edge");
if visited.insert(next) { if visited.insert(next) {
let mut next_path = path.clone(); let mut next_path = path.clone();
next_path.push(plugin); next_path.push(plugin);
queue.push_back((next, next_path)); queue.push_back((next, next_path));
tracing::trace!(
next,
queue_length = queue.len(),
"queued package recommendation state"
);
} }
} }
} }
} }
tracing::debug!(from, to, "official catalog has no package recommendation");
Vec::new() Vec::new()
} }
+114 -21
View File
@@ -24,55 +24,78 @@ pub struct DetectedFormat {
} }
fn normalized(format: &str) -> String { fn normalized(format: &str) -> String {
match format.to_ascii_lowercase().as_str() { let normalized = match format.to_ascii_lowercase().as_str() {
"jpg" => "jpeg".to_owned(), "jpg" => "jpeg".to_owned(),
"htm" => "html".to_owned(), "htm" => "html".to_owned(),
"txt" | "ascii" => "text".to_owned(), "txt" | "ascii" => "text".to_owned(),
other => other.to_owned(), other => other.to_owned(),
} };
tracing::trace!(
input = format,
output = normalized,
"normalized format name"
);
normalized
} }
pub fn requested_format(path: Option<&Path>, explicit: Option<&str>) -> Option<String> { pub fn requested_format(path: Option<&Path>, explicit: Option<&str>) -> Option<String> {
explicit.map(normalized).or_else(|| { tracing::trace!(path = ?path, explicit, "resolving requested format");
let requested = explicit.map(normalized).or_else(|| {
path.and_then(Path::extension) path.and_then(Path::extension)
.and_then(|value| value.to_str()) .and_then(|value| value.to_str())
.map(normalized) .map(normalized)
}) });
tracing::trace!(?requested, "resolved requested format");
requested
} }
pub fn identify_path(path: &Path, explicit: Option<&str>) -> Result<DetectedFormat, String> { pub fn identify_path(path: &Path, explicit: Option<&str>) -> Result<DetectedFormat, String> {
tracing::debug!(path = %path.display(), explicit, is_directory = path.is_dir(), "starting path identification");
if path.is_dir() { if path.is_dir() {
tracing::trace!(path = %path.display(), "identifying directory artifact");
if explicit.is_some_and(|format| normalized(format) != "frames") { if explicit.is_some_and(|format| normalized(format) != "frames") {
return Err("directory inputs currently support only the 'frames' format".to_owned()); return Err("directory inputs currently support only the 'frames' format".to_owned());
} }
let manifest = path.join(".convertis-frames.json"); let manifest = path.join(".convertis-frames.json");
tracing::trace!(manifest = %manifest.display(), exists = manifest.exists(), "checking frame manifest");
if manifest.exists() { if manifest.exists() {
let value: serde_json::Value = serde_json::from_slice( tracing::trace!(manifest = %manifest.display(), "reading frame manifest");
&fs::read(&manifest) let bytes = fs::read(&manifest)
.map_err(|error| format!("could not read {}: {error}", manifest.display()))?, .map_err(|error| format!("could not read {}: {error}", manifest.display()))?;
) tracing::trace!(manifest = %manifest.display(), byte_count = bytes.len(), "parsing frame manifest");
.map_err(|error| format!("invalid frame manifest: {error}"))?; let value: serde_json::Value = serde_json::from_slice(&bytes)
if value .map_err(|error| format!("invalid frame manifest: {error}"))?;
let schema_version = value
.get("schema_version") .get("schema_version")
.and_then(serde_json::Value::as_u64) .and_then(serde_json::Value::as_u64);
!= Some(1) tracing::trace!(?schema_version, "read frame manifest schema version");
{ if schema_version != Some(1) {
return Err("unsupported frame manifest schema".to_owned()); return Err("unsupported frame manifest schema".to_owned());
} }
} else { } else {
tracing::trace!(path = %path.display(), "scanning directory for recognizable frames");
let mut detected = None; let mut detected = None;
for entry in fs::read_dir(path) for entry in fs::read_dir(path).map_err(|error| error.to_string())? {
.map_err(|error| error.to_string())? let entry = match entry {
.flatten() Ok(entry) => entry,
{ Err(error) => {
tracing::trace!(%error, "could not inspect frame-directory entry");
continue;
}
};
tracing::trace!(entry = %entry.path().display(), "inspecting frame-directory entry");
if !entry.path().is_file() { if !entry.path().is_file() {
tracing::trace!(entry = %entry.path().display(), "skipping non-file frame-directory entry");
continue; continue;
} }
let bytes = fs::read(entry.path()).map_err(|error| error.to_string())?; let bytes = fs::read(entry.path()).map_err(|error| error.to_string())?;
tracing::trace!(entry = %entry.path().display(), byte_count = bytes.len(), "read potential frame file");
let Some(format) = identify_bytes(&bytes) else { let Some(format) = identify_bytes(&bytes) else {
tracing::trace!(entry = %entry.path().display(), "frame candidate was not recognizable");
continue; continue;
}; };
if format.media_kind != MediaKind::Image { if format.media_kind != MediaKind::Image {
tracing::trace!(entry = %entry.path().display(), media_kind = ?format.media_kind, "frame candidate was not a still image");
continue; continue;
} }
if detected if detected
@@ -83,6 +106,7 @@ pub fn identify_path(path: &Path, explicit: Option<&str>) -> Result<DetectedForm
"frame directories without metadata must use one image format".to_owned(), "frame directories without metadata must use one image format".to_owned(),
); );
} }
tracing::trace!(entry = %entry.path().display(), format = %format.format, "accepted frame candidate");
detected = Some(format.format); detected = Some(format.format);
} }
if detected.is_none() { if detected.is_none() {
@@ -91,6 +115,7 @@ pub fn identify_path(path: &Path, explicit: Option<&str>) -> Result<DetectedForm
); );
} }
} }
tracing::debug!(path = %path.display(), "identified frames directory");
return Ok(DetectedFormat { return Ok(DetectedFormat {
format: "frames".to_owned(), format: "frames".to_owned(),
mime: "application/vnd.convertis.frames+json".to_owned(), mime: "application/vnd.convertis.frames+json".to_owned(),
@@ -98,51 +123,109 @@ pub fn identify_path(path: &Path, explicit: Option<&str>) -> Result<DetectedForm
artifact_kind: ArtifactKind::Directory, artifact_kind: ArtifactKind::Directory,
}); });
} }
tracing::trace!(path = %path.display(), "reading file for content identification");
let bytes = let bytes =
fs::read(path).map_err(|error| format!("could not read {}: {error}", path.display()))?; fs::read(path).map_err(|error| format!("could not read {}: {error}", path.display()))?;
tracing::trace!(path = %path.display(), byte_count = bytes.len(), "read file for identification");
if let Some(format) = explicit { if let Some(format) = explicit {
return Ok(from_format(&normalized(format), &bytes)); let format = normalized(format);
tracing::debug!(path = %path.display(), format, "using explicit input format");
return Ok(from_format(&format, &bytes));
} }
identify_bytes(&bytes) let identified = identify_bytes(&bytes)
.ok_or_else(|| format!("could not identify {} from its contents", path.display())) .ok_or_else(|| format!("could not identify {} from its contents", path.display()))?;
tracing::debug!(path = %path.display(), format = %identified.format, "identified file contents");
Ok(identified)
} }
pub fn identify_bytes(bytes: &[u8]) -> Option<DetectedFormat> { pub fn identify_bytes(bytes: &[u8]) -> Option<DetectedFormat> {
tracing::trace!(
byte_count = bytes.len(),
"starting content signature identification"
);
if bytes.starts_with(&[0, 0, 1, 0]) { if bytes.starts_with(&[0, 0, 1, 0]) {
tracing::trace!(rule = "ico-header", "matched content signature");
return Some(from_format("ico", bytes)); return Some(from_format("ico", bytes));
} }
tracing::trace!(
rule = "ico-header",
matched = false,
"checked content signature"
);
if bytes.starts_with(b"<!DOCTYPE html") || bytes.starts_with(b"<html") { if bytes.starts_with(b"<!DOCTYPE html") || bytes.starts_with(b"<html") {
tracing::trace!(rule = "html-prefix", "matched content signature");
return Some(from_format("html", bytes)); return Some(from_format("html", bytes));
} }
tracing::trace!(
rule = "html-prefix",
matched = false,
"checked content signature"
);
if bytes.windows(4).any(|window| window == b"M4A ") { if bytes.windows(4).any(|window| window == b"M4A ") {
tracing::trace!(rule = "m4a-brand", "matched content signature");
return Some(from_format("m4a", bytes)); return Some(from_format("m4a", bytes));
} }
tracing::trace!(
rule = "m4a-brand",
matched = false,
"checked content signature"
);
if bytes.starts_with(b"OggS") { if bytes.starts_with(b"OggS") {
tracing::trace!(rule = "ogg-header", "matched content signature");
if bytes.windows(8).any(|window| window == b"OpusHead") { if bytes.windows(8).any(|window| window == b"OpusHead") {
tracing::trace!(rule = "opus-header", "matched Ogg subtype signature");
return Some(from_format("opus", bytes)); return Some(from_format("opus", bytes));
} }
if bytes.windows(6).any(|window| window == b"theora") { if bytes.windows(6).any(|window| window == b"theora") {
tracing::trace!(rule = "theora-header", "matched Ogg subtype signature");
return Some(from_format("ogv", bytes)); return Some(from_format("ogv", bytes));
} }
tracing::trace!("no Ogg subtype signature matched; using generic Ogg format");
return Some(from_format("ogg", bytes)); return Some(from_format("ogg", bytes));
} }
tracing::trace!(
rule = "ogg-header",
matched = false,
"checked content signature"
);
if let Some(kind) = infer::get(bytes) { if let Some(kind) = infer::get(bytes) {
return Some(from_format(&normalized(kind.extension()), bytes)); let format = normalized(kind.extension());
tracing::trace!(
infer_extension = kind.extension(),
mime = kind.mime_type(),
format,
"matched infer signature database"
);
return Some(from_format(&format, bytes));
} }
tracing::trace!("infer signature database did not match");
let text = std::str::from_utf8(bytes).ok()?; let text = std::str::from_utf8(bytes).ok()?;
tracing::trace!(
utf8 = true,
character_count = text.chars().count(),
"content is valid UTF-8"
);
if !text.contains('\0') { if !text.contains('\0') {
tracing::trace!(rule = "utf8-without-nul", "classified content as text");
return Some(from_format("text", bytes)); return Some(from_format("text", bytes));
} }
tracing::trace!("content did not match any supported identification rule");
None None
} }
fn from_format(format: &str, bytes: &[u8]) -> DetectedFormat { fn from_format(format: &str, bytes: &[u8]) -> DetectedFormat {
tracing::trace!(
format,
byte_count = bytes.len(),
"deriving media details from format"
);
let animation = match format { let animation = match format {
"webp" => bytes.len() > 20 && &bytes[12..16] == b"VP8X" && bytes[20] & 0x02 != 0, "webp" => bytes.len() > 20 && &bytes[12..16] == b"VP8X" && bytes[20] & 0x02 != 0,
"png" | "apng" => bytes.windows(4).any(|window| window == b"acTL"), "png" | "apng" => bytes.windows(4).any(|window| window == b"acTL"),
"gif" => gif_is_animated(bytes), "gif" => gif_is_animated(bytes),
_ => false, _ => false,
}; };
tracing::trace!(format, animation, "determined animation state");
let media_kind = if animation { let media_kind = if animation {
MediaKind::Animation MediaKind::Animation
} else if matches!( } else if matches!(
@@ -194,6 +277,7 @@ fn from_format(format: &str, bytes: &[u8]) -> DetectedFormat {
} else { } else {
format.to_owned() format.to_owned()
}; };
tracing::trace!(format, mime, ?media_kind, "derived media details");
DetectedFormat { DetectedFormat {
format, format,
mime: mime.to_owned(), mime: mime.to_owned(),
@@ -203,18 +287,27 @@ fn from_format(format: &str, bytes: &[u8]) -> DetectedFormat {
} }
fn gif_is_animated(bytes: &[u8]) -> bool { fn gif_is_animated(bytes: &[u8]) -> bool {
tracing::trace!(byte_count = bytes.len(), "checking GIF animation state");
let mut options = gif::DecodeOptions::new(); let mut options = gif::DecodeOptions::new();
options.set_color_output(gif::ColorOutput::Indexed); options.set_color_output(gif::ColorOutput::Indexed);
let Ok(mut decoder) = options.read_info(std::io::Cursor::new(bytes)) else { let Ok(mut decoder) = options.read_info(std::io::Cursor::new(bytes)) else {
tracing::trace!("GIF decoder rejected content while checking animation");
return false; return false;
}; };
let mut frames = 0; let mut frames = 0;
while let Ok(Some(_)) = decoder.read_next_frame() { while let Ok(Some(_)) = decoder.read_next_frame() {
frames += 1; frames += 1;
tracing::trace!(frames, "decoded GIF frame while checking animation");
if frames > 1 { if frames > 1 {
tracing::trace!(frames, animated = true, "GIF has multiple frames");
return true; return true;
} }
} }
tracing::trace!(
frames,
animated = false,
"GIF has fewer than two decodable frames"
);
false false
} }
+110 -7
View File
@@ -33,38 +33,70 @@ use std::{
const VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/version.txt")); const VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/version.txt"));
fn fail(message: impl std::fmt::Display) -> ! { fn fail(message: impl std::fmt::Display) -> ! {
tracing::trace!("entering fatal error path");
tracing::error!("{message}"); tracing::error!("{message}");
tracing::trace!(exit_code = 1, "terminating process");
process::exit(1) process::exit(1)
} }
fn parse_options(values: &[String]) -> Result<BTreeMap<String, String>, String> { fn parse_options(values: &[String]) -> Result<BTreeMap<String, String>, String> {
tracing::trace!(value_count = values.len(), "parsing plugin options");
let mut options = BTreeMap::new(); let mut options = BTreeMap::new();
for value in values { for (index, value) in values.iter().enumerate() {
tracing::trace!(index, raw_length = value.len(), "parsing plugin option");
let (key, value) = value let (key, value) = value
.split_once('=') .split_once('=')
.ok_or_else(|| format!("invalid option '{value}'; expected KEY=VALUE"))?; .ok_or_else(|| format!("invalid option '{value}'; expected KEY=VALUE"))?;
tracing::trace!(
index,
key,
value_length = value.len(),
"split plugin option"
);
if key.is_empty() || options.insert(key.to_owned(), value.to_owned()).is_some() { if key.is_empty() || options.insert(key.to_owned(), value.to_owned()).is_some() {
return Err(format!("invalid or duplicate option '{key}'")); return Err(format!("invalid or duplicate option '{key}'"));
} }
} }
tracing::trace!(option_count = options.len(), "plugin options parsed");
Ok(options) Ok(options)
} }
fn should_overwrite(args: &Args, output: &Path) -> bool { fn should_overwrite(args: &Args, output: &Path) -> bool {
if !output.exists() { let exists = output.exists();
tracing::trace!(
output = %output.display(),
exists,
yes = args.yes,
no = args.no,
quiet = args.quiet,
stdin_terminal = io::stdin().is_terminal(),
"evaluating output overwrite policy"
);
if !exists {
tracing::trace!("output is absent; overwrite is allowed");
return true; return true;
} }
if args.yes { if args.yes {
tracing::trace!("overwrite accepted by --yes");
return true; return true;
} }
if args.no || args.quiet || !io::stdin().is_terminal() { if args.no || args.quiet || !io::stdin().is_terminal() {
tracing::trace!("overwrite declined without prompting");
return false; return false;
} }
tracing::trace!("prompting for overwrite confirmation");
print!("'{}' exists. Replace it? [y/N]: ", output.display()); print!("'{}' exists. Replace it? [y/N]: ", output.display());
let _ = io::stdout().flush(); let _ = io::stdout().flush();
let mut answer = String::new(); let mut answer = String::new();
io::stdin().read_line(&mut answer).is_ok() let read = io::stdin().read_line(&mut answer);
&& matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") let accepted =
read.is_ok() && matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes");
tracing::trace!(
read_success = read.is_ok(),
accepted,
"processed overwrite response"
);
accepted
} }
fn main() { fn main() {
@@ -76,15 +108,30 @@ fn main() {
tracing::Level::from(args.verbose).into() tracing::Level::from(args.verbose).into()
}; };
tracing_subscriber::fmt().with_max_level(level).init(); tracing_subscriber::fmt().with_max_level(level).init();
tracing::debug!(
version = env!("CARGO_PKG_VERSION"),
target = env!("CONVERTIS_TARGET"),
rustc = env!("CONVERTIS_RUSTC_VERSION"),
?level,
"Convertis engine started"
);
tracing::trace!(?args, "parsed command-line arguments");
let registry = plugin::PluginRegistry::load(&args.plugin_dirs, !args.no_default_plugins); let registry = plugin::PluginRegistry::load(&args.plugin_dirs, !args.no_default_plugins);
tracing::trace!(
plugin_count = registry.plugins.len(),
diagnostic_count = registry.diagnostics.len(),
"plugin registry is ready"
);
for diagnostic in &registry.diagnostics { for diagnostic in &registry.diagnostics {
tracing::warn!("{diagnostic}"); tracing::warn!("{diagnostic}");
} }
if args.list_plugins { if args.list_plugins {
tracing::debug!("listing official plugins");
println!("Official plugins:"); println!("Official plugins:");
for metadata in catalog::official_plugins() { for metadata in catalog::official_plugins() {
tracing::trace!(plugin = %metadata.id, package = %metadata.package, "checking official plugin state");
let installed = registry let installed = registry
.plugins .plugins
.iter() .iter()
@@ -94,17 +141,23 @@ fn main() {
Some(Err(error)) => format!("unavailable: {error}"), Some(Err(error)) => format!("unavailable: {error}"),
None => "not installed".to_owned(), None => "not installed".to_owned(),
}; };
tracing::trace!(plugin = %metadata.id, state, "resolved official plugin state");
println!( println!(
" {:32} {:28} {}", " {:32} {:28} {}",
metadata.package, state, metadata.description metadata.package, state, metadata.description
); );
} }
tracing::debug!("finished listing official plugins");
return; return;
} }
if args.list_formats { if args.list_formats {
tracing::debug!("collecting formats from installed plugins");
let mut formats = HashSet::new(); let mut formats = HashSet::new();
for plugin in &registry.plugins { for plugin in &registry.plugins {
for conversion in plugin.metadata().conversions { let metadata = plugin.metadata();
tracing::trace!(plugin = %metadata.id, conversion_count = metadata.conversions.len(), "collecting plugin formats");
for conversion in metadata.conversions {
tracing::trace!(plugin = %metadata.id, from = %conversion.from, to = %conversion.to, "collecting conversion formats");
formats.insert(conversion.from); formats.insert(conversion.from);
formats.insert(conversion.to); formats.insert(conversion.to);
} }
@@ -116,6 +169,7 @@ fn main() {
} else { } else {
println!("{}", formats.join("\n")); println!("{}", formats.join("\n"));
} }
tracing::debug!(format_count = formats.len(), "finished listing formats");
return; return;
} }
@@ -123,30 +177,52 @@ fn main() {
.input_path .input_path
.as_deref() .as_deref()
.unwrap_or_else(|| fail("an input path is required")); .unwrap_or_else(|| fail("an input path is required"));
tracing::debug!(input = %input.display(), explicit_format = ?args.from_format, "identifying input");
let detected = identifier::identify_path(input, args.from_format.as_deref()) let detected = identifier::identify_path(input, args.from_format.as_deref())
.unwrap_or_else(|error| fail(error)); .unwrap_or_else(|error| fail(error));
tracing::info!("detected {} ({:?})", detected.format, detected.media_kind); tracing::info!(
format = %detected.format,
mime = %detected.mime,
media_kind = ?detected.media_kind,
artifact_kind = ?detected.artifact_kind,
"input identified"
);
tracing::trace!(output = ?args.output_path, explicit_target = ?args.to_format, "resolving requested target format");
let mut target = let mut target =
identifier::requested_format(args.output_path.as_deref(), args.to_format.as_deref()) identifier::requested_format(args.output_path.as_deref(), args.to_format.as_deref())
.unwrap_or_else(|| { .unwrap_or_else(|| {
fail("a target is required; provide an output extension or --to FORMAT") fail("a target is required; provide an output extension or --to FORMAT")
}); });
tracing::trace!(target, "resolved initial target format");
if matches!( if matches!(
detected.media_kind, detected.media_kind,
convertis_plugin_api::MediaKind::Video convertis_plugin_api::MediaKind::Video
| convertis_plugin_api::MediaKind::Animation | convertis_plugin_api::MediaKind::Animation
| convertis_plugin_api::MediaKind::Frames | convertis_plugin_api::MediaKind::Frames
) { ) {
let original_target = target.clone();
target = match target.as_str() { target = match target.as_str() {
"gif" => "animated-gif".to_owned(), "gif" => "animated-gif".to_owned(),
"webp" => "animated-webp".to_owned(), "webp" => "animated-webp".to_owned(),
_ => target, _ => target,
}; };
tracing::trace!(
original_target,
normalized_target = target,
"normalized animated target format"
);
} }
let options = parse_options(&args.options).unwrap_or_else(|error| fail(error)); let options = parse_options(&args.options).unwrap_or_else(|error| fail(error));
let mut banned = Vec::new(); let mut banned = Vec::new();
let result = loop { let result = loop {
tracing::debug!(
from = %detected.format,
to = %target,
priority = %args.priority,
banned = ?banned,
"searching for a conversion route"
);
let Some(route) = pathfinder::find_best_path( let Some(route) = pathfinder::find_best_path(
&registry.plugins, &registry.plugins,
&detected.format, &detected.format,
@@ -154,7 +230,9 @@ fn main() {
&args.priority, &args.priority,
&banned, &banned,
) else { ) else {
tracing::debug!("no installed conversion route was found");
let packages = catalog::recommend_packages(&detected.format, &target); let packages = catalog::recommend_packages(&detected.format, &target);
tracing::trace!(?packages, "resolved package recommendations");
if packages.is_empty() { if packages.is_empty() {
fail(format!( fail(format!(
"no conversion path from {} to {} is known", "no conversion path from {} to {} is known",
@@ -189,7 +267,9 @@ fn main() {
packages.join(" ") packages.join(" ")
)); ));
}; };
tracing::debug!(step_count = route.len(), "selected conversion route");
for key in options.keys() { for key in options.keys() {
tracing::trace!(key, "validating option against selected route");
let recognized = route.iter().any(|step| { let recognized = route.iter().any(|step| {
let metadata = step.plugin.metadata(); let metadata = step.plugin.metadata();
let option_name = key let option_name = key
@@ -205,37 +285,55 @@ fn main() {
"option '{key}' is not supported by the selected conversion route" "option '{key}' is not supported by the selected conversion route"
)); ));
} }
tracing::trace!(key, "option is supported by selected route");
} }
let route_names: Vec<_> = route.iter().map(|step| step.plugin.metadata().id).collect(); let route_names: Vec<_> = route.iter().map(|step| step.plugin.metadata().id).collect();
tracing::info!(route = %route_names.join(" -> "), "conversion route selected");
if args.test { if args.test {
tracing::debug!("test mode requested; skipping conversion");
println!("{}", route_names.join(" -> ")); println!("{}", route_names.join(" -> "));
return; return;
} }
tracing::debug!("executing conversion route");
match runner::run_conversion(&route, input, &args.temp_dir, &options) { match runner::run_conversion(&route, input, &args.temp_dir, &options) {
Ok(result) => break result, Ok(result) => {
tracing::debug!(result = %result.path.display(), kind = ?result.kind, "conversion route succeeded");
break result;
}
Err((error, plugin)) => { Err((error, plugin)) => {
tracing::warn!("plugin {plugin} failed: {error}; trying another route"); tracing::warn!("plugin {plugin} failed: {error}; trying another route");
banned.push(plugin); banned.push(plugin);
tracing::trace!(?banned, "updated failed-plugin exclusion list");
} }
} }
}; };
if let Some(output) = &args.output_path { if let Some(output) = &args.output_path {
tracing::debug!(output = %output.display(), "installing conversion result");
if !should_overwrite(&args, output) { if !should_overwrite(&args, output) {
fail("output was not replaced"); fail("output was not replaced");
} }
if output.exists() { if output.exists() {
if output.is_dir() { if output.is_dir() {
tracing::trace!(output = %output.display(), "removing existing output directory");
fs::remove_dir_all(output).unwrap_or_else(|error| fail(error)); fs::remove_dir_all(output).unwrap_or_else(|error| fail(error));
} else { } else {
tracing::trace!(output = %output.display(), "removing existing output file");
fs::remove_file(output).unwrap_or_else(|error| fail(error)); fs::remove_file(output).unwrap_or_else(|error| fail(error));
} }
} }
runner::install_result(&result, output).unwrap_or_else(|error| fail(error)); runner::install_result(&result, output).unwrap_or_else(|error| fail(error));
tracing::info!(output = %output.display(), "conversion result installed");
} else if result.kind == ArtifactKind::Directory { } else if result.kind == ArtifactKind::Directory {
fail("directory output requires an output path"); fail("directory output requires an output path");
} else { } else {
tracing::trace!(path = %result.path.display(), "reading result for standard output");
let bytes = fs::read(&result.path).unwrap_or_else(|error| fail(error)); let bytes = fs::read(&result.path).unwrap_or_else(|error| fail(error));
tracing::trace!(
byte_count = bytes.len(),
stdout_terminal = io::stdout().is_terminal(),
"prepared result bytes for standard output"
);
if io::stdout().is_terminal() && !args.write_to_console { if io::stdout().is_terminal() && !args.write_to_console {
tracing::warn!( tracing::warn!(
"writing conversion bytes to the terminal; use -c to suppress this warning" "writing conversion bytes to the terminal; use -c to suppress this warning"
@@ -244,5 +342,10 @@ fn main() {
io::stdout() io::stdout()
.write_all(&bytes) .write_all(&bytes)
.unwrap_or_else(|error| fail(error)); .unwrap_or_else(|error| fail(error));
tracing::trace!(
byte_count = bytes.len(),
"wrote result bytes to standard output"
);
} }
tracing::debug!("Convertis engine completed successfully");
} }
+82 -11
View File
@@ -22,7 +22,12 @@ pub struct RouteStep<'a> {
} }
fn route_score(route: &[RouteStep<'_>], priority: &str) -> Vec<u32> { fn route_score(route: &[RouteStep<'_>], priority: &str) -> Vec<u32> {
priority tracing::trace!(
step_count = route.len(),
priority,
"scoring conversion route"
);
let score: Vec<u32> = priority
.chars() .chars()
.map(|criterion| { .map(|criterion| {
route route
@@ -35,7 +40,9 @@ fn route_score(route: &[RouteStep<'_>], priority: &str) -> Vec<u32> {
}) })
.sum() .sum()
}) })
.collect() .collect();
tracing::trace!(?score, "calculated conversion route score");
score
} }
pub fn find_best_path<'a>( pub fn find_best_path<'a>(
@@ -45,13 +52,29 @@ pub fn find_best_path<'a>(
priority: &str, priority: &str,
banned: &[String], banned: &[String],
) -> Option<Vec<RouteStep<'a>>> { ) -> Option<Vec<RouteStep<'a>>> {
tracing::debug!(
plugin_count = plugins.len(),
from,
to,
priority,
?banned,
"building conversion graph"
);
let mut edges: HashMap<String, Vec<RouteStep<'a>>> = HashMap::new(); let mut edges: HashMap<String, Vec<RouteStep<'a>>> = HashMap::new();
for plugin in plugins { for plugin in plugins {
let metadata = plugin.metadata(); let metadata = plugin.metadata();
if banned.contains(&metadata.id) || plugin.availability().is_err() { if banned.contains(&metadata.id) {
tracing::trace!(plugin = %metadata.id, "excluded banned plugin from conversion graph");
continue; continue;
} }
tracing::trace!(plugin = %metadata.id, "checking plugin availability for conversion graph");
if let Err(error) = plugin.availability() {
tracing::debug!(plugin = %metadata.id, %error, "excluded unavailable plugin from conversion graph");
continue;
}
tracing::trace!(plugin = %metadata.id, conversion_count = metadata.conversions.len(), "adding available plugin conversions to graph");
for conversion in metadata.conversions { for conversion in metadata.conversions {
tracing::trace!(plugin = %metadata.id, from = %conversion.from, to = %conversion.to, "adding conversion graph edge");
edges edges
.entry(conversion.from.clone()) .entry(conversion.from.clone())
.or_default() .or_default()
@@ -61,38 +84,86 @@ pub fn find_best_path<'a>(
}); });
} }
} }
let edge_count: usize = edges.values().map(Vec::len).sum();
tracing::debug!(
node_count = edges.len(),
edge_count,
"conversion graph built"
);
let mut queue = VecDeque::from([(from.to_owned(), Vec::<RouteStep<'a>>::new())]); let mut queue = VecDeque::from([(from.to_owned(), Vec::<RouteStep<'a>>::new())]);
let mut depths = HashMap::from([(from.to_owned(), 0usize)]); let mut depths = HashMap::from([(from.to_owned(), 0usize)]);
let mut solutions = Vec::new(); let mut solutions = Vec::new();
let mut minimum = None; let mut minimum = None;
while let Some((current, route)) = queue.pop_front() { while let Some((current, route)) = queue.pop_front() {
tracing::trace!(
current,
depth = route.len(),
queue_length = queue.len(),
known_minimum = ?minimum,
"visiting conversion graph state"
);
if minimum.is_some_and(|depth| route.len() > depth) { if minimum.is_some_and(|depth| route.len() > depth) {
tracing::trace!(
current,
depth = route.len(),
"pruned state deeper than shortest solution"
);
continue; continue;
} }
if current == to && !route.is_empty() { if current == to && !route.is_empty() {
minimum = Some(route.len()); minimum = Some(route.len());
tracing::trace!(
depth = route.len(),
solution_count = solutions.len() + 1,
"found shortest conversion route candidate"
);
solutions.push(route); solutions.push(route);
continue; continue;
} }
if let Some(next_steps) = edges.get(&current) { if let Some(next_steps) = edges.get(&current) {
tracing::trace!(
current,
next_step_count = next_steps.len(),
"expanding conversion graph state"
);
for step in next_steps { for step in next_steps {
let next_depth = route.len() + 1; let next_depth = route.len() + 1;
if next_depth let previous_depth = depths
<= depths .get(&step.conversion.to)
.get(&step.conversion.to) .copied()
.copied() .unwrap_or(usize::MAX);
.unwrap_or(usize::MAX) tracing::trace!(
{ plugin = %step.plugin.metadata().id,
from = %step.conversion.from,
to = %step.conversion.to,
next_depth,
previous_depth,
"considering conversion graph edge"
);
if next_depth <= previous_depth {
depths.insert(step.conversion.to.clone(), next_depth); depths.insert(step.conversion.to.clone(), next_depth);
let mut next_route = route.clone(); let mut next_route = route.clone();
next_route.push(step.clone()); next_route.push(step.clone());
queue.push_back((step.conversion.to.clone(), next_route)); queue.push_back((step.conversion.to.clone(), next_route));
tracing::trace!(next = %step.conversion.to, queue_length = queue.len(), "queued conversion graph state");
} else {
tracing::trace!(next = %step.conversion.to, "skipped conversion graph state with longer path");
} }
} }
} else {
tracing::trace!(current, "conversion graph state has no outgoing edges");
} }
} }
solutions tracing::debug!(solution_count = solutions.len(), shortest_depth = ?minimum, "conversion graph search completed");
let selected = solutions
.into_iter() .into_iter()
.max_by_key(|route| route_score(route, priority)) .max_by_key(|route| route_score(route, priority));
if let Some(route) = &selected {
let plugins: Vec<_> = route.iter().map(|step| step.plugin.metadata().id).collect();
tracing::debug!(?plugins, score = ?route_score(route, priority), "selected highest-scoring shortest route");
} else {
tracing::debug!("no conversion route selected");
}
selected
} }
+377 -71
View File
@@ -3,24 +3,37 @@
// This program is free software: you can redistribute it and/or modify // 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 // it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 3 exclusively. // 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 convertis_plugin_api::{ use convertis_plugin_api::{
ENGINE_VERSION, FACTORY_SYMBOL, MANIFEST_SYMBOL, Plugin, PluginFactory, PluginManifest, AVAILABILITY_SYMBOL_V2, CONVERT_SYMBOL_V2, ConversionRequest, FREE_SYMBOL_V2,
LEGACY_FACTORY_SYMBOL_V1, LEGACY_MANIFEST_SYMBOL_V1, MANIFEST_SYMBOL, METADATA_SYMBOL_V2,
Plugin, PluginConvertCall, PluginFactory, PluginJsonCall, PluginManifest, PluginMetadata,
PluginStringFree, WireResponse,
}; };
use libloading::Library; use libloading::Library;
use serde::Deserialize; use semver::{Version, VersionReq};
use std::{collections::HashSet, ffi::CStr, fs, path::PathBuf}; use serde::{Deserialize, de::DeserializeOwned};
use std::{
collections::HashSet,
ffi::{CStr, CString, c_char},
fs,
path::{Path, PathBuf},
};
#[derive(Deserialize)] const SUPPORTED_PROTOCOLS: &[u32] = &[2];
struct AbiManifest {
#[derive(Debug, Deserialize)]
struct StableManifest {
manifest_version: u32,
protocol_versions: Vec<u32>,
engine_requirement: String,
plugin_id: String,
plugin_version: String,
target: String,
}
#[derive(Debug, Deserialize)]
struct LegacyManifestV1 {
api_version: u32, api_version: u32,
engine_version: String, engine_version: String,
plugin_id: String, plugin_id: String,
@@ -28,6 +41,67 @@ struct AbiManifest {
target: String, target: String,
} }
struct ProtocolV2Plugin {
metadata: PluginMetadata,
availability: PluginJsonCall,
convert: PluginConvertCall,
free: PluginStringFree,
}
impl ProtocolV2Plugin {
unsafe fn call<T: DeserializeOwned>(
&self,
operation: &str,
call: PluginJsonCall,
) -> Result<T, String> {
tracing::trace!(plugin = %self.metadata.id, operation, "calling plugin protocol operation");
let pointer = unsafe { call() };
let result = unsafe { decode_response(pointer, self.free, operation) };
tracing::trace!(
plugin = %self.metadata.id,
operation,
success = result.is_ok(),
"plugin protocol operation completed"
);
result
}
}
impl Plugin for ProtocolV2Plugin {
fn metadata(&self) -> PluginMetadata {
tracing::trace!(plugin = %self.metadata.id, "returning cached plugin metadata");
self.metadata.clone()
}
fn availability(&self) -> Result<(), String> {
unsafe { self.call("availability", self.availability) }
}
fn convert(&self, request: &ConversionRequest) -> Result<(), String> {
tracing::trace!(
plugin = %self.metadata.id,
input = %request.input.display(),
output = %request.output.display(),
from = %request.from,
to = %request.to,
option_count = request.options.len(),
"serializing conversion request"
);
let json = serde_json::to_string(request)
.map_err(|error| format!("could not serialize conversion request: {error}"))?;
let json =
CString::new(json).map_err(|_| "conversion request contained a NUL byte".to_owned())?;
let pointer = unsafe { (self.convert)(json.as_ptr()) };
let result = unsafe { decode_response(pointer, self.free, "convert") };
tracing::trace!(
plugin = %self.metadata.id,
success = result.is_ok(),
"conversion protocol call completed"
);
result
}
}
pub struct PluginRegistry { pub struct PluginRegistry {
// Plugins must be dropped before their backing libraries. // Plugins must be dropped before their backing libraries.
pub plugins: Vec<Box<dyn Plugin>>, pub plugins: Vec<Box<dyn Plugin>>,
@@ -37,19 +111,38 @@ pub struct PluginRegistry {
impl PluginRegistry { impl PluginRegistry {
pub fn load(extra_dirs: &[PathBuf], include_defaults: bool) -> Self { pub fn load(extra_dirs: &[PathBuf], include_defaults: bool) -> Self {
tracing::debug!(
extra_directory_count = extra_dirs.len(),
include_defaults,
"starting plugin discovery"
);
let mut directories = extra_dirs.to_vec(); let mut directories = extra_dirs.to_vec();
for directory in extra_dirs {
tracing::trace!(directory = %directory.display(), source = "command-line", "queued plugin directory");
}
if let Some(paths) = std::env::var_os("CONVERTIS_PLUGIN_PATH") { if let Some(paths) = std::env::var_os("CONVERTIS_PLUGIN_PATH") {
directories.extend(std::env::split_paths(&paths)); for directory in std::env::split_paths(&paths) {
tracing::trace!(directory = %directory.display(), source = "environment", "queued plugin directory");
directories.push(directory);
}
} else {
tracing::trace!("CONVERTIS_PLUGIN_PATH is not set");
} }
if include_defaults { if include_defaults {
if let Ok(executable) = std::env::current_exe() match std::env::current_exe() {
&& let Some(parent) = executable.parent() Ok(executable) => {
{ tracing::trace!(executable = %executable.display(), "resolved current executable");
directories.push(parent.to_path_buf()); if let Some(parent) = executable.parent() {
directories.push(parent.join("plugins")); directories.push(parent.to_path_buf());
directories.push(parent.join("plugins"));
}
}
Err(error) => tracing::debug!(%error, "could not resolve current executable"),
} }
if let Some(home) = std::env::var_os("HOME") { if let Some(home) = std::env::var_os("HOME") {
directories.push(PathBuf::from(home).join(".local/lib/convertis/plugins")); directories.push(PathBuf::from(home).join(".local/lib/convertis/plugins"));
} else {
tracing::trace!("HOME is not set; skipping per-user plugin directory");
} }
directories.push(PathBuf::from("/usr/lib/convertis/plugins")); directories.push(PathBuf::from("/usr/lib/convertis/plugins"));
} }
@@ -63,94 +156,307 @@ impl PluginRegistry {
let mut seen_ids = HashSet::new(); let mut seen_ids = HashSet::new();
for directory in directories { for directory in directories {
let Ok(directory) = directory.canonicalize() else { tracing::trace!(directory = %directory.display(), "examining plugin directory");
continue; let directory = match directory.canonicalize() {
Ok(directory) => directory,
Err(error) => {
tracing::trace!(directory = %directory.display(), %error, "plugin directory is unavailable");
continue;
}
}; };
if !seen_paths.insert(directory.clone()) { if !seen_paths.insert(directory.clone()) {
tracing::trace!(directory = %directory.display(), "skipping duplicate plugin directory");
continue; continue;
} }
let Ok(entries) = fs::read_dir(&directory) else { let entries = match fs::read_dir(&directory) {
continue; Ok(entries) => entries,
Err(error) => {
tracing::debug!(directory = %directory.display(), %error, "could not read plugin directory");
continue;
}
}; };
let mut paths: Vec<_> = entries.flatten().map(|entry| entry.path()).collect(); let mut paths: Vec<_> = entries
.filter_map(|entry| match entry {
Ok(entry) => Some(entry.path()),
Err(error) => {
tracing::trace!(directory = %directory.display(), %error, "could not read directory entry");
None
}
})
.collect();
paths.sort(); paths.sort();
tracing::trace!(directory = %directory.display(), entry_count = paths.len(), "sorted plugin directory entries");
for path in paths { for path in paths {
let is_plugin = path.extension().and_then(|value| value.to_str()) == Some("so") let is_plugin = path.extension().and_then(|value| value.to_str()) == Some("so")
&& path && path
.file_name() .file_name()
.and_then(|value| value.to_str()) .and_then(|value| value.to_str())
.is_some_and(|name| name.starts_with("libconvertis_")); .is_some_and(|name| name.starts_with("libconvertis_"));
tracing::trace!(path = %path.display(), is_plugin, "classified plugin candidate");
if !is_plugin { if !is_plugin {
continue; continue;
} }
tracing::debug!(path = %path.display(), "loading plugin candidate");
match unsafe { Self::load_one(&path) } { match unsafe { Self::load_one(&path) } {
Ok((library, plugin, id)) => { Ok((library, plugin, id, protocol)) => {
if seen_ids.insert(id.clone()) { if seen_ids.insert(id.clone()) {
tracing::info!(plugin = %id, protocol, path = %path.display(), "loaded plugin");
registry.plugins.push(plugin); registry.plugins.push(plugin);
registry.libraries.push(library); registry.libraries.push(library);
} else { } else {
tracing::debug!(plugin = %id, path = %path.display(), "ignored duplicate plugin");
registry.diagnostics.push(format!( registry.diagnostics.push(format!(
"ignored duplicate plugin '{id}' from {}", "ignored duplicate plugin '{id}' from {}",
path.display() path.display()
)); ));
} }
} }
Err(error) => registry Err(error) => {
.diagnostics tracing::debug!(path = %path.display(), %error, "plugin candidate was rejected");
.push(format!("could not load {}: {error}", path.display())), registry
.diagnostics
.push(format!("could not load {}: {error}", path.display()));
}
} }
} }
} }
tracing::debug!(
plugin_count = registry.plugins.len(),
diagnostic_count = registry.diagnostics.len(),
"plugin discovery completed"
);
registry registry
} }
unsafe fn load_one( unsafe fn load_one(path: &Path) -> Result<(Library, Box<dyn Plugin>, String, u32), String> {
path: &std::path::Path, tracing::trace!(path = %path.display(), "opening dynamic library");
) -> Result<(Library, Box<dyn Plugin>, String), String> {
let library = unsafe { Library::new(path) }.map_err(|error| error.to_string())?; let library = unsafe { Library::new(path) }.map_err(|error| error.to_string())?;
let manifest_fn = unsafe { library.get::<PluginManifest>(MANIFEST_SYMBOL) } tracing::trace!(path = %path.display(), "dynamic library opened");
.map_err(|error| format!("missing ABI manifest: {error}"))?;
let pointer = unsafe { manifest_fn() }; if let Ok(manifest_fn) = unsafe { library.get::<PluginManifest>(MANIFEST_SYMBOL) } {
if pointer.is_null() { tracing::trace!(path = %path.display(), "found stable plugin manifest entry point");
return Err("ABI manifest was null".to_owned()); let manifest: StableManifest = unsafe { read_manifest(*manifest_fn) }?;
let protocol = validate_stable_manifest(&manifest, env!("CARGO_PKG_VERSION"))?;
tracing::debug!(
plugin = %manifest.plugin_id,
plugin_version = %manifest.plugin_version,
protocol,
engine_requirement = %manifest.engine_requirement,
"negotiated plugin protocol"
);
let metadata_call = *unsafe { library.get::<PluginJsonCall>(METADATA_SYMBOL_V2) }
.map_err(|error| format!("protocol v2 metadata entry point is missing: {error}"))?;
let availability = *unsafe { library.get::<PluginJsonCall>(AVAILABILITY_SYMBOL_V2) }
.map_err(|error| {
format!("protocol v2 availability entry point is missing: {error}")
})?;
let convert = *unsafe { library.get::<PluginConvertCall>(CONVERT_SYMBOL_V2) }.map_err(
|error| format!("protocol v2 conversion entry point is missing: {error}"),
)?;
let free =
*unsafe { library.get::<PluginStringFree>(FREE_SYMBOL_V2) }.map_err(|error| {
format!("protocol v2 string-free entry point is missing: {error}")
})?;
tracing::trace!(plugin = %manifest.plugin_id, "resolved all protocol v2 entry points");
let metadata: PluginMetadata = unsafe {
let pointer = metadata_call();
decode_response(pointer, free, "metadata")
}?;
if metadata.id != manifest.plugin_id {
return Err("manifest and plugin metadata IDs differ".to_owned());
}
let id = manifest.plugin_id;
let plugin = ProtocolV2Plugin {
metadata,
availability,
convert,
free,
};
return Ok((library, Box::new(plugin), id, protocol));
} }
let json = unsafe { CStr::from_ptr(pointer) }
.to_str() tracing::trace!(path = %path.display(), "stable manifest absent; trying legacy protocol v1 adapter");
.map_err(|error| format!("invalid ABI manifest string: {error}"))?; let manifest_fn = unsafe { library.get::<PluginManifest>(LEGACY_MANIFEST_SYMBOL_V1) }
let manifest: AbiManifest = .map_err(|error| format!("missing stable or legacy ABI manifest: {error}"))?;
serde_json::from_str(json).map_err(|error| format!("invalid ABI manifest: {error}"))?; let manifest: LegacyManifestV1 = unsafe { read_manifest(*manifest_fn) }?;
if manifest.api_version != convertis_plugin_api::API_VERSION { validate_legacy_manifest(&manifest)?;
return Err(format!( let factory = unsafe { library.get::<PluginFactory>(LEGACY_FACTORY_SYMBOL_V1) }
"plugin API {} is not supported", .map_err(|error| format!("missing legacy Rust plugin factory: {error}"))?;
manifest.api_version
));
}
if manifest.engine_version != ENGINE_VERSION {
return Err(format!(
"plugin targets engine {}, but this engine is {}",
manifest.engine_version, ENGINE_VERSION
));
}
if manifest.rustc_version != env!("CONVERTIS_RUSTC_VERSION") {
return Err(format!(
"plugin was built with {}, but the engine uses {}",
manifest.rustc_version,
env!("CONVERTIS_RUSTC_VERSION")
));
}
if manifest.target != env!("CONVERTIS_TARGET") {
return Err(format!(
"plugin targets {}, but the engine targets {}",
manifest.target,
env!("CONVERTIS_TARGET")
));
}
let factory = unsafe { library.get::<PluginFactory>(FACTORY_SYMBOL) }
.map_err(|error| format!("missing Rust plugin factory: {error}"))?;
let plugin = unsafe { factory() }; let plugin = unsafe { factory() };
if plugin.metadata().id != manifest.plugin_id { if plugin.metadata().id != manifest.plugin_id {
return Err("manifest and plugin IDs differ".to_owned()); return Err("legacy manifest and plugin IDs differ".to_owned());
} }
Ok((library, plugin, manifest.plugin_id)) tracing::debug!(
plugin = %manifest.plugin_id,
built_for_engine = %manifest.engine_version,
"loaded plugin through legacy protocol v1 adapter"
);
Ok((library, plugin, manifest.plugin_id, 1))
}
}
fn validate_stable_manifest(
manifest: &StableManifest,
engine_version: &str,
) -> Result<u32, String> {
tracing::trace!(
?manifest,
engine_version,
"validating stable plugin manifest"
);
if manifest.manifest_version != 1 {
return Err(format!(
"manifest format {} is not supported",
manifest.manifest_version
));
}
let engine = Version::parse(engine_version)
.map_err(|error| format!("engine has an invalid version: {error}"))?;
let requirement = VersionReq::parse(&manifest.engine_requirement)
.map_err(|error| format!("plugin has an invalid engine requirement: {error}"))?;
if !requirement.matches(&engine) {
return Err(format!(
"plugin requires engine {}, but this engine is {}",
manifest.engine_requirement, engine_version
));
}
if manifest.target != env!("CONVERTIS_TARGET") {
return Err(format!(
"plugin targets {}, but the engine targets {}",
manifest.target,
env!("CONVERTIS_TARGET")
));
}
SUPPORTED_PROTOCOLS
.iter()
.rev()
.find(|version| manifest.protocol_versions.contains(version))
.copied()
.ok_or_else(|| {
format!(
"plugin protocols {:?} are not supported; engine supports {:?}",
manifest.protocol_versions, SUPPORTED_PROTOCOLS
)
})
}
fn validate_legacy_manifest(manifest: &LegacyManifestV1) -> Result<(), String> {
tracing::trace!(?manifest, "validating legacy plugin manifest");
if manifest.api_version != 1 {
return Err(format!(
"legacy plugin API {} is not supported",
manifest.api_version
));
}
if manifest.engine_version != convertis_plugin_api::ENGINE_VERSION {
tracing::debug!(
plugin = %manifest.plugin_id,
built_for_engine = %manifest.engine_version,
current_engine = convertis_plugin_api::ENGINE_VERSION,
"legacy engine release differs; continuing because protocol v1 is retained"
);
}
if manifest.rustc_version != env!("CONVERTIS_RUSTC_VERSION") {
return Err(format!(
"legacy plugin was built with {}, but the engine uses {}; protocol v1 uses the unstable Rust ABI",
manifest.rustc_version,
env!("CONVERTIS_RUSTC_VERSION")
));
}
if manifest.target != env!("CONVERTIS_TARGET") {
return Err(format!(
"legacy plugin targets {}, but the engine targets {}",
manifest.target,
env!("CONVERTIS_TARGET")
));
}
Ok(())
}
unsafe fn read_manifest<T: DeserializeOwned>(manifest: PluginManifest) -> Result<T, String> {
tracing::trace!("calling plugin manifest entry point");
let pointer = unsafe { manifest() };
if pointer.is_null() {
return Err("ABI manifest was null".to_owned());
}
let json = unsafe { CStr::from_ptr(pointer) }
.to_str()
.map_err(|error| format!("invalid ABI manifest string: {error}"))?;
tracing::trace!(
manifest_bytes = json.len(),
manifest = json,
"received plugin manifest"
);
serde_json::from_str(json).map_err(|error| format!("invalid ABI manifest: {error}"))
}
unsafe fn decode_response<T: DeserializeOwned>(
pointer: *mut c_char,
free: PluginStringFree,
operation: &str,
) -> Result<T, String> {
if pointer.is_null() {
return Err(format!("plugin returned null from {operation}"));
}
let json_result = unsafe { CStr::from_ptr(pointer) }
.to_str()
.map(str::to_owned)
.map_err(|error| format!("plugin returned invalid UTF-8 from {operation}: {error}"));
unsafe { free(pointer) };
let json = json_result?;
tracing::trace!(
operation,
response_bytes = json.len(),
"received plugin protocol response"
);
let response: WireResponse<T> = serde_json::from_str(&json)
.map_err(|error| format!("plugin returned invalid JSON from {operation}: {error}"))?;
match (response.ok, response.result, response.error) {
(true, Some(result), None) => Ok(result),
(true, None, None) => serde_json::from_value(serde_json::Value::Null).map_err(|error| {
format!("plugin returned no result value from successful {operation}: {error}")
}),
(false, _, Some(error)) => Err(error),
(true, _, Some(error)) => Err(format!(
"plugin returned both success and an error from {operation}: {error}"
)),
(false, _, None) => Err(format!(
"plugin returned failure without an error from {operation}"
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn manifest(requirement: &str, protocols: &[u32]) -> StableManifest {
StableManifest {
manifest_version: 1,
protocol_versions: protocols.to_vec(),
engine_requirement: requirement.into(),
plugin_id: "example".into(),
plugin_version: "0.3.0".into(),
target: env!("CONVERTIS_TARGET").into(),
}
}
#[test]
fn current_plugin_requirement_accepts_future_engines() {
let manifest = manifest(">=0.3.0", &[2]);
assert_eq!(validate_stable_manifest(&manifest, "0.3.0"), Ok(2));
assert_eq!(validate_stable_manifest(&manifest, "99.0.0"), Ok(2));
}
#[test]
fn protocol_is_negotiated_independently_of_release_version() {
let manifest = manifest(">=0.1.0", &[1, 2, 3]);
assert_eq!(validate_stable_manifest(&manifest, "1.0.0"), Ok(2));
}
#[test]
fn unsupported_protocol_is_rejected() {
let error = validate_stable_manifest(&manifest(">=0.3.0", &[7]), "0.3.0").unwrap_err();
assert!(error.contains("not supported"));
} }
} }
+58 -6
View File
@@ -32,35 +32,61 @@ pub fn run_conversion(
temp_root: &Path, temp_root: &Path,
options: &BTreeMap<String, String>, options: &BTreeMap<String, String>,
) -> Result<ConversionResult, (String, String)> { ) -> Result<ConversionResult, (String, String)> {
tracing::debug!(
route_steps = route.len(),
input = %input.display(),
temp_root = %temp_root.display(),
option_count = options.len(),
"preparing conversion workspace"
);
tracing::trace!(directory = %temp_root.display(), "ensuring temporary root exists");
fs::create_dir_all(temp_root).map_err(|error| (error.to_string(), "engine".to_owned()))?; fs::create_dir_all(temp_root).map_err(|error| (error.to_string(), "engine".to_owned()))?;
let workspace = tempfile::Builder::new() let workspace = tempfile::Builder::new()
.prefix("convertis-") .prefix("convertis-")
.tempdir_in(temp_root) .tempdir_in(temp_root)
.map_err(|error| (error.to_string(), "engine".to_owned()))?; .map_err(|error| (error.to_string(), "engine".to_owned()))?;
tracing::debug!(workspace = %workspace.path().display(), "created conversion workspace");
let mut current = input.to_path_buf(); let mut current = input.to_path_buf();
let mut kind = if input.is_dir() { let mut kind = if input.is_dir() {
ArtifactKind::Directory ArtifactKind::Directory
} else { } else {
ArtifactKind::File ArtifactKind::File
}; };
tracing::trace!(current = %current.display(), ?kind, "initialized current artifact");
for (index, step) in route.iter().enumerate() { for (index, step) in route.iter().enumerate() {
let metadata = step.plugin.metadata(); let metadata = step.plugin.metadata();
tracing::debug!(
step = index + 1,
total_steps = route.len(),
plugin = %metadata.id,
from = %step.conversion.from,
to = %step.conversion.to,
input_kind = ?step.conversion.input_kind,
output_kind = ?step.conversion.output_kind,
"preparing conversion step"
);
let mut plugin_options = BTreeMap::new(); let mut plugin_options = BTreeMap::new();
for option in &metadata.options { for option in &metadata.options {
if let Some(default) = &option.default { if let Some(default) = &option.default {
tracing::trace!(plugin = %metadata.id, option = %option.name, value_length = default.len(), "applying default plugin option");
plugin_options.insert(option.name.clone(), default.clone()); plugin_options.insert(option.name.clone(), default.clone());
} }
} }
for (key, value) in options { for (key, value) in options {
if let Some((plugin_id, option)) = key.split_once('.') { if let Some((plugin_id, option)) = key.split_once('.') {
if plugin_id == metadata.id { if plugin_id == metadata.id {
tracing::trace!(plugin = %metadata.id, key, option, value_length = value.len(), "applying qualified plugin option");
plugin_options.insert(option.to_owned(), value.clone()); plugin_options.insert(option.to_owned(), value.clone());
} else {
tracing::trace!(plugin = %metadata.id, key, "qualified option belongs to another route step");
} }
} else if metadata.options.iter().any(|option| option.name == *key) { } else if metadata.options.iter().any(|option| option.name == *key) {
tracing::trace!(plugin = %metadata.id, key, value_length = value.len(), "applying unqualified plugin option");
plugin_options.insert(key.clone(), value.clone()); plugin_options.insert(key.clone(), value.clone());
} }
} }
tracing::trace!(plugin = %metadata.id, options = ?plugin_options.keys().collect::<Vec<_>>(), "resolved conversion-step options");
let output = match step.conversion.output_kind { let output = match step.conversion.output_kind {
ArtifactKind::File => workspace.path().join(format!( ArtifactKind::File => workspace.path().join(format!(
"step-{index}.{}", "step-{index}.{}",
@@ -68,7 +94,9 @@ pub fn run_conversion(
)), )),
ArtifactKind::Directory => workspace.path().join(format!("step-{index}")), ArtifactKind::Directory => workspace.path().join(format!("step-{index}")),
}; };
tracing::trace!(plugin = %metadata.id, output = %output.display(), "allocated conversion-step output path");
if step.conversion.output_kind == ArtifactKind::Directory { if step.conversion.output_kind == ArtifactKind::Directory {
tracing::trace!(output = %output.display(), "creating directory artifact output");
fs::create_dir_all(&output) fs::create_dir_all(&output)
.map_err(|error| (error.to_string(), "engine".to_owned()))?; .map_err(|error| (error.to_string(), "engine".to_owned()))?;
} }
@@ -79,12 +107,22 @@ pub fn run_conversion(
to: step.conversion.to.clone(), to: step.conversion.to.clone(),
options: plugin_options, options: plugin_options,
}; };
step.plugin tracing::trace!(
.convert(&request) plugin = %metadata.id,
.map_err(|error| (error, step.plugin.metadata().id))?; input = %request.input.display(),
output = %request.output.display(),
"dispatching conversion request to plugin"
);
step.plugin.convert(&request).map_err(|error| {
tracing::debug!(plugin = %metadata.id, %error, "conversion step failed");
(error, metadata.id.clone())
})?;
tracing::trace!(plugin = %metadata.id, output_exists = output.exists(), "plugin returned conversion success");
current = output; current = output;
kind = step.conversion.output_kind; kind = step.conversion.output_kind;
tracing::debug!(step = index + 1, plugin = %metadata.id, artifact = %current.display(), ?kind, "conversion step completed");
} }
tracing::debug!(result = %current.display(), ?kind, "all conversion steps completed");
Ok(ConversionResult { Ok(ConversionResult {
path: current, path: current,
kind, kind,
@@ -93,45 +131,59 @@ pub fn run_conversion(
} }
fn file_extension(format: &str) -> &str { fn file_extension(format: &str) -> &str {
match format { let extension = match format {
"animated-gif" => "gif", "animated-gif" => "gif",
"animated-webp" => "webp", "animated-webp" => "webp",
"text" => "txt", "text" => "txt",
other => other, other => other,
} };
tracing::trace!(format, extension, "mapped format to workspace extension");
extension
} }
pub fn install_result(result: &ConversionResult, destination: &Path) -> io::Result<()> { pub fn install_result(result: &ConversionResult, destination: &Path) -> io::Result<()> {
let parent = destination.parent().unwrap_or_else(|| Path::new(".")); let parent = destination.parent().unwrap_or_else(|| Path::new("."));
tracing::debug!(source = %result.path.display(), destination = %destination.display(), ?result.kind, "installing result atomically");
tracing::trace!(parent = %parent.display(), "ensuring destination parent exists");
fs::create_dir_all(parent)?; fs::create_dir_all(parent)?;
match result.kind { match result.kind {
ArtifactKind::File => { ArtifactKind::File => {
tracing::trace!(parent = %parent.display(), "creating file staging area");
let staging = tempfile::NamedTempFile::new_in(parent)?; let staging = tempfile::NamedTempFile::new_in(parent)?;
tracing::trace!(staging = %staging.path().display(), "copying file result into staging area");
fs::copy(&result.path, staging.path())?; fs::copy(&result.path, staging.path())?;
tracing::trace!(destination = %destination.display(), "persisting staged file result");
staging.persist(destination).map_err(|error| error.error)?; staging.persist(destination).map_err(|error| error.error)?;
} }
ArtifactKind::Directory => { ArtifactKind::Directory => {
tracing::trace!(parent = %parent.display(), "creating directory staging area");
let staging = tempfile::Builder::new() let staging = tempfile::Builder::new()
.prefix(".convertis-output-") .prefix(".convertis-output-")
.tempdir_in(parent)?; .tempdir_in(parent)?;
let payload = staging.path().join("payload"); let payload = staging.path().join("payload");
copy_directory(&result.path, &payload)?; copy_directory(&result.path, &payload)?;
tracing::trace!(payload = %payload.display(), destination = %destination.display(), "renaming staged directory result");
fs::rename(payload, destination)?; fs::rename(payload, destination)?;
} }
} }
tracing::debug!(destination = %destination.display(), "result installation completed");
Ok(()) Ok(())
} }
fn copy_directory(source: &Path, destination: &Path) -> io::Result<()> { fn copy_directory(source: &Path, destination: &Path) -> io::Result<()> {
tracing::trace!(source = %source.display(), destination = %destination.display(), "copying directory");
fs::create_dir_all(destination)?; fs::create_dir_all(destination)?;
for entry in fs::read_dir(source)? { for entry in fs::read_dir(source)? {
let entry = entry?; let entry = entry?;
let target = destination.join(entry.file_name()); let target = destination.join(entry.file_name());
if entry.file_type()?.is_dir() { let file_type = entry.file_type()?;
tracing::trace!(source = %entry.path().display(), target = %target.display(), is_directory = file_type.is_dir(), "copying directory entry");
if file_type.is_dir() {
copy_directory(&entry.path(), &target)?; copy_directory(&entry.path(), &target)?;
} else { } else {
fs::copy(entry.path(), target)?; fs::copy(entry.path(), target)?;
} }
} }
tracing::trace!(source = %source.display(), destination = %destination.display(), "directory copy completed");
Ok(()) Ok(())
} }