tigerturtle/
lib.rs

1use anyhow::{Context, Result};
2use clap::Parser;
3use std::io::Read;
4use std::path::PathBuf;
5
6const SHELL_BOILERPLATE: &str = r#"
7toml_keys=()
8tt_out=$(mktemp); tt_err=$(mktemp)
9if tigerturtle file.toml -- ${toml_keys[@]} >$tt_out 2>$tt_err; then
10    eval $(<$tt_out); rm $tt_out; rm $tt_err;
11else
12    echo "$(<$tt_err)" >&2; rm $tt_out; rm $tt_err; exit 1;
13fi
14"#;
15
16#[derive(Debug, Parser)]
17#[clap(name = "tigerturtle")]
18#[clap(about = "Parse and evaluate toml files in bash")]
19#[clap(author = "https://ariel.ninja")]
20#[clap(version)]
21pub struct Args {
22    /// Toml file (pass nothing to read from stdin)
23    #[arg()]
24    pub file: Option<PathBuf>,
25    /// Nested delimiter
26    #[arg(short = 'd', long, default_value_t = String::from("__"))]
27    pub delim: String,
28    /// Evaluated variables prefix
29    #[arg(short = 'p', long)]
30    pub output_prefix: Option<String>,
31    /// Default TOML
32    #[arg(short = 'D', long)]
33    pub default: Option<String>,
34    /// Write default TOML if file is missing
35    #[arg(short = 'W', long)]
36    pub write_missing: bool,
37    /// Required key prefix
38    #[arg(short = 'r', long, default_value_t = String::from("_"))]
39    pub required_prefix: String,
40    /// Generate shellscript boilerplate
41    #[arg(short = 'G', long)]
42    pub generate: bool,
43    /// Keys to parse from TOML
44    #[arg(raw = true)]
45    pub keys: Vec<String>,
46}
47
48pub fn run() -> Result<()> {
49    let args = Args::parse();
50    if args.generate {
51        println!("{SHELL_BOILERPLATE}");
52        return Ok(());
53    }
54    if args.write_missing {
55        if let Some(default_content) = args.default.as_ref() {
56            if let Some(file) = args.file.as_ref() {
57                write_default_if_missing(file, default_content).context("write default toml")?;
58            }
59        }
60    }
61    let toml_contents = get_toml_content(args.file.as_ref(), args.default).context("get toml")?;
62    let evaluation_string = process_toml(
63        &toml_contents,
64        args.keys,
65        &args.output_prefix.unwrap_or_default(),
66        &args.required_prefix,
67        &args.delim,
68    )
69    .context("process toml")?;
70    println!("{evaluation_string}");
71    Ok(())
72}
73
74pub fn process_toml(
75    toml_contents: &str,
76    mut keys: Vec<String>,
77    output_prefix: &str,
78    required_prefix: &str,
79    delim: &str,
80) -> Result<String> {
81    let parsed_toml: toml::Table = toml::from_str(toml_contents).context("parse toml")?;
82    let mut lines = Vec::new();
83    for key in &mut keys {
84        let required = if let Some(stripped_key) = key.strip_prefix(required_prefix) {
85            *key = stripped_key.to_owned();
86            true
87        } else {
88            false
89        };
90        let key_path: Vec<String> = key
91            .split(delim)
92            .map(std::borrow::ToOwned::to_owned)
93            .collect();
94        let bash_key = key_path.join(delim);
95        let value = match (required, get_toml_value(&parsed_toml, &key_path)) {
96            (true, None) => anyhow::bail!(format!("missing required key: {key}")),
97            (false, None) => String::default(),
98            (_, Some(v)) => v,
99        };
100        lines.push(format!("{output_prefix}{bash_key}={value}"));
101    }
102    Ok(lines.join("\n"))
103}
104
105fn write_default_if_missing(file: &PathBuf, default: &String) -> Result<()> {
106    if file.exists() {
107        return Ok(());
108    }
109    if let Some(parent) = file.parent() {
110        if !parent.exists() {
111            std::fs::create_dir_all(parent).context("create directory for default file")?;
112        }
113    }
114    std::fs::write(file, default).context("write default file contents")
115}
116
117pub fn get_toml_content(file: Option<&PathBuf>, default: Option<String>) -> Result<String> {
118    let toml_contents: String = if let Some(toml_file) = file {
119        if toml_file.exists() {
120            std::fs::read_to_string(toml_file).context("read file")?
121        } else if let Some(default_content) = default {
122            default_content
123        } else {
124            anyhow::bail!("file does not exist and no default provided");
125        }
126    } else {
127        let mut stdin_input = String::new();
128        std::io::stdin()
129            .read_to_string(&mut stdin_input)
130            .context("read stdin")?;
131        stdin_input
132    };
133    Ok(toml_contents)
134}
135
136fn get_toml_value(table: &toml::Table, key_path: &[String]) -> Option<String> {
137    if let Some(next_key_part) = key_path.first() {
138        let next_value_part = table.get(next_key_part)?;
139        return match next_value_part {
140            toml::Value::Table(inner_table) => get_toml_value(inner_table, key_path.split_at(1).1),
141            value => (key_path.len() == 1).then_some(value.to_string()),
142        };
143    };
144    None
145}