Compare commits

..

2 Commits

Author SHA1 Message Date
Dylan Knutson
8fb51d12a7 feat: Add natural language duration parsing to validate_duration
- Support parsing strings like '1 hour', '7 days' in addition to plain numbers
- Maintain backwards compatibility with numeric input (e.g., '24')
- Support units: hour(s), hr(s), day(s)
- Add comprehensive test suite using rstest
- Enforce 1-720 hour limit (1 hour to 30 days)
- Provide clear error messages for invalid input
2025-08-30 00:17:24 +00:00
Dylan Knutson
374abf8c42 major db refactors 2025-08-29 23:25:12 +00:00
3 changed files with 97 additions and 20 deletions

View File

@@ -51,12 +51,52 @@ pub fn validate_slots(text: &str) -> Result<i32, String> {
}
pub fn validate_duration(text: &str) -> Result<ListingDuration, String> {
match text.parse::<i32>() {
Ok(hours) if (1..=720).contains(&hours) => Ok(ListingDuration::hours(hours)), // 1 hour to 30 days
Ok(_) => Err(
"❌ Duration must be between 1 and 720 hours. Please enter a valid number:".to_string(),
),
Err(_) => Err("❌ Invalid number. Please enter number of hours (1-720):".to_string()),
let text = text.trim().to_lowercase();
// Try to parse as plain number first (backwards compatibility)
if let Ok(hours) = text.parse::<i32>() {
if (1..=720).contains(&hours) {
return Ok(ListingDuration::hours(hours));
} else {
return Err("❌ Duration must be between 1 hour and 30 days (720 hours). Please enter a valid duration:".to_string());
}
}
// Parse natural language duration
let parts: Vec<&str> = text.split_whitespace().collect();
if parts.len() != 2 {
return Err(
"❌ Please enter duration like '1 hour', '7 days', or just hours (1-720):".to_string(),
);
}
let number_str = parts[0];
let unit = parts[1];
let number = match number_str.parse::<i32>() {
Ok(n) if n > 0 => n,
_ => {
return Err(
"❌ Duration number must be a positive integer. Please enter a valid duration:"
.to_string(),
)
}
};
let hours = match unit {
"hour" | "hours" | "hr" | "hrs" => number,
"day" | "days" => number * 24,
_ => {
return Err(
"❌ Supported units: hour(s), day(s). Please enter a valid duration:".to_string(),
)
}
};
if (1..=720).contains(&hours) {
Ok(ListingDuration::hours(hours))
} else {
Err("❌ Duration must be between 1 hour and 30 days (720 hours). Please enter a valid duration:".to_string())
}
}
@@ -72,3 +112,34 @@ pub fn validate_start_time(text: &str) -> Result<ListingDuration, String> {
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use rstest::rstest;
#[rstest]
#[case("24", ListingDuration::hours(24))] // Plain number
#[case("1 hour", ListingDuration::hours(1))]
#[case("2 hours", ListingDuration::hours(2))]
#[case("1 day", ListingDuration::hours(24))]
#[case("7 days", ListingDuration::hours(168))]
#[case("30 days", ListingDuration::hours(720))] // Max 30 days
fn test_validate_duration_valid(#[case] input: &str, #[case] expected: ListingDuration) {
let result = validate_duration(input).unwrap();
assert_eq!(result, expected);
}
#[rstest]
#[case("0")]
#[case("0 hours")]
#[case("721")] // Over limit
#[case("1 week")] // Unsupported unit
#[case("1 month")] // Unsupported unit
#[case("1 year")] // Unsupported unit
#[case("abc")] // Invalid text
#[case("-1 hour")] // Negative
fn test_validate_duration_invalid(#[case] input: &str) {
assert!(validate_duration(input).is_err());
}
}

View File

@@ -79,10 +79,11 @@ impl ListingDAO {
let query_str = format!(
r#"
UPDATE listings
SET {}
WHERE id = ? AND seller_id = ?
RETURNING {}
UPDATE listings
SET {}
WHERE id = ?
AND seller_id = ?
RETURNING {}
"#,
binds
.bind_names()
@@ -105,10 +106,13 @@ impl ListingDAO {
pool: &SqlitePool,
listing_id: ListingDbId,
) -> Result<Option<PersistedListing>> {
let result = sqlx::query_as("SELECT * FROM listings WHERE id = ?")
.bind(listing_id)
.fetch_optional(pool)
.await?;
let result = sqlx::query_as(&format!(
"SELECT {} FROM listings WHERE id = ?",
LISTING_RETURN_FIELDS.join(", ")
))
.bind(listing_id)
.fetch_optional(pool)
.await?;
Ok(result)
}
@@ -118,11 +122,13 @@ impl ListingDAO {
pool: &SqlitePool,
seller_id: UserDbId,
) -> Result<Vec<PersistedListing>> {
let rows =
sqlx::query_as("SELECT * FROM listings WHERE seller_id = ? ORDER BY created_at DESC")
.bind(seller_id)
.fetch_all(pool)
.await?;
let rows = sqlx::query_as(&format!(
"SELECT {} FROM listings WHERE seller_id = ? ORDER BY created_at DESC",
LISTING_RETURN_FIELDS.join(", ")
))
.bind(seller_id)
.fetch_all(pool)
.await?;
Ok(rows)
}

View File

@@ -170,11 +170,11 @@ impl FromRow<'_, SqliteRow> for PersistedUser {
created_at: row.get("created_at"),
updated_at: row.get("updated_at"),
},
telegram_id: row.get("telegram_id"),
username: row.get("username"),
first_name: row.get("first_name"),
last_name: row.get("last_name"),
is_banned: row.get("is_banned"),
telegram_id: row.get("telegram_id"),
})
}
}