diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml index e729089..e182e77 100644 --- a/.github/workflows/cli-ci.yml +++ b/.github/workflows/cli-ci.yml @@ -28,11 +28,11 @@ jobs: go-version-file: cli/go.mod cache-dependency-path: cli/go.sum - - name: go build - run: go build ./... + - name: make build + run: make build - - name: go vet - run: go vet ./... + - name: make lint + run: make lint - - name: go test - run: go test ./... + - name: make test + run: make test diff --git a/.gitignore b/.gitignore index 5f39ab3..46098ed 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ # Go CLI cli/dist/ cli/ossie + +# Generated from core-spec/osi-schema.json by `make prepare` +cli/internal/schema/osi-schema.json diff --git a/cli/Makefile b/cli/Makefile index d1f7d66..7575875 100644 --- a/cli/Makefile +++ b/cli/Makefile @@ -1,18 +1,22 @@ BINARY_NAME := ossie BUILD_DIR := dist -.PHONY: build install test lint release-dry-run clean +.PHONY: prepare build install test lint release-dry-run clean -build: +# Copy the canonical schema from the repo root before any Go step that needs it. +prepare: + cp ../core-spec/osi-schema.json internal/schema/osi-schema.json + +build: prepare go build -o $(BUILD_DIR)/$(BINARY_NAME) . -install: +install: prepare go build -o $(shell go env GOPATH)/bin/$(BINARY_NAME) . -test: +test: prepare go test ./... -lint: +lint: prepare go vet ./... release-dry-run: diff --git a/cli/cmd/convert.go b/cli/cmd/convert.go index 92722ee..d1dc157 100644 --- a/cli/cmd/convert.go +++ b/cli/cmd/convert.go @@ -1,8 +1,18 @@ package cmd import ( + "context" "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + "github.com/open-semantic-interchange/ossie/cli/internal/ossiedir" + "github.com/open-semantic-interchange/ossie/cli/internal/plugin" + "github.com/open-semantic-interchange/ossie/cli/internal/schema" "github.com/spf13/cobra" ) @@ -26,14 +36,213 @@ func init() { } func runConvert(cmd *cobra.Command, args []string) error { + // 1. Parse flags from, _ := cmd.Flags().GetString("from") to, _ := cmd.Flags().GetString("to") - - // MarkFlagsMutuallyExclusive handles the both-set case; handle neither here. if from == "" && to == "" { return fmt.Errorf("exactly one of --from or --to must be specified") } + input, _ := cmd.Flags().GetString("input") + outputFlag, _ := cmd.Flags().GetString("output") + pluginFlag, _ := cmd.Flags().GetString("plugin") + timeoutSecs, _ := cmd.Flags().GetInt("timeout") + maxInputSizeStr, _ := cmd.Flags().GetString("max-input-size") + // verbose is a PersistentFlag on root; Cobra injects it into child flag sets. + verbose, _ := cmd.Flags().GetBool("verbose") + + // 2. Resolve plugin + platformName := from + if to != "" { + platformName = to + } + p, err := resolvePlugin(platformName, pluginFlag, cmd.ErrOrStderr()) + if err != nil { + return err + } + + // 3. Select direction + direction, dir := selectDirection(p, from) + + // 4. Parse max-input-size + maxBytes, err := parseMaxInputSize(maxInputSizeStr) + if err != nil { + return fmt.Errorf("invalid --max-input-size %q: %w", maxInputSizeStr, err) + } + + // 5. Collect input files + files, err := plugin.CollectFiles(input, dir.Accepts, maxBytes) + if err != nil { + return fmt.Errorf("input collection failed: %w", err) + } + + // 6. Build context with timeout + ctx := cmd.Context() + if ctx == nil { + ctx = context.Background() + } + ctx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSecs)*time.Second) + defer cancel() + + // 7. Route plugin stderr + var pluginStderr io.Writer = io.Discard + if verbose { + pluginStderr = cmd.ErrOrStderr() + } + + // 8. Invoke plugin + resp, err := plugin.Invoke(ctx, p.Path, dir.Invoke, plugin.Request{Files: files}, pluginStderr) + if err != nil { + return fmt.Errorf("plugin invocation failed: %w", err) + } + + // 9. Validate output + resp.Issues = append(resp.Issues, validateOutput(resp, direction)...) + + // 10. Write output files + outDir := outputFlag + if outDir == "" { + outDir = filepath.Join("ossie-output", p.Platform.Name, direction) + } + if err := writeOutput(resp.Files, outDir); err != nil { + return err + } + + // 11. Render issues and set exit code + if hasErrors := renderIssues(resp.Issues, cmd.ErrOrStderr(), verbose); hasErrors { + return fmt.Errorf("conversion completed with errors") + } + return nil +} + +// resolvePlugin finds the plugin to use for the conversion. +// If pluginFlag is set, it loads from that path directly. Otherwise it +// discovers all installed plugins and filters by platformName. +func resolvePlugin(platformName, pluginFlag string, stderr io.Writer) (*plugin.Plugin, error) { + if pluginFlag != "" { + loaded, err := plugin.LoadPlugin(pluginFlag) + if err != nil { + return nil, fmt.Errorf("could not load plugin from %q: %w", pluginFlag, err) + } + return loaded, nil + } - fmt.Fprintln(cmd.OutOrStdout(), "not yet implemented") + pluginsDir, err := ossiedir.PluginDir() + if err != nil { + return nil, err + } + plugins, err := plugin.Discover(pluginsDir, stderr) + if err != nil { + return nil, err + } + + var matches []*plugin.Plugin + for _, candidate := range plugins { + if candidate.Platform.Name == platformName { + matches = append(matches, candidate) + } + } + switch len(matches) { + case 0: + return nil, fmt.Errorf("no plugin found for %q; run `ossie plugin install %s` to install it", platformName, platformName) + case 1: + return matches[0], nil + default: + return nil, fmt.Errorf("multiple plugins found for %q; use --plugin to specify a path", platformName) + } +} + +// selectDirection returns the direction string ("to_ossie" or "from_ossie") and +// the corresponding Direction based on whether --from or --to was specified. +func selectDirection(p *plugin.Plugin, from string) (string, plugin.Direction) { + if from != "" { + return "to_ossie", p.Convert.ToOssie + } + return "from_ossie", p.Convert.FromOssie +} + +// validateOutput validates to_ossie response files against the embedded OSI schema. +// Invalid files are removed from resp.Files in place and returned as +// error-severity issues for the caller to append to resp.Issues. +func validateOutput(resp *plugin.Response, direction string) []plugin.Issue { + if direction != "to_ossie" { + return nil + } + var issues []plugin.Issue + for name, content := range resp.Files { + if verr := schema.Validate([]byte(content)); verr != nil { + issues = append(issues, plugin.Issue{ + Severity: "error", + Path: name, + Message: fmt.Sprintf("output validation failed: %v", verr), + }) + delete(resp.Files, name) + } + } + return issues +} + +// writeOutput writes files to outDir, creating the directory only when there +// are files to write. Subdirectories within outDir are created as needed. +func writeOutput(files map[string]string, outDir string) error { + if len(files) == 0 { + return nil + } + if err := os.MkdirAll(outDir, 0755); err != nil { + return fmt.Errorf("could not create output directory %q: %w", outDir, err) + } + for name, content := range files { + dest := filepath.Join(outDir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(dest), 0755); err != nil { + return fmt.Errorf("could not create output subdirectory for %q: %w", name, err) + } + if err := os.WriteFile(dest, []byte(content), 0644); err != nil { + return fmt.Errorf("could not write output file %q: %w", dest, err) + } + } return nil } + +// parseMaxInputSize parses a size string like "100MB", "2GB", or a raw integer +// (interpreted as bytes). The suffix is case-insensitive. +func parseMaxInputSize(s string) (int64, error) { + upper := strings.ToUpper(strings.TrimSpace(s)) + if strings.HasSuffix(upper, "GB") { + n, err := strconv.ParseInt(strings.TrimSuffix(upper, "GB"), 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid GB value: %w", err) + } + return n * 1024 * 1024 * 1024, nil + } + if strings.HasSuffix(upper, "MB") { + n, err := strconv.ParseInt(strings.TrimSuffix(upper, "MB"), 10, 64) + if err != nil { + return 0, fmt.Errorf("invalid MB value: %w", err) + } + return n * 1024 * 1024, nil + } + n, err := strconv.ParseInt(upper, 10, 64) + if err != nil { + return 0, fmt.Errorf("unsupported size format %q (use e.g. 100MB, 2GB, or a raw byte count)", s) + } + return n, nil +} + +// renderIssues writes issues to w and returns true if any error-severity issue +// is present. info-severity issues are suppressed unless verbose is true. +func renderIssues(issues []plugin.Issue, w io.Writer, verbose bool) bool { + hasErrors := false + for _, iss := range issues { + if iss.Severity == "info" && !verbose { + continue + } + if iss.Path != "" { + fmt.Fprintf(w, "%s: %s: %s\n", iss.Severity, iss.Path, iss.Message) + } else { + fmt.Fprintf(w, "%s: %s\n", iss.Severity, iss.Message) + } + if iss.Severity == "error" { + hasErrors = true + } + } + return hasErrors +} diff --git a/cli/cmd/convert_test.go b/cli/cmd/convert_test.go new file mode 100644 index 0000000..adc7190 --- /dev/null +++ b/cli/cmd/convert_test.go @@ -0,0 +1,124 @@ +package cmd + +import ( + "strings" + "testing" + + "github.com/open-semantic-interchange/ossie/cli/internal/plugin" +) + +// --- parseMaxInputSize tests --- + +func TestParseMaxInputSize_MB(t *testing.T) { + got, err := parseMaxInputSize("100MB") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := int64(100 * 1024 * 1024) + if got != want { + t.Errorf("got %d, want %d", got, want) + } +} + +func TestParseMaxInputSize_MB_lowercase(t *testing.T) { + got, err := parseMaxInputSize("50mb") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := int64(50 * 1024 * 1024) + if got != want { + t.Errorf("got %d, want %d", got, want) + } +} + +func TestParseMaxInputSize_GB(t *testing.T) { + got, err := parseMaxInputSize("2GB") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := int64(2 * 1024 * 1024 * 1024) + if got != want { + t.Errorf("got %d, want %d", got, want) + } +} + +func TestParseMaxInputSize_rawBytes(t *testing.T) { + got, err := parseMaxInputSize("1048576") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != 1048576 { + t.Errorf("got %d, want 1048576", got) + } +} + +func TestParseMaxInputSize_invalid(t *testing.T) { + _, err := parseMaxInputSize("fooMB") + if err == nil { + t.Fatal("expected error for invalid input, got nil") + } +} + +func TestParseMaxInputSize_invalidNoSuffix(t *testing.T) { + _, err := parseMaxInputSize("not-a-number") + if err == nil { + t.Fatal("expected error for non-numeric input without suffix, got nil") + } +} + +// --- renderIssues tests --- + +func TestRenderIssues_errorSetsReturnTrue(t *testing.T) { + var buf strings.Builder + issues := []plugin.Issue{{Severity: "error", Message: "something failed"}} + if !renderIssues(issues, &buf, false) { + t.Error("expected hasErrors=true for error-severity issue") + } + if !strings.Contains(buf.String(), "error: something failed") { + t.Errorf("unexpected output: %q", buf.String()) + } +} + +func TestRenderIssues_warningSetsReturnFalse(t *testing.T) { + var buf strings.Builder + issues := []plugin.Issue{{Severity: "warning", Message: "a warning"}} + if renderIssues(issues, &buf, false) { + t.Error("expected hasErrors=false for warning-only issue") + } +} + +func TestRenderIssues_infoSuppressedWhenNotVerbose(t *testing.T) { + var buf strings.Builder + issues := []plugin.Issue{{Severity: "info", Message: "some info"}} + renderIssues(issues, &buf, false) + if buf.Len() != 0 { + t.Errorf("expected no output for info issue when not verbose, got: %q", buf.String()) + } +} + +func TestRenderIssues_infoPrintedWhenVerbose(t *testing.T) { + var buf strings.Builder + issues := []plugin.Issue{{Severity: "info", Message: "some info"}} + renderIssues(issues, &buf, true) + if !strings.Contains(buf.String(), "info: some info") { + t.Errorf("expected info output when verbose, got: %q", buf.String()) + } +} + +func TestRenderIssues_pathIncludedInOutput(t *testing.T) { + var buf strings.Builder + issues := []plugin.Issue{{Severity: "warning", Path: "models/orders.yaml", Message: "deprecated field"}} + renderIssues(issues, &buf, false) + if !strings.Contains(buf.String(), "warning: models/orders.yaml: deprecated field") { + t.Errorf("expected path in output, got: %q", buf.String()) + } +} + +func TestRenderIssues_emptyPathNoDoubleColon(t *testing.T) { + var buf strings.Builder + issues := []plugin.Issue{{Severity: "error", Message: "fatal"}} + renderIssues(issues, &buf, false) + if strings.Contains(buf.String(), ": : ") { + t.Errorf("empty path must not produce double colon, got: %q", buf.String()) + } +} diff --git a/cli/go.mod b/cli/go.mod index d17729d..afb2f28 100644 --- a/cli/go.mod +++ b/cli/go.mod @@ -3,6 +3,7 @@ module github.com/open-semantic-interchange/ossie/cli go 1.22 require ( + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 github.com/spf13/cobra v1.10.2 gopkg.in/yaml.v3 v3.0.1 ) @@ -10,4 +11,5 @@ require ( require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/text v0.14.0 // indirect ) diff --git a/cli/go.sum b/cli/go.sum index 47edb24..f11d3e5 100644 --- a/cli/go.sum +++ b/cli/go.sum @@ -1,12 +1,18 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= diff --git a/cli/internal/plugin/collect.go b/cli/internal/plugin/collect.go new file mode 100644 index 0000000..2098481 --- /dev/null +++ b/cli/internal/plugin/collect.go @@ -0,0 +1,91 @@ +package plugin + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// CollectFiles builds the files map for a plugin Request. +// +// If input is a regular file, it is read unconditionally (no extension filter) +// and keyed by its bare filename. The caller explicitly chose the file, so the +// plugin's Accepts list is not applied. +// +// If input is a directory, it is walked recursively. Only files whose extension +// (case-insensitive) appears in accepts are included. If accepts is empty, all +// files are included. Keys are forward-slash relative paths from the directory root. +// +// maxBytes enforces a cumulative size limit. Pass maxBytes <= 0 to disable it. +func CollectFiles(input string, accepts []string, maxBytes int64) (map[string]string, error) { + info, err := os.Stat(input) + if err != nil { + return nil, fmt.Errorf("cannot access input path %q: %w", input, err) + } + + if !info.IsDir() { + data, err := os.ReadFile(input) + if err != nil { + return nil, fmt.Errorf("cannot read input file %q: %w", input, err) + } + if maxBytes > 0 && int64(len(data)) > maxBytes { + return nil, fmt.Errorf("total input size exceeds limit (%dMB). Use --max-input-size to override", + maxBytes/(1024*1024)) + } + return map[string]string{filepath.Base(input): string(data)}, nil + } + + // Build a lowercase set for O(1) extension lookup. + acceptSet := make(map[string]struct{}, len(accepts)) + for _, ext := range accepts { + acceptSet[strings.ToLower(ext)] = struct{}{} + } + + return collectDir(input, acceptSet, maxBytes) +} + +func collectDir(root string, acceptSet map[string]struct{}, maxBytes int64) (map[string]string, error) { + files := make(map[string]string) + var total int64 + + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, werr error) error { + if werr != nil { + return werr + } + if d.IsDir() { + return nil + } + if len(acceptSet) > 0 { + ext := strings.ToLower(filepath.Ext(path)) + if _, ok := acceptSet[ext]; !ok { + return nil + } + } + + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("cannot read %q: %w", path, err) + } + + if maxBytes > 0 { + total += int64(len(data)) + if total > maxBytes { + return fmt.Errorf("total input size exceeds limit (%dMB). Use --max-input-size to override", + maxBytes/(1024*1024)) + } + } + + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + files[filepath.ToSlash(rel)] = string(data) + return nil + }) + if err != nil { + return nil, err + } + return files, nil +} diff --git a/cli/internal/plugin/collect_test.go b/cli/internal/plugin/collect_test.go new file mode 100644 index 0000000..bb0f550 --- /dev/null +++ b/cli/internal/plugin/collect_test.go @@ -0,0 +1,138 @@ +package plugin_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/open-semantic-interchange/ossie/cli/internal/plugin" +) + +// writeFile is a test helper that creates a file with the given content. +// Distinct from writePlugin in discover_test.go which creates a full plugin dir. +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + t.Fatalf("could not write %s: %v", name, err) + } +} + +func TestCollectFiles_singleFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "model.yaml") + if err := os.WriteFile(path, []byte("content"), 0644); err != nil { + t.Fatal(err) + } + + // Single file: extension filter (.json) does NOT apply. + files, err := plugin.CollectFiles(path, []string{".json"}, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(files) != 1 { + t.Fatalf("expected 1 file, got %d", len(files)) + } + if v, ok := files["model.yaml"]; !ok || v != "content" { + t.Errorf("unexpected files map: %v", files) + } +} + +func TestCollectFiles_directory(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "a.yaml", "a") + writeFile(t, dir, "b.yaml", "b") + + files, err := plugin.CollectFiles(dir, []string{".yaml"}, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(files) != 2 { + t.Errorf("expected 2 files, got %d: %v", len(files), files) + } +} + +func TestCollectFiles_directoryFiltered(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "model.yaml", "yaml-content") + writeFile(t, dir, "readme.md", "md-content") + + files, err := plugin.CollectFiles(dir, []string{".yaml"}, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(files) != 1 { + t.Fatalf("expected 1 file after filter, got %d: %v", len(files), files) + } + if _, ok := files["model.yaml"]; !ok { + t.Errorf("expected model.yaml in files, got: %v", files) + } +} + +func TestCollectFiles_forwardSlashKeys(t *testing.T) { + dir := t.TempDir() + sub := filepath.Join(dir, "subdir") + if err := os.MkdirAll(sub, 0755); err != nil { + t.Fatal(err) + } + writeFile(t, sub, "model.yaml", "content") + + files, err := plugin.CollectFiles(dir, []string{".yaml"}, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + for k := range files { + for _, ch := range k { + if ch == '\\' { + t.Errorf("key contains backslash: %q", k) + } + } + if k != "subdir/model.yaml" { + t.Errorf("expected key %q, got %q", "subdir/model.yaml", k) + } + } +} + +func TestCollectFiles_exceedsMaxSize(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "big.yaml", "12345") // 5 bytes + + _, err := plugin.CollectFiles(dir, []string{".yaml"}, 4) // limit 4 bytes + if err == nil { + t.Fatal("expected error for oversized input, got nil") + } +} + +func TestCollectFiles_emptyDirectory(t *testing.T) { + dir := t.TempDir() + + files, err := plugin.CollectFiles(dir, []string{".yaml"}, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(files) != 0 { + t.Errorf("expected empty map, got: %v", files) + } +} + +func TestCollectFiles_nonExistentInput(t *testing.T) { + _, err := plugin.CollectFiles(filepath.Join(t.TempDir(), "nonexistent.yaml"), nil, 0) + if err == nil { + t.Fatal("expected error for non-existent path, got nil") + } +} + +func TestCollectFiles_emptyAcceptsIncludesAllFiles(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "model.yaml", "yaml-content") + writeFile(t, dir, "readme.md", "md-content") + writeFile(t, dir, "data.json", "json-content") + + // Empty accepts means no filter — all files should be included. + files, err := plugin.CollectFiles(dir, []string{}, 0) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(files) != 3 { + t.Errorf("expected 3 files with empty accepts, got %d: %v", len(files), files) + } +} diff --git a/cli/internal/plugin/discover.go b/cli/internal/plugin/discover.go index 7263e32..2d22352 100644 --- a/cli/internal/plugin/discover.go +++ b/cli/internal/plugin/discover.go @@ -74,3 +74,10 @@ func loadPlugin(dir string) (*Plugin, error) { return raw.toPlugin(dir), nil } + +// LoadPlugin reads, parses, and validates the plugin.yaml inside dir. +// It is the exported equivalent of loadPlugin, used when --plugin bypasses +// name-based discovery. +func LoadPlugin(dir string) (*Plugin, error) { + return loadPlugin(dir) +} diff --git a/cli/internal/schema/schema.go b/cli/internal/schema/schema.go new file mode 100644 index 0000000..c3c0efd --- /dev/null +++ b/cli/internal/schema/schema.go @@ -0,0 +1,54 @@ +package schema + +import ( + "bytes" + _ "embed" // blank import required to activate //go:embed + "encoding/json" + "fmt" + + "github.com/santhosh-tekuri/jsonschema/v6" + "gopkg.in/yaml.v3" +) + +//go:embed osi-schema.json +var schemaJSON []byte + +// schemaID must exactly match the $id in osi-schema.json. +// The compiler uses this string as both the resource URL and the compile target. +const schemaID = "https://github.com/open-semantic-interchange/OSI/core-spec/osi-schema.json" + +var compiledSchema *jsonschema.Schema + +func init() { + schemaSrc, err := jsonschema.UnmarshalJSON(bytes.NewReader(schemaJSON)) + if err != nil { + panic(fmt.Sprintf("schema: failed to parse embedded OSI schema: %v", err)) + } + c := jsonschema.NewCompiler() + if err := c.AddResource(schemaID, schemaSrc); err != nil { + panic(fmt.Sprintf("schema: failed to register embedded OSI schema: %v", err)) + } + compiledSchema = c.MustCompile(schemaID) +} + +// Validate validates data (YAML or JSON bytes) against the embedded OSI schema. +// YAML is converted to JSON via a round-trip through yaml.v3 and encoding/json +// to normalise Go types to the representation jsonschema/v6 expects. +// Returns nil if valid, an error describing the violation otherwise. +func Validate(data []byte) error { + var doc any + if err := yaml.Unmarshal(data, &doc); err != nil { + return fmt.Errorf("failed to parse document: %w", err) + } + // Round-trip through JSON to normalise yaml.v3's native int/float64 + // to json.Number, which jsonschema/v6 expects. + jsonBytes, err := json.Marshal(doc) + if err != nil { + return fmt.Errorf("failed to serialise document to JSON: %w", err) + } + inst, err := jsonschema.UnmarshalJSON(bytes.NewReader(jsonBytes)) + if err != nil { + return fmt.Errorf("failed to prepare document for validation: %w", err) + } + return compiledSchema.Validate(inst) +} diff --git a/cli/internal/schema/schema_test.go b/cli/internal/schema/schema_test.go new file mode 100644 index 0000000..2a4ac85 --- /dev/null +++ b/cli/internal/schema/schema_test.go @@ -0,0 +1,55 @@ +package schema_test + +import ( + "testing" + + "github.com/open-semantic-interchange/ossie/cli/internal/schema" +) + +// minimalValidYAML satisfies all schema-required fields: +// root: version (const) + semantic_model (array) +// SemanticModel: name + datasets (minItems 1) +// Dataset: name + source +const minimalValidYAML = ` +version: "0.2.0.dev0" +semantic_model: + - name: test_model + datasets: + - name: test_dataset + source: db.schema.table +` + +const minimalValidJSON = `{ + "version": "0.2.0.dev0", + "semantic_model": [ + { + "name": "test_model", + "datasets": [ + { + "name": "test_dataset", + "source": "db.schema.table" + } + ] + } + ] +}` + +func TestValidate_validYAML(t *testing.T) { + if err := schema.Validate([]byte(minimalValidYAML)); err != nil { + t.Errorf("expected valid YAML to pass, got: %v", err) + } +} + +func TestValidate_validJSON(t *testing.T) { + if err := schema.Validate([]byte(minimalValidJSON)); err != nil { + t.Errorf("expected valid JSON to pass, got: %v", err) + } +} + +func TestValidate_invalidDocument(t *testing.T) { + // Missing required fields (version, semantic_model) and additionalProperties:false + // means unknown top-level keys are also rejected. + if err := schema.Validate([]byte(`name: foo`)); err == nil { + t.Error("expected invalid document to fail, got nil") + } +}