diff --git a/Cargo.toml b/Cargo.toml index c84533c..8e893b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,8 @@ version = "0.1.0" edition = "2021" [dependencies] +anyhow = "1.0.95" clap = { version = "4.5.21", features = ["derive"] } md5 = "0.7.0" serde_json = "1.0.134" +serde_yaml = "0.9.34" diff --git a/README.md b/README.md index feaaf14..2c2860f 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,20 @@ # checksum -A simple binary that computes checksums for JSON files regardless of key ordering. + +A simple binary that computes checksums for +JSON and YAML files regardless of key ordering. + +## Usage + +To install run: `cargo install --path .` + +```sh +checksum -p +``` + +```sh +checksum '{"a": 1, "b": 2}' -t json +``` + +TODOS: + +- [ ] Add workflow to handle versioned releases (dutchiebot-releases) diff --git a/src/digest.rs b/src/digest.rs index f75bacc..16fb4f4 100644 --- a/src/digest.rs +++ b/src/digest.rs @@ -1,12 +1,14 @@ +use anyhow::Result; use md5::Digest; use serde_json::Value; +use serde_yaml; use std::collections::BTreeMap; -fn sort_json(value: &mut Value) { +fn sort(value: &mut Value) { match value { Value::Array(arr) => { for val in arr.iter_mut() { - sort_json(val); + sort(val); } arr.sort_by_key(|a| a.to_string()); } @@ -14,7 +16,7 @@ fn sort_json(value: &mut Value) { let mut sorted_map = BTreeMap::new(); for (key, val) in map.iter_mut() { let mut val = val.take(); - sort_json(&mut val); + sort(&mut val); sorted_map.insert(key.clone(), val); } *map = sorted_map.into_iter().collect(); @@ -24,53 +26,47 @@ fn sort_json(value: &mut Value) { } } -fn compute_json_digest(s: &str) -> Digest { - let mut json: Value = serde_json::from_str(s).unwrap(); - sort_json(&mut json); - md5::compute(json.to_string()) +fn compute_json_digest(s: &str) -> Result { + let json: Result = serde_json::from_str(s); + match json { + Ok(mut data) => { + sort(&mut data); + Ok(md5::compute(data.to_string())) + } + Err(_) => Err(anyhow::anyhow!("Unable to parse json contents"))?, + } } -pub fn compute_digest(s: &str, ext: Option<&str>) -> Digest { - match ext { - Some("json") => compute_json_digest(s), - _ => panic!("Unsupported file extension: {:?}", ext), +fn compute_yaml_digest(s: &str) -> Result { + let yaml: Result = serde_yaml::from_str(s); + match yaml { + Ok(mut data) => { + sort(&mut data); + Ok(md5::compute(data.to_string())) + } + Err(_) => Err(anyhow::anyhow!("Unable to parse yaml contents"))?, } } -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn it_works() { - let a = compute_json_digest( - r#" - {"a": 1, "b": 2} - "#, - ); - let b = compute_json_digest( - r#" - {"b": 2, "a": 1} - "#, - ); - assert_eq!(a, b); - let a = compute_json_digest( - r#" - [ - {"foo": 1, "bar": 2}, - {"baz": 3, "bop": 4}, - {"zip": 5, "zap": 6} - ] - "#, - ); - let b = compute_json_digest( - r#" - [ - {"baz": 3, "bop": 4}, - {"zip": 5, "zap": 6}, - {"foo": 1, "bar": 2} - ] - "#, - ); - assert_eq!(a, b); +/// # Example +/// ``` +/// use checksum::digest::compute_digest; +/// use md5::Digest; +/// // regardless of the order of the keys, the digest should be the same +/// let digest_a: Digest = compute_digest("{\"a\": 1, \"b\": 2}", Some("json")).unwrap(); +/// let digest_b: Digest = compute_digest("{\"b\": 2, \"a\": 1}", Some("json")).unwrap(); +/// assert_eq!(digest_a, digest_b); +/// ``` +pub fn compute_digest(s: &str, ext: Option<&str>) -> Result { + match ext { + Some("json") => compute_json_digest(s), + Some("yaml") | Some("yml") => compute_yaml_digest(s), + _ => { + if let Some(ext) = ext { + panic!("Unsupported file type, {:?}", ext); + } else { + panic!("No file extension provided"); + } + } } } diff --git a/src/main.rs b/src/main.rs index 0db18d4..af082d3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,21 +1,62 @@ +use anyhow::Result; use checksum::digest::compute_digest; use clap::Parser; -use std::path::PathBuf; +use std::io::{self, IsTerminal, Read}; +use std::path::{Path, PathBuf}; -#[derive(Parser)] +#[derive(Debug, Parser)] #[command(version, about, long_about = None)] struct Cli { + /// Path to file to compute digest for #[arg(short, long, value_name = "FILE")] path: Option, + /// Input string to compute digest for + #[arg(short, long, value_name = "INPUT")] + input: Option, + /// File type of supplied input. Supported file extensions: json, yaml, yml + #[arg(short, long, value_name = "FILETYPE")] + r#type: Option, } -fn main() { - let cli = Cli::parse(); +fn handle_path(path: &Path) -> Result<(), anyhow::Error> { + if let Some(path_ext) = path.extension() { + let file_contents = std::fs::read_to_string(path)?; + let digest = compute_digest(&file_contents, path_ext.to_str())?; + println!("{:?}", digest); + Ok(()) + } else { + Err(anyhow::anyhow!( + "Unable to extract file extension for {:?}", + path + ))? + } +} + +fn handle_input(input: &str, filetype: Option<&str>) -> Result<(), anyhow::Error> { + let digest = compute_digest(input, filetype)?; + println!("{:?}", digest); + Ok(()) +} + +fn handle_terminal(filetype: Option<&str>) -> Result<(), anyhow::Error> { + let mut buffer = String::new(); + io::stdin().read_to_string(&mut buffer)?; + let digest = compute_digest(buffer.trim(), filetype)?; + println!("{:?}", digest); + Ok(()) +} +fn main() -> Result<()> { + let cli = Cli::parse(); if let Some(path) = cli.path.as_deref() { - let path_ext = path.extension().unwrap(); - let file_contents = std::fs::read_to_string(path).unwrap(); - let digest = compute_digest(&file_contents, path_ext.to_str()); - println!("{:?}", digest); + handle_path(path) + } else if let Some(input) = cli.input.as_deref() { + handle_input(input, cli.r#type.as_deref()) + } else if !io::stdin().is_terminal() { + handle_terminal(cli.r#type.as_deref()) + } else { + Err(anyhow::anyhow!( + "Unable to output digest with supplied command line args" + ))? } } diff --git a/data.json b/tests/data.json similarity index 100% rename from data.json rename to tests/data.json diff --git a/tests/data.yaml b/tests/data.yaml new file mode 100644 index 0000000..13e7365 --- /dev/null +++ b/tests/data.yaml @@ -0,0 +1,27 @@ +--- +up: + - uv venv && uv sync && echo "$(pwd)/.venv/bin" >> .bolt/.path + - echo "KUBECONFIG=$(pwd)/configs/kubeconfig" >> .bolt/.env + +cmds: + ci: + desc: Bolt steps to run in CI + steps: + - cmd: verify + - cmd: lint + + lint: + desc: Lint all Python scripts + steps: + - cmd: lint.yaml + - cmd: lint.python + cmds: + yaml: + steps: + - cmd: format.yaml + vars: { lint: true } + python: + steps: + - uv run ruff check scripts/ + - cmd: format.python + vars: { lint: true } diff --git a/tests/unit.rs b/tests/unit.rs new file mode 100644 index 0000000..385124f --- /dev/null +++ b/tests/unit.rs @@ -0,0 +1,98 @@ +#[cfg(test)] +mod tests { + use checksum::digest::compute_digest; + + #[test] + fn compute_json_digest_tests() { + let a = compute_digest( + r#" + {"a": 1, "b": 2} + "#, + Some("json"), + ) + .unwrap(); + let b = compute_digest( + r#" + {"b": 2, "a": 1} + "#, + Some("json"), + ) + .unwrap(); + assert_eq!(a, b); + let a = compute_digest( + r#" + [ + {"foo": 1, "bar": 2}, + {"baz": 3, "bop": 4}, + {"zip": 5, "zap": 6} + ] + "#, + Some("json"), + ) + .unwrap(); + let b = compute_digest( + r#" + [ + {"baz": 3, "bop": 4}, + {"zip": 5, "zap": 6}, + {"foo": 1, "bar": 2} + ] + "#, + Some("json"), + ) + .unwrap(); + assert_eq!(a, b); + } + + #[test] + fn compute_yaml_digest_tests() { + let a = compute_digest( + "apiVersion: v1\nkind: Pod\nmetadata:\n name: my-pod\nspec:\n containers:\n - name: my-container\n image: nginx\n" + , Some("yaml")).unwrap(); + let b = compute_digest( + "spec:\n containers:\n - name: my-container\n image: nginx\nkind: Pod\napiVersion: v1\nmetadata:\n name: my-pod\n", Some("yaml") + ).unwrap(); + assert_eq!(a, b); + let a = compute_digest( + r#" +apiVersion: v1 +kind: Pod +metadata: + name: example-pod + labels: + app: example +spec: + containers: + - name: example-container + image: nginx:latest + command: | + echo 'Starting...' + echo 'Hello, Kubernetes!' + echo 'Pod initialization complete.' +"#, + Some("yaml"), + ) + .unwrap(); + let b = compute_digest( + r#" +kind: Pod +apiVersion: v1 +spec: + containers: + - name: example-container + command: | + echo 'Starting...' + echo 'Hello, Kubernetes!' + echo 'Pod initialization complete.' + image: nginx:latest +metadata: + name: example-pod + labels: + app: example +"#, + Some("yaml"), + ) + .unwrap(); + assert_eq!(a, b); + } +}