yast/yast-storage-ng

View on GitHub
src/lib/y2storage/disk_size.rb

Summary

Maintainability
A
0 mins
Test Coverage
# Copyright (c) [2015] SUSE LLC
#
# All Rights Reserved.
#
# This program is free software; you can redistribute it and/or modify it
# under the terms of version 2 of the GNU General Public License as published
# by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, contact SUSE LLC.
#
# To contact SUSE LLC about this file by physical or electronic mail, you may
# find current contact information at www.suse.com.

require "yast"

# This file can be invoked separately for minimal testing.

module Y2Storage
  #
  # Class to handle disk sizes in the MB/GB/TB range with readable output.
  #
  # Disk sizes are stored internally in bytes. Negative values are
  # allowed in principle but the special value -1 is reserved to mean
  # 'unlimited'.
  #
  # {DiskSize} objects are used through {Y2Storage} whenever a size has to be specified.
  # Notable exception here is class {Region} where several methods expect Integer arguments.
  # Like {Region#start}, {Region#length}, {Region#end}, and others.
  #
  # To get the size in bytes, use {to_i}.
  #
  # @example
  #   x = DiskSize.MiB(6)   #=> <DiskSize 6.00 MiB (6291456)>
  #   x.to_i                #=> 6291456
  #
  class DiskSize
    include Comparable

    UNITS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"]
    # Old units from AutoYaST (to be deprecated)
    DEPRECATED_UNITS = ["K", "M", "G"]
    # International System of Units (SI)
    SI_UNITS = ["KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
    UNLIMITED = "unlimited"

    # Return {DiskSize} in bytes.
    #
    # @return [Integer]
    #
    # @example
    #   x = DiskSize.KB(8)    #=> <DiskSize 7.81 KiB (8000)>
    #   x.to_i                #=> 8000
    #
    # @example Unlimited size is represented internally by -1.
    #   x = DiskSize.unlimited    #=> <DiskSize <unlimited> (-1)>
    #   x.to_i                    #=> -1
    #
    attr_reader :size
    alias_method :to_i, :size
    alias_method :to_storage_value, :size

    # @!method new(size = 0)
    # @!scope class
    #
    # Accepts +Numeric+, +Strings+, or {DiskSize} objects as initializers.
    # @raise [TypeError] if anything else is used as initializer
    #
    # @see initialize
    # @see parse
    #
    # @param size [Numeric, String, DiskSize]
    # @return [DiskSize]
    #
    # @example Create 16 MiB DiskSize objects
    #   size1 = DiskSize.new(16*1024*1024)   #=> <DiskSize 16.00 MiB (16777216)>
    #   size2 = DiskSize.new("16 MiB")       #=> <DiskSize 16.00 MiB (16777216)>
    #   size3 = DiskSize.new(size1)          #=> <DiskSize 16.00 MiB (16777216)>
    #
    # @example The default is size 0
    #   DiskSize.new                         #=> <DiskSize 0.00 B (0)>
    #
    # @example You can have unlimited (infinite) size
    #   DiskSize.new("unlimited")            #=> <DiskSize <unlimited> (-1)>

    # @see new
    def initialize(size = 0)
      @size =
        if size.is_a?(Y2Storage::DiskSize)
          size.to_i
        elsif size.is_a?(::String)
          Y2Storage::DiskSize.parse(size).size
        elsif size.respond_to?(:round)
          size.round
        end
      raise(TypeError, "Cannot convert #{size.inspect} to DiskSize") unless @size
    end

    #
    # Factory methods
    #
    class << self
      # @!method B(size)
      #
      # Create {DiskSize} object in byte units.
      #
      # @param size [Float]
      # @return [DiskSize]
      #
      # @example These are 16 Bytes
      #   DiskSize.B(16)   #=> <DiskSize 16.00 B (16)>

      # @!method KiB(size)
      #
      # Create {DiskSize} object in KiB (2^10) units.
      #
      # @param size [Float]
      # @return [DiskSize]
      #
      # @example These are 16 KiB
      #   DiskSize.KiB(16)   #=> <DiskSize 16.00 KiB (16384)>

      # @!method KB(size)
      #
      # Create {DiskSize} object in KB (10^3) units.
      #
      # @param size [Float]
      # @return [DiskSize]
      #
      # @example These are 16 KB
      #   DiskSize.KB(16)   #=> <DiskSize 15.62 KiB (16000)>

      # @!method MiB(size)
      #
      # Create {DiskSize} object in MiB (2**20) units.
      #
      # @param size [Float]
      # @return [DiskSize]
      #
      # @example These are 16 MiB
      #   DiskSize.MiB(16)   #=> <DiskSize 16.00 MiB (16777216)>

      # @!method MB(size)
      #
      # Create {DiskSize} object in MB (10^6) units.
      #
      # @param size [Float]
      # @return [DiskSize]
      #
      # @example These are 16 MB
      #   DiskSize.MB(16)   #=> <DiskSize 15.26 MiB (16000000)>

      # @!method GiB(size)
      #
      # Create {DiskSize} object in GiB (2**30) units.
      #
      # @param size [Float]
      # @return [DiskSize]
      #
      # @example These are 16 GiB
      #   DiskSize.GiB(16)   #=> <DiskSize 16.00 GiB (17179869184)>

      # @!method GB(size)
      #
      # Create {DiskSize} object in GB (10^9) units.
      #
      # @param size [Float]
      # @return [DiskSize]
      #
      # @example These are 16 GB
      #   DiskSize.GB(16)   #=> <DiskSize 14.90 GiB (16000000000)>

      # @!method TiB(size)
      #
      # Create {DiskSize} object in TiB (2**40) units.
      #
      # @param size [Float]
      # @return [DiskSize]
      #
      # @example These are 16 TiB
      #   DiskSize.TiB(16)   #=> <DiskSize 16.00 TiB (17592186044416)>

      # @!method TB(size)
      #
      # Create {DiskSize} object in TB (10^12) units.
      #
      # @param size [Float]
      # @return [DiskSize]
      #
      # @example These are 16 TB
      #   DiskSize.TB(16)   #=> <DiskSize 14.55 TiB (16000000000000)>

      # @!method PiB(size)
      #
      # Create {DiskSize} object in PiB (2**50) units.
      #
      # @param size [Float]
      # @return [DiskSize]
      #
      # @example These are 16 PiB
      #   DiskSize.PiB(16)   #=> <DiskSize 16.00 PiB (18014398509481984)>

      # @!method PB(size)
      #
      # Create {DiskSize} object in PB (10^15) units.
      #
      # @param size [Float]
      # @return [DiskSize]
      #
      # @example These are 16 PB
      #   DiskSize.PB(16)   #=> <DiskSize 14.21 PiB (16000000000000000)>

      # @!method EiB(size)
      #
      # Create {DiskSize} object in EiB (2**60) units.
      #
      # @param size [Float]
      # @return [DiskSize]
      #
      # @example These are 16 EiB
      #   DiskSize.EiB(16)   #=> <DiskSize 16.00 EiB (18446744073709551616)>

      # @!method EB(size)
      #
      # Create {DiskSize} object in EB (10^18) units.
      #
      # @param size [Float]
      # @return [DiskSize]
      #
      # @example These are 16 EB
      #   DiskSize.EB(16)   #=> <DiskSize 13.88 EiB (16000000000000000000)>

      # @!method ZiB(size)
      #
      # Create {DiskSize} object in ZiB (2**70) units.
      #
      # @param size [Float]
      # @return [DiskSize]
      #
      # @example These are 16 ZiB
      #   DiskSize.ZiB(16)   #=> <DiskSize 16.00 ZiB (18889465931478580854784)>

      # @!method ZB(size)
      #
      # Create {DiskSize} object in ZB (10^21) units.
      #
      # @param size [Float]
      # @return [DiskSize]
      #
      # @example These are 16 ZB
      #   DiskSize.ZB(16)   #=> <DiskSize 13.55 ZiB (16000000000000000000000)>

      # @!method YiB(size)
      #
      # Create {DiskSize} object in YiB (2**80) units.
      #
      # @param size [Float]
      # @return [DiskSize]
      #
      # @example These are 16 YiB
      #   DiskSize.YiB(16)   #=> <DiskSize 16.00 YiB (19342813113834066795298816)>

      # @!method YB(size)
      #
      # Create {DiskSize} object in YB (10^24) units.
      #
      # @param size [Float]
      # @return [DiskSize]
      #
      # @example These are 16 YB
      #   DiskSize.YB(16)   #=> <DiskSize 13.23 YiB (16000000000000000000000000)>

      # Define an initializer method for each unit:
      # @see DiskSize::UNITS and DiskSize::SI_UNITS.
      #
      # Examples:
      #
      # DiskSize.MiB(10)  #=> new DiskSize of 10 MiB
      # DiskSize.MB(10)   #=> new DiskSize of 10 MB
      (UNITS + SI_UNITS).each do |unit|
        define_method(unit) { |v| DiskSize.new(calculate_bytes(v, unit)) }
      end

      # Create {DiskSize} object of unlimited size.
      #
      # @return [DiskSize]
      #
      # @example Unlimited (infinite) size
      #   DiskSize.unlimited   #=> <DiskSize <unlimited> (-1)>
      #
      def unlimited
        DiskSize.new(-1)
      end

      # Create {DiskSize} object of zero size.
      #
      # @return [DiskSize]
      #
      # @example Zero (0) size
      #   DiskSize.zero   #=> <DiskSize 0.00 B (0)>
      #
      def zero
        DiskSize.new(0)
      end

      # Total sum of all sizes in an +Array+.
      #
      # If the optional argument +rounding+ is used, every size will be
      # rounded up.
      #
      # @see ceil
      #
      # @param sizes [Array<DiskSize>] array of {DiskSize} objects to sum
      # @param rounding [DiskSize, nil]
      # @return [DiskSize] sum of all the sizes
      #
      # @example
      #   x = DiskSize.KiB(1)       #=> <DiskSize 1.00 KiB (1024)>
      #   DiskSize.sum([x, x, x])   #=> <DiskSize 3.00 KiB (3072)>
      #
      def sum(sizes, rounding: nil)
        rounding ||= DiskSize.new(1)
        sizes.reduce(DiskSize.zero) { |sum, size| sum + size.ceil(rounding) }
      end

      # Create a {DiskSize} from a parsed string.
      #
      # @param str [String]
      # @param legacy_units [Boolean] if true, International System units
      #   are considered as base 2 units, that is, MB is the same than MiB.
      #
      # @return [DiskSize]
      #
      # Valid format:
      #
      # NUMBER [UNIT] [(COMMENT)] | unlimited
      #
      # A floating point number, optionally followed by a binary unit (e.g. 'GiB'),
      # optionally followed by a comment in parentheses (which is ignored).
      # Alternatively, the string 'unlimited' represents an infinite size.
      #
      # If UNIT is missing, 'B' (bytes) is assumed.
      #
      # @example
      #   DiskSize.parse("42 GiB")              #=> <DiskSize 42.00 GiB (45097156608)>
      #   DiskSize.parse("42.00  GiB")          #=> <DiskSize 42.00 GiB (45097156608)>
      #   DiskSize.parse("42 GB")               #=> <DiskSize 39.12 GiB (42000000000)>
      #   DiskSize.parse("42GB")                #=> <DiskSize 39.12 GiB (42000000000)>
      #   DiskSize.parse("512")                 #=> <DiskSize 0.50 KiB (512)>
      #   DiskSize.parse("0.5 YiB (512 ZiB)")   #=> <DiskSize 0.50 YiB (604462909807314587353088)>
      #   DiskSize.parse("1024MiB(1 GiB)")      #=> <DiskSize 1.00 GiB (1073741824)>
      #   DiskSize.parse("unlimited")           #=> <DiskSize <unlimited> (-1)>
      #
      def parse(str, legacy_units: false)
        str = sanitize(str)
        return DiskSize.unlimited if str == UNLIMITED

        bytes = str_to_bytes(str, legacy_units: legacy_units)
        DiskSize.new(bytes)
      end

      # Create a {DiskSize} from a parsed string but returns a fallback when the string is not valid
      #
      # The parse method raises an exception when the given string does not
      # correspond to a valid size. This method returns a fallback value
      # instead of raising an exception.
      #
      # @example
      #   DiskSize.parse_or("42 GiB")                             #=> <DiskSize 42.00 GiB (45097156608)>
      #   DiskSize.parse_or("GiB")                                #=> nil
      #   DiskSize.parse_or("GiB", DiskSize.unlimited)            #=> <DiskSize <unlimited> -1>
      #   DiskSize.parse_or("1GB", legacy_units: true)            #=> <DiskSize 1.00 GiB (1073741824)>
      #
      # @param str [String] String representing a size
      # @param fallback [Object] Fallback value to return when parsing failed
      # @param legacy_units [Boolean] if true, International System units
      #   are considered as base 2 units, that is, MB is the same than MiB.
      # @return [DiskSize]
      #
      # @see .parse
      def parse_or(str, fallback = nil, legacy_units: false)
        parse(str, legacy_units: legacy_units)
      rescue TypeError
        fallback
      end

      alias_method :from_s, :parse
      alias_method :from_human_string, :parse

      private

      # Ignore everything added in parentheses, so we can also parse the output of #to_s
      def sanitize(str)
        str.gsub(/\(.*/, "").strip
      end

      def str_to_bytes(str, legacy_units: false)
        number = number(str).to_f
        unit = unit(str)
        return number if unit.empty?

        calculate_bytes(number, unit, legacy_units: legacy_units)
      end

      def number(str)
        number = str.scan(/^[+-]?\d+\.?\d*/).first
        raise TypeError, "Not a number: #{str}" if number.nil?

        number
      end

      # Include all units for comparison
      ALL_UNITS = (UNITS + DEPRECATED_UNITS + SI_UNITS).freeze

      def unit(str)
        unit = str.gsub(number(str), "").strip
        return "" if unit.empty?

        unit = ALL_UNITS.find { |v| v.casecmp(unit).zero? }
        raise TypeError, "Bad disk size unit: #{str}" if unit.nil?

        unit
      end

      def calculate_bytes(number, unit, legacy_units: false)
        if UNITS.include?(unit)
          base = 1024
          exp = UNITS.index(unit)
        elsif SI_UNITS.include?(unit)
          base = 1000
          exp = SI_UNITS.index(unit) + 1
        elsif DEPRECATED_UNITS.include?(unit)
          base = 1000
          exp = DEPRECATED_UNITS.index(unit) + 1
        else
          raise TypeError, "Bad disk size unit: #{str}"
        end
        base = 1024 if legacy_units
        number * (base**exp)
      end
    end

    #
    # Operators
    #

    # Add a {DiskSize} object and another object. The other object must
    # be acceptable to {new}.
    #
    # @param other [Numeric, String, DiskSize]
    #
    # @return [DiskSize]
    #
    # @example
    #   x = DiskSize.MiB(3)      #=> <DiskSize 3.00 MiB (3145728)>
    #   y = DiskSize.KB(1)       #=> <DiskSize 0.98 KiB (1000)>
    #   x + 100                  #=> <DiskSize 3.00 MiB (3145828)>
    #   x + "1 MiB"              #=> <DiskSize 4.00 MiB (4194304)>
    #   x + y                    #=> <DiskSize 3.00 MiB (3146728)>
    #   x + DiskSize.unlimited   #=> <DiskSize <unlimited> (-1)>
    #   x + "unlimited"          #=> <DiskSize <unlimited> (-1)>
    #
    def +(other)
      return DiskSize.unlimited if any_operand_unlimited?(other)

      DiskSize.new(@size + DiskSize.new(other).to_i)
    end

    # Subtract a {DiskSize} object and another object. The other object
    # must be acceptable to {new}.
    #
    # @param other [Numeric, String, DiskSize]
    #
    # @return [DiskSize]
    #
    # @example
    #   x = DiskSize.MiB(3)      #=> <DiskSize 3.00 MiB (3145728)>
    #   y = DiskSize.KB(1)       #=> <DiskSize 0.98 KiB (1000)>
    #   x - 100                  #=> <DiskSize 3.00 MiB (3145628)>
    #   x - "1 MiB"              #=> <DiskSize 2.00 MiB (2097152)>
    #   x - y                    #=> <DiskSize 3.00 MiB (3144728)>
    #   # sizes can be negative
    #   y - x                    #=> <DiskSize -3.00 MiB (-3144728)>
    #   # but there's no "-unlimited"
    #   x - DiskSize.unlimited   #=> <DiskSize <unlimited> (-1)>
    #
    def -(other)
      return DiskSize.unlimited if any_operand_unlimited?(other)

      DiskSize.new(@size - DiskSize.new(other).to_i)
    end

    # The remainder dividing a {DiskSize} object by another object. The
    # other object must be acceptable to {new}.
    #
    # @param other [Numeric, String, DiskSize]
    #
    # @return [DiskSize]
    #
    # @example
    #   x = DiskSize.MiB(3)   #=> <DiskSize 3.00 MiB (3145728)>
    #   y = DiskSize.KB(1)    #=> <DiskSize 0.98 KiB (1000)>
    #   x % 100               #=> <DiskSize 28.00 B (28)>
    #   X % "1 MB"            #=> <DiskSize 142.31 KiB (145728)>
    #   x % y                 #=> <DiskSize 0.71 KiB (728)>
    #
    def %(other)
      return DiskSize.unlimited if any_operand_unlimited?(other)

      DiskSize.new(@size % DiskSize.new(other).to_i)
    end

    # Multiply a {DiskSize} object by a +Numeric+ object.
    #
    # @param other [Numeric]
    #
    # @return [DiskSize]
    #
    # @example
    #   x = DiskSize.MiB(1)      #=> <DiskSize 1.00 MiB (1048576)>
    #   x * 3                    #=> <DiskSize 3.00 MiB (3145728)>
    #
    def *(other)
      raise TypeError, "Unexpected #{other.class}; expected Numeric value" if !other.is_a?(Numeric)

      return DiskSize.unlimited if unlimited?

      DiskSize.new(@size * other)
    end

    # Divide a {DiskSize} object by a +Numeric+ object.
    #
    # @param other [Numeric]
    #
    # @return [DiskSize]
    #
    # @example
    #   x = DiskSize.MiB(1)      #=> <DiskSize 1.00 MiB (1048576)>
    #   x / 3                    #=> <DiskSize 341.33 KiB (349525)>
    #
    def /(other)
      raise TypeError, "Unexpected #{other.class}; expected Numeric value" if !other.is_a?(Numeric)

      return DiskSize.unlimited if unlimited?

      DiskSize.new(@size.to_f / other)
    end

    #
    # Other methods
    #

    # Test if {DiskSize} is unlimited.
    #
    # @return [Boolean]
    #
    # @example
    #   x = DiskSize.GiB(10)   #=> <DiskSize 10.00 GiB (10737418240)>
    #   x.unlimited?           #=> false
    def unlimited?
      @size == -1
    end

    # Test if {DiskSize} is zero.
    #
    # @return [Boolean]
    #
    # @example
    #   x = DiskSize.GiB(10)   #=> <DiskSize 10.00 GiB (10737418240)>
    #   x.zero?                #=> false
    def zero?
      @size == 0
    end

    # Test if {DiskSize} (amount of bytes) is power of certain value
    #
    # @return [Boolean]
    #
    # @example
    #   x = DiskSize.KiB(4)   #=> <DiskSize 4.00 KiB (4096)>
    #   x.power_of?(2)        #=> true
    #   x.power_of?(10)       #=> false
    #
    #   x = DiskSize.KB(100)  #=> <DiskSize 97.66 KiB (100000)>
    #   x.power_of?(2)        #=> false
    #   x.power_of?(10)       #=> true
    def power_of?(exp)
      return false if unlimited?

      (Math.log(size, exp) % 1).zero?
    end

    # Compare two {DiskSize} objects.
    #
    # @return [Integer]
    #
    # @note The Comparable mixin will get us operators < > <= >= == != with this.
    #
    # @example
    #   x = DiskSize.GiB(10)   #=> <DiskSize 10.00 GiB (10737418240)>
    #   y = DiskSize.GB(10)    #=> <DiskSize 9.31 GiB (10000000000)>
    #   x <=> y                #=> 1
    #   x > y                  #=> true
    def <=>(other)
      if other.respond_to?(:unlimited?) && other.unlimited?
        return unlimited? ? 0 : -1
      end
      return 1 if unlimited?
      return @size <=> other.size if other.respond_to?(:size)

      raise TypeError, "Unexpected #{other.class}; expected DiskSize"
    end

    # Round up the size to the next value that is divisible by
    # a given size. Return the same value if it's already divisible.
    #
    # @param unit_size [DiskSize]
    # @return [DiskSize]
    #
    # @example
    #   x = DiskSize.KiB(10)   #=> <DiskSize 10.00 KiB (10240)>
    #   y = DiskSize.KB(1)     #=> <DiskSize 0.98 KiB (1000)>
    #   x.ceil(y)              #=> <DiskSize 10.74 KiB (11000)>
    def ceil(unit_size)
      new_size = floor(unit_size)
      new_size += unit_size if new_size != self
      new_size
    end

    # Round down the size to the previous value that is divisible
    # by a given size. Return the same value if it's already divisible.
    #
    # @param unit_size [DiskSize]
    # @return [DiskSize]
    #
    # @example
    #   x = DiskSize.KiB(10)   #=> <DiskSize 10.00 KiB (10240)>
    #   y = DiskSize.KB(1)     #=> <DiskSize 0.98 KiB (1000)>
    #   x.floor(y)             #=> <DiskSize 9.77 KiB (10000)>
    def floor(unit_size)
      return DiskSize.new(@size) unless can_be_rounded?(unit_size)

      modulo = @size % unit_size.to_i
      DiskSize.new(@size - modulo)
    end

    # Human-readable string. That is, represented in the biggest unit ("MiB",
    # "GiB", ...) that makes sense, even if it means losing some precision.
    #
    # *rounding_method*:
    #
    # - `:round` - the default
    #
    # - `:floor` (available as {#human_floor}) -
    # If we have 4.999 GiB of space, and ask the user how much of that
    # should be used, prefilling the "Size" widget with the maximum
    # rounded up to "5.00 GiB" it will then fail validation
    # (checking that the entered value fits in the available space)
    # We must round down.
    #
    # - `:ceil` (available as {#human_ceil}) -
    # (This seems unnecessary because actual minimum sizes
    # have few significant digits, but we provide it for symmetry)
    #
    # @param rounding_method [:round,:floor,:ceil] how to round
    # @return [String]
    #
    # @example
    #   x = DiskSize.KB(1)   #=> <DiskSize 0.98 KiB (1000)>
    #   x.to_human_string    #=> "0.98 KiB"
    #
    #   smaller = DiskSize.new(4095)  #=> <DiskSize 4.00 KiB (4095)>
    #   smaller.to_human_string       #=> "4.00 KiB"
    #   smaller.human_floor           #=> "3.99 KiB"
    #
    #   larger = DiskSize.new(4097)   #=> <DiskSize 4.00 KiB (4097)>
    #   larger.to_human_string        #=> "4.00 KiB"
    #   larger.human_ceil             #=> "4.01 KiB"
    #
    # @see human_floor
    # @see human_ceil
    def to_human_string(rounding_method: :round)
      return "unlimited" if unlimited?

      float, unit_s = human_string_components
      rounded = (float * 100).public_send(rounding_method) / 100.0
      # A plain "#{rounded} #{unit_s}" would not keep trailing zeros
      format("%.2f %s", rounded, unit_s)
    end

    # to_human_string(rounding_method: :floor)
    # @return [String]
    # @see to_human_string
    def human_floor
      to_human_string(rounding_method: :floor)
    end

    # to_human_string(rounding_method: :ceil)
    # @return [String]
    # @see to_human_string
    def human_ceil
      to_human_string(rounding_method: :ceil)
    end

    # Exact value + human readable in parentheses (if the latter makes sense).
    #
    # The result can be passed to {new} or {parse} to get a {DiskSize} object of the same size.
    #
    # @return [String]
    #
    # @example
    #   x = DiskSize.KB(1)     #=> <DiskSize 0.98 KiB (1000)>
    #   x.to_s                 #=> "1000 B (0.98 KiB)"
    #   DiskSize.new(x.to_s)   #=> "1000 B (0.98 KiB)"
    def to_s
      return "unlimited" if unlimited?

      size1, unit1 = human_string_components
      size2, unit2 = string_components
      v1 = format("%.2f %s", size1, unit1)
      v2 = "#{(size2 % 1 == 0) ? size2.to_i : size2} #{unit2}"
      # if both units are the same, just use exact value
      (unit1 == unit2) ? v2 : "#{v2} (#{v1})"
    end

    # Human readable + exact values in brackets for debugging or logging.
    #
    # @return [String]
    #
    # @example
    #   x = DiskSize.KB(1)   #=> <DiskSize 0.98 KiB (1000)>
    #   x.inspect            #=> "<DiskSize 0.98 KiB (1000)>"
    def inspect
      return "<DiskSize <unlimited> (-1)>" if unlimited?

      "<DiskSize #{to_human_string} (#{to_i})>"
    end

    # Used by ruby's +PP+ class.
    #
    # @return [nil]
    #
    # @example
    #   require "pp"
    #   x = DiskSize.KB(1)   #=> <DiskSize 0.98 KiB (1000)>
    #   pp x                 # print "<DiskSize 0.98 KiB (1000)>"
    def pretty_print(*)
      print inspect
    end

    private

    # Return 'true' if either self or other is unlimited.
    #
    def any_operand_unlimited?(other)
      return true if unlimited?
      return true if other.respond_to?(:unlimited?) && other.unlimited?

      other.respond_to?(:to_s) && other.to_s == "unlimited"
    end

    # Checks whether makes sense to round the value to the given size
    def can_be_rounded?(unit_size)
      return false if unit_size.unlimited? || unit_size.zero? || unit_size.to_i == 1

      !unlimited? && !zero?
    end

    # Return numeric size and unit ("MiB", "GiB", ...) in human-readable form
    #
    # @return [Array] [size, unit]
    def human_string_components
      return [UNLIMITED, ""] if @size == -1

      unit_index = 0
      # prefer 0.50 MiB over 512 KiB
      size2 = @size * 2

      while size2.abs >= 1024.0 && unit_index < UNITS.size - 1
        size2 /= 1024.0
        unit_index += 1
      end
      [size2 / 2.0, UNITS[unit_index]] # FIXME: Make unit translatable
    end

    # Return numeric size and unit ("MiB", "GiB", ...).
    # Unlike #human_string_components, always return the exact value.
    #
    # @return [Array] [size, unit]
    def string_components
      return [UNLIMITED, ""] if @size == -1

      unit_index = 0
      # allow half values
      size2 = @size * 2

      while size2 != 0 && (size2 % 1024) == 0 && unit_index < UNITS.size - 1
        size2 /= 1024
        unit_index += 1
      end
      [size2 / 2.0, UNITS[unit_index]]
    end
  end
end