diff --git a/.github/workflows/cli-ci.yml b/.github/workflows/cli-ci.yml new file mode 100644 index 0000000..e182e77 --- /dev/null +++ b/.github/workflows/cli-ci.yml @@ -0,0 +1,38 @@ +name: CLI CI + +on: + push: + paths: + - 'cli/**' + - '.github/workflows/cli-ci.yml' + pull_request: + paths: + - 'cli/**' + - '.github/workflows/cli-ci.yml' + +jobs: + build-and-test: + name: Build and test + runs-on: ubuntu-latest + defaults: + run: + working-directory: cli + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: cli/go.mod + cache-dependency-path: cli/go.sum + + - name: make build + run: make build + + - name: make lint + run: make lint + + - name: make test + run: make test diff --git a/.gitignore b/.gitignore index 63e36ff..46098ed 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,9 @@ **/__pycache__/ **/.venv/ + +# 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/.goreleaser.yaml b/cli/.goreleaser.yaml new file mode 100644 index 0000000..3a0a17b --- /dev/null +++ b/cli/.goreleaser.yaml @@ -0,0 +1,48 @@ +version: 2 + +project_name: ossie + +before: + hooks: + - go mod tidy + +builds: + - id: ossie + main: . + binary: ossie + goos: + - linux + - darwin + - windows + goarch: + - amd64 + - arm64 + ignore: + - goos: windows + goarch: arm64 + env: + - CGO_ENABLED=0 + ldflags: + - -s -w + - -X main.version={{.Version}} + - -X main.commit={{.Commit}} + - -X main.date={{.Date}} + +archives: + - id: ossie + format: tar.gz + format_overrides: + - goos: windows + format: zip + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + +checksum: + name_template: "checksums.txt" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + - "^chore:" diff --git a/cli/.tool-versions b/cli/.tool-versions new file mode 100644 index 0000000..05a23a6 --- /dev/null +++ b/cli/.tool-versions @@ -0,0 +1 @@ +golang 1.26.2 diff --git a/cli/Makefile b/cli/Makefile new file mode 100644 index 0000000..7575875 --- /dev/null +++ b/cli/Makefile @@ -0,0 +1,26 @@ +BINARY_NAME := ossie +BUILD_DIR := dist + +.PHONY: prepare build install test lint release-dry-run clean + +# 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: prepare + go build -o $(shell go env GOPATH)/bin/$(BINARY_NAME) . + +test: prepare + go test ./... + +lint: prepare + go vet ./... + +release-dry-run: + goreleaser release --snapshot --clean --config .goreleaser.yaml + +clean: + rm -rf $(BUILD_DIR) diff --git a/cli/cmd/convert.go b/cli/cmd/convert.go new file mode 100644 index 0000000..d1dc157 --- /dev/null +++ b/cli/cmd/convert.go @@ -0,0 +1,248 @@ +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" +) + +var convertCmd = &cobra.Command{ + Use: "convert --from --input | --to --input ", + Short: "Convert a semantic model between OSSIE and a platform format", + RunE: runConvert, +} + +func init() { + convertCmd.Flags().String("from", "", "Source platform — converts platform → OSSIE") + convertCmd.Flags().String("to", "", "Target platform — converts OSSIE → platform") + convertCmd.Flags().StringP("input", "i", "", "Input file or directory path (required)") + convertCmd.Flags().StringP("output", "o", "", "Output directory path (default: ./ossie-output//)") + convertCmd.Flags().String("plugin", "", "Path to plugin directory (bypasses name-based discovery)") + convertCmd.Flags().Int("timeout", 60, "Plugin invocation timeout in seconds") + convertCmd.Flags().String("max-input-size", "100MB", "Maximum total input size (e.g. 500MB)") + + _ = convertCmd.MarkFlagRequired("input") + convertCmd.MarkFlagsMutuallyExclusive("from", "to") +} + +func runConvert(cmd *cobra.Command, args []string) error { + // 1. Parse flags + from, _ := cmd.Flags().GetString("from") + to, _ := cmd.Flags().GetString("to") + 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 + } + + 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/cmd/plugin/install.go b/cli/cmd/plugin/install.go new file mode 100644 index 0000000..aabde2d --- /dev/null +++ b/cli/cmd/plugin/install.go @@ -0,0 +1,22 @@ +package plugin + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var installCmd = &cobra.Command{ + Use: "install [name[@version] | url]", + Short: "Install a plugin from the registry or a URL", + RunE: runPluginInstall, +} + +func init() { + installCmd.Flags().Bool("all", false, "Install the latest version of all registry plugins") +} + +func runPluginInstall(cmd *cobra.Command, args []string) error { + fmt.Fprintln(cmd.OutOrStdout(), "not yet implemented") + return nil +} diff --git a/cli/cmd/plugin/list.go b/cli/cmd/plugin/list.go new file mode 100644 index 0000000..ceb0b37 --- /dev/null +++ b/cli/cmd/plugin/list.go @@ -0,0 +1,100 @@ +package plugin + +import ( + "fmt" + "os" + "text/tabwriter" + + "github.com/spf13/cobra" + + "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/registry" +) + +var listCmd = &cobra.Command{ + Use: "list", + Short: "List available and installed plugins", + RunE: runPluginList, +} + +func runPluginList(cmd *cobra.Command, args []string) error { + reg, err := registry.Load() + if err != nil { + return err + } + + pluginsDir, err := ossiedir.PluginDir() + if err != nil { + return err + } + + installed, err := plugin.Discover(pluginsDir, os.Stderr) + if err != nil { + return err + } + + // Index installed plugins by platform name for O(1) lookup. + installedByPlatform := make(map[string]*plugin.Plugin, len(installed)) + for _, p := range installed { + installedByPlatform[p.Platform.Name] = p + } + + // Identify community plugins: installed but absent from the registry. + var community []*plugin.Plugin + for _, p := range installed { + if _, ok := reg.LatestEntry(p.Platform.Name); !ok { + community = append(community, p) + } + } + + platforms := reg.Platforms() // sorted alphabetically + out := cmd.OutOrStdout() + + if len(platforms) == 0 && len(installed) == 0 { + fmt.Fprintln(out, "no plugins available") + return nil + } + + if len(platforms) > 0 { + w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tSTATUS\tINSTALLED\tLATEST") + for _, platform := range platforms { + latest, _ := reg.LatestEntry(platform) + p, isInstalled := installedByPlatform[platform] + + var status, installedVer string + if isInstalled { + installedVer = p.OSSIEPluginSpec + if installedVer == latest.Version { + status = "installed" + } else { + status = "update available" + } + } else { + installedVer = "—" + status = "not installed" + } + + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", platform, status, installedVer, latest.Version) + } + if err := w.Flush(); err != nil { + return err + } + } + + if len(community) > 0 { + fmt.Fprintln(out) + fmt.Fprintln(out, "Community plugins:") + w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) + fmt.Fprintln(w, "NAME\tINSTALLED") + for _, p := range community { + fmt.Fprintf(w, "%s\t%s\n", p.Platform.Name, p.OSSIEPluginSpec) + } + if err := w.Flush(); err != nil { + return err + } + } + + return nil +} diff --git a/cli/cmd/plugin/plugin.go b/cli/cmd/plugin/plugin.go new file mode 100644 index 0000000..d996f13 --- /dev/null +++ b/cli/cmd/plugin/plugin.go @@ -0,0 +1,16 @@ +package plugin + +import "github.com/spf13/cobra" + +// Cmd is the parent "ossie plugin" command. It is exported so cmd/root.go can +// register it. Invoking it bare prints help. +var Cmd = &cobra.Command{ + Use: "plugin", + Short: "Manage OSSIE plugins", +} + +func init() { + Cmd.AddCommand(listCmd) + Cmd.AddCommand(installCmd) + Cmd.AddCommand(removeCmd) +} diff --git a/cli/cmd/plugin/remove.go b/cli/cmd/plugin/remove.go new file mode 100644 index 0000000..1b88669 --- /dev/null +++ b/cli/cmd/plugin/remove.go @@ -0,0 +1,19 @@ +package plugin + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var removeCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove an installed plugin", + Args: cobra.ExactArgs(1), + RunE: runPluginRemove, +} + +func runPluginRemove(cmd *cobra.Command, args []string) error { + fmt.Fprintln(cmd.OutOrStdout(), "not yet implemented") + return nil +} diff --git a/cli/cmd/root.go b/cli/cmd/root.go new file mode 100644 index 0000000..772d87b --- /dev/null +++ b/cli/cmd/root.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "github.com/open-semantic-interchange/ossie/cli/cmd/plugin" + "github.com/open-semantic-interchange/ossie/cli/internal/ossiedir" + "github.com/spf13/cobra" +) + +var rootCmd = &cobra.Command{ + Use: "ossie", + Short: "Open Semantic Interchange CLI", + Long: `ossie is the command-line tool for the Open Semantic Interchange (OSSIE) project.`, + // NOTE: Cobra does NOT automatically chain PersistentPreRunE from parent to + // child. If any subcommand defines its own PersistentPreRunE or PreRunE, this + // function will not run for that subcommand. Future subcommands that define + // their own must call ossiedir.EnsurePluginDir() explicitly. + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + return ossiedir.EnsurePluginDir() + }, +} + +// Execute runs the root command. Called by main. +func Execute() error { + return rootCmd.Execute() +} + +// SetVersion sets the version string reported by `ossie --version`. +func SetVersion(v string) { + rootCmd.Version = v +} + +func init() { + rootCmd.PersistentFlags().BoolP("verbose", "v", false, "Enable verbose output (shows plugin stderr)") + + rootCmd.AddCommand(convertCmd) + rootCmd.AddCommand(validateCmd) + rootCmd.AddCommand(plugin.Cmd) +} diff --git a/cli/cmd/validate.go b/cli/cmd/validate.go new file mode 100644 index 0000000..b11d00a --- /dev/null +++ b/cli/cmd/validate.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var validateCmd = &cobra.Command{ + Use: "validate [flags] [...]", + Short: "Validate one or more OSSIE YAML or JSON files", + Args: cobra.MinimumNArgs(1), + RunE: runValidate, +} + +func init() { + validateCmd.Flags().Bool("strict", false, "Promote warnings to errors") + validateCmd.Flags().String("output", "text", "Output format: text or json") +} + +func runValidate(cmd *cobra.Command, args []string) error { + fmt.Fprintln(cmd.OutOrStdout(), "not yet implemented") + return nil +} diff --git a/cli/go.mod b/cli/go.mod new file mode 100644 index 0000000..afb2f28 --- /dev/null +++ b/cli/go.mod @@ -0,0 +1,15 @@ +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 +) + +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 new file mode 100644 index 0000000..f11d3e5 --- /dev/null +++ b/cli/go.sum @@ -0,0 +1,19 @@ +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= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/internal/ossiedir/ossiedir.go b/cli/internal/ossiedir/ossiedir.go new file mode 100644 index 0000000..051a8bd --- /dev/null +++ b/cli/internal/ossiedir/ossiedir.go @@ -0,0 +1,40 @@ +package ossiedir + +import ( + "fmt" + "os" + "path/filepath" +) + +const ( + defaultOssieDir = ".ossie" + pluginsSubdir = "plugins" + envVar = "OSSIE_PLUGIN_DIR" +) + +// PluginDir returns the resolved plugin directory path. +// It respects $OSSIE_PLUGIN_DIR if set, otherwise defaults to ~/.ossie/plugins/. +func PluginDir() (string, error) { + if override := os.Getenv(envVar); override != "" { + return override, nil + } + // Use os.UserHomeDir rather than $HOME for Windows portability. + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("could not determine home directory: %w", err) + } + return filepath.Join(home, defaultOssieDir, pluginsSubdir), nil +} + +// EnsurePluginDir ensures the plugin directory exists, creating it if needed. +// It is safe to call multiple times — os.MkdirAll is idempotent. +func EnsurePluginDir() error { + dir, err := PluginDir() + if err != nil { + return err + } + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("could not create plugin directory %s: %w", dir, err) + } + return nil +} diff --git a/cli/internal/ossiedir/ossiedir_test.go b/cli/internal/ossiedir/ossiedir_test.go new file mode 100644 index 0000000..76a8981 --- /dev/null +++ b/cli/internal/ossiedir/ossiedir_test.go @@ -0,0 +1,65 @@ +package ossiedir + +import ( + "os" + "path/filepath" + "testing" +) + +func TestPluginDir_envOverride(t *testing.T) { + want := "/custom/plugin/dir" + t.Setenv(envVar, want) + + got, err := PluginDir() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginDir_default(t *testing.T) { + t.Setenv(envVar, "") + + home, err := os.UserHomeDir() + if err != nil { + t.Fatalf("could not determine home dir: %v", err) + } + want := filepath.Join(home, defaultOssieDir, pluginsSubdir) + + got, err := PluginDir() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestEnsurePluginDir_createsDirectory(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "plugins") + t.Setenv(envVar, target) + + if err := EnsurePluginDir(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if _, err := os.Stat(target); os.IsNotExist(err) { + t.Errorf("expected directory %q to exist, but it does not", target) + } +} + +func TestEnsurePluginDir_idempotent(t *testing.T) { + tmp := t.TempDir() + target := filepath.Join(tmp, "plugins") + t.Setenv(envVar, target) + + if err := EnsurePluginDir(); err != nil { + t.Fatalf("first call failed: %v", err) + } + if err := EnsurePluginDir(); err != nil { + t.Fatalf("second call failed: %v", err) + } +} 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 new file mode 100644 index 0000000..2d22352 --- /dev/null +++ b/cli/internal/plugin/discover.go @@ -0,0 +1,83 @@ +package plugin + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "gopkg.in/yaml.v3" +) + +const pluginYAML = "plugin.yaml" + +// Discover scans pluginsDir for subdirectories that contain a plugin.yaml, +// parses and validates each one, and returns all valid plugins. +// +// Malformed or invalid plugin directories are skipped with a warning written +// to stderr in the format: +// +// warning: skipping plugin at : +// +// A non-existent pluginsDir is treated as empty and returns (nil, nil). +// A hard error is only returned if pluginsDir itself cannot be read. +func Discover(pluginsDir string, stderr io.Writer) ([]*Plugin, error) { + entries, err := os.ReadDir(pluginsDir) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("could not read plugin directory %s: %w", pluginsDir, err) + } + + var plugins []*Plugin + for _, entry := range entries { + // TODO: handle symlinked plugin directories + // (entry.Type()&os.ModeSymlink != 0 requires os.Stat to resolve) + if !entry.IsDir() { + continue + } + pluginPath := filepath.Join(pluginsDir, entry.Name()) + p, err := loadPlugin(pluginPath) + if err != nil { + fmt.Fprintf(stderr, "warning: skipping plugin at %s: %s\n", pluginPath, err) + continue + } + plugins = append(plugins, p) + } + return plugins, nil +} + +// loadPlugin reads, parses, and validates the plugin.yaml inside dir. +func loadPlugin(dir string) (*Plugin, error) { + yamlPath := filepath.Join(dir, pluginYAML) + data, err := os.ReadFile(yamlPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("no plugin.yaml found") + } + return nil, fmt.Errorf("could not read plugin.yaml: %w", err) + } + + // yaml.Unmarshal is lenient by default: unknown fields are silently ignored. + // This is intentional — future spec versions may add fields that older CLI + // versions should tolerate rather than reject. + var raw rawPlugin + if err := yaml.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("invalid YAML: %w", err) + } + + if err := raw.validate(); err != nil { + return nil, err + } + + 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/plugin/discover_test.go b/cli/internal/plugin/discover_test.go new file mode 100644 index 0000000..951f785 --- /dev/null +++ b/cli/internal/plugin/discover_test.go @@ -0,0 +1,302 @@ +package plugin_test + +import ( + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "github.com/open-semantic-interchange/ossie/cli/internal/plugin" +) + +// validPluginYAML is the canonical plugin.yaml fixture using ossie_* keys. +const validPluginYAML = ` +ossie_plugin_spec: "0.1.0" +ossie_spec_version: ">=0.2.0" +platform: + name: dbt + vendor: dbt Labs +convert: + to_ossie: + invoke: ["ossie-plugin-dbt", "to-ossie"] + accepts: [".yaml", ".json"] + from_ossie: + invoke: ["ossie-plugin-dbt", "from-ossie"] +` + +// writePlugin creates a plugin directory under root with the given plugin.yaml content. +func writePlugin(t *testing.T, root, name, content string) string { + t.Helper() + dir := filepath.Join(root, name) + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatalf("could not create plugin dir: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "plugin.yaml"), []byte(content), 0644); err != nil { + t.Fatalf("could not write plugin.yaml: %v", err) + } + return dir +} + +func TestDiscover_emptyDir(t *testing.T) { + dir := t.TempDir() + var stderr strings.Builder + + plugins, err := plugin.Discover(dir, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 0 { + t.Errorf("expected no plugins, got %d", len(plugins)) + } + if stderr.Len() != 0 { + t.Errorf("expected no warnings, got: %q", stderr.String()) + } +} + +func TestDiscover_nonExistentDir(t *testing.T) { + var stderr strings.Builder + + plugins, err := plugin.Discover(filepath.Join(t.TempDir(), "nonexistent"), &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if plugins != nil { + t.Errorf("expected nil slice, got %v", plugins) + } +} + +func TestDiscover_validPlugin(t *testing.T) { + root := t.TempDir() + pluginDir := writePlugin(t, root, "dbt", validPluginYAML) + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + p := plugins[0] + if p.Path != pluginDir { + t.Errorf("Path: got %q, want %q", p.Path, pluginDir) + } + if p.Platform.Name != "dbt" { + t.Errorf("Platform.Name: got %q, want %q", p.Platform.Name, "dbt") + } + if p.Platform.Vendor != "dbt Labs" { + t.Errorf("Platform.Vendor: got %q, want %q", p.Platform.Vendor, "dbt Labs") + } + if p.OSSIEPluginSpec != "0.1.0" { + t.Errorf("OSSIEPluginSpec: got %q, want %q", p.OSSIEPluginSpec, "0.1.0") + } + if p.OSSIESpecVersion != ">=0.2.0" { + t.Errorf("OSSIESpecVersion: got %q, want %q", p.OSSIESpecVersion, ">=0.2.0") + } + wantInvoke := []string{"ossie-plugin-dbt", "to-ossie"} + if !slices.Equal(p.Convert.ToOssie.Invoke, wantInvoke) { + t.Errorf("ToOssie.Invoke: got %v, want %v", p.Convert.ToOssie.Invoke, wantInvoke) + } + wantAccepts := []string{".yaml", ".json"} + if !slices.Equal(p.Convert.ToOssie.Accepts, wantAccepts) { + t.Errorf("ToOssie.Accepts: got %v, want %v", p.Convert.ToOssie.Accepts, wantAccepts) + } + wantFromInvoke := []string{"ossie-plugin-dbt", "from-ossie"} + if !slices.Equal(p.Convert.FromOssie.Invoke, wantFromInvoke) { + t.Errorf("FromOssie.Invoke: got %v, want %v", p.Convert.FromOssie.Invoke, wantFromInvoke) + } + if stderr.Len() != 0 { + t.Errorf("unexpected warning: %q", stderr.String()) + } +} + +func TestDiscover_malformedYAML(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "bad", "this: is: not: valid: yaml: ][") + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 0 { + t.Errorf("expected 0 plugins, got %d", len(plugins)) + } + if !strings.Contains(stderr.String(), filepath.Join(root, "bad")) { + t.Errorf("warning should contain plugin path, got: %q", stderr.String()) + } +} + +func TestDiscover_missingRequiredField(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "noplat", ` +ossie_plugin_spec: "0.1.0" +ossie_spec_version: ">=0.2.0" +platform: + vendor: dbt Labs +convert: + to_ossie: + invoke: ["bin/convert"] + accepts: [".yaml"] + from_ossie: + invoke: ["bin/convert"] +`) + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 0 { + t.Errorf("expected 0 plugins, got %d", len(plugins)) + } + if !strings.Contains(stderr.String(), filepath.Join(root, "noplat")) { + t.Errorf("warning should contain plugin path, got: %q", stderr.String()) + } +} + +func TestDiscover_missingPluginYAML(t *testing.T) { + root := t.TempDir() + // Create a directory with no plugin.yaml inside it. + if err := os.MkdirAll(filepath.Join(root, "empty-plugin"), 0755); err != nil { + t.Fatal(err) + } + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 0 { + t.Errorf("expected 0 plugins, got %d", len(plugins)) + } + if !strings.Contains(stderr.String(), filepath.Join(root, "empty-plugin")) { + t.Errorf("warning should contain plugin path, got: %q", stderr.String()) + } +} + +func TestDiscover_multipleMixed(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "valid", validPluginYAML) + writePlugin(t, root, "malformed", "bad: yaml: ][") + if err := os.MkdirAll(filepath.Join(root, "no-yaml"), 0755); err != nil { + t.Fatal(err) + } + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 1 { + t.Errorf("expected 1 plugin, got %d", len(plugins)) + } + warnings := stderr.String() + if !strings.Contains(warnings, filepath.Join(root, "malformed")) { + t.Errorf("expected warning for malformed, got: %q", warnings) + } + if !strings.Contains(warnings, filepath.Join(root, "no-yaml")) { + t.Errorf("expected warning for no-yaml, got: %q", warnings) + } +} + +func TestDiscover_unknownFieldsIgnored(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "extra-fields", ` +ossie_plugin_spec: "0.1.0" +ossie_spec_version: ">=0.2.0" +future_field: "some value" +platform: + name: test + future_platform_field: "ignored" +convert: + to_ossie: + invoke: ["bin/convert"] + accepts: [".yaml"] + from_ossie: + invoke: ["bin/convert"] +`) + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d: future spec fields should be silently ignored", len(plugins)) + } + if stderr.Len() != 0 { + t.Errorf("unexpected warning: %q", stderr.String()) + } +} + +func TestDiscover_strayFileIgnored(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "valid", validPluginYAML) + // Place a plain file alongside the plugin directory. + if err := os.WriteFile(filepath.Join(root, "stray.txt"), []byte("not a plugin"), 0644); err != nil { + t.Fatal(err) + } + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 1 { + t.Errorf("expected 1 plugin, got %d", len(plugins)) + } + if stderr.Len() != 0 { + t.Errorf("expected no warnings for stray file, got: %q", stderr.String()) + } +} + +func TestDiscover_setupFieldPopulated(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "with-setup", ` +ossie_plugin_spec: "0.1.0" +ossie_spec_version: ">=0.2.0" +platform: + name: dbt +setup: bin/setup +convert: + to_ossie: + invoke: ["bin/convert", "to-ossie"] + accepts: [".yaml"] + from_ossie: + invoke: ["bin/convert", "from-ossie"] +`) + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + if plugins[0].Setup != "bin/setup" { + t.Errorf("Setup: got %q, want %q", plugins[0].Setup, "bin/setup") + } + if stderr.Len() != 0 { + t.Errorf("unexpected warning: %q", stderr.String()) + } +} + +func TestDiscover_setupFieldAbsent(t *testing.T) { + root := t.TempDir() + writePlugin(t, root, "no-setup", validPluginYAML) + var stderr strings.Builder + + plugins, err := plugin.Discover(root, &stderr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + if plugins[0].Setup != "" { + t.Errorf("Setup: got %q, want empty string", plugins[0].Setup) + } +} diff --git a/cli/internal/plugin/invoke.go b/cli/internal/plugin/invoke.go new file mode 100644 index 0000000..17bf848 --- /dev/null +++ b/cli/internal/plugin/invoke.go @@ -0,0 +1,83 @@ +package plugin + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os/exec" +) + +// Request is the JSON envelope sent to a plugin on stdin. +type Request struct { + Files map[string]string `json:"files"` +} + +// Response is the JSON envelope received from a plugin on stdout. +type Response struct { + Files map[string]string `json:"files"` + Issues []Issue `json:"issues,omitempty"` +} + +// Issue is a single diagnostic emitted by a plugin during conversion. +// Severity is one of "error", "warning", or "info". +// Path is optional; it is omitted for issues not tied to a specific location. +type Issue struct { + Severity string `json:"severity"` + Message string `json:"message"` + Path string `json:"path,omitempty"` +} + +// Invoke runs a plugin subprocess, pipes req as JSON to its stdin, +// and decodes its stdout as a JSON Response. +// +// pluginDir is used as the working directory for the subprocess so that +// relative paths in the invoke array resolve correctly. +// invoke is the command and its arguments (invoke[0] is the executable). +// pluginStderr receives the plugin's stderr verbatim; pass io.Discard to suppress. +// +// Hard errors: failed to marshal request, process start failure, context +// deadline exceeded, non-zero exit code, or invalid JSON in the response. +// A non-empty Issues slice in the response is NOT itself a Go error — the +// caller is responsible for inspecting severities and setting the exit code. +func Invoke(ctx context.Context, pluginDir string, invoke []string, req Request, pluginStderr io.Writer) (*Response, error) { + if len(invoke) == 0 { + return nil, fmt.Errorf("plugin invoke command is empty") + } + + // Normalise nil to empty map so plugins always receive {"files":{}} not {"files":null}. + if req.Files == nil { + req.Files = map[string]string{} + } + + reqJSON, err := json.Marshal(req) + if err != nil { + return nil, fmt.Errorf("failed to marshal plugin request: %w", err) + } + + var stdout bytes.Buffer + cmd := exec.CommandContext(ctx, invoke[0], invoke[1:]...) + cmd.Dir = pluginDir + cmd.Stdin = bytes.NewReader(reqJSON) + // Use an explicit buffer rather than cmd.Output() — cmd.Output() is + // incompatible with a pre-assigned cmd.Stderr writer. + cmd.Stdout = &stdout + cmd.Stderr = pluginStderr + + if err := cmd.Run(); err != nil { + // ctx.Err() is the authoritative signal for timeout/cancellation. + // exec.CommandContext kills the process and returns "signal: killed", + // not context.DeadlineExceeded, so we must check ctx.Err() explicitly. + if ctx.Err() != nil { + return nil, fmt.Errorf("plugin timed out: %w", ctx.Err()) + } + return nil, fmt.Errorf("plugin process failed: %w", err) + } + + var resp Response + if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil { + return nil, fmt.Errorf("invalid JSON response from plugin: %w", err) + } + return &resp, nil +} diff --git a/cli/internal/plugin/invoke_test.go b/cli/internal/plugin/invoke_test.go new file mode 100644 index 0000000..b2edf43 --- /dev/null +++ b/cli/internal/plugin/invoke_test.go @@ -0,0 +1,294 @@ +package plugin_test + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" + "testing" + "time" + + "github.com/open-semantic-interchange/ossie/cli/internal/plugin" +) + +// TestMain handles two roles: +// 1. When GO_TEST_PLUGIN=1 it acts as a fake plugin subprocess, reads stdin, +// and writes a canned response based on GO_TEST_PLUGIN_MODE. +// 2. Otherwise it runs the normal test suite. +func TestMain(m *testing.M) { + if os.Getenv("GO_TEST_PLUGIN") == "1" { + runFakePlugin() + // runFakePlugin calls os.Exit; this line is never reached. + } + os.Exit(m.Run()) +} + +// runFakePlugin implements a minimal plugin subprocess used by invoke tests. +// It reads all of stdin (the JSON request), then dispatches on GO_TEST_PLUGIN_MODE. +func runFakePlugin() { + stdinBytes, err := io.ReadAll(os.Stdin) + if err != nil { + fmt.Fprintln(os.Stderr, "fake plugin: failed to read stdin:", err) + os.Exit(2) + } + + switch mode := os.Getenv("GO_TEST_PLUGIN_MODE"); mode { + case "success": + json.NewEncoder(os.Stdout).Encode(map[string]any{ + "files": map[string]string{"output.yaml": "converted content"}, + }) + + case "warning_issue": + json.NewEncoder(os.Stdout).Encode(map[string]any{ + "files": map[string]string{"output.yaml": "converted content"}, + "issues": []map[string]string{ + {"severity": "warning", "message": "some warning", "path": "input.yaml"}, + }, + }) + + case "error_issue": + json.NewEncoder(os.Stdout).Encode(map[string]any{ + "files": map[string]string{}, + "issues": []map[string]string{ + {"severity": "error", "message": "conversion failed"}, + }, + }) + + case "invalid_json": + fmt.Fprint(os.Stdout, "not json") + + case "stderr_output": + fmt.Fprint(os.Stderr, "stderr from plugin") + json.NewEncoder(os.Stdout).Encode(map[string]any{ + "files": map[string]string{"output.yaml": "ok"}, + }) + + case "nonzero_exit": + os.Exit(1) + + case "timeout": + time.Sleep(30 * time.Second) + + case "echo_request": + // Echo the raw stdin bytes back in files["received_request"] so the + // test can assert the plugin received the correct request JSON. + json.NewEncoder(os.Stdout).Encode(map[string]any{ + "files": map[string]string{"received_request": string(stdinBytes)}, + }) + + default: + fmt.Fprintln(os.Stderr, "fake plugin: unknown mode:", mode) + os.Exit(2) + } + + os.Exit(0) +} + +// fakePluginInvoke sets up env vars for the fake plugin and returns the +// invoke slice. The test binary re-invokes itself with -test.run=^$ so that +// no tests run in the child — TestMain sees GO_TEST_PLUGIN=1 and exits early. +func fakePluginInvoke(t *testing.T, mode string) []string { + t.Helper() + t.Setenv("GO_TEST_PLUGIN", "1") + t.Setenv("GO_TEST_PLUGIN_MODE", mode) + return []string{os.Args[0], "-test.run=^$"} +} + +func TestInvoke_success(t *testing.T) { + invoke := fakePluginInvoke(t, "success") + ctx := context.Background() + + resp, err := plugin.Invoke(ctx, t.TempDir(), invoke, plugin.Request{Files: map[string]string{}}, io.Discard) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp == nil { + t.Fatal("expected non-nil response") + } + if _, ok := resp.Files["output.yaml"]; !ok { + t.Errorf("expected output.yaml in response files, got: %v", resp.Files) + } + if len(resp.Issues) != 0 { + t.Errorf("expected no issues, got %d", len(resp.Issues)) + } +} + +func TestInvoke_warningIssue(t *testing.T) { + invoke := fakePluginInvoke(t, "warning_issue") + ctx := context.Background() + + resp, err := plugin.Invoke(ctx, t.TempDir(), invoke, plugin.Request{}, io.Discard) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resp.Files) != 1 { + t.Errorf("expected 1 output file, got %d", len(resp.Files)) + } + if len(resp.Issues) != 1 { + t.Fatalf("expected 1 issue, got %d", len(resp.Issues)) + } + if resp.Issues[0].Severity != "warning" { + t.Errorf("issue severity: got %q, want %q", resp.Issues[0].Severity, "warning") + } +} + +func TestInvoke_errorIssue(t *testing.T) { + invoke := fakePluginInvoke(t, "error_issue") + ctx := context.Background() + + // An error-severity issue is NOT a Go error — Invoke must return nil err. + resp, err := plugin.Invoke(ctx, t.TempDir(), invoke, plugin.Request{}, io.Discard) + + if err != nil { + t.Fatalf("unexpected Go error: %v", err) + } + if len(resp.Issues) != 1 { + t.Fatalf("expected 1 issue, got %d", len(resp.Issues)) + } + if resp.Issues[0].Severity != "error" { + t.Errorf("issue severity: got %q, want %q", resp.Issues[0].Severity, "error") + } +} + +func TestInvoke_invalidResponseJSON(t *testing.T) { + invoke := fakePluginInvoke(t, "invalid_json") + ctx := context.Background() + + resp, err := plugin.Invoke(ctx, t.TempDir(), invoke, plugin.Request{}, io.Discard) + + if err == nil { + t.Fatal("expected error for invalid JSON response, got nil") + } + if resp != nil { + t.Errorf("expected nil response, got %+v", resp) + } +} + +func TestInvoke_nonZeroExit(t *testing.T) { + invoke := fakePluginInvoke(t, "nonzero_exit") + ctx := context.Background() + + resp, err := plugin.Invoke(ctx, t.TempDir(), invoke, plugin.Request{}, io.Discard) + + if err == nil { + t.Fatal("expected error for non-zero exit, got nil") + } + if resp != nil { + t.Errorf("expected nil response, got %+v", resp) + } +} + +func TestInvoke_stderrForwarded(t *testing.T) { + invoke := fakePluginInvoke(t, "stderr_output") + ctx := context.Background() + var buf strings.Builder + + _, err := plugin.Invoke(ctx, t.TempDir(), invoke, plugin.Request{}, &buf) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(buf.String(), "stderr from plugin") { + t.Errorf("expected stderr content in writer, got: %q", buf.String()) + } +} + +func TestInvoke_stderrSuppressed(t *testing.T) { + invoke := fakePluginInvoke(t, "stderr_output") + ctx := context.Background() + + // Passing io.Discard must not panic and must return a valid response. + resp, err := plugin.Invoke(ctx, t.TempDir(), invoke, plugin.Request{}, io.Discard) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp == nil { + t.Fatal("expected non-nil response") + } +} + +func TestInvoke_timeout(t *testing.T) { + invoke := fakePluginInvoke(t, "timeout") + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + resp, err := plugin.Invoke(ctx, t.TempDir(), invoke, plugin.Request{}, io.Discard) + + if err == nil { + t.Fatal("expected timeout error, got nil") + } + if resp != nil { + t.Errorf("expected nil response on timeout, got %+v", resp) + } + if !errors.Is(err, context.DeadlineExceeded) { + t.Errorf("expected DeadlineExceeded in error chain, got: %v", err) + } +} + +func TestInvoke_requestPayloadReachesPlugin(t *testing.T) { + invoke := fakePluginInvoke(t, "echo_request") + ctx := context.Background() + + req := plugin.Request{ + Files: map[string]string{ + "core/orders.yaml": "version: 2\nmodels:\n - name: orders", + }, + } + + resp, err := plugin.Invoke(ctx, t.TempDir(), invoke, req, io.Discard) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + raw, ok := resp.Files["received_request"] + if !ok { + t.Fatal("expected received_request key in response files") + } + + // Decode the echoed request and compare — avoids brittle JSON key-ordering checks. + var echoed plugin.Request + if err := json.Unmarshal([]byte(raw), &echoed); err != nil { + t.Fatalf("could not decode echoed request: %v", err) + } + want := req.Files["core/orders.yaml"] + if echoed.Files["core/orders.yaml"] != want { + t.Errorf("echoed file content:\ngot: %q\nwant: %q", echoed.Files["core/orders.yaml"], want) + } +} + +func TestInvoke_nilFilesNormalisedToEmptyObject(t *testing.T) { + // A Request with nil Files must marshal as {"files":{}} not {"files":null}. + // Plugins receiving null instead of an object may crash on iteration. + invoke := fakePluginInvoke(t, "echo_request") + ctx := context.Background() + + resp, err := plugin.Invoke(ctx, t.TempDir(), invoke, plugin.Request{}, io.Discard) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + raw := resp.Files["received_request"] + + // The echoed bytes must contain `"files":{}` not `"files":null`. + if !strings.Contains(raw, `"files":{}`) { + t.Errorf("expected files to be serialised as {}, got: %s", raw) + } +} + +func TestInvoke_emptyInvokeReturnsError(t *testing.T) { + ctx := context.Background() + + resp, err := plugin.Invoke(ctx, t.TempDir(), []string{}, plugin.Request{}, io.Discard) + + if err == nil { + t.Fatal("expected error for empty invoke slice, got nil") + } + if resp != nil { + t.Errorf("expected nil response, got %+v", resp) + } +} diff --git a/cli/internal/plugin/plugin.go b/cli/internal/plugin/plugin.go new file mode 100644 index 0000000..aa83cd8 --- /dev/null +++ b/cli/internal/plugin/plugin.go @@ -0,0 +1,99 @@ +package plugin + +import "errors" + +// Plugin is the parsed and validated representation of a single installed plugin. +// Path is the absolute path to the plugin's installation directory on disk. +type Plugin struct { + Path string // absolute path to plugin dir on disk + OSSIEPluginSpec string + OSSIESpecVersion string + Platform Platform + Setup string // relative path to setup script; empty = no setup + Convert ConvertConfig +} + +// Platform identifies the semantic platform this plugin handles. +type Platform struct { + Name string // required; matched against --from/--to values + Vendor string // optional; human-readable +} + +// ConvertConfig holds both conversion directions. +type ConvertConfig struct { + ToOssie Direction + FromOssie Direction +} + +// Direction describes one conversion direction. +type Direction struct { + Invoke []string // command + args; first element is the executable + Accepts []string // file extensions e.g. [".yaml"]; populated on ToOssie only +} + +// rawPlugin mirrors the on-disk plugin.yaml layout for YAML unmarshaling. +type rawPlugin struct { + OSSIEPluginSpec string `yaml:"ossie_plugin_spec"` + OSSIESpecVersion string `yaml:"ossie_spec_version"` + + Platform struct { + Name string `yaml:"name"` + Vendor string `yaml:"vendor"` + } `yaml:"platform"` + + Setup string `yaml:"setup"` + + Convert struct { + ToOssie struct { + Invoke []string `yaml:"invoke"` + Accepts []string `yaml:"accepts"` + } `yaml:"to_ossie"` + FromOssie struct { + Invoke []string `yaml:"invoke"` + } `yaml:"from_ossie"` + } `yaml:"convert"` +} + +// validate checks that all required fields are present. +// It performs presence checks only — not format validation. +func (r *rawPlugin) validate() error { + switch { + case r.OSSIEPluginSpec == "": + return errors.New("missing required field: ossie_plugin_spec") + case r.OSSIESpecVersion == "": + return errors.New("missing required field: ossie_spec_version") + case r.Platform.Name == "": + return errors.New("missing required field: platform.name") + case len(r.Convert.ToOssie.Invoke) == 0: + return errors.New("missing required field: convert.to_ossie.invoke") + case len(r.Convert.ToOssie.Accepts) == 0: + return errors.New("missing required field: convert.to_ossie.accepts") + case len(r.Convert.FromOssie.Invoke) == 0: + return errors.New("missing required field: convert.from_ossie.invoke") + } + return nil +} + +// toPlugin maps a validated rawPlugin to the exported Plugin type. +// path is the absolute directory path of the plugin's installation. +func (r *rawPlugin) toPlugin(path string) *Plugin { + return &Plugin{ + Path: path, + OSSIEPluginSpec: r.OSSIEPluginSpec, + OSSIESpecVersion: r.OSSIESpecVersion, + Platform: Platform{ + Name: r.Platform.Name, + Vendor: r.Platform.Vendor, + }, + Setup: r.Setup, + Convert: ConvertConfig{ + ToOssie: Direction{ + Invoke: r.Convert.ToOssie.Invoke, + Accepts: r.Convert.ToOssie.Accepts, + }, + FromOssie: Direction{ + Invoke: r.Convert.FromOssie.Invoke, + }, + }, + } +} diff --git a/cli/internal/registry/plugins-registry.yaml b/cli/internal/registry/plugins-registry.yaml new file mode 100644 index 0000000..9a03d86 --- /dev/null +++ b/cli/internal/registry/plugins-registry.yaml @@ -0,0 +1,45 @@ +# OSSIE Plugin Registry +# +# Format: each top-level key is a platform name. The value is a list of +# versioned entries ordered oldest → newest; the last entry is the latest. +# +# Entry types: +# git_release — download from a GitHub release tarball +# raw_url — download from an arbitrary URL +# +# NOTE: All entries below are placeholders pending plugin packaging. + +dbt: + - version: "0.1.0" + type: git_release + url: https://github.com/open-semantic-interchange/OSI + tag: plugin-dbt-v0.1.0 + checksum: sha256:placeholder + +gooddata: + - version: "0.1.0" + type: git_release + url: https://github.com/open-semantic-interchange/OSI + tag: plugin-gooddata-v0.1.0 + checksum: sha256:placeholder + +polaris: + - version: "0.1.0" + type: git_release + url: https://github.com/open-semantic-interchange/OSI + tag: plugin-polaris-v0.1.0 + checksum: sha256:placeholder + +salesforce: + - version: "0.1.0" + type: git_release + url: https://github.com/open-semantic-interchange/OSI + tag: plugin-salesforce-v0.1.0 + checksum: sha256:placeholder + +snowflake: + - version: "0.1.0" + type: git_release + url: https://github.com/open-semantic-interchange/OSI + tag: plugin-snowflake-v0.1.0 + checksum: sha256:placeholder diff --git a/cli/internal/registry/registry.go b/cli/internal/registry/registry.go new file mode 100644 index 0000000..d2817bf --- /dev/null +++ b/cli/internal/registry/registry.go @@ -0,0 +1,76 @@ +package registry + +import ( + _ "embed" // blank import required to activate //go:embed + "fmt" + "sort" + + "gopkg.in/yaml.v3" +) + +// Entry types supported by the registry. +// These constants are consumed by P2 (plugin install) when dispatching +// on how to download and extract a plugin archive. +const ( + EntryTypeGitRelease = "git_release" + EntryTypeRawURL = "raw_url" +) + +// Entry is a single versioned release of a plugin for one platform. +type Entry struct { + Version string `yaml:"version"` + Type string `yaml:"type"` // EntryTypeGitRelease or EntryTypeRawURL + URL string `yaml:"url"` + Tag string `yaml:"tag,omitempty"` // git_release only + Checksum string `yaml:"checksum"` +} + +// Registry maps a platform name to its list of versioned entries, ordered +// oldest to newest. The last entry in each slice is the latest version. +type Registry map[string][]Entry + +//go:embed plugins-registry.yaml +var registryData []byte + +// Load parses the embedded plugins-registry.yaml and returns a Registry. +// An error here indicates a build-time defect; registry_test.go validates +// the embedded data on every test run. +func Load() (Registry, error) { + var r Registry + if err := yaml.Unmarshal(registryData, &r); err != nil { + return nil, fmt.Errorf("failed to parse embedded plugin registry: %w", err) + } + return r, nil +} + +// Platforms returns all platform names in the registry in alphabetical order. +func (r Registry) Platforms() []string { + names := make([]string, 0, len(r)) + for name := range r { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// LatestEntry returns the last (newest) entry for the given platform. +// It returns false if the platform is not in the registry or has no entries. +// Registry maintainers are responsible for keeping entries ordered oldest → newest. +func (r Registry) LatestEntry(platform string) (Entry, bool) { + entries, ok := r[platform] + if !ok || len(entries) == 0 { + return Entry{}, false + } + return entries[len(entries)-1], true +} + +// FindEntry returns the entry matching the given platform and version. +// It returns false if the platform or version is not found. +func (r Registry) FindEntry(platform, version string) (Entry, bool) { + for _, entry := range r[platform] { + if entry.Version == version { + return entry, true + } + } + return Entry{}, false +} diff --git a/cli/internal/registry/registry_test.go b/cli/internal/registry/registry_test.go new file mode 100644 index 0000000..92288d8 --- /dev/null +++ b/cli/internal/registry/registry_test.go @@ -0,0 +1,115 @@ +package registry_test + +import ( + "testing" + + "github.com/open-semantic-interchange/ossie/cli/internal/registry" +) + +// knownPlatforms is the authoritative list of platforms that must be present +// in the embedded registry for the CLI to have useful output. +// Declared at package level (not inside the test) because slices cannot be +// constants in Go, and multiple tests reference this list. +var knownPlatforms = []string{"dbt", "gooddata", "polaris", "salesforce", "snowflake"} + +func TestLoad_succeeds(t *testing.T) { + reg, err := registry.Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + if reg == nil { + t.Fatal("Load() returned nil registry") + } +} + +func TestLoad_containsAllPlatforms(t *testing.T) { + reg, err := registry.Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + for _, platform := range knownPlatforms { + entries, ok := reg[platform] + if !ok { + t.Errorf("platform %q not found in registry", platform) + continue + } + if len(entries) == 0 { + t.Errorf("platform %q has no entries", platform) + } + } +} + +func TestRegistry_Platforms_sorted(t *testing.T) { + reg, err := registry.Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + got := reg.Platforms() + if len(got) == 0 { + t.Fatal("Platforms() returned empty slice") + } + for i := 1; i < len(got); i++ { + if got[i] < got[i-1] { + t.Errorf("Platforms() not sorted: %q appears after %q", got[i-1], got[i]) + } + } +} + +func TestRegistry_LatestEntry_found(t *testing.T) { + reg, err := registry.Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + entry, ok := reg.LatestEntry("dbt") + if !ok { + t.Fatal("LatestEntry(\"dbt\") returned false, want true") + } + if entry.Version == "" { + t.Error("LatestEntry(\"dbt\") returned entry with empty Version") + } + if entry.Type == "" { + t.Error("LatestEntry(\"dbt\") returned entry with empty Type") + } + if entry.URL == "" { + t.Error("LatestEntry(\"dbt\") returned entry with empty URL") + } + if entry.Checksum == "" { + t.Error("LatestEntry(\"dbt\") returned entry with empty Checksum") + } +} + +func TestRegistry_LatestEntry_notFound(t *testing.T) { + reg, err := registry.Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + _, ok := reg.LatestEntry("does-not-exist") + if ok { + t.Error("LatestEntry(\"does-not-exist\") returned true, want false") + } +} + +func TestRegistry_FindEntry_found(t *testing.T) { + reg, err := registry.Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + entry, ok := reg.FindEntry("dbt", "0.1.0") + if !ok { + t.Fatal("FindEntry(\"dbt\", \"0.1.0\") returned false, want true") + } + if entry.Version != "0.1.0" { + t.Errorf("FindEntry version: got %q, want %q", entry.Version, "0.1.0") + } +} + +func TestRegistry_FindEntry_notFound(t *testing.T) { + reg, err := registry.Load() + if err != nil { + t.Fatalf("Load() returned error: %v", err) + } + _, ok := reg.FindEntry("dbt", "99.99.99") + if ok { + t.Error("FindEntry(\"dbt\", \"99.99.99\") returned true, want false") + } +} 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") + } +} diff --git a/cli/main.go b/cli/main.go new file mode 100644 index 0000000..9d2a202 --- /dev/null +++ b/cli/main.go @@ -0,0 +1,21 @@ +package main + +import ( + "os" + + "github.com/open-semantic-interchange/ossie/cli/cmd" +) + +// version, commit, and date are set at build time by GoReleaser via ldflags. +var ( + version = "dev" + commit = "none" + date = "unknown" +) + +func main() { + cmd.SetVersion(version) + if err := cmd.Execute(); err != nil { + os.Exit(1) + } +}