use std::fmt;
use std::fs::{self, File as StdFile, OpenOptions};
use std::io::{self, Read, Write};
use std::path::{Path as StdPath, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use chrono::{DateTime as ChronoDateTime, NaiveDate, NaiveDateTime, Utc};
use uuid::Uuid;
fn format_float(value: f64) -> String {
if value.fract() == 0.0 && value.is_finite() {
format!("{:.1}", value)
} else {
format!("{}", value)
}
}
pub trait TypeConverter: fmt::Debug + Send + Sync {
type Value;
fn name(&self) -> &str;
fn convert(&self, value: &str) -> Result<Self::Value, String>;
fn get_metavar(&self) -> Option<String> {
None
}
fn get_missing_message(&self) -> Option<String> {
None
}
fn split_envvar_value(&self, value: &str) -> Vec<String> {
value.split_whitespace().map(|s| s.to_string()).collect()
}
fn shell_complete(&self, _incomplete: &str) -> Vec<CompletionItem> {
Vec::new()
}
fn is_composite(&self) -> bool {
false
}
fn arity(&self) -> usize {
1
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CompletionItem {
pub value: String,
pub completion_type: String,
pub help: Option<String>,
}
impl CompletionItem {
pub fn new(value: impl Into<String>) -> Self {
Self {
value: value.into(),
completion_type: "plain".to_string(),
help: None,
}
}
pub fn with_type(value: impl Into<String>, completion_type: impl Into<String>) -> Self {
Self {
value: value.into(),
completion_type: completion_type.into(),
help: None,
}
}
pub fn with_help(mut self, help: impl Into<String>) -> Self {
self.help = Some(help.into());
self
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct StringType;
impl TypeConverter for StringType {
type Value = String;
fn name(&self) -> &str {
"TEXT"
}
fn convert(&self, value: &str) -> Result<Self::Value, String> {
Ok(value.to_string())
}
fn get_metavar(&self) -> Option<String> {
Some("TEXT".to_string())
}
}
pub const STRING: StringType = StringType;
#[derive(Debug, Clone, Copy, Default)]
pub struct IntType;
impl TypeConverter for IntType {
type Value = i64;
fn name(&self) -> &str {
"INTEGER"
}
fn convert(&self, value: &str) -> Result<Self::Value, String> {
value
.trim()
.parse::<i64>()
.map_err(|_| format!("'{}' is not a valid integer.", value))
}
fn get_metavar(&self) -> Option<String> {
Some("INTEGER".to_string())
}
}
pub const INT: IntType = IntType;
#[derive(Debug, Clone, Copy, Default)]
pub struct FloatType;
impl TypeConverter for FloatType {
type Value = f64;
fn name(&self) -> &str {
"FLOAT"
}
fn convert(&self, value: &str) -> Result<Self::Value, String> {
value
.trim()
.parse::<f64>()
.map_err(|_| format!("'{}' is not a valid float.", value))
}
fn get_metavar(&self) -> Option<String> {
Some("FLOAT".to_string())
}
}
pub const FLOAT: FloatType = FloatType;
#[derive(Debug, Clone, Copy, Default)]
pub struct BoolType;
impl BoolType {
pub fn str_to_bool(value: &str) -> Option<bool> {
match value.trim().to_lowercase().as_str() {
"1" | "true" | "yes" | "on" | "t" | "y" => Some(true),
"0" | "false" | "no" | "off" | "f" | "n" | "" => Some(false),
_ => None,
}
}
pub const BOOL_STATES: &'static [&'static str] = &[
"", "0", "1", "f", "false", "n", "no", "off", "on", "t", "true", "y", "yes",
];
}
impl TypeConverter for BoolType {
type Value = bool;
fn name(&self) -> &str {
"BOOLEAN"
}
fn convert(&self, value: &str) -> Result<Self::Value, String> {
Self::str_to_bool(value).ok_or_else(|| {
format!(
"'{}' is not a valid boolean. Recognized values: {}",
value,
Self::BOOL_STATES.join(", ")
)
})
}
fn get_metavar(&self) -> Option<String> {
Some("BOOLEAN".to_string())
}
}
pub const BOOL: BoolType = BoolType;
#[derive(Debug, Clone, Copy, Default)]
pub struct UuidType;
impl TypeConverter for UuidType {
type Value = Uuid;
fn name(&self) -> &str {
"UUID"
}
fn convert(&self, value: &str) -> Result<Self::Value, String> {
Uuid::parse_str(value.trim()).map_err(|_| format!("'{}' is not a valid UUID", value))
}
fn get_metavar(&self) -> Option<String> {
Some("UUID".to_string())
}
}
pub const UUID: UuidType = UuidType;
#[derive(Debug, Clone, Copy, Default)]
pub struct UnprocessedType;
impl TypeConverter for UnprocessedType {
type Value = String;
fn name(&self) -> &str {
"TEXT"
}
fn convert(&self, value: &str) -> Result<Self::Value, String> {
Ok(value.to_string())
}
}
pub const UNPROCESSED: UnprocessedType = UnprocessedType;
#[derive(Debug, Clone, Copy)]
pub struct IntRange {
pub min: Option<i64>,
pub max: Option<i64>,
pub min_open: bool,
pub max_open: bool,
pub clamp: bool,
}
impl Default for IntRange {
fn default() -> Self {
Self::new()
}
}
impl IntRange {
pub const fn new() -> Self {
Self {
min: None,
max: None,
min_open: false,
max_open: false,
clamp: false,
}
}
pub const fn min(mut self, min: i64) -> Self {
self.min = Some(min);
self
}
pub const fn max(mut self, max: i64) -> Self {
self.max = Some(max);
self
}
pub const fn range(mut self, min: i64, max: i64) -> Self {
self.min = Some(min);
self.max = Some(max);
self
}
pub const fn min_open(mut self, open: bool) -> Self {
self.min_open = open;
self
}
pub const fn max_open(mut self, open: bool) -> Self {
self.max_open = open;
self
}
pub const fn clamp(mut self, clamp: bool) -> Self {
self.clamp = clamp;
self
}
fn describe_range(&self) -> String {
match (self.min, self.max) {
(None, None) => "any integer".to_string(),
(Some(min), None) => {
let op = if self.min_open { ">" } else { ">=" };
format!("x{}{}", op, min)
}
(None, Some(max)) => {
let op = if self.max_open { "<" } else { "<=" };
format!("x{}{}", op, max)
}
(Some(min), Some(max)) => {
let lop = if self.min_open { "<" } else { "<=" };
let rop = if self.max_open { "<" } else { "<=" };
format!("{}{lop}x{rop}{}", min, max)
}
}
}
fn clamp_value(&self, value: i64) -> i64 {
let mut result = value;
if let Some(min) = self.min {
let effective_min = if self.min_open {
min.saturating_add(1)
} else {
min
};
if result < effective_min {
result = effective_min;
}
}
if let Some(max) = self.max {
let effective_max = if self.max_open {
max.saturating_sub(1)
} else {
max
};
if result > effective_max {
result = effective_max;
}
}
result
}
}
impl TypeConverter for IntRange {
type Value = i64;
fn name(&self) -> &str {
"INTEGER RANGE"
}
fn convert(&self, value: &str) -> Result<Self::Value, String> {
let parsed: i64 = value
.trim()
.parse()
.map_err(|_| format!("'{}' is not a valid integer range.", value))?;
let lt_min = self.min.is_some_and(|min| {
if self.min_open {
parsed <= min
} else {
parsed < min
}
});
let gt_max = self.max.is_some_and(|max| {
if self.max_open {
parsed >= max
} else {
parsed > max
}
});
if self.clamp && (lt_min || gt_max) {
return Ok(self.clamp_value(parsed));
}
if lt_min || gt_max {
return Err(format!(
"{} is not in the range {}.",
parsed,
self.describe_range()
));
}
Ok(parsed)
}
fn get_metavar(&self) -> Option<String> {
Some(format!("INTEGER RANGE {}", self.describe_range()))
}
}
#[derive(Debug, Clone, Copy)]
pub struct FloatRange {
pub min: Option<f64>,
pub max: Option<f64>,
pub min_open: bool,
pub max_open: bool,
pub clamp: bool,
}
impl Default for FloatRange {
fn default() -> Self {
Self::new()
}
}
impl FloatRange {
pub const fn new() -> Self {
Self {
min: None,
max: None,
min_open: false,
max_open: false,
clamp: false,
}
}
pub fn min(mut self, min: f64) -> Self {
self.min = Some(min);
self
}
pub fn max(mut self, max: f64) -> Self {
self.max = Some(max);
self
}
pub fn range(mut self, min: f64, max: f64) -> Self {
self.min = Some(min);
self.max = Some(max);
self
}
pub fn min_open(mut self, open: bool) -> Self {
if open && self.clamp {
panic!("Clamping is not supported for open bounds");
}
self.min_open = open;
self
}
pub fn max_open(mut self, open: bool) -> Self {
if open && self.clamp {
panic!("Clamping is not supported for open bounds");
}
self.max_open = open;
self
}
pub fn clamp(mut self, clamp: bool) -> Self {
if clamp && (self.min_open || self.max_open) {
panic!("Clamping is not supported for open bounds");
}
self.clamp = clamp;
self
}
fn describe_range(&self) -> String {
match (self.min, self.max) {
(None, None) => "any float".to_string(),
(Some(min), None) => {
let op = if self.min_open { ">" } else { ">=" };
format!("x{}{}", op, format_float(min))
}
(None, Some(max)) => {
let op = if self.max_open { "<" } else { "<=" };
format!("x{}{}", op, format_float(max))
}
(Some(min), Some(max)) => {
let lop = if self.min_open { "<" } else { "<=" };
let rop = if self.max_open { "<" } else { "<=" };
format!("{}{lop}x{rop}{}", format_float(min), format_float(max))
}
}
}
}
impl TypeConverter for FloatRange {
type Value = f64;
fn name(&self) -> &str {
"FLOAT RANGE"
}
fn convert(&self, value: &str) -> Result<Self::Value, String> {
let parsed: f64 = value
.trim()
.parse()
.map_err(|_| format!("'{}' is not a valid float range.", value))?;
let lt_min = self.min.is_some_and(|min| {
if self.min_open {
parsed <= min
} else {
parsed < min
}
});
let gt_max = self.max.is_some_and(|max| {
if self.max_open {
parsed >= max
} else {
parsed > max
}
});
if self.clamp {
if lt_min {
return Ok(self.min.unwrap());
}
if gt_max {
return Ok(self.max.unwrap());
}
}
if lt_min || gt_max {
return Err(format!(
"{} is not in the range {}.",
format_float(parsed),
self.describe_range()
));
}
Ok(parsed)
}
fn get_metavar(&self) -> Option<String> {
Some(format!("FLOAT RANGE {}", self.describe_range()))
}
}
#[derive(Debug, Clone)]
pub struct DateTimeType {
pub formats: Vec<String>,
}
impl Default for DateTimeType {
fn default() -> Self {
Self::new()
}
}
impl DateTimeType {
pub const DEFAULT_FORMATS: &'static [&'static str] = &[
"%Y-%m-%d",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%dT%H:%M:%S%.f",
"%Y-%m-%dT%H:%M:%S%z",
"%Y-%m-%dT%H:%M:%S%.f%z",
];
pub fn new() -> Self {
Self {
formats: Self::DEFAULT_FORMATS
.iter()
.map(|s| s.to_string())
.collect(),
}
}
pub fn with_formats(formats: impl IntoIterator<Item = impl Into<String>>) -> Self {
Self {
formats: formats.into_iter().map(|s| s.into()).collect(),
}
}
}
impl TypeConverter for DateTimeType {
type Value = NaiveDateTime;
fn name(&self) -> &str {
"DATETIME"
}
fn convert(&self, value: &str) -> Result<Self::Value, String> {
let value = value.trim();
for format in &self.formats {
if let Ok(dt) = NaiveDateTime::parse_from_str(value, format) {
return Ok(dt);
}
if let Ok(date) = NaiveDate::parse_from_str(value, format) {
return Ok(date.and_hms_opt(0, 0, 0).unwrap());
}
if let Ok(dt) = ChronoDateTime::parse_from_str(value, format) {
return Ok(dt.with_timezone(&Utc).naive_utc());
}
}
Err(format!(
"'{}' does not match the formats: {}",
value,
self.formats.join(", ")
))
}
fn get_metavar(&self) -> Option<String> {
Some(format!("[{}]", self.formats.join("|")))
}
}
#[derive(Debug, Clone)]
pub struct Choice {
pub choices: Vec<String>,
pub case_sensitive: bool,
}
impl Choice {
pub fn new<I, S>(choices: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
Self {
choices: choices.into_iter().map(|s| s.into()).collect(),
case_sensitive: true,
}
}
pub fn case_sensitive(mut self, case_sensitive: bool) -> Self {
self.case_sensitive = case_sensitive;
self
}
fn normalize(&self, value: &str) -> String {
if self.case_sensitive {
value.to_string()
} else {
value.to_lowercase()
}
}
}
impl TypeConverter for Choice {
type Value = String;
fn name(&self) -> &str {
"CHOICE"
}
fn convert(&self, value: &str) -> Result<Self::Value, String> {
let normalized_value = self.normalize(value);
for choice in &self.choices {
if self.normalize(choice) == normalized_value {
return Ok(choice.clone());
}
}
if self.choices.len() == 1 {
Err(format!("'{}' is not '{}'.", value, self.choices[0]))
} else {
let choices_str = self
.choices
.iter()
.map(|c| format!("'{}'", c))
.collect::<Vec<_>>()
.join(", ");
Err(format!("'{}' is not one of {}.", value, choices_str))
}
}
fn get_missing_message(&self) -> Option<String> {
Some(format!("Choose from:\n\t{}", self.choices.join(",\n\t")))
}
fn shell_complete(&self, incomplete: &str) -> Vec<CompletionItem> {
let normalized_incomplete = self.normalize(incomplete);
self.choices
.iter()
.filter(|choice| {
let normalized_choice = self.normalize(choice);
normalized_choice.starts_with(&normalized_incomplete)
})
.map(|choice| CompletionItem::new(choice.clone()))
.collect()
}
fn get_metavar(&self) -> Option<String> {
if self.choices.is_empty() {
return Some("CHOICE".to_string());
}
Some(self.choices.join("|"))
}
}
#[derive(Debug, Clone)]
pub struct PathType {
pub exists: bool,
pub file_okay: bool,
pub dir_okay: bool,
pub readable: bool,
pub writable: bool,
pub executable: bool,
pub resolve_path: bool,
pub allow_dash: bool,
}
impl Default for PathType {
fn default() -> Self {
Self::new()
}
}
impl PathType {
pub const fn new() -> Self {
Self {
exists: false,
file_okay: true,
dir_okay: true,
readable: true,
writable: false,
executable: false,
resolve_path: false,
allow_dash: false,
}
}
pub const fn exists(mut self, exists: bool) -> Self {
self.exists = exists;
self
}
pub const fn file_okay(mut self, file_okay: bool) -> Self {
self.file_okay = file_okay;
self
}
pub const fn dir_okay(mut self, dir_okay: bool) -> Self {
self.dir_okay = dir_okay;
self
}
pub const fn readable(mut self, readable: bool) -> Self {
self.readable = readable;
self
}
pub const fn writable(mut self, writable: bool) -> Self {
self.writable = writable;
self
}
pub const fn executable(mut self, executable: bool) -> Self {
self.executable = executable;
self
}
pub const fn resolve_path(mut self, resolve_path: bool) -> Self {
self.resolve_path = resolve_path;
self
}
pub const fn allow_dash(mut self, allow_dash: bool) -> Self {
self.allow_dash = allow_dash;
self
}
fn type_name(&self) -> &str {
if self.file_okay && !self.dir_okay {
"File"
} else if self.dir_okay && !self.file_okay {
"Directory"
} else {
"Path"
}
}
}
impl TypeConverter for PathType {
type Value = PathBuf;
fn name(&self) -> &str {
if self.file_okay && !self.dir_okay {
"FILE"
} else if self.dir_okay && !self.file_okay {
"DIRECTORY"
} else {
"PATH"
}
}
fn convert(&self, value: &str) -> Result<Self::Value, String> {
if !self.file_okay && !self.dir_okay {
return Err("No path is valid (file_okay=false and dir_okay=false)".to_string());
}
if value == "-" {
if self.file_okay && self.allow_dash {
return Ok(PathBuf::from("-"));
}
return Err("'-' is not allowed".to_string());
}
let path = if self.resolve_path {
match std::fs::canonicalize(value) {
Ok(p) => p,
Err(_) if !self.exists => {
std::env::current_dir()
.map(|cwd| cwd.join(value))
.unwrap_or_else(|_| PathBuf::from(value))
}
Err(_) => return Err(format!("{} '{}' does not exist", self.type_name(), value)),
}
} else {
PathBuf::from(value)
};
if self.exists && !path.exists() {
return Err(format!("{} '{}' does not exist", self.type_name(), value));
}
if path.exists() {
let metadata = std::fs::metadata(&path)
.map_err(|e| format!("Cannot access '{}': {}", value, e))?;
if !self.file_okay && metadata.is_file() {
return Err(format!("{} '{}' is a file", self.type_name(), value));
}
if !self.dir_okay && metadata.is_dir() {
return Err(format!("{} '{}' is a directory", self.type_name(), value));
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = metadata.permissions();
let mode = perms.mode();
if self.readable && (mode & 0o444) == 0 {
return Err(format!("{} '{}' is not readable", self.type_name(), value));
}
if self.writable && (mode & 0o222) == 0 {
return Err(format!("{} '{}' is not writable", self.type_name(), value));
}
if self.executable && (mode & 0o111) == 0 {
return Err(format!(
"{} '{}' is not executable",
self.type_name(),
value
));
}
}
}
Ok(path)
}
fn split_envvar_value(&self, value: &str) -> Vec<String> {
std::env::split_paths(value)
.map(|p| p.to_string_lossy().into_owned())
.collect()
}
fn shell_complete(&self, incomplete: &str) -> Vec<CompletionItem> {
let completion_type = if self.dir_okay && !self.file_okay {
"dir"
} else {
"file"
};
vec![CompletionItem::with_type(incomplete, completion_type)]
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FileMode {
#[default]
Read,
Write,
Append,
ReadWrite,
}
impl FileMode {
pub fn parse(s: &str) -> Option<Self> {
match s {
"r" | "rb" => Some(FileMode::Read),
"w" | "wb" => Some(FileMode::Write),
"a" | "ab" => Some(FileMode::Append),
"r+" | "rb+" | "r+b" => Some(FileMode::ReadWrite),
"w+" | "wb+" | "w+b" => Some(FileMode::ReadWrite),
"a+" | "ab+" | "a+b" => Some(FileMode::ReadWrite), _ => None,
}
}
pub fn is_read(&self) -> bool {
matches!(self, FileMode::Read | FileMode::ReadWrite)
}
pub fn is_write(&self) -> bool {
matches!(
self,
FileMode::Write | FileMode::Append | FileMode::ReadWrite
)
}
}
#[derive(Debug)]
enum FileSource {
File(StdFile),
Stdin,
Stdout,
}
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[derive(Debug)]
pub struct LazyFile {
path: PathBuf,
mode: FileMode,
source: Option<FileSource>,
is_stdio: bool,
atomic: bool,
temp_path: Option<PathBuf>,
}
impl LazyFile {
pub fn new(path: PathBuf, mode: FileMode) -> Self {
let is_stdio = path.as_os_str() == "-";
Self {
path,
mode,
source: None,
is_stdio,
atomic: false,
temp_path: None,
}
}
pub fn stdin() -> Self {
Self {
path: PathBuf::from("-"),
mode: FileMode::Read,
source: None,
is_stdio: true,
atomic: false,
temp_path: None,
}
}
pub fn stdout() -> Self {
Self {
path: PathBuf::from("-"),
mode: FileMode::Write,
source: None,
is_stdio: true,
atomic: false,
temp_path: None,
}
}
pub fn atomic(mut self, atomic: bool) -> Self {
self.atomic = atomic;
self
}
pub fn path(&self) -> &StdPath {
&self.path
}
pub fn is_stdio(&self) -> bool {
self.is_stdio
}
fn open(&mut self) -> io::Result<()> {
if self.source.is_some() {
return Ok(());
}
let source = if self.is_stdio {
if self.mode.is_read() {
FileSource::Stdin
} else {
FileSource::Stdout
}
} else if self.atomic && self.mode == FileMode::Write {
let parent = self.path.parent().unwrap_or(StdPath::new("."));
let counter = TEMP_COUNTER.fetch_add(1, Ordering::SeqCst);
let temp_name = format!(
".{}.tmp.{}",
self.path
.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_default(),
counter
);
let temp_path = parent.join(&temp_name);
let file = StdFile::create(&temp_path)?;
self.temp_path = Some(temp_path);
FileSource::File(file)
} else {
let file = match self.mode {
FileMode::Read => StdFile::open(&self.path),
FileMode::Write => StdFile::create(&self.path),
FileMode::Append => OpenOptions::new()
.append(true)
.create(true)
.open(&self.path),
FileMode::ReadWrite => OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&self.path),
}?;
FileSource::File(file)
};
self.source = Some(source);
Ok(())
}
pub fn close(&mut self) -> io::Result<()> {
if let Some(FileSource::File(mut f)) = self.source.take() {
f.flush()?;
}
if let Some(temp_path) = self.temp_path.take() {
fs::rename(&temp_path, &self.path)?;
}
Ok(())
}
}
impl Drop for LazyFile {
fn drop(&mut self) {
let _ = self.close();
}
}
impl Read for LazyFile {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.open()?;
match self.source.as_mut() {
Some(FileSource::File(f)) => f.read(buf),
Some(FileSource::Stdin) => io::stdin().read(buf),
_ => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Cannot read from write-only file",
)),
}
}
}
impl Write for LazyFile {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.open()?;
match self.source.as_mut() {
Some(FileSource::File(f)) => f.write(buf),
Some(FileSource::Stdout) => io::stdout().write(buf),
_ => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Cannot write to read-only file",
)),
}
}
fn flush(&mut self) -> io::Result<()> {
match self.source.as_mut() {
Some(FileSource::File(f)) => f.flush(),
Some(FileSource::Stdout) => io::stdout().flush(),
_ => Ok(()),
}
}
}
#[derive(Debug, Clone)]
pub struct FileType {
pub mode: FileMode,
pub lazy: Option<bool>,
pub atomic: bool,
}
impl Default for FileType {
fn default() -> Self {
Self::new()
}
}
impl FileType {
pub const fn new() -> Self {
Self {
mode: FileMode::Read,
lazy: None,
atomic: false,
}
}
pub const fn mode(mut self, mode: FileMode) -> Self {
self.mode = mode;
self
}
pub const fn lazy(mut self, lazy: bool) -> Self {
self.lazy = Some(lazy);
self
}
pub const fn atomic(mut self, atomic: bool) -> Self {
self.atomic = atomic;
self
}
fn resolve_lazy(&self, path: &str) -> bool {
if let Some(lazy) = self.lazy {
return lazy;
}
if path == "-" {
return false;
}
matches!(self.mode, FileMode::Write | FileMode::Append)
}
}
impl TypeConverter for FileType {
type Value = LazyFile;
fn name(&self) -> &str {
"FILENAME"
}
fn convert(&self, value: &str) -> Result<Self::Value, String> {
if value == "-" {
if matches!(self.mode, FileMode::ReadWrite) {
return Err("'-' (stdin/stdout) cannot be used with read+write mode".to_string());
}
let lazy_file = if self.mode.is_read() {
LazyFile::stdin()
} else {
LazyFile::stdout()
};
return Ok(lazy_file);
}
let path = PathBuf::from(value);
if self.mode.is_read() && !self.resolve_lazy(value) && !path.exists() {
return Err(format!("'{}': No such file or directory", value));
}
let mut lazy_file = LazyFile::new(path, self.mode);
if self.atomic {
lazy_file = lazy_file.atomic(true);
}
if !self.resolve_lazy(value) {
lazy_file
.open()
.map_err(|e| format!("'{}': {}", value, e))?;
}
Ok(lazy_file)
}
fn split_envvar_value(&self, value: &str) -> Vec<String> {
std::env::split_paths(value)
.map(|p| p.to_string_lossy().into_owned())
.collect()
}
fn shell_complete(&self, incomplete: &str) -> Vec<CompletionItem> {
vec![CompletionItem::with_type(incomplete, "file")]
}
}
pub type BoxedTypeConverter<T> = Box<dyn TypeConverter<Value = T> + Send + Sync>;
#[derive(Debug)]
pub struct TupleType {
types: Vec<TupleElementType>,
}
#[derive(Debug, Clone)]
enum TupleElementType {
String,
Int,
Float,
Bool,
}
impl TupleType {
pub fn new<I, S>(types: I) -> Self
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
let types = types
.into_iter()
.map(|s| match s.as_ref().to_lowercase().as_str() {
"string" | "str" | "text" => TupleElementType::String,
"int" | "integer" => TupleElementType::Int,
"float" => TupleElementType::Float,
"bool" | "boolean" => TupleElementType::Bool,
_ => TupleElementType::String,
})
.collect();
Self { types }
}
pub fn strings(count: usize) -> Self {
Self {
types: vec![TupleElementType::String; count],
}
}
pub fn ints(count: usize) -> Self {
Self {
types: vec![TupleElementType::Int; count],
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum TupleValue {
String(String),
Int(i64),
Float(f64),
Bool(bool),
}
impl TupleValue {
pub fn as_string(&self) -> Option<&str> {
match self {
TupleValue::String(s) => Some(s),
_ => None,
}
}
pub fn as_int(&self) -> Option<i64> {
match self {
TupleValue::Int(i) => Some(*i),
_ => None,
}
}
pub fn as_float(&self) -> Option<f64> {
match self {
TupleValue::Float(f) => Some(*f),
_ => None,
}
}
pub fn as_bool(&self) -> Option<bool> {
match self {
TupleValue::Bool(b) => Some(*b),
_ => None,
}
}
}
impl TupleType {
pub fn convert_element(&self, index: usize, value: &str) -> Result<TupleValue, String> {
let element_type = self.types.get(index).ok_or_else(|| {
format!(
"tuple index {} out of bounds (arity {})",
index,
self.types.len()
)
})?;
match element_type {
TupleElementType::String => Ok(TupleValue::String(value.to_string())),
TupleElementType::Int => {
let i: i64 = value
.parse()
.map_err(|_| format!("'{}' is not a valid integer", value))?;
Ok(TupleValue::Int(i))
}
TupleElementType::Float => {
let f: f64 = value
.parse()
.map_err(|_| format!("'{}' is not a valid float", value))?;
Ok(TupleValue::Float(f))
}
TupleElementType::Bool => {
let b = BoolType::str_to_bool(value)
.ok_or_else(|| format!("'{}' is not a valid boolean", value))?;
Ok(TupleValue::Bool(b))
}
}
}
pub fn convert_values(&self, values: &[&str]) -> Result<Vec<TupleValue>, String> {
if values.len() != self.types.len() {
return Err(format!(
"{} values are required, but {} were given",
self.types.len(),
values.len()
));
}
values
.iter()
.enumerate()
.map(|(i, v)| self.convert_element(i, v))
.collect()
}
}
impl TypeConverter for TupleType {
type Value = Vec<TupleValue>;
fn name(&self) -> &str {
"TUPLE"
}
fn convert(&self, value: &str) -> Result<Self::Value, String> {
let parts: Vec<&str> = value.split_whitespace().collect();
self.convert_values(&parts)
}
fn get_metavar(&self) -> Option<String> {
let names: Vec<&str> = self
.types
.iter()
.map(|t| match t {
TupleElementType::String => "TEXT",
TupleElementType::Int => "INTEGER",
TupleElementType::Float => "FLOAT",
TupleElementType::Bool => "BOOLEAN",
})
.collect();
Some(format!("<{}>", names.join(" ")))
}
fn is_composite(&self) -> bool {
true
}
fn arity(&self) -> usize {
self.types.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_string_type() {
assert_eq!(STRING.convert("hello").unwrap(), "hello");
assert_eq!(STRING.convert(" spaces ").unwrap(), " spaces ");
assert_eq!(STRING.name(), "TEXT");
}
#[test]
fn test_int_type() {
assert_eq!(INT.convert("42").unwrap(), 42);
assert_eq!(INT.convert("-123").unwrap(), -123);
assert_eq!(INT.convert(" 456 ").unwrap(), 456);
assert!(INT.convert("not a number").is_err());
assert!(INT.convert("3.14").is_err());
}
#[test]
fn test_float_type() {
assert_eq!(FLOAT.convert("3.14").unwrap(), 3.14);
assert_eq!(FLOAT.convert("-2.5").unwrap(), -2.5);
assert_eq!(FLOAT.convert("42").unwrap(), 42.0);
assert!(FLOAT.convert("not a number").is_err());
}
#[test]
fn test_bool_type() {
assert!(BOOL.convert("true").unwrap());
assert!(BOOL.convert("True").unwrap());
assert!(BOOL.convert("TRUE").unwrap());
assert!(BOOL.convert("yes").unwrap());
assert!(BOOL.convert("1").unwrap());
assert!(BOOL.convert("on").unwrap());
assert!(!BOOL.convert("false").unwrap());
assert!(!BOOL.convert("no").unwrap());
assert!(!BOOL.convert("0").unwrap());
assert!(!BOOL.convert("off").unwrap());
assert!(BOOL.convert("maybe").is_err());
}
#[test]
fn test_uuid_type() {
let uuid_str = "550e8400-e29b-41d4-a716-446655440000";
let result = UUID.convert(uuid_str).unwrap();
assert_eq!(result.to_string(), uuid_str);
assert!(UUID.convert("not-a-uuid").is_err());
}
#[test]
fn test_int_range() {
let range = IntRange::new().range(0, 100);
assert_eq!(range.convert("50").unwrap(), 50);
assert_eq!(range.convert("0").unwrap(), 0);
assert_eq!(range.convert("100").unwrap(), 100);
assert!(range.convert("-1").is_err());
assert!(range.convert("101").is_err());
}
#[test]
fn test_int_range_open() {
let range = IntRange::new().min(0).max(10).min_open(true).max_open(true);
assert!(range.convert("0").is_err()); assert!(range.convert("10").is_err()); assert_eq!(range.convert("1").unwrap(), 1);
assert_eq!(range.convert("9").unwrap(), 9);
}
#[test]
fn test_int_range_clamp() {
let range = IntRange::new().range(0, 100).clamp(true);
assert_eq!(range.convert("-50").unwrap(), 0);
assert_eq!(range.convert("150").unwrap(), 100);
assert_eq!(range.convert("50").unwrap(), 50);
}
#[test]
fn test_float_range() {
let range = FloatRange::new().range(0.0, 1.0);
assert_eq!(range.convert("0.5").unwrap(), 0.5);
assert_eq!(range.convert("0.0").unwrap(), 0.0);
assert_eq!(range.convert("1.0").unwrap(), 1.0);
assert!(range.convert("-0.1").is_err());
assert!(range.convert("1.1").is_err());
}
#[test]
fn test_datetime_type() {
let dt = DateTimeType::new();
let result = dt.convert("2024-01-15").unwrap();
assert_eq!(result.date().to_string(), "2024-01-15");
let result = dt.convert("2024-01-15T10:30:00").unwrap();
assert_eq!(result.to_string(), "2024-01-15 10:30:00");
let result = dt.convert("2024-01-15 10:30:00").unwrap();
assert_eq!(result.to_string(), "2024-01-15 10:30:00");
assert!(dt.convert("not a date").is_err());
}
#[test]
fn test_choice() {
let choice = Choice::new(["one", "two", "three"]);
assert_eq!(choice.convert("one").unwrap(), "one");
assert_eq!(choice.convert("two").unwrap(), "two");
assert!(choice.convert("four").is_err());
}
#[test]
fn test_choice_case_insensitive() {
let choice = Choice::new(["One", "Two", "Three"]).case_sensitive(false);
assert_eq!(choice.convert("one").unwrap(), "One");
assert_eq!(choice.convert("ONE").unwrap(), "One");
assert_eq!(choice.convert("oNe").unwrap(), "One");
}
#[test]
fn test_choice_shell_complete() {
let choice = Choice::new(["apple", "apricot", "banana"]);
let completions = choice.shell_complete("ap");
assert_eq!(completions.len(), 2);
assert!(completions.iter().any(|c| c.value == "apple"));
assert!(completions.iter().any(|c| c.value == "apricot"));
}
#[test]
fn test_path_type() {
let path = PathType::new();
let result = path.convert("/some/path").unwrap();
assert_eq!(result, PathBuf::from("/some/path"));
}
#[test]
fn test_tuple_type() {
let tuple = TupleType::new(["string", "int", "bool"]);
let result = tuple.convert("hello 42 true").unwrap();
assert_eq!(result.len(), 3);
assert_eq!(result[0].as_string(), Some("hello"));
assert_eq!(result[1].as_int(), Some(42));
assert_eq!(result[2].as_bool(), Some(true));
}
#[test]
fn test_tuple_type_wrong_count() {
let tuple = TupleType::new(["string", "int"]);
assert!(tuple.convert("hello").is_err());
assert!(tuple.convert("hello 42 extra").is_err());
}
#[test]
fn test_unprocessed() {
assert_eq!(UNPROCESSED.convert("raw value").unwrap(), "raw value");
assert_eq!(UNPROCESSED.name(), "TEXT");
}
#[test]
fn test_completion_item() {
let item = CompletionItem::new("test");
assert_eq!(item.value, "test");
assert_eq!(item.completion_type, "plain");
assert!(item.help.is_none());
let item = CompletionItem::with_type("path", "file").with_help("A file path");
assert_eq!(item.value, "path");
assert_eq!(item.completion_type, "file");
assert_eq!(item.help, Some("A file path".to_string()));
}
}