suma 0.1.23 → 0.1.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+ require_relative "../eengine/wrapper"
5
+ require_relative "../eengine_converter"
6
+
7
+ module Suma
8
+ module Cli
9
+ # Command to compare EXPRESS schemas using eengine
10
+ class Compare < Thor
11
+ desc "compare TRIAL_SCHEMA REFERENCE_SCHEMA",
12
+ "Compare EXPRESS schemas using eengine and generate Change YAML"
13
+ long_desc <<~DESC
14
+ Compare two EXPRESS schemas from different git branches/checkouts.
15
+
16
+ Typical workflow:
17
+ 1. Check out old version of repo at /path/to/repo-old
18
+ 2. Check out new version of repo at /path/to/repo-new
19
+ 3. Run comparison:
20
+ suma compare \\
21
+ /path/to/repo-new/schemas/.../schema.exp \\
22
+ /path/to/repo-old/schemas/.../schema.exp \\
23
+ --version 2
24
+
25
+ The command will:
26
+ - Auto-detect repository roots from schema paths
27
+ - Use those as stepmod paths for eengine
28
+ - Generate/update the .changes.yaml file in the new repo
29
+ DESC
30
+
31
+ option :output, type: :string, aliases: "-o",
32
+ desc: "Output Change YAML file path " \
33
+ "(default: {schema}.changes.yaml in trial schema directory)"
34
+ option :version, type: :string, aliases: "-v", required: true,
35
+ desc: "Version number for this change edition"
36
+ option :mode, type: :string, default: "resource",
37
+ enum: ["resource", "module"],
38
+ desc: "Schema comparison mode"
39
+ option :trial_stepmod, type: :string,
40
+ desc: "Override auto-detected trial repo root"
41
+ option :reference_stepmod, type: :string,
42
+ desc: "Override auto-detected reference repo root"
43
+ option :verbose, type: :boolean, default: false,
44
+ desc: "Enable verbose output"
45
+
46
+ def compare(trial_schema, reference_schema)
47
+ # Validate schema files exist
48
+ unless File.exist?(trial_schema)
49
+ say "Error: Trial schema not found: #{trial_schema}", :red
50
+ exit 1
51
+ end
52
+
53
+ unless File.exist?(reference_schema)
54
+ say "Error: Reference schema not found: #{reference_schema}", :red
55
+ exit 1
56
+ end
57
+
58
+ # Check eengine availability
59
+ unless Eengine::Wrapper.available?
60
+ say "Error: eengine not found in PATH", :red
61
+ say "Install eengine following instructions at:"
62
+ say " macOS: https://github.com/expresslang/homebrew-eengine"
63
+ say " Linux: https://github.com/expresslang/eengine-releases"
64
+ exit 1
65
+ end
66
+
67
+ # Auto-detect repo roots
68
+ trial_stepmod = options[:trial_stepmod] ||
69
+ detect_repo_root(trial_schema)
70
+ reference_stepmod = options[:reference_stepmod] ||
71
+ detect_repo_root(reference_schema)
72
+
73
+ if options[:verbose]
74
+ say "Using eengine version: #{Eengine::Wrapper.version}", :green
75
+ say "Trial repo root: #{trial_stepmod}", :cyan
76
+ say "Reference repo root: #{reference_stepmod}", :cyan
77
+ end
78
+
79
+ # Create a temporary directory for eengine output
80
+ require "tmpdir"
81
+ out_dir = nil
82
+ out_dir = Dir.mktmpdir("eengine-compare-")
83
+
84
+ # Run comparison
85
+ result = Eengine::Wrapper.compare(
86
+ trial_schema,
87
+ reference_schema,
88
+ mode: options[:mode],
89
+ trial_stepmod: trial_stepmod,
90
+ reference_stepmod: reference_stepmod,
91
+ out_dir: out_dir,
92
+ )
93
+
94
+ unless result[:has_changes]
95
+ say "No changes detected between schemas", :yellow
96
+ # Clean up temp directory
97
+ FileUtils.rm_rf(out_dir) if out_dir && File.directory?(out_dir)
98
+ return
99
+ end
100
+
101
+ unless result[:xml_path]
102
+ say "Error: XML output not found", :red
103
+ exit 1
104
+ end
105
+
106
+ if options[:verbose]
107
+ say "Comparison XML generated: #{result[:xml_path]}", :green
108
+ end
109
+
110
+ # Convert to Change YAML
111
+ convert_to_change_yaml(result[:xml_path], trial_schema, out_dir)
112
+ rescue Eengine::EengineError => e
113
+ # Clean up temp directory
114
+ FileUtils.rm_rf(out_dir) if out_dir && File.directory?(out_dir)
115
+ say "Error: #{e.message}", :red
116
+ say e.stderr if e.respond_to?(:stderr) && options[:verbose]
117
+ exit 1
118
+ end
119
+
120
+ private
121
+
122
+ def detect_repo_root(schema_path)
123
+ # Walk up from schema path to find .git directory
124
+ current = File.expand_path(File.dirname(schema_path))
125
+
126
+ loop do
127
+ if File.directory?(File.join(current, ".git"))
128
+ return current
129
+ end
130
+
131
+ parent = File.dirname(current)
132
+ break if parent == current # reached root
133
+
134
+ current = parent
135
+ end
136
+
137
+ # If no .git found, use the directory containing the schema
138
+ # (for non-git workflows)
139
+ File.dirname(schema_path)
140
+ end
141
+
142
+ def convert_to_change_yaml(xml_path, trial_schema, out_dir)
143
+ schema_name = extract_schema_name(trial_schema)
144
+ output_path = determine_output_path(trial_schema)
145
+
146
+ # Load existing ChangeSchema if it exists
147
+ existing_schema = if File.exist?(output_path)
148
+ if options[:verbose]
149
+ say "Loading existing change schema: " \
150
+ "#{output_path}", :cyan
151
+ end
152
+ require "expressir/changes"
153
+ Expressir::Changes::SchemaChange.from_file(output_path)
154
+ end
155
+
156
+ # Convert using Suma's converter
157
+ converter = EengineConverter.new(xml_path, schema_name)
158
+ change_schema = converter.convert(
159
+ version: options[:version],
160
+ existing_change_schema: existing_schema,
161
+ )
162
+
163
+ # Save using Expressir model
164
+ change_schema.to_file(output_path)
165
+
166
+ # Determine what action was taken
167
+ if existing_schema
168
+ existing_edition = existing_schema.editions.find do |ed|
169
+ ed.version == options[:version]
170
+ end
171
+
172
+ say "Change YAML file updated: #{output_path}", :green
173
+ if existing_edition
174
+ say " Replaced existing version #{options[:version]}", :green
175
+ else
176
+ say " Added version #{options[:version]} to change editions",
177
+ :green
178
+ end
179
+ else
180
+ say "Change YAML file created: #{output_path}", :green
181
+ end
182
+
183
+ if options[:verbose]
184
+ say "\nGenerated change schema content:", :cyan
185
+ say File.read(output_path)
186
+ end
187
+
188
+ # Clean up temp directory and XML file
189
+ FileUtils.rm_rf(out_dir) if out_dir && File.directory?(out_dir)
190
+ end
191
+
192
+ def extract_schema_name(path)
193
+ # Remove version suffix if present (e.g., schema_1.exp -> schema)
194
+ basename = File.basename(path, ".exp")
195
+ basename.sub(/_\d+$/, "")
196
+ end
197
+
198
+ def determine_output_path(trial_schema)
199
+ if options[:output]
200
+ options[:output]
201
+ else
202
+ # Place .changes.yaml next to the trial schema in the NEW repo
203
+ base = extract_schema_name(trial_schema)
204
+ dir = File.dirname(trial_schema)
205
+ File.join(dir, "#{base}.changes.yaml")
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end
@@ -2,42 +2,50 @@
2
2
 
3
3
  require "thor"
4
4
  require_relative "../thor_ext"
5
+ require_relative "../export_standalone_schema"
5
6
 
6
7
  module Suma
7
8
  module Cli
8
9
  # Export command for exporting EXPRESS schemas from a manifest
9
10
  class Export < Thor
10
- desc "export MANIFEST_FILE",
11
- "Export EXPRESS schemas from manifest"
11
+ desc "export *FILES",
12
+ "Export EXPRESS schemas from manifest files or " \
13
+ "standalone EXPRESS files"
12
14
  option :output, type: :string, aliases: "-o", required: true,
13
15
  desc: "Output directory path"
14
- option :additional, type: :array, aliases: "-a",
15
- desc: "Additional schemas manifest files to merge (can be specified multiple times)"
16
16
  option :annotations, type: :boolean, default: false,
17
17
  desc: "Include annotations (remarks/comments)"
18
18
  option :zip, type: :boolean, default: false,
19
19
  desc: "Create ZIP archive of exported schemas"
20
20
 
21
- def export(manifest_file)
21
+ def export(*files)
22
22
  require_relative "../schema_exporter"
23
23
  require_relative "../utils"
24
24
  require "expressir"
25
25
 
26
- unless File.exist?(manifest_file)
27
- raise Errno::ENOENT, "Specified manifest file " \
28
- "`#{manifest_file}` not found."
29
- end
30
-
31
- run(manifest_file, options)
26
+ validate_files(files)
27
+ run(files, options)
32
28
  end
33
29
 
34
30
  private
35
31
 
36
- def run(manifest_file, options)
37
- config = load_and_merge_configs(manifest_file, options[:additional])
32
+ def validate_files(files)
33
+ if files.empty?
34
+ raise ArgumentError, "At least one file must be specified"
35
+ end
36
+
37
+ files.each do |file|
38
+ unless File.exist?(file)
39
+ raise Errno::ENOENT, "Specified file `#{file}` not found."
40
+ end
41
+ end
42
+ end
43
+
44
+ def run(files, options)
45
+ schemas = load_schemas_from_files(files)
38
46
 
39
47
  exporter = SchemaExporter.new(
40
- config: config,
48
+ schemas: schemas,
41
49
  output_path: options[:output],
42
50
  options: {
43
51
  annotations: options[:annotations],
@@ -48,36 +56,40 @@ module Suma
48
56
  exporter.export
49
57
  end
50
58
 
51
- # rubocop:disable Metrics/MethodLength
52
- def load_and_merge_configs(primary_path, additional_paths)
53
- primary_config = Expressir::SchemaManifest.from_file(primary_path)
54
- return primary_config unless additional_paths && !additional_paths.empty?
59
+ def load_schemas_from_files(files)
60
+ all_schemas = []
55
61
 
56
- # Load all additional manifests
57
- additional_configs = additional_paths.map do |path|
58
- unless File.exist?(path)
59
- raise Errno::ENOENT, "Specified additional manifest file " \
60
- "`#{path}` not found."
61
- end
62
- Expressir::SchemaManifest.from_file(path)
62
+ files.each do |file|
63
+ all_schemas += process_file(file)
63
64
  end
64
65
 
65
- # Merge all configs into the primary
66
- merge_all_configs(primary_config, additional_configs)
66
+ all_schemas
67
67
  end
68
- # rubocop:enable Metrics/MethodLength
69
-
70
- def merge_all_configs(primary, additional_configs)
71
- # Collect all schemas from primary and all additional manifests
72
- all_schemas = primary.schemas.dup
73
68
 
74
- additional_configs.each do |config|
75
- all_schemas += config.schemas
69
+ def process_file(file)
70
+ case File.extname(file).downcase
71
+ when ".yml", ".yaml"
72
+ load_manifest_schemas(file)
73
+ when ".exp"
74
+ [create_schema_from_exp_file(file)]
75
+ else
76
+ raise ArgumentError, "Unsupported file type: #{file}. " \
77
+ "Only .yml, .yaml, and .exp files are " \
78
+ "supported."
76
79
  end
80
+ end
81
+
82
+ def load_manifest_schemas(file)
83
+ manifest = Expressir::SchemaManifest.from_file(file)
84
+ manifest.schemas
85
+ end
77
86
 
78
- Expressir::SchemaManifest.new(
79
- path: primary.path,
80
- schemas: all_schemas,
87
+ def create_schema_from_exp_file(exp_file)
88
+ # Create a schema object from a standalone EXPRESS file
89
+ # The id will be determined during parsing
90
+ ExportStandaloneSchema.new(
91
+ id: nil,
92
+ path: File.expand_path(exp_file),
81
93
  )
82
94
  end
83
95
 
data/lib/suma/cli.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "thor"
4
4
  require_relative "thor_ext"
5
5
  require_relative "cli/validate"
6
+ require "expressir/cli"
6
7
 
7
8
  module Suma
8
9
  module Cli
@@ -15,10 +16,9 @@ module Suma
15
16
  option :compile, type: :boolean, default: true,
16
17
  desc: "Compile or skip compile of collection"
17
18
  option :schemas_all_path, type: :string, aliases: "-s",
18
- desc: "Generate file that contains all schemas in the collection."
19
+ desc: "Generate file that contains all " \
20
+ "schemas in the collection."
19
21
  def build(_site_manifest)
20
- # # If no arguments, add an empty array to ensure the default command is triggered
21
- # args = [] if args.empty?
22
22
  require_relative "cli/build"
23
23
  Cli::Build.start
24
24
  end
@@ -60,24 +60,45 @@ module Suma
60
60
  Cli::ConvertJsdai.start
61
61
  end
62
62
 
63
- desc "export MANIFEST_FILE",
64
- "Export EXPRESS schemas from manifest"
63
+ desc "export *FILES",
64
+ "Export EXPRESS schemas from manifest files or " \
65
+ "standalone EXPRESS files"
65
66
  option :output, type: :string, aliases: "-o", required: true,
66
67
  desc: "Output directory path"
67
- option :additional, type: :array, aliases: "-a",
68
- desc: "Additional schemas manifest files to merge (can be specified multiple times)"
69
68
  option :annotations, type: :boolean, default: false,
70
69
  desc: "Include annotations (remarks/comments)"
71
70
  option :zip, type: :boolean, default: false,
72
71
  desc: "Create ZIP archive of exported schemas"
73
- def export(_manifest_file)
72
+ def export(*_files)
74
73
  require_relative "cli/export"
75
74
  Cli::Export.start
76
75
  end
77
76
 
77
+ desc "compare TRIAL_SCHEMA REFERENCE_SCHEMA",
78
+ "Compare EXPRESS schemas using eengine and generate Change YAML"
79
+ option :output, type: :string, aliases: "-o",
80
+ desc: "Output Change YAML file path"
81
+ option :version, type: :string, aliases: "-v", required: true,
82
+ desc: "Version number for this change edition"
83
+ option :mode, type: :string, default: "resource",
84
+ desc: "Schema comparison mode (resource/module)"
85
+ option :trial_stepmod, type: :string,
86
+ desc: "Override auto-detected trial repo root"
87
+ option :reference_stepmod, type: :string,
88
+ desc: "Override auto-detected reference repo root"
89
+ option :verbose, type: :boolean, default: false,
90
+ desc: "Enable verbose output"
91
+ def compare(_trial_schema, _reference_schema)
92
+ require_relative "cli/compare"
93
+ Cli::Compare.start
94
+ end
95
+
78
96
  desc "validate SUBCOMMAND ...ARGS", "Validate express documents"
79
97
  subcommand "validate", Cli::Validate
80
98
 
99
+ desc "expressir SUBCOMMAND ...ARGS", "Expressir commands"
100
+ subcommand "expressir", Expressir::Cli
101
+
81
102
  def self.exit_on_failure?
82
103
  true
83
104
  end
@@ -36,12 +36,25 @@ module Suma
36
36
  model.entry = CollectionManifest.from_yaml(value.to_yaml)
37
37
  end
38
38
 
39
+ # Recursively exports schema configuration by traversing collection manifests.
40
+ #
41
+ # This method builds an EXPRESS Schema Manifest (Expressir::SchemaManifest) by:
42
+ # 1. Starting with an empty or existing Expressir::SchemaManifest
43
+ # 2. Recursively traversing child entries to collect schemas
44
+ # 3. Using Expressir::SchemaManifest#concat to combine manifests
45
+ #
46
+ # The actual schema manifest operations (creation, concatenation, serialization)
47
+ # are handled by Expressir's SchemaManifest class, keeping the logic DRY.
48
+ #
49
+ # @param path [String] Base path for resolving relative schema paths
50
+ # @return [Expressir::SchemaManifest] Combined schema manifest
39
51
  def export_schema_config(path)
40
52
  export_config = @schema_config || Expressir::SchemaManifest.new
41
53
  return export_config unless entry
42
54
 
43
55
  entry.each do |x|
44
56
  child_config = x.export_schema_config(path)
57
+ # Use Expressir's concat method to combine schema manifests
45
58
  export_config.concat(child_config) if child_config
46
59
  end
47
60
 
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Suma
4
+ module Eengine
5
+ # Base error class for eengine-related errors
6
+ class EengineError < StandardError; end
7
+
8
+ # Raised when eengine binary is not found in PATH
9
+ class EengineNotFoundError < EengineError
10
+ def initialize
11
+ super("eengine not found in PATH. Install eengine:\n " \
12
+ "macOS: https://github.com/expresslang/homebrew-eengine\n " \
13
+ "Linux: https://github.com/expresslang/eengine-releases")
14
+ end
15
+ end
16
+
17
+ # Raised when eengine comparison fails
18
+ class ComparisonError < EengineError
19
+ attr_reader :stderr
20
+
21
+ def initialize(message, stderr = nil)
22
+ super(message)
23
+ @stderr = stderr
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "errors"
5
+
6
+ module Suma
7
+ module Eengine
8
+ # Wrapper for eengine binary to compare EXPRESS schemas
9
+ class Wrapper
10
+ class << self
11
+ # Compare two EXPRESS schemas using eengine
12
+ #
13
+ # @param trial_schema [String] Path to the new/trial schema
14
+ # @param reference_schema [String] Path to the old/reference schema
15
+ # @param options [Hash] Comparison options
16
+ # @option options [String] :mode Comparison mode (resource/module)
17
+ # @option options [String] :trial_stepmod Path to trial repo root
18
+ # @option options [String] :reference_stepmod Path to reference repo root
19
+ # @return [Hash] Result with :success, :xml_path, :has_changes, :output
20
+ def compare(trial_schema, reference_schema, options = {})
21
+ ensure_eengine_available!
22
+
23
+ cmd = build_command(trial_schema, reference_schema, options)
24
+ output, error, status = Open3.capture3(*cmd)
25
+
26
+ unless status.success?
27
+ error_message = error.empty? ? "Unknown eengine error" : error.strip
28
+ raise ComparisonError.new(error_message, error)
29
+ end
30
+
31
+ parse_output(output, options)
32
+ end
33
+
34
+ # Check if eengine is available on the system
35
+ #
36
+ # @return [Boolean] true if eengine binary is found
37
+ def available?
38
+ return false if ENV["EENGINE_DISABLED"] == "true"
39
+
40
+ eengine_path && eengine_executable?
41
+ end
42
+
43
+ # Get the eengine version
44
+ #
45
+ # @return [String, nil] Version string or nil if unavailable
46
+ def version
47
+ return nil unless available?
48
+
49
+ cmd = [eengine_path, "--version"]
50
+ output, _, status = Open3.capture3(*cmd)
51
+ status.success? ? parse_version(output) : nil
52
+ rescue StandardError
53
+ nil
54
+ end
55
+
56
+ private
57
+
58
+ def eengine_path
59
+ @eengine_path ||= find_eengine_binary
60
+ end
61
+
62
+ def find_eengine_binary
63
+ # Search for eengine or eengine-* in PATH
64
+ ENV["PATH"].split(File::PATH_SEPARATOR).each do |dir|
65
+ # First try plain eengine
66
+ plain_path = File.join(dir, "eengine")
67
+ return plain_path if File.exist?(plain_path) && File.executable?(plain_path)
68
+
69
+ # Then try eengine-* pattern
70
+ Dir.glob(File.join(dir, "eengine-*")).each do |path|
71
+ return path if File.executable?(path)
72
+ end
73
+ end
74
+ nil
75
+ end
76
+
77
+ def eengine_executable?
78
+ eengine_path && File.executable?(eengine_path)
79
+ end
80
+
81
+ def ensure_eengine_available!
82
+ raise EengineNotFoundError unless available?
83
+ end
84
+
85
+ def build_command(trial, reference, options)
86
+ cmd = [
87
+ eengine_path,
88
+ "--compare",
89
+ "-trial_schema", trial,
90
+ "-trial_stepmod", options[:trial_stepmod] || ".",
91
+ "-reference_schema", reference,
92
+ "-reference_stepmod", options[:reference_stepmod] || ".",
93
+ "-mode", options[:mode] || "resource",
94
+ "--xml-output"
95
+ ]
96
+
97
+ # Add output directory if specified
98
+ if options[:out_dir]
99
+ cmd += ["-out-dir", options[:out_dir]]
100
+ end
101
+
102
+ cmd
103
+ end
104
+
105
+ def parse_output(output, _options)
106
+ # Extract XML file path from output
107
+ # eengine prints: "Writing \"path/to/file.xml\""
108
+ xml_match = output.match(/Writing "(.+\.xml)"/)
109
+ xml_path = xml_match ? xml_match[1] : nil
110
+
111
+ # Expand to absolute path if found
112
+ xml_path = File.expand_path(xml_path) if xml_path
113
+
114
+ # Determine if changes were detected
115
+ has_changes = detect_changes(output)
116
+
117
+ {
118
+ success: true,
119
+ xml_path: xml_path,
120
+ has_changes: has_changes,
121
+ output: output,
122
+ }
123
+ end
124
+
125
+ def detect_changes(output)
126
+ # Check for various indicators of changes in the output
127
+ return true if output.include?("Comparing TYPE")
128
+ return true if output.include?("Comparing ENTITY")
129
+ return true if output.include?("Comparing FUNCTION")
130
+ return true if output.include?("Comparing RULE")
131
+ return true if output.include?("Comparing PROCEDURE")
132
+
133
+ # Check for modification indicators
134
+ return true if output.include?("changed")
135
+ return true if output.include?("modified")
136
+ return true if output.include?("added")
137
+ return true if output.include?("removed")
138
+
139
+ false
140
+ end
141
+
142
+ def parse_version(output)
143
+ # Extract version from output like "Express Engine 5.2.7"
144
+ version_match = output.match(/Express Engine ([\d.]+)/)
145
+ version_match ? version_match[1] : nil
146
+ end
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "expressir/commands/changes_import_eengine"
4
+
5
+ module Suma
6
+ # Converts eengine comparison XML to Expressir::Changes::SchemaChange
7
+ # This is a thin wrapper around Expressir's ChangesImportEengine command
8
+ class EengineConverter
9
+ def initialize(xml_path, schema_name)
10
+ @xml_path = xml_path
11
+ @schema_name = schema_name
12
+ @xml_content = File.read(xml_path)
13
+ end
14
+
15
+ # Convert the eengine XML to a ChangeSchema
16
+ #
17
+ # @param version [String] Version number for this change edition
18
+ # @param existing_change_schema [Expressir::Changes::SchemaChange, nil]
19
+ # Existing schema to append to, or nil to create new
20
+ # @return [Expressir::Changes::SchemaChange] The updated change schema
21
+ def convert(version:, existing_change_schema: nil)
22
+ # Use Expressir's built-in conversion which properly handles
23
+ # HTML elements in descriptions
24
+ Expressir::Commands::ChangesImportEengine.from_xml(
25
+ @xml_content,
26
+ @schema_name,
27
+ version,
28
+ existing_schema: existing_change_schema,
29
+ )
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Suma
4
+ # Simple schema class for standalone EXPRESS files
5
+ # Used when exporting individual .exp files that are not part of a manifest
6
+ class ExportStandaloneSchema
7
+ attr_accessor :id, :path
8
+
9
+ def initialize(id:, path:)
10
+ @id = id
11
+ @path = path
12
+ end
13
+ end
14
+ end