diff --git a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift index af433898..c554d42c 100644 --- a/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift +++ b/GraphcodeKit/Sources/CLI/GraphcodeCommand.swift @@ -13,10 +13,11 @@ import MailroomKit /// it for that. public enum GraphcodeCommand: Equatable, Sendable { case help - case listProjects + /// `json` prints a JSON array for tools that enumerate projects and workspaces. + case listProjects(json: Bool) /// The support directories `GRAPHCODE_SUPPORT_DIR` can point every other verb at, read /// from disk by `Workspace.all()` — no daemon, since each workspace has its own. - case listWorkspaces + case listWorkspaces(json: Bool) case status(projectPath: String) /// `into` is the composite whose sub-graph the loop belongs in, when one was named — /// the command is the same, addressed at a nested graph instead of this one. @@ -90,9 +91,10 @@ public enum GraphcodeCommand: Equatable, Sendable { graphcode — drive a graph of loops from the shell. USAGE - graphcode projects - graphcode workspaces every workspace on this machine, * marking the current - one; point GRAPHCODE_SUPPORT_DIR at a path to use it + graphcode projects [--json] + graphcode workspaces [--json] every workspace on this machine, * marking the + current one; point GRAPHCODE_SUPPORT_DIR at a path to use it. + --json prints either list as a JSON array graphcode status graphcode node create --title --type [options] graphcode node stop @@ -312,13 +314,14 @@ public enum GraphcodeCommand: Equatable, Sendable { return .help case "projects": - try validateFlags(arguments, allowed: []) - return .listProjects + if arguments.contains(where: isHelpFlag) { throw HelpRequested() } + try validateFlags(arguments, allowed: ["json"]) + return .listProjects(json: arguments.contains("--json")) case "workspaces": if arguments.contains(where: isHelpFlag) { throw HelpRequested() } - try validateFlags(arguments, allowed: []) - return .listWorkspaces + try validateFlags(arguments, allowed: ["json"]) + return .listWorkspaces(json: arguments.contains("--json")) case "status": let path = try take(&arguments, name: "project-path") @@ -883,6 +886,28 @@ extension GraphcodeCommand { .joined(separator: "\n") } + public static func renderJSON(_ projects: [ProjectRef]) -> String { + encodeListJSON(projects) + } + + public static func renderJSON(_ workspaces: [Workspace], current: Workspace) -> String { + struct Row: Encodable { + var name: String + var path: String + var current: Bool + } + return encodeListJSON( + workspaces.map { Row(name: $0.name, path: $0.url.path, current: $0.id == current.id) }) + } + + private static func encodeListJSON(_ rows: some Encodable) -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + encoder.dateEncodingStrategy = .iso8601 + guard let data = try? encoder.encode(rows) else { return "[]" } + return String(decoding: data, as: UTF8.self) + } + /// The room for a terminal, from the mailbox the daemon answered /// (`DaemonCommand.mailbox`). `mail list` asks for the whole room (`unread` false); /// `mail inbox` asks for what the reading loop's cursor has not covered. The diff --git a/graphcode-cli/Sources/main.swift b/graphcode-cli/Sources/main.swift index 1f2e365c..f8c76b97 100644 --- a/graphcode-cli/Sources/main.swift +++ b/graphcode-cli/Sources/main.swift @@ -49,8 +49,11 @@ if case .help = command { // Before the daemon too: the list is read from disk, and the daemon this would dial // belongs to just one of the workspaces it lists. -if case .listWorkspaces = command { - print(GraphcodeCommand.render(Workspace.all(), current: .current)) +if case .listWorkspaces(let json) = command { + print( + json + ? GraphcodeCommand.renderJSON(Workspace.all(), current: .current) + : GraphcodeCommand.render(Workspace.all(), current: .current)) exit(0) } @@ -218,13 +221,13 @@ do { case .help: break - case .listProjects: + case .listProjects(let json): try sendCommand(.listRecentProjects) let event = try client.waitForEvent { if case .recentProjectsListed = $0 { return true } else { return false } } if case .recentProjectsListed(let projects) = event { - print(GraphcodeCommand.render(projects)) + print(json ? GraphcodeCommand.renderJSON(projects) : GraphcodeCommand.render(projects)) } case .status(let projectPath): diff --git a/graphcode/Tests/GraphcodeListingCommandTests.swift b/graphcode/Tests/GraphcodeListingCommandTests.swift new file mode 100644 index 00000000..b7f45fad --- /dev/null +++ b/graphcode/Tests/GraphcodeListingCommandTests.swift @@ -0,0 +1,93 @@ +import Foundation +import GraphcodeKit +import Testing + +/// `graphcode projects` and `graphcode workspaces`: the CLI's ways to find what every +/// other verb addresses — project paths, and the support directories +/// `GRAPHCODE_SUPPORT_DIR` can point those verbs at. `--json` is for tools that +/// enumerate both. +@Suite +struct GraphcodeListingCommandTests { + @Test + func workspacesTakesOnlyJSON() throws { + #expect(try GraphcodeCommand.parse(["workspaces"]) == .listWorkspaces(json: false)) + #expect(try GraphcodeCommand.parse(["workspaces", "--json"]) == .listWorkspaces(json: true)) + #expect(throws: GraphcodeCommand.ParseError.unknownOption("--yaml")) { + try GraphcodeCommand.parse(["workspaces", "--yaml"]) + } + } + + @Test + func projectsTakesOnlyJSON() throws { + #expect(try GraphcodeCommand.parse(["projects"]) == .listProjects(json: false)) + #expect(try GraphcodeCommand.parse(["projects", "--json"]) == .listProjects(json: true)) + #expect(throws: GraphcodeCommand.ParseError.unknownOption("--jsn")) { + try GraphcodeCommand.parse(["projects", "--jsn"]) + } + } + + @Test + func askingEitherListForHelpShowsHelp() throws { + #expect(try GraphcodeCommand.parse(["workspaces", "--help"]) == .help) + #expect(try GraphcodeCommand.parse(["workspaces", "-h"]) == .help) + #expect(try GraphcodeCommand.parse(["projects", "--help"]) == .help) + #expect(try GraphcodeCommand.parse(["projects", "-h"]) == .help) + } + + @Test + func eachWorkspacePrintsItsNameAndPathWithTheCurrentOneMarked() { + let (main, work) = Self.workspaces + #expect( + GraphcodeCommand.render([main, work], current: work) + == " Default /Users/me/.graphcode\n* work /Users/me/.graphcode-work") + } + + @Test + func workspacesAsJSONCarryNamePathAndWhichIsCurrent() throws { + struct Row: Decodable, Equatable { + var name: String + var path: String + var current: Bool + } + let (main, work) = Self.workspaces + let json = GraphcodeCommand.renderJSON([main, work], current: work) + #expect( + try JSONDecoder().decode([Row].self, from: Data(json.utf8)) == [ + Row(name: "Default", path: "/Users/me/.graphcode", current: false), + Row(name: "work", path: "/Users/me/.graphcode-work", current: true), + ]) + } + + @Test + func projectsAsJSONCarryNamePathAndLastOpened() throws { + let project = ProjectRef( + path: "/Users/me/src/app", name: "app", lastOpenedAt: Date(timeIntervalSince1970: 0)) + let json = GraphcodeCommand.renderJSON([project]) + let rows = try #require( + try JSONSerialization.jsonObject(with: Data(json.utf8)) as? [[String: String]]) + #expect( + rows == [ + ["name": "app", "path": "/Users/me/src/app", "lastOpenedAt": "1970-01-01T00:00:00Z"] + ]) + } + + /// "no projects yet" is for a person; a tool needs an array it can iterate. + @Test + func noProjectsAsJSONIsAnEmptyArray() { + #expect(GraphcodeCommand.renderJSON([ProjectRef]()) == "[]") + } + + @Test + func theHelpNamesBothListsAndTheirJSON() { + #expect(GraphcodeCommand.helpText.contains("graphcode projects [--json]")) + #expect(GraphcodeCommand.helpText.contains("graphcode workspaces [--json]")) + } + + private static var workspaces: (Workspace, Workspace) { + let home = URL(fileURLWithPath: "/Users/me", isDirectory: true) + return ( + Workspace(slug: "", url: home.appendingPathComponent(".graphcode")), + Workspace(slug: "work", url: home.appendingPathComponent(".graphcode-work")) + ) + } +} diff --git a/graphcode/Tests/GraphcodeWorkspacesCommandTests.swift b/graphcode/Tests/GraphcodeWorkspacesCommandTests.swift deleted file mode 100644 index c22931f7..00000000 --- a/graphcode/Tests/GraphcodeWorkspacesCommandTests.swift +++ /dev/null @@ -1,38 +0,0 @@ -import Foundation -import GraphcodeKit -import Testing - -/// `graphcode workspaces`: the CLI's way to find the support directories that -/// `GRAPHCODE_SUPPORT_DIR` can point every other verb at. -@Suite -struct GraphcodeWorkspacesCommandTests { - @Test - func workspacesTakesNoArguments() throws { - #expect(try GraphcodeCommand.parse(["workspaces"]) == .listWorkspaces) - #expect(throws: GraphcodeCommand.ParseError.unknownOption("--json")) { - try GraphcodeCommand.parse(["workspaces", "--json"]) - } - } - - @Test - func askingWorkspacesForHelpShowsHelp() throws { - #expect(try GraphcodeCommand.parse(["workspaces", "--help"]) == .help) - #expect(try GraphcodeCommand.parse(["workspaces", "-h"]) == .help) - } - - @Test - func eachWorkspacePrintsItsNameAndPathWithTheCurrentOneMarked() { - let home = URL(fileURLWithPath: "/Users/me", isDirectory: true) - let main = Workspace(slug: "", url: home.appendingPathComponent(".graphcode")) - let work = Workspace(slug: "work", url: home.appendingPathComponent(".graphcode-work")) - - #expect( - GraphcodeCommand.render([main, work], current: work) - == " Default /Users/me/.graphcode\n* work /Users/me/.graphcode-work") - } - - @Test - func theHelpNamesTheVerb() { - #expect(GraphcodeCommand.helpText.contains("graphcode workspaces")) - } -}