use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use crate::tools::shell::scan::{self, CdScan};
use super::GrepWord;
pub(super) const SPEC_FILE_FLAG: &str = "--spec-file";
const SPEC_DIR: &str = "grep-engine";
fn redirect_keeps_amp(before: &str) -> bool {
before.ends_with('>')
}
fn flush(
current: &mut String,
out: &mut Vec<(String, String)>,
conn: &str,
required: bool,
) -> bool {
let text = current.trim().to_string();
current.clear();
if text.is_empty() {
return !required;
}
out.push((text, conn.to_string()));
true
}
#[must_use]
pub(in crate::tools::shell) fn segment_command(command: &str) -> Option<Vec<(String, String)>> {
let chars: Vec<char> = command.chars().collect();
let mut out: Vec<(String, String)> = Vec::new();
let mut current = String::new();
let mut in_double = false;
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '"' {
if in_double && chars.get(i + 1) == Some(&'"') {
current.push_str("\"\"");
i += 2;
continue;
}
in_double = !in_double;
current.push('"');
i += 1;
continue;
}
if in_double {
current.push(c);
i += 1;
continue;
}
match c {
'^' | '(' | ')' | '\n' => return None,
'&' | '|' => {
if c == '&' && redirect_keeps_amp(¤t) {
current.push('&');
i += 1;
continue;
}
let doubled = chars.get(i + 1) == Some(&c);
let conn = match (c, doubled) {
('&', true) => "&&",
('&', false) => "&",
('|', true) => "||",
('|', false) => "|",
_ => unreachable!("only `&` and `|` reach this arm"),
};
if !flush(&mut current, &mut out, conn, true) {
return None;
}
i += if doubled { 2 } else { 1 };
}
_ => {
current.push(c);
i += 1;
}
}
}
if in_double {
return None;
}
flush(&mut current, &mut out, "", false);
if matches!(
out.last().map(|(_, c)| c.as_str()),
Some("&" | "&&" | "|" | "||")
) {
return None;
}
Some(out)
}
#[must_use]
pub(super) fn unquoted_semicolon(command: &str) -> bool {
let mut in_double = false;
let mut chars = command.chars().peekable();
while let Some(c) = chars.next() {
if c == '"' {
if in_double && chars.peek() == Some(&'"') {
chars.next();
continue;
}
in_double = !in_double;
continue;
}
if !in_double && c == ';' {
return true;
}
}
false
}
fn strip_double_quotes(word: &str) -> Option<&str> {
if let Some(inner) = word
.strip_prefix('"')
.and_then(|rest| rest.strip_suffix('"'))
{
return (!inner.contains('"')).then_some(inner);
}
(!word.contains('"')).then_some(word)
}
#[must_use]
pub(in crate::tools::shell) fn verb_key(word: &str) -> Option<String> {
let word = strip_double_quotes(word)?;
let bytes = word.as_bytes();
let stem = if bytes.len() >= 4 && bytes[bytes.len() - 4..].eq_ignore_ascii_case(b".exe") {
&word[..word.len() - 4]
} else {
word
};
Some(stem.to_ascii_lowercase())
}
#[must_use]
pub(super) fn grep_verb(word: &str) -> Option<&'static str> {
let key = verb_key(word)?;
if key.is_empty() || key.contains(['\\', '/']) {
return None;
}
match key.as_str() {
"grep" => Some("grep"),
"egrep" => Some("egrep"),
"fgrep" => Some("fgrep"),
_ => None,
}
}
fn flush_token(raw: &mut String, value: &mut String, out: &mut Vec<GrepWord>) {
if raw.is_empty() {
return;
}
let (redirect, needs_target) = match scan::classify_shell_token(raw) {
scan::TokenKind::Regular => (false, false),
scan::TokenKind::Redirect { needs_target } => (true, needs_target),
};
out.push(GrepWord {
value: std::mem::take(value),
raw: std::mem::take(raw),
redirect,
needs_target,
});
}
#[must_use]
pub(in crate::tools::shell) fn tokenize(segment: &str) -> Option<Vec<GrepWord>> {
let mut out = Vec::new();
let mut raw = String::new();
let mut value = String::new();
let mut in_double = false;
let mut chars = segment.chars().peekable();
while let Some(c) = chars.next() {
if c == '"' {
if in_double && chars.peek() == Some(&'"') {
chars.next();
raw.push_str("\"\"");
value.push('"');
continue;
}
in_double = !in_double;
raw.push('"');
continue;
}
if !in_double && c.is_whitespace() {
flush_token(&mut raw, &mut value, &mut out);
continue;
}
raw.push(c);
value.push(c);
}
if in_double {
return None;
}
flush_token(&mut raw, &mut value, &mut out);
Some(out)
}
#[must_use]
pub(super) fn has_glued_redirect(raw: &str) -> bool {
let mut in_quotes = false;
for (at, c) in raw.char_indices() {
match c {
'"' => in_quotes = !in_quotes,
'<' | '>' if !in_quotes && at > 0 => return true,
_ => {}
}
}
false
}
#[must_use]
pub(in crate::tools::shell) fn unquote_word(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let mut in_double = false;
let mut chars = raw.chars().peekable();
while let Some(c) = chars.next() {
if c == '"' {
if in_double && chars.peek() == Some(&'"') {
chars.next();
out.push('"');
continue;
}
in_double = !in_double;
continue;
}
out.push(c);
}
out
}
pub(super) fn cmd_quote(text: &str) -> Result<String, String> {
let forbidden = ['%', '!', '"', '\n', '\r'];
if let Some(bad) = text.chars().find(|c| forbidden.contains(c)) {
return Err(format!("argument contains `{bad}`"));
}
Ok(format!("\"{text}\""))
}
pub(super) fn spec_file_path() -> PathBuf {
static COUNTER: AtomicU64 = AtomicU64::new(0);
scratch_dir().join(format!(
"{}-{}.json",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
))
}
fn scratch_dir() -> PathBuf {
#[cfg(test)]
{
crate::util::test::test_root().join(SPEC_DIR)
}
#[cfg(not(test))]
{
PathBuf::from(crate::temp::shell_tmpdir()).join(SPEC_DIR)
}
}
pub(super) fn write_spec_file(path: &Path, json: &str) -> Result<(), String> {
if let Some(dir) = path.parent() {
fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?;
}
let _ = fs::remove_file(path);
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(path)
.map_err(|e| format!("{}: {e}", path.display()))?;
file.write_all(json.as_bytes())
.map_err(|e| format!("{}: {e}", path.display()))
}
#[must_use]
pub(super) fn is_absolute_word(word: &str) -> bool {
word.starts_with(['\\', '/']) || drive_anchor(word).is_some()
}
#[must_use]
pub(super) fn join_display(prefix: &str, name: &str) -> String {
if prefix.is_empty() || prefix.ends_with(['\\', '/']) {
format!("{prefix}{name}")
} else {
format!("{prefix}\\{name}")
}
}
#[must_use]
pub(super) fn traversal_display(root_display: &str, rel: &str) -> String {
let root = root_display.trim_end_matches(['\\', '/']);
let root = if root.is_empty() {
root_display.get(..1).unwrap_or("")
} else {
root
};
join_display(root, rel)
}
fn unc_anchor(pattern: &str) -> Option<&str> {
let rest = pattern
.strip_prefix("\\\\")
.or_else(|| pattern.strip_prefix("//"))?;
let mut parts = rest.splitn(3, ['\\', '/']);
let server = parts.next().filter(|s| !s.is_empty())?;
let share = parts.next().filter(|s| !s.is_empty())?;
parts.next()?; let len = server.len() + share.len() + 4;
Some(&pattern[..len])
}
fn drive_colon(word: &str) -> Option<usize> {
let bytes = word.as_bytes();
(bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':').then_some(1)
}
fn drive_anchor(pattern: &str) -> Option<&str> {
let colon = drive_colon(pattern)?;
if !matches!(pattern.as_bytes().get(colon + 1), Some(b'\\' | b'/')) {
return None;
}
Some(&pattern[..colon + 2])
}
#[must_use]
pub(in crate::tools::shell) fn is_drive_relative(word: &str) -> bool {
drive_colon(word).is_some() && drive_anchor(word).is_none()
}
#[must_use]
pub(super) fn split_components(pattern: &str) -> Option<(String, Vec<String>)> {
if is_drive_relative(pattern) {
return None;
}
let (prefix, rest) = if pattern.starts_with("\\\\") || pattern.starts_with("//") {
let anchor = unc_anchor(pattern)?;
(anchor.to_string(), &pattern[anchor.len()..])
} else if pattern.starts_with(['\\', '/']) {
(pattern[..1].to_string(), &pattern[1..])
} else if let Some(anchor) = drive_anchor(pattern) {
(anchor.to_string(), &pattern[anchor.len()..])
} else {
(String::new(), pattern)
};
let comps: Vec<String> = rest
.split(['\\', '/'])
.filter(|c| !c.is_empty())
.map(str::to_string)
.collect();
if comps.is_empty() {
return None;
}
Some((prefix, comps))
}
#[must_use]
pub(super) fn has_unquoted_glob(tok: &str) -> bool {
let mut in_double = false;
let mut chars = tok.chars().peekable();
while let Some(c) = chars.next() {
if c == '"' {
if in_double && chars.peek() == Some(&'"') {
chars.next();
continue;
}
in_double = !in_double;
continue;
}
if !in_double && matches!(c, '*' | '?' | '[') {
return true;
}
}
false
}
#[must_use]
pub(in crate::tools::shell) fn has_percent_expansion(word: &str) -> bool {
word.matches('%').count() >= 2
}
#[must_use]
pub(in crate::tools::shell) fn same_spelling(a: &Path, b: &Path) -> bool {
spelling_key(a) == spelling_key(b)
}
fn spelling_key(p: &Path) -> String {
let stripped = crate::util::strip_verbatim_prefix(p);
let folded = stripped.to_string_lossy().replace('/', "\\");
let trimmed = folded.trim_end_matches('\\');
if trimmed.is_empty() || trimmed.ends_with(':') {
folded.to_lowercase()
} else {
trimmed.to_lowercase()
}
}
#[must_use]
pub(super) fn cd_scan<'a>(words: &[&'a str]) -> CdScan<'a> {
let mut i = usize::from(words.first().is_some_and(|w| w.eq_ignore_ascii_case("/d")));
let Some(word) = words.get(i) else {
return CdScan::Bare;
};
if word.starts_with('/') || word.contains('\'') {
return CdScan::BadOption;
}
let Some(target) = strip_double_quotes(word) else {
return CdScan::BadOption;
};
if target.is_empty() {
return CdScan::BadOption;
}
i += 1;
CdScan::Target(target, i)
}
#[must_use]
pub(super) fn fnmatch(pattern: &str, name: &str, period: bool) -> bool {
let p: Vec<char> = pattern.chars().collect();
let n: Vec<char> = name.chars().collect();
matches_from(&p, 0, &n, 0, period)
}
fn bracket_match(p: &[char], i: usize, c: char) -> Option<(bool, usize)> {
let mut j = i + 1;
let negated = matches!(p.get(j), Some('!' | '^'));
if negated {
j += 1;
}
let mut matched = false;
if p.get(j) == Some(&']') {
matched = c == ']';
j += 1;
}
loop {
let start = *p.get(j)?;
if start == ']' {
return Some((matched != negated, j + 1));
}
let (start, next) = if start == '\\' {
(*p.get(j + 1)?, j + 2)
} else {
(start, j + 1)
};
if p.get(next) == Some(&'-') && p.get(next + 1).is_some_and(|&end| end != ']') {
let end = p[next + 1];
if start > end {
return None;
}
if (start..=end).contains(&c) {
matched = true;
}
j = next + 2;
continue;
}
if start == c {
matched = true;
}
j = next;
}
}
fn matches_from(p: &[char], mut pi: usize, n: &[char], mut ni: usize, period: bool) -> bool {
if period && ni == 0 && n.first() == Some(&'.') {
let literal_dot =
p.get(pi) == Some(&'.') || (p.get(pi) == Some(&'\\') && p.get(pi + 1) == Some(&'.'));
if !literal_dot {
return false;
}
}
loop {
let Some(&c) = p.get(pi) else {
return ni == n.len();
};
match c {
'*' => {
while p.get(pi) == Some(&'*') {
pi += 1;
}
if pi == p.len() {
return true;
}
let mut k = ni;
loop {
if matches_from(p, pi, n, k, period) {
return true;
}
if k == n.len() {
return false;
}
k += 1;
}
}
'?' => {
if ni == n.len() {
return false;
}
pi += 1;
ni += 1;
}
'[' => {
if ni == n.len() {
return false;
}
let Some((matched, next)) = bracket_match(p, pi, n[ni]) else {
return false;
};
if !matched {
return false;
}
pi = next;
ni += 1;
}
'\\' => {
let (literal, step) = match p.get(pi + 1) {
Some(&next) => (next, 2),
None => ('\\', 1),
};
if n.get(ni) != Some(&literal) {
return false;
}
pi += step;
ni += 1;
}
literal => {
if n.get(ni) != Some(&literal) {
return false;
}
pi += 1;
ni += 1;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_segments(command: &str, expected: &[(&str, &str)]) {
let got = segment_command(command)
.unwrap_or_else(|| panic!("{command:?}: expected a readable line"));
let want: Vec<(String, String)> = expected
.iter()
.map(|(s, c)| ((*s).to_string(), (*c).to_string()))
.collect();
assert_eq!(got, want, "{command:?}");
}
#[test]
fn connectors_split_like_cmd() {
assert_segments("a & b", &[("a", "&"), ("b", "")]);
assert_segments("a && b", &[("a", "&&"), ("b", "")]);
assert_segments("a | b", &[("a", "|"), ("b", "")]);
assert_segments("a || b", &[("a", "||"), ("b", "")]);
assert_segments("echo a & grep x f", &[("echo a", "&"), ("grep x f", "")]);
assert_segments(" a && b ", &[("a", "&&"), ("b", "")]);
assert!(segment_command("a &").is_none());
assert!(segment_command("a &&").is_none());
assert!(segment_command("a |").is_none());
assert!(segment_command("a ||").is_none());
assert!(segment_command("&& a").is_none());
assert!(segment_command("| a").is_none());
}
#[test]
fn quoting_and_unreadable_shapes() {
assert_segments("\"a & b\"", &[("\"a & b\"", "")]);
assert_segments("echo \"x | y\"", &[("echo \"x | y\"", "")]);
assert_segments("grep \"a\"\"&b\" f", &[("grep \"a\"\"&b\" f", "")]);
assert_segments("a 2>&1 b", &[("a 2>&1 b", "")]);
assert_segments("a >&2 b", &[("a >&2 b", "")]);
assert_segments("a >& out b", &[("a >& out b", "")]);
assert_segments("a;b", &[("a;b", "")]);
assert_segments(
"grep -n x a.txt; echo done",
&[("grep -n x a.txt; echo done", "")],
);
assert!(segment_command("echo ^& grep x f").is_none());
assert!(segment_command("(a) & b").is_none());
assert_segments("echo \"a^b(c)\"", &[("echo \"a^b(c)\"", "")]);
assert!(segment_command("\"a & b").is_none());
}
#[test]
fn a_line_break_the_shell_still_hands_over_is_refused() {
for text in ["a\nb", "a\r\nb", "a\n\nb", "a\n", "\n& a"] {
assert!(
segment_command(text).is_none(),
"a break must be refused: {text:?}"
);
}
}
#[test]
fn unquoted_semicolon_sees_only_outside_quotes() {
assert!(unquoted_semicolon("grep x a; b"));
assert!(!unquoted_semicolon("grep \"a;b\" f"));
assert!(!unquoted_semicolon("echo a\"\"b"));
assert!(unquoted_semicolon("echo \"a;b\" & c;d"));
}
#[test]
fn grep_verb_folds_case_and_the_exe_suffix() {
let rows = [
("grep", Some("grep")),
("GREP", Some("grep")),
("Grep", Some("grep")),
("grep.exe", Some("grep")),
("GREP.EXE", Some("grep")),
("Grep.EXE", Some("grep")),
("Grep.Exe", Some("grep")),
("egrep.exe", Some("egrep")),
("fgrep.exe", Some("fgrep")),
("\"grep\"", Some("grep")),
("findstr", None),
("grep.com", None),
("grep.bat", None),
(r"C:\bin\grep.exe", None),
("/usr/bin/grep", None),
("", None),
("\"grep", None),
];
for (word, expected) in rows {
assert_eq!(grep_verb(word), expected, "{word}");
}
}
#[test]
fn tokenize_reads_cmd_words() {
let words = tokenize("grep \"a b\" c").expect("balanced quotes");
let got: Vec<(&str, &str)> = words
.iter()
.map(|w| (w.value.as_str(), w.raw.as_str()))
.collect();
assert_eq!(got, [("grep", "grep"), ("a b", "\"a b\""), ("c", "c")]);
let words = tokenize("grep \"a\"\"b\" \"\"").expect("balanced quotes");
let got: Vec<&str> = words.iter().map(|w| w.value.as_str()).collect();
assert_eq!(got, ["grep", "a\"b", ""]);
let words = tokenize("grep a\"\"b").expect("balanced quotes");
assert_eq!(words[1].value, "ab");
let words = tokenize("grep 'a\\b' ^$ f").expect("balanced quotes");
let got: Vec<&str> = words.iter().map(|w| w.value.as_str()).collect();
assert_eq!(got, ["grep", "'a\\b'", "^$", "f"]);
let words = tokenize("grep \"a|b\" f").expect("balanced quotes");
assert_eq!(words.len(), 3);
assert_eq!(words[1].value, "a|b");
assert!(!words[1].redirect);
assert!(tokenize("grep \"a").is_none());
}
#[test]
fn tokenize_classifies_redirects_through_the_shared_classifier() {
let words = tokenize("grep x f 2>&1").expect("balanced quotes");
let last = words.last().expect("a token");
assert!(
last.redirect && !last.needs_target,
"2>&1 is self-contained"
);
let words = tokenize("grep x f > out.txt").expect("balanced quotes");
assert!(words[3].redirect && words[3].needs_target);
let words = tokenize("grep x < in.txt").expect("balanced quotes");
assert!(words[2].redirect && words[2].needs_target);
}
#[test]
fn unquote_word_is_idempotent_on_token_values() {
assert_eq!(unquote_word("\"a b\""), "a b");
assert_eq!(unquote_word("\"a\"\"b\""), "a\"b");
assert_eq!(unquote_word("a\"\"b"), "ab");
assert_eq!(unquote_word("a b"), "a b");
assert_eq!(unquote_word("'a'"), "'a'");
assert_eq!(unquote_word(""), "");
}
#[test]
fn cmd_quote_refuses_only_what_cmd_rewrites_inside_quotes() {
assert_eq!(cmd_quote(r"C:\ws\a b.txt").unwrap(), "\"C:\\ws\\a b.txt\"");
assert_eq!(cmd_quote("x").unwrap(), "\"x\"");
for safe in ["a&b", "a|b", "a<b", "a>b", "a(b", "a;b", "a^b", "a=b"] {
assert_eq!(cmd_quote(safe).unwrap(), format!("\"{safe}\""));
}
for bad in ["%TEMP%", "a!b", "a\"b", "a\nb", "a\rb"] {
assert!(cmd_quote(bad).is_err(), "{bad:?} must be refused");
}
}
#[test]
fn traversal_display_joins_with_the_windows_separator() {
assert_eq!(
traversal_display(r"C:\ws", r"src\main.rs"),
r"C:\ws\src\main.rs"
);
assert_eq!(
traversal_display(r"C:\ws\", r"src\main.rs"),
r"C:\ws\src\main.rs"
);
assert_eq!(traversal_display("\\", r"src\main.rs"), r"\src\main.rs");
assert_eq!(traversal_display("/", "src"), "/src");
}
#[test]
fn spec_files_are_allocated_uniquely_and_written_by_path() {
let first = spec_file_path();
let second = spec_file_path();
assert_ne!(first, second, "every member gets its own file");
assert_eq!(first.extension(), Some("json".as_ref()), "{first:?}");
let dir = tempfile::TempDir::new().expect("temp dir");
let path = dir.path().join("nested").join("one.json");
write_spec_file(&path, "{\"a\":1}").expect("write");
assert_eq!(fs::read_to_string(&path).expect("read"), "{\"a\":1}");
write_spec_file(&path, "{\"b\":2}").expect("rewrite");
assert_eq!(fs::read_to_string(&path).expect("read"), "{\"b\":2}");
assert!(write_spec_file(dir.path(), "{}").is_err());
}
#[test]
fn is_absolute_word_reads_windows_spellings() {
let rows = [
(r"C:\ws", true),
("C:/ws", true),
(r"\\srv\share", true),
("//srv/share", true),
(r"\ws", true),
("/ws", true),
("C:ws", false),
(r"C:", false),
(r"sub\ws", false),
("ws", false),
("", false),
];
for (word, expected) in rows {
assert_eq!(is_absolute_word(word), expected, "{word}");
}
}
#[test]
fn glob_patterns_split_and_join_by_their_anchor() {
let rows: &[(&str, &str, &[&str])] = &[
(r"C:\ws\*.txt", r"C:\", &["ws", "*.txt"]),
("C:/ws/*", "C:/", &["ws", "*"]),
(r"\\srv\share\a\*", r"\\srv\share\", &["a", "*"]),
("//srv/share/*", "//srv/share/", &["*"]),
(r"\sub\*", r"\", &["sub", "*"]),
("/sub/*", "/", &["sub", "*"]),
(r"sub\*.txt", "", &["sub", "*.txt"]),
("sub/*.txt", "", &["sub", "*.txt"]),
("*.txt", "", &["*.txt"]),
];
for (pattern, prefix, comps) in rows {
let got = split_components(pattern).unwrap_or_else(|| panic!("{pattern}"));
assert_eq!(got.0, *prefix, "prefix of {pattern}");
let got_c: Vec<&str> = got.1.iter().map(String::as_str).collect();
assert_eq!(got_c, *comps, "components of {pattern}");
}
for bad in [r"C:foo\*", r"\\srv\*", r"\\srv\share", "", r"C:\", r"\\"] {
assert!(split_components(bad).is_none(), "{bad} has no walk root");
}
let (prefix, comps) = split_components(r"a\\b\*").expect("relative pattern");
assert!(
prefix.is_empty(),
"a relative pattern keeps no prefix: {prefix}"
);
assert_eq!(comps, ["a", "b", "*"]);
assert_eq!(join_display(r"C:\ws", "a.txt"), r"C:\ws\a.txt");
assert_eq!(join_display(r"C:\", "ws"), r"C:\ws");
assert_eq!(join_display("", "ws"), "ws");
assert_eq!(join_display("/", "ws"), "/ws");
for (pattern, prefix, comps) in rows {
let mut built = (*prefix).to_string();
for c in *comps {
built = join_display(&built, c);
}
assert_eq!(
built.replace('/', "\\"),
pattern.replace('/', "\\"),
"round trip of {pattern}"
);
}
}
#[test]
fn has_unquoted_glob_respects_quoting() {
assert!(has_unquoted_glob("*.txt"));
assert!(has_unquoted_glob("a?c"));
assert!(has_unquoted_glob("[ab]c"));
assert!(!has_unquoted_glob("\"*.txt\""));
assert!(!has_unquoted_glob("plain.txt"));
assert!(!has_unquoted_glob("\"a\"\"*\""));
assert!(has_unquoted_glob("\"a\"\"b\"*"));
}
#[test]
fn same_spelling_ignores_case_separators_and_the_verbatim_prefix() {
assert!(same_spelling(Path::new(r"C:\ws"), Path::new(r"C:\WS\")));
assert!(same_spelling(Path::new(r"C:\ws"), Path::new(r"\\?\C:\ws")));
assert!(same_spelling(Path::new("C:/ws/"), Path::new(r"C:\ws")));
assert!(same_spelling(
Path::new(r"\\?\UNC\srv\share"),
Path::new(r"\\SRV\Share")
));
assert!(!same_spelling(Path::new(r"C:\ws\a"), Path::new(r"C:\ws")));
assert!(!same_spelling(Path::new(r"C:\"), Path::new("C:")));
assert!(same_spelling(Path::new(r"C:\"), Path::new("C:/")));
}
#[test]
fn cd_grammar_is_cmd_shaped() {
assert_eq!(cd_scan(&[]), CdScan::Bare);
assert_eq!(cd_scan(&["/d"]), CdScan::Bare);
assert_eq!(cd_scan(&["sub"]), CdScan::Target("sub", 1));
assert_eq!(cd_scan(&["/D", "sub"]), CdScan::Target("sub", 2));
assert_eq!(cd_scan(&["~"]), CdScan::Target("~", 1));
assert_eq!(cd_scan(&["-"]), CdScan::Target("-", 1));
assert_eq!(cd_scan(&["~", "sub"]), CdScan::Target("~", 1));
assert_eq!(cd_scan(&[r"~\sub"]), CdScan::Target(r"~\sub", 1));
assert_eq!(cd_scan(&[r"C:\ws"]), CdScan::Target(r"C:\ws", 1));
assert_eq!(cd_scan(&["\"my dir\""]), CdScan::Target("my dir", 1));
assert_eq!(cd_scan(&["/x"]), CdScan::BadOption);
assert_eq!(cd_scan(&["-P"]), CdScan::Target("-P", 1));
assert_eq!(cd_scan(&["'sub'"]), CdScan::BadOption);
assert_eq!(cd_scan(&["\"sub"]), CdScan::BadOption);
assert_eq!(cd_scan(&["\"\""]), CdScan::BadOption);
assert_eq!(cd_scan(&["/d", "/d"]), CdScan::BadOption);
}
}