use crate::builtins::error::Error;
use crate::env::environment::Environment as _;
use crate::{err_fmt, err_str};
use std::fs::Metadata;
use std::os::unix::prelude::{FileTypeExt as _, MetadataExt as _};
use std::time::SystemTime;
use super::prelude::*;
use crate::path::path_apply_working_directory;
use crate::wutil::{
file_id_for_path, lwstat, normalize_path, waccess, wbasename, wdirname, wrealpath, wstat,
INVALID_FILE_ID,
};
use bitflags::bitflags;
use fish_util::wcsfilecmp_glob;
use fish_wcstringutil::split_string_tok;
use libc::{mode_t, PATH_MAX, S_ISGID, S_ISUID};
use nix::unistd::{AccessFlags, Gid, Uid};
const PATH_CHUNK_SIZE: usize = PATH_MAX as usize;
#[inline]
fn arguments<'iter, 'args>(
args: &'iter [&'args wstr],
optind: &'iter mut usize,
streams: &mut IoStreams,
) -> Arguments<'args, 'iter> {
Arguments::new(args, optind, streams, PATH_CHUNK_SIZE)
}
bitflags! {
#[derive(Copy, Clone, Default)]
pub struct TypeFlags: u32 {
const BLOCK = 1 << 0;
const DIR = 1 << 1;
const FILE = 1 << 2;
const LINK = 1 << 3;
const CHAR = 1 << 4;
const FIFO = 1 << 5;
const SOCK = 1 << 6;
}
}
impl TryFrom<&wstr> for TypeFlags {
type Error = ();
fn try_from(value: &wstr) -> Result<Self, Self::Error> {
let flag = match value {
t if t == "file" => Self::FILE,
t if t == "dir" => Self::DIR,
t if t == "block" => Self::BLOCK,
t if t == "char" => Self::CHAR,
t if t == "fifo" => Self::FIFO,
t if t == "socket" => Self::SOCK,
t if t == "link" => Self::LINK,
_ => return Err(()),
};
Ok(flag)
}
}
bitflags! {
#[derive(Copy, Clone, Default)]
pub struct PermFlags: u32 {
const READ = 1 << 0;
const WRITE = 1 << 1;
const EXEC = 1 << 2;
const SUID = 1 << 3;
const SGID = 1 << 4;
const USER = 1 << 5;
const GROUP = 1 << 6;
}
}
impl PermFlags {
fn is_special(self) -> bool {
self.intersects(Self::SUID | Self::SGID | Self::USER | Self::GROUP)
}
}
impl TryFrom<&wstr> for PermFlags {
type Error = ();
fn try_from(value: &wstr) -> Result<Self, Self::Error> {
let flag = match value {
t if t == "read" => Self::READ,
t if t == "write" => Self::WRITE,
t if t == "exec" => Self::EXEC,
t if t == "suid" => Self::SUID,
t if t == "sgid" => Self::SGID,
t if t == "user" => Self::USER,
t if t == "group" => Self::GROUP,
_ => return Err(()),
};
Ok(flag)
}
}
#[derive(Default)]
struct Options<'args> {
null_in: bool,
null_out: bool,
quiet: bool,
invert_valid: bool,
invert: bool,
relative_valid: bool,
relative: bool,
reverse_valid: bool,
reverse: bool,
unique_valid: bool,
unique: bool,
key: Option<&'args wstr>,
types_valid: bool,
types: Option<TypeFlags>,
perms_valid: bool,
perms: Option<PermFlags>,
arg1: Option<&'args wstr>,
no_ext_valid: bool,
no_ext: bool,
all_valid: bool,
all: bool,
}
#[inline]
fn path_out(streams: &mut IoStreams, opts: &Options<'_>, s: impl AsRef<wstr>) {
let s = s.as_ref();
if !opts.quiet {
if !opts.null_out {
streams
.out
.append_with_separation(s, SeparationType::explicitly, true);
} else {
let mut output = WString::with_capacity(s.len() + 1);
output.push_utfstr(s);
output.push('\0');
streams.out.append(&output);
}
}
}
fn construct_short_opts(opts: &Options) -> WString {
let mut short_opts = WString::from("zZq");
if opts.perms_valid {
short_opts += L!("p:");
short_opts += L!("rwx");
}
if opts.types_valid {
short_opts += L!("t:");
short_opts += L!("fld");
}
if opts.invert_valid {
short_opts.push('v');
}
if opts.relative_valid {
short_opts.push('R');
}
if opts.reverse_valid {
short_opts.push('r');
}
if opts.unique_valid {
short_opts.push('u');
}
if opts.no_ext_valid {
short_opts.push('E');
}
short_opts
}
const LONG_OPTIONS: [WOption<'static>; 12] = [
wopt(L!("quiet"), NoArgument, 'q'),
wopt(L!("null-in"), NoArgument, 'z'),
wopt(L!("null-out"), NoArgument, 'Z'),
wopt(L!("perm"), RequiredArgument, 'p'),
wopt(L!("type"), RequiredArgument, 't'),
wopt(L!("invert"), NoArgument, 'v'),
wopt(L!("relative"), NoArgument, 'R'),
wopt(L!("reverse"), NoArgument, 'r'),
wopt(L!("unique"), NoArgument, 'u'),
wopt(L!("key"), RequiredArgument, NON_OPTION_CHAR),
wopt(L!("no-extension"), NoArgument, 'E'),
wopt(L!("all"), NoArgument, '\x02'),
];
fn parse_opts<'args>(
opts: &mut Options<'args>,
optind: &mut usize,
n_req_args: usize,
args: &mut [&'args wstr],
parser: &Parser,
streams: &mut IoStreams,
) -> BuiltinResult {
let cmd = L!("path");
let subcmd = args[0];
let mut args_read = Vec::with_capacity(args.len());
args_read.extend_from_slice(args);
let short_opts = construct_short_opts(opts);
let mut w = WGetopter::new(&short_opts, &LONG_OPTIONS, args);
while let Some(c) = w.next_opt() {
match c {
':' => {
builtin_missing_argument(
parser,
streams,
cmd,
Some(subcmd),
args_read[w.wopt_index - 1],
false,
);
return Err(STATUS_INVALID_ARGS);
}
';' => {
err_fmt!(Error::UNEXP_OPT_ARG, args_read[w.wopt_index - 1])
.subcmd(cmd, subcmd)
.finish(streams);
return Err(STATUS_INVALID_ARGS);
}
'?' => {
err_fmt!(Error::UNKNOWN_OPT, args_read[w.wopt_index - 1])
.subcmd(cmd, subcmd)
.full_trailer(parser)
.finish(streams);
return Err(STATUS_INVALID_ARGS);
}
'q' => {
opts.quiet = true;
continue;
}
'z' => {
opts.null_in = true;
continue;
}
'Z' => {
opts.null_out = true;
continue;
}
'v' if opts.invert_valid => {
opts.invert = true;
continue;
}
't' if opts.types_valid => {
let types = opts.types.get_or_insert_default();
let types_args = split_string_tok(w.woptarg.unwrap(), L!(","), None);
for t in types_args {
let Ok(r#type) = t.try_into() else {
err_fmt!("Invalid type '%s'", t)
.subcmd(cmd, subcmd)
.finish(streams);
return Err(STATUS_INVALID_ARGS);
};
*types |= r#type;
}
continue;
}
'p' if opts.perms_valid => {
let perms = opts.perms.get_or_insert_default();
let perms_args = split_string_tok(w.woptarg.unwrap(), L!(","), None);
for p in perms_args {
let Ok(perm) = p.try_into() else {
err_fmt!("Invalid permission '%s'", p)
.subcmd(cmd, subcmd)
.finish(streams);
return Err(STATUS_INVALID_ARGS);
};
*perms |= perm;
}
continue;
}
'r' if opts.reverse_valid => {
opts.reverse = true;
continue;
}
'r' if opts.perms_valid => {
let perms = opts.perms.get_or_insert_default();
*perms |= PermFlags::READ;
continue;
}
'w' if opts.perms_valid => {
let perms = opts.perms.get_or_insert_default();
*perms |= PermFlags::WRITE;
continue;
}
'x' if opts.perms_valid => {
let perms = opts.perms.get_or_insert_default();
*perms |= PermFlags::EXEC;
continue;
}
'f' if opts.types_valid => {
let types = opts.types.get_or_insert_default();
*types |= TypeFlags::FILE;
continue;
}
'l' if opts.types_valid => {
let types = opts.types.get_or_insert_default();
*types |= TypeFlags::LINK;
continue;
}
'd' if opts.types_valid => {
let types = opts.types.get_or_insert_default();
*types |= TypeFlags::DIR;
continue;
}
'u' if opts.unique_valid => {
opts.unique = true;
continue;
}
'R' if opts.relative_valid => {
opts.relative = true;
continue;
}
'E' if opts.no_ext_valid => {
opts.no_ext = true;
continue;
}
NON_OPTION_CHAR => {
assert!(w.woptarg.is_some());
opts.key = w.woptarg;
continue;
}
'\x02' if opts.all_valid => {
opts.all = true;
continue;
}
_ => {
err_fmt!(Error::UNKNOWN_OPT, args_read[w.wopt_index - 1])
.subcmd(cmd, subcmd)
.full_trailer(parser)
.finish(streams);
return Err(STATUS_INVALID_ARGS);
}
}
}
*optind = w.wopt_index;
if n_req_args != 0 {
assert_eq!(n_req_args, 1);
opts.arg1 = args.get(*optind).copied();
if opts.arg1.is_some() {
*optind += 1;
}
if opts.arg1.is_none() && n_req_args == 1 {
err_str!(Error::MISSING_ARG)
.subcmd(cmd, subcmd)
.finish(streams);
return Err(STATUS_INVALID_ARGS);
}
}
if streams.stdin_is_directly_redirected && args.len() > *optind {
err_str!(Error::TOO_MANY_ARGUMENTS)
.subcmd(cmd, subcmd)
.finish(streams);
return Err(STATUS_INVALID_ARGS);
}
Ok(SUCCESS)
}
fn path_transform(
parser: &Parser,
streams: &mut IoStreams,
args: &mut [&wstr],
func: impl Fn(&wstr) -> WString,
custom_opts: impl Fn(&mut Options),
) -> BuiltinResult {
let mut opts = Options::default();
custom_opts(&mut opts);
let mut optind = 0;
parse_opts(&mut opts, &mut optind, 0, args, parser, streams)?;
let mut n_transformed = 0;
let arguments = arguments(args, &mut optind, streams).with_split_behavior(match opts.null_in {
true => SplitBehavior::Null,
false => SplitBehavior::InferNull,
});
for InputValue { arg, .. } in arguments {
if arg.is_empty() {
continue;
}
let mut transformed = func(&arg);
if opts.no_ext {
if let Some(idx) = find_extension(&transformed) {
transformed.truncate(idx);
}
}
if transformed != arg {
n_transformed += 1;
if opts.quiet {
return Ok(SUCCESS);
}
}
path_out(streams, &opts, transformed);
}
if n_transformed > 0 {
Ok(SUCCESS)
} else {
Err(STATUS_CMD_ERROR)
}
}
fn path_basename(parser: &Parser, streams: &mut IoStreams, args: &mut [&wstr]) -> BuiltinResult {
path_transform(
parser,
streams,
args,
|s| wbasename(s).to_owned(),
|opts| {
opts.no_ext_valid = true;
},
)
}
fn path_dirname(parser: &Parser, streams: &mut IoStreams, args: &mut [&wstr]) -> BuiltinResult {
path_transform(parser, streams, args, |s| wdirname(s).to_owned(), |_| {})
}
fn normalize_help(path: &wstr) -> WString {
let mut np = normalize_path(path, false);
if !np.is_empty() && np.char_at(0) == '-' {
np = "./".chars().chain(np.chars()).collect();
}
np
}
fn path_normalize(parser: &Parser, streams: &mut IoStreams, args: &mut [&wstr]) -> BuiltinResult {
path_transform(parser, streams, args, normalize_help, |_| {})
}
fn path_mtime(parser: &Parser, streams: &mut IoStreams, args: &mut [&wstr]) -> BuiltinResult {
let mut opts = Options {
relative_valid: true,
..Default::default()
};
let mut optind = 0;
parse_opts(&mut opts, &mut optind, 0, args, parser, streams)?;
let mut n_transformed = 0;
let t = match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) {
Ok(dur) => dur.as_secs() as i64,
Err(err) => -(err.duration().as_secs() as i64),
};
let arguments = arguments(args, &mut optind, streams).with_split_behavior(match opts.null_in {
true => SplitBehavior::Null,
false => SplitBehavior::InferNull,
});
for InputValue { arg, .. } in arguments {
let ret = file_id_for_path(&arg);
if ret != INVALID_FILE_ID {
if opts.quiet {
return Ok(SUCCESS);
}
n_transformed += 1;
if !opts.relative {
path_out(streams, &opts, (ret.mod_seconds).to_wstring());
} else {
#[allow(clippy::unnecessary_cast)]
path_out(streams, &opts, (t - ret.mod_seconds as i64).to_wstring());
}
}
}
if n_transformed > 0 {
Ok(SUCCESS)
} else {
Err(STATUS_CMD_ERROR)
}
}
fn find_extension(path: &wstr) -> Option<usize> {
let filename = wbasename(path);
if filename == "." || filename == ".." {
return None;
}
let pos = filename.chars().rposition(|c| c == '.');
match pos {
None | Some(0) => None,
Some(pos) => Some(pos + path.len() - filename.len()),
}
}
fn path_extension(parser: &Parser, streams: &mut IoStreams, args: &mut [&wstr]) -> BuiltinResult {
let mut opts = Options::default();
let mut optind = 0;
parse_opts(&mut opts, &mut optind, 0, args, parser, streams)?;
let mut n_transformed = 0;
let arguments = arguments(args, &mut optind, streams).with_split_behavior(match opts.null_in {
true => SplitBehavior::Null,
false => SplitBehavior::InferNull,
});
for InputValue { arg, .. } in arguments {
let pos = find_extension(&arg);
let Some(pos) = pos else {
path_out(streams, &opts, L!(""));
continue;
};
let ext = arg.slice_from(pos);
if opts.quiet && !ext.is_empty() {
return Ok(SUCCESS);
}
path_out(streams, &opts, ext);
n_transformed += 1;
}
if n_transformed > 0 {
Ok(SUCCESS)
} else {
Err(STATUS_CMD_ERROR)
}
}
fn path_change_extension(
parser: &Parser,
streams: &mut IoStreams,
args: &mut [&wstr],
) -> BuiltinResult {
let mut opts = Options::default();
let mut optind = 0;
parse_opts(&mut opts, &mut optind, 1, args, parser, streams)?;
let mut n_transformed = 0usize;
let arguments = arguments(args, &mut optind, streams).with_split_behavior(match opts.null_in {
true => SplitBehavior::Null,
false => SplitBehavior::InferNull,
});
for InputValue { mut arg, .. } in arguments {
let pos = find_extension(&arg);
let mut ext = match pos {
Some(pos) => {
arg.to_mut().truncate(pos);
arg.into_owned()
}
None => arg.into_owned(),
};
if let Some(replacement) = opts.arg1 {
if !replacement.is_empty() {
if replacement.char_at(0) != '.' {
ext.push('.');
}
ext.push_utfstr(replacement);
}
}
path_out(streams, &opts, ext);
n_transformed += 1;
}
if n_transformed > 0 {
Ok(SUCCESS)
} else {
Err(STATUS_CMD_ERROR)
}
}
fn path_resolve(parser: &Parser, streams: &mut IoStreams, args: &mut [&wstr]) -> BuiltinResult {
let mut opts = Options::default();
let mut optind = 0;
parse_opts(&mut opts, &mut optind, 0, args, parser, streams)?;
let mut n_transformed = 0usize;
let arguments = arguments(args, &mut optind, streams).with_split_behavior(match opts.null_in {
true => SplitBehavior::Null,
false => SplitBehavior::InferNull,
});
for InputValue { arg, .. } in arguments {
let mut real = match wrealpath(&arg) {
Some(p) => p,
None => {
let mut next = arg.into_owned();
if !next.is_empty() && next.char_at(0) != '/' {
next = path_apply_working_directory(&next, &parser.vars().get_pwd_slash());
}
let mut rest = wbasename(&next).to_owned();
let mut real = None;
while !next.is_empty() && next != "/" {
next = wdirname(&next).to_owned();
real = wrealpath(&next);
if let Some(ref mut real) = real {
real.push('/');
real.push_utfstr(&rest);
*real = normalize_path(real, false);
break;
}
rest = (wbasename(&next).to_owned() + L!("/")) + rest.as_utfstr();
}
match real {
Some(p) => p,
None => continue,
}
}
};
real = normalize_path(&real, false);
if opts.quiet {
return Ok(SUCCESS);
}
path_out(streams, &opts, real);
n_transformed += 1;
}
if n_transformed > 0 {
Ok(SUCCESS)
} else {
Err(STATUS_CMD_ERROR)
}
}
fn path_sort(parser: &Parser, streams: &mut IoStreams, args: &mut [&wstr]) -> BuiltinResult {
let mut opts = Options {
reverse_valid: true,
unique_valid: true,
..Default::default()
};
let mut optind = 0;
parse_opts(&mut opts, &mut optind, 0, args, parser, streams)?;
let keyfunc: fn(&wstr) -> &wstr = match &opts.key {
Some(k) if k == "basename" => wbasename,
Some(k) if k == "dirname" => wdirname,
Some(k) if k == "path" => {
opts.key = None;
wbasename
}
None => wbasename,
Some(k) => {
err_fmt!("Invalid sort key '%s'", k)
.subcmd(L!("path"), args[0])
.finish(streams);
return Err(STATUS_INVALID_ARGS);
}
};
let arguments = arguments(args, &mut optind, streams).with_split_behavior(match opts.null_in {
true => SplitBehavior::Null,
false => SplitBehavior::InferNull,
});
let mut list: Vec<_> = arguments.map(|input_value| input_value.arg).collect();
if opts.key.is_some() {
list.sort_by(|a, b| match wcsfilecmp_glob(keyfunc(a), keyfunc(b)) {
order if opts.reverse => order.reverse(),
order => order,
});
if opts.unique {
list.dedup_by(|a, b| keyfunc(a) == keyfunc(b));
}
} else {
list.sort_by(|a, b| match wcsfilecmp_glob(a, b) {
order if opts.reverse => order.reverse(),
order => order,
});
if opts.unique {
list.dedup();
}
}
for entry in list {
path_out(streams, &opts, &entry);
}
Ok(SUCCESS)
}
fn filter_path(opts: &Options, path: &wstr, uid: Option<Uid>, gid: Option<Gid>) -> bool {
if opts.types.is_none() && opts.perms.is_none() {
return true;
}
let mut metadata: Option<Metadata> = None;
if let Some(t) = opts.types {
let mut type_ok = false;
if t.contains(TypeFlags::LINK) {
let md = lwstat(path);
type_ok = md.as_ref().is_ok_and(Metadata::is_symlink);
}
let Ok(md) = wstat(path) else {
return false;
};
let ft = md.file_type();
type_ok = match type_ok {
true => true,
_ if t.contains(TypeFlags::FILE) && ft.is_file() => true,
_ if t.contains(TypeFlags::DIR) && ft.is_dir() => true,
_ if t.contains(TypeFlags::BLOCK) && ft.is_block_device() => true,
_ if t.contains(TypeFlags::CHAR) && ft.is_char_device() => true,
_ if t.contains(TypeFlags::FIFO) && ft.is_fifo() => true,
_ if t.contains(TypeFlags::SOCK) && ft.is_socket() => true,
_ => false,
};
if !type_ok {
return false;
}
metadata = Some(md);
}
if let Some(perm) = opts.perms {
let mut amode = AccessFlags::empty();
if perm.contains(PermFlags::READ) {
amode.insert(AccessFlags::R_OK);
}
if perm.contains(PermFlags::WRITE) {
amode.insert(AccessFlags::W_OK);
}
if perm.contains(PermFlags::EXEC) {
amode.insert(AccessFlags::X_OK);
}
if !amode.is_empty() && waccess(path, amode).is_err() {
return false;
}
if perm.is_special() {
let md = match metadata {
Some(n) => n,
_ => {
let Ok(md) = wstat(path) else {
return false;
};
md
}
};
#[allow(clippy::if_same_then_else)]
if perm.contains(PermFlags::SUID) && (md.mode() as mode_t & S_ISUID) == 0 {
return false;
} else if perm.contains(PermFlags::SGID) && (md.mode() as mode_t & S_ISGID) == 0 {
return false;
} else if perm.contains(PermFlags::USER) && uid.map(|u| u.as_raw()) != Some(md.uid()) {
return false;
} else if perm.contains(PermFlags::GROUP) && gid.map(|g| g.as_raw()) != Some(md.gid()) {
return false;
}
}
}
true
}
fn path_filter_maybe_is(
parser: &Parser,
streams: &mut IoStreams,
args: &mut [&wstr],
is_is: bool,
) -> BuiltinResult {
let mut opts = Options {
types_valid: true,
perms_valid: true,
invert_valid: true,
all_valid: true,
..Default::default()
};
let mut optind = 0;
parse_opts(&mut opts, &mut optind, 0, args, parser, streams)?;
if is_is {
opts.quiet = true;
}
let mut n_transformed = 0;
let arguments = arguments(args, &mut optind, streams).with_split_behavior(match opts.null_in {
true => SplitBehavior::Null,
false => SplitBehavior::InferNull,
});
let uid = if opts.perms.unwrap_or_default().contains(PermFlags::USER) {
Some(Uid::effective())
} else {
None
};
let gid = if opts.perms.unwrap_or_default().contains(PermFlags::GROUP) {
Some(Gid::effective())
} else {
None
};
let arguments_vec: Vec<_> = arguments.collect();
for InputValue { arg, .. } in arguments_vec.iter().filter(|&InputValue { arg, .. }| {
(opts.perms.is_none() && opts.types.is_none())
|| (filter_path(&opts, arg, uid, gid) != opts.invert)
}) {
if opts.perms.is_none() && opts.types.is_none() {
let ok = waccess(arg, AccessFlags::F_OK).is_ok();
if ok == opts.invert {
if opts.all {
return Err(STATUS_CMD_ERROR);
}
continue;
}
}
n_transformed += 1;
if opts.all {
continue;
}
if !arg.is_empty() && arg.starts_with('-') {
let out = WString::from("./") + arg.as_ref();
path_out(streams, &opts, out);
} else {
path_out(streams, &opts, arg);
}
if opts.quiet {
return Ok(SUCCESS);
}
}
if opts.all && n_transformed != arguments_vec.len() {
return Err(STATUS_CMD_ERROR);
}
if n_transformed > 0 {
Ok(SUCCESS)
} else {
Err(STATUS_CMD_ERROR)
}
}
fn path_filter(parser: &Parser, streams: &mut IoStreams, args: &mut [&wstr]) -> BuiltinResult {
path_filter_maybe_is(parser, streams, args, false)
}
fn path_is(parser: &Parser, streams: &mut IoStreams, args: &mut [&wstr]) -> BuiltinResult {
path_filter_maybe_is(parser, streams, args, true)
}
pub fn path(parser: &Parser, streams: &mut IoStreams, args: &mut [&wstr]) -> BuiltinResult {
let Some(&cmd) = args.first() else {
return Err(STATUS_INVALID_ARGS);
};
let argc = args.len();
if argc <= 1 {
err_str!(Error::MISSING_SUBCMD)
.cmd(cmd)
.full_trailer(parser)
.finish(streams);
return Err(STATUS_INVALID_ARGS);
}
if args[1] == "-h" || args[1] == "--help" {
builtin_print_help(parser, streams, cmd);
return Ok(SUCCESS);
}
let subcmd_name = args[1];
let subcmd: BuiltinCmd = match subcmd_name.to_string().as_str() {
"basename" => path_basename,
"change-extension" => path_change_extension,
"dirname" => path_dirname,
"extension" => path_extension,
"filter" => path_filter,
"is" => path_is,
"mtime" => path_mtime,
"normalize" => path_normalize,
"resolve" => path_resolve,
"sort" => path_sort,
_ => {
err_str!(Error::INVALID_SUBCMD)
.subcmd(cmd, subcmd_name)
.full_trailer(parser)
.finish(streams);
return Err(STATUS_INVALID_ARGS);
}
};
if argc >= 3 && (args[2] == "-h" || args[2] == "--help") {
builtin_print_help(parser, streams, cmd);
return Ok(SUCCESS);
}
let args = &mut args[1..];
subcmd(parser, streams, args)
}
#[cfg(test)]
mod tests {
use super::find_extension;
use crate::prelude::*;
#[test]
fn test_find_extension() {
let cases = [
(L!("foo.wmv"), Some(3)),
(L!("verylongfilename.wmv"), Some("verylongfilename".len())),
(L!("foo"), None),
(L!(".foo"), None),
(L!("./foo.wmv"), Some(5)),
];
for (f, ext_idx) in cases {
assert_eq!(find_extension(f), ext_idx);
}
}
}