diff --git a/Cargo.lock b/Cargo.lock index 6532bce..e556ecf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -245,6 +245,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "either" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" + [[package]] name = "encode_unicode" version = "0.3.6" @@ -341,6 +347,15 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.10" @@ -382,6 +397,21 @@ version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" +[[package]] +name = "minijinja" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe0ff215195a22884d867b547c70a0c4815cbbcc70991f281dca604b20d10ce" +dependencies = [ + "serde", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.7.1" @@ -391,6 +421,16 @@ dependencies = [ "adler", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "object" version = "0.32.2" @@ -474,6 +514,21 @@ dependencies = [ "walkdir", ] +[[package]] +name = "punktf-lib-gsgh" +version = "0.1.0" +dependencies = [ + "cfg-if", + "log", + "minijinja", + "semver", + "serde", + "serde_yaml", + "thiserror", + "version-compare", + "versions", +] + [[package]] name = "quote" version = "1.0.35" @@ -565,6 +620,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "semver" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97ed7a9823b74f99c7742f5336af7be5ecd3eeafcb1507d1fa93347b1d589b0" +dependencies = [ + "serde", +] + [[package]] name = "serde" version = "1.0.195" @@ -701,6 +765,23 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +[[package]] +name = "version-compare" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579a42fc0b8e0c63b76519a339be31bed574929511fa53c1a3acae26eb258f29" + +[[package]] +name = "versions" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c73a36bc44e3039f51fbee93e39f41225f6b17b380eb70cc2aab942df06b34dd" +dependencies = [ + "itertools", + "nom", + "serde", +] + [[package]] name = "walkdir" version = "2.4.0" diff --git a/Cargo.toml b/Cargo.toml index 035ea4a..0f41b4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,10 +38,6 @@ opt-level = 0 # and is only used when debugging. debug = 1 -[profile.dev.package.backtrace] -# color-eyre: Improves performance for debug builds -opt-level = 3 - [profile.release] lto = "thin" # Optimize for binary size. In this case also turns out to be the fastest to diff --git a/crates/punktf-lib-gsgh/Cargo.toml b/crates/punktf-lib-gsgh/Cargo.toml new file mode 100644 index 0000000..0bd4258 --- /dev/null +++ b/crates/punktf-lib-gsgh/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "punktf-lib-gsgh" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +keywords.workspace = true + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +cfg-if = "1.0.0" +log.workspace = true +minijinja = { version = "1.0.6", default-features = false, features = ["builtins", "adjacent_loop_items", "debug", "deserialization"] } +semver = { version = "1.0.18", features = ["serde"] } +serde = { workspace = true, features = ["derive"] } +serde_yaml = "0.9.25" +thiserror.workspace = true +version-compare = "0.1.1" +versions = { version = "5.0.0", features = ["serde"] } diff --git a/crates/punktf-lib-gsgh/profile.yaml b/crates/punktf-lib-gsgh/profile.yaml new file mode 100644 index 0000000..794f095 --- /dev/null +++ b/crates/punktf-lib-gsgh/profile.yaml @@ -0,0 +1,47 @@ +version: 1.0.0 + +aliases: + - Foo + - Bar + +extends: + - Parent + +env: + Foo: Bar + Bool: true + Number: 2.4 + +transformers: + - type: line_terminator + with: LF + +target: /tmp + +pre_hooks: + type: inline + with: | + set -eoux pipefail + echo 'Foo' + +items: + - priority: 5 + env: + Foo: Bar + Bool: true + pre_hook: + type: inline + with: |- + set -eoux pipefail + echo 'Foo' + path: /dev/null + merge: + type: hook + with: + type: inline + with: | + #!/usr/bin/env bash + + set -eoux pipefail + + echo "test" diff --git a/crates/punktf-lib-gsgh/src/env.rs b/crates/punktf-lib-gsgh/src/env.rs new file mode 100644 index 0000000..22d32d1 --- /dev/null +++ b/crates/punktf-lib-gsgh/src/env.rs @@ -0,0 +1,80 @@ +use std::{ + collections::{btree_set, BTreeMap, BTreeSet}, +}; + +use serde::{Deserialize, Serialize}; + +use crate::value::Value; + +#[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Environment(pub BTreeMap); + +impl Environment { + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +#[derive(Default, Debug, Clone, PartialEq)] +pub struct LayeredEnvironment(Vec<(&'static str, Environment)>); + +impl LayeredEnvironment { + pub fn push(&mut self, name: &'static str, env: Environment) { + self.0.push((name, env)); + } + + pub fn pop(&mut self) -> Option<(&'static str, Environment)> { + self.0.pop() + } + + pub fn keys(&self) -> BTreeSet<&str> { + self.0 + .iter() + .flat_map(|(_, layer)| layer.0.keys()) + .map(|key| key.as_str()) + .collect() + } + + pub fn get(&self, key: &str) -> Option<&Value> { + for (_, layer) in self.0.iter() { + if let Some(value) = layer.0.get(key) { + return Some(value); + } + } + + None + } + + pub fn iter(&self) -> LayeredIter<'_> { + LayeredIter::new(self) + } + + pub fn as_str_map(&self) -> BTreeMap<&str, String> { + self.iter() + // TODO: Optimize + // `trim` to remove trailing `\n` + .map(|(k, v)| (k, serde_yaml::to_string(v).unwrap().trim().into())) + .collect() + } +} + +pub struct LayeredIter<'a> { + env: &'a LayeredEnvironment, + keys: btree_set::IntoIter<&'a str>, +} + +impl<'a> LayeredIter<'a> { + pub fn new(env: &'a LayeredEnvironment) -> Self { + let keys = env.keys().into_iter(); + Self { env, keys } + } +} + +impl<'a> Iterator for LayeredIter<'a> { + type Item = (&'a str, &'a Value); + + fn next(&mut self) -> Option { + let key = self.keys.next()?; + Some((key, self.env.get(key)?)) + } +} diff --git a/crates/punktf-lib-gsgh/src/hook.rs b/crates/punktf-lib-gsgh/src/hook.rs new file mode 100644 index 0000000..8d5825c --- /dev/null +++ b/crates/punktf-lib-gsgh/src/hook.rs @@ -0,0 +1,173 @@ +use std::{ + io::{BufRead, BufReader}, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::env::LayeredEnvironment; + +// Have special syntax for skipping deployment on pre_hook +// Analog: +// e.g. punktf:skip_deployment + +#[derive(Error, Debug)] +pub enum HookError { + #[error("IO Error")] + IoError(#[from] std::io::Error), + + #[error("Process failed with status `{0}`")] + ExitStatusError(std::process::ExitStatus), +} + +impl From for HookError { + fn from(value: std::process::ExitStatus) -> Self { + Self::ExitStatusError(value) + } +} + +pub type Result = std::result::Result; + +// TODO: Replace once `exit_ok` becomes stable +trait ExitOk { + type Error; + + fn exit_ok(self) -> Result<(), Self::Error>; +} + +impl ExitOk for std::process::ExitStatus { + type Error = HookError; + + fn exit_ok(self) -> Result<(), ::Error> { + if self.success() { + Ok(()) + } else { + Err(self.into()) + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "with", rename_all = "snake_case")] +pub enum Hook { + Inline(String), + File(PathBuf), +} + +impl Hook { + pub fn run(self, cwd: &Path, env: LayeredEnvironment) -> Result<()> { + let mut child = self + .prepare_command()? + .current_dir(cwd) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .envs(env.as_str_map()) + .spawn()?; + + // No need to call kill here as the program will immediately exit + // and thereby kill all spawned children + let stdout = child.stdout.take().expect("Failed to get stdout from hook"); + + for line in BufReader::new(stdout).lines() { + match line { + Ok(line) => println!("hook::stdout > {}", line), + Err(err) => { + // Result is explicitly ignored as an error was already + // encountered + let _ = child.kill(); + return Err(err.into()); + } + } + } + + // No need to call kill here as the program will immediately exit + // and thereby kill all spawned children + let stderr = child.stderr.take().expect("Failed to get stderr from hook"); + + for line in BufReader::new(stderr).lines() { + match line { + Ok(line) => println!("hook::stderr > {}", line), + Err(err) => { + // Result is explicitly ignored as an error was already + // encountered + let _ = child.kill(); + return Err(err.into()); + } + } + } + + child + .wait_with_output()? + .status + .exit_ok() + .map_err(Into::into) + } + + fn prepare_command(&self) -> Result { + #[allow(unused_assignments)] + let mut cmd = None; + + #[cfg(target_family = "windows")] + { + let mut c = Command::new("cmd"); + c.arg("/C"); + cmd = Some(c); + } + + #[cfg(target_family = "unix")] + { + let mut c = Command::new("sh"); + c.arg("-c"); + cmd = Some(c) + } + + let Some(mut cmd) = cmd else { + return Err(HookError::IoError(std::io::Error::new( + std::io::ErrorKind::Other, + "Hooks are only supported on Windows and Unix-based systems", + ))); + }; + + match self { + Self::Inline(s) => { + cmd.arg(s); + } + Self::File(path) => { + let s = std::fs::read_to_string(path)?; + cmd.arg(s); + } + } + + Ok(cmd) + } +} + +#[cfg(test)] +mod tests { + use crate::{env::Environment, value::Value}; + + use super::*; + + #[test] + fn echo_hello_world() { + let env = Environment( + [ + ("TEST", Value::Bool(true)), + ("FOO", Value::String(" BAR Test".into())), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(), + ); + + let mut lenv = LayeredEnvironment::default(); + lenv.push("test", env); + + println!("{:#?}", lenv.as_str_map()); + + let hook = Hook::Inline(r#"echo "Hello World""#.to_string()); + hook.run(Path::new("/tmp"), lenv).unwrap(); + } +} diff --git a/crates/punktf-lib-gsgh/src/item.rs b/crates/punktf-lib-gsgh/src/item.rs new file mode 100644 index 0000000..32e7d30 --- /dev/null +++ b/crates/punktf-lib-gsgh/src/item.rs @@ -0,0 +1,22 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +use crate::{merge::MergeMode, profile::Shared}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct Item { + #[serde(flatten)] + pub shared: Shared, + + pub path: PathBuf, + + #[serde(skip_serializing_if = "Option::is_none", default)] + pub rename: Option, + + #[serde(skip_serializing_if = "Option::is_none", default)] + pub overwrite_target: Option, + + #[serde(skip_serializing_if = "Option::is_none", default)] + pub merge: Option, +} diff --git a/crates/punktf-lib-gsgh/src/lib.rs b/crates/punktf-lib-gsgh/src/lib.rs new file mode 100644 index 0000000..49c3b40 --- /dev/null +++ b/crates/punktf-lib-gsgh/src/lib.rs @@ -0,0 +1,119 @@ +pub mod env; +pub mod hook; +pub mod item; +pub mod merge; +pub mod prio; +pub mod profile; +pub mod template; +pub mod transform; +pub mod value; +pub mod version; + +#[test] +#[ignore = "debugging"] +fn main() -> Result<(), Box> { + use std::str::FromStr; + + let profile = std::fs::read_to_string("profile.yaml")?; + let p = profile::Profile::from_str(&profile)?; + + println!("{p:#?}"); + + Ok(()) +} + +#[test] +#[ignore = "debugging"] +fn prnp() { + use crate::hook::Hook; + use crate::{item::Item, prio::Priority}; + use env::Environment; + use profile::{Profile, ProfileVersion}; + use std::path::PathBuf; + use transform::Transformer; + use value::Value; + + use crate::profile::Shared; + + let p = Profile { + version: ProfileVersion { + version: Profile::VERSION, + }, + aliases: vec!["Foo".into(), "Bar".into()], + extends: vec!["Parent".into()], + target: Some(PathBuf::from("Test")), + shared: Shared { + environment: Environment( + [ + ("Foo".into(), Value::String("Bar".into())), + ("Bool".into(), Value::Bool(true)), + ] + .into_iter() + .collect(), + ), + transformers: vec![Transformer::LineTerminator(transform::LineTerminator::LF)], + pre_hook: Some(Hook::Inline("set -eoux pipefail\necho 'Foo'".into())), + post_hook: Some(Hook::File("Test".into())), + priority: Some(Priority(5)), + }, + + items: vec![Item { + shared: Shared { + environment: Environment( + [ + ("Foo".into(), Value::String("Bar".into())), + ("Bool".into(), Value::Bool(true)), + ] + .into_iter() + .collect(), + ), + transformers: vec![Transformer::LineTerminator(transform::LineTerminator::LF)], + pre_hook: None, + post_hook: None, + priority: Some(Priority(5)), + }, + path: PathBuf::from("/dev/null"), + rename: None, + overwrite_target: None, + merge: None, + }], + }; + + serde_yaml::to_writer(std::io::stdout(), &p).unwrap(); +} + +#[test] +#[ignore = "debugging"] +fn prni() { + use crate::hook::Hook; + use crate::{item::Item, prio::Priority}; + use env::Environment; + use std::path::PathBuf; + use transform::Transformer; + use value::Value; + + use crate::profile::Shared; + + let i = Item { + shared: Shared { + environment: Environment( + [ + ("Foo".into(), Value::String("Bar".into())), + ("Bool".into(), Value::Bool(true)), + ] + .into_iter() + .collect(), + ), + transformers: vec![Transformer::LineTerminator(transform::LineTerminator::LF)], + pre_hook: Some(Hook::Inline("set -eoux pipefail\necho 'Foo'".into())), + post_hook: None, + priority: Some(Priority(5)), + }, + path: PathBuf::from("/dev/null"), + rename: None, + overwrite_target: None, + merge: None, + }; + + serde_yaml::to_writer(std::io::stdout(), &i).unwrap(); +} diff --git a/crates/punktf-lib-gsgh/src/merge.rs b/crates/punktf-lib-gsgh/src/merge.rs new file mode 100644 index 0000000..7200df4 --- /dev/null +++ b/crates/punktf-lib-gsgh/src/merge.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; + +use crate::hook::Hook; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "with", rename_all = "snake_case")] +pub enum MergeMode { + Hook(Hook), +} diff --git a/crates/punktf-lib-gsgh/src/prio.rs b/crates/punktf-lib-gsgh/src/prio.rs new file mode 100644 index 0000000..952469a --- /dev/null +++ b/crates/punktf-lib-gsgh/src/prio.rs @@ -0,0 +1,17 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Priority(pub u32); + +impl PartialOrd for Priority { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Priority { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + // Reverse sort ordering (smaller = higher) + other.0.cmp(&self.0) + } +} diff --git a/crates/punktf-lib-gsgh/src/profile.rs b/crates/punktf-lib-gsgh/src/profile.rs new file mode 100644 index 0000000..b4c895a --- /dev/null +++ b/crates/punktf-lib-gsgh/src/profile.rs @@ -0,0 +1,110 @@ +use crate::{ + env::Environment, hook::Hook, item::Item, prio::Priority, transform::Transformer, + version::Version, +}; +use std::{path::PathBuf, str::FromStr}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("invalid profile: {0}")] + InvalidProfile(#[from] serde_yaml::Error), + #[error("unsupported version: {0}")] + UnsupportedVersion(Version), +} + +pub type Result = std::result::Result; + +/// Wrapper struct to be able to first parse only the version and then choose +/// the appropriate profile struct for it to do version compatible parsing. +#[repr(transparent)] +#[derive(Debug, Deserialize, Serialize)] +#[serde(default)] +pub struct ProfileVersion { + pub version: Version, +} + +impl Default for ProfileVersion { + fn default() -> Self { + Self { + version: Version::ZERO, + } + } +} + +impl From for Version { + fn from(value: ProfileVersion) -> Self { + value.version + } +} + +impl AsRef for ProfileVersion { + fn as_ref(&self) -> &Version { + &self.version + } +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Shared { + #[serde(skip_serializing_if = "Option::is_none", default)] + pub priority: Option, + + #[serde(rename = "env", skip_serializing_if = "Environment::is_empty", default)] + pub environment: Environment, + + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub transformers: Vec, + + #[serde(skip_serializing_if = "Option::is_none", default)] + pub pre_hook: Option, + + #[serde(skip_serializing_if = "Option::is_none", default)] + pub post_hook: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct Profile { + #[serde(flatten)] + pub version: ProfileVersion, + + #[serde(flatten)] + pub shared: Shared, + + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub aliases: Vec, + + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub extends: Vec, + + #[serde(skip_serializing_if = "Option::is_none", default)] + pub target: Option, + + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub items: Vec, +} + +impl Profile { + pub const VERSION: Version = Version::new(1, 0, 0); +} + +impl FromStr for Profile { + type Err = Error; + + fn from_str(s: &str) -> Result { + let version: Version = serde_yaml::from_str::(s)?.version; + + // No version or explicit zero version + if version == Version::ZERO { + return Err(Error::UnsupportedVersion(version)); + } + + // Version matching + if Self::VERSION.compatible(version) { + serde_yaml::from_str(s).map_err(Into::into) + } else { + Err(Error::UnsupportedVersion(version)) + } + } +} diff --git a/crates/punktf-lib-gsgh/src/template.rs b/crates/punktf-lib-gsgh/src/template.rs new file mode 100644 index 0000000..b22cde0 --- /dev/null +++ b/crates/punktf-lib-gsgh/src/template.rs @@ -0,0 +1,101 @@ +use std::{collections::BTreeMap, io::Write, path::Path}; + +use crate::env::LayeredEnvironment; + +pub type Result> = std::result::Result; + +pub trait TemplateEngine { + fn render_to_write( + &mut self, + w: &mut dyn Write, + name: &str, + env: &LayeredEnvironment, + content: &str, + ) -> Result<()>; + + fn render(&mut self, name: &str, env: &LayeredEnvironment, content: &str) -> Result { + let mut buf = Vec::new(); + self.render_to_write(&mut buf, name, env, content)?; + Ok(String::from_utf8(buf)?) + } +} + +#[derive(Default)] +pub struct Registry(BTreeMap<&'static str, Box>); + +impl Registry { + pub fn register(&mut self, extension: &'static str, engine: E) { + self.0.insert(extension, Box::new(engine)); + } + + pub fn get_for_path(&mut self, path: &Path) -> Option<&mut dyn TemplateEngine> { + let ext = path.extension()?.to_str()?; + let r = self.0.get_mut(ext)?; + Some(r.as_mut()) + } +} + +pub mod mj { + use std::io::Write; + + use crate::{env::LayeredEnvironment, value}; + + use super::{Result, TemplateEngine}; + use minijinja::{value::StructObject, Environment, UndefinedBehavior, Value}; + + impl From for Value { + fn from(value: value::Value) -> Self { + match value { + value::Value::Null => Value::from(()), + value::Value::String(v) => Value::from(v), + value::Value::Bool(v) => Value::from(v), + value::Value::Float(v) => Value::from(v), + value::Value::Int(v) => Value::from(v), + } + } + } + + impl StructObject for LayeredEnvironment { + fn get_field(&self, name: &str) -> Option { + self.get(name).map(|v| v.clone().into()) + } + } + + pub struct MiniJinja; + + impl TemplateEngine for MiniJinja { + fn render_to_write( + &mut self, + w: &mut dyn Write, + name: &str, + ctx: &LayeredEnvironment, + content: &str, + ) -> Result<()> { + let mut env = Environment::new(); + // Error on undefined variables + env.set_undefined_behavior(UndefinedBehavior::Strict); + env.add_template(name, content)?; + + let tmpl = env.get_template(name)?; + let val = Value::from_struct_object(ctx.clone()); + tmpl.render_to_write(val, w)?; + + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry() { + let mut reg = Registry::default(); + reg.register("mjinja", mj::MiniJinja); + + let eng = reg.get_for_path(Path::new("/test/path/123/file.txt.mjinja")); + + assert!(eng.is_some()) + } +} diff --git a/crates/punktf-lib-gsgh/src/transform.rs b/crates/punktf-lib-gsgh/src/transform.rs new file mode 100644 index 0000000..c7322c9 --- /dev/null +++ b/crates/punktf-lib-gsgh/src/transform.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; + +pub trait Transform { + fn apply(&self, content: String) -> Result>; +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", content = "with", rename_all = "snake_case")] +pub enum Transformer { + /// Transformer which replaces line termination characters with either unix + /// style (`\n`) or windows style (`\r\b`). + LineTerminator(LineTerminator), +} + +/// Transformer which replaces line termination characters with either unix +/// style (`\n`) or windows style (`\r\b`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +pub enum LineTerminator { + /// Replaces all occurrences of `\r\n` with `\n` (unix style). + LF, + + /// Replaces all occurrences of `\n` with `\r\n` (windows style). + CRLF, +} diff --git a/crates/punktf-lib-gsgh/src/value.rs b/crates/punktf-lib-gsgh/src/value.rs new file mode 100644 index 0000000..d7b3504 --- /dev/null +++ b/crates/punktf-lib-gsgh/src/value.rs @@ -0,0 +1,197 @@ +#[derive(Debug, Clone, PartialEq, PartialOrd)] +pub enum Value { + Null, + String(String), + Bool(bool), + Float(f64), + Int(i64), +} + +impl From for Value { + fn from(value: String) -> Self { + Self::String(value) + } +} + +impl From<&str> for Value { + fn from(value: &str) -> Self { + Self::String(value.to_owned()) + } +} + +impl From for Value { + fn from(value: bool) -> Self { + Self::Bool(value) + } +} + +impl From<&bool> for Value { + fn from(value: &bool) -> Self { + Self::Bool(*value) + } +} + +impl> From> for Value { + fn from(value: Option) -> Self { + let Some(v) = value else { + return Value::Null; + }; + v.into() + } +} + +impl From<()> for Value { + fn from(_: ()) -> Self { + Self::Null + } +} + +pub mod ser { + use super::Value; + use serde::ser; + + impl ser::Serialize for Value { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Value::Null => serializer.serialize_unit(), + Value::String(v) => serializer.serialize_str(v), + Value::Bool(v) => serializer.serialize_bool(*v), + Value::Float(v) => serializer.serialize_f64(*v), + Value::Int(v) => serializer.serialize_i64(*v), + } + } + } +} + +pub mod de { + use serde::de; + + use super::Value; + + impl<'de> de::Deserialize<'de> for Value { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct ValueVisitor; + + impl<'de> de::Visitor<'de> for ValueVisitor { + type Value = Value; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("any literal value") + } + + fn visit_i8(self, v: i8) -> Result + where + E: de::Error, + { + Ok(Value::Int(v as i64)) + } + + fn visit_i16(self, v: i16) -> Result + where + E: de::Error, + { + Ok(Value::Int(v as i64)) + } + + fn visit_i32(self, v: i32) -> Result + where + E: de::Error, + { + Ok(Value::Int(v as i64)) + } + + fn visit_i64(self, v: i64) -> Result + where + E: de::Error, + { + Ok(Value::Int(v)) + } + + fn visit_u8(self, v: u8) -> Result + where + E: de::Error, + { + Ok(Value::Int(v as i64)) + } + + fn visit_u16(self, v: u16) -> Result + where + E: de::Error, + { + Ok(Value::Int(v as i64)) + } + + fn visit_u32(self, v: u32) -> Result + where + E: de::Error, + { + Ok(Value::Int(v as i64)) + } + + fn visit_u64(self, v: u64) -> Result + where + E: de::Error, + { + Ok(Value::Int(v as i64)) + } + + fn visit_f32(self, v: f32) -> Result + where + E: de::Error, + { + Ok(Value::Float(v as f64)) + } + + fn visit_f64(self, v: f64) -> Result + where + E: de::Error, + { + Ok(Value::Float(v)) + } + + fn visit_str(self, v: &str) -> Result + where + E: de::Error, + { + Ok(Value::String(v.to_owned())) + } + + fn visit_string(self, v: String) -> Result + where + E: de::Error, + { + Ok(Value::String(v)) + } + + fn visit_bool(self, v: bool) -> Result + where + E: de::Error, + { + Ok(Value::Bool(v)) + } + + fn visit_none(self) -> Result + where + E: de::Error, + { + Ok(Value::Null) + } + + fn visit_unit(self) -> Result + where + E: de::Error, + { + Ok(Value::Null) + } + } + + deserializer.deserialize_any(ValueVisitor) + } + } +} diff --git a/crates/punktf-lib-gsgh/src/version.rs b/crates/punktf-lib-gsgh/src/version.rs new file mode 100644 index 0000000..17c63c3 --- /dev/null +++ b/crates/punktf-lib-gsgh/src/version.rs @@ -0,0 +1,264 @@ +use serde::{de::Visitor, Deserialize, Deserializer, Serialize}; +use std::{fmt, num::ParseIntError, str::FromStr}; +use thiserror::Error; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] +pub enum ParseVersionError { + #[error("trailing characters")] + TrailingCharacters, + #[error("invalid number")] + InvalidNumber, + #[error("empty")] + Empty, + #[error("invalid separator")] + InvalidSeparator, +} + +impl From for ParseVersionError { + fn from(_: ParseIntError) -> Self { + Self::InvalidNumber + } +} + +pub type Result = std::result::Result; + +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct Version { + pub major: u8, + pub minor: u8, + pub patch: u8, +} + +impl fmt::Display for Version { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self { + major, + minor, + patch, + } = self; + + write!(f, "{major}.{minor}.{patch}") + } +} + +impl Version { + pub const ZERO: Self = Version { + major: 0, + minor: 0, + patch: 0, + }; + + pub const fn new(major: u8, minor: u8, patch: u8) -> Self { + Self { + major, + minor, + patch, + } + } + + pub const fn with_major(mut self, major: u8) -> Self { + self.major = major; + self + } + + pub const fn with_minor(mut self, minor: u8) -> Self { + self.minor = minor; + self + } + + pub const fn with_patch(mut self, patch: u8) -> Self { + self.patch = patch; + self + } + + pub const fn compatible(self, other: Self) -> bool { + self.major == other.major + } +} + +fn parse_u8(s: &str) -> Result> { + fn check_digit(bytes: &[u8], idx: usize) -> bool { + bytes.get(idx).map(u8::is_ascii_digit).unwrap_or(false) + } + + if s.is_empty() { + return Ok(None); + } + + let bytes = s.as_bytes(); + + // u8 can be max 3 digits (255) + let eat = match ( + check_digit(bytes, 0), + check_digit(bytes, 1), + check_digit(bytes, 2), + ) { + (true, true, true) => 3, + (true, true, _) => 2, + (true, _, _) => 1, + _ => return Err(ParseVersionError::InvalidNumber), + }; + + Ok(Some((&s[eat..], s[..eat].parse::()?))) +} + +fn parse_dot(s: &str) -> Result> { + if s.is_empty() { + return Ok(None); + } + + if s.as_bytes()[0] == b'.' { + Ok(Some(&s[1..])) + } else { + Err(ParseVersionError::InvalidSeparator) + } +} + +impl FromStr for Version { + type Err = ParseVersionError; + + fn from_str(s: &str) -> Result { + let Some((s, major)) = parse_u8(s)? else { + return Err(ParseVersionError::Empty); + }; + + let Some(s) = parse_dot(s)? else { + return Ok(Version { + major, + ..Default::default() + }); + }; + + let Some((s, minor)) = parse_u8(s)? else { + // The parse `.` is trailing + return Err(ParseVersionError::TrailingCharacters); + }; + + let Some(s) = parse_dot(s)? else { + return Ok(Version { + major, + minor, + ..Default::default() + }); + }; + + let Some((s, patch)) = parse_u8(s)? else { + // The parse `.` is trailing + return Err(ParseVersionError::TrailingCharacters); + }; + + if s.is_empty() { + Ok(Version { + major, + minor, + patch, + }) + } else { + Err(ParseVersionError::TrailingCharacters) + } + } +} + +impl Serialize for Version { + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for Version { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct VersionVisitor; + + impl<'de> Visitor<'de> for VersionVisitor { + type Value = Version; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("semver version") + } + + fn visit_str(self, string: &str) -> Result + where + E: serde::de::Error, + { + string.parse().map_err(serde::de::Error::custom) + } + } + + deserializer.deserialize_str(VersionVisitor) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn version_parse_ok() -> Result<(), Box> { + assert_eq!("1".parse::()?, Version::ZERO.with_major(1)); + assert_eq!( + "22.12".parse::()?, + Version::ZERO.with_major(22).with_minor(12) + ); + assert_eq!( + "0.12.55".parse::()?, + Version::ZERO.with_minor(12).with_patch(55) + ); + Ok(()) + } + + #[test] + fn version_parse_err() -> Result<(), Box> { + assert_eq!( + "1.".parse::(), + Err(ParseVersionError::TrailingCharacters) + ); + assert_eq!("".parse::(), Err(ParseVersionError::Empty)); + assert_eq!( + "1.1.1 ".parse::(), + Err(ParseVersionError::TrailingCharacters) + ); + assert_eq!( + "1.1.1.".parse::(), + Err(ParseVersionError::TrailingCharacters) + ); + assert_eq!( + "1.1.1.1".parse::(), + Err(ParseVersionError::TrailingCharacters) + ); + assert_eq!( + "256".parse::(), + Err(ParseVersionError::InvalidNumber) + ); + + Ok(()) + } + + #[test] + fn version_cmp() { + assert!(Version::ZERO.with_major(1) == Version::ZERO.with_major(1)); + assert!(Version::ZERO.with_minor(2) == Version::ZERO.with_minor(2)); + assert!(Version::ZERO.with_patch(3) == Version::ZERO.with_patch(3)); + + assert!(Version::ZERO.with_major(1) < Version::ZERO.with_major(2)); + assert!(Version::ZERO.with_minor(2) < Version::ZERO.with_major(2)); + assert!(Version::ZERO.with_patch(3) < Version::ZERO.with_major(2)); + + assert!( + Version { + major: 2, + minor: 3, + patch: 1 + } > Version { + major: 2, + minor: 1, + patch: 10 + } + ); + } +}