├── .github ├── CODEOWNERS ├── dependabot.yaml └── workflows │ ├── license.yaml │ └── build.yaml ├── .editorconfig ├── .vscode ├── settings.json └── extensions.json ├── Makefile ├── .gitignore ├── LICENSE ├── Cargo.toml ├── README.md ├── .gitattributes └── src └── lib.rs /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @RonniSkansing -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | charset = utf-8 6 | trim_trailing_whitespace = true 7 | insert_final_newline = true 8 | indent_style = space 9 | indent_size = 4 10 | max_line_length = 120 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "rust-analyzer.check.command": "clippy", 4 | "[rust]": { 5 | "editor.defaultFormatter": "rust-lang.rust-analyzer" 6 | }, 7 | "[markdown]": { 8 | "editor.wordWrap": "off" 9 | } 10 | } -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "cargo" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "EditorConfig.EditorConfig", 4 | "GitHub.vscode-github-actions", 5 | "Gruntfuggly.todo-tree", 6 | "rust-lang.rust-analyzer", 7 | "serayuzgur.crates", 8 | "tamasfe.even-better-toml", 9 | "usernamehw.errorlens", 10 | "vadimcn.vscode-lldb" 11 | ] 12 | } -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: 2 | cargo test --features="serde" 3 | install-dev: 4 | cargo install cargo-tarpaulin 5 | cargo install cargo-watch 6 | cargo install cargo-readme 7 | generate-docs: 8 | cargo readme > readme.md 9 | dev-mode-vsc: 10 | cargo watch -x "tarpaulin --run-types Tests --out Lcov --output-dir coverage; cargo test --doc; cargo doc" # VSCODE - Coverage Gutters 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /.github/workflows/license.yaml: -------------------------------------------------------------------------------- 1 | name: license 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 1 1 *" 6 | 7 | concurrency: 8 | group: ${{ github.workflow }} 9 | cancel-in-progress: true 10 | 11 | jobs: 12 | license: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout 16 | uses: actions/checkout@v6 17 | with: 18 | fetch-depth: 0 19 | 20 | - uses: FantasticFiasco/action-update-license-year@v3 21 | with: 22 | token: ${{ secrets.GITHUB_TOKEN }} 23 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: ["master"] 6 | pull_request: 7 | branches: ["master"] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | # Make sure CI fails on all warnings, including Clippy lints 12 | RUSTFLAGS: "-Dwarnings" 13 | 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v6 20 | - name: Lint 21 | run: | 22 | cargo fmt --all -- --check 23 | cargo clippy --all-targets --all-features 24 | - name: Build 25 | run: cargo build --verbose --all-features 26 | - name: Run tests 27 | run: cargo test --verbose --all-features 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo will have compiled files and executables 2 | /debug/ 3 | /target/ 4 | 5 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 6 | Cargo.lock 7 | 8 | # These are backup files generated by rustfmt 9 | **/*.rs.bk 10 | 11 | # MSVC Windows builds of rustc generate these, which store debugging information 12 | *.pdb 13 | 14 | # Log 15 | *.log 16 | 17 | # JetBrains 18 | /.idea/ 19 | 20 | # Visual Studio Code 21 | /.vscode/* 22 | !/.vscode/settings.json 23 | !/.vscode/tasks.json 24 | !/.vscode/launch.json 25 | !/.vscode/extensions.json 26 | !/.vscode/*.code-snippets 27 | 28 | # Local History for Visual Studio Code 29 | /.history/ 30 | 31 | # Built Visual Studio Code Extensions 32 | *.vsix 33 | 34 | # Converage 35 | /coverage/ 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2025 Tokio Contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "duration-string" 3 | description = "String to duration and vice-versa lib. Format is [0-9]+(ns|us|ms|[smhdwy]) such as 100ms, 1s, 2h, 1y" 4 | documentation = "https://docs.rs/duration-string" 5 | readme = "README.md" 6 | repository = "https://github.com/Ronniskansing/duration-string" 7 | license-file = "LICENSE" 8 | keywords = ["duration"] 9 | categories = ["date-and-time"] 10 | exclude = ["coverage/**/*"] 11 | version = "0.5.3" 12 | authors = [ 13 | "Ronni Skansing ", 14 | "Martin Davy ", 15 | "Philip Sequeira ", 16 | "Kiran Ostrolenk ", 17 | "Carlo Corradini ", 18 | ] 19 | edition = "2021" 20 | rust-version = "1.61" 21 | 22 | [features] 23 | # default = ["serde"] 24 | 25 | [dependencies] 26 | serde = { version = "1.0.105", optional = true, features = ["derive"] } 27 | 28 | [dev-dependencies] 29 | serde_json = { version = "1.0.49" } 30 | 31 | [profile.test] 32 | 33 | [lints.rust] 34 | unsafe_code = "forbid" 35 | 36 | [lints.clippy] 37 | all = { level = "deny", priority = -1 } 38 | pedantic = { level = "deny", priority = -1 } 39 | cargo = { level = "deny", priority = -1 } 40 | 41 | 42 | # testing and doc'ing 43 | # cargo install cargo-tarpaulin 44 | # cargo install cargo-watch 45 | 46 | # cargo watch -x "tarpaulin --run-types Tests --out Lcov --output-dir coverage" 47 | # cargo watch -x "tarpaulin --run-types Tests --out Lcov --output-dir coverage; cargo test --doc; cargo doc" # VSCODE - Coverage Gutters 48 | 49 | # update readme 50 | # cargo install cargo-readme 51 | # cargo readme > readme.md 52 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # duration-string 2 | 3 | `duration-string` is a library to convert from `String` to `Duration` and vice-versa. 4 | 5 | Uses zero dependencies unless `serde` feature is enabled. 6 | 7 | [![build](https://github.com/RonniSkansing/duration-string/actions/workflows/build.yaml/badge.svg)](https://github.com/RonniSkansing/duration-string/actions/workflows/build.yaml) 8 | ![Crates.io](https://img.shields.io/crates/v/duration-string.svg) 9 | 10 | Takes a `String` such as `100ms`, `2s`, `5m 30s`, `1h10m` and converts it into a `Duration`. 11 | 12 | Takes a `Duration` and converts it into `String`. 13 | 14 | The `String` format is a multiply of `[0-9]+(ns|us|ms|[smhdwy])` 15 | 16 | ## Example 17 | 18 | `String` to `Duration`: 19 | 20 | ```rust 21 | use std::convert::TryFrom; 22 | use duration_string::DurationString; 23 | use std::time::Duration; 24 | 25 | let d: Duration = DurationString::try_from(String::from("100ms")).unwrap().into(); 26 | assert_eq!(d, Duration::from_millis(100)); 27 | 28 | // Alternatively 29 | let d: Duration = "100ms".parse::().unwrap().into(); 30 | assert_eq!(d, Duration::from_millis(100)); 31 | ``` 32 | 33 | `Duration` to `String`: 34 | 35 | ```rust 36 | use std::convert::TryFrom; 37 | use duration_string::*; 38 | use std::time::Duration; 39 | 40 | let d: String = DurationString::from(Duration::from_millis(100)).into(); 41 | assert_eq!(d, String::from("100ms")); 42 | ``` 43 | 44 | ## Serde support 45 | 46 | You can enable _serialization/deserialization_ support by adding the feature `serde` 47 | 48 | - Add `serde` feature 49 | 50 | ```toml 51 | duration-string = { version = "0.5.3", features = ["serde"] } 52 | ``` 53 | 54 | - Add derive to struct 55 | 56 | ```rust 57 | use duration_string::DurationString; 58 | use serde::{Deserialize, Serialize}; 59 | 60 | #[derive(Serialize, Deserialize)] 61 | struct Foo { 62 | duration: DurationString 63 | } 64 | ``` 65 | 66 | ## License 67 | 68 | This project is licensed under the [MIT](https://opensource.org/licenses/MIT) License. 69 | 70 | See [LICENSE](./LICENSE) file for details. 71 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | 3 | # Source code 4 | *.bash text eol=lf 5 | *.bat text eol=crlf 6 | *.c text diff=cpp 7 | *.cc text diff=cpp 8 | *.cmd text eol=crlf 9 | *.coffee text 10 | *.cpi text diff=cpp 11 | *.cpp text diff=cpp 12 | *.css text diff=css 13 | *.cxx text diff=cpp 14 | *.c++ text diff=cpp 15 | *.h text diff=cpp 16 | *.hh text diff=cpp 17 | *.hpp text diff=cpp 18 | *.htm text diff=html 19 | *.html text diff=html 20 | *.h++ text diff=cpp 21 | *.inc text 22 | *.ini text 23 | *.js text 24 | *.json text 25 | *.jsx text 26 | *.less text 27 | *.ls text 28 | *.map text -diff 29 | *.od text 30 | *.onlydata text 31 | *.php text diff=php 32 | *.pl text 33 | *.ps1 text eol=crlf 34 | *.py text diff=python 35 | *.rb text diff=ruby 36 | *.rs text diff=rust 37 | *.sass text 38 | *.scm text 39 | *.scss text diff=css 40 | *.sh text eol=lf 41 | *.sql text 42 | *.styl text 43 | *.tag text 44 | *.ts text 45 | *.tsx text 46 | *.xml text 47 | *.xhtml text diff=html 48 | 49 | # Compiled Object files 50 | *.slo binary 51 | *.lo binary 52 | *.o binary 53 | *.obj binary 54 | 55 | # Precompiled Headers 56 | *.gch binary 57 | *.pch binary 58 | 59 | # Compiled Dynamic libraries 60 | *.so binary 61 | *.dylib binary 62 | *.dll binary 63 | 64 | # Compiled Static libraries 65 | *.lai binary 66 | *.la binary 67 | *.a binary 68 | *.lib binary 69 | 70 | # Docker 71 | Dockerfile text 72 | 73 | # Documentation 74 | *.ipynb text 75 | *.markdown text diff=markdown 76 | *.md text diff=markdown 77 | *.mdwn text diff=markdown 78 | *.mdown text diff=markdown 79 | *.mkd text diff=markdown 80 | *.mkdn text diff=markdown 81 | *.mdtxt text 82 | *.mdtext text 83 | *.txt text 84 | AUTHORS text 85 | CHANGELOG text 86 | CHANGES text 87 | CONTRIBUTING text 88 | COPYING text 89 | copyright text 90 | *COPYRIGHT* text 91 | INSTALL text 92 | license text 93 | LICENSE text 94 | NEWS text 95 | readme text 96 | *README* text 97 | TODO text 98 | 99 | # Templates 100 | *.dot text 101 | *.ejs text 102 | *.erb text 103 | *.haml text 104 | *.handlebars text 105 | *.hbs text 106 | *.hbt text 107 | *.jade text 108 | *.latte text 109 | *.mustache text 110 | *.njk text 111 | *.phtml text 112 | *.svelte text 113 | *.tmpl text 114 | *.tpl text 115 | *.twig text 116 | *.vue text 117 | 118 | # Configs 119 | Cargo.lock text 120 | *.cnf text 121 | *.conf text 122 | *.config text 123 | .editorconfig text 124 | .env text 125 | .gitattributes text 126 | .gitconfig text 127 | .htaccess text 128 | *.lock text -diff 129 | package.json text eol=lf 130 | package-lock.json text -diff 131 | pnpm-lock.yaml text eol=lf -diff 132 | .prettierrc text 133 | yarn.lock text -diff 134 | *.toml text diff=toml 135 | *.yaml text 136 | *.yml text 137 | browserslist text 138 | Makefile text 139 | makefile text 140 | 141 | # Heroku 142 | Procfile text 143 | 144 | # Graphics 145 | *.ai binary 146 | *.bmp binary 147 | *.eps binary 148 | *.gif binary 149 | *.gifv binary 150 | *.ico binary 151 | *.jng binary 152 | *.jp2 binary 153 | *.jpg binary 154 | *.jpeg binary 155 | *.jpx binary 156 | *.jxr binary 157 | *.pdf binary 158 | *.png binary 159 | *.psb binary 160 | *.psd binary 161 | *.svg text 162 | *.svgz binary 163 | *.tif binary 164 | *.tiff binary 165 | *.wbmp binary 166 | *.webp binary 167 | 168 | # Audio 169 | *.kar binary 170 | *.m4a binary 171 | *.mid binary 172 | *.midi binary 173 | *.mp3 binary 174 | *.ogg binary 175 | *.ra binary 176 | 177 | # Video 178 | *.3gpp binary 179 | *.3gp binary 180 | *.as binary 181 | *.asf binary 182 | *.asx binary 183 | *.avi binary 184 | *.fla binary 185 | *.flv binary 186 | *.m4v binary 187 | *.mng binary 188 | *.mov binary 189 | *.mp4 binary 190 | *.mpeg binary 191 | *.mpg binary 192 | *.ogv binary 193 | *.swc binary 194 | *.swf binary 195 | *.webm binary 196 | 197 | # Archives 198 | *.7z binary 199 | *.gz binary 200 | *.jar binary 201 | *.rar binary 202 | *.tar binary 203 | *.zip binary 204 | 205 | # Fonts 206 | *.ttf binary 207 | *.eot binary 208 | *.otf binary 209 | *.woff binary 210 | *.woff2 binary 211 | 212 | # Executables 213 | *.exe binary 214 | *.out binary 215 | *.app binary 216 | 217 | # RC files 218 | *.*rc text 219 | 220 | # Ignore files 221 | *.*ignore text 222 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! `duration-string` is a library to convert from `String` to `Duration` and vice-versa. 2 | //! 3 | //! ![Crates.io](https://img.shields.io/crates/v/duration-string.svg) 4 | //! ![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg) 5 | //! 6 | //! Takes a `String` such as `100ms`, `2s`, `5m 30s`, `1h10m` and converts it into a `Duration`. 7 | //! 8 | //! Takes a `Duration` and converts it into `String`. 9 | //! 10 | //! The `String` format is a multiply of `[0-9]+(ns|us|ms|[smhdwy])` 11 | //! 12 | //! ## Example 13 | //! 14 | //! `String` to `Duration`: 15 | //! 16 | //! ```rust 17 | //! use std::convert::TryFrom; 18 | //! use duration_string::DurationString; 19 | //! use std::time::Duration; 20 | //! 21 | //! let d: Duration = DurationString::try_from(String::from("100ms")).unwrap().into(); 22 | //! assert_eq!(d, Duration::from_millis(100)); 23 | //! 24 | //! // Alternatively 25 | //! let d: Duration = "100ms".parse::().unwrap().into(); 26 | //! assert_eq!(d, Duration::from_millis(100)); 27 | //! ``` 28 | //! 29 | //! `Duration` to `String`: 30 | //! 31 | //! ```rust 32 | //! use std::convert::TryFrom; 33 | //! use duration_string::*; 34 | //! use std::time::Duration; 35 | //! 36 | //! let d: String = DurationString::from(Duration::from_millis(100)).into(); 37 | //! assert_eq!(d, String::from("100ms")); 38 | //! ``` 39 | //! 40 | //! ## Serde support 41 | //! 42 | //! You can enable _serialization/deserialization_ support by adding the feature `serde` 43 | //! 44 | //! - Add `serde` feature 45 | //! 46 | //! ```toml 47 | //! duration-string = { version = "0.5.2", features = ["serde"] } 48 | //! ``` 49 | //! 50 | //! - Add derive to struct 51 | //! 52 | //! ```ignore 53 | //! use duration_string::DurationString; 54 | //! use serde::{Deserialize, Serialize}; 55 | //! 56 | //! #[derive(Serialize, Deserialize)] 57 | //! struct Foo { 58 | //! duration: DurationString 59 | //! } 60 | //! ``` 61 | //! 62 | #![cfg_attr(feature = "serde", doc = "```rust")] 63 | #![cfg_attr(not(feature = "serde"), doc = "```ignore")] 64 | #![allow(clippy::doc_markdown)] 65 | //! ``` 66 | //! use duration_string::DurationString; 67 | //! use serde::{Deserialize, Serialize}; 68 | //! use serde_json; 69 | //! 70 | //! #[derive(Serialize, Deserialize)] 71 | //! struct SerdeSupport { 72 | //! t: DurationString, 73 | //! } 74 | //! let s = SerdeSupport { 75 | //! t: DurationString::from_string(String::from("1m")).unwrap(), 76 | //! }; 77 | //! assert_eq!(r#"{"t":"1m"}"#, serde_json::to_string(&s).unwrap()); 78 | //! ``` 79 | 80 | #[cfg(feature = "serde")] 81 | use serde::de::Unexpected; 82 | use std::borrow::{Borrow, BorrowMut}; 83 | use std::convert::TryFrom; 84 | #[cfg(feature = "serde")] 85 | use std::fmt; 86 | use std::iter::Sum; 87 | #[cfg(feature = "serde")] 88 | use std::marker::PhantomData; 89 | use std::num::ParseIntError; 90 | use std::ops::{Add, AddAssign, Deref, DerefMut, Div, DivAssign, Mul, MulAssign, Sub, SubAssign}; 91 | use std::str::FromStr; 92 | use std::time::Duration; 93 | 94 | const YEAR_IN_NANO: u128 = 31_556_926_000_000_000; 95 | const WEEK_IN_NANO: u128 = 604_800_000_000_000; 96 | const DAY_IN_NANO: u128 = 86_400_000_000_000; 97 | const HOUR_IN_NANO: u128 = 3_600_000_000_000; 98 | const MINUTE_IN_NANO: u128 = 60_000_000_000; 99 | const SECOND_IN_NANO: u128 = 1_000_000_000; 100 | const MILLISECOND_IN_NANO: u128 = 1_000_000; 101 | const MICROSECOND_IN_NANO: u128 = 1000; 102 | 103 | const HOUR_IN_SECONDS: u32 = 3600; 104 | const MINUTE_IN_SECONDS: u32 = 60; 105 | const DAY_IN_SECONDS: u32 = 86_400; 106 | const WEEK_IN_SECONDS: u32 = 604_800; 107 | const YEAR_IN_SECONDS: u32 = 31_556_926; 108 | 109 | pub type Result = std::result::Result; 110 | 111 | #[derive(Clone, Debug, Eq, PartialEq)] 112 | pub enum Error { 113 | Format, 114 | Overflow, 115 | ParseInt(ParseIntError), 116 | } 117 | 118 | impl std::fmt::Display for Error { 119 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 120 | match self { 121 | Self::Format => write!( 122 | f, 123 | "missing time duration format, must be multiples of `[0-9]+(ns|us|ms|[smhdwy])`" 124 | ), 125 | Self::Overflow => write!(f, "number is too large to fit in target type"), 126 | Self::ParseInt(err) => write!(f, "{err}"), 127 | } 128 | } 129 | } 130 | 131 | impl std::error::Error for Error { 132 | fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { 133 | match self { 134 | Self::Format | Self::Overflow => None, 135 | Self::ParseInt(err) => Some(err), 136 | } 137 | } 138 | } 139 | 140 | impl From for Error { 141 | fn from(value: ParseIntError) -> Self { 142 | Self::ParseInt(value) 143 | } 144 | } 145 | 146 | #[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Default)] 147 | pub struct DurationString(Duration); 148 | 149 | impl DurationString { 150 | #[must_use] 151 | pub const fn new(duration: Duration) -> DurationString { 152 | DurationString(duration) 153 | } 154 | 155 | #[allow(clippy::missing_errors_doc)] 156 | pub fn from_string(duration: String) -> Result { 157 | DurationString::try_from(duration) 158 | } 159 | } 160 | 161 | impl std::fmt::Display for DurationString { 162 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 163 | let s: String = (*self).into(); 164 | write!(f, "{s}") 165 | } 166 | } 167 | 168 | impl From for Duration { 169 | fn from(value: DurationString) -> Self { 170 | value.0 171 | } 172 | } 173 | 174 | impl From for String { 175 | fn from(value: DurationString) -> Self { 176 | let ns = value.0.as_nanos(); 177 | if ns % YEAR_IN_NANO == 0 { 178 | return (ns / YEAR_IN_NANO).to_string() + "y"; 179 | } 180 | if ns % WEEK_IN_NANO == 0 { 181 | return (ns / WEEK_IN_NANO).to_string() + "w"; 182 | } 183 | if ns % DAY_IN_NANO == 0 { 184 | return (ns / DAY_IN_NANO).to_string() + "d"; 185 | } 186 | if ns % HOUR_IN_NANO == 0 { 187 | return (ns / HOUR_IN_NANO).to_string() + "h"; 188 | } 189 | if ns % MINUTE_IN_NANO == 0 { 190 | return (ns / MINUTE_IN_NANO).to_string() + "m"; 191 | } 192 | if ns % SECOND_IN_NANO == 0 { 193 | return (ns / SECOND_IN_NANO).to_string() + "s"; 194 | } 195 | if ns % MILLISECOND_IN_NANO == 0 { 196 | return (ns / MILLISECOND_IN_NANO).to_string() + "ms"; 197 | } 198 | if ns % MICROSECOND_IN_NANO == 0 { 199 | return (ns / MICROSECOND_IN_NANO).to_string() + "us"; 200 | } 201 | ns.to_string() + "ns" 202 | } 203 | } 204 | 205 | impl From for DurationString { 206 | fn from(duration: Duration) -> Self { 207 | DurationString(duration) 208 | } 209 | } 210 | 211 | impl TryFrom for DurationString { 212 | type Error = Error; 213 | 214 | fn try_from(duration: String) -> std::result::Result { 215 | duration.parse() 216 | } 217 | } 218 | 219 | impl FromStr for DurationString { 220 | type Err = Error; 221 | 222 | fn from_str(duration: &str) -> std::result::Result { 223 | let duration: Vec = duration.chars().filter(|c| !c.is_whitespace()).collect(); 224 | let mut grouped_durations: Vec<(Vec, Vec)> = vec![(vec![], vec![])]; 225 | for i in 0..duration.len() { 226 | // Vector initialised with a starting element so unwraps should never panic 227 | if duration[i].is_numeric() { 228 | grouped_durations.last_mut().unwrap().0.push(duration[i]); 229 | } else { 230 | grouped_durations.last_mut().unwrap().1.push(duration[i]); 231 | } 232 | if i != duration.len() - 1 && !duration[i].is_numeric() && duration[i + 1].is_numeric() 233 | { 234 | // move to next group 235 | grouped_durations.push((vec![], vec![])); 236 | } 237 | } 238 | if grouped_durations.is_empty() { 239 | // `duration` either contains no numbers or no letters 240 | return Err(Error::Format); 241 | } 242 | let mut total_duration = Duration::new(0, 0); 243 | for (period, format) in grouped_durations { 244 | let period = match period.iter().collect::().parse::() { 245 | Ok(period) => Ok(period), 246 | Err(err) => Err(Error::ParseInt(err)), 247 | }?; 248 | let multiply_period = |multiplier: u32| -> std::result::Result { 249 | Duration::from_secs(period) 250 | .checked_mul(multiplier) 251 | .ok_or(Error::Overflow) 252 | }; 253 | let period_duration = match format.iter().collect::().as_ref() { 254 | "ns" => Ok(Duration::from_nanos(period)), 255 | "us" => Ok(Duration::from_micros(period)), 256 | "ms" => Ok(Duration::from_millis(period)), 257 | "s" => Ok(Duration::from_secs(period)), 258 | "m" => multiply_period(MINUTE_IN_SECONDS), 259 | "h" => multiply_period(HOUR_IN_SECONDS), 260 | "d" => multiply_period(DAY_IN_SECONDS), 261 | "w" => multiply_period(WEEK_IN_SECONDS), 262 | "y" => multiply_period(YEAR_IN_SECONDS), 263 | _ => Err(Error::Format), 264 | }?; 265 | total_duration = total_duration 266 | .checked_add(period_duration) 267 | .ok_or(Error::Overflow)?; 268 | } 269 | Ok(DurationString(total_duration)) 270 | } 271 | } 272 | 273 | impl Deref for DurationString { 274 | type Target = Duration; 275 | 276 | fn deref(&self) -> &Self::Target { 277 | &self.0 278 | } 279 | } 280 | 281 | impl DerefMut for DurationString { 282 | fn deref_mut(&mut self) -> &mut Self::Target { 283 | &mut self.0 284 | } 285 | } 286 | 287 | impl Borrow for DurationString { 288 | fn borrow(&self) -> &Duration { 289 | &self.0 290 | } 291 | } 292 | 293 | impl BorrowMut for DurationString { 294 | fn borrow_mut(&mut self) -> &mut Duration { 295 | &mut self.0 296 | } 297 | } 298 | 299 | impl PartialEq for DurationString { 300 | fn eq(&self, other: &Duration) -> bool { 301 | self.0.eq(other) 302 | } 303 | } 304 | 305 | impl PartialEq for Duration { 306 | fn eq(&self, other: &DurationString) -> bool { 307 | self.eq(&other.0) 308 | } 309 | } 310 | 311 | impl PartialOrd for DurationString { 312 | fn partial_cmp(&self, other: &Duration) -> Option { 313 | self.0.partial_cmp(other) 314 | } 315 | } 316 | 317 | impl PartialOrd for Duration { 318 | fn partial_cmp(&self, other: &DurationString) -> Option { 319 | self.partial_cmp(&other.0) 320 | } 321 | } 322 | 323 | impl Add for DurationString { 324 | type Output = Self; 325 | 326 | fn add(self, other: Self) -> Self::Output { 327 | Self::new(self.0.add(other.0)) 328 | } 329 | } 330 | 331 | impl Add for DurationString { 332 | type Output = Self; 333 | 334 | fn add(self, other: Duration) -> Self::Output { 335 | Self::new(self.0.add(other)) 336 | } 337 | } 338 | 339 | impl Add for Duration { 340 | type Output = Self; 341 | 342 | fn add(self, other: DurationString) -> Self::Output { 343 | self.add(other.0) 344 | } 345 | } 346 | 347 | impl AddAssign for DurationString { 348 | fn add_assign(&mut self, other: Self) { 349 | self.0.add_assign(other.0); 350 | } 351 | } 352 | 353 | impl AddAssign for DurationString { 354 | fn add_assign(&mut self, other: Duration) { 355 | self.0.add_assign(other); 356 | } 357 | } 358 | 359 | impl AddAssign for Duration { 360 | fn add_assign(&mut self, other: DurationString) { 361 | self.add_assign(other.0); 362 | } 363 | } 364 | 365 | impl Sub for DurationString { 366 | type Output = Self; 367 | 368 | fn sub(self, other: Self) -> Self::Output { 369 | Self::new(self.0.sub(other.0)) 370 | } 371 | } 372 | 373 | impl Sub for DurationString { 374 | type Output = Self; 375 | 376 | fn sub(self, other: Duration) -> Self::Output { 377 | Self::new(self.0.sub(other)) 378 | } 379 | } 380 | 381 | impl Sub for Duration { 382 | type Output = Self; 383 | 384 | fn sub(self, other: DurationString) -> Self::Output { 385 | self.sub(other.0) 386 | } 387 | } 388 | 389 | impl SubAssign for DurationString { 390 | fn sub_assign(&mut self, other: Self) { 391 | self.0.sub_assign(other.0); 392 | } 393 | } 394 | 395 | impl SubAssign for DurationString { 396 | fn sub_assign(&mut self, other: Duration) { 397 | self.0.sub_assign(other); 398 | } 399 | } 400 | 401 | impl SubAssign for Duration { 402 | fn sub_assign(&mut self, other: DurationString) { 403 | self.sub_assign(other.0); 404 | } 405 | } 406 | 407 | impl Mul for DurationString { 408 | type Output = Self; 409 | 410 | fn mul(self, other: u32) -> Self::Output { 411 | Self::new(self.0.mul(other)) 412 | } 413 | } 414 | 415 | impl Mul for u32 { 416 | type Output = DurationString; 417 | 418 | fn mul(self, other: DurationString) -> Self::Output { 419 | DurationString::new(self.mul(other.0)) 420 | } 421 | } 422 | 423 | impl MulAssign for DurationString { 424 | fn mul_assign(&mut self, other: u32) { 425 | self.0.mul_assign(other); 426 | } 427 | } 428 | 429 | impl Div for DurationString { 430 | type Output = Self; 431 | 432 | fn div(self, other: u32) -> Self::Output { 433 | Self::new(self.0.div(other)) 434 | } 435 | } 436 | 437 | impl DivAssign for DurationString { 438 | fn div_assign(&mut self, other: u32) { 439 | self.0.div_assign(other); 440 | } 441 | } 442 | 443 | impl Sum for DurationString { 444 | fn sum>(iter: I) -> Self { 445 | Self::new(Duration::sum(iter.map(|duration_string| duration_string.0))) 446 | } 447 | } 448 | 449 | impl<'a> Sum<&'a DurationString> for DurationString { 450 | fn sum>(iter: I) -> Self { 451 | Self::new(Duration::sum( 452 | iter.map(|duration_string| &duration_string.0), 453 | )) 454 | } 455 | } 456 | 457 | #[cfg(feature = "serde")] 458 | struct DurationStringVisitor { 459 | marker: PhantomData DurationString>, 460 | } 461 | 462 | #[cfg(feature = "serde")] 463 | impl DurationStringVisitor { 464 | fn new() -> Self { 465 | Self { 466 | marker: PhantomData, 467 | } 468 | } 469 | } 470 | 471 | #[cfg(feature = "serde")] 472 | #[allow(clippy::needless_lifetimes)] 473 | impl serde::de::Visitor<'_> for DurationStringVisitor { 474 | type Value = DurationString; 475 | 476 | fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 477 | formatter.write_str("string") 478 | } 479 | 480 | fn visit_str(self, string: &str) -> std::result::Result 481 | where 482 | E: serde::de::Error, 483 | { 484 | match DurationString::from_string(string.to_string()) { 485 | Ok(d) => Ok(d), 486 | Err(s) => Err(serde::de::Error::invalid_value( 487 | Unexpected::Str(&s.to_string()), 488 | &self, 489 | )), 490 | } 491 | } 492 | } 493 | 494 | #[cfg(feature = "serde")] 495 | impl<'de> serde::Deserialize<'de> for DurationString { 496 | fn deserialize(deserializer: D) -> std::result::Result 497 | where 498 | D: serde::Deserializer<'de>, 499 | { 500 | deserializer.deserialize_str(DurationStringVisitor::new()) 501 | } 502 | } 503 | 504 | #[cfg(feature = "serde")] 505 | impl serde::Serialize for DurationString { 506 | fn serialize(&self, serializer: S) -> std::result::Result 507 | where 508 | S: serde::Serializer, 509 | { 510 | serializer.serialize_str(&self.to_string()) 511 | } 512 | } 513 | #[cfg(test)] 514 | mod tests { 515 | use super::*; 516 | #[cfg(feature = "serde")] 517 | use serde::{Deserialize, Serialize}; 518 | 519 | #[cfg(feature = "serde")] 520 | #[derive(Serialize, Deserialize)] 521 | struct SerdeSupport { 522 | d: DurationString, 523 | } 524 | 525 | #[cfg(feature = "serde")] 526 | #[test] 527 | fn test_serialize_trait() { 528 | let s = SerdeSupport { 529 | d: DurationString::from_string(String::from("1m")).unwrap(), 530 | }; 531 | assert_eq!(r#"{"d":"1m"}"#, serde_json::to_string(&s).unwrap()); 532 | } 533 | 534 | #[cfg(feature = "serde")] 535 | #[test] 536 | fn test_deserialize_trait() { 537 | let s = r#"{"d":"2m"}"#; 538 | match serde_json::from_str::(s) { 539 | Ok(v) => { 540 | assert_eq!(v.d.to_string(), "2m"); 541 | } 542 | Err(err) => panic!("failed to deserialize: {err}"), 543 | } 544 | } 545 | 546 | #[test] 547 | fn test_string_int_overflow() { 548 | DurationString::from_string(String::from("ms")).expect_err("parsing \"ms\" should fail"); 549 | } 550 | 551 | #[test] 552 | fn test_from_string_no_char() { 553 | DurationString::from_string(String::from("1234")) 554 | .expect_err("parsing \"1234\" should fail"); 555 | } 556 | 557 | // fn test_from_string 558 | #[test] 559 | fn test_from_string() { 560 | let d = DurationString::from_string(String::from("100ms")); 561 | assert_eq!("100ms", format!("{}", d.unwrap())); 562 | } 563 | 564 | #[test] 565 | fn test_display_trait() { 566 | let d = DurationString::from(Duration::from_millis(100)); 567 | assert_eq!("100ms", format!("{d}")); 568 | } 569 | 570 | #[test] 571 | fn test_from_duration() { 572 | let d: String = DurationString::from(Duration::from_millis(100)).into(); 573 | assert_eq!(d, String::from("100ms")); 574 | } 575 | 576 | fn test_parse_string(input_str: &str, expected_duration: Duration) { 577 | let d_fromstr: Duration = input_str 578 | .parse::() 579 | .expect("Parse with FromStr failed") 580 | .into(); 581 | assert_eq!(d_fromstr, expected_duration, "FromStr"); 582 | let d_using_tryfrom: Duration = DurationString::try_from(input_str.to_owned()) 583 | .expect("Parse with TryFrom failed") 584 | .into(); 585 | assert_eq!(d_using_tryfrom, expected_duration, "TryFrom"); 586 | } 587 | 588 | #[test] 589 | fn test_from_string_ms() { 590 | test_parse_string("100ms", Duration::from_millis(100)); 591 | } 592 | 593 | #[test] 594 | fn test_from_string_us() { 595 | test_parse_string("100us", Duration::from_micros(100)); 596 | } 597 | 598 | #[test] 599 | fn test_from_string_us_ms() { 600 | test_parse_string("1ms100us", Duration::from_micros(1100)); 601 | } 602 | 603 | #[test] 604 | fn test_from_string_ns() { 605 | test_parse_string("100ns", Duration::from_nanos(100)); 606 | } 607 | 608 | #[test] 609 | fn test_from_string_s() { 610 | test_parse_string("1s", Duration::from_secs(1)); 611 | } 612 | 613 | #[test] 614 | fn test_from_string_m() { 615 | test_parse_string("1m", Duration::from_secs(60)); 616 | } 617 | 618 | #[test] 619 | fn test_from_string_m_s() { 620 | test_parse_string("1m 1s", Duration::from_secs(61)); 621 | } 622 | 623 | #[test] 624 | fn test_from_string_h() { 625 | test_parse_string("1h", Duration::from_secs(3600)); 626 | } 627 | 628 | #[test] 629 | fn test_from_string_h_m() { 630 | test_parse_string("1h30m", Duration::from_secs(5400)); 631 | } 632 | 633 | #[test] 634 | fn test_from_string_h_m2() { 635 | test_parse_string("1h128m", Duration::from_secs(11280)); 636 | } 637 | 638 | #[test] 639 | fn test_from_string_d() { 640 | test_parse_string("1d", Duration::from_secs(86_400)); 641 | } 642 | 643 | #[test] 644 | fn test_from_string_w() { 645 | test_parse_string("1w", Duration::from_secs(604_800)); 646 | } 647 | 648 | #[test] 649 | fn test_from_string_w_s() { 650 | test_parse_string("1w 1s", Duration::from_secs(604_801)); 651 | } 652 | 653 | #[test] 654 | fn test_from_string_y() { 655 | test_parse_string("1y", Duration::from_secs(31_556_926)); 656 | } 657 | 658 | #[test] 659 | fn test_into_string_ms() { 660 | let d: String = DurationString::try_from(String::from("100ms")) 661 | .unwrap() 662 | .into(); 663 | assert_eq!(d, "100ms"); 664 | } 665 | 666 | #[test] 667 | fn test_into_string_s() { 668 | let d: String = DurationString::try_from(String::from("1s")).unwrap().into(); 669 | assert_eq!(d, "1s"); 670 | } 671 | 672 | #[test] 673 | fn test_into_string_m() { 674 | let d: String = DurationString::try_from(String::from("1m")).unwrap().into(); 675 | assert_eq!(d, "1m"); 676 | } 677 | 678 | #[test] 679 | fn test_into_string_h() { 680 | let d: String = DurationString::try_from(String::from("1h")).unwrap().into(); 681 | assert_eq!(d, "1h"); 682 | } 683 | 684 | #[test] 685 | fn test_into_string_d() { 686 | let d: String = DurationString::try_from(String::from("1d")).unwrap().into(); 687 | assert_eq!(d, "1d"); 688 | } 689 | 690 | #[test] 691 | fn test_into_string_w() { 692 | let d: String = DurationString::try_from(String::from("1w")).unwrap().into(); 693 | assert_eq!(d, "1w"); 694 | } 695 | 696 | #[test] 697 | fn test_into_string_y() { 698 | let d: String = DurationString::try_from(String::from("1y")).unwrap().into(); 699 | assert_eq!(d, "1y"); 700 | } 701 | 702 | #[test] 703 | fn test_into_string_overflow_unit() { 704 | let d: String = DurationString::try_from(String::from("1000ms")) 705 | .unwrap() 706 | .into(); 707 | assert_eq!(d, "1s"); 708 | 709 | let d: String = DurationString::try_from(String::from("60000ms")) 710 | .unwrap() 711 | .into(); 712 | assert_eq!(d, "1m"); 713 | 714 | let d: String = DurationString::try_from(String::from("61000ms")) 715 | .unwrap() 716 | .into(); 717 | assert_eq!(d, "61s"); 718 | } 719 | 720 | #[test] 721 | fn test_from_string_invalid_string() { 722 | DurationString::try_from(String::from("1000x")) 723 | .expect_err("Should have failed with invalid format"); 724 | } 725 | 726 | #[test] 727 | fn test_try_from_string_overflow_y() { 728 | let result = DurationString::try_from(String::from("584554530873y")); 729 | assert_eq!(result, Err(Error::Overflow)); 730 | } 731 | 732 | #[test] 733 | fn test_try_from_string_overflow_y_w() { 734 | let result = DurationString::try_from(String::from("584554530872y 29w")); 735 | assert_eq!(result, Err(Error::Overflow)); 736 | } 737 | 738 | #[test] 739 | fn test_eq() { 740 | let duration = Duration::from_secs(1); 741 | assert_eq!(DurationString::new(duration), DurationString::new(duration)); 742 | assert_eq!(DurationString::new(duration), duration); 743 | assert_eq!(duration, DurationString::new(duration)); 744 | } 745 | 746 | #[test] 747 | fn test_ne() { 748 | let a = Duration::from_secs(1); 749 | let b = Duration::from_secs(2); 750 | assert_ne!(DurationString::new(a), DurationString::new(b)); 751 | assert_ne!(DurationString::new(a), b); 752 | assert_ne!(a, DurationString::new(b)); 753 | } 754 | 755 | #[test] 756 | fn test_lt() { 757 | let a = Duration::from_secs(1); 758 | let b = Duration::from_secs(2); 759 | assert!(DurationString::new(a) < DurationString::new(b)); 760 | assert!(DurationString::new(a) < b); 761 | assert!(a < DurationString::new(b)); 762 | } 763 | 764 | #[test] 765 | fn test_le() { 766 | let a = Duration::from_secs(1); 767 | let b = Duration::from_secs(2); 768 | assert!(DurationString::new(a) <= DurationString::new(b)); 769 | assert!(DurationString::new(a) <= b); 770 | assert!(a <= DurationString::new(b)); 771 | let a = Duration::from_secs(1); 772 | let b = Duration::from_secs(1); 773 | assert!(DurationString::new(a) <= DurationString::new(b)); 774 | assert!(DurationString::new(a) <= b); 775 | assert!(a <= DurationString::new(b)); 776 | } 777 | 778 | #[test] 779 | fn test_gt() { 780 | let a = Duration::from_secs(2); 781 | let b = Duration::from_secs(1); 782 | assert!(DurationString::new(a) > DurationString::new(b)); 783 | assert!(DurationString::new(a) > b); 784 | assert!(a > DurationString::new(b)); 785 | } 786 | 787 | #[test] 788 | fn test_ge() { 789 | let a = Duration::from_secs(2); 790 | let b = Duration::from_secs(1); 791 | assert!(DurationString::new(a) >= DurationString::new(b)); 792 | assert!(DurationString::new(a) >= b); 793 | assert!(a >= DurationString::new(b)); 794 | let a = Duration::from_secs(1); 795 | let b = Duration::from_secs(1); 796 | assert!(DurationString::new(a) >= DurationString::new(b)); 797 | assert!(DurationString::new(a) >= b); 798 | assert!(a >= DurationString::new(b)); 799 | } 800 | 801 | #[test] 802 | fn test_add() { 803 | let a = Duration::from_secs(1); 804 | let b = Duration::from_secs(1); 805 | let result = a + b; 806 | assert_eq!( 807 | DurationString::new(a) + DurationString::new(b), 808 | DurationString::new(result) 809 | ); 810 | assert_eq!(DurationString::new(a) + b, DurationString::new(result)); 811 | assert_eq!(a + DurationString::new(b), result); 812 | } 813 | 814 | #[test] 815 | fn test_add_assign() { 816 | let a = Duration::from_secs(1); 817 | let b = Duration::from_secs(1); 818 | let result = a + b; 819 | let mut duration_string_duration_string = DurationString::new(a); 820 | duration_string_duration_string += DurationString::new(b); 821 | let mut duration_string_duration = DurationString::new(a); 822 | duration_string_duration += b; 823 | let mut duration_duration_string = a; 824 | duration_duration_string += DurationString::new(b); 825 | assert_eq!(duration_string_duration_string, DurationString::new(result)); 826 | assert_eq!(duration_string_duration, DurationString::new(result)); 827 | assert_eq!(duration_duration_string, result); 828 | } 829 | 830 | #[test] 831 | fn test_sub() { 832 | let a = Duration::from_secs(1); 833 | let b = Duration::from_secs(1); 834 | let result = a - b; 835 | assert_eq!( 836 | DurationString::new(a) - DurationString::new(b), 837 | DurationString::new(result) 838 | ); 839 | assert_eq!(DurationString::new(a) - b, DurationString::new(result)); 840 | assert_eq!(a - DurationString::new(b), result); 841 | } 842 | 843 | #[test] 844 | fn test_sub_assign() { 845 | let a = Duration::from_secs(1); 846 | let b = Duration::from_secs(1); 847 | let result = a - b; 848 | let mut duration_string_duration_string = DurationString::new(a); 849 | duration_string_duration_string -= DurationString::new(b); 850 | let mut duration_string_duration = DurationString::new(a); 851 | duration_string_duration -= b; 852 | let mut duration_duration_string = a; 853 | duration_duration_string -= DurationString::new(b); 854 | assert_eq!(duration_string_duration_string, DurationString::new(result)); 855 | assert_eq!(duration_string_duration, DurationString::new(result)); 856 | assert_eq!(duration_duration_string, result); 857 | } 858 | 859 | #[test] 860 | fn test_mul() { 861 | let a = 2u32; 862 | let a_duration = DurationString::new(Duration::from_secs(a.into())); 863 | let b = 4u32; 864 | let b_duration = DurationString::new(Duration::from_secs(b.into())); 865 | let result = DurationString::new(Duration::from_secs((a * b).into())); 866 | assert_eq!(a_duration * b, result); 867 | assert_eq!(a * b_duration, result); 868 | } 869 | 870 | #[test] 871 | fn test_mul_assign() { 872 | let a = 2u32; 873 | let b = 4u32; 874 | let result = DurationString::new(Duration::from_secs((a * b).into())); 875 | let mut duration_string_u32 = DurationString::new(Duration::from_secs(a.into())); 876 | duration_string_u32 *= b; 877 | assert_eq!(duration_string_u32, result); 878 | } 879 | 880 | #[test] 881 | fn test_div() { 882 | let a = 8u32; 883 | let a_duration = DurationString::new(Duration::from_secs(a.into())); 884 | let b = 4u32; 885 | let result = DurationString::new(Duration::from_secs((a / b).into())); 886 | assert_eq!(a_duration / b, result); 887 | } 888 | 889 | #[test] 890 | fn test_div_assign() { 891 | let a = 8u32; 892 | let b = 4u32; 893 | let result = DurationString::new(Duration::from_secs((a / b).into())); 894 | let mut duration_string_u32 = DurationString::new(Duration::from_secs(a.into())); 895 | duration_string_u32 /= b; 896 | assert_eq!(duration_string_u32, result); 897 | } 898 | 899 | #[test] 900 | fn test_sum() { 901 | let durations = [ 902 | Duration::from_secs(1), 903 | Duration::from_secs(2), 904 | Duration::from_secs(3), 905 | Duration::from_secs(4), 906 | Duration::from_secs(5), 907 | Duration::from_secs(6), 908 | Duration::from_secs(7), 909 | Duration::from_secs(8), 910 | Duration::from_secs(9), 911 | ]; 912 | let result = DurationString::new(durations.iter().sum()); 913 | let durations = durations 914 | .iter() 915 | .map(|duration| Into::::into(*duration)) 916 | .collect::>(); 917 | assert_eq!(durations.iter().sum::(), result); 918 | assert_eq!(durations.into_iter().sum::(), result); 919 | } 920 | } 921 | --------------------------------------------------------------------------------