diff --git a/.github/workflows/ci-coverage.yml b/.github/workflows/ci-coverage.yml index 6ba7b93..6243315 100644 --- a/.github/workflows/ci-coverage.yml +++ b/.github/workflows/ci-coverage.yml @@ -136,12 +136,12 @@ jobs: env: COVERAGE: ${{ steps.coverage.outputs.percentage }} run: | - THRESHOLD=80 + THRESHOLD=90 if (( $(echo "$COVERAGE < $THRESHOLD" | bc -l) )) then - echo "Coverage ${COVERAGE}% is below ${THRESHOLD}%" - echo "Consider adding more tests." + echo "Coverage ${COVERAGE}% is below ${THRESHOLD}% — failing CI." + exit 1 else echo "Coverage ${COVERAGE}% meets ${THRESHOLD}%" fi diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 3038cdc..d953910 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -198,7 +198,7 @@ jobs: run: | set -euo pipefail HASHES=$(sha256sum \ - target/release/rust-template \ + target/release/rust_template \ | base64 -w0) echo "hashes=${HASHES}" >> "$GITHUB_OUTPUT" diff --git a/Dockerfile b/Dockerfile index 434d594..d110f8a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -39,7 +39,7 @@ USER nonroot:nonroot # Health check HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ - CMD ["/usr/local/bin/rust_template", "--version"] + CMD ["/usr/local/bin/rust_template"] # Run the binary ENTRYPOINT ["/usr/local/bin/rust_template"] diff --git a/README.md b/README.md index 2dd9cf9..2c07985 100644 --- a/README.md +++ b/README.md @@ -45,11 +45,11 @@ use rust_template::{add, divide, Config}; fn main() -> Result<(), rust_template::Error> { // Basic arithmetic let sum = add(2, 3); - println!("2 + 3 = {}", sum); + println!("2 + 3 = {sum}"); // Safe division with error handling let quotient = divide(10, 2)?; - println!("10 / 2 = {}", quotient); + println!("10 / 2 = {quotient}"); // Using configuration builder let config = Config::new() @@ -58,7 +58,7 @@ fn main() -> Result<(), rust_template::Error> { .with_timeout(60); println!("Config: verbose={}, retries={}, timeout={}s", - config.verbose, config.max_retries, config.timeout_secs); + config.verbose(), config.max_retries(), config.timeout_secs()); Ok(()) } diff --git a/crates/lib.rs b/crates/lib.rs index 574119d..81e0515 100644 --- a/crates/lib.rs +++ b/crates/lib.rs @@ -4,6 +4,7 @@ use thiserror::Error; /// Error type for `rust_template` operations. #[derive(Error, Debug)] +#[non_exhaustive] pub enum Error { /// Invalid input was provided. #[error("invalid input: {0}")] @@ -55,11 +56,12 @@ pub const fn add(a: i64, b: i64) -> i64 { /// /// # Returns /// -/// The quotient, or an error if `divisor` is zero. +/// The quotient, or an error if `divisor` is zero or the operation overflows. /// /// # Errors /// /// Returns [`Error::InvalidInput`] if `divisor` is zero. +/// Returns [`Error::OperationFailed`] if the division overflows. /// /// # Examples /// @@ -73,18 +75,60 @@ pub fn divide(dividend: i64, divisor: i64) -> Result { if divisor == 0 { return Err(Error::InvalidInput("divisor cannot be zero".to_string())); } - Ok(dividend / divisor) + + dividend.checked_div(divisor).ok_or_else(|| Error::OperationFailed { + operation: "divide".to_string(), + cause: "overflow when dividing i64 values".to_string(), + }) +} + +/// Parses `input` as a non-negative integer and returns it. +/// +/// # Arguments +/// +/// * `input` - A string slice to parse as an `i64`. +/// +/// # Returns +/// +/// The parsed non-negative integer value on success. +/// +/// # Errors +/// +/// - Returns [`Error::InvalidInput`] if `input` cannot be parsed as an `i64`. +/// - Returns [`Error::OperationFailed`] if the parsed value is negative. +/// +/// # Examples +/// +/// ```rust +/// use rust_template::process; +/// +/// assert_eq!(process("42").unwrap(), 42); +/// assert_eq!(process("0").unwrap(), 0); +/// assert!(process("abc").is_err()); +/// assert!(process("-1").is_err()); +/// ``` +pub fn process(input: &str) -> Result { + let value = input + .parse::() + .map_err(|e| Error::InvalidInput(format!("not a valid integer: {e}")))?; + + if value < 0 { + return Err(Error::OperationFailed { + operation: "process".to_string(), + cause: format!("value {value} is negative"), + }); + } + + Ok(value) } /// Configuration for the crate. #[derive(Debug, Clone)] +#[non_exhaustive] pub struct Config { - /// Enable verbose logging. - pub verbose: bool, - /// Maximum number of retries. - pub max_retries: u32, - /// Timeout in seconds. - pub timeout_secs: u64, + verbose: bool, + max_retries: u32, + timeout_secs: u64, } impl Default for Config { @@ -104,6 +148,24 @@ impl Config { } } + /// Returns whether verbose logging is enabled. + #[must_use] + pub const fn verbose(&self) -> bool { + self.verbose + } + + /// Returns the maximum number of retries. + #[must_use] + pub const fn max_retries(&self) -> u32 { + self.max_retries + } + + /// Returns the timeout in seconds. + #[must_use] + pub const fn timeout_secs(&self) -> u64 { + self.timeout_secs + } + /// Sets the verbose flag. #[must_use] pub const fn with_verbose(mut self, verbose: bool) -> Self { @@ -148,7 +210,6 @@ mod tests { #[test] fn test_divide_by_zero() { let result = divide(10, 0); - assert!(result.is_err()); assert!(matches!(result, Err(Error::InvalidInput(ref msg)) if msg.contains("zero"))); } @@ -159,17 +220,39 @@ mod tests { .with_max_retries(5) .with_timeout(60); - assert!(config.verbose); - assert_eq!(config.max_retries, 5); - assert_eq!(config.timeout_secs, 60); + assert!(config.verbose()); + assert_eq!(config.max_retries(), 5); + assert_eq!(config.timeout_secs(), 60); } #[test] fn test_config_default() { let config = Config::default(); - assert!(!config.verbose); - assert_eq!(config.max_retries, 3); - assert_eq!(config.timeout_secs, 30); + assert!(!config.verbose()); + assert_eq!(config.max_retries(), 3); + assert_eq!(config.timeout_secs(), 30); + } + + #[test] + fn test_process_valid() { + assert_eq!(process("42").unwrap(), 42); + assert_eq!(process("0").unwrap(), 0); + assert_eq!(process("100").unwrap(), 100); + } + + #[test] + fn test_process_invalid_input() { + let result = process("abc"); + assert!(matches!(result, Err(Error::InvalidInput(_)))); + } + + #[test] + fn test_process_negative() { + let result = process("-1"); + assert!(matches!( + result, + Err(Error::OperationFailed { ref operation, .. }) if operation == "process" + )); } #[test] diff --git a/crates/main.rs b/crates/main.rs index f4039f1..8481d0a 100644 --- a/crates/main.rs +++ b/crates/main.rs @@ -10,7 +10,7 @@ use rust_template::{Config, add, divide}; fn run() -> Result<(), rust_template::Error> { let config = Config::new().with_verbose(true); - if config.verbose { + if config.verbose() { eprintln!("Running rust_template with verbose mode enabled"); } @@ -33,3 +33,23 @@ fn main() -> ExitCode { }, } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_run_succeeds() { + let result = run(); + assert!( + result.is_ok(), + "run() should succeed with the default implementation" + ); + } + + #[test] + fn test_main_returns_success() { + let exit_code = main(); + assert_eq!(exit_code, ExitCode::SUCCESS); + } +} diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 9c55e37..fb45674 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -29,7 +29,6 @@ fn test_divide_integration() { #[test] fn test_divide_by_zero() { let result = divide(42, 0); - assert!(result.is_err()); assert!( matches!(result, Err(Error::InvalidInput(ref msg)) if msg.contains("zero")), "Expected InvalidInput error with message containing 'zero'" @@ -43,9 +42,9 @@ fn test_config_builder_pattern() { .with_max_retries(10) .with_timeout(120); - assert!(config.verbose); - assert_eq!(config.max_retries, 10); - assert_eq!(config.timeout_secs, 120); + assert!(config.verbose()); + assert_eq!(config.max_retries(), 10); + assert_eq!(config.timeout_secs(), 120); } #[test] @@ -53,9 +52,9 @@ fn test_config_clone() { let config1 = Config::new().with_verbose(true); let config2 = config1.clone(); - assert_eq!(config1.verbose, config2.verbose); - assert_eq!(config1.max_retries, config2.max_retries); - assert_eq!(config1.timeout_secs, config2.timeout_secs); + assert_eq!(config1.verbose(), config2.verbose()); + assert_eq!(config1.max_retries(), config2.max_retries()); + assert_eq!(config1.timeout_secs(), config2.timeout_secs()); } #[test] @@ -76,7 +75,10 @@ fn test_error_types() { assert!(display.contains("file not found")); } -/// Helper function demonstrating Result handling patterns. +/// Adds `a` and `b`, then divides the sum by 2. +/// +/// Demonstrates composing fallible operations — the `Result` from `divide` +/// propagates directly to the caller without explicit `match`. fn process_numbers(a: i64, b: i64) -> Result { let sum = add(a, b); divide(sum, 2) @@ -94,12 +96,25 @@ mod property_tests { proptest! { #[test] + // i32 inputs are widened to i64 to prevent overflow false positives: + // two arbitrary i64 values can overflow on addition, but widened i32s + // fit within i64 range, keeping the test valid for all sampled inputs. fn add_is_commutative(a in any::(), b in any::()) { let a = i64::from(a); let b = i64::from(b); prop_assert_eq!(add(a, b), add(b, a)); } + #[test] + // Same i32→i64 widening strategy: ensures (a+b)+c and a+(b+c) never + // overflow for any sampled triple, making the invariant always checkable. + fn add_is_associative(a in any::(), b in any::(), c in any::()) { + let a = i64::from(a); + let b = i64::from(b); + let c = i64::from(c); + prop_assert_eq!(add(add(a, b), c), add(a, add(b, c))); + } + #[test] fn add_zero_is_identity(n in any::()) { prop_assert_eq!(add(n, 0), n); @@ -112,8 +127,88 @@ mod property_tests { } #[test] - fn divide_by_nonzero_succeeds(dividend in any::(), divisor in any::().prop_filter("non-zero", |&x| x != 0)) { + fn divide_by_nonzero_succeeds( + (dividend, divisor) in + (any::(), any::()).prop_filter( + "non-zero divisor and non-overflowing pair", + |(d, v)| *v != 0 && !(*d == i64::MIN && *v == -1), + ), + ) { prop_assert!(divide(dividend, divisor).is_ok()); } } } + +/// Parameterized tests using the `test-case` crate. +mod parameterized_tests { + use rust_template::{add, divide}; + use test_case::test_case; + + #[test_case(2, 3, 5 ; "positive numbers")] + #[test_case(-1, 1, 0 ; "negative plus positive")] + #[test_case(0, 0, 0 ; "both zero")] + #[test_case(i64::MAX, 0, i64::MAX ; "max plus zero")] + #[test_case(i64::MIN, 0, i64::MIN ; "min plus zero")] + fn test_add_cases(a: i64, b: i64, expected: i64) { + assert_eq!(add(a, b), expected); + } + + #[test_case(10, 2, 5 ; "basic division")] + #[test_case(-10, 2, -5 ; "negative dividend")] + #[test_case(10, -2, -5 ; "negative divisor")] + #[test_case(-10, -2, 5 ; "both negative")] + #[test_case(7, 3, 2 ; "truncating toward zero positive")] + #[test_case(-7, 3, -2 ; "truncating toward zero negative")] + fn test_divide_cases(dividend: i64, divisor: i64, expected: i64) { + assert_eq!(divide(dividend, divisor).ok(), Some(expected)); + } +} + +/// Tests for derived trait implementations on public types. +mod trait_tests { + use super::*; + + #[test] + fn test_config_debug_format() { + let config = Config::new(); + let debug_str = format!("{config:?}"); + assert!(debug_str.contains("Config")); + assert!(debug_str.contains("verbose")); + assert!(debug_str.contains("max_retries")); + assert!(debug_str.contains("timeout_secs")); + } + + #[test] + fn test_error_invalid_input_debug() { + let err = Error::InvalidInput("msg".to_string()); + let debug_str = format!("{err:?}"); + assert!(debug_str.contains("InvalidInput")); + assert!(debug_str.contains("msg")); + } + + #[test] + fn test_error_operation_failed_debug() { + let err = Error::OperationFailed { + operation: "write".to_string(), + cause: "disk full".to_string(), + }; + let debug_str = format!("{err:?}"); + assert!(debug_str.contains("OperationFailed")); + assert!(debug_str.contains("write")); + assert!(debug_str.contains("disk full")); + } + + #[test] + fn test_config_clone_independence() { + let original = Config::new().with_verbose(true).with_max_retries(9); + let mut cloned = original.clone(); + // Modifying cloned via builder creates a new value; verify original is unchanged + cloned = cloned.with_verbose(false); + assert!(original.verbose(), "original should retain verbose=true"); + assert!( + !cloned.verbose(), + "cloned should have verbose=false after rebuild" + ); + assert_eq!(original.max_retries(), cloned.max_retries()); + } +}