use chrono::{DateTime, Utc};
use clap::Parser;
use std::path::PathBuf;
#[derive(Parser, Debug)]
#[command(name = "sniffy")]
#[command(version, about, long_about = None)]
pub struct Cli {
#[arg(default_value = ".")]
pub paths: Vec<PathBuf>,
#[arg(short = 'H', long)]
pub hidden: bool,
#[arg(short, long)]
pub verbose: bool,
#[arg(long)]
pub history: bool,
#[arg(long, value_name = "DATE")]
pub since: Option<String>,
#[arg(long, value_name = "DATE")]
pub until: Option<String>,
#[arg(long, value_name = "N", conflicts_with_all = ["since", "until"])]
pub last: Option<usize>,
#[arg(long)]
pub by_day: bool,
#[arg(long)]
pub by_week: bool,
#[arg(long, value_name = "NAME")]
pub author: Option<String>,
#[arg(long, default_value = "table", value_name = "FORMAT")]
pub format: String,
#[arg(short = 'j', long, default_value = "0", value_name = "N")]
pub jobs: usize,
#[arg(long)]
pub no_color: bool,
}
impl Cli {
pub fn parse_args() -> Self {
Self::parse()
}
pub fn validate(&self) -> Result<(), String> {
for path in &self.paths {
if !path.exists() {
return Err(format!("Path does not exist: {}", path.display()));
}
}
if self.by_day && self.by_week {
return Err("Cannot use both --by-day and --by-week".to_string());
}
if !self.history
&& (self.since.is_some()
|| self.until.is_some()
|| self.last.is_some()
|| self.by_day
|| self.by_week
|| self.author.is_some())
{
return Err(
"History-related flags (--since, --until, --last, --by-day, --by-week, --author) require --history"
.to_string(),
);
}
let format_lower = self.format.to_lowercase();
if !["table", "json", "csv"].contains(&format_lower.as_str()) {
return Err(format!(
"Invalid format '{}'. Supported formats: table, json, csv",
self.format
));
}
Ok(())
}
pub fn should_use_color(&self) -> bool {
if self.no_color {
return false;
}
if let Ok(val) = std::env::var("NO_COLOR") {
if !val.is_empty() {
return false;
}
}
true
}
pub fn parse_since_date(&self) -> Result<Option<DateTime<Utc>>, String> {
if let Some(days) = self.last {
let now = Utc::now();
let duration = chrono::Duration::days(days as i64);
return Ok(Some(now - duration));
}
let Some(since_str) = &self.since else {
return Ok(None);
};
Self::parse_date_string(since_str)
}
pub fn parse_until_date(&self) -> Result<Option<DateTime<Utc>>, String> {
let Some(until_str) = &self.until else {
return Ok(None);
};
Self::parse_date_string(until_str)
}
fn parse_date_string(date_str: &str) -> Result<Option<DateTime<Utc>>, String> {
if let Ok(dt) = DateTime::parse_from_rfc3339(date_str) {
return Ok(Some(dt.with_timezone(&Utc)));
}
if let Ok(naive_date) = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d") {
let naive_datetime = naive_date.and_hms_opt(0, 0, 0).unwrap();
return Ok(Some(naive_datetime.and_utc()));
}
Err(format!(
"Invalid date format '{}'. Use YYYY-MM-DD or RFC3339 format.",
date_str
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_date_string_yyyy_mm_dd() {
let result = Cli::parse_date_string("2024-01-15");
assert!(result.is_ok());
let dt = result.unwrap().unwrap();
assert_eq!(dt.format("%Y-%m-%d").to_string(), "2024-01-15");
}
#[test]
fn test_parse_date_string_rfc3339() {
let result = Cli::parse_date_string("2024-01-15T10:30:00Z");
assert!(result.is_ok());
let dt = result.unwrap().unwrap();
assert_eq!(dt.format("%Y-%m-%d").to_string(), "2024-01-15");
}
#[test]
fn test_parse_date_string_invalid() {
let result = Cli::parse_date_string("not-a-date");
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid date format"));
}
#[test]
fn test_parse_date_string_invalid_format() {
let result = Cli::parse_date_string("01/15/2024");
assert!(result.is_err());
}
#[test]
fn test_should_use_color_default() {
std::env::remove_var("NO_COLOR");
let cli = Cli {
paths: vec![],
hidden: false,
verbose: false,
history: false,
since: None,
until: None,
last: None,
by_day: false,
by_week: false,
author: None,
format: "table".to_string(),
jobs: 0,
no_color: false,
};
assert!(cli.should_use_color());
}
#[test]
fn test_should_use_color_with_flag() {
std::env::remove_var("NO_COLOR");
let cli = Cli {
paths: vec![],
hidden: false,
verbose: false,
history: false,
since: None,
until: None,
last: None,
by_day: false,
by_week: false,
author: None,
format: "table".to_string(),
jobs: 0,
no_color: true,
};
assert!(!cli.should_use_color());
}
#[test]
fn test_should_use_color_with_env() {
std::env::set_var("NO_COLOR", "1");
let cli = Cli {
paths: vec![],
hidden: false,
verbose: false,
history: false,
since: None,
until: None,
last: None,
by_day: false,
by_week: false,
author: None,
format: "table".to_string(),
jobs: 0,
no_color: false,
};
assert!(!cli.should_use_color());
std::env::remove_var("NO_COLOR");
}
#[test]
fn test_should_use_color_empty_env() {
std::env::set_var("NO_COLOR", "");
let cli = Cli {
paths: vec![],
hidden: false,
verbose: false,
history: false,
since: None,
until: None,
last: None,
by_day: false,
by_week: false,
author: None,
format: "table".to_string(),
jobs: 0,
no_color: false,
};
assert!(cli.should_use_color());
std::env::remove_var("NO_COLOR");
}
}