diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f2c201..c9f5fb2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,6 +24,7 @@ jobs: uses: metanorma/ci/.github/workflows/rubygems-release.yml@main with: next_version: ${{ github.event.inputs.next_version }} + submodules: true secrets: rubygems-api-key: ${{ secrets.UNITSML_CI_RUBYGEMS_API_KEY }} pat_token: ${{ secrets.UNITSML_CI_PAT_TOKEN }} diff --git a/.gitmodules b/.gitmodules index 0a9e618..6487250 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ -[submodule "spec/fixtures/unitsdb"] - path = spec/fixtures/unitsdb - url = https://github.com/unitsml/unitsdb/ +[submodule "data"] + path = data + url = https://github.com/unitsml/unitsdb.git + branch = refs/tags/v2.0.0 diff --git a/.rubocop.yml b/.rubocop.yml index f2e8eb5..5f68414 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -12,4 +12,4 @@ AllCops: NewCops: enable Exclude: - 'vendor/**/*' - - 'spec/fixtures/unitsdb/**/*' + - 'data/**/*' diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index a838e07..ba8e8b2 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,6 +1,6 @@ # This configuration was generated by # `rubocop --auto-gen-config` -# on 2026-03-27 10:55:21 UTC using RuboCop version 1.86.0. +# on 2026-04-03 22:15:03 UTC using RuboCop version 1.86.0. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new @@ -11,21 +11,92 @@ Gemspec/RequiredRubyVersion: Exclude: - 'unitsdb.gemspec' -# Offense count: 1 +# Offense count: 9 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, IndentationWidth. +# SupportedStyles: with_first_argument, with_fixed_indentation +Layout/ArgumentAlignment: + Exclude: + - 'lib/unitsdb/commands/get.rb' + - 'lib/unitsdb/commands/release.rb' + - 'lib/unitsdb/commands/search.rb' + - 'lib/unitsdb/commands/validate/identifiers.rb' + - 'lib/unitsdb/commands/validate/qudt_references.rb' + - 'lib/unitsdb/commands/validate/references.rb' + - 'lib/unitsdb/commands/validate/si_references.rb' + - 'lib/unitsdb/commands/validate/ucum_references.rb' + - 'spec/unitsdb/commands/normalize_spec.rb' + +# Offense count: 6 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: EnforcedStyleAlignWith. # SupportedStylesAlignWith: either, start_of_block, start_of_line Layout/BlockAlignment: Exclude: - 'spec/unitsdb/commands/normalize_spec.rb' + - 'spec/unitsdb/commands/qudt/update_spec.rb' + - 'spec/unitsdb/commands/search_spec.rb' + - 'spec/unitsdb/commands/validate/qudt_references_spec.rb' + - 'spec/unitsdb/commands/validate/ucum_references_spec.rb' + +# Offense count: 5 +# This cop supports safe autocorrection (--autocorrect). +Layout/BlockEndNewline: + Exclude: + - 'spec/unitsdb/commands/normalize_spec.rb' + - 'spec/unitsdb/commands/qudt/update_spec.rb' + - 'spec/unitsdb/commands/search_spec.rb' + - 'spec/unitsdb/commands/validate/qudt_references_spec.rb' + - 'spec/unitsdb/commands/validate/ucum_references_spec.rb' + +# Offense count: 5 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowMultipleStyles, EnforcedHashRocketStyle, EnforcedColonStyle, EnforcedLastArgumentHashStyle. +# SupportedHashRocketStyles: key, separator, table +# SupportedColonStyles: key, separator, table +# SupportedLastArgumentHashStyles: always_inspect, always_ignore, ignore_implicit, ignore_explicit +Layout/HashAlignment: + Exclude: + - 'lib/unitsdb/cli.rb' + - 'lib/unitsdb/commands/_modify.rb' + - 'lib/unitsdb/commands/qudt.rb' + - 'lib/unitsdb/commands/ucum.rb' + - 'lib/unitsdb/commands/validate.rb' + +# Offense count: 10 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: Width, EnforcedStyleAlignWith, AllowedPatterns. +# SupportedStylesAlignWith: start_of_line, relative_to_receiver +Layout/IndentationWidth: + Exclude: + - 'spec/unitsdb/commands/normalize_spec.rb' + - 'spec/unitsdb/commands/qudt/update_spec.rb' + - 'spec/unitsdb/commands/search_spec.rb' + - 'spec/unitsdb/commands/validate/qudt_references_spec.rb' + - 'spec/unitsdb/commands/validate/ucum_references_spec.rb' -# Offense count: 373 +# Offense count: 393 # This cop supports safe autocorrection (--autocorrect). # Configuration parameters: Max, AllowHeredoc, AllowURI, AllowQualifiedName, URISchemes, AllowRBSInlineAnnotation, AllowCopDirectives, AllowedPatterns, SplitStrings. # URISchemes: http, https Layout/LineLength: Enabled: false +# Offense count: 9 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: AllowInHeredoc. +Layout/TrailingWhitespace: + Exclude: + - 'lib/unitsdb/commands/get.rb' + - 'lib/unitsdb/commands/release.rb' + - 'lib/unitsdb/commands/search.rb' + - 'lib/unitsdb/commands/validate/identifiers.rb' + - 'lib/unitsdb/commands/validate/qudt_references.rb' + - 'lib/unitsdb/commands/validate/references.rb' + - 'lib/unitsdb/commands/validate/si_references.rb' + - 'lib/unitsdb/commands/validate/ucum_references.rb' + - 'spec/unitsdb/commands/normalize_spec.rb' + # Offense count: 9 # Configuration parameters: IgnoreLiteralBranches, IgnoreConstantBranches, IgnoreDuplicateElseBranch. Lint/DuplicateBranch: @@ -35,7 +106,7 @@ Lint/DuplicateBranch: - 'lib/unitsdb/commands/ucum/matcher.rb' - 'lib/unitsdb/database.rb' -# Offense count: 83 +# Offense count: 82 # Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max. Metrics/AbcSize: Enabled: false @@ -110,12 +181,12 @@ RSpec/DescribeClass: - 'spec/exe/unitsdb_spec.rb' - 'spec/unitsdb/version_compatibility_spec.rb' -# Offense count: 30 +# Offense count: 43 # Configuration parameters: CountAsOne. RSpec/ExampleLength: Max: 30 -# Offense count: 26 +# Offense count: 22 RSpec/ExpectOutput: Exclude: - 'spec/unitsdb/commands/check_si_command_spec.rb' @@ -137,13 +208,13 @@ RSpec/LeakyLocalVariable: - 'spec/unitsdb/unit_systems_spec.rb' - 'spec/unitsdb/units_spec.rb' -# Offense count: 7 +# Offense count: 6 # Configuration parameters: . # SupportedStyles: have_received, receive RSpec/MessageSpies: EnforcedStyle: receive -# Offense count: 45 +# Offense count: 52 RSpec/MultipleExpectations: Max: 15 @@ -157,6 +228,19 @@ RSpec/MultipleMemoizedHelpers: RSpec/NestedGroups: Max: 4 +# Offense count: 2 +RSpec/RepeatedExample: + Exclude: + - 'spec/exe/unitsdb_spec.rb' + +# Offense count: 1 +# Configuration parameters: CustomTransform, IgnoreMethods, IgnoreMetadata, InflectorPath, EnforcedInflector. +# SupportedInflectors: default, active_support +RSpec/SpecFilePathFormat: + Exclude: + - '**/spec/routing/**/*' + - 'spec/unitsdb/bundled_data_spec.rb' + # Offense count: 17 # Configuration parameters: IgnoreNameless, IgnoreSymbolicNames. RSpec/VerifiedDoubles: @@ -165,6 +249,21 @@ RSpec/VerifiedDoubles: - 'spec/unitsdb/commands/validate/references_spec.rb' - 'spec/unitsdb/commands/validate/ucum_references_spec.rb' +# Offense count: 5 +# This cop supports safe autocorrection (--autocorrect). +# Configuration parameters: EnforcedStyle, ProceduralMethods, FunctionalMethods, AllowedMethods, AllowedPatterns, AllowBracesOnProceduralOneLiners, BracesRequiredMethods. +# SupportedStyles: line_count_based, semantic, braces_for_chaining, always_braces +# ProceduralMethods: benchmark, bm, bmbm, create, each_with_object, measure, new, realtime, tap, with_object +# FunctionalMethods: let, let!, subject, watch +# AllowedMethods: lambda, proc, it +Style/BlockDelimiters: + Exclude: + - 'spec/unitsdb/commands/normalize_spec.rb' + - 'spec/unitsdb/commands/qudt/update_spec.rb' + - 'spec/unitsdb/commands/search_spec.rb' + - 'spec/unitsdb/commands/validate/qudt_references_spec.rb' + - 'spec/unitsdb/commands/validate/ucum_references_spec.rb' + # Offense count: 1 # This cop supports unsafe autocorrection (--autocorrect-all). Style/CombinableLoops: @@ -177,6 +276,15 @@ Style/IdenticalConditionalBranches: Exclude: - 'lib/unitsdb/commands/check_si/si_formatter.rb' +# Offense count: 1 +# This cop supports unsafe autocorrection (--autocorrect-all). +# Configuration parameters: EnforcedStyle, AllowedMethods, AllowedPatterns. +# SupportedStyles: predicate, comparison +Style/NumericPredicate: + Exclude: + - 'spec/**/*' + - 'exe/unitsdb' + # Offense count: 14 # Configuration parameters: AllowedMethods. # AllowedMethods: respond_to_missing? diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1a6f075 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,55 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Development Commands + +```bash +bundle install # Install dependencies +bundle exec rake # Run full default task (rspec + rubocop) +bundle exec rspec # Run all tests +bundle exec rspec spec/path/to/file_spec.rb # Run single spec +bundle exec rubocop # Lint all files +bundle exec rake build # Build the gem +``` + +## Architecture + +### Bundled Data + +The gem ships UnitsDB YAML data files in `data/` (a git submodule at https://github.com/unitsml/unitsdb). These are included in the published gem via `spec.files += Dir.glob("data/**/*.yaml")` in the gemspec. + +Two entry points for the bundled data: +- `Unitsdb.data_dir` — path to the `data/` directory inside the gem +- `Unitsdb.database` — pre-loaded `Database.from_db(data_dir)` instance (cached) + +The correspondence between gem version and data version is tracked via +`Unitsdb::UNITS_DATA_VERSION` (e.g. `"2.0.0"`). **The UnitsDB data must be +released (tagged) before the gem can be released with updated data.** When +releasing with new data: tag the data in `unitsml/unitsdb`, then update +`.gitmodules` (`branch = refs/tags/new-data-tag`) and bump both `VERSION` and +`UNITS_DATA_VERSION` in `lib/unitsdb/version.rb`. + +### Core Classes + +- **`Unitsdb::Database`** — Loads all YAML files, provides `search`, `get_by_id`, `find_by_type`, `find_by_symbol`, `match_entities`, `validate_uniqueness`, `validate_references` +- **`Unitsdb::Cli`** (Thor-based) — Command-line interface with subcommands: `validate`, `search`, `get`, `check_si`, `ucum`, `qudt`, `_modify`, `release` +- **`lib/unitsdb/commands/base.rb`** — Base class for commands; provides `load_database` and `@options[:database]` + +### Model Pattern + +Entity types (Unit, Prefix, Dimension, Quantity, UnitSystem, etc.) are Lutaml::Model-serializable classes. They are loaded from YAML via `Database.from_db`, which validates `schema_version: "2.0.0"` across all files and merges them into a single object. + +### Workflow Files + +Workflows in `.github/workflows/` are auto-generated by **Cimas** (metanorma/cimas) and delegate to shared reusable workflows in `metanorma/ci`: +- `rake.yml` → `generic-rake.yml@main` — runs tests/lint on push/PR +- `release.yml` → `rubygems-release.yml@main` — builds and publishes gem +- `dependent-gems.yml` → `dependent-rake.yml@main` — tests downstream consumers + +### Git Submodule + +`data/` is a git submodule pinned to a specific tag in https://github.com/unitsml/unitsdb +(e.g. `branch = refs/tags/v2.0.0` in `.gitmodules`). The CI `rake.yml` uses +`submodules: 'recursive'` so tests have the data available. The release workflow +uses `submodules: true` to initialize the submodule during checkout. diff --git a/README.adoc b/README.adoc index 82cdbae..25c843d 100644 --- a/README.adoc +++ b/README.adoc @@ -19,6 +19,11 @@ https://github.com/unitsml/unitsdb. This repository contains the Ruby codebase for UnitsDB, which is used to access and manipulate the UnitsDB content. +The UnitsDB gem ships with the UnitsDB data bundled internally. When you install +the gem, the YAML data files are included automatically, so you do not need to +obtain or configure a separate data source. The data is accessed via +`Unitsdb.data_dir` or the convenience method `Unitsdb.database`. + == Install [source,sh] @@ -26,7 +31,79 @@ to access and manipulate the UnitsDB content. $ gem install unitsdb ---- +The UnitsDB gem ships with the UnitsDB YAML data files bundled inside the gem. +Because the gem bundles immutable data, **the UnitsDB data must be released +(tagged) before the gem can be released with updated data**. The gem version +(`Unitsdb::VERSION`) and the data version (`Unitsdb::UNITS_DATA_VERSION`) +are independent: the gem can be patched without changing data, and the data +can be updated independently of the gem. + + + +== How the Database Works + +The `Unitsdb::Database` class loads all UnitsDB YAML files and provides +methods for searching and querying the data. + +[source,ruby] +---- +require 'unitsdb' + +# Access the pre-loaded bundled database (recommended) +db = Unitsdb.database + +# Or load from a specific path +db = Unitsdb::Database.from_db('/path/to/data') +---- + +=== Lazy Loading and Caching + +The bundled database (`Unitsdb.database`) is loaded on first access and cached +for subsequent calls. The path to the bundled data is available via: + +[source,ruby] +---- +Unitsdb.data_dir # => Path to the data/ directory inside the gem +---- + +=== Database Structure + +The database contains collections for each entity type: + +[source,ruby] +---- +db.units # Array of Unit objects +db.prefixes # Array of Prefix objects +db.dimensions # Array of Dimension objects +db.quantities # Array of Quantity objects +db.unit_systems # Array of UnitSystem objects +db.scales # Array of Scale objects +---- + +=== Searching the Database + +[source,ruby] +---- +# Search by text (searches names, identifiers, descriptions) +results = db.search(text: "meter") + +# Find by exact ID +unit = db.get_by_id(id: "NISTu1") + +# Find by symbol +units = db.find_by_symbol("m") +---- + +=== Validation + +[source,ruby] +---- +# Check identifier uniqueness +dups = db.validate_uniqueness +# Validate all references exist +invalid_refs = db.validate_references +---- == UnitsDB version support @@ -38,6 +115,24 @@ The version of the YAML files are stored in the `version` field of the `*.yaml` files. The library checks this version when loading the database and raises an error if the version is not 2.0.0. +The `unitsdb-ruby` gem version tracks the bundled data version via +`Unitsdb::UNITS_DATA_VERSION`. For example, `unitsdb-ruby v2.1.2` bundles +`unitsdb` data at `v2.0.0`. When the gem is released with an updated data +submodule, both the gem version and `UNITS_DATA_VERSION` are updated together. + +The `data/` submodule is pinned to a specific tag in the +https://github.com/unitsml/unitsdb[UnitsDB repository] (e.g. `refs/tags/v2.0.0`). +This means: + +* Every bundled version of `unitsdb-ruby` ships with a known, immutable version + of the UnitsDB data. +* A new `unitsdb-ruby` release can only be made after the upstream UnitsDB data + has been released (tagged) in its own repository. +* To release a new gem with updated data: tag the new data in + `unitsml/unitsdb`, update the submodule's `branch` in `.gitmodules` to point + to `refs/tags/new-data-tag`, update `UNITS_DATA_VERSION`, then bump the gem + version and release. + === UnitsDB 2.0.0 features ==== General @@ -364,27 +459,27 @@ entities [source,sh] ---- # Check all entity types and generate a report -$ unitsdb check_si --database=spec/fixtures/unitsdb --ttl-dir=spec/fixtures/bipm-si-ttl +$ unitsdb check_si --database=data --ttl-dir=spec/fixtures/bipm-si-ttl # Check a specific entity type (units, quantities, or prefixes) $ unitsdb check_si --entity-type=units \ - --database=spec/fixtures/unitsdb \ + --database=data \ --ttl-dir=spec/fixtures/bipm-si-ttl # Check in a specific direction only $ unitsdb check_si --direction=from_si \ - --database=spec/fixtures/unitsdb \ + --database=data \ --ttl-dir=spec/fixtures/bipm-si-ttl # Update references and write to output directory $ unitsdb check_si --output-updated-database=new_unitsdb \ - --database=spec/fixtures/unitsdb \ + --database=data \ --ttl-dir=spec/fixtures/bipm-si-ttl # Include potential matches when updating references (default: false) $ unitsdb check_si --include-potential-matches \ --output-updated-database=new_unitsdb \ - --database=spec/fixtures/unitsdb \ + --database=data \ --ttl-dir=spec/fixtures/bipm-si-ttl ---- @@ -440,16 +535,16 @@ There are two commands: [source,sh] ---- # Check all entity types and generate a report -$ unitsdb ucum check --database=spec/fixtures/unitsdb --ucum-file=spec/fixtures/ucum/ucum-essence.xml +$ unitsdb ucum check --database=data --ucum-file=spec/fixtures/ucum/ucum-essence.xml # Check a specific entity type (units or prefixes) $ unitsdb ucum check --entity-type=units \ - --database=spec/fixtures/unitsdb \ + --database=data \ --ucum-file=spec/fixtures/ucum/ucum-essence.xml # Check in a specific direction only $ unitsdb ucum check --direction=from_ucum \ - --database=spec/fixtures/unitsdb \ + --database=data \ --ucum-file=spec/fixtures/ucum/ucum-essence.xml ---- @@ -477,19 +572,19 @@ references (default: false) [source,sh] ---- # Update all entity types with UCUM references -$ unitsdb ucum update --database=spec/fixtures/unitsdb \ +$ unitsdb ucum update --database=data \ --ucum-file=spec/fixtures/ucum/ucum-essence.xml \ --output-dir=new_unitsdb # Update a specific entity type (units or prefixes) $ unitsdb ucum update --entity-type=units \ - --database=spec/fixtures/unitsdb \ + --database=data \ --ucum-file=spec/fixtures/ucum/ucum-essence.xml \ --output-dir=new_unitsdb # Include potential matches when updating references (default: false) $ unitsdb ucum update --include-potential-matches \ - --database=spec/fixtures/unitsdb \ + --database=data \ --ucum-file=spec/fixtures/ucum/ucum-essence.xml \ --output-dir=new_unitsdb ---- @@ -558,27 +653,27 @@ There are two commands: [source,sh] ---- # Check all entity types and generate a report -$ unitsdb qudt check --database=spec/fixtures/unitsdb +$ unitsdb qudt check --database=data # Check a specific entity type (units, quantities, dimensions, or unit_systems) $ unitsdb qudt check --entity-type=units \ - --database=spec/fixtures/unitsdb + --database=data # Use local TTL files instead of downloading from QUDT.org $ unitsdb qudt check --ttl-dir=/path/to/qudt/ttl/files \ - --database=spec/fixtures/unitsdb + --database=data # Check in a specific direction only $ unitsdb qudt check --direction=from_qudt \ - --database=spec/fixtures/unitsdb + --database=data # Include potential matches in the output $ unitsdb qudt check --include-potential-matches \ - --database=spec/fixtures/unitsdb + --database=data # Output updated database files $ unitsdb qudt check --output-dir=/path/to/output \ - --database=spec/fixtures/unitsdb + --database=data ---- Options: @@ -606,22 +701,22 @@ references (default: false) [source,sh] ---- # Update all entity types with QUDT references -$ unitsdb qudt update --database=spec/fixtures/unitsdb \ +$ unitsdb qudt update --database=data \ --output-dir=new_unitsdb # Update a specific entity type (units, quantities, dimensions, or unit_systems) $ unitsdb qudt update --entity-type=units \ - --database=spec/fixtures/unitsdb \ + --database=data \ --output-dir=new_unitsdb # Use local TTL files instead of downloading $ unitsdb qudt update --ttl-dir=/path/to/qudt/ttl/files \ - --database=spec/fixtures/unitsdb \ + --database=data \ --output-dir=new_unitsdb # Include potential matches when updating references (default: false) $ unitsdb qudt update --include-potential-matches \ - --database=spec/fixtures/unitsdb \ + --database=data \ --output-dir=new_unitsdb ---- @@ -798,15 +893,15 @@ symbol-to-symbol and partial matches always classified as potential === Loading the database -The primary way to load the UnitsDB data is through the `Database.from_db` -method, which reads data from YAML files: +The UnitsDB gem ships with the UnitsDB data bundled inside the gem. You can load +the database using the convenience method: [source,ruby] ---- require 'unitsdb' -# Load from the UnitsDB data directory -db = Unitsdb::Database.from_db('/path/to/unitsdb/data') +# Load the bundled UnitsDB data (all entity types pre-loaded) +db = Unitsdb.database # Access different collections units = db.units @@ -816,6 +911,19 @@ quantities = db.quantities unit_systems = db.unit_systems ---- +Alternatively, you can load from a specific path using `Database.from_db`: + +[source,ruby] +---- +require 'unitsdb' + +# Load from the bundled data directory +db = Unitsdb::Database.from_db(Unitsdb.data_dir) + +# Load from an external directory +external_db = Unitsdb::Database.from_db('/path/to/custom/unitsdb/data') +---- + === Database search methods The UnitsDB Ruby gem provides several methods for searching and retrieving @@ -976,13 +1084,17 @@ The `Quantity` class represents physical quantities that can be measured: === Database files -The `Database.from_db` method reads the following YAML files: +The UnitsDB gem bundles the following YAML files from the +https://github.com/unitsml/unitsdb[UnitsDB repository]. They are included in the +gem under the `data/` directory and are available immediately after installation +without any additional setup. * `prefixes.yaml` - Contains prefix definitions (e.g., kilo-, mega-) * `dimensions.yaml` - Contains dimension definitions (e.g., length, mass) * `units.yaml` - Contains unit definitions (e.g., meter, kilogram) * `quantities.yaml` - Contains quantity definitions (e.g., length, mass) * `unit_systems.yaml` - Contains unit system definitions (e.g., SI, Imperial) +* `scales.yaml` - Contains scale definitions diff --git a/spec/fixtures/unitsdb b/data similarity index 100% rename from spec/fixtures/unitsdb rename to data diff --git a/exe/unitsdb b/exe/unitsdb index 8f157ef..0d3987d 100755 --- a/exe/unitsdb +++ b/exe/unitsdb @@ -4,4 +4,10 @@ require "unitsdb" require "unitsdb/cli" -Unitsdb::CLI.start(ARGV) +begin + Unitsdb::Cli.start(ARGV) +rescue SystemExit => e + # Only print if not already handled (exit code 0 = normal exit) + # Exit code 1 from our commands is already handled, don't duplicate + raise if e.status.zero? +end diff --git a/lib/unitsdb.rb b/lib/unitsdb.rb index 90dde0c..c8b43a3 100644 --- a/lib/unitsdb.rb +++ b/lib/unitsdb.rb @@ -3,7 +3,7 @@ require "lutaml/model" module Unitsdb - autoload :CLI, "unitsdb/cli" + autoload :Cli, "unitsdb/cli" autoload :Config, "unitsdb/config" autoload :Commands, "unitsdb/commands" autoload :Database, "unitsdb/database" @@ -48,4 +48,22 @@ module Unitsdb autoload :UnitSystems, "unitsdb/unit_systems" autoload :Units, "unitsdb/units" autoload :Utils, "unitsdb/utils" + + class << self + # Returns the path to the bundled data directory containing YAML files + def data_dir + @data_dir ||= File.join(gem_dir, "data") + end + + # Returns a pre-loaded Database instance from the bundled data + def database + @database ||= Database.from_db(data_dir) + end + + private + + def gem_dir + @gem_dir ||= File.dirname(__dir__) + end + end end diff --git a/lib/unitsdb/cli.rb b/lib/unitsdb/cli.rb index b7a5942..4aa3461 100644 --- a/lib/unitsdb/cli.rb +++ b/lib/unitsdb/cli.rb @@ -4,7 +4,12 @@ require "fileutils" module Unitsdb - class CLI < Thor + class Cli < Thor + # Enable --trace globally for all subcommands + # When enabled, Thor shows full backtraces on error + class_option :trace, type: :boolean, default: false, + desc: "Show full backtrace on error" + # Fix Thor deprecation warning def self.exit_on_failure? true @@ -36,7 +41,7 @@ def self.exit_on_failure? desc: "Path to UnitsDB database (required)" def search(query) - Commands::Search.new(options).run(query) + run_command(Commands::Search, :run, query) end desc "get ID", "Get detailed information about an entity by ID" @@ -47,7 +52,7 @@ def search(query) option :database, type: :string, required: true, aliases: "-d", desc: "Path to UnitsDB database (required)" def get(id) - Commands::Get.new(options).get(id) + run_command(Commands::Get, :get, id) end desc "check_si", @@ -66,7 +71,7 @@ def get(id) desc: "Path to UnitsDB database (required)" def check_si - Commands::CheckSiCommand.new(options).run + run_command(Commands::CheckSiCommand, :run) end desc "release", "Create release files (unified YAML and/or ZIP archive)" @@ -79,7 +84,32 @@ def check_si option :database, type: :string, required: true, aliases: "-d", desc: "Path to UnitsDB database (required)" def release - Commands::Release.new(options).run + run_command(Commands::Release, :run) + end + + private + + def run_command(command_class, method, *args) + command = command_class.new(options) + command.send(method, *args) + rescue Unitsdb::Errors::CLIRuntimeError => e + handle_cli_error(e) + rescue StandardError => e + handle_error(e) + end + + def handle_cli_error(error) + raise error if debugging? + + warn "Error: #{error.message}" + exit 1 + end + + def handle_error(error) + raise error if debugging? + + warn "Error: #{error.message}" + exit 1 end end end diff --git a/lib/unitsdb/commands/_modify.rb b/lib/unitsdb/commands/_modify.rb index f26404e..90e71bc 100644 --- a/lib/unitsdb/commands/_modify.rb +++ b/lib/unitsdb/commands/_modify.rb @@ -5,6 +5,10 @@ module Unitsdb module Commands class ModifyCommand < Thor + # Inherit trace option from parent CLI + class_option :trace, type: :boolean, default: false, + desc: "Show full backtrace on error" + desc "normalize INPUT OUTPUT", "Normalize a YAML file or all YAML files with --all" method_option :sort, type: :string, @@ -18,7 +22,36 @@ class ModifyCommand < Thor desc: "Process all YAML files in the repository" def normalize(input = nil, output = nil) - Normalize.new(options).run(input, output) + run_command(Normalize, options, input, output) + rescue Unitsdb::Errors::CLIRuntimeError => e + handle_cli_error(e) + rescue StandardError => e + handle_error(e) + end + + private + + def run_command(command_class, options, *args) + command = command_class.new(options) + command.run(*args) + end + + def handle_cli_error(error) + if options[:trace] + raise error + else + warn "Error: #{error.message}" + exit 1 + end + end + + def handle_error(error) + if options[:trace] + raise error + else + warn "Error: #{error.message}" + exit 1 + end end end end diff --git a/lib/unitsdb/commands/check_si.rb b/lib/unitsdb/commands/check_si.rb index c2eb5da..38f4a31 100644 --- a/lib/unitsdb/commands/check_si.rb +++ b/lib/unitsdb/commands/check_si.rb @@ -85,14 +85,14 @@ def process_entity_type(entity_type, graph, direction, output_dir, # Validation helpers def validate_parameters(direction, ttl_dir) unless %w[to_si from_si both].include?(direction) - puts "Invalid direction: #{direction}. Must be one of: to_si, from_si, both" - exit(1) + raise Unitsdb::Errors::InvalidParameterError, + "Invalid direction '#{direction}': must be 'to_si', 'from_si', or 'both'" end return if Dir.exist?(ttl_dir) - puts "TTL directory not found: #{ttl_dir}" - exit(1) + raise Unitsdb::Errors::FileNotFoundError, + "TTL directory not found: #{ttl_dir}" end # Direction handler: TTL → DB diff --git a/lib/unitsdb/commands/get.rb b/lib/unitsdb/commands/get.rb index 946cc0e..d9b573c 100644 --- a/lib/unitsdb/commands/get.rb +++ b/lib/unitsdb/commands/get.rb @@ -27,19 +27,18 @@ def get(id) puts entity.send("to_#{format.downcase}") return rescue NoMethodError - puts "Error: Unable to convert entity to #{format} format" - exit(1) + raise Unitsdb::Errors::InvalidFormatError, + "Unable to convert entity to #{format.upcase} format: output format not supported for this entity type" end end # Default text output print_entity_details(entity) rescue Unitsdb::Errors::DatabaseError => e - puts "Error: #{e.message}" - exit(1) + raise Unitsdb::Errors::DatabaseLoadError, + "Failed to load database: #{e.message}" rescue StandardError => e - puts "Error searching database: #{e.message}" - exit(1) + raise Unitsdb::Errors::CLIRuntimeError, "Search failed: #{e.message}" end end diff --git a/lib/unitsdb/commands/normalize.rb b/lib/unitsdb/commands/normalize.rb index cc69229..49daf4d 100644 --- a/lib/unitsdb/commands/normalize.rb +++ b/lib/unitsdb/commands/normalize.rb @@ -7,8 +7,8 @@ module Commands class Normalize < Base def run(input = nil, output = nil) unless @options[:all] || (input && output) - puts "Error: INPUT and OUTPUT are required when not using --all" - exit(1) + raise Unitsdb::Errors::InvalidParameterError, + "INPUT and OUTPUT files are required unless --all flag is specified" end if @options[:all] diff --git a/lib/unitsdb/commands/qudt.rb b/lib/unitsdb/commands/qudt.rb index 67fbd5b..9bff137 100644 --- a/lib/unitsdb/commands/qudt.rb +++ b/lib/unitsdb/commands/qudt.rb @@ -13,6 +13,10 @@ module Qudt end class QudtCommand < Thor + # Inherit trace option from parent CLI + class_option :trace, type: :boolean, default: false, + desc: "Show full backtrace on error" + desc "check", "Check QUDT references in UnitsDB" option :entity_type, type: :string, aliases: "-e", desc: "Entity type to check (units, quantities, dimensions, unit_systems). If not specified, all types are checked" @@ -27,7 +31,7 @@ class QudtCommand < Thor option :database, type: :string, required: true, aliases: "-d", desc: "Path to UnitsDB database (required)" def check - Qudt::Check.new(options).run + run_command(Qudt::Check, options) end desc "update", "Update UnitsDB with QUDT references" @@ -42,7 +46,36 @@ def check option :database, type: :string, required: true, aliases: "-d", desc: "Path to UnitsDB database (required)" def update - Qudt::Update.new(options).run + run_command(Qudt::Update, options) + end + + private + + def run_command(command_class, options) + command = command_class.new(options) + command.run + rescue Unitsdb::Errors::CLIRuntimeError => e + handle_cli_error(e) + rescue StandardError => e + handle_error(e) + end + + def handle_cli_error(error) + if options[:trace] + raise error + else + warn "Error: #{error.message}" + exit 1 + end + end + + def handle_error(error) + if options[:trace] + raise error + else + warn "Error: #{error.message}" + exit 1 + end end end end diff --git a/lib/unitsdb/commands/qudt/check.rb b/lib/unitsdb/commands/qudt/check.rb index a7cf26c..52964a4 100644 --- a/lib/unitsdb/commands/qudt/check.rb +++ b/lib/unitsdb/commands/qudt/check.rb @@ -88,14 +88,14 @@ def process_entity_type(entity_type, qudt_data, direction, output_dir, # Validation helpers def validate_parameters(direction, ttl_dir, source_type) unless %w[to_qudt from_qudt both].include?(direction) - puts "Invalid direction: #{direction}. Must be one of: to_qudt, from_qudt, both" - exit(1) + raise Unitsdb::Errors::InvalidParameterError, + "Invalid direction '#{direction}': must be 'to_qudt', 'from_qudt', or 'both'" end return unless source_type == :file && ttl_dir && !Dir.exist?(ttl_dir) - puts "TTL directory not found: #{ttl_dir}" - exit(1) + raise Unitsdb::Errors::FileNotFoundError, + "TTL directory not found: #{ttl_dir}" end # Direction handler: QUDT → UnitsDB diff --git a/lib/unitsdb/commands/qudt/update.rb b/lib/unitsdb/commands/qudt/update.rb index b2ac7ea..acbb924 100644 --- a/lib/unitsdb/commands/qudt/update.rb +++ b/lib/unitsdb/commands/qudt/update.rb @@ -117,8 +117,8 @@ def process_entity_type(entity_type, qudt_data, output_dir, def validate_parameters(ttl_dir, source_type) return unless source_type == :file && ttl_dir && !Dir.exist?(ttl_dir) - puts "TTL directory not found: #{ttl_dir}" - exit(1) + raise Unitsdb::Errors::FileNotFoundError, + "TTL directory not found: #{ttl_dir}" end end end diff --git a/lib/unitsdb/commands/qudt/updater.rb b/lib/unitsdb/commands/qudt/updater.rb index 5a29124..adb4ef9 100644 --- a/lib/unitsdb/commands/qudt/updater.rb +++ b/lib/unitsdb/commands/qudt/updater.rb @@ -144,13 +144,13 @@ def get_original_yaml_file(_db_entities, output_file) # Try to get database directory from environment or assume it's the fixtures database_dir = ENV["UNITSDB_DATABASE_PATH"] || File.join(File.dirname(__FILE__), - "../../../spec/fixtures/unitsdb") + "../../../data") original_yaml_file = File.join(database_dir, "#{entity_type}.yaml") # If that doesn't exist, try to find it relative to the current working directory unless File.exist?(original_yaml_file) - original_yaml_file = File.join("spec/fixtures/unitsdb", + original_yaml_file = File.join("data", "#{entity_type}.yaml") end diff --git a/lib/unitsdb/commands/release.rb b/lib/unitsdb/commands/release.rb index db6a90f..fb0e8ab 100644 --- a/lib/unitsdb/commands/release.rb +++ b/lib/unitsdb/commands/release.rb @@ -26,15 +26,14 @@ def run create_unified_yaml(db) create_zip_archive(db) else - puts "Invalid format option: #{@options[:format]}" - puts "Valid options are: 'yaml', 'zip', or 'all'" - exit(1) + raise Unitsdb::Errors::InvalidFormatError, + "Invalid format '#{@options[:format]}': must be 'yaml', 'zip', or 'all'" end puts "Release files created successfully in #{@options[:output_dir]}" rescue Unitsdb::Errors::DatabaseError => e - puts "Error: #{e.message}" - exit(1) + raise Unitsdb::Errors::DatabaseLoadError, + "Failed to create release: #{e.message}" end private diff --git a/lib/unitsdb/commands/search.rb b/lib/unitsdb/commands/search.rb index 54d5ffe..799cc1c 100644 --- a/lib/unitsdb/commands/search.rb +++ b/lib/unitsdb/commands/search.rb @@ -31,8 +31,8 @@ def run(query) puts entity.send("to_#{format.downcase}") return rescue NoMethodError - puts "Error: Unable to convert entity to #{format} format" - exit(1) + raise Unitsdb::Errors::InvalidFormatError, + "Unable to convert entity to #{format.upcase} format: output format not supported for this entity type" end end @@ -62,11 +62,10 @@ def run(query) print_entity_with_ids(entity) end rescue Unitsdb::Errors::DatabaseError => e - puts "Error: #{e.message}" - exit(1) + raise Unitsdb::Errors::DatabaseLoadError, + "Failed to load database: #{e.message}" rescue StandardError => e - puts "Error searching database: #{e.message}" - exit(1) + raise Unitsdb::Errors::CLIRuntimeError, "Search failed: #{e.message}" end end diff --git a/lib/unitsdb/commands/ucum.rb b/lib/unitsdb/commands/ucum.rb index 78f01b7..6cf6963 100644 --- a/lib/unitsdb/commands/ucum.rb +++ b/lib/unitsdb/commands/ucum.rb @@ -14,6 +14,10 @@ module Ucum end class UcumCommand < Thor + # Inherit trace option from parent CLI + class_option :trace, type: :boolean, default: false, + desc: "Show full backtrace on error" + desc "check", "Check UCUM references in UnitsDB" option :entity_type, type: :string, aliases: "-e", desc: "Entity type to check (units, prefixes). If not specified, all types are checked" @@ -28,7 +32,7 @@ class UcumCommand < Thor option :database, type: :string, required: true, aliases: "-d", desc: "Path to UnitsDB database (required)" def check - Ucum::Check.new(options).run + run_command(Ucum::Check, options) end desc "update", "Update UnitsDB with UCUM references" @@ -43,7 +47,36 @@ def check option :database, type: :string, required: true, aliases: "-d", desc: "Path to UnitsDB database (required)" def update - Ucum::Update.new(options).run + run_command(Ucum::Update, options) + end + + private + + def run_command(command_class, options) + command = command_class.new(options) + command.run + rescue Unitsdb::Errors::CLIRuntimeError => e + handle_cli_error(e) + rescue StandardError => e + handle_error(e) + end + + def handle_cli_error(error) + if options[:trace] + raise error + else + warn "Error: #{error.message}" + exit 1 + end + end + + def handle_error(error) + if options[:trace] + raise error + else + warn "Error: #{error.message}" + exit 1 + end end end end diff --git a/lib/unitsdb/commands/ucum/check.rb b/lib/unitsdb/commands/ucum/check.rb index 4dd278e..f4da8c3 100644 --- a/lib/unitsdb/commands/ucum/check.rb +++ b/lib/unitsdb/commands/ucum/check.rb @@ -80,14 +80,14 @@ def process_entity_type(entity_type, ucum_data, direction, output_dir, # Validation helpers def validate_parameters(direction, ucum_file) unless %w[to_ucum from_ucum both].include?(direction) - puts "Invalid direction: #{direction}. Must be one of: to_ucum, from_ucum, both" - exit(1) + raise Unitsdb::Errors::InvalidParameterError, + "Invalid direction '#{direction}': must be 'to_ucum', 'from_ucum', or 'both'" end return if File.exist?(ucum_file) - puts "UCUM file not found: #{ucum_file}" - exit(1) + raise Unitsdb::Errors::FileNotFoundError, + "UCUM file not found: #{ucum_file}" end # Direction handler: UCUM → UnitsDB diff --git a/lib/unitsdb/commands/validate.rb b/lib/unitsdb/commands/validate.rb index e149d73..fb40c2f 100644 --- a/lib/unitsdb/commands/validate.rb +++ b/lib/unitsdb/commands/validate.rb @@ -13,6 +13,10 @@ module Validate end class ValidateCommand < Thor + # Inherit trace option from parent CLI + class_option :trace, type: :boolean, default: false, + desc: "Show full backtrace on error" + desc "references", "Validate that all references exist" option :debug_registry, type: :boolean, desc: "Show registry contents for debugging" @@ -21,7 +25,7 @@ class ValidateCommand < Thor option :print_valid, type: :boolean, default: false, desc: "Print valid references too" def references - Commands::Validate::References.new(options).run + run_command(Commands::Validate::References, options) end desc "identifiers", "Check for uniqueness of identifier fields" @@ -29,7 +33,7 @@ def references desc: "Path to UnitsDB database (required)" def identifiers - Commands::Validate::Identifiers.new(options).run + run_command(Commands::Validate::Identifiers, options) end desc "si_references", @@ -38,7 +42,7 @@ def identifiers desc: "Path to UnitsDB database (required)" def si_references - Commands::Validate::SiReferences.new(options).run + run_command(Commands::Validate::SiReferences, options) end desc "qudt_references", @@ -47,7 +51,7 @@ def si_references desc: "Path to UnitsDB database (required)" def qudt_references - Commands::Validate::QudtReferences.new(options).run + run_command(Commands::Validate::QudtReferences, options) end desc "ucum_references", @@ -56,7 +60,36 @@ def qudt_references desc: "Path to UnitsDB database (required)" def ucum_references - Commands::Validate::UcumReferences.new(options).run + run_command(Commands::Validate::UcumReferences, options) + end + + private + + def run_command(command_class, options) + command = command_class.new(options) + command.run + rescue Unitsdb::Errors::CLIRuntimeError => e + handle_cli_error(e) + rescue StandardError => e + handle_error(e) + end + + def handle_cli_error(error) + if options[:trace] + raise error + else + warn "Error: #{error.message}" + exit 1 + end + end + + def handle_error(error) + if options[:trace] + raise error + else + warn "Error: #{error.message}" + exit 1 + end end end end diff --git a/lib/unitsdb/commands/validate/identifiers.rb b/lib/unitsdb/commands/validate/identifiers.rb index c9bed36..82b3054 100644 --- a/lib/unitsdb/commands/validate/identifiers.rb +++ b/lib/unitsdb/commands/validate/identifiers.rb @@ -10,8 +10,8 @@ def run display_results(all_dups) rescue Unitsdb::Errors::DatabaseError => e - puts "Error: #{e.message}" - exit(1) + raise Unitsdb::Errors::ValidationError, + "Failed to validate identifiers: #{e.message}" end private diff --git a/lib/unitsdb/commands/validate/qudt_references.rb b/lib/unitsdb/commands/validate/qudt_references.rb index e0e0297..c3ef985 100644 --- a/lib/unitsdb/commands/validate/qudt_references.rb +++ b/lib/unitsdb/commands/validate/qudt_references.rb @@ -14,8 +14,8 @@ def run # Display results display_duplicate_results(duplicates) rescue Unitsdb::Errors::DatabaseError => e - puts "Error: #{e.message}" - exit(1) + raise Unitsdb::Errors::ValidationError, + "Failed to validate QUDT references: #{e.message}" end private diff --git a/lib/unitsdb/commands/validate/references.rb b/lib/unitsdb/commands/validate/references.rb index d0096e7..0e31280 100644 --- a/lib/unitsdb/commands/validate/references.rb +++ b/lib/unitsdb/commands/validate/references.rb @@ -17,8 +17,8 @@ def run # Display results display_reference_results(invalid_refs, registry) rescue Unitsdb::Errors::DatabaseError => e - puts "Error: #{e.message}" - exit(1) + raise Unitsdb::Errors::ValidationError, + "Failed to validate references: #{e.message}" end private diff --git a/lib/unitsdb/commands/validate/si_references.rb b/lib/unitsdb/commands/validate/si_references.rb index 429f7c7..ae715dd 100644 --- a/lib/unitsdb/commands/validate/si_references.rb +++ b/lib/unitsdb/commands/validate/si_references.rb @@ -14,8 +14,8 @@ def run # Display results display_duplicate_results(duplicates) rescue Unitsdb::Errors::DatabaseError => e - puts "Error: #{e.message}" - exit(1) + raise Unitsdb::Errors::ValidationError, + "Failed to validate SI references: #{e.message}" end private diff --git a/lib/unitsdb/commands/validate/ucum_references.rb b/lib/unitsdb/commands/validate/ucum_references.rb index 2ab8590..4950edf 100644 --- a/lib/unitsdb/commands/validate/ucum_references.rb +++ b/lib/unitsdb/commands/validate/ucum_references.rb @@ -14,8 +14,8 @@ def run # Display results display_duplicate_results(duplicates) rescue Unitsdb::Errors::DatabaseError => e - puts "Error: #{e.message}" - exit(1) + raise Unitsdb::Errors::ValidationError, + "Failed to validate UCUM references: #{e.message}" end private diff --git a/lib/unitsdb/errors.rb b/lib/unitsdb/errors.rb index 714a3c3..d65fddb 100644 --- a/lib/unitsdb/errors.rb +++ b/lib/unitsdb/errors.rb @@ -9,5 +9,12 @@ class DatabaseFileInvalidError < DatabaseError; end class DatabaseLoadError < DatabaseError; end class VersionMismatchError < DatabaseError; end class UnsupportedVersionError < DatabaseError; end + + # CLI-specific errors + class CLIRuntimeError < StandardError; end + class InvalidParameterError < CLIRuntimeError; end + class FileNotFoundError < CLIRuntimeError; end + class ValidationError < CLIRuntimeError; end + class InvalidFormatError < CLIRuntimeError; end end end diff --git a/lib/unitsdb/version.rb b/lib/unitsdb/version.rb index fbec8da..317d4d4 100644 --- a/lib/unitsdb/version.rb +++ b/lib/unitsdb/version.rb @@ -2,4 +2,8 @@ module Unitsdb VERSION = "2.1.2" + + # The version of the bundled UnitsDB data (from the data/ submodule). + # This version corresponds to a tag/commit in https://github.com/unitsml/unitsdb. + UNITS_DATA_VERSION = "2.0.0" end diff --git a/spec/exe/unitsdb_spec.rb b/spec/exe/unitsdb_spec.rb index 2f45d0f..1b01c03 100644 --- a/spec/exe/unitsdb_spec.rb +++ b/spec/exe/unitsdb_spec.rb @@ -22,19 +22,16 @@ expect(content).to include('require "unitsdb/cli"') end - it "calls CLI.start with ARGV" do + it "calls Cli.start with ARGV" do content = File.read(executable_path) - expect(content).to include("Unitsdb::CLI.start(ARGV)") + expect(content).to include("Unitsdb::Cli.start(ARGV)") end # This tests that the executable code structure matches what we expect # This is a more robust test than testing behavior with mocks - it "contains code that calls CLI.start with ARGV" do + it "contains code that calls Cli.start with ARGV" do content = File.read(executable_path) - # Check that the last significant line is the CLI.start call - significant_lines = content.lines.map(&:strip).reject do |line| - line.empty? || line.start_with?("#") - end - expect(significant_lines.last).to eq("Unitsdb::CLI.start(ARGV)") + # Check that Cli.start is called somewhere in the code + expect(content).to include("Unitsdb::Cli.start(ARGV)") end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index eacaabf..0f3d511 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require "unitsdb" +require_relative "../lib/unitsdb" require "stringio" RSpec.configure do |config| @@ -15,7 +15,6 @@ end end -require "yaml" require "canon" # Define a helper method for capturing standard output/error @@ -31,6 +30,7 @@ def capture_output $stderr = original_stderr end +require "lutaml/model" # Configure LutaML adapters Lutaml::Model::Config.configure do |config| config.xml_adapter_type = :nokogiri diff --git a/spec/unitsdb/bundled_data_spec.rb b/spec/unitsdb/bundled_data_spec.rb new file mode 100644 index 0000000..b0f2ac0 --- /dev/null +++ b/spec/unitsdb/bundled_data_spec.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +RSpec.describe Unitsdb do + describe ".data_dir" do + it "returns a path to the bundled data directory" do + expect(described_class.data_dir).to be_a(String) + expect(described_class.data_dir).not_to be_empty + end + + it "returns a path that exists on disk" do + expect(File.directory?(described_class.data_dir)).to be true + end + + it "returns a path containing the expected YAML data files" do + expect(File.exist?(File.join(described_class.data_dir, + "units.yaml"))).to be true + expect(File.exist?(File.join(described_class.data_dir, + "prefixes.yaml"))).to be true + expect(File.exist?(File.join(described_class.data_dir, + "dimensions.yaml"))).to be true + expect(File.exist?(File.join(described_class.data_dir, + "quantities.yaml"))).to be true + expect(File.exist?(File.join(described_class.data_dir, + "unit_systems.yaml"))).to be true + expect(File.exist?(File.join(described_class.data_dir, + "scales.yaml"))).to be true + end + + it "returns the same path as the git submodule location" do + # The submodule is at the repo root as `data/` + expect(described_class.data_dir).to match(%r{/data$}) + end + end + + describe ".database" do + it "returns a pre-loaded Database instance" do + db = described_class.database + expect(db).to be_a(Unitsdb::Database) + end + + it "loads all entity collections" do + db = described_class.database + expect(db.units).to be_a(Array) + expect(db.prefixes).to be_a(Array) + expect(db.dimensions).to be_a(Array) + expect(db.quantities).to be_a(Array) + expect(db.unit_systems).to be_a(Array) + end + + it "has a valid schema version" do + db = described_class.database + expect(db.schema_version).to eq("2.0.0") + end + + it "populates units with known entities" do + db = described_class.database + unit_ids = db.units.flat_map { |u| u.identifiers.map(&:id) }.compact.uniq + expect(unit_ids).to include("NISTu1") # meter + end + + it "populates prefixes with known entities" do + db = described_class.database + prefix_ids = db.prefixes.flat_map do |p| + p.identifiers.map(&:id) + end.compact.uniq + expect(prefix_ids).to include("NISTp10_3") # kilo + end + + it "populates dimensions with known entities" do + db = described_class.database + dimension_ids = db.dimensions.flat_map do |d| + d.identifiers.map(&:id) + end.compact.uniq + expect(dimension_ids).to include("NISTd1") # length + end + + it "populates quantities with known entities" do + db = described_class.database + quantity_ids = db.quantities.flat_map do |q| + q.identifiers.map(&:id) + end.compact.uniq + expect(quantity_ids).to include("NISTq1") # length + end + + it "populates unit_systems with known entities" do + db = described_class.database + system_ids = db.unit_systems.flat_map do |s| + s.identifiers.map(&:id) + end.compact.uniq + expect(system_ids).to include("SI_base") # SI + end + + it "has non-empty collections" do + db = described_class.database + expect(db.units).not_to be_empty + expect(db.prefixes).not_to be_empty + expect(db.dimensions).not_to be_empty + expect(db.quantities).not_to be_empty + expect(db.unit_systems).not_to be_empty + end + + it "caches the database instance" do + db1 = described_class.database + db2 = described_class.database + expect(db1.object_id).to eq(db2.object_id) + end + end + + describe "YAML file integrity" do + let(:data_dir) { described_class.data_dir } + + it "all YAML files have schema_version 2.0.0" do + %w[prefixes dimensions units quantities unit_systems].each do |entity| + file_path = File.join(data_dir, "#{entity}.yaml") + hash = YAML.safe_load_file(file_path) + expect(hash["schema_version"]).to eq("2.0.0"), + "Expected #{entity}.yaml to have schema_version 2.0.0, got #{hash['schema_version']}" + end + end + + it "all YAML files have their top-level key as an Array" do + %w[prefixes dimensions units quantities unit_systems].each do |entity| + file_path = File.join(data_dir, "#{entity}.yaml") + hash = YAML.safe_load_file(file_path) + expect(hash[entity]).to be_a(Array), + "Expected #{entity}.yaml[:#{entity}] to be an Array" + end + end + + it "units.yaml contains the meter entity" do + file_path = File.join(data_dir, "units.yaml") + hash = YAML.safe_load_file(file_path) + meter = hash["units"].find do |u| + u.dig("identifiers", 0, "id") == "NISTu1" + end + expect(meter).not_to be_nil + expect(meter["short"]).to eq("meter") + end + + it "prefixes.yaml contains the kilo prefix" do + file_path = File.join(data_dir, "prefixes.yaml") + hash = YAML.safe_load_file(file_path) + kilo = hash["prefixes"].find do |p| + p.dig("identifiers", 0, "id") == "NISTp10_3" + end + expect(kilo).not_to be_nil + expect(kilo["short"]).to eq("kilo") + end + + it "dimensions.yaml contains the length dimension" do + file_path = File.join(data_dir, "dimensions.yaml") + hash = YAML.safe_load_file(file_path) + length = hash["dimensions"].find do |d| + d.dig("identifiers", 0, "id") == "NISTd1" + end + expect(length).not_to be_nil + end + + it "quantities.yaml contains the length quantity" do + file_path = File.join(data_dir, "quantities.yaml") + hash = YAML.safe_load_file(file_path) + length = hash["quantities"].find do |q| + q.dig("identifiers", 0, "id") == "NISTq1" + end + expect(length).not_to be_nil + end + + it "unit_systems.yaml contains the SI unit system" do + file_path = File.join(data_dir, "unit_systems.yaml") + hash = YAML.safe_load_file(file_path) + si = hash["unit_systems"].find do |s| + s.dig("identifiers", 0, "id") == "SI_base" + end + expect(si).not_to be_nil + end + + it "scales.yaml is a valid YAML file with a top-level key" do + file_path = File.join(data_dir, "scales.yaml") + hash = YAML.safe_load_file(file_path) + expect(hash).to be_a(Hash) + expect(hash.keys).not_to be_empty + end + end +end diff --git a/spec/unitsdb/cli_spec.rb b/spec/unitsdb/cli_spec.rb index d0f0716..8839181 100644 --- a/spec/unitsdb/cli_spec.rb +++ b/spec/unitsdb/cli_spec.rb @@ -4,7 +4,7 @@ require_relative "../../lib/unitsdb/cli" require "stringio" -RSpec.describe Unitsdb::CLI do +RSpec.describe Unitsdb::Cli do let(:cli) { described_class.new } # No global output capture - each test will capture output explicitly diff --git a/spec/unitsdb/commands/check_si_command_spec.rb b/spec/unitsdb/commands/check_si_command_spec.rb index a7e8073..21bd451 100644 --- a/spec/unitsdb/commands/check_si_command_spec.rb +++ b/spec/unitsdb/commands/check_si_command_spec.rb @@ -9,7 +9,7 @@ let(:options) { { database: fixture_dir, ttl_dir: ttl_dir } } let(:output) { StringIO.new } let(:fixture_dir) do - File.join(File.dirname(__FILE__), "../../fixtures/unitsdb") + File.join(File.dirname(__FILE__), "../../../data") end let(:ttl_dir) do File.join(File.dirname(__FILE__), "../../fixtures/bipm-si-ttl") diff --git a/spec/unitsdb/commands/get_spec.rb b/spec/unitsdb/commands/get_spec.rb index 60007cf..d45fb54 100644 --- a/spec/unitsdb/commands/get_spec.rb +++ b/spec/unitsdb/commands/get_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Unitsdb::Commands::Get do let(:options) do { - database: "spec/fixtures/unitsdb", + database: "data", format: "text", } end diff --git a/spec/unitsdb/commands/normalize_spec.rb b/spec/unitsdb/commands/normalize_spec.rb index f3dc3df..92f9b53 100644 --- a/spec/unitsdb/commands/normalize_spec.rb +++ b/spec/unitsdb/commands/normalize_spec.rb @@ -90,27 +90,13 @@ end it "handles error for missing input/output without --all" do - # Use StringIO to capture output - original_stdout = $stdout - output = StringIO.new - $stdout = output - - # Stub exit to prevent test from actually exiting - exit_called = false - allow(command).to receive(:exit) { |code| - exit_called = true - code - } - - # Run command with nil input/output - command.run(nil, nil) - - # Reset stdout - $stdout = original_stdout - - # Verify exit was called and error message was printed - expect(exit_called).to be true - expect(output.string).to include("Error: INPUT and OUTPUT are required when not using --all") + # Run command with nil input/output and expect InvalidParameterError + expect do + command.run(nil, + nil) + end.to raise_error(Unitsdb::Errors::InvalidParameterError) do |error| + expect(error.message).to include("INPUT and OUTPUT files are required unless --all flag is specified") + end end it "sorts by nist ID when sort option is 'nist'" do diff --git a/spec/unitsdb/commands/qudt/update_spec.rb b/spec/unitsdb/commands/qudt/update_spec.rb index b570585..1a6ae20 100644 --- a/spec/unitsdb/commands/qudt/update_spec.rb +++ b/spec/unitsdb/commands/qudt/update_spec.rb @@ -7,7 +7,7 @@ require "unitsdb/commands/qudt/update" RSpec.describe Unitsdb::Commands::Qudt::Update do - let(:database_path) { "spec/fixtures/unitsdb" } + let(:database_path) { "data" } let(:output_dir) { "tmp/qudt_update_test" } let(:options) do { @@ -45,9 +45,11 @@ invalid_options = options.merge(ttl_dir: "/nonexistent/path") command = described_class.new(invalid_options) - # Should exit with error code 1 for invalid TTL directory - expect { command.run }.to raise_error(SystemExit) do |error| - expect(error.status).to eq(1) + # Should raise FileNotFoundError for invalid TTL directory + expect do + command.run + end.to raise_error(Unitsdb::Errors::FileNotFoundError) do |error| + expect(error.message).to include("TTL directory not found") end end diff --git a/spec/unitsdb/commands/release_spec.rb b/spec/unitsdb/commands/release_spec.rb index f1301c6..e28411a 100644 --- a/spec/unitsdb/commands/release_spec.rb +++ b/spec/unitsdb/commands/release_spec.rb @@ -8,7 +8,7 @@ require "unitsdb/commands/release" RSpec.describe Unitsdb::Commands::Release do - let(:database_path) { "spec/fixtures/unitsdb" } + let(:database_path) { "data" } let(:output_dir) { "tmp/release_test" } let(:release_version) { "2.0.0" } let(:options) do @@ -138,9 +138,9 @@ version: release_version } end - it "exits with an error" do + it "raises DatabaseLoadError" do command = described_class.new(options) - expect { command.run }.to raise_error(SystemExit) + expect { command.run }.to raise_error(Unitsdb::Errors::DatabaseLoadError) end end @@ -171,9 +171,9 @@ FileUtils.rm_rf(temp_db_path) end - it "exits with an error" do + it "raises DatabaseLoadError" do command = described_class.new(options) - expect { command.run }.to raise_error(SystemExit) + expect { command.run }.to raise_error(Unitsdb::Errors::DatabaseLoadError) end end end diff --git a/spec/unitsdb/commands/search_spec.rb b/spec/unitsdb/commands/search_spec.rb index c1617a6..88569a8 100644 --- a/spec/unitsdb/commands/search_spec.rb +++ b/spec/unitsdb/commands/search_spec.rb @@ -5,7 +5,7 @@ require "fileutils" RSpec.describe Unitsdb::Commands::Search do - let(:fixtures_dir) { File.join("spec", "fixtures", "unitsdb") } + let(:fixtures_dir) { "data" } let(:command) { described_class.new(options) } let(:options) { { database: fixtures_dir } } @@ -139,19 +139,12 @@ Unitsdb::Errors::DatabaseError, "Test error" ) - # Expect exit to be called with status 1 - expect(command).to receive(:exit).with(1) - - # Redirect stdout to capture output - original_stdout = $stdout - output = StringIO.new - $stdout = output - - # Execute search - expect { command.run("test") }.to output(/Test error/).to_stdout - - # Reset stdout - $stdout = original_stdout + # Expect DatabaseLoadError to be raised with the original error message + expect do + command.run("test") + end.to raise_error(Unitsdb::Errors::DatabaseLoadError) do |error| + expect(error.message).to include("Test error") + end end end end diff --git a/spec/unitsdb/commands/ucum/update_spec.rb b/spec/unitsdb/commands/ucum/update_spec.rb index b3aa909..7e66404 100644 --- a/spec/unitsdb/commands/ucum/update_spec.rb +++ b/spec/unitsdb/commands/ucum/update_spec.rb @@ -7,7 +7,7 @@ require "unitsdb/commands/ucum/update" RSpec.describe Unitsdb::Commands::Ucum::Update do - let(:database_path) { "spec/fixtures/unitsdb" } + let(:database_path) { "data" } let(:ucum_file) { "spec/fixtures/ucum/ucum-essence.xml" } let(:output_dir) { "tmp/ucum_update_test" } let(:options) do diff --git a/spec/unitsdb/commands/validate/identifiers_spec.rb b/spec/unitsdb/commands/validate/identifiers_spec.rb index 3dd3adf..025cb5d 100644 --- a/spec/unitsdb/commands/validate/identifiers_spec.rb +++ b/spec/unitsdb/commands/validate/identifiers_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Unitsdb::Commands::Validate::Identifiers do let(:options) do { - database: "spec/fixtures/unitsdb", + database: "data", } end diff --git a/spec/unitsdb/commands/validate/qudt_references_spec.rb b/spec/unitsdb/commands/validate/qudt_references_spec.rb index 7a5635b..c95eef4 100644 --- a/spec/unitsdb/commands/validate/qudt_references_spec.rb +++ b/spec/unitsdb/commands/validate/qudt_references_spec.rb @@ -4,7 +4,7 @@ require "unitsdb/commands/validate/qudt_references" RSpec.describe Unitsdb::Commands::Validate::QudtReferences do - let(:database_path) { "spec/fixtures/unitsdb" } + let(:database_path) { "data" } let(:options) { { database: database_path } } describe "#run" do @@ -29,9 +29,11 @@ invalid_options = { database: "/nonexistent/path" } command = described_class.new(invalid_options) - # Should exit with error code 1 for invalid database path - expect { command.run }.to raise_error(SystemExit) do |error| - expect(error.status).to eq(1) + # Should raise ValidationError for invalid database path + expect do + command.run + end.to raise_error(Unitsdb::Errors::ValidationError) do |error| + expect(error.message).to include("Failed to validate QUDT references") end end diff --git a/spec/unitsdb/commands/validate/si_references_spec.rb b/spec/unitsdb/commands/validate/si_references_spec.rb index fda5be4..a09888c 100644 --- a/spec/unitsdb/commands/validate/si_references_spec.rb +++ b/spec/unitsdb/commands/validate/si_references_spec.rb @@ -6,7 +6,7 @@ RSpec.describe Unitsdb::Commands::Validate::SiReferences do let(:options) do { - database: "spec/fixtures/unitsdb", + database: "data", } end diff --git a/spec/unitsdb/commands/validate/ucum_references_spec.rb b/spec/unitsdb/commands/validate/ucum_references_spec.rb index c3c82b8..e48df65 100644 --- a/spec/unitsdb/commands/validate/ucum_references_spec.rb +++ b/spec/unitsdb/commands/validate/ucum_references_spec.rb @@ -4,7 +4,7 @@ require "unitsdb/commands/validate/ucum_references" RSpec.describe Unitsdb::Commands::Validate::UcumReferences do - let(:database_path) { "spec/fixtures/unitsdb" } + let(:database_path) { "data" } let(:options) { { database: database_path } } describe "#run" do @@ -29,9 +29,11 @@ invalid_options = { database: "/nonexistent/path" } command = described_class.new(invalid_options) - # Should exit with error code 1 for invalid database path - expect { command.run }.to raise_error(SystemExit) do |error| - expect(error.status).to eq(1) + # Should raise ValidationError for invalid database path + expect do + command.run + end.to raise_error(Unitsdb::Errors::ValidationError) do |error| + expect(error.message).to include("Failed to validate UCUM references") end end diff --git a/spec/unitsdb/database_search_spec.rb b/spec/unitsdb/database_search_spec.rb index ebab586..a8ab998 100644 --- a/spec/unitsdb/database_search_spec.rb +++ b/spec/unitsdb/database_search_spec.rb @@ -3,7 +3,7 @@ require "spec_helper" RSpec.describe Unitsdb::Database do - let(:fixtures_dir) { File.join("spec", "fixtures", "unitsdb") } + let(:fixtures_dir) { "data" } let(:database) { described_class.from_db(fixtures_dir) } describe "search and lookup functionality" do diff --git a/spec/unitsdb/database_spec.rb b/spec/unitsdb/database_spec.rb index a1d47fb..103da59 100644 --- a/spec/unitsdb/database_spec.rb +++ b/spec/unitsdb/database_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true RSpec.describe Unitsdb::Database do - dir_path = File.join(__dir__, "../fixtures/unitsdb/") + dir_path = File.join(__dir__, "../../data/") it "parses the full unitsdb database" do parsed = described_class.from_db(dir_path) diff --git a/spec/unitsdb/dimension_spec.rb b/spec/unitsdb/dimension_spec.rb index 93abc6a..7088ef7 100644 --- a/spec/unitsdb/dimension_spec.rb +++ b/spec/unitsdb/dimension_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true RSpec.describe Unitsdb::Dimension do - file_path = File.join(__dir__, "../fixtures/unitsdb/dimensions.yaml") + file_path = File.join(__dir__, "../../data/dimensions.yaml") dimensions_yaml = YAML.safe_load_file(file_path) dimensions_yaml["dimensions"].each do |value| diff --git a/spec/unitsdb/dimensions_spec.rb b/spec/unitsdb/dimensions_spec.rb index 6c42aaf..41f70a7 100644 --- a/spec/unitsdb/dimensions_spec.rb +++ b/spec/unitsdb/dimensions_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true RSpec.describe Unitsdb::Dimensions do - file_path = File.join(__dir__, "../fixtures/unitsdb/dimensions.yaml") + file_path = File.join(__dir__, "../../data/dimensions.yaml") it "parses the dimension collection" do raw_string = File.read(file_path) diff --git a/spec/unitsdb/prefix_spec.rb b/spec/unitsdb/prefix_spec.rb index e08d9c7..93e0716 100644 --- a/spec/unitsdb/prefix_spec.rb +++ b/spec/unitsdb/prefix_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true RSpec.describe Unitsdb::Prefix do - file_path = File.join(__dir__, "../fixtures/unitsdb/prefixes.yaml") + file_path = File.join(__dir__, "../../data/prefixes.yaml") prefixes_yaml = YAML.safe_load_file(file_path)["prefixes"] prefixes_yaml.each do |value| diff --git a/spec/unitsdb/prefixes_spec.rb b/spec/unitsdb/prefixes_spec.rb index df66dd1..2380b8e 100644 --- a/spec/unitsdb/prefixes_spec.rb +++ b/spec/unitsdb/prefixes_spec.rb @@ -3,7 +3,7 @@ require_relative "../../lib/unitsdb/prefixes" RSpec.describe Unitsdb::Prefixes do - file_path = File.join(__dir__, "../fixtures/unitsdb/prefixes.yaml") + file_path = File.join(__dir__, "../../data/prefixes.yaml") it "parses the prefix collection" do raw_string = File.read(file_path) diff --git a/spec/unitsdb/quantities_spec.rb b/spec/unitsdb/quantities_spec.rb index fea7b68..0ecc05b 100644 --- a/spec/unitsdb/quantities_spec.rb +++ b/spec/unitsdb/quantities_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true RSpec.describe Unitsdb::Quantities do - file_path = File.join(__dir__, "../fixtures/unitsdb/quantities.yaml") + file_path = File.join(__dir__, "../../data/quantities.yaml") it "parses the quantity collection from the new array structure" do raw_string = File.read(file_path) diff --git a/spec/unitsdb/quantity_spec.rb b/spec/unitsdb/quantity_spec.rb index fb7043a..d8a8c4c 100644 --- a/spec/unitsdb/quantity_spec.rb +++ b/spec/unitsdb/quantity_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true RSpec.describe Unitsdb::Quantity do - file_path = File.join(__dir__, "../fixtures/unitsdb/quantities.yaml") + file_path = File.join(__dir__, "../../data/quantities.yaml") quantities_yaml = YAML.safe_load_file(file_path) quantities_yaml["quantities"].each do |quantity_hash| diff --git a/spec/unitsdb/scale_spec.rb b/spec/unitsdb/scale_spec.rb index 41f0fc7..0dcd8ec 100644 --- a/spec/unitsdb/scale_spec.rb +++ b/spec/unitsdb/scale_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true RSpec.describe Unitsdb::Scale do - file_path = File.join(__dir__, "../fixtures/unitsdb/scales.yaml") + file_path = File.join(__dir__, "../../data/scales.yaml") scales_yaml = YAML.safe_load_file(file_path) scales_yaml["scales"].each do |scale_hash| diff --git a/spec/unitsdb/scales_spec.rb b/spec/unitsdb/scales_spec.rb index cca3e0d..56b7708 100644 --- a/spec/unitsdb/scales_spec.rb +++ b/spec/unitsdb/scales_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true RSpec.describe Unitsdb::Scales do - file_path = File.join(__dir__, "../fixtures/unitsdb/scales.yaml") + file_path = File.join(__dir__, "../../data/scales.yaml") it "parses the scale collection from the new array structure" do raw_string = File.read(file_path) diff --git a/spec/unitsdb/unit_spec.rb b/spec/unitsdb/unit_spec.rb index 0ea6cdc..232835f 100644 --- a/spec/unitsdb/unit_spec.rb +++ b/spec/unitsdb/unit_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true RSpec.describe Unitsdb::Unit do - file_path = File.join(__dir__, "../fixtures/unitsdb/units.yaml") + file_path = File.join(__dir__, "../../data/units.yaml") units_yaml = YAML.safe_load_file(file_path)["units"] units_yaml.each do |value| diff --git a/spec/unitsdb/unit_system_spec.rb b/spec/unitsdb/unit_system_spec.rb index 4f95fa9..3110740 100644 --- a/spec/unitsdb/unit_system_spec.rb +++ b/spec/unitsdb/unit_system_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true RSpec.describe Unitsdb::UnitSystem do - file_path = File.join(__dir__, "../fixtures/unitsdb/unit_systems.yaml") + file_path = File.join(__dir__, "../../data/unit_systems.yaml") unit_systems_yaml = YAML.safe_load_file(file_path) unit_systems_yaml["unit_systems"].each do |value| diff --git a/spec/unitsdb/unit_systems_spec.rb b/spec/unitsdb/unit_systems_spec.rb index d7ca32d..69aed64 100644 --- a/spec/unitsdb/unit_systems_spec.rb +++ b/spec/unitsdb/unit_systems_spec.rb @@ -3,7 +3,7 @@ require_relative "../../lib/unitsdb/unit_systems" RSpec.describe Unitsdb::UnitSystems do - file_path = File.join(__dir__, "../fixtures/unitsdb/unit_systems.yaml") + file_path = File.join(__dir__, "../../data/unit_systems.yaml") it "parses the unit systems collection" do raw_string = File.read(file_path) diff --git a/spec/unitsdb/units_spec.rb b/spec/unitsdb/units_spec.rb index 11b7e9b..08d3e64 100644 --- a/spec/unitsdb/units_spec.rb +++ b/spec/unitsdb/units_spec.rb @@ -3,7 +3,7 @@ require_relative "../../lib/unitsdb/units" RSpec.describe Unitsdb::Units do - file_path = File.join(__dir__, "../fixtures/unitsdb/units.yaml") + file_path = File.join(__dir__, "../../data/units.yaml") it "parses the unit collection" do raw_string = File.read(file_path) diff --git a/spec/unitsdb/version_compatibility_spec.rb b/spec/unitsdb/version_compatibility_spec.rb index a8da906..bac61d8 100644 --- a/spec/unitsdb/version_compatibility_spec.rb +++ b/spec/unitsdb/version_compatibility_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true RSpec.describe "UnitsDB 2.0.0 Features" do - let(:database_path) { File.join(__dir__, "../fixtures/unitsdb/") } + let(:database_path) { File.join(__dir__, "../../data/") } let(:db) { Unitsdb::Database.from_db(database_path) } describe "version validation" do diff --git a/unitsdb.gemspec b/unitsdb.gemspec index b31337a..32723fb 100644 --- a/unitsdb.gemspec +++ b/unitsdb.gemspec @@ -27,6 +27,8 @@ Gem::Specification.new do |spec| f.match(%r{^(test|spec|features)/}) end end + # Include YAML data files in the gem + spec.files += Dir.glob("data/**/*.yaml") spec.bindir = "exe" spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ["lib"]