Skip to main content

athena/
build.rs

1//! SQL template building functionality
2//!
3//! This module provides functionality to build (render) SQL from Tera templates.
4//! It handles both single SQL files and directories containing multiple template files.
5//!
6//! The build process:
7//! 1. Loads all `.sql` files from the working directory as templates
8//! 2. Renders the target template (or `index.sql` if a directory is provided)
9//! 3. Outputs the rendered SQL to stdout or a file
10
11use anyhow::{bail, Context, Result};
12use log::debug;
13use std::{fs::File, io::Write, path::PathBuf};
14
15use crate::tera::get_tera;
16use crate::utils::{get_current_working_dir, get_full_path_str, is_dir, pretty_print};
17
18// Constants
19const INDEX_SQL_FILENAME: &str = "index.sql";
20const SQL_FILE_EXTENSION: &str = "sql";
21
22#[derive(clap::Args, Debug, Clone)]
23pub struct Build {
24    /// Target path to render. If the target path is a directory,
25    /// the root folder must contains the index.sql file
26    pub file: PathBuf,
27
28    /// Output path. The file will be overwritten if is already exists
29    #[arg(long, short)]
30    pub out: Option<PathBuf>,
31
32    /// Change the context current working dir
33    #[arg(long, short)]
34    pub context: Option<PathBuf>,
35
36    /// No pretty print for SQL
37    #[arg(long, short)]
38    pub no_pretty: Option<bool>,
39}
40
41pub async fn call(args: Build) -> Result<()> {
42    // Render SQL
43    let sql = build(args.clone())?;
44
45    // Print to stdout or write to file?
46    match args.out {
47        Some(path) => {
48            let mut file = File::create(&path)
49                .with_context(|| format!("could not create output file {}", path.display()))?;
50
51            match file.write_all(sql.as_bytes()) {
52                Ok(_) => println!("Write to {}", path.display()),
53                Err(e) => println!("Failed write to {}: {}", path.display(), e),
54            }
55        }
56        None => {
57            if sql.is_empty() {
58                // Nothing to print for empty output
59            } else if args.no_pretty.unwrap_or_default() {
60                print!("{}", sql);
61            } else {
62                pretty_print(sql.as_bytes());
63            }
64        }
65    }
66
67    Ok(())
68}
69
70pub fn build(args: Build) -> Result<String> {
71    let path = &args.file;
72
73    let is_dir = is_dir(path);
74
75    // If input path is empty folder, just return empty
76    if is_dir && path.read_dir()?.next().is_none() {
77        return Ok("".to_string());
78    }
79
80    let (working_dir, path_str) = get_dirs(args.clone())?;
81
82    // If input path contains no *.sql files, error
83    if is_dir
84        && !path
85            .read_dir()?
86            .filter_map(Result::ok)
87            .any(|f| f.path().extension().and_then(|s| s.to_str()) == Some(SQL_FILE_EXTENSION))
88    {
89        let files = path
90            .read_dir()?
91            .map(Result::ok)
92            .into_iter()
93            .flatten()
94            .map(|f| f.path())
95            .collect::<Vec<_>>();
96
97        bail!("top-level doesn't contains any .sql file: {:?}", files);
98    }
99
100    // Init Tera template
101    let tera = get_tera(args.file.clone(), working_dir)?;
102
103    // For debug
104    let loaded_template: Vec<_> = tera.get_template_names().collect();
105    debug!("loaded templates: {:?}", loaded_template);
106
107    // TODO: Tera context
108    let context = tera::Context::new();
109
110    // Render the index.sql file if the target path is a folder
111    let endpoint = if is_dir {
112        format!("{}/{}", path_str, INDEX_SQL_FILENAME)
113            .trim_start_matches('/')
114            .to_string()
115    } else {
116        path_str.to_string()
117    };
118
119    let out = tera
120        .render(&endpoint, &context)
121        .with_context(|| format!("failed to render from {}", path_str))?;
122
123    Ok(out.trim().to_string())
124}
125
126fn get_dirs(args: Build) -> Result<(PathBuf, String)> {
127    let path = &args.file;
128
129    // Working directory (context directory)
130    let working_dir = get_current_working_dir(args.context)?;
131    debug!("Working dir: {}", &working_dir.display());
132
133    // Get path_str (without context directory prefix)
134    let path_str = get_full_path_str(path)?;
135    let working_dir_str = working_dir
136        .to_str()
137        .ok_or_else(|| anyhow::anyhow!("working directory path is not valid UTF-8"))?;
138    let path_str = path_str
139        .trim_start_matches(working_dir_str)
140        .trim_start_matches('/')
141        .to_string();
142
143    Ok((working_dir, path_str))
144}