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
492 changes: 492 additions & 0 deletions integration/model_validate_test.ts

Large diffs are not rendered by default.

8 changes: 3 additions & 5 deletions src/cli/commands/model_create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,7 @@ import { ModelType } from "../../domain/models/model_type.ts";
import { ModelInput } from "../../domain/models/model_input.ts";
import { YamlInputRepository } from "../../infrastructure/persistence/yaml_input_repository.ts";
import { modelRegistry } from "../../domain/models/model.ts";
import { echoModel } from "../../domain/models/echo/echo_model.ts";

// Register the echo model
modelRegistry.register(echoModel);
import { modelValidateCommand } from "./model_validate.ts";

// deno-lint-ignore no-explicit-any
type AnyOptions = any;
Expand Down Expand Up @@ -74,4 +71,5 @@ export const modelCommand = new Command()
.action(function () {
this.showHelp();
})
.command("create", modelCreateCommand);
.command("create", modelCreateCommand)
.command("validate", modelValidateCommand);
7 changes: 7 additions & 0 deletions src/cli/commands/model_create_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,10 @@ Deno.test("modelCreateCommand is registered as subcommand", async () => {
const createCmd = commands.find((c) => c.getName() === "create");
assertEquals(createCmd !== undefined, true);
});

Deno.test("modelValidateCommand is registered as subcommand", async () => {
const { modelCommand } = await import("./model_create.ts");
const commands = modelCommand.getCommands();
const validateCmd = commands.find((c) => c.getName() === "validate");
assertEquals(validateCmd !== undefined, true);
});
193 changes: 193 additions & 0 deletions src/cli/commands/model_validate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { Command } from "@cliffy/command";
import {
type ModelValidateData,
renderModelValidate,
renderModelValidateAll,
type ValidationItemData,
} from "../../presentation/output/model_validate_output.tsx";
import { createContext, type GlobalOptions } from "../context.ts";
import {
createModelInputId,
type ModelInput,
} from "../../domain/models/model_input.ts";
import type { ModelType } from "../../domain/models/model_type.ts";
import { YamlInputRepository } from "../../infrastructure/persistence/yaml_input_repository.ts";
import { YamlResourceRepository } from "../../infrastructure/persistence/yaml_resource_repository.ts";
import { modelRegistry } from "../../domain/models/model.ts";
import {
DefaultModelValidationService,
type ValidationResult,
} from "../../domain/models/validation_service.ts";

/**
* UUID v4 regex pattern for detecting if an argument is a UUID.
*/
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

/**
* Checks if a string looks like a UUID.
*/
function isUuid(value: string): boolean {
return UUID_PATTERN.test(value);
}

/**
* Finds an input by ID, searching across all registered model types.
*/
async function findInputByIdGlobal(
inputRepo: YamlInputRepository,
id: string,
): Promise<{ input: ModelInput; type: ModelType } | null> {
const inputId = createModelInputId(id);

for (const type of modelRegistry.types()) {
const input = await inputRepo.findById(type, inputId);
if (input) {
return { input, type };
}
}

return null;
}

/**
* Converts ValidationResult array to ValidationItemData array for presentation.
*/
function toValidationItemData(
results: ValidationResult[],
): ValidationItemData[] {
return results.map((r) => ({
name: r.name,
passed: r.passed,
error: r.error,
}));
}

// deno-lint-ignore no-explicit-any
type AnyOptions = any;

export const modelValidateCommand = new Command()
.name("validate")
.description("Validate a model input against its schema")
.arguments("[model_id_or_name:string]")
.option("--repo-dir <dir:string>", "Repository directory", { default: "." })
.action(
async function (options: AnyOptions, modelIdOrName?: string) {
const ctx = createContext(options as GlobalOptions, "model-validate");
const repoDir = options.repoDir ?? ".";
const inputRepo = new YamlInputRepository(repoDir);
const resourceRepo = new YamlResourceRepository(repoDir);
const validationService = new DefaultModelValidationService();

// If no argument provided, validate all models
if (!modelIdOrName) {
ctx.logger.debug`Validating all models`;
const allInputs = await inputRepo.findAllGlobal();

if (allInputs.length === 0) {
throw new Error("No models found");
}

const results: ModelValidateData[] = [];
for (const { input, type } of allInputs) {
const definition = modelRegistry.get(type);
if (!definition) {
continue;
}

const resource = await resourceRepo.findByInputId(type, input.id);
const validationResults = await validationService.validateModel(
input,
definition,
resource,
);

const validations = toValidationItemData(validationResults);
const allPassed = validationResults.every((r) => r.passed);

results.push({
modelId: input.id,
modelName: input.name,
type: type.normalized,
validations,
passed: allPassed,
});
}

renderModelValidateAll(results, ctx.outputMode);

const anyFailed = results.some((r) => !r.passed);
ctx.logger.debug`Validation completed, anyFailed=${anyFailed}`;

if (anyFailed) {
Deno.exit(1);
}
return;
}

// Single model validation (existing behavior)
ctx.logger.debug`Validating model: ${modelIdOrName}`;

// Look up the model input
let input: ModelInput;
let modelType: ModelType;

if (isUuid(modelIdOrName)) {
ctx.logger.debug`Looking up by ID: ${modelIdOrName}`;
const result = await findInputByIdGlobal(inputRepo, modelIdOrName);
if (!result) {
throw new Error(`Model not found: ${modelIdOrName}`);
}
input = result.input;
modelType = result.type;
} else {
ctx.logger.debug`Looking up by name: ${modelIdOrName}`;
const result = await inputRepo.findByNameGlobal(modelIdOrName);
if (!result) {
throw new Error(`Model not found: ${modelIdOrName}`);
}
input = result.input;
modelType = result.type;
}

ctx.logger
.debug`Found model: id=${input.id}, type=${modelType.normalized}`;

// Get the model definition
const definition = modelRegistry.get(modelType);
if (!definition) {
throw new Error(`Unknown model type: ${modelType.normalized}`);
}

// Load the resource if it exists
const resource = await resourceRepo.findByInputId(modelType, input.id);
ctx.logger.debug`Resource exists: ${resource !== null}`;

// Run validations
const results = await validationService.validateModel(
input,
definition,
resource,
);

const validations = toValidationItemData(results);
const allPassed = results.every((r) => r.passed);

const data: ModelValidateData = {
modelId: input.id,
modelName: input.name,
type: modelType.normalized,
validations,
passed: allPassed,
};

renderModelValidate(data, ctx.outputMode);
ctx.logger.debug`Validation completed, passed=${allPassed}`;

// Exit with code 1 if any validation failed
if (!allPassed) {
Deno.exit(1);
}
},
);
21 changes: 21 additions & 0 deletions src/cli/commands/model_validate_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { assertEquals } from "@std/assert";
import { initializeLogging } from "../../infrastructure/logging/logger.ts";

// Initialize logging for tests
await initializeLogging({ debugLogs: false });

// Note: Full CLI integration tests are in integration/model_validate_test.ts
// These tests verify the command module loads correctly

Deno.test("modelValidateCommand module loads", async () => {
const { modelValidateCommand } = await import("./model_validate.ts");
assertEquals(modelValidateCommand.getName(), "validate");
});

Deno.test("modelValidateCommand has correct description", async () => {
const { modelValidateCommand } = await import("./model_validate.ts");
assertEquals(
modelValidateCommand.getDescription(),
"Validate a model input against its schema",
);
});
3 changes: 3 additions & 0 deletions src/cli/mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import { VERSION, versionCommand } from "./commands/version.ts";
import { modelCommand } from "./commands/model_create.ts";
import type { GlobalOptions } from "./context.ts";

// Initialize model registry at startup
import "../domain/models/registry_init.ts";

export async function runCli(args: string[]): Promise<void> {
const cli = new Command()
.name("swamp")
Expand Down
21 changes: 21 additions & 0 deletions src/domain/models/registry_init.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Initializes the model registry with all known model types.
*
* This module should be imported at application startup to ensure
* all models are registered before any commands run.
*/
import { modelRegistry } from "./model.ts";
import { echoModel } from "./echo/echo_model.ts";

/**
* Registers all model definitions with the global registry.
* Safe to call multiple times - will not re-register existing models.
*/
export function initializeModelRegistry(): void {
if (!modelRegistry.has(echoModel.type)) {
modelRegistry.register(echoModel);
}
}

// Auto-register on import
initializeModelRegistry();
Loading
Loading