qrlkit 0.5.4

Quick Resource Locator: open named URLs from imported TOML files
use anyhow::{Result, ensure};

pub fn names(template: &str) -> Result<Vec<String>> {
    let mut names = vec![];
    let mut rest = template;
    while let Some(start) = rest.find(['{', '}']) {
        ensure!(
            rest.as_bytes()[start] == b'{',
            "Unmatched closing brace in resource"
        );
        let end = rest[start + 1..]
            .find('}')
            .ok_or_else(|| anyhow::anyhow!("Unclosed placeholder in resource"))?
            + start
            + 1;
        let name = &rest[start + 1..end];
        ensure!(
            !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'),
            "Placeholder names must contain letters, digits or underscores"
        );
        if !names.iter().any(|n| n == name) {
            names.push(name.to_owned());
        }
        rest = &rest[end + 1..];
    }
    Ok(names)
}

pub fn expand(
    template: &str,
    args: &[String],
    mut prompt: impl FnMut(&str) -> Result<String>,
) -> Result<String> {
    let names = names(template)?;
    ensure!(
        args.len() <= names.len(),
        "Expected {} argument(s), got {}",
        names.len(),
        args.len()
    );
    let mut values = args.to_vec();
    for name in names.iter().skip(values.len()) {
        values.push(prompt(name)?);
    }
    let url = crate::resource::is_url(template);
    let mut result = String::new();
    let mut rest = template;
    while let Some(start) = rest.find('{') {
        let end = rest[start..].find('}').unwrap() + start;
        result.push_str(&rest[..start]);
        let index = names
            .iter()
            .position(|n| n == &rest[start + 1..end])
            .unwrap();
        let value = &values[index];
        if url {
            for byte in value.bytes() {
                if byte.is_ascii_alphanumeric() || b"-._~".contains(&byte) {
                    result.push(char::from(byte));
                } else {
                    use std::fmt::Write;
                    write!(result, "%{byte:02X}")?;
                }
            }
        } else {
            result.push_str(value);
        }
        rest = &rest[end + 1..];
    }
    result.push_str(rest);
    Ok(result)
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn ordering_reuse_encoding_and_literal_substitution() {
        let result = expand(
            "https://example.com/{env}/{id}?again={id}",
            &["prod".into(), "a /?&ΓΈ".into()],
            |_| panic!(),
        )
        .unwrap();
        assert_eq!(
            result,
            "https://example.com/prod/a%20%2F%3F%26%C3%B8?again=a%20%2F%3F%26%C3%B8"
        );
        assert_eq!(
            expand("./{one}/{two}", &["{two}".into()], |name| {
                assert_eq!(name, "two");
                Ok("literal $HOME".into())
            })
            .unwrap(),
            "./{two}/literal $HOME"
        );
    }
    #[test]
    fn malformed_extra_and_missing_arguments() {
        for bad in ["./{", "./}", "./{}", "./{bad name}", "./{{nested}}"] {
            assert!(names(bad).is_err());
        }
        assert!(expand("./fixed", &["extra".into()], |_| panic!()).is_err());
        assert!(expand("./{missing}", &[], |_| anyhow::bail!("cancelled")).is_err());
    }
}