Provider for MappedProfileProvider {
231 | fn metadata(&self) -> Metadata {
232 | self.provider.metadata()
233 | }
234 |
235 | fn data(&self) -> Result, figment::Error> {
236 | let data = self.provider.data()?;
237 | let mut mapped = value::Map::::new();
238 |
239 | for (profile, data) in data {
240 | mapped.insert(self.mapping.get(&profile).map_or(profile, Clone::clone), data);
241 | }
242 |
243 | mapped.pipe(Ok)
244 | }
245 | }
246 |
247 | #[cfg(test)]
248 | mod tests {
249 | use fake::{Fake, Faker};
250 | use rstest::rstest;
251 | use speculoos::prelude::*;
252 |
253 | use super::Config;
254 | use crate::FileFormat;
255 |
256 | #[rstest]
257 | fn ser_de(#[values(Faker.fake::(), Config::default())] config: Config, #[values(FileFormat::Yaml, FileFormat::Toml, FileFormat::Json)] format: FileFormat) {
258 | let serialized = super::serialize_config(&config, format);
259 | let serialized = assert_that!(&serialized).is_ok().subject;
260 |
261 | let deserialized = super::deserialize_config(serialized, format);
262 | assert_that!(&deserialized).is_ok().is_equal_to(config);
263 | }
264 | }
265 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 | All notable changes to this project will be documented in this file.
3 |
4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6 |
7 | ## [Unreleased]
8 |
9 | ## [1.2.1] - 2025-04-14
10 |
11 | - Update dependencies
12 | - Update selector error messages
13 |
14 | ## [1.2.0] - 2025-03-07
15 |
16 | - Added shell completion ([#365](https://github.com/volllly/rotz/pull/365) by [@IcyTv](https://github.com/IcyTv))
17 |
18 | ## [1.1.0] - 2025-02-27
19 |
20 | ### Added
21 |
22 | - Added feature to allow [advanced selection](https://volllly.github.io/rotz/docs/configuration/os-specific-configuration#advanced-selection) for the os keys ([#331](https://github.com/volllly/rotz/issues/331)).
23 | This allows for e.g. selecting by the distro name:
24 | ```yaml
25 | linux[whoami.distro^="Ubuntu"]:
26 | installs: sudo apt install -y {{ name }}
27 | linux[whoami.distro^="Arch"]:
28 | installs: sudo pacman -S --noconfirm {{ name }}
29 | ```
30 |
31 | ### Changed
32 |
33 | - Updated terminal colors to be more readable
34 |
35 | ### Fixed
36 |
37 | - Fixed resolution of `~` to the users home directory in configuration and cli ([#358](https://github.com/volllly/rotz/issues/358))
38 |
39 | ### Removed
40 |
41 | - Removed support for the previously deprecated name `dots.(yaml|toml|json)` for the defaults file `defaults.(yaml|toml|json)`
42 |
43 | ## [1.0.0] - 2024-12-17
44 |
45 | ### Removed
46 |
47 | - Removed the `sync` command from rotz ([#334](https://github.com/volllly/rotz/discussions/334))
48 |
49 | ## [0.10.0] - 2023-12-10
50 |
51 | ### Added
52 |
53 | - Default files `default.(yaml|toml|json)` can now be located in any folder of the dotfiles repo. The defaults will be applied to all `dot.(yaml|toml|json)` files in the same folder and all subfolders.
54 |
55 | ### Changed
56 |
57 | - Repo level config file now don't need to specify `global`, `windows`, `linux` or `darwin` keys. If none is provided the `global` key will be used.
58 |
59 | ## [0.9.5] - 2023-07-14
60 |
61 | ### Added
62 |
63 | - Added build target for aarch64-pc-windows-msvc (without "handlebars_misc_helpers/http_attohttpc" feature)
64 | - Added .sha256 checksum files to releases
65 |
66 | ## [0.9.4] - 2023-07-05
67 |
68 | ### Added
69 |
70 | - Added build targets for aarch64 architectures @kecrily
71 |
72 | ## [0.9.3] - 2023-02-12
73 |
74 | ### Fixed
75 |
76 | - Issue where rotz would create empty symlinks if the source file does not exist
77 |
78 | ## [0.9.2] - 2023-01-18
79 |
80 | ### Fixed
81 |
82 | - Issue where rotz would incorrectly flag files as orphans
83 |
84 | ## [0.9.1] - 2022-11-06
85 |
86 | ### Added
87 |
88 | - Added binaries to relases
89 |
90 | ## [0.9.0] - 2022-10-07
91 |
92 | ### Added
93 |
94 | - Linked files are tracked and stored
95 | - When a previously linked file is not a link target anymore it will be removed ([#8](https://github.com/volllly/rotz/issues/8))
96 |
97 | ### Changed
98 |
99 | - When previously linked file is linked again it will be automatically overwritten without the need for the `--force` cli flag
100 |
101 | ## [0.8.1] - 2022-09-29
102 |
103 | ### Fixed
104 |
105 | - Issue where rotz could not parse dots with mixed links section types ([#40](https://github.com/volllly/rotz/issues/40))
106 |
107 | ### Changed
108 |
109 | - Updated cli parser to clap v4 which slightly changes help output
110 |
111 | ## [0.8.0] - 2022-09-16
112 |
113 | ### Added
114 |
115 | - Template helpers `#windows`, `#linx` and `#darwin` which work like `if`s for the respective os
116 | - `eval` template helper which evaluates the given string on the shell
117 |
118 | ## [0.7.1] - 2022-09-12
119 |
120 | ### Fixed
121 |
122 | - Filtering of dots in commands was not working correctly
123 |
124 | ## [0.7.0] - 2022-09-11
125 |
126 | ### Changed
127 |
128 | - The repo level config file now has support for a `force` key for forced values which cannot be changed by the config file
129 | - Rotz can now automatically detect the filetype and parse the format if the feature (`yaml`, `toml` or `json`) is enabled
130 | - The features `yaml`, `toml` and `json` can now be enabled simultaneously
131 |
132 | ### Added
133 |
134 | - Added `whoami` variable to templating
135 | - Added `directories` variable to templating
136 | - Add ability to recurse into subdirectories
137 |
138 | ### Fixed
139 |
140 | - Bug where the repo level config would not merge correctly
141 |
142 | ## [0.6.1] - 2022-08-18
143 |
144 | ### Changed
145 |
146 | - The repo level config file now uses the key `global` instead of `default`
147 | - The default `shell_command` on windows now correctly uses PowerShell instead of PowerShell Core
148 |
149 | ### Fixed
150 |
151 | - The repo level config file can now override config default values
152 |
153 | ## [0.6.0] - 2022-07-29
154 |
155 | ### Added
156 |
157 | - Implemented init command which initializes the config
158 | - Added templating to `dot.(yaml|toml|json)` files
159 |
160 | ### Removed
161 |
162 | - Removed the `repo` key from the config as its not needed
163 |
164 | ### Changed
165 |
166 | - The `repo` argument is now required for the clone command
167 |
168 | ## [0.5.0] - 2022-07-15
169 |
170 | ### Added
171 |
172 | - Implemented install command functionality
173 |
174 | ## [0.4.1] - 2022-06-30
175 |
176 | ### Fixed
177 |
178 | - Wildcard "*" in install command not working
179 | - Defaults and global values in `dot.(yaml|toml|json)` files not working correctly
180 |
181 | ## [0.4.0] - 2022-06-29
182 |
183 | ### Added
184 |
185 | - Global `--dry-run` cli parameter
186 | - Implemented install command functionality
187 | - Option to skip installing dependencies in install command
188 | - Option to continue on installation error in install command
189 | - Support for a repo level config file. You can now add a `config.(yaml|toml|json)` file containing os specific defaults to the root of your dotfiles repo.
190 | - `shell_command` configuration parameter
191 |
192 | ### Changed
193 |
194 | - Improved Error messages
195 |
196 | ### Fixed
197 |
198 | - Parsing of `dot.(yaml|toml|json)` files in the `installs` section
199 |
200 | ### Removed
201 |
202 | - Removed the `update` command. Updates to the applications should be performed by your packagemanager.
203 |
204 | ## [0.3.2] - 2022-06-28
205 |
206 | ### Fixed
207 |
208 | - Linking now also creates the parent directory if it's not present on windows
209 |
210 | ## [0.3.1] - 2022-05-27
211 |
212 | ### Added
213 |
214 | - Added error codes and help messages
215 |
216 | ### Changed
217 |
218 | - Refactored the command code
219 |
220 | ### Fixed
221 |
222 | - Linking now also creates the parent directory if it's not present
223 |
224 | ## [0.3.0] - 2022-05-09
225 |
226 | ### Added
227 |
228 | - `clone` command creates a config file with the repo configured if it does not exist
229 | - Started adding unit tests
230 |
231 | ### Changed
232 |
233 | - Better error messages
234 | - Moved from [eyre](https://crates.io/crates/eyre) to [miette](https://crates.io/crates/miette) for error handline
235 |
236 | ## [0.2.0] - 2022-02-21
237 |
238 | ### Added
239 |
240 | - Added `clone` command
241 |
242 | ### Fixed
243 |
244 | - Fixed `link` command default value for Dots not working
245 |
246 | ## [0.1.1] - 2022-02-18
247 |
248 | ### Changed
249 |
250 | - Updated Readme
251 |
252 | ## [0.1.0] - 2022-02-18
253 |
254 | ### Added
255 |
256 | - Cli parsing
257 | - Config parsing
258 | - `yaml` support
259 | - `toml` support
260 | - `json` support
261 | - Dotfile linking
262 | - Error handling
263 |
264 | [Unreleased]: https://github.com/volllly/rotz/compare/v1.2.1...HEAD
265 | [1.2.1]: https://github.com/volllly/rotz/releases/tag/v1.2.1
266 | [1.2.0]: https://github.com/volllly/rotz/releases/tag/v1.2.0
267 | [1.1.0]: https://github.com/volllly/rotz/releases/tag/v1.1.0
268 | [1.0.0]: https://github.com/volllly/rotz/releases/tag/v1.0.0
269 | [0.10.0]: https://github.com/volllly/rotz/releases/tag/v0.10.0
270 | [0.9.5]: https://github.com/volllly/rotz/releases/tag/v0.9.5
271 | [0.9.4]: https://github.com/volllly/rotz/releases/tag/v0.9.4
272 | [0.9.3]: https://github.com/volllly/rotz/releases/tag/v0.9.3
273 | [0.9.2]: https://github.com/volllly/rotz/releases/tag/v0.9.2
274 | [0.9.1]: https://github.com/volllly/rotz/releases/tag/v0.9.1
275 | [0.9.0]: https://github.com/volllly/rotz/releases/tag/v0.9.0
276 | [0.8.1]: https://github.com/volllly/rotz/releases/tag/v0.8.1
277 | [0.8.0]: https://github.com/volllly/rotz/releases/tag/v0.8.0
278 | [0.7.1]: https://github.com/volllly/rotz/releases/tag/v0.7.1
279 | [0.7.0]: https://github.com/volllly/rotz/releases/tag/v0.7.0
280 | [0.6.1]: https://github.com/volllly/rotz/releases/tag/v0.6.1
281 | [0.6.0]: https://github.com/volllly/rotz/releases/tag/v0.6.0
282 | [0.5.0]: https://github.com/volllly/rotz/releases/tag/v0.5.0
283 | [0.4.1]: https://github.com/volllly/rotz/releases/tag/v0.4.1
284 | [0.4.0]: https://github.com/volllly/rotz/releases/tag/v0.4.0
285 | [0.3.2]: https://github.com/volllly/rotz/releases/tag/v0.3.2
286 | [0.3.1]: https://github.com/volllly/rotz/releases/tag/v0.3.1
287 | [0.3.0]: https://github.com/volllly/rotz/releases/tag/v0.3.0
288 | [0.2.0]: https://github.com/volllly/rotz/releases/tag/v0.2.0
289 | [0.1.1]: https://github.com/volllly/rotz/releases/tag/v0.1.1
290 | [0.1.0]: https://github.com/volllly/rotz/releases/tag/v0.1.0
291 |
--------------------------------------------------------------------------------
/src/templating/mod.rs:
--------------------------------------------------------------------------------
1 | use std::{collections::HashMap, fmt::Debug, path::PathBuf, sync::LazyLock};
2 |
3 | use directories::BaseDirs;
4 | use handlebars::{Context, Handlebars, Helper, HelperDef, HelperResult, Output, RenderContext, RenderError, RenderErrorReason, Renderable, ScopedJson};
5 | use itertools::Itertools;
6 | use miette::Diagnostic;
7 | use serde::Serialize;
8 | use tap::{Conv, Pipe};
9 | #[cfg(feature = "profiling")]
10 | use tracing::instrument;
11 | use velcro::hash_map;
12 |
13 | use crate::{
14 | USER_DIRS,
15 | cli::Cli,
16 | config::Config,
17 | helpers::{self, os},
18 | };
19 |
20 | pub static ENV: LazyLock> = LazyLock::new(|| std::env::vars().collect());
21 |
22 | #[derive(thiserror::Error, Diagnostic, Debug)]
23 | pub enum Error {
24 | #[error("Could not render templeate")]
25 | #[diagnostic(code(template::render))]
26 | RenderingTemplate(#[source] handlebars::RenderError),
27 |
28 | #[error("Could not parse eval command")]
29 | #[diagnostic(code(template::eval::parse))]
30 | ParseEvalCommand(#[source] shellwords::MismatchedQuotes),
31 |
32 | #[error("Eval command did not run successfully")]
33 | #[diagnostic(code(template::eval::run))]
34 | RunEvalCommand(
35 | #[source]
36 | #[diagnostic_source]
37 | helpers::RunError,
38 | ),
39 | }
40 |
41 | #[derive(Serialize, Debug)]
42 | pub struct Parameters<'a> {
43 | pub config: &'a Config,
44 | pub name: &'a str,
45 | }
46 |
47 | #[derive(Serialize, Debug)]
48 | pub struct WhoamiPrameters {
49 | pub realname: String,
50 | pub username: String,
51 | pub lang: Vec,
52 | pub devicename: String,
53 | pub hostname: Option,
54 | pub platform: String,
55 | pub distro: String,
56 | pub desktop_env: String,
57 | pub arch: String,
58 | }
59 |
60 | pub static WHOAMI_PRAMETERS: LazyLock = LazyLock::new(|| WhoamiPrameters {
61 | realname: whoami::realname(),
62 | username: whoami::username(),
63 | lang: whoami::langs().map(|l| l.map(|l| l.to_string()).collect_vec()).unwrap_or_default(),
64 | devicename: whoami::devicename(),
65 | hostname: whoami::fallible::hostname().ok(),
66 | platform: whoami::platform().to_string(),
67 | distro: whoami::distro(),
68 | desktop_env: whoami::desktop_env().to_string(),
69 | arch: whoami::arch().to_string(),
70 | });
71 |
72 | #[derive(Serialize, Debug)]
73 | pub struct DirectoryPrameters {
74 | pub base: HashMap<&'static str, PathBuf>,
75 | pub user: HashMap<&'static str, PathBuf>,
76 | }
77 |
78 | pub static DIRECTORY_PRAMETERS: LazyLock = LazyLock::new(|| {
79 | let mut base: HashMap<&'static str, PathBuf> = HashMap::new();
80 |
81 | if let Some(dirs) = BaseDirs::new() {
82 | base.insert("cache", dirs.cache_dir().to_path_buf());
83 | base.insert("config", dirs.config_dir().to_path_buf());
84 | base.insert("data", dirs.data_dir().to_path_buf());
85 | base.insert("data_local", dirs.data_local_dir().to_path_buf());
86 | base.insert("home", dirs.home_dir().to_path_buf());
87 | base.insert("preference", dirs.preference_dir().to_path_buf());
88 | if let Some(dir) = dirs.executable_dir() {
89 | base.insert("executable", dir.to_path_buf());
90 | }
91 | if let Some(dir) = dirs.runtime_dir() {
92 | base.insert("runtime", dir.to_path_buf());
93 | }
94 | if let Some(dir) = dirs.state_dir() {
95 | base.insert("state", dir.to_path_buf());
96 | }
97 | }
98 |
99 | let mut user: HashMap<&'static str, PathBuf> = HashMap::new();
100 |
101 | user.insert("home", USER_DIRS.home_dir().to_path_buf());
102 | if let Some(dir) = USER_DIRS.audio_dir() {
103 | user.insert("audio", dir.to_path_buf());
104 | }
105 | if let Some(dir) = USER_DIRS.desktop_dir() {
106 | user.insert("desktop", dir.to_path_buf());
107 | }
108 | if let Some(dir) = USER_DIRS.document_dir() {
109 | user.insert("document", dir.to_path_buf());
110 | }
111 | if let Some(dir) = USER_DIRS.download_dir() {
112 | user.insert("download", dir.to_path_buf());
113 | }
114 | if let Some(dir) = USER_DIRS.font_dir() {
115 | user.insert("font", dir.to_path_buf());
116 | }
117 | if let Some(dir) = USER_DIRS.picture_dir() {
118 | user.insert("picture", dir.to_path_buf());
119 | }
120 | if let Some(dir) = USER_DIRS.public_dir() {
121 | user.insert("public", dir.to_path_buf());
122 | }
123 | if let Some(dir) = USER_DIRS.template_dir() {
124 | user.insert("template", dir.to_path_buf());
125 | }
126 | if let Some(dir) = USER_DIRS.video_dir() {
127 | user.insert("video", dir.to_path_buf());
128 | }
129 |
130 | DirectoryPrameters { base, user }
131 | });
132 |
133 | #[derive(Serialize, Debug)]
134 | struct CompleteParameters<'a, T> {
135 | #[serde(flatten)]
136 | pub parameters: &'a T,
137 | pub env: &'a HashMap,
138 | pub os: &'a str,
139 | pub whoami: &'static WhoamiPrameters,
140 | pub dirs: &'static DirectoryPrameters,
141 | }
142 |
143 | pub(crate) struct Engine<'a>(Handlebars<'a>);
144 |
145 | impl<'b> Engine<'b> {
146 | #[cfg_attr(feature = "profiling", instrument)]
147 | pub fn new<'a>(config: &'a Config, cli: &'a Cli) -> Engine<'b> {
148 | let mut hb = handlebars_misc_helpers::new_hbs::<'b>();
149 | hb.set_strict_mode(false);
150 |
151 | hb.register_helper("windows", WindowsHelper.conv::>());
152 | hb.register_helper("linux", LinuxHelper.conv::>());
153 | hb.register_helper("darwin", DarwinHelper.conv::>());
154 |
155 | hb.register_helper(
156 | "eval",
157 | EvalHelper {
158 | shell_command: config.shell_command.clone(),
159 | dry_run: cli.dry_run,
160 | }
161 | .pipe(Box::new),
162 | );
163 |
164 | Self(hb)
165 | }
166 |
167 | #[cfg_attr(feature = "profiling", instrument(skip(self)))]
168 | pub fn render(&self, template: &str, parameters: &(impl Serialize + Debug)) -> Result {
169 | let complete = CompleteParameters {
170 | parameters,
171 | env: &ENV,
172 | whoami: &WHOAMI_PRAMETERS,
173 | os: &helpers::os::OS.to_string().to_ascii_lowercase(),
174 | dirs: &DIRECTORY_PRAMETERS,
175 | };
176 | self.render_template(template, &complete).map_err(Error::RenderingTemplate)
177 | }
178 |
179 | #[cfg_attr(feature = "profiling", instrument(skip(self)))]
180 | pub fn render_template(&self, template_string: &str, data: &(impl Serialize + Debug)) -> Result {
181 | self.0.render_template(template_string, data)
182 | }
183 | }
184 |
185 | pub struct WindowsHelper;
186 |
187 | impl HelperDef for WindowsHelper {
188 | #[cfg_attr(feature = "profiling", instrument(skip(self, out)))]
189 | fn call<'reg: 'rc, 'rc>(&self, h: &Helper<'rc>, r: &'reg Handlebars<'reg>, ctx: &'rc Context, rc: &mut RenderContext<'reg, 'rc>, out: &mut dyn Output) -> HelperResult {
190 | if os::OS.is_windows() { h.template() } else { h.inverse() }.map(|t| t.render(r, ctx, rc, out)).map_or(Ok(()), |r| r)
191 | }
192 | }
193 |
194 | pub struct LinuxHelper;
195 |
196 | impl HelperDef for LinuxHelper {
197 | #[cfg_attr(feature = "profiling", instrument(skip(self, out)))]
198 | fn call<'reg: 'rc, 'rc>(&self, h: &Helper<'rc>, r: &'reg Handlebars<'reg>, ctx: &'rc Context, rc: &mut RenderContext<'reg, 'rc>, out: &mut dyn Output) -> HelperResult {
199 | if os::OS.is_linux() { h.template() } else { h.inverse() }.map(|t| t.render(r, ctx, rc, out)).map_or(Ok(()), |r| r)
200 | }
201 | }
202 |
203 | pub struct DarwinHelper;
204 |
205 | impl HelperDef for DarwinHelper {
206 | #[cfg_attr(feature = "profiling", instrument(skip(self, out)))]
207 | fn call<'reg: 'rc, 'rc>(&self, h: &Helper<'rc>, r: &'reg Handlebars<'reg>, ctx: &'rc Context, rc: &mut RenderContext<'reg, 'rc>, out: &mut dyn Output) -> HelperResult {
208 | if os::OS.is_darwin() { h.template() } else { h.inverse() }.map(|t| t.render(r, ctx, rc, out)).map_or(Ok(()), |r| r)
209 | }
210 | }
211 |
212 | pub struct EvalHelper {
213 | shell_command: Option,
214 | dry_run: bool,
215 | }
216 |
217 | impl HelperDef for EvalHelper {
218 | #[cfg_attr(feature = "profiling", instrument(skip(self)))]
219 | fn call_inner<'reg: 'rc, 'rc>(&self, h: &Helper<'rc>, r: &'reg Handlebars<'reg>, _: &'rc Context, _: &mut RenderContext<'reg, 'rc>) -> Result, RenderError> {
220 | let cmd = h
221 | .param(0)
222 | .ok_or_else(|| RenderErrorReason::ParamNotFoundForIndex("eval", 0))?
223 | .value()
224 | .as_str()
225 | .ok_or_else(|| RenderErrorReason::InvalidParamType("String"))?;
226 |
227 | if self.dry_run {
228 | format!("{{{{ eval \"{cmd}\" }}}}").conv::().conv::().pipe(Ok)
229 | } else {
230 | let cmd = if let Some(shell_command) = self.shell_command.as_ref() {
231 | r.render_template(shell_command, &hash_map! { "cmd": &cmd })?
232 | } else {
233 | cmd.to_owned()
234 | };
235 |
236 | let cmd = shellwords::split(&cmd).map_err(|e| RenderErrorReason::NestedError(Box::new(Error::ParseEvalCommand(e))))?;
237 |
238 | match helpers::run_command(&cmd[0], &cmd[1..], true, false) {
239 | Err(err) => RenderErrorReason::NestedError(Box::new(Error::RunEvalCommand(err))).conv::().pipe(Err),
240 | Ok(result) => result.trim().conv::().conv::().pipe(Ok),
241 | }
242 | }
243 | }
244 | }
245 |
246 | #[cfg(test)]
247 | pub mod test;
248 |
--------------------------------------------------------------------------------
/src/main.rs:
--------------------------------------------------------------------------------
1 | use std::{
2 | collections::{HashMap, HashSet},
3 | convert::TryFrom,
4 | fs::{self, File},
5 | path::{Path, PathBuf},
6 | sync::LazyLock,
7 | };
8 |
9 | use clap::Parser;
10 | use commands::Command;
11 | use directories::{ProjectDirs, UserDirs};
12 | #[cfg(feature = "json")]
13 | use figment::providers::Json;
14 | #[cfg(feature = "toml")]
15 | use figment::providers::Toml;
16 | #[cfg(feature = "yaml")]
17 | use figment::providers::Yaml;
18 | use figment::{
19 | Figment, Profile,
20 | providers::{Env, Format},
21 | };
22 | use helpers::os;
23 | use miette::{Diagnostic, Result, SourceSpan};
24 | use strum::Display;
25 |
26 | mod helpers;
27 |
28 | mod cli;
29 | use cli::Cli;
30 |
31 | mod config;
32 | use config::{Config, MappedProfileProvider};
33 | use state::State;
34 | use tap::Pipe;
35 | #[cfg(feature = "profiling")]
36 | use tracing::instrument;
37 | use velcro::hash_map;
38 |
39 | mod commands;
40 | mod dot;
41 | mod state;
42 | mod templating;
43 |
44 | #[cfg(not(any(feature = "toml", feature = "yaml", feature = "json")))]
45 | compile_error!("At least one file format features needs to be enabled");
46 |
47 | #[derive(thiserror::Error, Diagnostic, Debug)]
48 | pub enum Error {
49 | #[error("Unknown file extension")]
50 | #[diagnostic(code(parse::extension))]
51 | UnknownExtension(#[source_code] String, #[label] SourceSpan),
52 |
53 | #[error("Could not get \"{0}\" directory")]
54 | #[diagnostic(code(project_dirs::not_found))]
55 | GettingDirs(&'static str),
56 |
57 | #[error("Could parse config file directory \"{0}\"")]
58 | #[diagnostic(code(config::parent_dir))]
59 | ParsingConfigDir(PathBuf),
60 |
61 | #[error("Could not create config file directory \"{0}\"")]
62 | #[diagnostic(code(config::create))]
63 | CreatingConfig(PathBuf, #[source] std::io::Error),
64 |
65 | #[error("Could not read config file \"{0}\"")]
66 | #[diagnostic(code(config::read), help("Do you have access to the config file?"))]
67 | ReadingConfig(PathBuf, #[source] std::io::Error),
68 |
69 | #[error("Cloud not parse config")]
70 | #[diagnostic(code(config::parse), help("Is the config file in the correct format?"))]
71 | ParsingConfig(#[source] figment::Error),
72 |
73 | #[error("Cloud not parse config")]
74 | #[diagnostic(code(config::local::parse), help("Did you provide a top level \"global\" key in the repo level config?"))]
75 | RepoConfigProfile(#[source] figment::Error),
76 |
77 | #[error("Default profile not allowed in config")]
78 | #[diagnostic(code(config::local::default), help("Change the top level \"default\" key in the repo level config to \"global\""))]
79 | RepoConfigDefaultProfile,
80 | }
81 |
82 | pub(crate) static PROJECT_DIRS: LazyLock = LazyLock::new(|| ProjectDirs::from("com", "", "rotz").ok_or(Error::GettingDirs("application data")).expect("Could not read project dirs"));
83 | pub(crate) static USER_DIRS: LazyLock = LazyLock::new(|| UserDirs::new().ok_or(Error::GettingDirs("user")).expect("Could not read user dirs"));
84 | pub(crate) const FILE_EXTENSIONS_GLOB: &str = "{yml,toml,json}";
85 | pub(crate) const FILE_EXTENSIONS: &[(&str, FileFormat)] = &[
86 | #[cfg(feature = "yaml")]
87 | ("yaml", FileFormat::Yaml),
88 | #[cfg(feature = "yaml")]
89 | ("yml", FileFormat::Yaml),
90 | #[cfg(feature = "toml")]
91 | ("toml", FileFormat::Toml),
92 | #[cfg(feature = "json")]
93 | ("json", FileFormat::Json),
94 | ];
95 |
96 | #[derive(Debug, Display, Clone, Copy)]
97 | pub(crate) enum FileFormat {
98 | #[cfg(feature = "yaml")]
99 | #[strum(to_string = "yaml")]
100 | Yaml,
101 | #[cfg(feature = "toml")]
102 | #[strum(to_string = "toml")]
103 | Toml,
104 | #[cfg(feature = "json")]
105 | #[strum(to_string = "json")]
106 | Json,
107 | }
108 |
109 | impl TryFrom<&str> for FileFormat {
110 | type Error = Error;
111 |
112 | fn try_from(value: &str) -> Result {
113 | FILE_EXTENSIONS
114 | .iter()
115 | .find(|e| e.0 == value)
116 | .map(|e| e.1)
117 | .ok_or_else(|| Error::UnknownExtension(value.to_owned(), (0, value.len()).into()))
118 | }
119 | }
120 |
121 | impl TryFrom<&Path> for FileFormat {
122 | type Error = Error;
123 |
124 | fn try_from(value: &Path) -> Result {
125 | value.extension().map_or_else(
126 | || Error::UnknownExtension(value.to_string_lossy().to_string(), (0, 0).into()).pipe(Err),
127 | |extension| {
128 | FILE_EXTENSIONS
129 | .iter()
130 | .find(|e| e.0 == extension)
131 | .map(|e| e.1)
132 | .ok_or_else(|| Error::UnknownExtension(extension.to_string_lossy().to_string(), (0, extension.len()).into()))
133 | },
134 | )
135 | }
136 | }
137 |
138 | #[cfg(feature = "profiling")]
139 | fn main() -> Result<(), miette::Report> {
140 | use tracing_subscriber::prelude::*;
141 | use tracing_tracy::TracyLayer;
142 |
143 | let tracy_layer = TracyLayer::default();
144 | tracing_subscriber::registry().with(tracy_layer).init();
145 |
146 | let result = run();
147 |
148 | std::thread::sleep(std::time::Duration::from_secs(2));
149 |
150 | result
151 | }
152 |
153 | #[cfg(not(feature = "profiling"))]
154 | fn main() -> Result<(), miette::Report> {
155 | miette::set_hook(Box::new(|_| Box::new(miette::MietteHandlerOpts::new().show_related_errors_as_nested().with_cause_chain().build()))).unwrap();
156 | run()
157 | }
158 |
159 | #[cfg_attr(feature = "profiling", instrument)]
160 | fn run() -> Result<(), miette::Report> {
161 | let cli = Cli::parse();
162 |
163 | if !cli.config.0.exists() {
164 | fs::create_dir_all(cli.config.0.parent().ok_or_else(|| Error::ParsingConfigDir(cli.config.0.clone()))?).map_err(|e| Error::CreatingConfig(cli.config.0.clone(), e))?;
165 | File::create(&cli.config.0).map_err(|e| Error::CreatingConfig(cli.config.0.clone(), e))?;
166 | }
167 |
168 | let config = read_config(&cli)?;
169 |
170 | let engine = templating::Engine::new(&config, &cli);
171 | let mut state = State::read()?;
172 | match cli.command.clone() {
173 | cli::Command::Link { link } => commands::Link::new(config, engine)
174 | .execute((cli.bake(), link.bake(), &state.linked))
175 | .map(|linked| state.linked = linked),
176 | cli::Command::Clone { repo } => commands::Clone::new(config).execute((cli, repo)),
177 | cli::Command::Install { install } => commands::Install::new(config, engine).execute((cli.bake(), install.bake())),
178 | cli::Command::Init { repo } => commands::Init::new(config).execute((cli, repo)),
179 | cli::Command::Completions { shell } => commands::Completions::new().execute(shell),
180 | }?;
181 |
182 | state.write().map_err(Into::into)
183 | }
184 |
185 | fn read_config(cli: &Cli) -> Result {
186 | let env_config = Env::prefixed("ROTZ_");
187 |
188 | let config_path = helpers::resolve_home(&cli.config.0);
189 |
190 | let mut figment = Figment::new().merge_from_path(&config_path, false)?.merge(env_config).merge(cli);
191 |
192 | let config: Config = figment.clone().join(Config::default()).extract().map_err(Error::ParsingConfig)?;
193 |
194 | let dotfiles = helpers::resolve_home(&config.dotfiles);
195 |
196 | if let Some((config, _)) = helpers::get_file_with_format(dotfiles, "config") {
197 | figment = figment.join_from_path(config, true, hash_map!( "global".into(): "default".into(), "force".into(): "global".into() ))?;
198 | }
199 |
200 | let mut config: Config = figment
201 | .join(Config::default())
202 | .select(os::OS.to_string().to_ascii_lowercase())
203 | .extract()
204 | .map_err(Error::RepoConfigProfile)?;
205 |
206 | config.dotfiles = helpers::resolve_home(&config.dotfiles);
207 |
208 | config.pipe(Ok)
209 | }
210 |
211 | trait FigmentExt {
212 | fn merge_from_path(self, path: impl AsRef, nested: bool) -> Result
213 | where
214 | Self: std::marker::Sized;
215 | fn join_from_path(self, path: impl AsRef, nested: bool, mapping: HashMap) -> Result
216 | where
217 | Self: std::marker::Sized;
218 | }
219 |
220 | trait DataExt {
221 | fn set_nested(self, nested: bool) -> Self
222 | where
223 | Self: std::marker::Sized;
224 | }
225 |
226 | impl DataExt for figment::providers::Data {
227 | fn set_nested(self, nested: bool) -> Self
228 | where
229 | Self: std::marker::Sized,
230 | {
231 | if nested { self.nested() } else { self }
232 | }
233 | }
234 |
235 | #[derive(strum::Display, strum::EnumString)]
236 | #[strum(ascii_case_insensitive)]
237 | enum Profiles {
238 | Force,
239 | Global,
240 | }
241 |
242 | impl FigmentExt for Figment {
243 | fn merge_from_path(self, path: impl AsRef, nested: bool) -> Result {
244 | let config_str = fs::read_to_string(&path).map_err(|e| Error::ReadingConfig(path.as_ref().to_path_buf(), e))?;
245 | if !config_str.is_empty() {
246 | let file_extension = &*path.as_ref().extension().unwrap().to_string_lossy();
247 | return match file_extension {
248 | #[cfg(feature = "yaml")]
249 | "yaml" | "yml" => self.merge(Yaml::string(&config_str).set_nested(nested)),
250 | #[cfg(feature = "toml")]
251 | "toml" => self.merge(Toml::string(&config_str).set_nested(nested)),
252 | #[cfg(feature = "json")]
253 | "json" => self.merge(Json::string(&config_str).set_nested(nested)),
254 | _ => {
255 | let file_name = path.as_ref().file_name().unwrap().to_string_lossy().to_string();
256 | return Error::UnknownExtension(file_name.clone(), (file_name.rfind(file_extension).unwrap(), file_extension.len()).into()).pipe(Err);
257 | }
258 | }
259 | .pipe(Ok);
260 | }
261 |
262 | self.pipe(Ok)
263 | }
264 |
265 | fn join_from_path(self, path: impl AsRef, mut nested: bool, mapping: HashMap) -> Result
266 | where
267 | Self: std::marker::Sized,
268 | {
269 | let config_str = fs::read_to_string(&path).map_err(|e| Error::ReadingConfig(path.as_ref().to_path_buf(), e))?;
270 | if !config_str.is_empty() {
271 | let file_extension = &*path.as_ref().extension().unwrap().to_string_lossy();
272 |
273 | if nested {
274 | let profiles = match file_extension {
275 | #[cfg(feature = "yaml")]
276 | "yaml" | "yml" => serde_yaml::from_str::>(&config_str).unwrap().pipe(Ok),
277 | #[cfg(feature = "toml")]
278 | "toml" => serde_toml::from_str::>(&config_str).unwrap().pipe(Ok),
279 | #[cfg(feature = "json")]
280 | "json" => serde_json::from_str::>(&config_str).unwrap().pipe(Ok),
281 | _ => {
282 | let file_name = path.as_ref().file_name().unwrap().to_string_lossy().to_string();
283 | return Error::UnknownExtension(file_name.clone(), (file_name.rfind(file_extension).unwrap(), file_extension.len()).into()).pipe(Err);
284 | }
285 | }?
286 | .into_iter()
287 | .map(|(k, _)| k)
288 | .collect::>();
289 |
290 | if profiles.contains(&Profile::Default.to_string().to_lowercase()) {
291 | Error::RepoConfigDefaultProfile.pipe(Err)?;
292 | }
293 |
294 | nested = profiles.iter().any(|p| Profiles::try_from(p.as_str()).is_ok() || os::Os::try_from(p.as_str()).is_ok());
295 | }
296 |
297 | match file_extension {
298 | #[cfg(feature = "yaml")]
299 | "yaml" | "yml" => {
300 | return self
301 | .join(MappedProfileProvider {
302 | mapping,
303 | provider: Yaml::string(&config_str).set_nested(nested),
304 | })
305 | .pipe(Ok);
306 | }
307 | #[cfg(feature = "toml")]
308 | "toml" => {
309 | return self
310 | .join(MappedProfileProvider {
311 | mapping,
312 | provider: Toml::string(&config_str).set_nested(nested),
313 | })
314 | .pipe(Ok);
315 | }
316 | #[cfg(feature = "json")]
317 | "json" => {
318 | return self
319 | .join(MappedProfileProvider {
320 | mapping,
321 | provider: Json::string(&config_str).set_nested(nested),
322 | })
323 | .pipe(Ok);
324 | }
325 | _ => {
326 | let file_name = path.as_ref().file_name().unwrap().to_string_lossy().to_string();
327 | return Error::UnknownExtension(file_name.clone(), (file_name.rfind(file_extension).unwrap(), file_extension.len()).into()).pipe(Err);
328 | }
329 | }
330 | }
331 |
332 | self.pipe(Ok)
333 | }
334 | }
335 |
336 | #[cfg(test)]
337 | mod test;
338 |
--------------------------------------------------------------------------------
/src/dot/repr/selector.rs:
--------------------------------------------------------------------------------
1 | use std::str::FromStr;
2 |
3 | use chumsky::{Parser, prelude::*};
4 | #[cfg(test)]
5 | use fake::Dummy;
6 | use miette::{Diagnostic, LabeledSpan};
7 | use strum::EnumString;
8 | use tap::Pipe;
9 |
10 | use crate::helpers::{MultipleErrors, os};
11 | use crate::templating::{self, Engine, Parameters};
12 | use thiserror::Error;
13 |
14 | #[derive(Debug, EnumString, Hash, PartialEq, Eq, Clone)]
15 | #[cfg_attr(test, derive(Dummy))]
16 | pub(super) enum Operator {
17 | #[strum(serialize = "=")]
18 | Eq,
19 | #[strum(serialize = "^=")]
20 | StartsWith,
21 | #[strum(serialize = "$=")]
22 | EndsWith,
23 | #[strum(serialize = "*=")]
24 | Contains,
25 | #[strum(serialize = "!=")]
26 | NotEq,
27 | }
28 |
29 | #[derive(Debug, Hash, PartialEq, Eq, Clone)]
30 | #[cfg_attr(test, derive(Dummy))]
31 | pub(super) struct Attribute {
32 | key: String,
33 | operator: Operator,
34 | value: String,
35 | }
36 |
37 | #[derive(Debug, Hash, PartialEq, Eq, Clone)]
38 | #[cfg_attr(test, derive(Dummy))]
39 | pub(super) struct Selector {
40 | pub os: os::Os,
41 | pub attributes: Vec,
42 | }
43 |
44 | fn string<'src>() -> impl Parser<'src, &'src str, String, extra::Err>> {
45 | let escape = just('\\').ignore_then(
46 | just('\\')
47 | .or(just('/'))
48 | .or(just('"'))
49 | .or(just('b').to('\x08'))
50 | .or(just('f').to('\x0C'))
51 | .or(just('n').to('\n'))
52 | .or(just('r').to('\r'))
53 | .or(just('t').to('\t'))
54 | .or(
55 | just('u').ignore_then(any().filter(|c: &char| c.is_ascii_hexdigit()).repeated().exactly(4).collect::().validate(|digits, e, emitter| {
56 | char::from_u32(u32::from_str_radix(&digits, 16).unwrap()).unwrap_or_else(|| {
57 | emitter.emit(Rich::custom(e.span(), "invalid unicode character"));
58 | '\u{FFFD}' // unicode replacement character
59 | })
60 | })),
61 | ),
62 | );
63 |
64 | just('"')
65 | .ignore_then(any().filter(|c| *c != '\\' && *c != '"').or(escape).repeated().collect::>())
66 | .then_ignore(just('"'))
67 | .map(|s| s.into_iter().collect::())
68 | }
69 |
70 | fn operator<'src>() -> impl Parser<'src, &'src str, Operator, extra::Err>> {
71 | just("=")
72 | .map(|_| Operator::Eq)
73 | .or(just("$=").map(|_| Operator::EndsWith))
74 | .or(just("^=").map(|_| Operator::StartsWith))
75 | .or(just("*=").map(|_| Operator::Contains))
76 | .or(just("!=").map(|_| Operator::NotEq))
77 | }
78 | fn path<'src>() -> impl Parser<'src, &'src str, String, extra::Err>> {
79 | text::ident().separated_by(just('.')).at_least(1).collect::>().map(|k| k.join("."))
80 | }
81 | fn attribute<'src>() -> impl Parser<'src, &'src str, Attribute, extra::Err>> {
82 | path().then(operator().padded()).then(string()).map(|((key, operator), value)| Attribute { key, operator, value })
83 | }
84 | fn attributes<'src>() -> impl Parser<'src, &'src str, Vec, extra::Err>> {
85 | attribute().delimited_by(just('['), just(']')).padded().repeated().collect::>()
86 | }
87 | fn os<'src>() -> impl Parser<'src, &'src str, os::Os, extra::Err>> {
88 | text::ident().try_map(|i: &str, span| os::Os::try_from(i).map_err(|e| Rich::custom(span, e)))
89 | }
90 | fn selector<'src>() -> impl Parser<'src, &'src str, Selector, extra::Err>> {
91 | os().then(attributes()).map(|(os, attributes)| Selector { os, attributes })
92 | }
93 |
94 | #[derive(Debug, Hash, PartialEq, Eq, Clone)]
95 | #[cfg_attr(test, derive(Dummy))]
96 | pub struct Selectors(Vec);
97 |
98 | impl FromStr for Selectors {
99 | type Err = MultipleErrors;
100 | fn from_str(s: &str) -> Result {
101 | selector()
102 | .separated_by(just('|').padded())
103 | .collect::>()
104 | .then_ignore(end())
105 | .try_map(|selectors, span| {
106 | let selectors = Selectors(selectors);
107 | if selectors.is_global() && selectors.0.len() > 1 {
108 | Rich::custom(span, "global can not be mixed with an operating system").pipe(Err)
109 | } else {
110 | selectors.pipe(Ok)
111 | }
112 | })
113 | .parse(s)
114 | .into_result()
115 | .map_err(|e| MultipleErrors::from_chumsky(s, e))
116 | }
117 | }
118 |
119 | impl Selectors {
120 | pub fn is_global(&self) -> bool {
121 | self.0.iter().any(|f| f.os == os::Os::Global)
122 | }
123 |
124 | pub fn applies(&self, engine: &Engine, parameters: &Parameters) -> Result> {
125 | let mut errors = Vec::::new();
126 | let mut applies = false;
127 | for selector in &self.0 {
128 | if selector.os.is_global() || os::OS == selector.os {
129 | let mut all = true;
130 | for attribute in &selector.attributes {
131 | let value = match engine.render(&format!("{{{{ {} }}}}", &attribute.key), parameters) {
132 | Ok(v) => v,
133 | Err(e) => {
134 | errors.push(e);
135 | continue;
136 | }
137 | };
138 |
139 | if !match attribute.operator {
140 | Operator::Eq => value == attribute.value,
141 | Operator::StartsWith => value.starts_with(&attribute.value),
142 | Operator::EndsWith => value.ends_with(&attribute.value),
143 | Operator::Contains => value.contains(&attribute.value),
144 | Operator::NotEq => value != attribute.value,
145 | } {
146 | all = false;
147 | }
148 | }
149 | if all {
150 | applies = true;
151 | }
152 | }
153 | }
154 |
155 | if !errors.is_empty() {
156 | return Err(errors);
157 | }
158 |
159 | Ok(applies)
160 | }
161 | }
162 |
163 | #[derive(Error, Debug, Diagnostic)]
164 | #[error("{reason}")]
165 | #[diagnostic(code(parsing::selector::error))]
166 | struct SelectorError {
167 | #[source_code]
168 | src: String,
169 | #[label(collection, "error happened here")]
170 | labels: Vec,
171 | reason: String,
172 | }
173 |
174 | impl MultipleErrors {
175 | fn from_chumsky(selector: &str, errors: Vec>) -> Self {
176 | MultipleErrors::from(
177 | errors
178 | .into_iter()
179 | .map(|e| {
180 | let (reason, labels) = match e.reason() {
181 | chumsky::error::RichReason::ExpectedFound { expected, found } => (
182 | found.map_or_else(|| "Selector ended unexpectedly".to_owned(), |f| format!("unexpected input: {f:?}")),
183 | expected
184 | .iter()
185 | .map(|p| LabeledSpan::new_with_span(Some(format!("expected one of: {p}")), e.span().into_range()))
186 | .collect(),
187 | ),
188 | chumsky::error::RichReason::Custom(c) => (c.clone(), vec![LabeledSpan::new_with_span(None, e.span().into_range())]),
189 | };
190 |
191 | SelectorError {
192 | src: selector.to_owned(),
193 | reason,
194 | labels,
195 | }
196 | })
197 | .collect::>(),
198 | )
199 | }
200 | }
201 |
202 | #[cfg(test)]
203 | mod test {
204 | use super::{Attribute, Operator, Selector, Selectors};
205 | use crate::helpers::os;
206 | use crate::os::Os;
207 | use chumsky::{Parser, prelude::end};
208 | use rstest::rstest;
209 | use speculoos::assert_that;
210 |
211 | #[rstest]
212 | #[case("\"test\"", "test")]
213 | #[case("\"tes444t\"", "tes444t")]
214 | #[case("\"tes44 sf sdf \\\"sdf\\n dfg \\t g \\b \\f \\r \\/ \\\\4t\"", "tes44 sf sdf \"sdf\n dfg \t g \x08 \x0C \r / \\4t")]
215 | fn string_parser(#[case] from: &str, #[case] expected: &str) {
216 | let parsed = super::string().then_ignore(end()).parse(from).unwrap();
217 | assert_that!(parsed.as_str()).is_equal_to(expected);
218 | }
219 |
220 | #[rstest]
221 | #[case("test")]
222 | #[case("test.tt")]
223 | #[case("test.t04.e")]
224 | fn path_parser(#[case] from: &str) {
225 | let parsed = super::path().then_ignore(end()).parse(from).unwrap();
226 | assert_that!(parsed.as_str()).is_equal_to(from);
227 | }
228 |
229 | #[rstest]
230 | #[case("=", Operator::Eq)]
231 | #[case("^=", Operator::StartsWith)]
232 | #[case("$=", Operator::EndsWith)]
233 | #[case("*=", Operator::Contains)]
234 | #[case("!=", Operator::NotEq)]
235 | fn operator_parser(#[case] from: &str, #[case] expected: Operator) {
236 | let parsed = super::operator().then_ignore(end()).parse(from).unwrap();
237 | assert_that!(parsed).is_equal_to(expected);
238 | }
239 |
240 | #[rstest]
241 | #[case("test=\"value\"", Attribute { key: "test".to_owned(), operator: Operator::Eq, value: "value".to_owned() })]
242 | #[case("test.test=\"value\"", Attribute { key: "test.test".to_owned(), operator: Operator::Eq, value: "value".to_owned() })]
243 | #[case("test =\"value\"", Attribute { key: "test".to_owned(), operator: Operator::Eq, value: "value".to_owned() })]
244 | #[case("test= \"value\"", Attribute { key: "test".to_owned(), operator: Operator::Eq, value: "value".to_owned() })]
245 | #[case("test = \"value\"", Attribute { key: "test".to_owned(), operator: Operator::Eq, value: "value".to_owned() })]
246 | #[case("test^=\"value\"", Attribute { key: "test".to_owned(), operator: Operator::StartsWith, value: "value".to_owned() })]
247 | #[case("test $=\"value\"", Attribute { key: "test".to_owned(), operator: Operator::EndsWith, value: "value".to_owned() })]
248 | #[case("test*= \"value\"", Attribute { key: "test".to_owned(), operator: Operator::Contains, value: "value".to_owned() })]
249 | #[case("test != \"value\"", Attribute { key: "test".to_owned(), operator: Operator::NotEq, value: "value".to_owned() })]
250 | fn attribute_parser(#[case] from: &str, #[case] expected: Attribute) {
251 | let parsed = super::attribute().then_ignore(end()).parse(from).unwrap();
252 | assert_that!(parsed).is_equal_to(expected);
253 | }
254 |
255 | #[rstest]
256 | #[case("[test=\"value\"]", vec![Attribute { key: "test".to_owned(), operator: Operator::Eq, value: "value".to_owned() }])]
257 | #[case("[test=\"value\"][test=\"value\"]", vec![Attribute { key: "test".to_owned(), operator: Operator::Eq, value: "value".to_owned() }, Attribute { key: "test".to_owned(), operator: Operator::Eq, value: "value".to_owned() }])]
258 | #[case("[test=\"value\"][test=\"value\"]", vec![Attribute { key: "test".to_owned(), operator: Operator::Eq, value: "value".to_owned() }, Attribute { key: "test".to_owned(), operator: Operator::Eq, value: "value".to_owned() }])]
259 | #[case("[test=\"value\"][test=\"value\"]", vec![Attribute { key: "test".to_owned(), operator: Operator::Eq, value: "value".to_owned() }, Attribute { key: "test".to_owned(), operator: Operator::Eq, value: "value".to_owned() }])]
260 | fn attributes_parser(#[case] from: &str, #[case] expected: Vec) {
261 | let parsed = super::attributes().then_ignore(end()).parse(from).unwrap();
262 | assert_that!(parsed).is_equal_to(expected);
263 | }
264 |
265 | #[rstest]
266 | #[case("windows", Os::Windows)]
267 | #[case("linux", Os::Linux)]
268 | #[case("darwin", Os::Darwin)]
269 | #[case("global", Os::Global)]
270 | fn os_parser(#[case] from: &str, #[case] expected: os::Os) {
271 | let parsed = super::os().then_ignore(end()).parse(from).unwrap();
272 | assert_that!(parsed).is_equal_to(expected);
273 | }
274 |
275 | #[rstest]
276 | #[case("windows", Selector { os: Os::Windows, attributes: vec![] })]
277 | #[case("global[test=\"some\"][test=\"some\"]", Selector { os: Os::Global, attributes: vec![
278 | Attribute {
279 | key: String::from("test"),
280 | operator: Operator::Eq,
281 | value: String::from("some")
282 | },
283 | Attribute {
284 | key: String::from("test"),
285 | operator: Operator::Eq,
286 | value: String::from("some")
287 | }
288 | ]})]
289 | #[case("linux[whoami.distribution^=\"some\"]", Selector { os: Os::Linux, attributes: vec![
290 | Attribute {
291 | key: String::from("whoami.distribution"),
292 | operator: Operator::StartsWith,
293 | value: String::from("some")
294 | }
295 | ]})]
296 | fn selector_parser(#[case] from: &str, #[case] expected: Selector) {
297 | let parsed = super::selector().then_ignore(end()).parse(from).unwrap();
298 | assert_that!(parsed).named(from).is_equal_to(expected);
299 | }
300 |
301 | #[rstest]
302 | #[case("windows", vec![Selector { os: Os::Windows, attributes: vec![] }])]
303 | #[case("windows|linux", vec![Selector { os: Os::Windows, attributes: vec![] }, Selector { os: Os::Linux, attributes: vec![] }])]
304 | #[case("darwin|linux", vec![Selector { os: Os::Darwin, attributes: vec![] }, Selector { os: Os::Linux, attributes: vec![] }])]
305 | #[case("linux[whoami.distribution^=\"some\"]", vec![Selector { os: Os::Linux, attributes: vec![
306 | Attribute {
307 | key: String::from("whoami.distribution"),
308 | operator: Operator::StartsWith,
309 | value: String::from("some")
310 | }
311 | ]}])]
312 | #[case("global[whoami.distribution$=\"some\"][test=\"other\"]", vec![Selector { os: Os::Global, attributes: vec![
313 | Attribute {
314 | key: String::from("whoami.distribution"),
315 | operator: Operator::EndsWith,
316 | value: String::from("some")
317 | },
318 | Attribute {
319 | key: String::from("test"),
320 | operator: Operator::Eq,
321 | value: String::from("other")
322 | }
323 | ] }])]
324 | #[case("linux[whoami.distribution^=\"some\"]|windows[whoami.distribution$=\"some\"][test=\"other\"]", vec![Selector { os: Os::Linux, attributes: vec![
325 | Attribute {
326 | key: String::from("whoami.distribution"),
327 | operator: Operator::StartsWith,
328 | value: String::from("some")
329 | }
330 | ] },Selector { os: Os::Windows, attributes: vec![
331 | Attribute {
332 | key: String::from("whoami.distribution"),
333 | operator: Operator::EndsWith,
334 | value: String::from("some")
335 | },
336 | Attribute {
337 | key: String::from("test"),
338 | operator: Operator::Eq,
339 | value: String::from("other")
340 | }
341 | ] }])]
342 | fn selector_deserialization(#[case] from: &str, #[case] selector: Vec) {
343 | use std::str::FromStr;
344 |
345 | let parsed = Selectors::from_str(from).unwrap();
346 |
347 | assert_that!(parsed).named(from).is_equal_to(Selectors(selector));
348 | }
349 |
350 | #[rstest]
351 | #[case("windows[")]
352 | #[case("windows[]")]
353 | #[case("windows[test=]")]
354 | #[case("windows[test=\"test\"")]
355 | #[case("windows[test=\"]")]
356 | #[case("windows[999=\"\"]")]
357 | #[case("windows[999##=\"\"]")]
358 | #[case("windows test=\"\"]")]
359 | fn errors(#[case] from: &str) {
360 | use std::str::FromStr;
361 |
362 | Selectors::from_str(from).unwrap_err();
363 | }
364 | }
365 |
--------------------------------------------------------------------------------