Skip to main content

dockerfile_roast/
repository.rs

1//! Repository-aware Dockerfile and build-context discovery.
2
3use std::collections::{BTreeMap, HashMap, HashSet};
4use std::ffi::OsStr;
5use std::path::{Path, PathBuf};
6
7use hcl::eval::{Context, Evaluate};
8use hcl::{BlockLabel, Body, Expression, Value as HclValue};
9use ignore::WalkBuilder;
10use serde_yaml::Value as YamlValue;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct BuildInput {
14    pub dockerfile: PathBuf,
15    pub context: PathBuf,
16}
17
18#[derive(Debug, Default)]
19pub struct Discovery {
20    pub inputs: Vec<BuildInput>,
21    pub warnings: Vec<String>,
22}
23
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum DockerignoreProblem {
26    Missing { expected: PathBuf },
27    Empty { path: PathBuf },
28}
29
30/// Resolve CLI paths into local Dockerfiles and their effective build contexts.
31pub fn discover(requested: &[PathBuf]) -> Discovery {
32    let requested = if requested.is_empty() {
33        vec![PathBuf::from(".")]
34    } else {
35        requested.to_vec()
36    };
37    let mut discovery = Discovery::default();
38    let mut inputs = BTreeMap::<(PathBuf, PathBuf), BuildInput>::new();
39
40    for requested_path in requested {
41        let pattern = requested_path.to_string_lossy();
42        if contains_glob(&pattern) {
43            match glob::glob(&pattern) {
44                Ok(matches) => {
45                    for path in matches.flatten() {
46                        discover_path(&path, &mut inputs, &mut discovery.warnings);
47                    }
48                }
49                Err(error) => discovery
50                    .warnings
51                    .push(format!("invalid path pattern {pattern:?}: {error}")),
52            }
53        } else {
54            discover_path(&requested_path, &mut inputs, &mut discovery.warnings);
55        }
56    }
57
58    discovery.inputs = inputs.into_values().collect();
59    discovery
60}
61
62fn discover_path(
63    path: &Path,
64    inputs: &mut BTreeMap<(PathBuf, PathBuf), BuildInput>,
65    warnings: &mut Vec<String>,
66) {
67    if path == Path::new("-") {
68        insert_input(inputs, path.to_path_buf(), current_directory());
69    } else if path.is_dir() {
70        discover_directory(path, inputs, warnings);
71    } else if path.is_file() && is_compose_file(path) {
72        discover_compose(path, inputs, warnings);
73    } else if path.is_file() && is_bake_file(path) {
74        discover_bake(path, inputs, warnings);
75    } else {
76        let context = path.parent().unwrap_or_else(|| Path::new("."));
77        insert_input(inputs, path.to_path_buf(), absolute_path(context));
78    }
79}
80
81fn discover_directory(
82    root: &Path,
83    inputs: &mut BTreeMap<(PathBuf, PathBuf), BuildInput>,
84    warnings: &mut Vec<String>,
85) {
86    let mut dockerfiles = Vec::new();
87    let mut compose_files = Vec::new();
88    let mut bake_files = Vec::new();
89
90    let mut walker = WalkBuilder::new(root);
91    walker
92        .follow_links(false)
93        .hidden(false)
94        .ignore(true)
95        .git_ignore(true)
96        .git_exclude(true)
97        .parents(true);
98    for entry in walker.build() {
99        let entry = match entry {
100            Ok(entry) => entry,
101            Err(error) => {
102                warnings.push(format!("cannot inspect repository entry: {error}"));
103                continue;
104            }
105        };
106        if !entry
107            .file_type()
108            .is_some_and(|file_type| file_type.is_file())
109        {
110            continue;
111        }
112        let path = entry.into_path();
113        if is_dockerfile_name(&path) {
114            dockerfiles.push(path);
115        } else if is_compose_file(&path) {
116            compose_files.push(path);
117        } else if is_bake_file(&path) {
118            bake_files.push(path);
119        }
120    }
121
122    compose_files.sort();
123    bake_files.sort();
124    dockerfiles.sort();
125
126    // Build definitions carry stronger context information than filename-only
127    // discovery, so process them first. Pair-based deduplication preserves the
128    // legitimate case where one Dockerfile is built from multiple contexts.
129    for path in compose_files {
130        discover_compose(&path, inputs, warnings);
131    }
132    for path in bake_files {
133        discover_bake(&path, inputs, warnings);
134    }
135    for dockerfile in dockerfiles {
136        if contains_dockerfile(inputs, &dockerfile) {
137            continue;
138        }
139        let context = infer_context(&dockerfile, root);
140        insert_input(inputs, dockerfile, context);
141    }
142}
143
144fn contains_dockerfile(
145    inputs: &BTreeMap<(PathBuf, PathBuf), BuildInput>,
146    dockerfile: &Path,
147) -> bool {
148    let dockerfile = absolute_path(dockerfile);
149    inputs.keys().any(|(known, _)| known == &dockerfile)
150}
151
152fn infer_context(dockerfile: &Path, repository_root: &Path) -> PathBuf {
153    let root = absolute_path(repository_root);
154    let mut directory = dockerfile
155        .parent()
156        .map(absolute_path)
157        .unwrap_or_else(|| root.clone());
158    loop {
159        if directory.join(".dockerignore").is_file() {
160            return directory;
161        }
162        if directory == root || !directory.starts_with(&root) {
163            return root;
164        }
165        let Some(parent) = directory.parent() else {
166            return root;
167        };
168        directory = parent.to_path_buf();
169    }
170}
171
172fn discover_compose(
173    compose_file: &Path,
174    inputs: &mut BTreeMap<(PathBuf, PathBuf), BuildInput>,
175    warnings: &mut Vec<String>,
176) {
177    let content = match std::fs::read_to_string(compose_file) {
178        Ok(content) => content,
179        Err(error) => {
180            warnings.push(format!(
181                "cannot read Compose file '{}': {error}",
182                compose_file.display()
183            ));
184            return;
185        }
186    };
187    let document: YamlValue = match serde_yaml::from_str(&content) {
188        Ok(document) => document,
189        Err(error) => {
190            warnings.push(format!(
191                "cannot parse Compose file '{}': {error}",
192                compose_file.display()
193            ));
194            return;
195        }
196    };
197    let Some(services) = yaml_mapping_value(&document, "services").and_then(YamlValue::as_mapping)
198    else {
199        return;
200    };
201    let project_dir = compose_file.parent().unwrap_or_else(|| Path::new("."));
202    let environment = compose_environment(project_dir);
203
204    for (service_name, service) in services {
205        let Some(build) = yaml_mapping_value(service, "build") else {
206            continue;
207        };
208        let name = service_name.as_str().unwrap_or("<unnamed>");
209        let (context_value, dockerfile_value) = match build {
210            YamlValue::String(context) => (Some(context.as_str()), Some("Dockerfile")),
211            YamlValue::Mapping(_) => {
212                if yaml_mapping_value(build, "dockerfile_inline").is_some() {
213                    continue;
214                }
215                (
216                    yaml_mapping_value(build, "context").and_then(YamlValue::as_str),
217                    yaml_mapping_value(build, "dockerfile")
218                        .and_then(YamlValue::as_str)
219                        .or(Some("Dockerfile")),
220                )
221            }
222            _ => continue,
223        };
224        let Some(context_value) = interpolate_path(context_value.unwrap_or("."), &environment)
225        else {
226            warnings.push(format!(
227                "Compose service {name:?} in '{}' has an unresolved build context",
228                compose_file.display()
229            ));
230            continue;
231        };
232        let Some(dockerfile_value) =
233            dockerfile_value.and_then(|value| interpolate_path(value, &environment))
234        else {
235            warnings.push(format!(
236                "Compose service {name:?} in '{}' has an unresolved Dockerfile path",
237                compose_file.display()
238            ));
239            continue;
240        };
241        let Some(context) = resolve_local_path(project_dir, &context_value) else {
242            continue;
243        };
244        let dockerfile = resolve_path(&context, &dockerfile_value);
245        insert_referenced(
246            inputs,
247            dockerfile,
248            context,
249            "Compose",
250            compose_file,
251            warnings,
252        );
253    }
254}
255
256fn yaml_mapping_value<'a>(value: &'a YamlValue, key: &str) -> Option<&'a YamlValue> {
257    value.as_mapping()?.get(YamlValue::String(key.to_string()))
258}
259
260#[derive(Debug, Default, Clone)]
261struct BakeTarget {
262    context: Option<String>,
263    dockerfile: Option<String>,
264    inherits: Vec<String>,
265    inline: bool,
266}
267
268fn discover_bake(
269    bake_file: &Path,
270    inputs: &mut BTreeMap<(PathBuf, PathBuf), BuildInput>,
271    warnings: &mut Vec<String>,
272) {
273    let content = match std::fs::read_to_string(bake_file) {
274        Ok(content) => content,
275        Err(error) => {
276            warnings.push(format!(
277                "cannot read Bake file '{}': {error}",
278                bake_file.display()
279            ));
280            return;
281        }
282    };
283    let targets = if bake_file.extension() == Some(OsStr::new("json")) {
284        match parse_bake_json(&content) {
285            Ok(targets) => targets,
286            Err(error) => {
287                warnings.push(format!(
288                    "cannot parse Bake file '{}': {error}",
289                    bake_file.display()
290                ));
291                return;
292            }
293        }
294    } else {
295        match parse_bake_hcl(&content) {
296            Ok(targets) => targets,
297            Err(error) => {
298                warnings.push(format!(
299                    "cannot parse Bake file '{}': {error}",
300                    bake_file.display()
301                ));
302                return;
303            }
304        }
305    };
306    let base = bake_file.parent().unwrap_or_else(|| Path::new("."));
307
308    for name in targets.keys() {
309        let mut visiting = HashSet::new();
310        let Some(target) = resolve_bake_target(name, &targets, &mut visiting) else {
311            warnings.push(format!(
312                "Bake target {name:?} in '{}' has cyclic or missing inheritance",
313                bake_file.display()
314            ));
315            continue;
316        };
317        if target.inline {
318            continue;
319        }
320        let context_value = target.context.as_deref().unwrap_or(".");
321        let dockerfile_value = target.dockerfile.as_deref().unwrap_or("Dockerfile");
322        let Some(context) = resolve_local_path(base, context_value) else {
323            continue;
324        };
325        let dockerfile = resolve_path(&context, dockerfile_value);
326        insert_referenced(inputs, dockerfile, context, "Bake", bake_file, warnings);
327    }
328}
329
330fn parse_bake_json(content: &str) -> Result<HashMap<String, BakeTarget>, serde_json::Error> {
331    let document: serde_json::Value = serde_json::from_str(content)?;
332    let mut result = HashMap::new();
333    let Some(targets) = document
334        .get("target")
335        .and_then(serde_json::Value::as_object)
336    else {
337        return Ok(result);
338    };
339    for (name, target) in targets {
340        let inherits = target.get("inherits").map(json_strings).unwrap_or_default();
341        result.insert(
342            name.clone(),
343            BakeTarget {
344                context: target
345                    .get("context")
346                    .and_then(serde_json::Value::as_str)
347                    .map(str::to_string),
348                dockerfile: target
349                    .get("dockerfile")
350                    .and_then(serde_json::Value::as_str)
351                    .map(str::to_string),
352                inherits,
353                inline: target.get("dockerfile-inline").is_some()
354                    || target.get("dockerfile_inline").is_some(),
355            },
356        );
357    }
358    Ok(result)
359}
360
361fn json_strings(value: &serde_json::Value) -> Vec<String> {
362    match value {
363        serde_json::Value::String(value) => vec![value.clone()],
364        serde_json::Value::Array(values) => values
365            .iter()
366            .filter_map(serde_json::Value::as_str)
367            .map(str::to_string)
368            .collect(),
369        _ => Vec::new(),
370    }
371}
372
373fn parse_bake_hcl(content: &str) -> Result<HashMap<String, BakeTarget>, hcl::Error> {
374    let body: Body = hcl::parse(content)?;
375    let mut context = Context::new();
376    for block in body
377        .blocks()
378        .filter(|block| block.identifier() == "variable")
379    {
380        let Some(name) = block_label(block.labels.first()) else {
381            continue;
382        };
383        let value = std::env::var(name).ok().map(HclValue::from).or_else(|| {
384            hcl_attribute(block, "default")
385                .and_then(|expression| expression.evaluate(&context).ok())
386        });
387        if let Some(value) = value {
388            context.declare_var(name, value);
389        }
390    }
391
392    let mut result = HashMap::new();
393    for block in body.blocks().filter(|block| block.identifier() == "target") {
394        let Some(name) = block_label(block.labels.first()) else {
395            continue;
396        };
397        let string_attribute = |key| {
398            hcl_attribute(block, key)
399                .and_then(|expression| expression.evaluate(&context).ok())
400                .and_then(|value| value.as_str().map(str::to_string))
401        };
402        let inherits = hcl_attribute(block, "inherits")
403            .and_then(|expression| expression.evaluate(&context).ok())
404            .map(hcl_strings)
405            .unwrap_or_default();
406        result.insert(
407            name.to_string(),
408            BakeTarget {
409                context: string_attribute("context"),
410                dockerfile: string_attribute("dockerfile"),
411                inherits,
412                inline: hcl_attribute(block, "dockerfile-inline").is_some(),
413            },
414        );
415    }
416    Ok(result)
417}
418
419fn block_label(label: Option<&BlockLabel>) -> Option<&str> {
420    match label? {
421        BlockLabel::Identifier(value) => Some(value.as_str()),
422        BlockLabel::String(value) => Some(value),
423    }
424}
425
426fn hcl_attribute<'a>(block: &'a hcl::Block, key: &str) -> Option<&'a Expression> {
427    block
428        .body
429        .attributes()
430        .find(|attribute| attribute.key() == key)
431        .map(|attribute| attribute.expr())
432}
433
434fn hcl_strings(value: HclValue) -> Vec<String> {
435    match value {
436        HclValue::String(value) => vec![value],
437        HclValue::Array(values) => values
438            .into_iter()
439            .filter_map(|value| value.as_str().map(str::to_string))
440            .collect(),
441        _ => Vec::new(),
442    }
443}
444
445fn resolve_bake_target(
446    name: &str,
447    targets: &HashMap<String, BakeTarget>,
448    visiting: &mut HashSet<String>,
449) -> Option<BakeTarget> {
450    if !visiting.insert(name.to_string()) {
451        return None;
452    }
453    let target = targets.get(name)?;
454    let mut resolved = BakeTarget::default();
455    for parent in &target.inherits {
456        let inherited = resolve_bake_target(parent, targets, visiting)?;
457        merge_bake_target(&mut resolved, inherited);
458    }
459    merge_bake_target(&mut resolved, target.clone());
460    visiting.remove(name);
461    Some(resolved)
462}
463
464fn merge_bake_target(base: &mut BakeTarget, override_target: BakeTarget) {
465    if override_target.context.is_some() {
466        base.context = override_target.context;
467    }
468    if override_target.dockerfile.is_some() {
469        base.dockerfile = override_target.dockerfile;
470        base.inline = false;
471    }
472    if override_target.inline {
473        base.dockerfile = None;
474        base.inline = true;
475    }
476}
477
478fn insert_referenced(
479    inputs: &mut BTreeMap<(PathBuf, PathBuf), BuildInput>,
480    dockerfile: PathBuf,
481    context: PathBuf,
482    kind: &str,
483    definition: &Path,
484    warnings: &mut Vec<String>,
485) {
486    if dockerfile.is_file() {
487        insert_input(inputs, dockerfile, absolute_path(&context));
488    } else {
489        warnings.push(format!(
490            "{kind} file '{}' references missing Dockerfile '{}'",
491            definition.display(),
492            dockerfile.display()
493        ));
494    }
495}
496
497fn insert_input(
498    inputs: &mut BTreeMap<(PathBuf, PathBuf), BuildInput>,
499    dockerfile: PathBuf,
500    context: PathBuf,
501) {
502    let dockerfile_key = if dockerfile == Path::new("-") {
503        dockerfile.clone()
504    } else {
505        absolute_path(&dockerfile)
506    };
507    let context = absolute_path(&context);
508    let display_path = relative_to_current_directory(&dockerfile_key);
509    inputs
510        .entry((dockerfile_key, context.clone()))
511        .or_insert(BuildInput {
512            dockerfile: display_path,
513            context,
514        });
515}
516
517/// Return the missing or ineffective ignore file for a build input.
518pub fn dockerignore_problem(
519    dockerfile: &Path,
520    context: &Path,
521) -> std::io::Result<Option<DockerignoreProblem>> {
522    let specific = dockerfile
523        .parent()
524        .unwrap_or_else(|| Path::new("."))
525        .join(format!(
526            "{}.dockerignore",
527            dockerfile
528                .file_name()
529                .unwrap_or_else(|| OsStr::new("Dockerfile"))
530                .to_string_lossy()
531        ));
532    let root = context.join(".dockerignore");
533    let effective = if specific.is_file() {
534        specific
535    } else if root.is_file() {
536        root
537    } else {
538        return Ok(Some(DockerignoreProblem::Missing { expected: root }));
539    };
540    let content = std::fs::read_to_string(&effective)?;
541    if has_exclusion_pattern(&content) {
542        Ok(None)
543    } else {
544        Ok(Some(DockerignoreProblem::Empty { path: effective }))
545    }
546}
547
548fn has_exclusion_pattern(content: &str) -> bool {
549    content.lines().enumerate().any(|(index, line)| {
550        let line = if index == 0 {
551            line.trim_start_matches('\u{feff}')
552        } else {
553            line
554        };
555        if line.starts_with('#') {
556            return false;
557        }
558        let pattern = line.trim();
559        !pattern.is_empty() && pattern != "." && !pattern.starts_with('!')
560    })
561}
562
563fn is_dockerfile_name(path: &Path) -> bool {
564    let Some(name) = path.file_name().and_then(OsStr::to_str) else {
565        return false;
566    };
567    name == "Dockerfile"
568        || name == "Containerfile"
569        || name
570            .strip_prefix("Dockerfile.")
571            .is_some_and(|suffix| !suffix.is_empty())
572        || name
573            .strip_prefix("Containerfile.")
574            .is_some_and(|suffix| !suffix.is_empty())
575        || name
576            .strip_suffix(".Dockerfile")
577            .is_some_and(|prefix| !prefix.is_empty())
578}
579
580fn is_compose_file(path: &Path) -> bool {
581    let Some(name) = path.file_name().and_then(OsStr::to_str) else {
582        return false;
583    };
584    (name == "compose.yaml"
585        || name == "compose.yml"
586        || name == "docker-compose.yaml"
587        || name == "docker-compose.yml")
588        || ((name.starts_with("compose.") || name.starts_with("docker-compose."))
589            && (name.ends_with(".yaml") || name.ends_with(".yml")))
590}
591
592fn is_bake_file(path: &Path) -> bool {
593    let Some(name) = path.file_name().and_then(OsStr::to_str) else {
594        return false;
595    };
596    name.starts_with("docker-bake") && (name.ends_with(".hcl") || name.ends_with(".json"))
597}
598
599fn contains_glob(path: &str) -> bool {
600    path.contains('*') || path.contains('?') || path.contains('[')
601}
602
603fn compose_environment(project_dir: &Path) -> HashMap<String, String> {
604    let mut environment = HashMap::new();
605    let dotenv = project_dir.join(".env");
606    if dotenv.is_file() {
607        if let Ok(entries) = dotenvy::from_path_iter(dotenv) {
608            environment.extend(entries.flatten());
609        }
610    }
611    environment.extend(std::env::vars());
612    environment
613}
614
615fn interpolate_path(value: &str, environment: &HashMap<String, String>) -> Option<String> {
616    let mut result = String::new();
617    let chars = value.as_bytes();
618    let mut index = 0usize;
619    while index < chars.len() {
620        if chars[index] != b'$' {
621            let character = value[index..].chars().next()?;
622            result.push(character);
623            index += character.len_utf8();
624            continue;
625        }
626        if chars.get(index + 1) == Some(&b'$') {
627            result.push('$');
628            index += 2;
629            continue;
630        }
631        if chars.get(index + 1) == Some(&b'{') {
632            let end = value[index + 2..].find('}')? + index + 2;
633            let expression = &value[index + 2..end];
634            let name_end = expression
635                .find(|character: char| !character.is_ascii_alphanumeric() && character != '_')
636                .unwrap_or(expression.len());
637            let name = &expression[..name_end];
638            if name.is_empty() {
639                return None;
640            }
641            let operator = &expression[name_end..];
642            let current = environment.get(name);
643            let replacement = if let Some(default) = operator.strip_prefix(":-") {
644                current
645                    .filter(|value| !value.is_empty())
646                    .map(String::as_str)
647                    .unwrap_or(default)
648            } else if let Some(default) = operator.strip_prefix('-') {
649                current.map(String::as_str).unwrap_or(default)
650            } else if let Some(alternative) = operator.strip_prefix(":+") {
651                if current.is_some_and(|value| !value.is_empty()) {
652                    alternative
653                } else {
654                    ""
655                }
656            } else if let Some(alternative) = operator.strip_prefix('+') {
657                if current.is_some() {
658                    alternative
659                } else {
660                    ""
661                }
662            } else if operator.starts_with(":?") {
663                current
664                    .filter(|value| !value.is_empty())
665                    .map(String::as_str)?
666            } else if operator.starts_with('?') || operator.is_empty() {
667                current.map(String::as_str)?
668            } else {
669                return None;
670            };
671            result.push_str(replacement);
672            index = end + 1;
673            continue;
674        }
675        let start = index + 1;
676        let mut end = start;
677        while chars
678            .get(end)
679            .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
680        {
681            end += 1;
682        }
683        if end == start {
684            return None;
685        }
686        result.push_str(environment.get(&value[start..end])?);
687        index = end;
688    }
689    Some(expand_home(result))
690}
691
692fn expand_home(value: String) -> String {
693    if value == "~" || value.starts_with("~/") {
694        if let Some(home) = std::env::var_os("HOME") {
695            return PathBuf::from(home)
696                .join(value.strip_prefix("~/").unwrap_or(""))
697                .to_string_lossy()
698                .into_owned();
699        }
700    }
701    value
702}
703
704fn resolve_local_path(base: &Path, value: &str) -> Option<PathBuf> {
705    if value.contains("://") || value.starts_with("git@") || value.starts_with("target:") {
706        return None;
707    }
708    Some(resolve_path(base, value))
709}
710
711fn resolve_path(base: &Path, value: &str) -> PathBuf {
712    let path = Path::new(value);
713    if path.is_absolute() {
714        path.to_path_buf()
715    } else {
716        base.join(path)
717    }
718}
719
720fn absolute_path(path: &Path) -> PathBuf {
721    path.canonicalize().unwrap_or_else(|_| {
722        if path.is_absolute() {
723            path.to_path_buf()
724        } else {
725            current_directory().join(path)
726        }
727    })
728}
729
730fn current_directory() -> PathBuf {
731    std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
732}
733
734fn relative_to_current_directory(path: &Path) -> PathBuf {
735    path.strip_prefix(current_directory())
736        .ok()
737        .filter(|relative| !relative.as_os_str().is_empty())
738        .map(Path::to_path_buf)
739        .unwrap_or_else(|| path.to_path_buf())
740}
741
742#[cfg(test)]
743mod tests {
744    use super::{has_exclusion_pattern, interpolate_path, is_dockerfile_name};
745    use std::collections::HashMap;
746    use std::path::Path;
747
748    #[test]
749    fn dockerfile_name_patterns_are_exact() {
750        for name in [
751            "Dockerfile",
752            "Dockerfile.dev",
753            "web.Dockerfile",
754            "Containerfile",
755            "Containerfile.release",
756        ] {
757            assert!(is_dockerfile_name(Path::new(name)), "{name}");
758        }
759        for name in [
760            "dockerfile",
761            "Dockerfile.",
762            ".Dockerfile",
763            "myContainerfile",
764        ] {
765            assert!(!is_dockerfile_name(Path::new(name)), "{name}");
766        }
767    }
768
769    #[test]
770    fn dockerignore_needs_a_positive_pattern() {
771        assert!(!has_exclusion_pattern("# comment\n\n!README.md\n.\n"));
772        assert!(has_exclusion_pattern("# comment\nnode_modules\n"));
773    }
774
775    #[test]
776    fn compose_path_defaults_are_interpolated() {
777        let mut environment = HashMap::new();
778        environment.insert("EMPTY".to_string(), String::new());
779        assert_eq!(
780            interpolate_path("${MISSING:-contexts/api}", &environment),
781            Some("contexts/api".into())
782        );
783        assert_eq!(
784            interpolate_path("${EMPTY-default}", &environment),
785            Some(String::new())
786        );
787        assert_eq!(
788            interpolate_path("${EMPTY:-default}", &environment),
789            Some("default".into())
790        );
791        assert_eq!(
792            interpolate_path("cost-$$5", &environment),
793            Some("cost-$5".into())
794        );
795    }
796}