interpolator.rs•3.96 kB
// Licensed to Elasticsearch B.V. under one or more contributor
// license agreements. See the NOTICE file distributed with
// this work for additional information regarding copyright
// ownership. Elasticsearch B.V. licenses this file to you under
// the Apache License, Version 2.0 (the "License"); you may
// not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//    http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied.  See the License for the
// specific language governing permissions and limitations
// under the License.
//! Simple string interpolator to inject environment variables in the configuration file.
use thiserror::Error;
#[derive(Error, Debug)]
#[error("Invalid configuration template: {reason} at {line}:{char}")]
pub struct InterpolationError {
    pub reason: String,
    pub line: usize,
    pub char: usize,
}
pub fn interpolate_from_env(s: String) -> Result<String, InterpolationError> {
    interpolate(s, |name| std::env::var(name).ok())
}
const OPEN: &str = "${";
const OPEN_LEN: usize = OPEN.len();
const CLOSE: &str = "}";
const CLOSE_LEN: usize = CLOSE.len();
/// Simple string interpolation using the `${name}` and `${name:default_value}` syntax.
pub fn interpolate(s: String, lookup: impl Fn(&str) -> Option<String>) -> Result<String, InterpolationError> {
    if !s.contains(OPEN) {
        return Ok(s);
    }
    let mut result: String = String::new();
    for (line_no, mut line) in s.lines().enumerate() {
        if line_no > 0 {
            result.push('\n');
        }
        let mut char_no = 0;
        let err = |char_no: usize, msg: String| InterpolationError {
            reason: msg,
            line: line_no + 1, // editors (and humans) are 1-based
            char: char_no,
        };
        while let Some(pos) = line.find(OPEN) {
            // Push text before the opening brace
            result.push_str(&line[..pos]);
            char_no += pos + OPEN_LEN;
            line = &line[pos + OPEN_LEN..];
            if let Some(pos) = line.find(CLOSE) {
                let expr = &line[..pos];
                let value = if let Some((name, default)) = expr.split_once(':') {
                    lookup(name).unwrap_or(default.to_string())
                } else {
                    lookup(expr).ok_or_else(|| err(char_no, format!("env variable '{expr}' not defined")))?
                };
                result.push_str(&value);
                char_no += expr.len() + CLOSE_LEN;
                line = &line[expr.len() + CLOSE_LEN..];
            } else {
                return Err(err(char_no, "missing closing braces".to_string()));
            }
        }
        result.push_str(line);
    }
    Ok(result)
}
#[cfg(test)]
mod tests {
    use super::*;
    fn expand(name: &str) -> Result<String, InterpolationError> {
        let lookup = |s: &str| match s {
            "foo" => Some("foo_value".to_string()),
            "bar" => Some("bar_value".to_string()),
            _ => None,
        };
        interpolate(name.to_string(), lookup)
    }
    #[test]
    fn good_extrapolation() -> anyhow::Result<()> {
        assert_eq!("012345678", expand("012345678")?);
        assert_eq!("foo_value01234", expand("${foo}01234")?);
        assert_eq!("foo_value01234\n1234bar_value", expand("${foo}01234\n1234${bar}")?);
        assert_eq!("foo_value01234bar_value", expand("${foo}01234${bar}")?);
        assert_eq!("_01_foo_value01234bar_value567", expand("_01_${foo}01234${bar}567")?);
        Ok(())
    }
    #[test]
    fn failed_extrapolation() {
        assert!(expand("${foo01234").is_err());
        assert!(expand("${foo}01234${bar").is_err());
        assert!(expand("${baz}01234").is_err());
    }
}