Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions packages/cli/src/utils/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("@studiometa/forge-core")>();
return {
listServers: vi.fn(),
listSites: vi.fn(),
matchByName: actual.matchByName,
};
});

const mockServer = (id: number, name: string): ForgeServer =>
({
Expand Down
16 changes: 3 additions & 13 deletions packages/cli/src/utils/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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", [
Expand Down Expand Up @@ -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", [
Expand Down
21 changes: 5 additions & 16 deletions packages/core/src/executors/servers/resolve.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -28,27 +29,15 @@ export async function resolveServers(
): Promise<ExecutorResult<ResolveResult>> {
const response = await ctx.client.get<ServersResponse>("/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,
},
};
}
21 changes: 5 additions & 16 deletions packages/core/src/executors/sites/resolve.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -29,27 +30,15 @@ export async function resolveSites(
): Promise<ExecutorResult<ResolveSiteResult>> {
const response = await ctx.client.get<SitesResponse>(`/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,
},
};
}
4 changes: 4 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
88 changes: 88 additions & 0 deletions packages/core/src/utils/name-matcher.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
29 changes: 29 additions & 0 deletions packages/core/src/utils/name-matcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* Result of a name matching operation.
* Provides both exact and partial matches for flexible consumption.
*/
export interface NameMatch<T> {
/** 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<T>(
items: T[],
query: string,
getName: (item: T) => string,
): NameMatch<T> {
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 };
}