├── scripts ├── release └── ci │ ├── format │ ├── check │ └── test ├── .gitignore ├── Cargo.toml ├── LICENSE.txt ├── README.md ├── .github └── workflows │ └── ci.yaml └── src └── lib.rs /scripts/release: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | cargo release "$@" 3 | -------------------------------------------------------------------------------- /scripts/ci/format: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | cargo fmt -- --check --verbose 3 | -------------------------------------------------------------------------------- /scripts/ci/check: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | cargo clippy --all-targets -- -Dwarnings 3 | -------------------------------------------------------------------------------- /scripts/ci/test: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | cargo tarpaulin -v --all-features --ignore-tests --out Lcov 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target 2 | Cargo.lock 3 | 4 | # Backups from `cargo fmt` 5 | *.rs.bk 6 | 7 | # Coverage report 8 | lcov.* 9 | *.lcov 10 | *.profraw 11 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "preferences" 3 | version = "2.0.1-dev.0" 4 | authors = ["Andy Barron "] 5 | edition = "2021" 6 | rust-version = "1.61.0" # syn 7 | 8 | description = "Read and write user-specific application data (in stable Rust)" 9 | documentation = "https://docs.rs/preferences" 10 | repository = "https://github.com/AndyBarron/preferences-rs" 11 | readme = "README.md" 12 | keywords = ["preferences", "user", "data", "persistent", "storage"] 13 | categories = ["config"] 14 | license = "MIT-0" 15 | 16 | [dependencies] 17 | app_dirs = { package = "app_dirs2", version = "2.5" } 18 | serde = { version = "^1.0.0", features = ["derive"] } 19 | serde_json = "^1.0.0" 20 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright 2024 Andy Barron 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this 6 | software and associated documentation files (the "Software"), to deal in the Software 7 | without restriction, including without limitation the rights to use, copy, modify, 8 | merge, publish, distribute, sublicense, and/or sell copies of the Software, and to 9 | permit persons to whom the Software is furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, 12 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 13 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 14 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 15 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 16 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # preferences 2 | _Read and write user-specific application data in Rust_ 3 | 4 | [![Crates.io Version](https://img.shields.io/crates/v/preferences)](https://crates.io/crates/preferences) 5 | ![Crates.io MSRV](https://img.shields.io/crates/msrv/preferences) 6 | [![Crates.io License](https://img.shields.io/crates/l/preferences)](https://github.com/andybarron/preferences-rs/blob/main/LICENSE.txt) 7 | ![Crates.io Total Downloads](https://img.shields.io/crates/d/preferences) 8 | 9 | [![docs.rs](https://img.shields.io/docsrs/preferences)](https://docs.rs/preferences) 10 | [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/andybarron/preferences-rs/ci.yaml)](https://github.com/andybarron/preferences-rs/actions/workflows/ci.yaml) 11 | [![Coveralls](https://img.shields.io/coverallsCoverage/github/andybarron/preferences-rs)](https://coveralls.io/github/andybarron/preferences-rs) 12 | 13 | ## Documentation 14 | https://docs.rs/preferences 15 | 16 | ## Installation 17 | ```sh 18 | cargo add preferences 19 | ``` 20 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: "Continuous integration" 2 | on: 3 | push: 4 | pull_request: 5 | 6 | jobs: 7 | ci: 8 | name: CI checks 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | rust: 13 | - stable 14 | - beta 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions-rust-lang/setup-rust-toolchain@v1 18 | if: ${{ matrix.rust == 'stable' }} 19 | with: 20 | toolchain: "1.61.0" # TODO: Pull this from Cargo.toml? 21 | - uses: actions-rust-lang/setup-rust-toolchain@v1 22 | with: 23 | components: clippy, rustfmt 24 | toolchain: ${{ matrix.rust }} 25 | - uses: taiki-e/install-action@v2 26 | with: 27 | tool: cargo-msrv,cargo-tarpaulin 28 | - run: ./scripts/ci/format 29 | - run: ./scripts/ci/check 30 | - run: ./scripts/ci/test 31 | - run: cargo msrv verify 32 | if: ${{ matrix.rust == 'stable' }} 33 | - uses: coverallsapp/github-action@v2 34 | if: ${{ matrix.rust == 'stable' }} 35 | with: 36 | file: lcov.info 37 | format: lcov 38 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn(clippy::all, clippy::pedantic, clippy::nursery, clippy::cargo)] 2 | #![allow(clippy::enum_glob_use)] 3 | //! *Read and write user-specific application data* 4 | //! 5 | //! This crate allows Rust developers to store and retrieve user-local preferences and other 6 | //! application data in a flexible and platform-appropriate way. 7 | //! 8 | //! Though it was originally inspired by Java's convenient 9 | //! [Preferences API](https://docs.oracle.com/javase/8/docs/api/java/util/prefs/Preferences.html), 10 | //! this crate is more flexible. *Any* struct or enum that implements 11 | //! [`serde`][serde-api]'s `Serialize` and `Deserialize` 12 | //! traits can be stored and retrieved as user data. Implementing those traits is 13 | //! trivial with the `#[derive(Serialize, Deserialize)]` attribute. 14 | //! 15 | //! # Usage 16 | //! For convenience, the type [`PreferencesMap`](type.PreferencesMap.html) is provided. (It's 17 | //! actually just [`std::collections::HashMap`][hashmap-api], where `T` defaults to 18 | //! `String`). This mirrors the Java API, which models user data as an opaque key-value store. As 19 | //! long as `T` is serializable and deserializable, [`Preferences`](trait.Preferences.html) 20 | //! will be implemented for your map instance. This allows you to seamlessly save and load 21 | //! user data with the `save(..)` and `load(..)` trait methods from `Preferences`. 22 | //! 23 | //! # Basic example 24 | //! ``` 25 | //! extern crate preferences; 26 | //! use preferences::{AppInfo, PreferencesMap, Preferences}; 27 | //! 28 | //! const APP_INFO: AppInfo = AppInfo{name: "preferences", author: "Rust language community"}; 29 | //! 30 | //! fn main() { 31 | //! 32 | //! // Create a new preferences key-value map 33 | //! // (Under the hood: HashMap) 34 | //! let mut faves: PreferencesMap = PreferencesMap::new(); 35 | //! 36 | //! // Edit the preferences (std::collections::HashMap) 37 | //! faves.insert("color".into(), "blue".into()); 38 | //! faves.insert("programming language".into(), "Rust".into()); 39 | //! 40 | //! // Store the user's preferences 41 | //! let prefs_key = "tests/docs/basic-example"; 42 | //! let save_result = faves.save(&APP_INFO, prefs_key); 43 | //! assert!(save_result.is_ok()); 44 | //! 45 | //! // ... Then do some stuff ... 46 | //! 47 | //! // Retrieve the user's preferences 48 | //! let load_result = PreferencesMap::::load(&APP_INFO, prefs_key); 49 | //! assert!(load_result.is_ok()); 50 | //! assert_eq!(load_result.unwrap(), faves); 51 | //! 52 | //! } 53 | //! ``` 54 | //! 55 | //! # Using custom data types 56 | //! ``` 57 | //! extern crate preferences; 58 | //! extern crate serde; 59 | //! use preferences::{AppInfo, Preferences}; 60 | //! use serde::{Serialize, Deserialize}; 61 | //! 62 | //! const APP_INFO: AppInfo = AppInfo{name: "preferences", author: "Rust language community"}; 63 | //! 64 | //! // Deriving `Serialize` and `Deserialize` on a struct/enum automatically implements 65 | //! // the `Preferences` trait. 66 | //! #[derive(Serialize, Deserialize, PartialEq, Debug)] 67 | //! struct PlayerData { 68 | //! level: u32, 69 | //! health: f32, 70 | //! } 71 | //! 72 | //! fn main() { 73 | //! 74 | //! let player = PlayerData{level: 2, health: 0.75}; 75 | //! 76 | //! let prefs_key = "tests/docs/custom-types"; 77 | //! let save_result = player.save(&APP_INFO, prefs_key); 78 | //! assert!(save_result.is_ok()); 79 | //! 80 | //! // Method `load` is from trait `Preferences`. 81 | //! let load_result = PlayerData::load(&APP_INFO, prefs_key); 82 | //! assert!(load_result.is_ok()); 83 | //! assert_eq!(load_result.unwrap(), player); 84 | //! 85 | //! } 86 | //! ``` 87 | //! 88 | //! # Using custom data types with `PreferencesMap` 89 | //! ``` 90 | //! extern crate preferences; 91 | //! extern crate serde; 92 | //! use preferences::{AppInfo, PreferencesMap, Preferences}; 93 | //! use serde::{Serialize, Deserialize}; 94 | //! 95 | //! const APP_INFO: AppInfo = AppInfo{name: "preferences", author: "Rust language community"}; 96 | //! 97 | //! #[derive(Serialize, Deserialize, PartialEq, Debug)] 98 | //! struct Point(f32, f32); 99 | //! 100 | //! fn main() { 101 | //! 102 | //! let mut places = PreferencesMap::new(); 103 | //! places.insert("treasure".into(), Point(1.0, 1.0)); 104 | //! places.insert("home".into(), Point(-1.0, 6.6)); 105 | //! 106 | //! let prefs_key = "tests/docs/custom-types-with-preferences-map"; 107 | //! let save_result = places.save(&APP_INFO, prefs_key); 108 | //! assert!(save_result.is_ok()); 109 | //! 110 | //! let load_result = PreferencesMap::load(&APP_INFO, prefs_key); 111 | //! assert!(load_result.is_ok()); 112 | //! assert_eq!(load_result.unwrap(), places); 113 | //! 114 | //! } 115 | //! ``` 116 | //! 117 | //! # Using custom data types with serializable containers 118 | //! ``` 119 | //! extern crate preferences; 120 | //! extern crate serde; 121 | //! use preferences::{AppInfo, Preferences}; 122 | //! use serde::{Serialize, Deserialize}; 123 | //! 124 | //! const APP_INFO: AppInfo = AppInfo{name: "preferences", author: "Rust language community"}; 125 | //! 126 | //! #[derive(Serialize, Deserialize, PartialEq, Debug)] 127 | //! struct Point(usize, usize); 128 | //! 129 | //! fn main() { 130 | //! 131 | //! let square = vec![ 132 | //! Point(0,0), 133 | //! Point(1,0), 134 | //! Point(1,1), 135 | //! Point(0,1), 136 | //! ]; 137 | //! 138 | //! let prefs_key = "tests/docs/custom-types-in-containers"; 139 | //! let save_result = square.save(&APP_INFO, prefs_key); 140 | //! assert!(save_result.is_ok()); 141 | //! 142 | //! let load_result = Vec::::load(&APP_INFO, prefs_key); 143 | //! assert!(load_result.is_ok()); 144 | //! assert_eq!(load_result.unwrap(), square); 145 | //! 146 | //! } 147 | //! ``` 148 | //! 149 | //! # Under the hood 150 | //! Data is written to flat files under the active user's home directory in a location specific to 151 | //! the operating system. This location is decided by the `app_dirs` crate with the data type 152 | //! `UserConfig`. Within the data directory, the files are stored in a folder hierarchy that maps 153 | //! to a sanitized version of the preferences key passed to `save(..)`. 154 | //! 155 | //! The data is stored in JSON format. This has several advantages: 156 | //! 157 | //! * Human-readable and self-describing 158 | //! * More compact than e.g. XML 159 | //! * Better adoption rates and language compatibility than e.g. TOML 160 | //! * Not reliant on a consistent memory layout like e.g. binary 161 | //! 162 | //! You could, of course, implement `Preferences` yourself and store your user data in 163 | //! whatever location and format that you wanted. But that would defeat the purpose of this 164 | //! library. 😊 165 | //! 166 | //! [hashmap-api]: https://doc.rust-lang.org/std/collections/struct.HashMap.html 167 | //! [serde-api]: https://crates.io/crates/serde 168 | 169 | #![warn(missing_docs)] 170 | 171 | extern crate app_dirs; 172 | extern crate serde; 173 | extern crate serde_json; 174 | 175 | use app_dirs::{get_app_dir, get_data_root, AppDataType}; 176 | pub use app_dirs::{AppDirsError, AppInfo}; 177 | use serde::de::DeserializeOwned; 178 | use serde::Serialize; 179 | use std::collections::HashMap; 180 | use std::ffi::OsString; 181 | use std::fmt; 182 | use std::fs::{create_dir_all, File}; 183 | use std::io::{self, ErrorKind, Read, Write}; 184 | use std::path::PathBuf; 185 | use std::string::FromUtf8Error; 186 | 187 | const DATA_TYPE: AppDataType = AppDataType::UserConfig; 188 | static PREFS_FILE_EXTENSION: &str = ".prefs.json"; 189 | static DEFAULT_PREFS_FILENAME: &str = "prefs.json"; 190 | 191 | /// Generic key-value store for user data. 192 | /// 193 | /// This is actually a wrapper type around [`std::collections::HashMap`][hashmap-api] 194 | /// (with `T` defaulting to `String`), so use the `HashMap` API methods to access and change user 195 | /// data in memory. 196 | /// 197 | /// To save or load user data, use the methods defined for the trait 198 | /// [`Preferences`](trait.Preferences.html), which will be automatically implemented for 199 | /// `PreferencesMap` as long as `T` is serializable. (See the 200 | /// [module documentation](index.html) for examples and more details.) 201 | /// 202 | /// [hashmap-api]: https://doc.rust-lang.org/std/collections/struct.HashMap.html 203 | pub type PreferencesMap = HashMap; 204 | 205 | /// Error type representing the errors that can occur when saving or loading user data. 206 | #[derive(Debug)] 207 | pub enum PreferencesError { 208 | /// An error occurred during JSON serialization or deserialization. 209 | Json(serde_json::Error), 210 | /// An error occurred during preferences file I/O. 211 | Io(io::Error), 212 | /// Couldn't figure out where to put or find the serialized data. 213 | Directory(AppDirsError), 214 | } 215 | 216 | impl fmt::Display for PreferencesError { 217 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 218 | use PreferencesError::*; 219 | match *self { 220 | Json(ref e) => e.fmt(f), 221 | Io(ref e) => e.fmt(f), 222 | Directory(ref e) => e.fmt(f), 223 | } 224 | } 225 | } 226 | 227 | impl std::error::Error for PreferencesError { 228 | fn cause(&self) -> Option<&dyn std::error::Error> { 229 | use PreferencesError::*; 230 | Some(match *self { 231 | Json(ref e) => e, 232 | Io(ref e) => e, 233 | Directory(ref e) => e, 234 | }) 235 | } 236 | } 237 | 238 | impl From for PreferencesError { 239 | fn from(e: serde_json::Error) -> Self { 240 | Self::Json(e) 241 | } 242 | } 243 | 244 | impl From for PreferencesError { 245 | fn from(_: FromUtf8Error) -> Self { 246 | let kind = ErrorKind::InvalidData; 247 | let msg = "Preferences file contained invalid UTF-8"; 248 | let err = io::Error::new(kind, msg); 249 | Self::Io(err) 250 | } 251 | } 252 | 253 | impl From for PreferencesError { 254 | fn from(e: std::io::Error) -> Self { 255 | Self::Io(e) 256 | } 257 | } 258 | 259 | impl From for PreferencesError { 260 | fn from(e: AppDirsError) -> Self { 261 | Self::Directory(e) 262 | } 263 | } 264 | 265 | /// Trait for types that can be saved & loaded as user data. 266 | /// 267 | /// This type is automatically implemented for any struct/enum `T` which implements both 268 | /// `Serialize` and `Deserialize` (from `serde`). (Trivially, you can annotate the type 269 | /// with `#[derive(Serialize, Deserialize)`). It is encouraged to use the provided 270 | /// type, [`PreferencesMap`](type.PreferencesMap.html), to bundle related user preferences. 271 | /// 272 | /// For the `app` parameter of `save(..)` and `load(..)`, it's recommended that you use a single 273 | /// `const` instance of `AppInfo` that represents your program: 274 | /// 275 | /// ``` 276 | /// use preferences::AppInfo; 277 | /// const APP_INFO: AppInfo = AppInfo{name: "Awesome App", author: "Dedicated Dev"}; 278 | /// ``` 279 | /// 280 | /// The `key` parameter of `save(..)` and `load(..)` should be used to uniquely identify different 281 | /// preferences data. It roughly maps to a platform-dependent directory hierarchy, with forward 282 | /// slashes used as separators on all platforms. Keys are sanitized to be valid paths; to ensure 283 | /// human-readable paths, use only letters, digits, spaces, hyphens, underscores, periods, and 284 | /// slashes. 285 | /// 286 | /// # Example keys 287 | /// * `options/graphics` 288 | /// * `saves/quicksave` 289 | /// * `bookmarks/favorites` 290 | pub trait Preferences: Sized { 291 | /// Saves the current state of this object. Implementation is platform-dependent, but the data 292 | /// will be local to the active user. 293 | /// 294 | /// # Errors 295 | /// If a serialization or file I/O error (e.g. permission denied) occurs. 296 | fn save>(&self, app: &AppInfo, key: S) -> Result<(), PreferencesError>; 297 | /// Loads this object's state from previously saved user data with the same `key`. This is 298 | /// an instance method which completely overwrites the object's state with the serialized 299 | /// data. Thus, it is recommended that you call this method immediately after instantiating 300 | /// the preferences object. 301 | /// 302 | /// # Errors 303 | /// If a deserialization or file I/O error (e.g. permission denied) occurs, or if no user data 304 | /// exists at that `path`. 305 | fn load>(app: &AppInfo, key: S) -> Result; 306 | /// Same as `save`, but writes the serialized preferences to an arbitrary writer. 307 | /// 308 | /// # Errors 309 | /// If a write or serialization error occurs. 310 | fn save_to(&self, writer: &mut W) -> Result<(), PreferencesError>; 311 | /// Same as `load`, but reads the serialized preferences from an arbitrary writer. 312 | /// 313 | /// # Errors 314 | /// If a read or deserialization error occurs. 315 | fn load_from(reader: &mut R) -> Result; 316 | } 317 | 318 | fn compute_file_path>(app: &AppInfo, key: S) -> Result { 319 | let mut path = get_app_dir(DATA_TYPE, app, key.as_ref())?; 320 | let new_name = match path.file_name() { 321 | Some(name) if !name.is_empty() => { 322 | let mut new_name = OsString::with_capacity(name.len() + PREFS_FILE_EXTENSION.len()); 323 | new_name.push(name); 324 | new_name.push(PREFS_FILE_EXTENSION); 325 | new_name 326 | } 327 | _ => DEFAULT_PREFS_FILENAME.into(), 328 | }; 329 | path.set_file_name(new_name); 330 | Ok(path) 331 | } 332 | 333 | impl Preferences for T 334 | where 335 | T: Serialize + DeserializeOwned + Sized, 336 | { 337 | fn save(&self, app: &AppInfo, key: S) -> Result<(), PreferencesError> 338 | where 339 | S: AsRef, 340 | { 341 | let path = compute_file_path(app, key.as_ref())?; 342 | path.parent().map(create_dir_all); 343 | let mut file = File::create(path)?; 344 | self.save_to(&mut file) 345 | } 346 | fn load>(app: &AppInfo, key: S) -> Result { 347 | let path = compute_file_path(app, key.as_ref())?; 348 | let mut file = File::open(path)?; 349 | Self::load_from(&mut file) 350 | } 351 | fn save_to(&self, writer: &mut W) -> Result<(), PreferencesError> { 352 | serde_json::to_writer(writer, self).map_err(Into::into) 353 | } 354 | fn load_from(reader: &mut R) -> Result { 355 | serde_json::from_reader(reader).map_err(Into::into) 356 | } 357 | } 358 | 359 | /// Get full path to the base directory for preferences. 360 | /// 361 | /// This makes no guarantees that the specified directory path actually *exists* (though you can 362 | /// easily use `std::fs::create_dir_all(..)`). Returns `None` if the directory cannot be determined 363 | /// or is not available on the current platform. 364 | #[must_use] 365 | pub fn prefs_base_dir() -> Option { 366 | get_data_root(AppDataType::UserConfig).ok() 367 | } 368 | 369 | #[cfg(test)] 370 | mod tests { 371 | use super::{AppInfo, Preferences, PreferencesMap}; 372 | const APP_INFO: AppInfo = AppInfo { 373 | name: "preferences", 374 | author: "Rust language community", 375 | }; 376 | const TEST_PREFIX: &str = "tests/module"; 377 | fn gen_test_name(name: &str) -> String { 378 | TEST_PREFIX.to_owned() + "/" + name 379 | } 380 | fn gen_sample_prefs() -> PreferencesMap { 381 | let mut prefs = PreferencesMap::new(); 382 | prefs.insert("foo".into(), "bar".into()); 383 | prefs.insert("age".into(), "23".into()); 384 | prefs.insert("PI".into(), "3.14".into()); 385 | prefs.insert("offset".into(), "-9".into()); 386 | prefs 387 | } 388 | #[test] 389 | fn test_save_load() { 390 | let sample_map = gen_sample_prefs(); 391 | let sample_other: i32 = 4; 392 | let name_map = gen_test_name("save-load-map"); 393 | let name_other = gen_test_name("save-load-other"); 394 | let save_map_result = sample_map.save(&APP_INFO, &name_map); 395 | let save_other_result = sample_other.save(&APP_INFO, &name_other); 396 | assert!(save_map_result.is_ok()); 397 | assert!(save_other_result.is_ok()); 398 | let load_map_result = PreferencesMap::load(&APP_INFO, &name_map); 399 | let load_other_result = i32::load(&APP_INFO, &name_other); 400 | assert!(load_map_result.is_ok()); 401 | assert!(load_other_result.is_ok()); 402 | assert_eq!(load_map_result.unwrap(), sample_map); 403 | assert_eq!(load_other_result.unwrap(), sample_other); 404 | } 405 | } 406 | --------------------------------------------------------------------------------