Skip to content

Commit a4bdcbc

Browse files
committed
cli(archive): compare archives via an unpacker/virtualizer plugin
Add 'archive --unpacker PLUGIN_ID': discover the named unpacker/folder_virtualizer plugin, run its unpack_folder operation on each archive to get a VirtualNode tree, and compare the two trees via compare_virtual_trees — instead of the built-in tar/unzip extractor. Useful for archive formats the built-in cannot read. Reports a summary (text/JSON) and exits 0 (equal) / 1 (differ) / 2 (error). The plugin must declare the unpacker or folder_virtualizer class. This completes the discovery -> invoke -> compare wiring for Phase 6's folder-virtualizer routing; automatic extension/MIME fallback to a plugin remains. Test installs a virtualizer fixture (one-file tree hashed from content) and asserts equal archives exit 0, differing archives exit 1 with JSON detail, and an unknown plugin id exits 2.
1 parent e062795 commit a4bdcbc

2 files changed

Lines changed: 191 additions & 12 deletions

File tree

crates/linsync-cli/src/main.rs

Lines changed: 115 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ use linsync_core::{
1818
ThreeWayConflict, ThreeWayMergeState, active_sandbox_status, assess_operation_risks,
1919
builtin_profiles, builtin_text_regex_rule_sets, clear_plugin_option, compare_binary_files,
2020
compare_folders, compare_table_files, compare_text, compare_text_files,
21-
compare_text_files_with_prediffer_chain, discover_installed_plugins, find_builtin,
22-
is_likely_binary, load_plugin_enabled_map, load_plugin_options, merge_three_way,
21+
compare_text_files_with_prediffer_chain, compare_virtual_trees, discover_installed_plugins,
22+
find_builtin, is_likely_binary, load_plugin_enabled_map, load_plugin_options, merge_three_way,
2323
parse_conflict_markers, plan_folder_operation, probe_plugin, resolve_enabled_prediffers,
24-
set_plugin_enabled, set_plugin_option,
24+
run_unpack_folder_plugin, set_plugin_enabled, set_plugin_option,
2525
};
2626

2727
fn main() -> ExitCode {
@@ -1265,23 +1265,46 @@ fn archive_command(args: &[String]) -> Result<ExitCode, String> {
12651265
let mut paths = Vec::new();
12661266
let mut keep_temp = false;
12671267
let mut json = false;
1268-
for arg in args {
1269-
match arg.as_str() {
1270-
"--keep-temp" => keep_temp = true,
1271-
"--json" => json = true,
1268+
let mut unpacker = None;
1269+
let mut index = 0;
1270+
while index < args.len() {
1271+
match args[index].as_str() {
1272+
"--keep-temp" => {
1273+
keep_temp = true;
1274+
index += 1;
1275+
}
1276+
"--json" => {
1277+
json = true;
1278+
index += 1;
1279+
}
1280+
"--unpacker" => {
1281+
unpacker = Some(
1282+
args.get(index + 1)
1283+
.ok_or("--unpacker requires a plugin id")?
1284+
.clone(),
1285+
);
1286+
index += 2;
1287+
}
12721288
value if value.starts_with("--") => {
12731289
return Err(format!("unknown archive flag '{value}'"));
12741290
}
1275-
value => paths.push(value.to_owned()),
1291+
value => {
1292+
paths.push(value.to_owned());
1293+
index += 1;
1294+
}
12761295
}
12771296
}
12781297
if paths.len() != 2 {
12791298
return Err(
1280-
"usage: linsync-cli archive [--keep-temp] [--json] LEFT.{zip|tar|tgz|...} RIGHT.{...}"
1299+
"usage: linsync-cli archive [--keep-temp] [--json] [--unpacker PLUGIN_ID] LEFT.{zip|tar|tgz|...} RIGHT.{...}"
12811300
.to_owned(),
12821301
);
12831302
}
12841303

1304+
if let Some(id) = unpacker {
1305+
return archive_compare_via_plugin(&id, &paths[0], &paths[1], json);
1306+
}
1307+
12851308
let cache_root = AppPaths::from_env().comparison_cache_dir();
12861309
fs::create_dir_all(&cache_root).map_err(|err| format!("cannot prepare cache dir: {err}"))?;
12871310

@@ -1334,6 +1357,86 @@ fn archive_command(args: &[String]) -> Result<ExitCode, String> {
13341357
Ok(code)
13351358
}
13361359

1360+
/// Compare two archives by routing each through a folder-virtualizer / unpacker
1361+
/// plugin (`unpack_folder`) and comparing the resulting virtual trees, instead
1362+
/// of the built-in extractor. Useful for formats the built-in cannot read.
1363+
fn archive_compare_via_plugin(
1364+
id: &str,
1365+
left_archive: &str,
1366+
right_archive: &str,
1367+
json: bool,
1368+
) -> Result<ExitCode, String> {
1369+
let paths = AppPaths::from_env();
1370+
let discovery = discover_installed_plugins(&paths);
1371+
let plugin = discovery
1372+
.plugins
1373+
.iter()
1374+
.find(|p| p.manifest.id == id)
1375+
.ok_or_else(|| format!("no installed plugin with id '{id}'"))?;
1376+
if !plugin.manifest.classes.iter().any(|c| {
1377+
matches!(
1378+
c,
1379+
linsync_core::PluginClass::Unpacker | linsync_core::PluginClass::FolderVirtualizer
1380+
)
1381+
}) {
1382+
return Err(format!(
1383+
"plugin '{id}' does not declare the unpacker or folder_virtualizer class"
1384+
));
1385+
}
1386+
1387+
let options = PluginExecutionOptions::default();
1388+
let unpack = |archive: &str, side: &str| -> Result<Vec<linsync_core::VirtualNode>, String> {
1389+
let response = run_unpack_folder_plugin(&plugin.root, &plugin.manifest, archive, &options)
1390+
.map_err(|err| format!("{side} unpack failed: {err}"))?;
1391+
if !response.ok {
1392+
return Err(format!(
1393+
"{side} unpack failed: {}",
1394+
response
1395+
.error
1396+
.unwrap_or_else(|| "plugin reported failure".to_owned())
1397+
));
1398+
}
1399+
Ok(response.tree)
1400+
};
1401+
let left_tree = unpack(left_archive, "left")?;
1402+
let right_tree = unpack(right_archive, "right")?;
1403+
let result = compare_virtual_trees(&left_tree, &right_tree);
1404+
let summary = &result.summary;
1405+
1406+
if json {
1407+
let body = serde_json::json!({
1408+
"left": { "archive": left_archive, "unpacker": id, "entries": left_tree.len() },
1409+
"right": { "archive": right_archive, "unpacker": id, "entries": right_tree.len() },
1410+
"equal": result.is_equal(),
1411+
"summary": {
1412+
"compared": summary.compared_count,
1413+
"identical": summary.identical_count,
1414+
"different": summary.different_count,
1415+
"one_sided": summary.one_sided_count,
1416+
"left_only": summary.left_only_count,
1417+
"right_only": summary.right_only_count,
1418+
},
1419+
});
1420+
println!("{body}");
1421+
} else {
1422+
println!(
1423+
"unpacker={id} compared={} identical={} different={} one_sided={} left_only={} right_only={}",
1424+
summary.compared_count,
1425+
summary.identical_count,
1426+
summary.different_count,
1427+
summary.one_sided_count,
1428+
summary.left_only_count,
1429+
summary.right_only_count,
1430+
);
1431+
}
1432+
1433+
Ok(if result.is_equal() {
1434+
ExitCode::SUCCESS
1435+
} else {
1436+
ExitCode::from(1)
1437+
})
1438+
}
1439+
13371440
struct ExtractedArchive {
13381441
path: PathBuf,
13391442
temp_root: PathBuf,
@@ -6060,8 +6163,8 @@ linsync-cli \- command-line file and folder comparison tools
60606163
provides scriptable access to LinSync comparison primitives.
60616164
.SH COMMANDS
60626165
.TP
6063-
.B archive [--keep-temp] [--json] LEFT RIGHT
6064-
Compare two archive files by extracting them (via tar / unzip subprocesses) and running a folder compare on the extracted trees. Supported extensions: .zip, .jar, .war, .apk, .ipa, .tar, .tgz, .tar.gz, .tbz2, .tar.bz2, .txz, .tar.xz, .tzst, .tar.zst.
6166+
.B archive [--keep-temp] [--json] [--unpacker PLUGIN_ID] LEFT RIGHT
6167+
Compare two archive files by extracting them (via tar / unzip subprocesses) and running a folder compare on the extracted trees. Supported extensions: .zip, .jar, .war, .apk, .ipa, .tar, .tgz, .tar.gz, .tbz2, .tar.bz2, .txz, .tar.xz, .tzst, .tar.zst. --unpacker PLUGIN_ID instead routes both archives through an installed unpacker / folder_virtualizer plugin (its unpack_folder operation) and compares the resulting virtual trees by SHA-256/size — useful for formats the built-in extractor cannot read.
60656168
.TP
60666169
.B cache clear [--scope webcompare]
60676170
Clear LinSync cache directories. Currently the only supported scope is webcompare (the webpage compare HTTP fetch cache under $XDG_CACHE_HOME/linsync/webcompare).
@@ -6154,7 +6257,7 @@ fn print_help() {
61546257
linsync-cli {}
61556258
61566259
USAGE:
6157-
linsync-cli archive [--keep-temp] [--json] LEFT RIGHT
6260+
linsync-cli archive [--keep-temp] [--json] [--unpacker PLUGIN_ID] LEFT RIGHT
61586261
linsync-cli cache clear [--scope webcompare]
61596262
linsync-cli compare [--profile NAME-OR-PATH] [--type auto|text|binary|hex|folder|table|image|document] [--json|--count|--quiet] [--ignore-case] [--ignore-whitespace] [--ignore-blank-lines] [--ignore-eol] [--ignore-line-regex REGEX] [--regex-rule-set NAME] [--prediffer PLUGIN_ID] [--substitute-regex REGEX REPLACEMENT] [--detect-moves] [--diff-algorithm lcs|patience|myers] [--inline-granularity char|word|grapheme] [--context LINES] [--show-only-changes] [--render side-by-side|unified|context|normal|html] [--syntax plain|auto|rust|json|html|markdown|shell|toml|yaml] [--find PATTERN] [--find-regex] [--find-case-sensitive] [--bookmark SIDE:LINE[:LABEL]] [--encoding auto|utf8|utf8-bom|utf16le|utf16be|lossy-utf8] [--image-mode exact|tolerance|perceptual] [--image-tolerance F] [--image-delta-e F] [--document-mode text|ocr_text] [--ocr-language LANG] LEFT RIGHT
61606263
linsync-cli compare3 [--markers|--json] LEFT BASE RIGHT

crates/linsync-cli/tests/plugin_cli.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,3 +462,79 @@ fn compare_prediffer_chain_applies_all_stages_in_order() {
462462
"stderr should report the chain order"
463463
);
464464
}
465+
466+
/// Install a folder-virtualizer plugin whose helper emits a one-file virtual
467+
/// tree whose sha256 is the source file's content, so two archives with equal
468+
/// content compare equal and differing content compares different.
469+
fn install_virtualizer_plugin(home: &Path) -> &'static str {
470+
let plugin_dir = home.join("data/linsync/plugins/virt");
471+
fs::create_dir_all(&plugin_dir).unwrap();
472+
let script = "#!/bin/sh\n\
473+
request=$(cat)\n\
474+
source=$(printf '%s' \"$request\" | sed -n 's/.*\"source\":\"\\([^\"]*\\)\".*/\\1/p')\n\
475+
content=$(cat \"$source\")\n\
476+
printf '{\"ok\":true,\"tree\":[{\"path\":\"entry.txt\",\"kind\":\"file\",\"sha256\":\"%s\"}]}\\n' \"$content\"\n";
477+
let helper = plugin_dir.join("helper.sh");
478+
fs::write(&helper, script).unwrap();
479+
#[cfg(unix)]
480+
{
481+
use std::os::unix::fs::PermissionsExt;
482+
let mut perms = fs::metadata(&helper).unwrap().permissions();
483+
perms.set_mode(0o755);
484+
fs::set_permissions(&helper, perms).unwrap();
485+
}
486+
let manifest = r#"{
487+
"schema_version": 1,
488+
"id": "test.virt",
489+
"name": "Virtualizer Fixture",
490+
"version": "1.0.0",
491+
"license": "GPL-3.0-only",
492+
"entry": ["./helper.sh"],
493+
"classes": ["folder_virtualizer"],
494+
"mime_types": ["application/zip"],
495+
"extensions": ["zip"],
496+
"capabilities": [],
497+
"deterministic": true,
498+
"sandbox": { "network": false, "writes_input": false, "requires_home_access": false },
499+
"options_schema": []
500+
}"#;
501+
fs::write(plugin_dir.join("linsync-plugin.json"), manifest).unwrap();
502+
"test.virt"
503+
}
504+
505+
#[test]
506+
fn archive_unpacker_compares_virtual_trees() {
507+
let home = temp_home("archive-virt");
508+
let id = install_virtualizer_plugin(&home);
509+
let a = home.join("a.zip");
510+
let b = home.join("b.zip");
511+
let c = home.join("c.zip");
512+
fs::write(&a, "AAA").unwrap();
513+
fs::write(&b, "AAA").unwrap(); // same content as a
514+
fs::write(&c, "BBB").unwrap(); // different
515+
let (a, b, c) = (
516+
a.to_str().unwrap(),
517+
b.to_str().unwrap(),
518+
c.to_str().unwrap(),
519+
);
520+
521+
// Equal virtual trees → exit 0.
522+
let equal = run_isolated_unsandboxed(&home, &["archive", "--unpacker", id, a, b]);
523+
assert_eq!(
524+
equal.status.code(),
525+
Some(0),
526+
"stderr={}",
527+
String::from_utf8_lossy(&equal.stderr)
528+
);
529+
530+
// Differing trees → exit 1, JSON reports the difference.
531+
let diff = run_isolated_unsandboxed(&home, &["archive", "--unpacker", id, a, c, "--json"]);
532+
assert_eq!(diff.status.code(), Some(1));
533+
let json: serde_json::Value = serde_json::from_str(&stdout(&diff)).unwrap();
534+
assert_eq!(json["equal"], serde_json::json!(false));
535+
assert_eq!(json["summary"]["different"], serde_json::json!(1));
536+
537+
// Unknown plugin id → error exit 2.
538+
let unknown = run_isolated_unsandboxed(&home, &["archive", "--unpacker", "nope", a, b]);
539+
assert_eq!(unknown.status.code(), Some(2));
540+
}

0 commit comments

Comments
 (0)