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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/cli-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 9 additions & 5 deletions cli/Makefile
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
215 changes: 212 additions & 3 deletions cli/cmd/convert.go
Original file line number Diff line number Diff line change
@@ -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"
)

Expand All @@ -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
}
Loading
Loading