diff --git a/Project.toml b/Project.toml index 0a4926d..77d2f10 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,17 @@ name = "CualerID" uuid = "a4892dd5-cee5-4429-912e-1922c40e7f9b" -authors = ["Thomas A. Christensen II <25492070+MillironX@users.noreply.github.com> and contributors"] version = "0.0.1" +authors = ["Thomas A. Christensen II <25492070+MillironX@users.noreply.github.com> and contributors"] + +[deps] +ArgParse = "c7e460c6-2fb9-53a9-8c5b-16f535851c63" +FilePathsBase = "48062228-2e41-5def-b9a4-89aafe57970f" +UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" [compat] +ArgParse = "1.2.0" +FilePathsBase = "0.9.24" +UUIDs = "1.11.0" julia = "1.12" + +[apps.cualer-id] diff --git a/README.md b/README.md index 8d16d10..8bacf03 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,4 @@ # CualerID [![Code Style: Blue](https://img.shields.io/badge/code%20style-blue-4495d1.svg)](https://github.com/invenia/BlueStyle) +[![Aqua QA](https://raw.githubusercontent.com/JuliaTesting/Aqua.jl/master/badge.svg)](https://github.com/JuliaTesting/Aqua.jl) diff --git a/src/Base30.jl b/src/Base30.jl new file mode 100644 index 0000000..345fcb7 --- /dev/null +++ b/src/Base30.jl @@ -0,0 +1,106 @@ +""" + BASE30_ALPHABET::Vector{UInt8} + +An alphabet designed for unambiguous representation of values in either upper- +or lower-case. The alphabet was formed by taking the numbers 0-9 and the Latin +alphabet and removing characters that are considered easy to confuse. Excluded +characters are + +- ``I`` => easily confused for lowercase ``L`` or the digit ``1`` +- ``L`` => easily confused for uppercase ``I`` or the digit ``1`` +- ``O`` => easily confused for the digit ``0`` +- ``Q`` => easily confused with uppercase ``O`` or lowercase ``g`` +- ``V`` => easily confused with ``U`` + +This array contains the ASCII index of the characters included in the base30 +alphabet starting with 0 at position `BASE30_ALPHABET[1]`. Use `String()` to +convert an array of these indices to a base30 string. +""" +const BASE30_ALPHABET = UInt8.([ + 48:57..., #0-9 + 65:72..., #A-H + 74:75..., #J-K + 77:78..., #M-N + 80, #P + 83:85..., #S-U + 87:90..., #W-Z +]) + +""" + rebase(v::Integer, base::Integer) + +Rebase `v` into the base system of `base`. `v` can be any integer type, and can +be represented in base 10 (decimal) or base 16 (hexadecimal) based on Julia's +treatment of that integer type. Returns a `Vector{UInt8}` containing the +**indices** of each digit in the rebased number. Since Julia is 1-indexed, this +means that `rebase(0, base)[1]` actually returns a value of `UInt8[1]`, since +the first index of a numeral system's alphabet is assumed to be its ``0`` value. + +# Examples + +```jldoctest +julia> rebase(0, 10) +1-element Vector{UInt8}: + 0x01 + +julia> # Two plus two is ten... IN BASE FOUR! I'M FINE! + +julia> rebase(2 + 2, 4) +2-element Vector{UInt8}: + 0x02 + 0x01 + +julia> base_4_alphabet = ['0', '1', '2', '3']; + +julia> String([base_4_alphabet[i] for i in rebase(2 + 2, 4)]) +"10" +``` + +!!! warning + + This method is not tested on negative integers and may produce incorrect + output when passed negative numbers +""" +function rebase(v::Integer, base::Integer) + remainder = UInt8((v % base) + 1) + quotient = div(v, base) + + if quotient == 0 + return [remainder] + else + return [rebase(quotient, base)..., remainder] + end #if + +end #function + +""" + base30encode(v::Integer) + base30encode(v::UUID) + +Creates a base30 representation of `v`. If `v` is a `UUID`, then the `UInt128` +value of the `UUID` is passed directly into the method. + +# Examples + +```jldoctest; filter = r"[0-9A-Z]{26}" +julia> base30encode(30) +"10" + +julia> using UUIDs + +julia> base30encode(UUIDs.uuid1()) +"7ZK00JECBDF2H7GNBXN59C6S9S" +``` + +!!! warning + + Just like [`rebase`](@ref), this method is not tested on negative integers + and may produce incorrect output when passed negative numbers +""" +function base30encode(v::Integer) + return String([BASE30_ALPHABET[i] for i in rebase(v, 30)]) +end #function + +function base30encode(v::UUID) + return base30encode(v.value) +end #function diff --git a/src/CualerID.jl b/src/CualerID.jl index 95ceb36..08879b2 100644 --- a/src/CualerID.jl +++ b/src/CualerID.jl @@ -1,5 +1,174 @@ module CualerID -# Write your package code here. +using ArgParse: ArgParse, ArgParseSettings, parse_args, parse_item, @add_arg_table! +using FilePathsBase: AbstractPath, Path, exists, isfile +using UUIDs: UUID -end +include("Base30.jl") +include("ResultCodeOption.jl") + +export ResultCode +export ResultCodeOption +export base30encode +export has_duplicate +export has_fixed +export has_not_fixable +export has_valid +export rebase + +function _create_arg_table() + s = ArgParseSettings(autofix_names = true) + + #! format: off + @add_arg_table! s begin + "create" + help = "Create CualerID sample ids or barcode labels" + action = :command + "fix" + help = "Compare a set of possibly invalid IDs against a correct set of IDs to identify errors in the IDs" + action = :command + "convert" + help = "Convert CualID sample ids to CualerID sample ids or vice-versa" + action = :command + end #add_arg_table + + @add_arg_table! s["create"] begin + "ids" + help = "Generate sample ids" + action = :command + "labels" + help = "Generate a pdf of barcodes (NOT YET IMPLEMENTED)" + action = :command + end #add_arg_table + + @add_arg_table! s["create"]["ids"] begin + "--length", "-l" + help = "The length of the printed sample ids" + arg_type = UInt64 + default = UInt64(5) + "--fail-threshold", "-f" + help = "NOT IMPLEMENTED: The rate at which similar ids will fail the process" + arg_type = Float64 + range_tester = x -> x >= 0 && x <= 1.0 + default = 0.99 + "number_of_ids" + help = "The number of sample ids to print" + arg_type = UInt64 + range_tester = x -> x >= 1 + required = true + end #add_arg_table + + @add_arg_table! s["fix"] begin + "--correct-ids" + help = "Path to the list of correct ids" + arg_type = AbstractPath + range_tester = x -> exists(x) && isfile(x) + required = true + "--show", "-s" + help = """ + Which types of fixes should be printed to output. Can be a + combination of D for duplicate, F for fixed, N for not fixable, + and V for valid. + """ + arg_type = ResultCodeOption + default = ResultCodeOption("DFN") + "--all", "-a" + help = "Show all ids regardless of fixed status" + action = :store_true + "ids_to_be_corrected" + help = "File containing CualerIDs that need correction" + arg_type = AbstractPath + range_tester = x -> exists(x) && isfile(x) + required = true + end #add_arg_table + + @add_arg_table! s["convert"] begin + "--length", "-l" + help = "The length of the printed sample ids" + arg_type = UInt64 + default = UInt64(5) + "--fail-threshold", "-f" + help = "NOT IMPLEMENTED: The rate at which similar ids will fail the process" + arg_type = Float64 + range_tester = x -> x >= 0 && x <= 1.0 + default = 0.99 + "--to-cual-id" + help = "Convert from CualerID to CualID" + action = :store_true + "ids_to_be_converted" + help = "TSV file containing CualIDs or CualerIDs to be converted" + arg_type = AbstractPath + range_tester = x -> exists(x) && isfile(x) + required = true + end #@add_arg_table! + + #! format: on + + return s + +end #function + +const ARG_PARSE_TABLE = _create_arg_table() + +function _create(parsed_args::Dict{Symbol,Any}) + if parsed_args[:_COMMAND_] == :ids + _create_ids(parsed_args[:ids]) + elseif parsed_args[:_COMMAND_] == :labels + _create_labels(parsed_args[:labels]) + end #if + + return 0 +end #function + +function _create_ids(parsed_args::Dict{Symbol,Any}) + println("COMMAND:\tcreate ids") + for (key, val) in parsed_args + println("\t$key:\t$val") + end #function + + return 0 +end #function + +function _create_labels(parsed_args::Dict{Symbol,Any}) + println("COMMAND:\tcreate labels") + for (key, val) in parsed_args + println("\t$key:\t$val") + end #function + + return 0 +end #function + +function _fix(parsed_args::Dict{Symbol,Any}) + println("COMMAND:\tfix") + for (key, val) in parsed_args + println("\t$key:\t$val") + end #function + + return 0 +end #function + +function _convert(parsed_args::Dict{Symbol,Any}) + println("COMMAND:\tconvert") + for (key, val) in parsed_args + println("\t$key:\t$val") + end #function + + return 0 +end #function + + +function (@main)() + parsed_args = parse_args(ARG_PARSE_TABLE; as_symbols = true) + + if parsed_args[:_COMMAND_] == :create + _create(parsed_args[:create]) + elseif parsed_args[:_COMMAND_] == :fix + _fix(parsed_args[:fix]) + elseif parsed_args[:_COMMAND_] == :convert + _convert(parsed_args[:convert]) + end #if + + return 0 +end #function + +end #module diff --git a/src/ResultCodeOption.jl b/src/ResultCodeOption.jl new file mode 100644 index 0000000..b435609 --- /dev/null +++ b/src/ResultCodeOption.jl @@ -0,0 +1,120 @@ +""" + ResultCode::UInt8 + +Encodes a result of attempting to correct a CualerID + +The valid codes are + +- **D**: duplicate +- **F**: fixed +- **N**: not fixable +- **V**: valid (no need for correction) +""" +@enum ResultCode::UInt8 begin + D = UInt8(1) + F = UInt8(2) + N = UInt8(4) + V = UInt8(8) +end #enum + +""" + ResultCodeOption + +Encodes potential corrected id codes. + +The valid codes are + +- **D**: duplicate +- **F**: fixed +- **N**: not fixable +- **V**: valid (no need for correction) + +The codes are implemented exactly as from cual-id and in that order + +# Constructors + + ResultCodeOption(str::AbstractString) + +Create a `ResultCodeOption` based on a the character codes contained within +`str`. `str` is case insensitive, may contain an arbitrary number of valid codes +(as specified above), and may contain duplicates. A `ResultCodeOption` cannot be +constructed from a combination of strings and bitmasks/integers. + +# Extended help + +The struct stores values as a bitmask + +| Bit (Int) | Bit (UInt) | Description | +| ---------:| ----------:| --------------- | +| 1 | 0x01 | D - duplicate | +| 2 | 0x02 | F - fixed | +| 4 | 0x04 | N - not fixable | +| 8 | 0x08 | V - valid | + +""" +struct ResultCodeOption + val::UInt8 +end #struct + +ResultCodeOption(str::AbstractString) = Base.parse(ResultCodeOption, str) + +function Base.parse(::Type{ResultCodeOption}, str::AbstractString) + upper_str = uppercase(str) + + # Throw if there are any invalid codes + occursin(r"[^DFNV]", upper_str) && + throw(DomainError("Valid `ResultCodeOption` must contain D, F, N, or V only")) + + # Throw if there are no valid codes + occursin(r"[DFNV]", upper_str) || throw( + DomainError("Valid `ResultCodeOption` must contain at least one of D, F, N, or V"), + ) + + result_code = sum( + c -> occursin(string(Symbol(c)), upper_str) ? UInt8(c) : UInt8(0), + instances(ResultCode), + ) + + return ResultCodeOption(result_code) + +end #function + +function Base.show(io::IO, r::ResultCodeOption) + print_code = "" + + print(io, summary(r), " (") + foreach( + c -> r.val & UInt8(c) != 0 && print(io, string(Symbol(c))), + instances(ResultCode), + ) + print(io, ")") +end #function + +""" + has_duplicate(r::ResultCodeOption) + +Determines if `r` contains a duplicate result ([`ResultCode`](@ref) D) +""" +has_duplicate(r::ResultCodeOption) = r.val & UInt8(D) != 0 + +""" + has_fixed(r::ResultCodeOption) + +Determines if `r` contains a fixed result ([`ResultCode`](@ref) F) +""" +has_fixed(r::ResultCodeOption) = r.val & UInt8(F) != 0 + +""" + has_not_fixable(r::ResultCodeOption) + +Determines if `r` contains a not fixable result ([`ResultCode`](@ref) N) +""" +has_not_fixable(r::ResultCodeOption) = r.val & UInt8(N) != 0 + +""" + has_valid(r::ResultCodeOption) + +Determines if `r` contains a valid (not corrected) result +([`ResultCode`](@ref) V) +""" +has_valid(r::ResultCodeOption) = r.val & UInt8(V) != 0 diff --git a/test/Aqua.jl b/test/Aqua.jl new file mode 100644 index 0000000..73d1555 --- /dev/null +++ b/test/Aqua.jl @@ -0,0 +1,5 @@ +using Aqua + +@testset "Aqua" begin + Aqua.test_all(CualerID) +end #@testset diff --git a/test/Base30.jl b/test/Base30.jl new file mode 100644 index 0000000..6de61d3 --- /dev/null +++ b/test/Base30.jl @@ -0,0 +1,21 @@ +using CualerID: base30encode, rebase + +@testset "rebase" begin + @test rebase(0, 10) == UInt8[1] + @test rebase(0, 2) == UInt8[1] + @test rebase(0, 32) == UInt8[1] + + @test rebase(10, 10) == UInt8[2, 1] + @test rebase(2, 2) == UInt8[2, 1] + @test rebase(32, 32) == UInt8[2, 1] + + @test rebase(11, 10) == UInt8[2, 2] + @test rebase(3, 2) == UInt8[2, 2] + @test rebase(33, 32) == UInt8[2, 2] +end #@testset + +@testset "base30encode" begin + @test base30encode(0) == "0" + @test base30encode(1) == "1" + @test base30encode(30) == "10" +end diff --git a/test/Doctests.jl b/test/Doctests.jl new file mode 100644 index 0000000..414b707 --- /dev/null +++ b/test/Doctests.jl @@ -0,0 +1,6 @@ +using Documenter + +@testset "doctests" begin + DocMeta.setdocmeta!(CualerID, :DocTestSetup, :(using CualerID); recursive = true) + doctest(CualerID) +end diff --git a/test/Project.toml b/test/Project.toml index 0c36332..abb5d2c 100644 --- a/test/Project.toml +++ b/test/Project.toml @@ -1,2 +1,10 @@ [deps] +Aqua = "4c88cf16-eb10-579e-8560-4a9242c79595" +Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" Test = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +UUIDs = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" + +[compat] +Aqua = "0.8" +Documenter = "1.16" +UUIDs = "1.11.0" diff --git a/test/ResultCodeOption.jl b/test/ResultCodeOption.jl new file mode 100644 index 0000000..1cb00ba --- /dev/null +++ b/test/ResultCodeOption.jl @@ -0,0 +1,8 @@ +@testset "ResultCode" begin + @test_throws DomainError ResultCodeOption("A") + @test_throws DomainError ResultCodeOption("") + @test has_duplicate(ResultCodeOption("D")) + @test has_fixed(ResultCodeOption("F")) + @test has_not_fixable(ResultCodeOption("N")) + @test has_valid(ResultCodeOption("V")) +end #@testset diff --git a/test/runtests.jl b/test/runtests.jl index 43f586c..a9a4b00 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,6 +1,7 @@ using CualerID using Test -@testset "CualerID.jl" begin - # Write your tests here. -end +include("Doctests.jl") +include("Aqua.jl") +include("Base30.jl") +include("ResultCodeOption.jl")