From 61cd888620b24447ae30987e9555602f3acca151 Mon Sep 17 00:00:00 2001 From: kjuulh Date: Sat, 15 Feb 2025 22:43:25 +0100 Subject: [PATCH] feat: create support for templates --- Cargo.lock | 17 ++ crates/forest/Cargo.toml | 2 + crates/forest/src/cli.rs | 175 ++++++++++++++---- crates/forest/src/model.rs | 71 +++++++ examples/project/forest.kdl | 6 + .../project/templates/something.yaml.jinja2 | 1 + 6 files changed, 234 insertions(+), 38 deletions(-) create mode 100644 examples/project/templates/something.yaml.jinja2 diff --git a/Cargo.lock b/Cargo.lock index e25548b..31fc9dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -236,7 +236,9 @@ dependencies = [ "anyhow", "clap", "dotenvy", + "glob", "kdl", + "minijinja", "rusty-s3", "serde", "tokio", @@ -284,6 +286,12 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "glob" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" + [[package]] name = "heck" version = "0.5.0" @@ -535,6 +543,15 @@ dependencies = [ "syn", ] +[[package]] +name = "minijinja" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff7b8df5e85e30b87c2b0b3f58ba3a87b68e133738bf512a7713769326dbca9" +dependencies = [ + "serde", +] + [[package]] name = "miniz_oxide" version = "0.8.3" diff --git a/crates/forest/Cargo.toml b/crates/forest/Cargo.toml index 68974ce..2e1f121 100644 --- a/crates/forest/Cargo.toml +++ b/crates/forest/Cargo.toml @@ -17,3 +17,5 @@ rusty-s3 = "0.7.0" url = "2.5.4" kdl = "6.3.3" walkdir = "2.5.0" +minijinja = "2.7.0" +glob = "0.3.2" diff --git a/crates/forest/src/cli.rs b/crates/forest/src/cli.rs index 5bd3dbd..95f5be6 100644 --- a/crates/forest/src/cli.rs +++ b/crates/forest/src/cli.rs @@ -3,9 +3,10 @@ use std::{net::SocketAddr, path::PathBuf}; use clap::{Parser, Subcommand}; use kdl::KdlDocument; use rusty_s3::{Bucket, Credentials, S3Action}; +use tokio::io::AsyncWriteExt; use crate::{ - model::{Context, Plan, Project}, + model::{Context, Plan, Project, TemplateType}, plan_reconciler::PlanReconciler, state::SharedState, }; @@ -15,18 +16,20 @@ use crate::{ struct Command { #[command(subcommand)] command: Option, + + #[arg( + env = "FOREST_PROJECT_PATH", + long = "project-path", + default_value = "." + )] + project_path: PathBuf, } #[derive(Subcommand)] enum Commands { - Init { - #[arg( - env = "FOREST_PROJECT_PATH", - long = "project-path", - default_value = "." - )] - project_path: PathBuf, - }, + Init {}, + + Template {}, Serve { #[arg(env = "FOREST_HOST", long, default_value = "127.0.0.1:3000")] @@ -52,42 +55,138 @@ enum Commands { pub async fn execute() -> anyhow::Result<()> { let cli = Command::parse(); + let project_path = &cli.project_path.canonicalize()?; + let project_file_path = project_path.join("forest.kdl"); + if !project_file_path.exists() { + anyhow::bail!( + "no 'forest.kdl' file was found at: {}", + project_file_path.display().to_string() + ); + } + + let project_file = tokio::fs::read_to_string(&project_file_path).await?; + let project_doc: KdlDocument = project_file.parse()?; + + let project: Project = project_doc.try_into()?; + tracing::trace!("found a project name: {}", project.name); + + let plan = if let Some(plan_file_path) = PlanReconciler::new() + .reconcile(&project, project_path) + .await? + { + let plan_file = tokio::fs::read_to_string(&plan_file_path).await?; + let plan_doc: KdlDocument = plan_file.parse()?; + + let plan: Plan = plan_doc.try_into()?; + tracing::trace!("found a plan name: {}", project.name); + + Some(plan) + } else { + None + }; + + let context = Context { project, plan }; + match cli.command.unwrap() { - Commands::Init { project_path } => { + Commands::Init {} => { tracing::info!("initializing project"); + tracing::trace!("found context: {:?}", context); + } - let project_file_path = project_path.join("forest.kdl"); - if !project_file_path.exists() { - anyhow::bail!( - "no 'forest.kdl' file was found at: {}", - project_file_path.display().to_string() - ); - } + Commands::Template {} => { + tracing::info!("templating"); - let project_file = tokio::fs::read_to_string(&project_file_path).await?; - let project_doc: KdlDocument = project_file.parse()?; - - let project: Project = project_doc.try_into()?; - tracing::trace!("found a project name: {}", project.name); - - let plan = if let Some(plan_file_path) = PlanReconciler::new() - .reconcile(&project, &project_path) - .await? - { - let plan_file = tokio::fs::read_to_string(&plan_file_path).await?; - let plan_doc: KdlDocument = plan_file.parse()?; - - let plan: Plan = plan_doc.try_into()?; - tracing::trace!("found a plan name: {}", project.name); - - Some(plan) - } else { - None + let Some(template) = context.project.templates else { + return Ok(()); }; - let context = Context { project, plan }; + match template.ty { + TemplateType::Jinja2 => { + for entry in glob::glob(&format!( + "{}/{}", + project_path.display().to_string().trim_end_matches("/"), + template.path.trim_start_matches("./"), + )) + .map_err(|e| anyhow::anyhow!("failed to read glob pattern: {}", e))? + { + let entry = + entry.map_err(|e| anyhow::anyhow!("failed to read path: {}", e))?; + let entry_name = entry.display().to_string(); - tracing::info!("context: {:+?}", context); + let entry_rel = if entry.is_absolute() { + entry.strip_prefix(project_path).map(|e| e.to_path_buf()) + } else { + Ok(entry.clone()) + }; + + let rel_file_path = entry_rel + .map(|p| { + if p.file_name() + .map(|f| f.to_string_lossy().ends_with(".jinja2")) + .unwrap_or(false) + { + p.with_file_name( + p.file_stem().expect("to be able to find a filename"), + ) + } else { + p.to_path_buf() + } + }) + .map_err(|e| { + anyhow::anyhow!( + "failed to find relative file: {}, project: {}, file: {}", + e, + project_path.display(), + entry_name + ) + })?; + + let output_file_path = project_path + .join(".forest/temp") + .join(&template.output) + .join(rel_file_path); + + let contents = tokio::fs::read_to_string(&entry).await.map_err(|e| { + anyhow::anyhow!( + "failed to read template: {}, err: {}", + entry.display(), + e + ) + })?; + + let mut env = minijinja::Environment::new(); + env.add_template(&entry_name, &contents)?; + let tmpl = env.get_template(&entry_name)?; + + let output = tmpl + .render(minijinja::context! {}) + .map_err(|e| anyhow::anyhow!("failed to render template: {}", e))?; + + tracing::info!("rendered template: {}", output); + + if let Some(parent) = output_file_path.parent() { + tokio::fs::create_dir_all(parent).await.map_err(|e| { + anyhow::anyhow!( + "failed to create directory (path: {}) for output: {}", + parent.display(), + e + ) + })?; + } + + let mut output_file = tokio::fs::File::create(&output_file_path) + .await + .map_err(|e| { + anyhow::anyhow!( + "failed to create file: {}, error: {}", + output_file_path.display(), + e + ) + })?; + output_file.write_all(output.as_bytes()).await?; + } + } + } } Commands::Serve { diff --git a/crates/forest/src/model.rs b/crates/forest/src/model.rs index 6dac045..fca559b 100644 --- a/crates/forest/src/model.rs +++ b/crates/forest/src/model.rs @@ -153,12 +153,79 @@ impl TryFrom<&KdlNode> for Global { } } +#[derive(Debug, Clone, Default)] +pub enum TemplateType { + #[default] + Jinja2, +} + +#[derive(Debug, Clone)] +pub struct Templates { + pub ty: TemplateType, + pub path: String, + pub output: PathBuf, +} + +impl Default for Templates { + fn default() -> Self { + Self { + ty: TemplateType::default(), + path: "./templates/*.jinja2".into(), + output: "output/".into(), + } + } +} + +impl TryFrom<&KdlNode> for Templates { + type Error = anyhow::Error; + + fn try_from(value: &KdlNode) -> Result { + let mut templates = Templates::default(); + + for entry in value.entries() { + let Some(name) = entry.name() else { continue }; + match name.value() { + "type" => { + let Some(val) = entry.value().as_string() else { + anyhow::bail!("type is not a valid string") + }; + + match val.to_lowercase().as_str() { + "jinja2" => templates.ty = TemplateType::Jinja2, + e => { + anyhow::bail!("failed to find a template matching the required type: {}, only 'jinja2' is supported", e); + } + } + } + "path" => { + let Some(val) = entry.value().as_string() else { + anyhow::bail!("failed to parse path as a valid string") + }; + + templates.path = val.to_string(); + } + "output" => { + let Some(val) = entry.value().as_string() else { + anyhow::bail!("failed to parse val as a valid string") + }; + + templates.output = PathBuf::from(val); + } + _ => continue, + } + } + + Ok(templates) + } +} + #[derive(Debug, Clone)] pub struct Project { pub name: String, pub description: Option, pub plan: Option, pub global: Global, + pub templates: Option, } impl TryFrom for Project { @@ -203,6 +270,10 @@ impl TryFrom for Project { }), plan: project_plan, global: global.unwrap_or_default(), + templates: project_children + .get("templates") + .map(|t| t.try_into()) + .transpose()?, }) } } diff --git a/examples/project/forest.kdl b/examples/project/forest.kdl index 0eb9738..ca69701 100644 --- a/examples/project/forest.kdl +++ b/examples/project/forest.kdl @@ -20,4 +20,10 @@ project { } } } + + templates type=jinja2 { + path "templates/*.jinja2" + output "output/" + } } + diff --git a/examples/project/templates/something.yaml.jinja2 b/examples/project/templates/something.yaml.jinja2 new file mode 100644 index 0000000..deba01f --- /dev/null +++ b/examples/project/templates/something.yaml.jinja2 @@ -0,0 +1 @@ +something