Nested archives by signature, per-side sizes, applied-mode icon, manual pairing

Archives are detected by their signature, not just their extension, so a ZIP
renamed to anything still opens as a folder — and nesting keeps going, verified
on EAR -> JAR -> renamed ZIP. Entries with clearly non-container extensions are
skipped without inflating, since checking the signature inside an archive means
decompressing, and an EAR holds thousands of .class files.

The comparison now records which mode settled each file, so Automatic shows an
icon per row (byte / text / structural) instead of leaving you to guess when it
went semantic.

Sizes are shown on both sides and highlighted when they disagree, which is often
the fastest hint of what changed.

Two files whose names don't match can now be compared: mark one side, pick the
other, and it becomes the new base in this tab or a new one. Entries living
inside an archive are extracted to a temporary file first, since they have no
path of their own — that's what lets a file inside a JAR be paired with a loose
one.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfhDYRLTpGJSKGP1m3LVJN
This commit is contained in:
alexandrev-tibco
2026-07-30 15:53:33 +02:00
parent 4c0f55c32d
commit e3cf1df73e
12 changed files with 415 additions and 21 deletions
+5
View File
@@ -33,6 +33,10 @@ final class ComparisonModel: Identifiable {
/// sides of a row can carry a disclosure control.
private var expanded: Set<String> = []
/// One side of a manual pairing, held while the other is chosen. Lets two
/// files with different names be compared, which the tree alone can't express.
var pendingPick: PendingPick?
func isExpanded(_ id: String) -> Bool { expanded.contains(id) }
func toggleExpansion(_ id: String) {
@@ -135,6 +139,7 @@ final class ComparisonModel: Identifiable {
visibleRows = []
searchText = ""
expanded.removeAll()
pendingPick = nil
}
func compare() {
+21 -1
View File
@@ -4,6 +4,8 @@ import KotejEngine
struct ContentView: View {
@Bindable var model: ComparisonModel
/// Opens an arbitrary pair (left, right, inNewTab); provided by RootView.
var openComparison: ((URL, URL, Bool) -> Void)?
/// How much of the height the tree takes; dragging the divider keeps it.
@AppStorage("kotej.splitFraction") private var splitFraction: Double = 0.45
@@ -17,6 +19,9 @@ struct ContentView: View {
if let message = model.errorMessage {
banner(message)
}
if let pending = model.pendingPick {
pendingBanner(pending)
}
if model.isScanning {
VStack(spacing: 10) {
@@ -26,7 +31,7 @@ struct ContentView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if model.root != nil {
SplitPane(fraction: splitBinding) {
DiffTreeView(model: model)
DiffTreeView(model: model, openComparison: openComparison)
} bottom: {
FileDiffView(model: model)
}
@@ -44,6 +49,21 @@ struct ContentView: View {
Binding(get: { CGFloat(splitFraction) }, set: { splitFraction = Double($0) })
}
/// Keeps the marked side visible; otherwise it's easy to forget one is armed.
private func pendingBanner(_ pending: PendingPick) -> some View {
HStack(spacing: 8) {
Image(systemName: "pin.fill").foregroundStyle(.tint)
Text("Marked for comparison:") + Text(verbatim: " \(pending.name)")
Spacer()
Button("Forget") { model.pendingPick = nil }
.buttonStyle(.borderless)
}
.font(.caption)
.padding(.horizontal, 12)
.padding(.vertical, 5)
.background(Color.accentColor.opacity(0.1))
}
private func banner(_ text: String) -> some View {
Label(text, systemImage: "exclamationmark.triangle.fill")
.font(.callout)
+105 -9
View File
@@ -57,8 +57,11 @@ struct FlatRow: Identifiable {
/// The side-by-side tree. Archives appear as folders.
struct DiffTreeView: View {
@Bindable var model: ComparisonModel
/// Opens an arbitrary pair as a new comparison; supplied by the window.
var openComparison: ((URL, URL, Bool) -> Void)?
private let sizeWidth: CGFloat = 84
private let sizeWidth: CGFloat = 76
private let modeWidth: CGFloat = 26
private let statusWidth: CGFloat = 104
var body: some View {
@@ -86,6 +89,7 @@ struct DiffTreeView: View {
List(flatRows, selection: selectionBinding) { row in
DiffRow(row: row,
sizeWidth: sizeWidth,
modeWidth: modeWidth,
statusWidth: statusWidth,
onToggle: { model.toggleExpansion(row.id) })
.contentShape(Rectangle())
@@ -127,11 +131,13 @@ struct DiffTreeView: View {
if let name = model.leftURL?.lastPathComponent { Text(verbatim: name) } else { Text("Left") }
}
.frame(maxWidth: .infinity, alignment: .leading)
Text("Size").frame(width: sizeWidth, alignment: .trailing)
Group {
if let name = model.rightURL?.lastPathComponent { Text(verbatim: name) } else { Text("Right") }
}
.frame(maxWidth: .infinity, alignment: .leading)
Text("Size").frame(width: sizeWidth, alignment: .trailing)
Color.clear.frame(width: modeWidth, height: 1)
Text("Status").frame(width: statusWidth, alignment: .trailing)
}
.font(.caption.weight(.semibold))
@@ -152,6 +158,30 @@ struct DiffTreeView: View {
}
@ViewBuilder private func rowMenu(_ node: DiffNode) -> some View {
// Pair up two files whose names don't match: mark one side, then pick the
// other. The marked side survives changing filter or folder.
if let left = node.left, !node.isDirectory {
Button("Mark left for comparison") { mark(left, path: node.path) }
}
if let right = node.right, !node.isDirectory {
Button("Mark right for comparison") { mark(right, path: node.path) }
}
if let pending = model.pendingPick {
Divider()
if let left = node.left, !node.isDirectory {
Button("Compare left with “\(pending.name)") { compare(pending, with: left, newTab: false) }
}
if let right = node.right, !node.isDirectory {
Button("Compare right with “\(pending.name)") { compare(pending, with: right, newTab: false) }
}
if let right = node.right, !node.isDirectory {
Button("Compare right with “\(pending.name)” in a new tab") {
compare(pending, with: right, newTab: true)
}
}
Button("Forget “\(pending.name)") { model.pendingPick = nil }
}
Divider()
Button("Expand all") { model.expandAll(from: displayRoot) }
Button("Collapse all") { model.collapseAll() }
Divider()
@@ -166,6 +196,18 @@ struct DiffTreeView: View {
}
}
private func mark(_ node: Node, path: String) {
guard let url = try? NodeExporter.url(for: node) else { return }
model.pendingPick = PendingPick(path: path, name: node.name, url: url)
}
/// Extracts the second side if needed and hands the pair to the window.
private func compare(_ pending: PendingPick, with node: Node, newTab: Bool) {
guard let url = try? NodeExporter.url(for: node) else { return }
model.pendingPick = nil
openComparison?(pending.url, url, newTab)
}
/// Only nodes backed by a real file can be shown in Finder; entries living
/// inside an archive have no path of their own.
private func canReveal(_ node: Node?) -> Bool {
@@ -185,6 +227,7 @@ struct DiffTreeView: View {
struct DiffRow: View {
let row: FlatRow
let sizeWidth: CGFloat
let modeWidth: CGFloat
let statusWidth: CGFloat
let onToggle: () -> Void
@@ -193,12 +236,12 @@ struct DiffRow: View {
var body: some View {
HStack(spacing: 0) {
side(present: node.left != nil)
sizeCell(node.left?.size, differs: sizesDiffer)
side(present: node.right != nil)
sizeCell(node.right?.size, differs: sizesDiffer)
Text(verbatim: sizeText)
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
.frame(width: sizeWidth, alignment: .trailing)
ModeIcon(mode: node.appliedMode)
.frame(width: modeWidth, alignment: .center)
StatusBadge(status: node.status)
.frame(width: statusWidth, alignment: .trailing)
@@ -242,12 +285,25 @@ struct DiffRow: View {
return node.isDirectory ? "folder.fill" : "doc"
}
/// Folders don't report a size; files show the side that exists.
private var sizeText: String {
guard !node.isDirectory else { return "" }
let bytes = node.left?.size ?? node.right?.size ?? 0
/// A size per side, so they can be compared directly. Highlighted when they
/// disagree, which is often the quickest hint of what changed.
private func sizeCell(_ bytes: UInt64?, differs: Bool) -> some View {
Text(verbatim: sizeText(bytes))
.font(.caption.monospacedDigit())
.foregroundStyle(differs ? Color.orange : Color.secondary)
.frame(width: sizeWidth, alignment: .trailing)
.padding(.trailing, 8)
}
private func sizeText(_ bytes: UInt64?) -> String {
guard !node.isDirectory, let bytes else { return "" }
return ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file)
}
private var sizesDiffer: Bool {
guard !node.isDirectory, let l = node.left?.size, let r = node.right?.size else { return false }
return l != r
}
}
/// Coloured pill so the verdict reads at a glance instead of as plain text.
@@ -285,3 +341,43 @@ struct StatusBadge: View {
}
}
}
/// Shows which comparison settled a row, which is what makes "Automatic"
/// trustworthy: you can see when it went semantic rather than assuming.
struct ModeIcon: View {
let mode: ComparisonMode?
var body: some View {
if let mode {
Image(systemName: symbol(mode))
.font(.caption2)
.foregroundStyle(tint(mode))
.help(helpText(mode))
}
}
private func symbol(_ mode: ComparisonMode) -> String {
switch mode {
case .binary: return "number"
case .text: return "text.alignleft"
case .semantic: return "curlybraces"
}
}
private func tint(_ mode: ComparisonMode) -> Color {
switch mode {
case .binary: return .secondary
case .text: return .blue
case .semantic: return .purple
}
}
private func helpText(_ mode: ComparisonMode) -> LocalizedStringKey {
switch mode {
case .binary: return "Compared byte for byte"
case .text: return "Compared as text"
case .semantic: return "Compared structurally (JSON/XML)"
}
}
}
+4 -1
View File
@@ -58,7 +58,10 @@ struct RootView: View {
VStack(spacing: 0) {
TabStrip(tabs: tabs)
Divider()
ContentView(model: tabs.selected)
ContentView(model: tabs.selected,
openComparison: { left, right, newTab in
tabs.openPair(left: left, right: right, inNewTab: newTab)
})
// Rebuild the body when the tab changes so per-tab state
// (selection, loaded diff) doesn't leak across tabs.
.id(tabs.selectedID)
+37
View File
@@ -0,0 +1,37 @@
import Foundation
import KotejEngine
/// Gets a real file path for any node, so an arbitrary pair can be re-compared as
/// a new base.
///
/// Entries living inside an archive have no path of their own, so they're written
/// out to a temporary file first that's what makes it possible to pick a file
/// inside a JAR on one side and a loose file on the other.
enum NodeExporter {
/// Returns a URL for the node, extracting it if it only exists inside an
/// archive. Nil when the node has no readable content.
static func url(for node: Node) throws -> URL {
switch node.source {
case .fileSystem(let url), .extracted(let url, _):
return url
case .archiveEntry:
return try extract(node)
}
}
private static func extract(_ node: Node) throws -> URL {
let directory = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("kotej-pick-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent(node.name)
try node.data().write(to: url)
return url
}
}
/// One side of a manual pairing, kept while the other side is chosen.
struct PendingPick: Equatable {
let path: String
let name: String
let url: URL
}
+9
View File
@@ -44,6 +44,15 @@ final class TabsModel {
func closeSelected() { close(selected) }
/// Opens an arbitrary pair, either replacing the current comparison or in a
/// new tab, which is what makes a hand-picked pair the new base.
func openPair(left: URL, right: URL, inNewTab: Bool) {
let target = inNewTab ? newTab() : selected
target.setSide(.left, to: left)
target.setSide(.right, to: right)
selectedID = target.id
}
/// Opens a comparison in a tab: reuses the current one while it's still empty
/// (the common case right after launch) and otherwise starts a new one, so an
/// in-progress comparison is never overwritten.
+9
View File
@@ -85,3 +85,12 @@
"whitespace" = "whitespace";
"added" = "added";
"removed" = "removed";
/* Manual pairing */
"Mark left for comparison" = "Mark left for comparison";
"Mark right for comparison" = "Mark right for comparison";
"Marked for comparison:" = "Marked for comparison:";
"Forget" = "Forget";
"Compared byte for byte" = "Compared byte for byte";
"Compared as text" = "Compared as text";
"Compared structurally (JSON/XML)" = "Compared structurally (JSON/XML)";
+9
View File
@@ -85,3 +85,12 @@
"whitespace" = "espacios";
"added" = "añadidas";
"removed" = "borradas";
/* Emparejar manualmente */
"Mark left for comparison" = "Marcar izquierda para comparar";
"Mark right for comparison" = "Marcar derecha para comparar";
"Marked for comparison:" = "Marcado para comparar:";
"Forget" = "Olvidar";
"Compared byte for byte" = "Comparado byte a byte";
"Compared as text" = "Comparado como texto";
"Compared structurally (JSON/XML)" = "Comparado estructuralmente (JSON/XML)";
+36 -8
View File
@@ -29,6 +29,9 @@ public final class DiffNode: @unchecked Sendable {
public let left: Node?
public let right: Node?
public var status: DiffStatus
/// Which comparison actually settled this file, so "Automatic" can show what
/// it decided rather than leaving you to guess.
public var appliedMode: ComparisonMode?
public internal(set) var children: [DiffNode]
init(name: String, path: String, isDirectory: Bool, isArchive: Bool,
@@ -135,9 +138,12 @@ public enum DirectoryComparer {
left: left, right: right, status: .different)
}
return DiffNode(name: name, path: path, isDirectory: false,
isArchive: false, left: left, right: right,
status: fileStatus(left: left, right: right, options: options))
let outcome = fileOutcome(left: left, right: right, options: options)
let node = DiffNode(name: name, path: path, isDirectory: false,
isArchive: false, left: left, right: right,
status: outcome.status)
node.appliedMode = outcome.mode
return node
}
private static func pairChildren(left: Node, right: Node, options: Options) -> [DiffNode] {
@@ -161,6 +167,21 @@ public enum DirectoryComparer {
return rows
}
/// The status plus which comparison produced it.
static func fileOutcome(left: Node, right: Node,
options: Options) -> (status: DiffStatus, mode: ComparisonMode?) {
// Level 1 CRC32 straight from the archive directory: free and decisive.
if let leftCRC = left.crc32, let rightCRC = right.crc32 {
if leftCRC == rightCRC && left.size == right.size { return (.identical, nil) }
} else if left.size == right.size, left.size == 0 {
return (.identical, nil)
}
let mustRead = options.resolveContentEagerly && max(left.size, right.size) <= options.eagerSizeLimit
guard mustRead else { return (.pending, nil) }
return resolveDetailed(left: left, right: right, options: options)
}
/// The layered check for a single pair of files.
static func fileStatus(left: Node, right: Node, options: Options) -> DiffStatus {
// Level 1 CRC32 straight from the archive directory: free and decisive.
@@ -179,16 +200,23 @@ public enum DirectoryComparer {
/// Reads both sides and applies the chosen (or inferred) comparison mode.
public static func resolve(left: Node, right: Node, options: Options = Options()) -> DiffStatus {
guard let leftData = try? left.data(), let rightData = try? right.data() else { return .different }
if leftData == rightData { return .identical }
resolveDetailed(left: left, right: right, options: options).status
}
/// Same, but also reports which mode was used.
public static func resolveDetailed(left: Node, right: Node,
options: Options = Options()) -> (status: DiffStatus, mode: ComparisonMode?) {
guard let leftData = try? left.data(), let rightData = try? right.data() else { return (.different, nil) }
let kind = ContentKind.detect(path: left.name, data: leftData)
let mode = options.mode ?? kind.defaultMode
if leftData == rightData { return (.identical, mode) }
switch ContentComparer.compare(leftData, rightData, mode: mode, kind: kind,
textOptions: options.textOptions) {
case .equal: return .identical
case .equivalent: return .equivalent
case .different, .unsupported: return .different
case .equal: return (.identical, mode)
case .equivalent: return (.equivalent, mode)
case .different, .unsupported: return (.different, mode)
}
}
}
+4 -1
View File
@@ -154,8 +154,11 @@ public enum TreeScanner {
let name = (entry.path as NSString).lastPathComponent
// A JAR inside an EAR: extract once so its own entries can be listed.
if expandNestedArchives, ZipReader.archiveExtensions.contains((name as NSString).pathExtension.lowercased()) {
// A JAR inside an EAR, or any renamed ZIP: expand it as a folder.
if expandNestedArchives,
ZipReader.mayBeNestedArchive(name: name, uncompressedSize: entry.uncompressedSize) {
if let nested = try? extractNested(reader: reader, entry: entry, name: name),
ZipReader.looksLikeArchive(fileAt: nested),
let nestedNode = try? scanArchive(nested, expandNestedArchives: expandNestedArchives,
path: childPath) {
parent.add(nestedNode)
+47 -1
View File
@@ -40,7 +40,53 @@ public struct ZipReader {
public static let archiveExtensions: Set<String> = ["zip", "jar", "ear", "war", "aar", "ipa"]
public static func isArchive(_ url: URL) -> Bool {
archiveExtensions.contains(url.pathExtension.lowercased())
if archiveExtensions.contains(url.pathExtension.lowercased()) { return true }
// A ZIP renamed to anything else is still a ZIP, and deployment artefacts
// do this constantly, so fall back to the signature.
return looksLikeArchive(fileAt: url)
}
/// Extensions that are definitely not containers, used to skip the signature
/// check on the thousands of small entries inside a real EAR.
static let neverArchiveExtensions: Set<String> = [
"class", "java", "swift", "kt", "go", "c", "h", "m", "js", "ts", "css", "scss",
"html", "htm", "xml", "xsd", "xsl", "xslt", "wsdl", "json", "yml", "yaml",
"properties", "conf", "cfg", "ini", "toml", "md", "txt", "log", "csv", "sql",
"sh", "bat", "mf", "sf", "rsa", "dsa", "png", "jpg", "jpeg", "gif", "svg",
"ico", "pdf", "ttf", "otf", "woff", "woff2", "so", "dylib", "plist",
]
/// The local-file-header signature, plus the empty and spanned variants.
private static let signatures: [[UInt8]] = [
[0x50, 0x4B, 0x03, 0x04],
[0x50, 0x4B, 0x05, 0x06],
[0x50, 0x4B, 0x07, 0x08],
]
/// Cheap check: read only the first four bytes.
public static func looksLikeArchive(fileAt url: URL) -> Bool {
guard let handle = try? FileHandle(forReadingFrom: url) else { return false }
defer { try? handle.close() }
guard let head = try? handle.read(upToCount: 4) else { return false }
return looksLikeArchive(head)
}
public static func looksLikeArchive(_ head: Data?) -> Bool {
guard let head, head.count >= 4 else { return false }
let bytes = Array(head.prefix(4))
return signatures.contains(bytes)
}
/// Whether an entry inside an archive is worth opening as one. Known archive
/// extensions always are; anything with a clearly non-container extension
/// never is; the rest are only inflated when small enough that guessing wrong
/// is cheap, since checking the signature means decompressing it.
static func mayBeNestedArchive(name: String, uncompressedSize: UInt64,
inflateLimit: UInt64 = 64 * 1024 * 1024) -> Bool {
let ext = (name as NSString).pathExtension.lowercased()
if archiveExtensions.contains(ext) { return true }
if !ext.isEmpty && neverArchiveExtensions.contains(ext) { return false }
return uncompressedSize >= 22 && uncompressedSize <= inflateLimit
}
public init(url: URL) throws {
@@ -0,0 +1,129 @@
import XCTest
@testable import KotejEngine
final class NestedArchiveTests: XCTestCase {
private var scratch: URL!
override func setUpWithError() throws {
scratch = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("kotej-nested-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: scratch, withIntermediateDirectories: true)
}
override func tearDownWithError() throws {
try? FileManager.default.removeItem(at: scratch)
}
private func zip(_ files: [String: String], named name: String) throws -> URL {
let content = scratch.appendingPathComponent("src-\(UUID().uuidString)")
for (path, body) in files {
let url = content.appendingPathComponent(path)
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try body.write(to: url, atomically: true, encoding: .utf8)
}
let archive = scratch.appendingPathComponent(name)
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
process.arguments = ["-q", "-r", archive.path, "."]
process.currentDirectoryURL = content
try process.run()
process.waitUntilExit()
return archive
}
/// A ZIP renamed to something unfamiliar must still open as a folder.
func testArchiveWithAnUnknownExtensionIsDetected() throws {
let archive = try zip(["a/b.txt": "hola"], named: "bundle.customext")
XCTAssertTrue(ZipReader.isArchive(archive), "detection must fall back to the signature")
let tree = try TreeScanner.scan(archive)
XCTAssertTrue(tree.isDirectory)
XCTAssertNotNil(tree.children.first { $0.name == "a" })
}
func testPlainFileIsNotMistakenForAnArchive() throws {
let file = scratch.appendingPathComponent("notes.unknownext")
try "solo texto".write(to: file, atomically: true, encoding: .utf8)
XCTAssertFalse(ZipReader.isArchive(file))
}
/// EAR JAR renamed ZIP: every level has to keep expanding.
func testThreeLevelsOfNestingExpand() throws {
let inner = try zip(["deep/Service.class": "bytecode"], named: "inner.zipball")
let middleSource = scratch.appendingPathComponent("mid-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: middleSource, withIntermediateDirectories: true)
try FileManager.default.copyItem(at: inner, to: middleSource.appendingPathComponent("inner.zipball"))
try "manifest".write(to: middleSource.appendingPathComponent("MANIFEST.MF"),
atomically: true, encoding: .utf8)
let middle = scratch.appendingPathComponent("lib.jar")
var process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
process.arguments = ["-q", "-r", middle.path, "."]
process.currentDirectoryURL = middleSource
try process.run(); process.waitUntilExit()
let outerSource = scratch.appendingPathComponent("out-\(UUID().uuidString)")
try FileManager.default.createDirectory(at: outerSource, withIntermediateDirectories: true)
try FileManager.default.copyItem(at: middle, to: outerSource.appendingPathComponent("lib.jar"))
let outer = scratch.appendingPathComponent("app.ear")
process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/zip")
process.arguments = ["-q", "-r", outer.path, "."]
process.currentDirectoryURL = outerSource
try process.run(); process.waitUntilExit()
let tree = try TreeScanner.scan(outer)
let jar = try XCTUnwrap(tree.children.first { $0.name == "lib.jar" })
XCTAssertTrue(jar.isDirectory, "JAR inside the EAR must open")
let renamed = try XCTUnwrap(jar.children.first { $0.name == "inner.zipball" })
XCTAssertTrue(renamed.isDirectory, "a renamed ZIP inside the JAR must open too")
XCTAssertNotNil(renamed.children.first { $0.name == "deep" })
}
func testEntriesThatCannotBeArchivesAreSkipped() {
// Cheap guard: thousands of .class files in an EAR must not be inflated
// just to check for a signature.
XCTAssertFalse(ZipReader.mayBeNestedArchive(name: "Service.class", uncompressedSize: 5_000))
XCTAssertFalse(ZipReader.mayBeNestedArchive(name: "beans.xml", uncompressedSize: 900))
XCTAssertTrue(ZipReader.mayBeNestedArchive(name: "lib.jar", uncompressedSize: 5_000))
XCTAssertTrue(ZipReader.mayBeNestedArchive(name: "mystery.bundle", uncompressedSize: 5_000))
XCTAssertFalse(ZipReader.mayBeNestedArchive(name: "huge.bundle", uncompressedSize: 900_000_000),
"too big to inflate on a guess")
}
// MARK: Which comparison was applied
func testAutomaticReportsTheModeItChose() throws {
let left = scratch.appendingPathComponent("l"), right = scratch.appendingPathComponent("r")
for dir in [left, right] {
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
}
try #"{"a":1,"b":2}"#.write(to: left.appendingPathComponent("c.json"), atomically: true, encoding: .utf8)
try "{\n \"b\":2,\n \"a\":1\n}".write(to: right.appendingPathComponent("c.json"), atomically: true, encoding: .utf8)
try "uno".write(to: left.appendingPathComponent("n.txt"), atomically: true, encoding: .utf8)
try "dos".write(to: right.appendingPathComponent("n.txt"), atomically: true, encoding: .utf8)
let diff = DirectoryComparer.compare(left: try TreeScanner.scan(left),
right: try TreeScanner.scan(right))
func row(_ name: String) -> DiffNode? { diff.children.first { $0.name == name } }
XCTAssertEqual(row("c.json")?.appliedMode, .semantic, "JSON should report semantic")
XCTAssertEqual(row("n.txt")?.appliedMode, .text)
}
func testForcedModeIsReportedAsSuch() throws {
let left = scratch.appendingPathComponent("bl"), right = scratch.appendingPathComponent("br")
for dir in [left, right] {
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
}
try #"{"a":1}"#.write(to: left.appendingPathComponent("c.json"), atomically: true, encoding: .utf8)
try "{ \"a\": 1 }".write(to: right.appendingPathComponent("c.json"), atomically: true, encoding: .utf8)
let diff = DirectoryComparer.compare(left: try TreeScanner.scan(left),
right: try TreeScanner.scan(right),
options: .init(mode: .binary))
XCTAssertEqual(diff.children.first?.appliedMode, .binary)
XCTAssertEqual(diff.children.first?.status, .different)
}
}