make the thing

This commit is contained in:
2026-08-31 21:56:09 +02:00
parent 3615193ae4
commit f8b5923079
20 changed files with 931 additions and 34 deletions
+26
View File
@@ -0,0 +1,26 @@
PREFIX ?= $(HOME)/.local
.PHONY: build
build: .build/release/aria .build/plugins/GenerateManual/outputs/aria/aria.1
.build/release/aria:
swift build -c release
.build/plugins/GenerateManual/outputs/aria/aria.1:
swift package generate-manual
.PHONY: clean
clean:
@rm -rf .build
.PHONY: install
install: build
@install -d $(PREFIX)/bin/
@install -Dm755 .build/release/aria $(PREFIX)/bin/
@install -d $(PREFIX)/share/man/man1/
@install -Dm644 .build/plugins/GenerateManual/outputs/aria/aria.1 $(PREFIX)/share/man/man1/
.PHONY: uninstall
uninstall:
@rm -f $(PREFIX)/bin/aria
@rm -f $(PREFIX)/share/man/man1/aria.1
+51
View File
@@ -0,0 +1,51 @@
{
"originHash" : "9f9e8184b879efd7c4ba6a8c50c6ca2b0a9b4487ab0e36961c895f3b91c2ced5",
"pins" : [
{
"identity" : "noora",
"kind" : "remoteSourceControl",
"location" : "https://github.com/tuist/Noora/",
"state" : {
"revision" : "b01663496be276c7e8b198486febedc4b3689c2f",
"version" : "0.57.0"
}
},
{
"identity" : "path",
"kind" : "remoteSourceControl",
"location" : "https://github.com/tuist/path",
"state" : {
"revision" : "7c74ac435e03a927c3a73134c48b61e60221abcb",
"version" : "0.3.8"
}
},
{
"identity" : "rainbow",
"kind" : "remoteSourceControl",
"location" : "https://github.com/onevcat/Rainbow",
"state" : {
"revision" : "cdf146ae671b2624917648b61c908d1244b98ca1",
"version" : "4.2.1"
}
},
{
"identity" : "swift-argument-parser",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-argument-parser.git",
"state" : {
"revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382",
"version" : "1.8.2"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log",
"state" : {
"revision" : "3ffafb9722d5d918c614feb496c8789a3b59d222",
"version" : "1.15.0"
}
}
],
"version" : 3
}
+20 -20
View File
@@ -1,26 +1,26 @@
// swift-tools-version: 6.3
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "aria",
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"),
],
targets: [
// Targets are the basic building blocks of a package, defining a module or a test suite.
// Targets can depend on other targets in this package and products from dependencies.
.executableTarget(
name: "aria",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
]
),
.testTarget(
name: "ariaTests",
dependencies: ["aria"]
),
],
swiftLanguageModes: [.v6]
name: "aria",
platforms: [.macOS(.v26)],
dependencies: [
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.2.0"),
.package(url: "https://github.com/tuist/Noora/", from: "0.56.0"),
],
targets: [
.executableTarget(
name: "aria",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Noora", package: "Noora"),
]
),
.testTarget(
name: "ariaTests",
dependencies: ["aria"]
),
],
swiftLanguageModes: [.v6]
)
+37
View File
@@ -0,0 +1,37 @@
import ArgumentParser
import Foundation
import Noora
extension Aria.Add {
struct Torrent: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "torrent",
abstract: "Add download from .torrent file."
)
@Option(name: .shortAndLong, help: "Define the directory to save files.")
var directory: String = Config.directory
@Flag(name: .long, help: "Enable seeding after download.")
var seed: Bool = false
@Argument(help: "URI of .torrent file to download.")
var uri: String
func run() async throws {
let noora = Noora()
var options = [String : String]()
options.updateValue((directory as NSString).expandingTildeInPath, forKey: "dir")
if seed == false { options.updateValue("0", forKey: "seed-time") }
do {
let gid = try await Aria2.addTorrent(uri: uri, options: options)
noora.success("\(gid)")
} catch {
noora.error("\(error.localizedDescription)")
throw ExitCode.failure
}
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import ArgumentParser
import Foundation
import Noora
extension Aria.Add {
struct URI: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "uri",
abstract: "Add downloads from URIs.",
)
@Option(name: .shortAndLong, help: "Define the directory to save files.")
var directory: String = Config.directory
@Flag(name: .long, help: "Enable seeding after download.")
var seed: Bool = false
@Argument(help: "URIs of resources to download.")
var uris: [String]
func run() async throws {
let noora = Noora()
var options = [String: String]()
options.updateValue((directory as NSString).expandingTildeInPath, forKey: "dir")
if seed == false { options.updateValue("0", forKey: "seed-time") }
do {
let gid = try await Aria2.addUri(uris: uris, options: options)
noora.success("\(gid)")
} catch {
noora.error("\(error.localizedDescription)")
throw ExitCode.failure
}
}
}
}
+12
View File
@@ -0,0 +1,12 @@
import ArgumentParser
extension Aria {
struct Add: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "add",
abstract: "Add downloads.",
subcommands: [URI.self, Torrent.self],
defaultSubcommand: URI.self,
)
}
}
+92
View File
@@ -0,0 +1,92 @@
import ArgumentParser
import Foundation
import Noora
extension Aria {
struct List: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "list",
abstract: "List downloads.",
aliases: ["ls"]
)
mutating func run() async throws {
let noora = Noora()
do {
let initial = try await Self.makeSnapshot()
let latestGids = LatestGids(Self.gids(from: initial))
let updates = AsyncStream<TableData> { continuation in
let producer = Task {
while !Task.isCancelled {
if let snapshot = try? await Self.makeSnapshot() {
await latestGids.set(Self.gids(from: snapshot))
continuation.yield(snapshot)
}
try? await Task.sleep(for: .seconds(1))
}
continuation.finish()
}
continuation.onTermination = { _ in
producer.cancel()
}
}
let selectedIndex = try await noora.selectableTable(
initial,
updates: updates,
pageSize: 8
)
let gids = await latestGids.current()
if gids.indices.contains(selectedIndex) {
print("Picked download: \(gids[selectedIndex])")
} else {
print("Picked download at index \(selectedIndex)")
}
} catch {
noora.error(.alert("\(error)"))
throw ExitCode.failure
}
}
private static func makeColumns() -> [TableColumn] {
[
TableColumn(title: "Name", width: .flexible(min: 10, max: 50), alignment: .left),
TableColumn(title: "Status", width: .auto, alignment: .left),
TableColumn(title: "Progress", width: .auto, alignment: .left),
TableColumn(title: "Size", width: .auto, alignment: .left),
TableColumn(title: "Down", width: .auto, alignment: .left),
TableColumn(title: "Up", width: .auto, alignment: .left),
TableColumn(title: "ETA", width: .auto, alignment: .left),
TableColumn(title: "GID", width: .auto, alignment: .left),
]
}
private static func makeSnapshot() async throws -> TableData {
let active = try await Aria2.tellAll()
return TableData(columns: makeColumns(), rows: active.map(\.tabularized))
}
private static func gids(from data: TableData) -> [String] {
data.rows.map { $0.first?.plain() ?? "-" }
}
}
}
private actor LatestGids {
private var gids: [String]
init(_ gids: [String]) {
self.gids = gids
}
func set(_ gids: [String]) {
self.gids = gids
}
func current() -> [String] {
gids
}
}
+39
View File
@@ -0,0 +1,39 @@
import ArgumentParser
import Noora
extension Aria {
struct Pause: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "pause",
abstract: "Pause downloads.",
aliases: ["stop"]
)
@Argument(help: .init("The GIDs of the downloads to pause."))
var gids: [String] = []
func run() async throws {
let noora = Noora()
if gids.isEmpty {
do {
let status = try await Aria2.pauseAll()
noora.success("\(status)")
} catch {
noora.error("\(error.localizedDescription)")
throw ExitCode.failure
}
} else {
do {
for gid in gids {
let status = try await Aria2.pause(gid: gid)
noora.success("\(status)")
}
} catch {
noora.error("\(error.localizedDescription)")
throw ExitCode.failure
}
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
import ArgumentParser
import Noora
extension Aria {
struct Purge: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "purge",
abstract: "Purge completed, removed, and failed downloads.",
aliases: ["clean"]
)
func run() async throws {
let noora = Noora()
do {
let status = try await Aria2.purgeDownloadResult()
noora.success("\(status)")
} catch {
noora.error("\(error.localizedDescription)")
throw ExitCode.failure
}
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import ArgumentParser
import Noora
extension Aria {
struct Remove: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "remove",
abstract: "Remove downloads.",
aliases: ["rm", "cancel"]
)
@Argument(help: .init("The GIDs of the downloads to remove."))
var gids: [String]
func run() async throws {
let noora = Noora()
do {
for gid in gids {
let status = try await Aria2.remove(gid: gid)
noora.success("\(status)")
}
} catch {
noora.error("\(error.localizedDescription)")
throw ExitCode.failure
}
}
}
}
+39
View File
@@ -0,0 +1,39 @@
import ArgumentParser
import Noora
extension Aria {
struct Resume: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "resume",
abstract: "Resume downloads.",
aliases: ["play"]
)
@Argument(help: .init("The GIDs of the downloads to resume."))
var gids: [String] = []
func run() async throws {
let noora = Noora()
if gids.isEmpty {
do {
let status = try await Aria2.unpauseAll()
noora.success("\(status)")
} catch {
noora.error("\(error.localizedDescription)")
throw ExitCode.failure
}
} else {
do {
for gid in gids {
let status = try await Aria2.unpause(gid: gid)
noora.success("\(status)")
}
} catch {
noora.error("\(error.localizedDescription)")
throw ExitCode.failure
}
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
import Foundation
enum Config {
static let host: String = "127.0.0.1"
static let port: Int = 6800
static let secret: String = "my_secret"
static var endpoint: URL {
URL(string: "http://\(host):\(port)/jsonrpc")!
}
static let directory: String = "~/Downloads/"
}
@@ -0,0 +1,72 @@
import Foundation
extension KeyedDecodingContainer {
/// aria2 sends numeric fields as strings; accept either.
func decodeLenientInt64(forKey key: K) throws -> Int64 {
if let value = try? decode(Int64.self, forKey: key) {
return value
}
let string = try decode(String.self, forKey: key)
guard let value = Int64(string) else {
throw DecodingError.typeMismatch(
Int.self,
DecodingError.Context(
codingPath: codingPath + [key],
debugDescription: "Expected Int or numeric string, got \(string)"
)
)
}
return value
}
func decodeLenientInt64IfPresent(forKey key: K) throws -> Int64? {
guard contains(key), try decodeNil(forKey: key) == false else { return nil }
return try decodeLenientInt64(forKey: key)
}
/// aria2 sends numeric fields as strings; accept either.
func decodeLenientInt(forKey key: K) throws -> Int {
if let value = try? decode(Int.self, forKey: key) {
return value
}
let string = try decode(String.self, forKey: key)
guard let value = Int(string) else {
throw DecodingError.typeMismatch(
Int.self,
DecodingError.Context(
codingPath: codingPath + [key],
debugDescription: "Expected Int or numeric string, got \(string)"
)
)
}
return value
}
func decodeLenientIntIfPresent(forKey key: K) throws -> Int? {
guard contains(key), try decodeNil(forKey: key) == false else { return nil }
return try decodeLenientInt(forKey: key)
}
/// aria2 sends booleans as "true"/"false" strings; accept either.
func decodeLenientBool(forKey key: K) throws -> Bool {
if let value = try? decode(Bool.self, forKey: key) {
return value
}
let string = try decode(String.self, forKey: key)
guard let value = Bool(string) else {
throw DecodingError.typeMismatch(
Bool.self,
DecodingError.Context(
codingPath: codingPath + [key],
debugDescription: "Expected Bool or boolean string, got \(string)"
)
)
}
return value
}
func decodeLenientBoolIfPresent(forKey key: K) throws -> Bool? {
guard contains(key), try decodeNil(forKey: key) == false else { return nil }
return try decodeLenientBool(forKey: key)
}
}
@@ -0,0 +1,3 @@
import Noora
extension TableData: @retroactive @unchecked Sendable {}
+122
View File
@@ -0,0 +1,122 @@
extension Aria2 {
struct AddUriParams: Encodable {
let secret: String = Config.secret
let uris: [String]
let options: [String: String]
func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode("token:\(secret)")
try container.encode(uris)
if !options.isEmpty { try container.encode(options) }
}
}
struct AddTorrentParams: Encodable {
let secret: String = Config.secret
let uri: String
let options: [String: String]
func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode("token:\(secret)")
try container.encode(uri)
if !options.isEmpty { try container.encode(options) }
}
}
struct TellActiveParams: Encodable {
let secret: String = Config.secret
func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode("token:\(secret)")
}
}
struct TellWaitingParams: Encodable {
let secret: String = Config.secret
let offset: Int
let num: Int
func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode("token:\(secret)")
try container.encode(offset)
try container.encode(num)
}
}
struct TellStoppedParams: Encodable {
let secret: String = Config.secret
let offset: Int
let num: Int
func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode("token:\(secret)")
try container.encode(offset)
try container.encode(num)
}
}
struct PauseParams: Encodable {
let secret: String = Config.secret
let gid: String
func encode(to encoder: any Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode("token:\(secret)")
try container.encode(gid)
}
}
struct PauseAllParams: Encodable {
let secret: String = Config.secret
func encode(to encoder: any Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode("token:\(secret)")
}
}
struct UnpauseParams: Encodable {
let secret: String = Config.secret
let gid: String
func encode(to encoder: any Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode("token:\(secret)")
try container.encode(gid)
}
}
struct UnpauseAllParams: Encodable {
let secret: String = Config.secret
func encode(to encoder: any Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode("token:\(secret)")
}
}
struct RemoveParams: Encodable {
let secret: String = Config.secret
let gid: String
func encode(to encoder: any Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode("token:\(secret)")
try container.encode(gid)
}
}
struct PurgeDownloadResultParams: Encodable {
let secret: String = Config.secret
func encode(to encoder: any Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode("token:\(secret)")
}
}
}
+115
View File
@@ -0,0 +1,115 @@
import Foundation
import Noora
extension Aria2 {
struct TellStatusValue: Decodable {
let gid: String
let status: Status
let totalLength: Int64
let completedLength: Int64
let uploadLength: Int64
let downloadSpeed: Int64
let uploadSpeed: Int64
let numSeeders: Int?
let seeder: Bool?
// [more]
let dir: URL
let files: [File]
// [more]
let bittorrent: BitTorrent?
/// The download completion progress (e.g. `80.5%`).
var progress: String {
let fraction = totalLength > 0 ? Double(completedLength) / Double(totalLength) : 0
return fraction.formatted(.percent.precision(.fractionLength(2)))
}
/// The estimated remaining time (e.g. `14hr 10min`), or `n/a` when it cannot be computed.
var ETA: String {
guard downloadSpeed > 0, completedLength < totalLength else { return "n/a" }
let seconds = Double(totalLength - completedLength) / Double(downloadSpeed)
return Self.etaFormatter.string(from: seconds) ?? "n/a"
}
private static let etaFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .abbreviated
formatter.allowedUnits = [.day, .hour, .minute, .second]
formatter.collapsesLargestUnit = true
return formatter
}()
var name: String {
if let name = bittorrent?.info?.name {
return name
} else if let name = files.first?.path, let url = URL(string: name) {
return url.lastPathComponent
} else if let name = files.first?.path {
return name
} else {
return "-"
}
}
var tabularized: TableRow {
let byteCountFormatter = ByteCountFormatter()
return [
name,
status.rawValue,
progress,
byteCountFormatter.string(fromByteCount: totalLength),
byteCountFormatter.string(fromByteCount: downloadSpeed),
byteCountFormatter.string(fromByteCount: uploadSpeed),
ETA,
gid
].map(TerminalText.init)
}
enum CodingKeys: String, CodingKey {
case gid, status, totalLength, completedLength, uploadLength
case downloadSpeed, uploadSpeed, numSeeders, seeder, dir, files, bittorrent
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
gid = try container.decode(String.self, forKey: .gid)
status = try container.decode(Status.self, forKey: .status)
totalLength = try container.decodeLenientInt64(forKey: .totalLength)
completedLength = try container.decodeLenientInt64(forKey: .completedLength)
uploadLength = try container.decodeLenientInt64(forKey: .uploadLength)
downloadSpeed = try container.decodeLenientInt64(forKey: .downloadSpeed)
uploadSpeed = try container.decodeLenientInt64(forKey: .uploadSpeed)
numSeeders = try container.decodeLenientIntIfPresent(forKey: .numSeeders)
seeder = try container.decodeLenientBoolIfPresent(forKey: .seeder)
dir = try container.decode(URL.self, forKey: .dir)
files = try container.decode([File].self, forKey: .files)
if container.contains(.bittorrent) {
bittorrent = try container.decode(BitTorrent.self, forKey: .bittorrent)
} else {
bittorrent = nil
}
}
enum Status: String, Decodable {
case active
case waiting
case paused
case error
case complete
case removed
}
struct BitTorrent: Decodable {
let info: Info?
struct Info: Decodable {
let name: String
}
}
struct File: Decodable {
let path: String
}
}
}
+86
View File
@@ -0,0 +1,86 @@
enum Aria2 {
static func addUri(uris: [String], options: [String: String] = [:]) async throws -> String {
try await JSONRPC.call(
method: "aria2.addUri",
params: AddUriParams(uris: uris, options: options)
)
}
static func addTorrent(uri: String, options: [String: String] = [:]) async throws -> String {
try await JSONRPC.call(
method: "aria2.addTorrent",
params: AddTorrentParams(uri: uri, options: options)
)
}
static func tellActive() async throws -> [TellStatusValue] {
try await JSONRPC.call(
method: "aria2.tellActive",
params: TellActiveParams()
)
}
static func tellWaiting(offset: Int = 0, num: Int = .max) async throws -> [TellStatusValue] {
try await JSONRPC.call(
method: "aria2.tellWaiting",
params: TellWaitingParams(offset: offset, num: num)
)
}
static func tellStopped(offset: Int = 0, num: Int = .max) async throws -> [TellStatusValue] {
try await JSONRPC.call(
method: "aria2.tellStopped",
params: TellStoppedParams(offset: offset, num: num)
)
}
static func tellAll() async throws -> [TellStatusValue] {
let maxNum = 999_999
var downloads = try await tellActive()
downloads += try await tellWaiting(num: maxNum)
downloads += try await tellStopped(num: maxNum)
return downloads
}
static func pause(gid: String) async throws -> String {
try await JSONRPC.call(
method: "aria2.pause",
params: PauseParams(gid: gid)
)
}
static func pauseAll() async throws -> String {
try await JSONRPC.call(
method: "aria2.pauseAll",
params: PauseAllParams()
)
}
static func unpause(gid: String) async throws -> String {
try await JSONRPC.call(
method: "aria2.unpause",
params: UnpauseParams(gid: gid)
)
}
static func unpauseAll() async throws -> String {
try await JSONRPC.call(
method: "aria2.unpauseAll",
params: UnpauseAllParams()
)
}
static func remove(gid: String) async throws -> String {
try await JSONRPC.call(
method: "aria2.remove",
params: RemoveParams(gid: gid)
)
}
static func purgeDownloadResult() async throws -> String {
try await JSONRPC.call(
method: "aria2.purgeDownloadResult",
params: PurgeDownloadResultParams()
)
}
}
+62
View File
@@ -0,0 +1,62 @@
import Foundation
struct JSONRPCRequest<Params: Encodable>: Encodable {
let jsonrpc = "2.0"
let id = "aria"
let method: String
let params: Params
}
struct JSONRPCResponse<Value: Decodable>: Decodable {
let result: Value?
let error: JSONRPCError?
}
struct JSONRPCError: Decodable, Error, LocalizedError {
let code: Int
let message: String
var errorDescription: String? { "aria2 RPC error \(code): \(message)" }
}
enum JSONRPCClientError: Error, LocalizedError {
case unexpectedHTTPStatus(Int)
case missingResult
var errorDescription: String? {
switch self {
case let .unexpectedHTTPStatus(status):
"aria2 RPC endpoint returned HTTP \(status)"
case .missingResult:
"aria2 RPC response contained neither a result nor an error"
}
}
}
enum JSONRPC {
static func call<Params: Encodable, Value: Decodable>(
method: String,
params: Params
) async throws -> Value {
var request = URLRequest(url: Config.endpoint)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONEncoder().encode(JSONRPCRequest(method: method, params: params))
let (data, response) = try await URLSession.shared.data(for: request)
if let httpResponse = response as? HTTPURLResponse,
!(200..<300).contains(httpResponse.statusCode)
{
throw JSONRPCClientError.unexpectedHTTPStatus(httpResponse.statusCode)
}
let decoded = try JSONDecoder().decode(JSONRPCResponse<Value>.self, from: data)
if let error = decoded.error {
throw error
}
guard let result = decoded.result else {
throw JSONRPCClientError.missingResult
}
return result
}
}
+24 -10
View File
@@ -1,14 +1,28 @@
// The Swift Programming Language
// https://docs.swift.org/swift-book
//
// Swift Argument Parser
// https://swiftpackageindex.com/apple/swift-argument-parser/documentation
import ArgumentParser
@main
struct aria: ParsableCommand {
mutating func run() throws {
print("Hello, world!")
}
struct Aria: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "aria",
abstract: "Friendly aria2 wrapper for your personal downloads.",
discussion: """
aria helps you keep an eye on and manage all your aria2 downloads.
The cli wraps the standard aria2c JSONRPC server, exposing just
enough functionalities to be dangerous.
Enjoy a unified API to add, pause, resume and remove downloads.
Use the list command to check the current status in a beautifully
formatted table.
""",
version: "1.0.0",
subcommands: [
List.self,
Add.self,
Pause.self,
Resume.self,
Remove.self,
Purge.self
],
defaultSubcommand: List.self
)
}
+26 -4
View File
@@ -1,8 +1,30 @@
import Foundation
import Testing
@testable import aria
@Test func example() async throws {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
// Swift Testing Documentation
// https://developer.apple.com/documentation/testing
@Test func addUriParamsEncodesPositionalArray() throws {
let params = Aria2.AddUriParams(
secret: "s3cret",
uris: ["https://example.com/file.zip"],
options: ["dir": "/tmp"]
)
let data = try JSONEncoder().encode(params)
let value = try JSONSerialization.jsonObject(with: data) as! [Any]
#expect(value.count == 3)
#expect(value[0] as? String == "token:s3cret")
#expect(value[1] as? [String] == ["https://example.com/file.zip"])
#expect(value[2] as? [String: String] == ["dir": "/tmp"])
}
@Test func addUriParamsOmitsSecretAndOptions() throws {
let params = Aria2.AddUriParams(secret: nil, uris: ["magnet:?xt=1"], options: [:])
let data = try JSONEncoder().encode(params)
let value = try JSONSerialization.jsonObject(with: data) as! [Any]
#expect(value.count == 1)
#expect(value[0] as? [String] == ["magnet:?xt=1"])
}