diff --git a/packages/cli/src/utils/resolve.test.ts b/packages/cli/src/utils/resolve.test.ts index f5425d3..41071c9 100644 --- a/packages/cli/src/utils/resolve.test.ts +++ b/packages/cli/src/utils/resolve.test.ts @@ -5,10 +5,14 @@ import type { ForgeServer, ForgeSite } from "@studiometa/forge-api"; import { resolveServerId, resolveSiteId } from "./resolve.ts"; import { ValidationError } from "../errors.ts"; -vi.mock("@studiometa/forge-core", () => ({ - listServers: vi.fn(), - listSites: vi.fn(), -})); +vi.mock("@studiometa/forge-core", async (importOriginal) => { + const actual = await importOriginal(); + return { + listServers: vi.fn(), + listSites: vi.fn(), + matchByName: actual.matchByName, + }; +}); const mockServer = (id: number, name: string): ForgeServer => ({ diff --git a/packages/cli/src/utils/resolve.ts b/packages/cli/src/utils/resolve.ts index f823c87..194d6a2 100644 --- a/packages/cli/src/utils/resolve.ts +++ b/packages/cli/src/utils/resolve.ts @@ -4,7 +4,7 @@ * Accepts numeric IDs (used as-is) or plain strings (resolved by name/partial match). */ -import { listServers, listSites } from "@studiometa/forge-core"; +import { listServers, listSites, matchByName } from "@studiometa/forge-core"; import type { ExecutorContext } from "@studiometa/forge-core"; import type { ForgeServer, ForgeSite } from "@studiometa/forge-api"; import { ValidationError } from "../errors.ts"; @@ -25,17 +25,12 @@ export async function resolveServerId(value: string, execCtx: ExecutorContext): const result = await listServers({}, execCtx); const servers = result.data as ForgeServer[]; - const lower = value.toLowerCase(); + const { exact, partial } = matchByName(servers, value, (s) => s.name); - // Exact match by name - const exact = servers.filter((s) => s.name.toLowerCase() === lower); if (exact.length === 1) { return String(exact[0].id); } - // Partial match - const partial = servers.filter((s) => s.name.toLowerCase().includes(lower)); - if (partial.length === 0) { const available = servers.map((s) => ` ${s.name} (${s.id})`).join("\n"); throw new ValidationError(`No server found matching "${value}"`, "server", [ @@ -75,17 +70,12 @@ export async function resolveSiteId( const result = await listSites({ server_id: serverId }, execCtx); const sites = result.data as ForgeSite[]; - const lower = value.toLowerCase(); + const { exact, partial } = matchByName(sites, value, (s) => s.name); - // Exact match by domain - const exact = sites.filter((s) => s.name.toLowerCase() === lower); if (exact.length === 1) { return String(exact[0].id); } - // Partial match - const partial = sites.filter((s) => s.name.toLowerCase().includes(lower)); - if (partial.length === 0) { const available = sites.map((s) => ` ${s.name} (${s.id})`).join("\n"); throw new ValidationError(`No site found matching "${value}" on server ${serverId}`, "site", [ diff --git a/packages/core/src/executors/servers/resolve.ts b/packages/core/src/executors/servers/resolve.ts index 8983755..4d0b53b 100644 --- a/packages/core/src/executors/servers/resolve.ts +++ b/packages/core/src/executors/servers/resolve.ts @@ -1,5 +1,6 @@ import type { ServersResponse } from "@studiometa/forge-api"; import type { ExecutorContext, ExecutorResult } from "../../context.ts"; +import { matchByName } from "../../utils/name-matcher.ts"; export interface ResolveServersOptions { query: string; @@ -28,27 +29,15 @@ export async function resolveServers( ): Promise> { const response = await ctx.client.get("/servers"); const servers = response.servers; - const lower = options.query.toLowerCase(); - // Exact match first - const exact = servers.filter((s) => s.name.toLowerCase() === lower); - if (exact.length === 1) { - return { - data: { - query: options.query, - matches: [{ id: exact[0]!.id, name: exact[0]!.name }], - total: 1, - }, - }; - } + const match = matchByName(servers, options.query, (s) => s.name); + const matches = match.exact.length === 1 ? match.exact : match.partial; - // Partial match - const partial = servers.filter((s) => s.name.toLowerCase().includes(lower)); return { data: { query: options.query, - matches: partial.map((s) => ({ id: s.id, name: s.name })), - total: partial.length, + matches: matches.map((s) => ({ id: s.id, name: s.name })), + total: matches.length, }, }; } diff --git a/packages/core/src/executors/sites/resolve.ts b/packages/core/src/executors/sites/resolve.ts index 99dc515..6e1477c 100644 --- a/packages/core/src/executors/sites/resolve.ts +++ b/packages/core/src/executors/sites/resolve.ts @@ -1,5 +1,6 @@ import type { SitesResponse } from "@studiometa/forge-api"; import type { ExecutorContext, ExecutorResult } from "../../context.ts"; +import { matchByName } from "../../utils/name-matcher.ts"; export interface ResolveSitesOptions { server_id: string; @@ -29,27 +30,15 @@ export async function resolveSites( ): Promise> { const response = await ctx.client.get(`/servers/${options.server_id}/sites`); const sites = response.sites; - const lower = options.query.toLowerCase(); - // Exact match first - const exact = sites.filter((s) => s.name.toLowerCase() === lower); - if (exact.length === 1) { - return { - data: { - query: options.query, - matches: [{ id: exact[0]!.id, name: exact[0]!.name }], - total: 1, - }, - }; - } + const match = matchByName(sites, options.query, (s) => s.name); + const matches = match.exact.length === 1 ? match.exact : match.partial; - // Partial match - const partial = sites.filter((s) => s.name.toLowerCase().includes(lower)); return { data: { query: options.query, - matches: partial.map((s) => ({ id: s.id, name: s.name })), - total: partial.length, + matches: matches.map((s) => ({ id: s.id, name: s.name })), + total: matches.length, }, }; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 15cfae2..2e29d01 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,6 +4,10 @@ export { ACTIONS, RESOURCES } from "./constants.ts"; export type { Action, Resource } from "./constants.ts"; +// Utilities +export { matchByName } from "./utils/name-matcher.ts"; +export type { NameMatch } from "./utils/name-matcher.ts"; + // Audit logging export { createAuditLogger, sanitizeArgs, getAuditLogPath } from "./logger.ts"; export type { AuditLogger, AuditLogEntry } from "./logger.ts"; diff --git a/packages/core/src/utils/name-matcher.test.ts b/packages/core/src/utils/name-matcher.test.ts new file mode 100644 index 0000000..ed690d7 --- /dev/null +++ b/packages/core/src/utils/name-matcher.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; + +import { matchByName } from "./name-matcher.ts"; + +interface Item { + id: number; + name: string; +} + +const items: Item[] = [ + { id: 1, name: "prod-web-1" }, + { id: 2, name: "prod-web-2" }, + { id: 3, name: "staging-web-1" }, + { id: 4, name: "production" }, +]; + +const getName = (item: Item) => item.name; + +describe("matchByName", () => { + it("returns exact match in exact array", () => { + const { exact } = matchByName(items, "production", getName); + expect(exact).toHaveLength(1); + expect(exact[0]!.id).toBe(4); + }); + + it("returns partial matches in partial array", () => { + const { partial } = matchByName(items, "prod", getName); + expect(partial).toHaveLength(3); // prod-web-1, prod-web-2, production + }); + + it("includes exact matches in partial array", () => { + const { exact, partial } = matchByName(items, "production", getName); + expect(exact).toHaveLength(1); + expect(partial).toContainEqual(exact[0]); + }); + + it("is case insensitive for exact match", () => { + const { exact } = matchByName(items, "PRODUCTION", getName); + expect(exact).toHaveLength(1); + expect(exact[0]!.name).toBe("production"); + }); + + it("is case insensitive for partial match", () => { + const { partial } = matchByName(items, "PROD", getName); + expect(partial).toHaveLength(3); + }); + + it("matches all items with empty query", () => { + const { exact, partial } = matchByName(items, "", getName); + // Every name includes "" and equals "" is false → exact empty, partial all + expect(exact).toHaveLength(0); + expect(partial).toHaveLength(items.length); + }); + + it("returns empty arrays when no matches", () => { + const { exact, partial } = matchByName(items, "nonexistent", getName); + expect(exact).toHaveLength(0); + expect(partial).toHaveLength(0); + }); + + it("works with a custom getName function", () => { + const data = [ + { id: 1, label: "Alpha" }, + { id: 2, label: "Beta" }, + { id: 3, label: "alpha-extra" }, + ]; + const { exact, partial } = matchByName(data, "alpha", (d) => d.label); + expect(exact).toHaveLength(1); + expect(exact[0]!.id).toBe(1); + expect(partial).toHaveLength(2); + }); + + it("returns empty arrays for empty items array", () => { + const { exact, partial } = matchByName([], "prod", getName); + expect(exact).toHaveLength(0); + expect(partial).toHaveLength(0); + }); + + it("returns multiple exact matches when names are duplicated", () => { + const dupes: Item[] = [ + { id: 1, name: "prod" }, + { id: 2, name: "prod" }, + ]; + const { exact, partial } = matchByName(dupes, "prod", getName); + expect(exact).toHaveLength(2); + expect(partial).toHaveLength(2); + }); +}); diff --git a/packages/core/src/utils/name-matcher.ts b/packages/core/src/utils/name-matcher.ts new file mode 100644 index 0000000..729d74d --- /dev/null +++ b/packages/core/src/utils/name-matcher.ts @@ -0,0 +1,29 @@ +/** + * Result of a name matching operation. + * Provides both exact and partial matches for flexible consumption. + */ +export interface NameMatch { + /** Items whose name matches the query exactly (case-insensitive). */ + exact: T[]; + /** Items whose name contains the query (case-insensitive). Includes exact matches. */ + partial: T[]; +} + +/** + * Match items by name using case-insensitive exact and partial matching. + * + * @param items - The items to search through. + * @param query - The search query. + * @param getName - Function to extract the name from an item. + * @returns Object with exact and partial match arrays. + */ +export function matchByName( + items: T[], + query: string, + getName: (item: T) => string, +): NameMatch { + const lower = query.toLowerCase(); + const exact = items.filter((item) => getName(item).toLowerCase() === lower); + const partial = items.filter((item) => getName(item).toLowerCase().includes(lower)); + return { exact, partial }; +}