feat: implement protocol v2 for plugin system and add comprehensive tracing support
This commit is contained in:
Generated
+19
-11
@@ -313,7 +313,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "convertis"
|
||||
version = "0.3.0"
|
||||
version = "0.3.0-dev"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"clap_mangen",
|
||||
@@ -321,6 +321,7 @@ dependencies = [
|
||||
"gif",
|
||||
"infer",
|
||||
"libloading",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
@@ -330,14 +331,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "convertis-ffmpeg-audio"
|
||||
version = "0.3.0"
|
||||
version = "0.3.0-dev"
|
||||
dependencies = [
|
||||
"convertis-plugin-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convertis-ffmpeg-frames-to-video"
|
||||
version = "0.3.0"
|
||||
version = "0.3.0-dev"
|
||||
dependencies = [
|
||||
"convertis-plugin-api",
|
||||
"serde",
|
||||
@@ -346,14 +347,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "convertis-ffmpeg-video"
|
||||
version = "0.3.0"
|
||||
version = "0.3.0-dev"
|
||||
dependencies = [
|
||||
"convertis-plugin-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convertis-ffmpeg-video-to-frames"
|
||||
version = "0.3.0"
|
||||
version = "0.3.0-dev"
|
||||
dependencies = [
|
||||
"convertis-plugin-api",
|
||||
"serde",
|
||||
@@ -362,14 +363,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "convertis-graphicsmagick"
|
||||
version = "0.3.0"
|
||||
version = "0.3.0-dev"
|
||||
dependencies = [
|
||||
"convertis-plugin-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convertis-html"
|
||||
version = "0.3.0"
|
||||
version = "0.3.0-dev"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"convertis-plugin-api",
|
||||
@@ -378,7 +379,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "convertis-image-ascii"
|
||||
version = "0.3.0"
|
||||
version = "0.3.0-dev"
|
||||
dependencies = [
|
||||
"convertis-plugin-api",
|
||||
"image",
|
||||
@@ -386,14 +387,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "convertis-imagemagick"
|
||||
version = "0.3.0"
|
||||
version = "0.3.0-dev"
|
||||
dependencies = [
|
||||
"convertis-plugin-api",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convertis-native-image"
|
||||
version = "0.3.0"
|
||||
version = "0.3.0-dev"
|
||||
dependencies = [
|
||||
"convertis-plugin-api",
|
||||
"image",
|
||||
@@ -401,9 +402,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "convertis-plugin-api"
|
||||
version = "0.3.0"
|
||||
version = "0.3.0-dev"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1135,6 +1137,12 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.228"
|
||||
|
||||
+2
-1
@@ -22,7 +22,7 @@ license = "GPL-3.0-only"
|
||||
repository = "https://git.ewenlau.net/ewenlau/convertis"
|
||||
|
||||
[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"
|
||||
image = "0.25.10"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -55,6 +55,7 @@ gif = "0.14"
|
||||
libloading = "0.8"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
semver = "1"
|
||||
tempfile.workspace = true
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = "0.3.23"
|
||||
|
||||
@@ -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`.
|
||||
|
||||
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
|
||||
|
||||
@@ -109,6 +111,14 @@ convertis --list-formats
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -8,3 +8,4 @@ repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -13,12 +13,25 @@
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
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 MANIFEST_SYMBOL: &[u8] = b"convertis_plugin_manifest_v1\0";
|
||||
pub const FACTORY_SYMBOL: &[u8] = b"convertis_plugin_create_v1\0";
|
||||
pub const MANIFEST_SYMBOL: &[u8] = b"convertis_plugin_manifest\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)]
|
||||
pub enum ArtifactKind {
|
||||
@@ -79,7 +92,7 @@ pub struct PluginMetadata {
|
||||
pub options: Vec<OptionSpec>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ConversionRequest {
|
||||
pub input: PathBuf,
|
||||
pub output: PathBuf,
|
||||
@@ -98,19 +111,77 @@ pub trait Plugin: Send + Sync {
|
||||
|
||||
pub type PluginFactory = unsafe fn() -> Box<dyn Plugin>;
|
||||
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_rules! export_plugin {
|
||||
($plugin:expr, $id:literal) => {
|
||||
#[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!(
|
||||
"{\"api_version\":1,\"engine_version\":\"",
|
||||
"{\"manifest_version\":1,\"protocol_versions\":[2],\"engine_requirement\":\">=",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
"\",\"plugin_id\":\"",
|
||||
$id,
|
||||
"\",\"rustc_version\":\"",
|
||||
env!("CONVERTIS_RUSTC_VERSION"),
|
||||
"\",\"plugin_version\":\"",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
"\",\"target\":\"",
|
||||
env!("CONVERTIS_TARGET"),
|
||||
"\"}\0"
|
||||
@@ -119,8 +190,79 @@ macro_rules! export_plugin {
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub fn convertis_plugin_create_v1() -> Box<dyn $crate::Plugin> {
|
||||
Box::new($plugin)
|
||||
pub extern "C" fn convertis_plugin_metadata_v2() -> *mut std::ffi::c_char {
|
||||
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
@@ -21,14 +21,14 @@ pub struct Args {
|
||||
#[arg(
|
||||
long,
|
||||
help = "List formats supported by installed plugins.",
|
||||
exclusive = true
|
||||
conflicts_with = "list_plugins"
|
||||
)]
|
||||
pub list_formats: bool,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
help = "List installed and known official plugins.",
|
||||
exclusive = true
|
||||
conflicts_with = "list_formats"
|
||||
)]
|
||||
pub list_plugins: bool,
|
||||
|
||||
|
||||
@@ -56,6 +56,11 @@ const MAGICK_IMAGES: &[&str] = &[
|
||||
];
|
||||
|
||||
fn all_pairs(formats: &[&str], scores: (u8, u8, u8)) -> Vec<Conversion> {
|
||||
tracing::trace!(
|
||||
format_count = formats.len(),
|
||||
?scores,
|
||||
"building all-pairs conversion catalog"
|
||||
);
|
||||
formats
|
||||
.iter()
|
||||
.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> {
|
||||
tracing::trace!(
|
||||
input_count = inputs.len(),
|
||||
output_count = outputs.len(),
|
||||
?scores,
|
||||
"building cross-pair conversion catalog"
|
||||
);
|
||||
inputs
|
||||
.iter()
|
||||
.flat_map(|from| {
|
||||
@@ -85,6 +96,12 @@ fn metadata(
|
||||
description: &str,
|
||||
conversions: Vec<Conversion>,
|
||||
) -> PluginMetadata {
|
||||
tracing::trace!(
|
||||
id,
|
||||
package,
|
||||
conversion_count = conversions.len(),
|
||||
"building official plugin metadata"
|
||||
);
|
||||
PluginMetadata {
|
||||
id: id.to_owned(),
|
||||
package: package.to_owned(),
|
||||
@@ -95,6 +112,7 @@ fn metadata(
|
||||
}
|
||||
|
||||
pub fn official_plugins() -> Vec<PluginMetadata> {
|
||||
tracing::trace!("building official plugin catalog");
|
||||
let mut plugins = vec![
|
||||
metadata(
|
||||
"ffmpeg-audio",
|
||||
@@ -195,13 +213,23 @@ pub fn official_plugins() -> Vec<PluginMetadata> {
|
||||
help: "Override frame rate; folders without metadata default to 30".into(),
|
||||
default: None,
|
||||
}];
|
||||
tracing::trace!(
|
||||
plugin_count = plugins.len(),
|
||||
"official plugin catalog built"
|
||||
);
|
||||
plugins
|
||||
}
|
||||
|
||||
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 mut edges: HashMap<&str, Vec<(usize, &str)>> = HashMap::new();
|
||||
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 {
|
||||
edges
|
||||
.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 visited = HashSet::from([from]);
|
||||
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 {
|
||||
let mut packages = Vec::new();
|
||||
for index in path {
|
||||
let package = plugins[index].package.clone();
|
||||
if !packages.contains(&package) {
|
||||
tracing::trace!(package, "adding package to recommendation");
|
||||
packages.push(package);
|
||||
}
|
||||
}
|
||||
tracing::debug!(?packages, "found package recommendation");
|
||||
return packages;
|
||||
}
|
||||
if let Some(next_edges) = edges.get(current) {
|
||||
for &(plugin, next) in next_edges {
|
||||
tracing::trace!(current, next, plugin = %plugins[plugin].id, "considering recommendation graph edge");
|
||||
if visited.insert(next) {
|
||||
let mut next_path = path.clone();
|
||||
next_path.push(plugin);
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
+114
-21
@@ -24,55 +24,78 @@ pub struct DetectedFormat {
|
||||
}
|
||||
|
||||
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(),
|
||||
"htm" => "html".to_owned(),
|
||||
"txt" | "ascii" => "text".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> {
|
||||
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)
|
||||
.and_then(|value| value.to_str())
|
||||
.map(normalized)
|
||||
})
|
||||
});
|
||||
tracing::trace!(?requested, "resolved requested format");
|
||||
requested
|
||||
}
|
||||
|
||||
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() {
|
||||
tracing::trace!(path = %path.display(), "identifying directory artifact");
|
||||
if explicit.is_some_and(|format| normalized(format) != "frames") {
|
||||
return Err("directory inputs currently support only the 'frames' format".to_owned());
|
||||
}
|
||||
let manifest = path.join(".convertis-frames.json");
|
||||
tracing::trace!(manifest = %manifest.display(), exists = manifest.exists(), "checking frame manifest");
|
||||
if manifest.exists() {
|
||||
let value: serde_json::Value = serde_json::from_slice(
|
||||
&fs::read(&manifest)
|
||||
.map_err(|error| format!("could not read {}: {error}", manifest.display()))?,
|
||||
)
|
||||
.map_err(|error| format!("invalid frame manifest: {error}"))?;
|
||||
if value
|
||||
tracing::trace!(manifest = %manifest.display(), "reading frame manifest");
|
||||
let bytes = fs::read(&manifest)
|
||||
.map_err(|error| format!("could not read {}: {error}", manifest.display()))?;
|
||||
tracing::trace!(manifest = %manifest.display(), byte_count = bytes.len(), "parsing frame manifest");
|
||||
let value: serde_json::Value = serde_json::from_slice(&bytes)
|
||||
.map_err(|error| format!("invalid frame manifest: {error}"))?;
|
||||
let schema_version = value
|
||||
.get("schema_version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
!= Some(1)
|
||||
{
|
||||
.and_then(serde_json::Value::as_u64);
|
||||
tracing::trace!(?schema_version, "read frame manifest schema version");
|
||||
if schema_version != Some(1) {
|
||||
return Err("unsupported frame manifest schema".to_owned());
|
||||
}
|
||||
} else {
|
||||
tracing::trace!(path = %path.display(), "scanning directory for recognizable frames");
|
||||
let mut detected = None;
|
||||
for entry in fs::read_dir(path)
|
||||
.map_err(|error| error.to_string())?
|
||||
.flatten()
|
||||
{
|
||||
for entry in fs::read_dir(path).map_err(|error| error.to_string())? {
|
||||
let entry = match entry {
|
||||
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() {
|
||||
tracing::trace!(entry = %entry.path().display(), "skipping non-file frame-directory entry");
|
||||
continue;
|
||||
}
|
||||
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 {
|
||||
tracing::trace!(entry = %entry.path().display(), "frame candidate was not recognizable");
|
||||
continue;
|
||||
};
|
||||
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;
|
||||
}
|
||||
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(),
|
||||
);
|
||||
}
|
||||
tracing::trace!(entry = %entry.path().display(), format = %format.format, "accepted frame candidate");
|
||||
detected = Some(format.format);
|
||||
}
|
||||
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 {
|
||||
format: "frames".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,
|
||||
});
|
||||
}
|
||||
tracing::trace!(path = %path.display(), "reading file for content identification");
|
||||
let bytes =
|
||||
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 {
|
||||
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)
|
||||
.ok_or_else(|| format!("could not identify {} from its contents", path.display()))
|
||||
let identified = identify_bytes(&bytes)
|
||||
.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> {
|
||||
tracing::trace!(
|
||||
byte_count = bytes.len(),
|
||||
"starting content signature identification"
|
||||
);
|
||||
if bytes.starts_with(&[0, 0, 1, 0]) {
|
||||
tracing::trace!(rule = "ico-header", "matched content signature");
|
||||
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") {
|
||||
tracing::trace!(rule = "html-prefix", "matched content signature");
|
||||
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 ") {
|
||||
tracing::trace!(rule = "m4a-brand", "matched content signature");
|
||||
return Some(from_format("m4a", bytes));
|
||||
}
|
||||
tracing::trace!(
|
||||
rule = "m4a-brand",
|
||||
matched = false,
|
||||
"checked content signature"
|
||||
);
|
||||
if bytes.starts_with(b"OggS") {
|
||||
tracing::trace!(rule = "ogg-header", "matched content signature");
|
||||
if bytes.windows(8).any(|window| window == b"OpusHead") {
|
||||
tracing::trace!(rule = "opus-header", "matched Ogg subtype signature");
|
||||
return Some(from_format("opus", bytes));
|
||||
}
|
||||
if bytes.windows(6).any(|window| window == b"theora") {
|
||||
tracing::trace!(rule = "theora-header", "matched Ogg subtype signature");
|
||||
return Some(from_format("ogv", bytes));
|
||||
}
|
||||
tracing::trace!("no Ogg subtype signature matched; using generic Ogg format");
|
||||
return Some(from_format("ogg", bytes));
|
||||
}
|
||||
tracing::trace!(
|
||||
rule = "ogg-header",
|
||||
matched = false,
|
||||
"checked content signature"
|
||||
);
|
||||
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()?;
|
||||
tracing::trace!(
|
||||
utf8 = true,
|
||||
character_count = text.chars().count(),
|
||||
"content is valid UTF-8"
|
||||
);
|
||||
if !text.contains('\0') {
|
||||
tracing::trace!(rule = "utf8-without-nul", "classified content as text");
|
||||
return Some(from_format("text", bytes));
|
||||
}
|
||||
tracing::trace!("content did not match any supported identification rule");
|
||||
None
|
||||
}
|
||||
|
||||
fn from_format(format: &str, bytes: &[u8]) -> DetectedFormat {
|
||||
tracing::trace!(
|
||||
format,
|
||||
byte_count = bytes.len(),
|
||||
"deriving media details from format"
|
||||
);
|
||||
let animation = match format {
|
||||
"webp" => bytes.len() > 20 && &bytes[12..16] == b"VP8X" && bytes[20] & 0x02 != 0,
|
||||
"png" | "apng" => bytes.windows(4).any(|window| window == b"acTL"),
|
||||
"gif" => gif_is_animated(bytes),
|
||||
_ => false,
|
||||
};
|
||||
tracing::trace!(format, animation, "determined animation state");
|
||||
let media_kind = if animation {
|
||||
MediaKind::Animation
|
||||
} else if matches!(
|
||||
@@ -194,6 +277,7 @@ fn from_format(format: &str, bytes: &[u8]) -> DetectedFormat {
|
||||
} else {
|
||||
format.to_owned()
|
||||
};
|
||||
tracing::trace!(format, mime, ?media_kind, "derived media details");
|
||||
DetectedFormat {
|
||||
format,
|
||||
mime: mime.to_owned(),
|
||||
@@ -203,18 +287,27 @@ fn from_format(format: &str, bytes: &[u8]) -> DetectedFormat {
|
||||
}
|
||||
|
||||
fn gif_is_animated(bytes: &[u8]) -> bool {
|
||||
tracing::trace!(byte_count = bytes.len(), "checking GIF animation state");
|
||||
let mut options = gif::DecodeOptions::new();
|
||||
options.set_color_output(gif::ColorOutput::Indexed);
|
||||
let Ok(mut decoder) = options.read_info(std::io::Cursor::new(bytes)) else {
|
||||
tracing::trace!("GIF decoder rejected content while checking animation");
|
||||
return false;
|
||||
};
|
||||
let mut frames = 0;
|
||||
while let Ok(Some(_)) = decoder.read_next_frame() {
|
||||
frames += 1;
|
||||
tracing::trace!(frames, "decoded GIF frame while checking animation");
|
||||
if frames > 1 {
|
||||
tracing::trace!(frames, animated = true, "GIF has multiple frames");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
tracing::trace!(
|
||||
frames,
|
||||
animated = false,
|
||||
"GIF has fewer than two decodable frames"
|
||||
);
|
||||
false
|
||||
}
|
||||
|
||||
|
||||
+110
-7
@@ -33,38 +33,70 @@ use std::{
|
||||
const VERSION: &str = include_str!(concat!(env!("OUT_DIR"), "/version.txt"));
|
||||
|
||||
fn fail(message: impl std::fmt::Display) -> ! {
|
||||
tracing::trace!("entering fatal error path");
|
||||
tracing::error!("{message}");
|
||||
tracing::trace!(exit_code = 1, "terminating process");
|
||||
process::exit(1)
|
||||
}
|
||||
|
||||
fn parse_options(values: &[String]) -> Result<BTreeMap<String, String>, String> {
|
||||
tracing::trace!(value_count = values.len(), "parsing plugin options");
|
||||
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
|
||||
.split_once('=')
|
||||
.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() {
|
||||
return Err(format!("invalid or duplicate option '{key}'"));
|
||||
}
|
||||
}
|
||||
tracing::trace!(option_count = options.len(), "plugin options parsed");
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
if args.yes {
|
||||
tracing::trace!("overwrite accepted by --yes");
|
||||
return true;
|
||||
}
|
||||
if args.no || args.quiet || !io::stdin().is_terminal() {
|
||||
tracing::trace!("overwrite declined without prompting");
|
||||
return false;
|
||||
}
|
||||
tracing::trace!("prompting for overwrite confirmation");
|
||||
print!("'{}' exists. Replace it? [y/N]: ", output.display());
|
||||
let _ = io::stdout().flush();
|
||||
let mut answer = String::new();
|
||||
io::stdin().read_line(&mut answer).is_ok()
|
||||
&& matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes")
|
||||
let read = io::stdin().read_line(&mut answer);
|
||||
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() {
|
||||
@@ -76,15 +108,30 @@ fn main() {
|
||||
tracing::Level::from(args.verbose).into()
|
||||
};
|
||||
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);
|
||||
tracing::trace!(
|
||||
plugin_count = registry.plugins.len(),
|
||||
diagnostic_count = registry.diagnostics.len(),
|
||||
"plugin registry is ready"
|
||||
);
|
||||
for diagnostic in ®istry.diagnostics {
|
||||
tracing::warn!("{diagnostic}");
|
||||
}
|
||||
|
||||
if args.list_plugins {
|
||||
tracing::debug!("listing official plugins");
|
||||
println!("Official plugins:");
|
||||
for metadata in catalog::official_plugins() {
|
||||
tracing::trace!(plugin = %metadata.id, package = %metadata.package, "checking official plugin state");
|
||||
let installed = registry
|
||||
.plugins
|
||||
.iter()
|
||||
@@ -94,17 +141,23 @@ fn main() {
|
||||
Some(Err(error)) => format!("unavailable: {error}"),
|
||||
None => "not installed".to_owned(),
|
||||
};
|
||||
tracing::trace!(plugin = %metadata.id, state, "resolved official plugin state");
|
||||
println!(
|
||||
" {:32} {:28} {}",
|
||||
metadata.package, state, metadata.description
|
||||
);
|
||||
}
|
||||
tracing::debug!("finished listing official plugins");
|
||||
return;
|
||||
}
|
||||
if args.list_formats {
|
||||
tracing::debug!("collecting formats from installed plugins");
|
||||
let mut formats = HashSet::new();
|
||||
for plugin in ®istry.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.to);
|
||||
}
|
||||
@@ -116,6 +169,7 @@ fn main() {
|
||||
} else {
|
||||
println!("{}", formats.join("\n"));
|
||||
}
|
||||
tracing::debug!(format_count = formats.len(), "finished listing formats");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -123,30 +177,52 @@ fn main() {
|
||||
.input_path
|
||||
.as_deref()
|
||||
.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())
|
||||
.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 =
|
||||
identifier::requested_format(args.output_path.as_deref(), args.to_format.as_deref())
|
||||
.unwrap_or_else(|| {
|
||||
fail("a target is required; provide an output extension or --to FORMAT")
|
||||
});
|
||||
tracing::trace!(target, "resolved initial target format");
|
||||
if matches!(
|
||||
detected.media_kind,
|
||||
convertis_plugin_api::MediaKind::Video
|
||||
| convertis_plugin_api::MediaKind::Animation
|
||||
| convertis_plugin_api::MediaKind::Frames
|
||||
) {
|
||||
let original_target = target.clone();
|
||||
target = match target.as_str() {
|
||||
"gif" => "animated-gif".to_owned(),
|
||||
"webp" => "animated-webp".to_owned(),
|
||||
_ => 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 mut banned = Vec::new();
|
||||
|
||||
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(
|
||||
®istry.plugins,
|
||||
&detected.format,
|
||||
@@ -154,7 +230,9 @@ fn main() {
|
||||
&args.priority,
|
||||
&banned,
|
||||
) else {
|
||||
tracing::debug!("no installed conversion route was found");
|
||||
let packages = catalog::recommend_packages(&detected.format, &target);
|
||||
tracing::trace!(?packages, "resolved package recommendations");
|
||||
if packages.is_empty() {
|
||||
fail(format!(
|
||||
"no conversion path from {} to {} is known",
|
||||
@@ -189,7 +267,9 @@ fn main() {
|
||||
packages.join(" ")
|
||||
));
|
||||
};
|
||||
tracing::debug!(step_count = route.len(), "selected conversion route");
|
||||
for key in options.keys() {
|
||||
tracing::trace!(key, "validating option against selected route");
|
||||
let recognized = route.iter().any(|step| {
|
||||
let metadata = step.plugin.metadata();
|
||||
let option_name = key
|
||||
@@ -205,37 +285,55 @@ fn main() {
|
||||
"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();
|
||||
tracing::info!(route = %route_names.join(" -> "), "conversion route selected");
|
||||
if args.test {
|
||||
tracing::debug!("test mode requested; skipping conversion");
|
||||
println!("{}", route_names.join(" -> "));
|
||||
return;
|
||||
}
|
||||
tracing::debug!("executing conversion route");
|
||||
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)) => {
|
||||
tracing::warn!("plugin {plugin} failed: {error}; trying another route");
|
||||
banned.push(plugin);
|
||||
tracing::trace!(?banned, "updated failed-plugin exclusion list");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(output) = &args.output_path {
|
||||
tracing::debug!(output = %output.display(), "installing conversion result");
|
||||
if !should_overwrite(&args, output) {
|
||||
fail("output was not replaced");
|
||||
}
|
||||
if output.exists() {
|
||||
if output.is_dir() {
|
||||
tracing::trace!(output = %output.display(), "removing existing output directory");
|
||||
fs::remove_dir_all(output).unwrap_or_else(|error| fail(error));
|
||||
} else {
|
||||
tracing::trace!(output = %output.display(), "removing existing output file");
|
||||
fs::remove_file(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 {
|
||||
fail("directory output requires an output path");
|
||||
} else {
|
||||
tracing::trace!(path = %result.path.display(), "reading result for standard output");
|
||||
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 {
|
||||
tracing::warn!(
|
||||
"writing conversion bytes to the terminal; use -c to suppress this warning"
|
||||
@@ -244,5 +342,10 @@ fn main() {
|
||||
io::stdout()
|
||||
.write_all(&bytes)
|
||||
.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
@@ -22,7 +22,12 @@ pub struct RouteStep<'a> {
|
||||
}
|
||||
|
||||
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()
|
||||
.map(|criterion| {
|
||||
route
|
||||
@@ -35,7 +40,9 @@ fn route_score(route: &[RouteStep<'_>], priority: &str) -> Vec<u32> {
|
||||
})
|
||||
.sum()
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
tracing::trace!(?score, "calculated conversion route score");
|
||||
score
|
||||
}
|
||||
|
||||
pub fn find_best_path<'a>(
|
||||
@@ -45,13 +52,29 @@ pub fn find_best_path<'a>(
|
||||
priority: &str,
|
||||
banned: &[String],
|
||||
) -> 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();
|
||||
for plugin in plugins {
|
||||
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;
|
||||
}
|
||||
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 {
|
||||
tracing::trace!(plugin = %metadata.id, from = %conversion.from, to = %conversion.to, "adding conversion graph edge");
|
||||
edges
|
||||
.entry(conversion.from.clone())
|
||||
.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 depths = HashMap::from([(from.to_owned(), 0usize)]);
|
||||
let mut solutions = Vec::new();
|
||||
let mut minimum = None;
|
||||
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) {
|
||||
tracing::trace!(
|
||||
current,
|
||||
depth = route.len(),
|
||||
"pruned state deeper than shortest solution"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if current == to && !route.is_empty() {
|
||||
minimum = Some(route.len());
|
||||
tracing::trace!(
|
||||
depth = route.len(),
|
||||
solution_count = solutions.len() + 1,
|
||||
"found shortest conversion route candidate"
|
||||
);
|
||||
solutions.push(route);
|
||||
continue;
|
||||
}
|
||||
if let Some(next_steps) = edges.get(¤t) {
|
||||
tracing::trace!(
|
||||
current,
|
||||
next_step_count = next_steps.len(),
|
||||
"expanding conversion graph state"
|
||||
);
|
||||
for step in next_steps {
|
||||
let next_depth = route.len() + 1;
|
||||
if next_depth
|
||||
<= depths
|
||||
.get(&step.conversion.to)
|
||||
.copied()
|
||||
.unwrap_or(usize::MAX)
|
||||
{
|
||||
let previous_depth = depths
|
||||
.get(&step.conversion.to)
|
||||
.copied()
|
||||
.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);
|
||||
let mut next_route = route.clone();
|
||||
next_route.push(step.clone());
|
||||
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()
|
||||
.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
@@ -3,24 +3,37 @@
|
||||
// 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 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 serde::Deserialize;
|
||||
use std::{collections::HashSet, ffi::CStr, fs, path::PathBuf};
|
||||
use semver::{Version, VersionReq};
|
||||
use serde::{Deserialize, de::DeserializeOwned};
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
ffi::{CStr, CString, c_char},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AbiManifest {
|
||||
const SUPPORTED_PROTOCOLS: &[u32] = &[2];
|
||||
|
||||
#[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,
|
||||
engine_version: String,
|
||||
plugin_id: String,
|
||||
@@ -28,6 +41,67 @@ struct AbiManifest {
|
||||
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 {
|
||||
// Plugins must be dropped before their backing libraries.
|
||||
pub plugins: Vec<Box<dyn Plugin>>,
|
||||
@@ -37,19 +111,38 @@ pub struct PluginRegistry {
|
||||
|
||||
impl PluginRegistry {
|
||||
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();
|
||||
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") {
|
||||
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 let Ok(executable) = std::env::current_exe()
|
||||
&& let Some(parent) = executable.parent()
|
||||
{
|
||||
directories.push(parent.to_path_buf());
|
||||
directories.push(parent.join("plugins"));
|
||||
match std::env::current_exe() {
|
||||
Ok(executable) => {
|
||||
tracing::trace!(executable = %executable.display(), "resolved current executable");
|
||||
if let Some(parent) = executable.parent() {
|
||||
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") {
|
||||
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"));
|
||||
}
|
||||
@@ -63,94 +156,307 @@ impl PluginRegistry {
|
||||
let mut seen_ids = HashSet::new();
|
||||
|
||||
for directory in directories {
|
||||
let Ok(directory) = directory.canonicalize() else {
|
||||
continue;
|
||||
tracing::trace!(directory = %directory.display(), "examining plugin directory");
|
||||
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()) {
|
||||
tracing::trace!(directory = %directory.display(), "skipping duplicate plugin directory");
|
||||
continue;
|
||||
}
|
||||
let Ok(entries) = fs::read_dir(&directory) else {
|
||||
continue;
|
||||
let entries = match fs::read_dir(&directory) {
|
||||
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();
|
||||
tracing::trace!(directory = %directory.display(), entry_count = paths.len(), "sorted plugin directory entries");
|
||||
for path in paths {
|
||||
let is_plugin = path.extension().and_then(|value| value.to_str()) == Some("so")
|
||||
&& path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.is_some_and(|name| name.starts_with("libconvertis_"));
|
||||
tracing::trace!(path = %path.display(), is_plugin, "classified plugin candidate");
|
||||
if !is_plugin {
|
||||
continue;
|
||||
}
|
||||
tracing::debug!(path = %path.display(), "loading plugin candidate");
|
||||
match unsafe { Self::load_one(&path) } {
|
||||
Ok((library, plugin, id)) => {
|
||||
Ok((library, plugin, id, protocol)) => {
|
||||
if seen_ids.insert(id.clone()) {
|
||||
tracing::info!(plugin = %id, protocol, path = %path.display(), "loaded plugin");
|
||||
registry.plugins.push(plugin);
|
||||
registry.libraries.push(library);
|
||||
} else {
|
||||
tracing::debug!(plugin = %id, path = %path.display(), "ignored duplicate plugin");
|
||||
registry.diagnostics.push(format!(
|
||||
"ignored duplicate plugin '{id}' from {}",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
Err(error) => registry
|
||||
.diagnostics
|
||||
.push(format!("could not load {}: {error}", path.display())),
|
||||
Err(error) => {
|
||||
tracing::debug!(path = %path.display(), %error, "plugin candidate was rejected");
|
||||
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
|
||||
}
|
||||
|
||||
unsafe fn load_one(
|
||||
path: &std::path::Path,
|
||||
) -> Result<(Library, Box<dyn Plugin>, String), String> {
|
||||
unsafe fn load_one(path: &Path) -> Result<(Library, Box<dyn Plugin>, String, u32), String> {
|
||||
tracing::trace!(path = %path.display(), "opening dynamic library");
|
||||
let library = unsafe { Library::new(path) }.map_err(|error| error.to_string())?;
|
||||
let manifest_fn = unsafe { library.get::<PluginManifest>(MANIFEST_SYMBOL) }
|
||||
.map_err(|error| format!("missing ABI manifest: {error}"))?;
|
||||
let pointer = unsafe { manifest_fn() };
|
||||
if pointer.is_null() {
|
||||
return Err("ABI manifest was null".to_owned());
|
||||
tracing::trace!(path = %path.display(), "dynamic library opened");
|
||||
|
||||
if let Ok(manifest_fn) = unsafe { library.get::<PluginManifest>(MANIFEST_SYMBOL) } {
|
||||
tracing::trace!(path = %path.display(), "found stable plugin manifest entry point");
|
||||
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()
|
||||
.map_err(|error| format!("invalid ABI manifest string: {error}"))?;
|
||||
let manifest: AbiManifest =
|
||||
serde_json::from_str(json).map_err(|error| format!("invalid ABI manifest: {error}"))?;
|
||||
if manifest.api_version != convertis_plugin_api::API_VERSION {
|
||||
return Err(format!(
|
||||
"plugin API {} is not supported",
|
||||
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}"))?;
|
||||
|
||||
tracing::trace!(path = %path.display(), "stable manifest absent; trying legacy protocol v1 adapter");
|
||||
let manifest_fn = unsafe { library.get::<PluginManifest>(LEGACY_MANIFEST_SYMBOL_V1) }
|
||||
.map_err(|error| format!("missing stable or legacy ABI manifest: {error}"))?;
|
||||
let manifest: LegacyManifestV1 = unsafe { read_manifest(*manifest_fn) }?;
|
||||
validate_legacy_manifest(&manifest)?;
|
||||
let factory = unsafe { library.get::<PluginFactory>(LEGACY_FACTORY_SYMBOL_V1) }
|
||||
.map_err(|error| format!("missing legacy Rust plugin factory: {error}"))?;
|
||||
let plugin = unsafe { factory() };
|
||||
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
@@ -32,35 +32,61 @@ pub fn run_conversion(
|
||||
temp_root: &Path,
|
||||
options: &BTreeMap<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()))?;
|
||||
let workspace = tempfile::Builder::new()
|
||||
.prefix("convertis-")
|
||||
.tempdir_in(temp_root)
|
||||
.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 kind = if input.is_dir() {
|
||||
ArtifactKind::Directory
|
||||
} else {
|
||||
ArtifactKind::File
|
||||
};
|
||||
tracing::trace!(current = %current.display(), ?kind, "initialized current artifact");
|
||||
|
||||
for (index, step) in route.iter().enumerate() {
|
||||
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();
|
||||
for option in &metadata.options {
|
||||
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());
|
||||
}
|
||||
}
|
||||
for (key, value) in options {
|
||||
if let Some((plugin_id, option)) = key.split_once('.') {
|
||||
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());
|
||||
} else {
|
||||
tracing::trace!(plugin = %metadata.id, key, "qualified option belongs to another route step");
|
||||
}
|
||||
} 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());
|
||||
}
|
||||
}
|
||||
tracing::trace!(plugin = %metadata.id, options = ?plugin_options.keys().collect::<Vec<_>>(), "resolved conversion-step options");
|
||||
let output = match step.conversion.output_kind {
|
||||
ArtifactKind::File => workspace.path().join(format!(
|
||||
"step-{index}.{}",
|
||||
@@ -68,7 +94,9 @@ pub fn run_conversion(
|
||||
)),
|
||||
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 {
|
||||
tracing::trace!(output = %output.display(), "creating directory artifact output");
|
||||
fs::create_dir_all(&output)
|
||||
.map_err(|error| (error.to_string(), "engine".to_owned()))?;
|
||||
}
|
||||
@@ -79,12 +107,22 @@ pub fn run_conversion(
|
||||
to: step.conversion.to.clone(),
|
||||
options: plugin_options,
|
||||
};
|
||||
step.plugin
|
||||
.convert(&request)
|
||||
.map_err(|error| (error, step.plugin.metadata().id))?;
|
||||
tracing::trace!(
|
||||
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;
|
||||
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 {
|
||||
path: current,
|
||||
kind,
|
||||
@@ -93,45 +131,59 @@ pub fn run_conversion(
|
||||
}
|
||||
|
||||
fn file_extension(format: &str) -> &str {
|
||||
match format {
|
||||
let extension = match format {
|
||||
"animated-gif" => "gif",
|
||||
"animated-webp" => "webp",
|
||||
"text" => "txt",
|
||||
other => other,
|
||||
}
|
||||
};
|
||||
tracing::trace!(format, extension, "mapped format to workspace extension");
|
||||
extension
|
||||
}
|
||||
|
||||
pub fn install_result(result: &ConversionResult, destination: &Path) -> io::Result<()> {
|
||||
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)?;
|
||||
match result.kind {
|
||||
ArtifactKind::File => {
|
||||
tracing::trace!(parent = %parent.display(), "creating file staging area");
|
||||
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())?;
|
||||
tracing::trace!(destination = %destination.display(), "persisting staged file result");
|
||||
staging.persist(destination).map_err(|error| error.error)?;
|
||||
}
|
||||
ArtifactKind::Directory => {
|
||||
tracing::trace!(parent = %parent.display(), "creating directory staging area");
|
||||
let staging = tempfile::Builder::new()
|
||||
.prefix(".convertis-output-")
|
||||
.tempdir_in(parent)?;
|
||||
let payload = staging.path().join("payload");
|
||||
copy_directory(&result.path, &payload)?;
|
||||
tracing::trace!(payload = %payload.display(), destination = %destination.display(), "renaming staged directory result");
|
||||
fs::rename(payload, destination)?;
|
||||
}
|
||||
}
|
||||
tracing::debug!(destination = %destination.display(), "result installation completed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_directory(source: &Path, destination: &Path) -> io::Result<()> {
|
||||
tracing::trace!(source = %source.display(), destination = %destination.display(), "copying directory");
|
||||
fs::create_dir_all(destination)?;
|
||||
for entry in fs::read_dir(source)? {
|
||||
let entry = entry?;
|
||||
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)?;
|
||||
} else {
|
||||
fs::copy(entry.path(), target)?;
|
||||
}
|
||||
}
|
||||
tracing::trace!(source = %source.display(), destination = %destination.display(), "directory copy completed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user