Skip to main content

nu_command/filesystem/
glob.rs

1use nu_engine::command_prelude::*;
2use nu_protocol::{ListStream, Signals, shell_error::generic::GenericError};
3use wax::{
4    Glob as WaxGlob, any, walk::DepthBehavior, walk::DepthMax, walk::Entry, walk::FileIterator,
5    walk::GlobEntry, walk::LinkBehavior, walk::WalkBehavior,
6};
7
8#[derive(Clone)]
9pub struct Glob;
10
11impl Command for Glob {
12    fn name(&self) -> &str {
13        "glob"
14    }
15
16    fn signature(&self) -> Signature {
17        let signature = Signature::build("glob")
18            .input_output_types(vec![(Type::Nothing, Type::List(Box::new(Type::String)))])
19            .required(
20                "glob",
21                SyntaxShape::OneOf(vec![SyntaxShape::String, SyntaxShape::GlobPattern]),
22                "The glob expression.",
23            )
24            .named(
25                "depth",
26                SyntaxShape::Int,
27                "Directory depth to search.",
28                Some('d'),
29            )
30            .switch(
31                "no-dir",
32                "Whether to filter out directories from the returned paths.",
33                Some('D'),
34            )
35            .switch(
36                "no-file",
37                "Whether to filter out files from the returned paths.",
38                Some('F'),
39            )
40            .switch(
41                "no-symlink",
42                "Whether to filter out symlinks from the returned paths.",
43                Some('S'),
44            )
45            .switch(
46                "follow-symlinks",
47                "Whether to follow symbolic links to their targets.",
48                Some('l'),
49            )
50            .named(
51                "exclude",
52                SyntaxShape::List(Box::new(SyntaxShape::String)),
53                "Patterns to exclude from the search: `glob` will not walk the inside of directories matching the excluded patterns.",
54                Some('e'),
55            )
56            .category(Category::FileSystem);
57
58        if !nu_experimental::DC_GLOB.get() {
59            return signature;
60        }
61
62        signature
63            .switch(
64                "ignore-case",
65                "Whether to ignore case when matching the glob pattern.",
66                Some('i'),
67            )
68            .rest(
69                "debug-args",
70                SyntaxShape::String,
71                "Additional positional args used by --dbg-matches/--dbg-glob.",
72            )
73            .switch(
74                "dbg-parse",
75                "Use dc-glob debug parser mode. Requires one positional pattern.",
76                None,
77            )
78            .switch(
79                "dbg-compile",
80                "Use dc-glob debug compile mode. Requires one positional pattern.",
81                None,
82            )
83            .switch(
84                "dbg-matches",
85                "Use dc-glob debug match mode. Requires pattern and optional path positional args.",
86                None,
87            )
88            .switch(
89                "dbg-glob",
90                "Use dc-glob debug glob mode. Requires pattern and optional relative-to positional args.",
91                None,
92            )
93    }
94
95    fn description(&self) -> &str {
96        "Creates a list of files and/or folders based on the glob pattern provided."
97    }
98
99    fn search_terms(&self) -> Vec<&str> {
100        vec!["pattern", "files", "folders", "list", "ls"]
101    }
102
103    fn examples(&self) -> Vec<Example<'_>> {
104        vec![
105            Example {
106                description: "Search for *.rs files.",
107                example: "glob *.rs",
108                result: None,
109            },
110            Example {
111                description: "Search for *.rs and *.toml files recursively up to 2 folders deep.",
112                example: "glob **/*.{rs,toml} --depth 2",
113                result: None,
114            },
115            Example {
116                description: "Search for files and folders that begin with uppercase C or lowercase c.",
117                example: r#"glob "[Cc]*""#,
118                result: None,
119            },
120            Example {
121                description: "Search for files and folders like abc or xyz substituting a character for ?.",
122                example: r#"glob "{a?c,x?z}""#,
123                result: None,
124            },
125            Example {
126                description: "A case-insensitive search for files and folders that begin with c.",
127                example: if nu_experimental::DC_GLOB.get() {
128                    "glob c* --ignore-case"
129                } else {
130                    r#"glob "(?i)c*""#
131                },
132                result: None,
133            },
134            Example {
135                description: "Search for files or folders that do not begin with c, C, b, M, or s.",
136                example: r#"glob "[!cCbMs]*""#,
137                result: None,
138            },
139            Example {
140                description: "Search for files or folders with 3 a's in a row in the name.",
141                example: "glob <a*:3>",
142                result: None,
143            },
144            Example {
145                description: "Search for files or folders with only a, b, c, or d in the file name between 1 and 10 times.",
146                example: "glob <[a-d]:1,10>",
147                result: None,
148            },
149            Example {
150                description: "Search for folders that begin with an uppercase ASCII letter, ignoring files and symlinks.",
151                example: r#"glob "[A-Z]*" --no-file --no-symlink"#,
152                result: None,
153            },
154            Example {
155                description: "Search for files named tsconfig.json that are not in node_modules directories.",
156                example: "glob **/tsconfig.json --exclude [**/node_modules/**]",
157                result: None,
158            },
159            Example {
160                description: "Search for all files that are not in the target nor .git directories.",
161                example: "glob **/* --exclude [**/target/** **/.git/** */]",
162                result: None,
163            },
164            Example {
165                description: "Search for files following symbolic links to their targets.",
166                example: r#"glob "**/*.txt" --follow-symlinks"#,
167                result: None,
168            },
169        ]
170    }
171
172    fn extra_description(&self) -> &str {
173        if nu_experimental::DC_GLOB.get() {
174            ""
175        } else {
176            "For more glob pattern help, please refer to https://docs.rs/crate/wax/latest."
177        }
178    }
179
180    fn run(
181        &self,
182        engine_state: &EngineState,
183        stack: &mut Stack,
184        call: &Call,
185        _input: PipelineData,
186    ) -> Result<PipelineData, ShellError> {
187        let span = call.head;
188        let glob_pattern_input: Value = call.req(engine_state, stack, 0)?;
189
190        if nu_experimental::DC_GLOB.get() {
191            let has_dbg_flags = call.get_flag_span(stack, "dbg-parse").is_some()
192                || call.get_flag_span(stack, "dbg-compile").is_some()
193                || call.get_flag_span(stack, "dbg-matches").is_some()
194                || call.get_flag_span(stack, "dbg-glob").is_some();
195
196            if has_dbg_flags {
197                let dbg_parse = call.has_flag(engine_state, stack, "dbg-parse")?;
198                let dbg_compile = call.has_flag(engine_state, stack, "dbg-compile")?;
199                let dbg_matches = call.has_flag(engine_state, stack, "dbg-matches")?;
200                let dbg_glob = call.has_flag(engine_state, stack, "dbg-glob")?;
201
202                let dbg_modes = [dbg_parse, dbg_compile, dbg_matches, dbg_glob]
203                    .into_iter()
204                    .filter(|set| *set)
205                    .count();
206
207                if dbg_modes > 1 {
208                    return Err(ShellError::IncompatibleParametersSingle {
209                        msg:
210                            "use only one of --dbg-parse, --dbg-compile, --dbg-matches, --dbg-glob"
211                                .to_string(),
212                        span,
213                    });
214                }
215
216                if dbg_modes == 1 {
217                    let args = call.rest::<Spanned<String>>(engine_state, stack, 0)?;
218                    let subcommand = if dbg_parse {
219                        "dbg-parse"
220                    } else if dbg_compile {
221                        "dbg-compile"
222                    } else if dbg_matches {
223                        "dbg-matches"
224                    } else {
225                        "dbg-glob"
226                    };
227
228                    return run_debug_subcommand(
229                        engine_state,
230                        stack,
231                        subcommand.to_string(),
232                        args,
233                        span,
234                    );
235                }
236            }
237        }
238
239        let glob_span = glob_pattern_input.span();
240        let depth = call.get_flag(engine_state, stack, "depth")?;
241        let no_dirs = call.has_flag(engine_state, stack, "no-dir")?;
242        let no_files = call.has_flag(engine_state, stack, "no-file")?;
243        let no_symlinks = call.has_flag(engine_state, stack, "no-symlink")?;
244        let follow_symlinks = call.has_flag(engine_state, stack, "follow-symlinks")?;
245        let paths_to_exclude: Option<Value> = call.get_flag(engine_state, stack, "exclude")?;
246
247        let (not_patterns, not_pattern_span): (Vec<String>, Span) = match paths_to_exclude {
248            None => (vec![], span),
249            Some(f) => {
250                let pat_span = f.span();
251                match f {
252                    Value::List { vals: pats, .. } => {
253                        let p = convert_patterns(pats.as_slice())?;
254                        (p, pat_span)
255                    }
256                    _ => (vec![], span),
257                }
258            }
259        };
260
261        let glob_pattern =
262            match glob_pattern_input {
263                Value::String { val, .. } | Value::Glob { val, .. } => val,
264                _ => return Err(ShellError::IncorrectValue {
265                    msg: "Incorrect glob pattern supplied to glob. Please use string or glob only."
266                        .to_string(),
267                    val_span: call.head,
268                    call_span: glob_span,
269                }),
270            };
271
272        let extra_args = call.rest::<Spanned<String>>(engine_state, stack, 1)?;
273        if !extra_args.is_empty() {
274            return Err(ShellError::IncompatibleParametersSingle {
275                msg: "extra positional argument".to_string(),
276                span: extra_args[0].span,
277            });
278        }
279
280        if glob_pattern.is_empty() {
281            return Err(ShellError::Generic(
282                GenericError::new(
283                    "glob pattern must not be empty",
284                    "glob pattern is empty",
285                    glob_span,
286                )
287                .with_help("add characters to the glob pattern"),
288            ));
289        }
290
291        match nu_experimental::DC_GLOB.get() {
292            true => {
293                let ignore_case = call.has_flag(engine_state, stack, "ignore-case")?;
294                run_dc_glob(
295                    engine_state,
296                    stack,
297                    &glob_pattern,
298                    depth,
299                    follow_symlinks,
300                    not_patterns,
301                    glob_span,
302                    no_dirs,
303                    no_files,
304                    no_symlinks,
305                    ignore_case,
306                    span,
307                )
308            }
309            false => {
310                // paths starting with drive letters must be escaped for wax on Windows
311                #[cfg(windows)]
312                let glob_pattern = patch_windows_glob_pattern(glob_pattern, glob_span)?;
313
314                run_legacy_glob(
315                    engine_state,
316                    stack,
317                    &glob_pattern,
318                    depth,
319                    follow_symlinks,
320                    not_patterns,
321                    glob_span,
322                    not_pattern_span,
323                    no_dirs,
324                    no_files,
325                    no_symlinks,
326                    span,
327                )
328            }
329        }
330    }
331}
332
333fn infer_folder_depth(glob_pattern: &str, depth: Option<usize>) -> usize {
334    if let Some(depth) = depth {
335        depth
336    } else if glob_pattern.contains("**") {
337        usize::MAX
338    } else if glob_pattern.contains('/') {
339        glob_pattern.split('/').count() + 1
340    } else {
341        1
342    }
343}
344
345#[allow(clippy::too_many_arguments)]
346fn run_dc_glob(
347    engine_state: &EngineState,
348    stack: &Stack,
349    glob_pattern: &str,
350    depth: Option<usize>,
351    follow_symlinks: bool,
352    not_patterns: Vec<String>,
353    glob_span: Span,
354    no_dirs: bool,
355    no_files: bool,
356    no_symlinks: bool,
357    ignore_case: bool,
358    span: Span,
359) -> Result<PipelineData, ShellError> {
360    let folder_depth = infer_folder_depth(glob_pattern, depth);
361    let cwd = engine_state.cwd(Some(stack))?;
362    let options = nu_glob::dc_glob::GlobWalkOptions {
363        max_depth: (folder_depth != usize::MAX).then_some(folder_depth),
364        follow_symlinks,
365        excludes: not_patterns,
366        interrupt: engine_state.signals().interrupt_flag(),
367        ignore_case,
368    };
369    let cwd_for_matches = cwd.as_std_path().to_path_buf();
370    let glob_pattern = nu_path::expand_tilde(glob_pattern)
371        .to_string_lossy()
372        .to_string();
373
374    let matches =
375        nu_glob::dc_glob::glob_with(cwd.as_std_path(), &glob_pattern, &options).map_err(|err| {
376            ShellError::Generic(GenericError::new(
377                "error with glob pattern",
378                err.to_string(),
379                glob_span,
380            ))
381        })?;
382
383    let matches = matches.map(move |item| {
384        item.map(|path| {
385            if path.is_absolute() {
386                path
387            } else {
388                cwd_for_matches.join(path)
389            }
390        })
391        .map_err(|err| {
392            ShellError::Generic(GenericError::new(
393                "error with glob pattern",
394                err.to_string(),
395                glob_span,
396            ))
397        })
398    });
399
400    let values = glob_paths_to_value(
401        engine_state.signals(),
402        matches,
403        no_dirs,
404        no_files,
405        no_symlinks,
406        span,
407    );
408
409    Ok(values.into_pipeline_data(span, engine_state.signals().clone()))
410}
411
412#[allow(clippy::too_many_arguments)]
413fn run_legacy_glob(
414    engine_state: &EngineState,
415    stack: &Stack,
416    glob_pattern: &str,
417    depth: Option<usize>,
418    follow_symlinks: bool,
419    not_patterns: Vec<String>,
420    glob_span: Span,
421    not_pattern_span: Span,
422    no_dirs: bool,
423    no_files: bool,
424    no_symlinks: bool,
425    span: Span,
426) -> Result<PipelineData, ShellError> {
427    // below we have to check / instead of MAIN_SEPARATOR because glob uses / as separator
428    // using a glob like **\\*.rs should fail because it's not a valid glob pattern
429    let folder_depth = infer_folder_depth(glob_pattern, depth);
430
431    let (prefix, glob) = match WaxGlob::new(glob_pattern) {
432        Ok(p) => p.partition_or_empty(),
433        Err(e) => {
434            return Err(ShellError::Generic(GenericError::new(
435                "error with glob pattern",
436                format!("{e}"),
437                glob_span,
438            )));
439        }
440    };
441
442    let path = engine_state.cwd_as_string(Some(stack))?;
443    let path = nu_path::absolute_with(prefix, path).map_err(|e| {
444        ShellError::Generic(GenericError::new("invalid path", format!("{e}"), glob_span))
445    })?;
446    let path = match path.try_exists() {
447        Ok(true) => path,
448        Ok(false) => std::path::PathBuf::new(), // user should get empty list not an error
449        Err(e) => {
450            return Err(ShellError::Generic(GenericError::new(
451                "error accessing path",
452                format!("{e}"),
453                glob_span,
454            )));
455        }
456    };
457
458    let link_behavior = match follow_symlinks {
459        true => LinkBehavior::ReadTarget,
460        false => LinkBehavior::ReadFile,
461    };
462
463    let make_walk_behavior = |depth: usize| WalkBehavior {
464        depth: DepthBehavior::Max(DepthMax(depth)),
465        link: link_behavior,
466    };
467
468    let result = if !not_patterns.is_empty() {
469        let patterns: Vec<WaxGlob<'static>> = not_patterns
470            .into_iter()
471            .map(|pattern| {
472                WaxGlob::new(&pattern)
473                    .map_err(|err| {
474                        ShellError::Generic(GenericError::new(
475                            "error with glob's not pattern",
476                            format!("{err}"),
477                            not_pattern_span,
478                        ))
479                    })
480                    .map(|g| g.into_owned())
481            })
482            .collect::<Result<_, _>>()?;
483
484        let any_pattern = any(patterns).map_err(|err| {
485            ShellError::Generic(GenericError::new(
486                "error with glob's not pattern",
487                format!("{err}"),
488                not_pattern_span,
489            ))
490        })?;
491
492        let glob_results = glob
493            .walk_with_behavior(path, make_walk_behavior(folder_depth))
494            .not(any_pattern)
495            .map_err(|err| {
496                ShellError::Generic(GenericError::new(
497                    "error with glob's not pattern",
498                    format!("{err}"),
499                    not_pattern_span,
500                ))
501            })?
502            .flatten();
503
504        glob_to_value(
505            engine_state.signals(),
506            glob_results,
507            no_dirs,
508            no_files,
509            no_symlinks,
510            span,
511        )
512    } else {
513        let glob_results = glob
514            .walk_with_behavior(path, make_walk_behavior(folder_depth))
515            .flatten();
516        glob_to_value(
517            engine_state.signals(),
518            glob_results,
519            no_dirs,
520            no_files,
521            no_symlinks,
522            span,
523        )
524    };
525
526    Ok(result.into_pipeline_data(span, engine_state.signals().clone()))
527}
528
529#[cfg(windows)]
530fn patch_windows_glob_pattern(glob_pattern: String, glob_span: Span) -> Result<String, ShellError> {
531    let mut chars = glob_pattern.chars();
532    match (chars.next(), chars.next(), chars.next()) {
533        (Some(drive), Some(':'), Some('/' | '\\')) if drive.is_ascii_alphabetic() => {
534            Ok(format!("{drive}\\:/{}", chars.as_str()))
535        }
536        (Some(drive), Some(':'), Some(_)) if drive.is_ascii_alphabetic() => {
537            Err(ShellError::Generic(
538                GenericError::new(
539                    "invalid Windows path format",
540                    "Windows paths with drive letters must include a path separator (/) after the colon",
541                    glob_span,
542                )
543                .with_help("use format like 'C:/' instead of 'C:'"),
544            ))
545        }
546        _ => Ok(glob_pattern),
547    }
548}
549
550fn convert_patterns(columns: &[Value]) -> Result<Vec<String>, ShellError> {
551    let res = columns
552        .iter()
553        .map(|value| match &value {
554            Value::String { val: s, .. } => Ok(s.clone()),
555            _ => Err(ShellError::IncompatibleParametersSingle {
556                msg: "Incorrect column format, Only string as column name".to_string(),
557                span: value.span(),
558            }),
559        })
560        .collect::<Result<Vec<String>, _>>()?;
561
562    Ok(res)
563}
564
565fn glob_to_value(
566    signals: &Signals,
567    glob_results: impl Iterator<Item = GlobEntry> + Send + 'static,
568    no_dirs: bool,
569    no_files: bool,
570    no_symlinks: bool,
571    span: Span,
572) -> ListStream {
573    let map_signals = signals.clone();
574    let result = glob_results.filter_map(move |entry| {
575        if let Err(err) = map_signals.check(&span) {
576            return Some(Value::error(err, span));
577        };
578        let file_type = entry.file_type();
579
580        if !(no_dirs && file_type.is_dir()
581            || no_files && file_type.is_file()
582            || no_symlinks && file_type.is_symlink())
583        {
584            Some(Value::string(
585                entry.into_path().to_string_lossy().into_owned(),
586                span,
587            ))
588        } else {
589            None
590        }
591    });
592
593    ListStream::new(result, span, signals.clone())
594}
595
596fn glob_paths_to_value(
597    signals: &Signals,
598    glob_results: impl Iterator<Item = Result<std::path::PathBuf, ShellError>> + Send + 'static,
599    no_dirs: bool,
600    no_files: bool,
601    no_symlinks: bool,
602    span: Span,
603) -> ListStream {
604    let map_signals = signals.clone();
605    let needs_file_type = no_dirs || no_files || no_symlinks;
606    let result = glob_results.filter_map(move |entry| {
607        if let Err(err) = map_signals.check(&span) {
608            return Some(Value::error(err, span));
609        }
610
611        let path = match entry {
612            Ok(path) => path,
613            Err(err) => return Some(Value::error(err, span)),
614        };
615
616        if !needs_file_type {
617            return Some(Value::string(path.to_string_lossy().into_owned(), span));
618        }
619
620        let file_type = match std::fs::symlink_metadata(&path) {
621            Ok(meta) => meta.file_type(),
622            Err(_) => {
623                return Some(Value::string(path.to_string_lossy().into_owned(), span));
624            }
625        };
626
627        if !(no_dirs && file_type.is_dir()
628            || no_files && file_type.is_file()
629            || no_symlinks && file_type.is_symlink())
630        {
631            Some(Value::string(path.to_string_lossy().into_owned(), span))
632        } else {
633            None
634        }
635    });
636
637    ListStream::new(result, span, signals.clone())
638}
639
640fn run_debug_subcommand(
641    engine_state: &EngineState,
642    stack: &Stack,
643    subcommand: String,
644    args: Vec<Spanned<String>>,
645    span: Span,
646) -> Result<PipelineData, ShellError> {
647    let expected = match subcommand.as_str() {
648        "dbg-parse" | "dbg-compile" => 1,
649        "dbg-matches" | "dbg-glob" => 2,
650        _ => 0,
651    };
652
653    if expected > 0 && args.len() > expected {
654        return Err(ShellError::IncompatibleParametersSingle {
655            msg: "extra positional argument".to_string(),
656            span: args[expected].span,
657        });
658    }
659
660    match (subcommand.as_str(), args.first()) {
661        ("dbg-parse", Some(pattern)) => {
662            let text = nu_glob::dc_glob::debug_parse(&pattern.item);
663
664            Ok(Value::string(text, span).into_pipeline_data())
665        }
666        ("dbg-compile", Some(pattern)) => {
667            let text = nu_glob::dc_glob::debug_compile(&pattern.item).map_err(|err| {
668                ShellError::Generic(GenericError::new(
669                    "failed to compile debug glob pattern",
670                    err.to_string(),
671                    pattern.span,
672                ))
673            })?;
674            Ok(Value::string(text, span).into_pipeline_data())
675        }
676        ("dbg-matches", Some(pattern)) => {
677            let path = args.get(1).map(|p| p.item.as_str()).unwrap_or(".");
678            let matches = nu_glob::dc_glob::debug_matches(&pattern.item, path).map_err(|err| {
679                ShellError::Generic(GenericError::new(
680                    "failed to run debug match",
681                    err.to_string(),
682                    pattern.span,
683                ))
684            })?;
685            Ok(Value::bool(matches, span).into_pipeline_data())
686        }
687        ("dbg-glob", Some(pattern)) => {
688            let pattern_span = pattern.span;
689            let relative_to = args.get(1).map(|p| p.item.as_str()).unwrap_or(".");
690            let cwd = engine_state.cwd(Some(stack))?;
691            let relative_to =
692                nu_path::absolute_with(relative_to, cwd.as_std_path()).map_err(|err| {
693                    ShellError::Generic(GenericError::new(
694                        "invalid debug glob path",
695                        err.to_string(),
696                        span,
697                    ))
698                })?;
699            let expanded_pattern = nu_path::expand_tilde(&pattern.item)
700                .to_string_lossy()
701                .to_string();
702            let out = nu_glob::dc_glob::glob_with(
703                relative_to,
704                &expanded_pattern,
705                &nu_glob::dc_glob::GlobWalkOptions::default(),
706            )
707            .map_err(|err| {
708                ShellError::Generic(GenericError::new(
709                    "failed to run debug glob",
710                    err.to_string(),
711                    pattern.span,
712                ))
713            })?;
714
715            let values = out.map(move |path| match path {
716                Ok(path) => Value::string(path.to_string_lossy().into_owned(), span),
717                Err(err) => Value::error(
718                    ShellError::Generic(GenericError::new(
719                        "failed to run debug glob",
720                        err.to_string(),
721                        pattern_span,
722                    )),
723                    span,
724                ),
725            });
726            Ok(
727                ListStream::new(values, span, engine_state.signals().clone())
728                    .into_pipeline_data(span, engine_state.signals().clone()),
729            )
730        }
731        ("dbg-parse" | "dbg-compile" | "dbg-matches" | "dbg-glob", None) => {
732            Err(ShellError::MissingParameter {
733                param_name: "pattern".to_string(),
734                span,
735            })
736        }
737        (unknown, _) => Err(ShellError::IncompatibleParametersSingle {
738            msg: format!("unknown debug subcommand '{unknown}'"),
739            span,
740        }),
741    }
742}
743
744#[cfg(windows)]
745#[cfg(test)]
746mod windows_tests {
747    use super::*;
748
749    #[test]
750    fn glob_pattern_with_drive_letter() {
751        let pattern = "D:/*.mp4".to_string();
752        let result = patch_windows_glob_pattern(pattern, Span::test_data()).unwrap();
753        assert!(WaxGlob::new(&result).is_ok());
754
755        let pattern = "Z:/**/*.md".to_string();
756        let result = patch_windows_glob_pattern(pattern, Span::test_data()).unwrap();
757        assert!(WaxGlob::new(&result).is_ok());
758
759        let pattern = "C:/nested/**/escaped/path/<[_a-zA-Z\\-]>.md".to_string();
760        let result = patch_windows_glob_pattern(pattern, Span::test_data()).unwrap();
761        assert!(dbg!(WaxGlob::new(&result)).is_ok());
762    }
763
764    #[test]
765    fn glob_pattern_without_drive_letter() {
766        let pattern = "/usr/bin/*.sh".to_string();
767        let result = patch_windows_glob_pattern(pattern.clone(), Span::test_data()).unwrap();
768        assert_eq!(result, pattern);
769        assert!(WaxGlob::new(&result).is_ok());
770
771        let pattern = "a".to_string();
772        let result = patch_windows_glob_pattern(pattern.clone(), Span::test_data()).unwrap();
773        assert_eq!(result, pattern);
774        assert!(WaxGlob::new(&result).is_ok());
775    }
776
777    #[test]
778    fn invalid_path_format() {
779        let invalid = "C:lol".to_string();
780        let result = patch_windows_glob_pattern(invalid, Span::test_data());
781        assert!(result.is_err());
782    }
783
784    #[test]
785    fn unpatched_patterns() {
786        let unpatched = "C:/Users/*.txt".to_string();
787        assert!(WaxGlob::new(&unpatched).is_err());
788
789        let patched = patch_windows_glob_pattern(unpatched, Span::test_data()).unwrap();
790        assert!(WaxGlob::new(&patched).is_ok());
791    }
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797
798    #[test]
799    fn signature_mentions_dbg_subcommands_and_ignore_case() {
800        let signature = Glob.signature();
801        let rendered = format!("{signature:#?}");
802
803        if nu_experimental::DC_GLOB.get() {
804            assert!(
805                rendered.contains("dbg-parse") && rendered.contains("dbg-glob"),
806                "glob signature should mention dbg-* subcommands when dc-glob is enabled"
807            );
808            assert!(
809                rendered.contains("ignore-case"),
810                "glob signature should mention --ignore-case when dc-glob is enabled"
811            );
812        } else {
813            assert!(
814                !rendered.contains("dbg-parse") && !rendered.contains("dbg-glob"),
815                "glob signature should hide dbg-* subcommands when dc-glob is disabled"
816            );
817            assert!(
818                !rendered.contains("ignore-case"),
819                "glob signature should hide --ignore-case when dc-glob is disabled"
820            );
821        }
822    }
823}