diff --git a/README.md b/README.md index 0ded891..2351fb6 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ opensea --format table collections stats mfers | `swaps` | Get swap quotes for token trading | | `accounts` | Get account details | -Global options: `--api-key`, `--chain` (default: ethereum), `--format` (json/table), `--base-url` +Global options: `--api-key`, `--chain` (default: ethereum), `--format` (json/table/toon), `--base-url` Full command reference with all options and flags: [docs/cli-reference.md](docs/cli-reference.md) @@ -137,6 +137,33 @@ Table - human-readable output: opensea --format table collections list --limit 5 ``` +TOON - [Token-Oriented Object Notation](https://github.com/toon-format/toon), a compact format that uses ~40% fewer tokens than JSON. Ideal for piping output into LLM / AI agent context windows: + +```bash +opensea --format toon tokens trending --limit 5 +``` + +Example TOON output for a list of tokens: + +``` +tokens[3]{name,symbol,chain,market_cap,price_usd}: + Ethereum,ETH,ethereum,250000000000,2100.50 + Bitcoin,BTC,bitcoin,900000000000,48000.00 + Solana,SOL,solana,30000000000,95.25 +next: abc123 +``` + +TOON collapses uniform arrays of objects into CSV-like tables with a single header row, while nested objects use YAML-like indentation. The encoder follows the [TOON v3.0 spec](https://github.com/toon-format/spec/blob/main/SPEC.md) and is implemented without external dependencies. + +TOON is also available programmatically via the SDK: + +```typescript +import { formatToon } from "@opensea/cli" + +const data = await client.tokens.trending({ limit: 5 }) +console.log(formatToon(data)) +``` + ## Exit Codes - `0` - Success diff --git a/src/cli.ts b/src/cli.ts index ad2c448..dc1fbbb 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,6 +11,7 @@ import { swapsCommand, tokensCommand, } from "./commands/index.js" +import type { OutputFormat } from "./output.js" import { parseIntOption } from "./parse.js" const BANNER = ` @@ -33,7 +34,7 @@ program .addHelpText("before", BANNER) .option("--api-key ", "OpenSea API key (or set OPENSEA_API_KEY env var)") .option("--chain ", "Default chain", "ethereum") - .option("--format ", "Output format (json or table)", "json") + .option("--format ", "Output format (json, table, or toon)", "json") .option("--base-url ", "API base URL") .option("--timeout ", "Request timeout in milliseconds", "30000") .option("--verbose", "Log request and response info to stderr") @@ -64,9 +65,11 @@ function getClient(): OpenSeaClient { }) } -function getFormat(): "json" | "table" { +function getFormat(): OutputFormat { const opts = program.opts<{ format: string }>() - return opts.format === "table" ? "table" : "json" + if (opts.format === "table") return "table" + if (opts.format === "toon") return "toon" + return "json" } program.addCommand(collectionsCommand(getClient, getFormat)) diff --git a/src/commands/accounts.ts b/src/commands/accounts.ts index cf33cb5..c2e03e5 100644 --- a/src/commands/accounts.ts +++ b/src/commands/accounts.ts @@ -1,11 +1,12 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" +import type { OutputFormat } from "../output.js" import { formatOutput } from "../output.js" import type { Account } from "../types/index.js" export function accountsCommand( getClient: () => OpenSeaClient, - getFormat: () => "json" | "table", + getFormat: () => OutputFormat, ): Command { const cmd = new Command("accounts").description("Query accounts") diff --git a/src/commands/collections.ts b/src/commands/collections.ts index ff9fa0c..56d0cfd 100644 --- a/src/commands/collections.ts +++ b/src/commands/collections.ts @@ -1,5 +1,6 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" +import type { OutputFormat } from "../output.js" import { formatOutput } from "../output.js" import { parseIntOption } from "../parse.js" import type { @@ -12,7 +13,7 @@ import type { export function collectionsCommand( getClient: () => OpenSeaClient, - getFormat: () => "json" | "table", + getFormat: () => OutputFormat, ): Command { const cmd = new Command("collections").description( "Manage and query NFT collections", diff --git a/src/commands/events.ts b/src/commands/events.ts index 7fd39c2..aefd2fa 100644 --- a/src/commands/events.ts +++ b/src/commands/events.ts @@ -1,12 +1,13 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" +import type { OutputFormat } from "../output.js" import { formatOutput } from "../output.js" import { parseIntOption } from "../parse.js" import type { AssetEvent } from "../types/index.js" export function eventsCommand( getClient: () => OpenSeaClient, - getFormat: () => "json" | "table", + getFormat: () => OutputFormat, ): Command { const cmd = new Command("events").description("Query marketplace events") diff --git a/src/commands/listings.ts b/src/commands/listings.ts index c4ee0a5..460b744 100644 --- a/src/commands/listings.ts +++ b/src/commands/listings.ts @@ -1,12 +1,13 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" +import type { OutputFormat } from "../output.js" import { formatOutput } from "../output.js" import { parseIntOption } from "../parse.js" import type { Listing } from "../types/index.js" export function listingsCommand( getClient: () => OpenSeaClient, - getFormat: () => "json" | "table", + getFormat: () => OutputFormat, ): Command { const cmd = new Command("listings").description("Query NFT listings") diff --git a/src/commands/nfts.ts b/src/commands/nfts.ts index 26af0a1..ac1035b 100644 --- a/src/commands/nfts.ts +++ b/src/commands/nfts.ts @@ -1,12 +1,13 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" +import type { OutputFormat } from "../output.js" import { formatOutput } from "../output.js" import { parseIntOption } from "../parse.js" import type { Contract, NFT } from "../types/index.js" export function nftsCommand( getClient: () => OpenSeaClient, - getFormat: () => "json" | "table", + getFormat: () => OutputFormat, ): Command { const cmd = new Command("nfts").description("Query NFTs") diff --git a/src/commands/offers.ts b/src/commands/offers.ts index 5dc31e5..afa9060 100644 --- a/src/commands/offers.ts +++ b/src/commands/offers.ts @@ -1,12 +1,13 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" +import type { OutputFormat } from "../output.js" import { formatOutput } from "../output.js" import { parseIntOption } from "../parse.js" import type { Offer } from "../types/index.js" export function offersCommand( getClient: () => OpenSeaClient, - getFormat: () => "json" | "table", + getFormat: () => OutputFormat, ): Command { const cmd = new Command("offers").description("Query NFT offers") diff --git a/src/commands/search.ts b/src/commands/search.ts index 621eb8e..b9aef4b 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -1,5 +1,6 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" +import type { OutputFormat } from "../output.js" import { formatOutput } from "../output.js" import { parseIntOption } from "../parse.js" import { @@ -17,7 +18,7 @@ import type { export function searchCommand( getClient: () => OpenSeaClient, - getFormat: () => "json" | "table", + getFormat: () => OutputFormat, ): Command { const cmd = new Command("search").description( "Search for collections, NFTs, tokens, and accounts", diff --git a/src/commands/swaps.ts b/src/commands/swaps.ts index a4fc712..17f0d2e 100644 --- a/src/commands/swaps.ts +++ b/src/commands/swaps.ts @@ -1,12 +1,13 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" +import type { OutputFormat } from "../output.js" import { formatOutput } from "../output.js" import { parseFloatOption } from "../parse.js" import type { SwapQuoteResponse } from "../types/index.js" export function swapsCommand( getClient: () => OpenSeaClient, - getFormat: () => "json" | "table", + getFormat: () => OutputFormat, ): Command { const cmd = new Command("swaps").description( "Get swap quotes for token trading", diff --git a/src/commands/tokens.ts b/src/commands/tokens.ts index e18457e..9ab90ba 100644 --- a/src/commands/tokens.ts +++ b/src/commands/tokens.ts @@ -1,12 +1,13 @@ import { Command } from "commander" import type { OpenSeaClient } from "../client.js" +import type { OutputFormat } from "../output.js" import { formatOutput } from "../output.js" import { parseIntOption } from "../parse.js" import type { Chain, Token, TokenDetails } from "../types/index.js" export function tokensCommand( getClient: () => OpenSeaClient, - getFormat: () => "json" | "table", + getFormat: () => OutputFormat, ): Command { const cmd = new Command("tokens").description( "Query trending tokens, top tokens, and token details", diff --git a/src/index.ts b/src/index.ts index 16eb7bb..8e563d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,6 @@ export { OpenSeaAPIError, OpenSeaClient } from "./client.js" +export type { OutputFormat } from "./output.js" +export { formatOutput } from "./output.js" export { OpenSeaCLI } from "./sdk.js" +export { formatToon } from "./toon.js" export type * from "./types/index.js" diff --git a/src/output.ts b/src/output.ts index c6530f6..cbc78a2 100644 --- a/src/output.ts +++ b/src/output.ts @@ -1,7 +1,14 @@ -export function formatOutput(data: unknown, format: "json" | "table"): string { +import { formatToon } from "./toon.js" + +export type OutputFormat = "json" | "table" | "toon" + +export function formatOutput(data: unknown, format: OutputFormat): string { if (format === "table") { return formatTable(data) } + if (format === "toon") { + return formatToon(data) + } return JSON.stringify(data, null, 2) } diff --git a/src/toon.ts b/src/toon.ts new file mode 100644 index 0000000..20af7ea --- /dev/null +++ b/src/toon.ts @@ -0,0 +1,338 @@ +const INDENT = " " + +const NUMERIC_RE = /^-?\d+(?:\.\d+)?(?:e[+-]?\d+)?$/i +const LEADING_ZERO_RE = /^0\d+$/ +const UNQUOTED_KEY_RE = /^[A-Za-z_][A-Za-z0-9_.]*$/ + +function escapeString(s: string): string { + return s + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t") +} + +function needsQuoting(value: string, delimiter: string): boolean { + if (value === "") return true + if (value !== value.trim()) return true + if (value === "true" || value === "false" || value === "null") return true + if (NUMERIC_RE.test(value) || LEADING_ZERO_RE.test(value)) return true + if (/[:"\\[\]{}]/.test(value)) return true + if (/[\n\r\t]/.test(value)) return true + if (value.includes(delimiter)) return true + if (value.startsWith("-")) return true + return false +} + +function encodeKey(key: string): string { + if (UNQUOTED_KEY_RE.test(key)) return key + return `"${escapeString(key)}"` +} + +function encodePrimitive(value: unknown, delimiter: string): string { + if (value === null) return "null" + if (value === undefined) return "null" + if (typeof value === "boolean") return String(value) + if (typeof value === "number") return String(value) + if (typeof value === "string") { + if (needsQuoting(value, delimiter)) { + return `"${escapeString(value)}"` + } + return value + } + return `"${escapeString(String(value))}"` +} + +function isPrimitive(value: unknown): boolean { + return ( + value === null || + value === undefined || + typeof value === "boolean" || + typeof value === "number" || + typeof value === "string" + ) +} + +function isTabular(arr: unknown[]): boolean { + if (arr.length === 0) return false + const first = arr[0] + if (first === null || typeof first !== "object" || Array.isArray(first)) + return false + const keys = Object.keys(first as Record).sort() + for (const item of arr) { + if (item === null || typeof item !== "object" || Array.isArray(item)) + return false + const itemKeys = Object.keys(item as Record).sort() + if (itemKeys.length !== keys.length) return false + for (let i = 0; i < keys.length; i++) { + if (itemKeys[i] !== keys[i]) return false + } + for (const k of keys) { + if (!isPrimitive((item as Record)[k])) return false + } + } + return true +} + +function isPrimitiveArray(arr: unknown[]): boolean { + return arr.every(isPrimitive) +} + +function encodeValue(value: unknown, depth: number, delimiter: string): string { + if (isPrimitive(value)) { + return encodePrimitive(value, delimiter) + } + + if (Array.isArray(value)) { + return encodeArray(value, depth, delimiter) + } + + if (typeof value === "object" && value !== null) { + return encodeObject(value as Record, depth, delimiter) + } + + return encodePrimitive(value, delimiter) +} + +function encodeObject( + obj: Record, + depth: number, + delimiter: string, +): string { + const entries = Object.entries(obj) + if (entries.length === 0) return "" + + const lines: string[] = [] + const prefix = INDENT.repeat(depth) + + for (const [key, value] of entries) { + const encodedKey = encodeKey(key) + + if (isPrimitive(value)) { + lines.push(`${prefix}${encodedKey}: ${encodePrimitive(value, delimiter)}`) + } else if (Array.isArray(value)) { + lines.push(encodeArrayField(encodedKey, value, depth, delimiter)) + } else if (typeof value === "object" && value !== null) { + const nested = value as Record + if (Object.keys(nested).length === 0) { + lines.push(`${prefix}${encodedKey}:`) + } else { + lines.push(`${prefix}${encodedKey}:`) + lines.push(encodeObject(nested, depth + 1, delimiter)) + } + } + } + + return lines.join("\n") +} + +function encodeArrayField( + encodedKey: string, + arr: unknown[], + depth: number, + delimiter: string, +): string { + const prefix = INDENT.repeat(depth) + + if (arr.length === 0) { + return `${prefix}${encodedKey}[0]:` + } + + if (isPrimitiveArray(arr)) { + const values = arr.map(v => encodePrimitive(v, delimiter)).join(delimiter) + return `${prefix}${encodedKey}[${arr.length}]: ${values}` + } + + if (isTabular(arr)) { + const firstObj = arr[0] as Record + const fields = Object.keys(firstObj) + const fieldHeader = fields.map(encodeKey).join(delimiter) + const lines: string[] = [] + lines.push(`${prefix}${encodedKey}[${arr.length}]{${fieldHeader}}:`) + const rowPrefix = INDENT.repeat(depth + 1) + for (const item of arr) { + const obj = item as Record + const row = fields + .map(f => encodePrimitive(obj[f], delimiter)) + .join(delimiter) + lines.push(`${rowPrefix}${row}`) + } + return lines.join("\n") + } + + return encodeExpandedList(encodedKey, arr, depth, delimiter) +} + +function encodeExpandedList( + encodedKey: string, + arr: unknown[], + depth: number, + delimiter: string, +): string { + const prefix = INDENT.repeat(depth) + const itemPrefix = INDENT.repeat(depth + 1) + const lines: string[] = [] + lines.push(`${prefix}${encodedKey}[${arr.length}]:`) + + for (const item of arr) { + if (isPrimitive(item)) { + lines.push(`${itemPrefix}- ${encodePrimitive(item, delimiter)}`) + } else if (Array.isArray(item)) { + if (isPrimitiveArray(item)) { + const values = item + .map(v => encodePrimitive(v, delimiter)) + .join(delimiter) + lines.push(`${itemPrefix}- [${item.length}]: ${values}`) + } else { + lines.push(`${itemPrefix}- [${item.length}]:`) + for (const inner of item) { + lines.push(encodeValue(inner, depth + 2, delimiter)) + } + } + } else if (typeof item === "object" && item !== null) { + const obj = item as Record + const entries = Object.entries(obj) + if (entries.length === 0) { + lines.push(`${itemPrefix}-`) + } else { + const [firstKey, firstValue] = entries[0] + const ek = encodeKey(firstKey) + + if (Array.isArray(firstValue)) { + const arrayLine = encodeArrayField(ek, firstValue, 0, delimiter) + lines.push(`${itemPrefix}- ${arrayLine.trimStart()}`) + } else if (isPrimitive(firstValue)) { + lines.push( + `${itemPrefix}- ${ek}: ${encodePrimitive(firstValue, delimiter)}`, + ) + } else { + lines.push(`${itemPrefix}- ${ek}:`) + lines.push( + encodeObject( + firstValue as Record, + depth + 2, + delimiter, + ), + ) + } + + for (let i = 1; i < entries.length; i++) { + const [k, v] = entries[i] + const encodedK = encodeKey(k) + if (isPrimitive(v)) { + lines.push( + `${INDENT.repeat(depth + 2)}${encodedK}: ${encodePrimitive(v, delimiter)}`, + ) + } else if (Array.isArray(v)) { + lines.push(encodeArrayField(encodedK, v, depth + 2, delimiter)) + } else if (typeof v === "object" && v !== null) { + lines.push(`${INDENT.repeat(depth + 2)}${encodedK}:`) + lines.push( + encodeObject(v as Record, depth + 3, delimiter), + ) + } + } + } + } + } + + return lines.join("\n") +} + +function encodeArray(arr: unknown[], depth: number, delimiter: string): string { + const prefix = INDENT.repeat(depth) + + if (arr.length === 0) { + return `${prefix}[0]:` + } + + if (isPrimitiveArray(arr)) { + const values = arr.map(v => encodePrimitive(v, delimiter)).join(delimiter) + return `${prefix}[${arr.length}]: ${values}` + } + + if (isTabular(arr)) { + const firstObj = arr[0] as Record + const fields = Object.keys(firstObj) + const fieldHeader = fields.map(encodeKey).join(delimiter) + const lines: string[] = [] + lines.push(`${prefix}[${arr.length}]{${fieldHeader}}:`) + const rowPrefix = INDENT.repeat(depth + 1) + for (const item of arr) { + const obj = item as Record + const row = fields + .map(f => encodePrimitive(obj[f], delimiter)) + .join(delimiter) + lines.push(`${rowPrefix}${row}`) + } + return lines.join("\n") + } + + const lines: string[] = [] + lines.push(`${prefix}[${arr.length}]:`) + const itemPrefix = INDENT.repeat(depth + 1) + for (const item of arr) { + if (isPrimitive(item)) { + lines.push(`${itemPrefix}- ${encodePrimitive(item, delimiter)}`) + } else if (Array.isArray(item)) { + if (isPrimitiveArray(item)) { + const values = item + .map(v => encodePrimitive(v, delimiter)) + .join(delimiter) + lines.push(`${itemPrefix}- [${item.length}]: ${values}`) + } else { + lines.push(`${itemPrefix}- [${item.length}]:`) + } + } else if (typeof item === "object" && item !== null) { + const obj = item as Record + const entries = Object.entries(obj) + if (entries.length > 0) { + const [firstKey, firstValue] = entries[0] + const ek = encodeKey(firstKey) + if (isPrimitive(firstValue)) { + lines.push( + `${itemPrefix}- ${ek}: ${encodePrimitive(firstValue, delimiter)}`, + ) + } else { + lines.push(`${itemPrefix}- ${ek}:`) + lines.push(encodeValue(firstValue, depth + 2, delimiter)) + } + for (let i = 1; i < entries.length; i++) { + const [k, v] = entries[i] + const encodedK = encodeKey(k) + if (isPrimitive(v)) { + lines.push( + `${INDENT.repeat(depth + 2)}${encodedK}: ${encodePrimitive(v, delimiter)}`, + ) + } else if (Array.isArray(v)) { + lines.push(encodeArrayField(encodedK, v, depth + 2, delimiter)) + } else if (typeof v === "object" && v !== null) { + lines.push(`${INDENT.repeat(depth + 2)}${encodedK}:`) + lines.push( + encodeObject(v as Record, depth + 3, delimiter), + ) + } + } + } + } + } + + return lines.join("\n") +} + +export function formatToon(data: unknown): string { + if (isPrimitive(data)) { + return encodePrimitive(data, ",") + } + + if (Array.isArray(data)) { + return encodeArray(data, 0, ",") + } + + if (typeof data === "object" && data !== null) { + return encodeObject(data as Record, 0, ",") + } + + return String(data) +} diff --git a/test/mocks.ts b/test/mocks.ts index 29e2d0c..d93175c 100644 --- a/test/mocks.ts +++ b/test/mocks.ts @@ -1,5 +1,6 @@ import { type Mock, vi } from "vitest" import type { OpenSeaClient } from "../src/client.js" +import type { OutputFormat } from "../src/output.js" export type MockClient = { get: Mock @@ -10,7 +11,7 @@ export type MockClient = { export type CommandTestContext = { mockClient: MockClient getClient: () => OpenSeaClient - getFormat: () => "json" | "table" + getFormat: () => OutputFormat consoleSpy: ReturnType } @@ -21,7 +22,7 @@ export function createCommandTestContext(): CommandTestContext { graphql: vi.fn(), } const getClient = () => mockClient as unknown as OpenSeaClient - const getFormat = () => "json" as "json" | "table" + const getFormat = () => "json" as OutputFormat const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {}) return { mockClient, getClient, getFormat, consoleSpy } diff --git a/test/toon.test.ts b/test/toon.test.ts new file mode 100644 index 0000000..7fb8dd2 --- /dev/null +++ b/test/toon.test.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from "vitest" +import { formatToon } from "../src/toon.js" + +describe("formatToon", () => { + describe("primitives", () => { + it("encodes null", () => { + expect(formatToon(null)).toBe("null") + }) + + it("encodes booleans", () => { + expect(formatToon(true)).toBe("true") + expect(formatToon(false)).toBe("false") + }) + + it("encodes numbers", () => { + expect(formatToon(42)).toBe("42") + expect(formatToon(3.14)).toBe("3.14") + expect(formatToon(0)).toBe("0") + expect(formatToon(-1)).toBe("-1") + }) + + it("encodes simple strings unquoted", () => { + expect(formatToon("hello")).toBe("hello") + }) + + it("quotes strings that look like booleans", () => { + expect(formatToon("true")).toBe('"true"') + expect(formatToon("false")).toBe('"false"') + }) + + it("quotes strings that look like null", () => { + expect(formatToon("null")).toBe('"null"') + }) + + it("quotes strings that look like numbers", () => { + expect(formatToon("42")).toBe('"42"') + expect(formatToon("3.14")).toBe('"3.14"') + }) + + it("quotes empty strings", () => { + expect(formatToon("")).toBe('""') + }) + + it("quotes strings with special characters", () => { + expect(formatToon("hello:world")).toBe('"hello:world"') + expect(formatToon('say "hi"')).toBe('"say \\"hi\\""') + expect(formatToon("line\nnewline")).toBe('"line\\nnewline"') + }) + + it("quotes strings starting with hyphen", () => { + expect(formatToon("-value")).toBe('"-value"') + expect(formatToon("-")).toBe('"-"') + }) + + it("quotes strings with leading/trailing whitespace", () => { + expect(formatToon(" hello")).toBe('" hello"') + expect(formatToon("hello ")).toBe('"hello "') + }) + + it("does not quote strings with internal spaces", () => { + expect(formatToon("hello world")).toBe("hello world") + }) + }) + + describe("objects", () => { + it("encodes a flat object", () => { + const result = formatToon({ name: "Alice", age: 30 }) + expect(result).toBe("name: Alice\nage: 30") + }) + + it("encodes nested objects", () => { + const result = formatToon({ + user: { name: "Alice", role: "admin" }, + }) + expect(result).toBe("user:\n name: Alice\n role: admin") + }) + + it("encodes an empty object as empty string", () => { + expect(formatToon({})).toBe("") + }) + + it("quotes keys that need quoting", () => { + const result = formatToon({ "my-key": "value" }) + expect(result).toBe('"my-key": value') + }) + }) + + describe("primitive arrays", () => { + it("encodes an inline primitive array at root", () => { + const result = formatToon([1, 2, 3]) + expect(result).toBe("[3]: 1,2,3") + }) + + it("encodes a string array at root", () => { + const result = formatToon(["ana", "luis", "sam"]) + expect(result).toBe("[3]: ana,luis,sam") + }) + + it("encodes empty array", () => { + expect(formatToon([])).toBe("[0]:") + }) + + it("encodes array with mixed primitives", () => { + const result = formatToon([1, "hello", true, null]) + expect(result).toBe("[4]: 1,hello,true,null") + }) + + it("quotes values containing commas", () => { + const result = formatToon(["a,b", "c"]) + expect(result).toBe('[2]: "a,b",c') + }) + + it("encodes primitive array as object field", () => { + const result = formatToon({ friends: ["ana", "luis", "sam"] }) + expect(result).toBe("friends[3]: ana,luis,sam") + }) + }) + + describe("tabular arrays (arrays of uniform objects)", () => { + it("encodes a tabular array at root", () => { + const result = formatToon([ + { id: 1, name: "Alice", role: "admin" }, + { id: 2, name: "Bob", role: "user" }, + ]) + expect(result).toBe("[2]{id,name,role}:\n 1,Alice,admin\n 2,Bob,user") + }) + + it("encodes a tabular array as object field", () => { + const result = formatToon({ + users: [ + { id: 1, name: "Alice", role: "admin" }, + { id: 2, name: "Bob", role: "user" }, + ], + }) + expect(result).toBe( + "users[2]{id,name,role}:\n 1,Alice,admin\n 2,Bob,user", + ) + }) + + it("quotes tabular values that contain commas", () => { + const result = formatToon([ + { name: "Smith, John", age: 30 }, + { name: "Doe, Jane", age: 25 }, + ]) + expect(result).toBe( + '[2]{name,age}:\n "Smith, John",30\n "Doe, Jane",25', + ) + }) + + it("handles null values in tabular rows", () => { + const result = formatToon([ + { id: 1, value: null }, + { id: 2, value: "test" }, + ]) + expect(result).toBe("[2]{id,value}:\n 1,null\n 2,test") + }) + }) + + describe("mixed/non-uniform arrays", () => { + it("encodes an array of objects with nested values as expanded list", () => { + const result = formatToon([{ name: "Alice", meta: { role: "admin" } }]) + expect(result).toContain("[1]:") + expect(result).toContain("- name: Alice") + expect(result).toContain("meta:") + expect(result).toContain("role: admin") + }) + }) + + describe("complex nested structures", () => { + it("encodes the hikes example from the spec", () => { + const data = { + context: { + task: "Our favorite hikes together", + location: "Boulder", + season: "spring_2025", + }, + friends: ["ana", "luis", "sam"], + hikes: [ + { + id: 1, + name: "Blue Lake Trail", + distanceKm: 7.5, + elevationGain: 320, + companion: "ana", + wasSunny: true, + }, + { + id: 2, + name: "Ridge Overlook", + distanceKm: 9.2, + elevationGain: 540, + companion: "luis", + wasSunny: false, + }, + { + id: 3, + name: "Wildflower Loop", + distanceKm: 5.1, + elevationGain: 180, + companion: "sam", + wasSunny: true, + }, + ], + } + + const result = formatToon(data) + expect(result).toContain("context:") + expect(result).toContain(" task: Our favorite hikes together") + expect(result).toContain(" location: Boulder") + expect(result).toContain(" season: spring_2025") + expect(result).toContain("friends[3]: ana,luis,sam") + expect(result).toContain( + "hikes[3]{id,name,distanceKm,elevationGain,companion,wasSunny}:", + ) + expect(result).toContain(" 1,Blue Lake Trail,7.5,320,ana,true") + expect(result).toContain(" 2,Ridge Overlook,9.2,540,luis,false") + expect(result).toContain(" 3,Wildflower Loop,5.1,180,sam,true") + }) + + it("encodes deeply nested objects", () => { + const data = { + a: { b: { c: { d: "deep" } } }, + } + const result = formatToon(data) + expect(result).toBe("a:\n b:\n c:\n d: deep") + }) + }) + + describe("formatOutput integration", () => { + it("is used by formatOutput with toon format", async () => { + const { formatOutput } = await import("../src/output.js") + const data = { name: "test", value: 42 } + const result = formatOutput(data, "toon") + expect(result).toBe("name: test\nvalue: 42") + }) + }) +})