chore: add code

This commit is contained in:
Jesús Pérex 2025-06-26 23:20:57 +01:00
parent 050b9b8512
commit 38a7885504
5 changed files with 2990 additions and 0 deletions

2615
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

24
Cargo.toml Normal file
View File

@ -0,0 +1,24 @@
[package]
name = "nu_plugin_tera"
version = "0.1.0"
authors = ["Jesús Pérex <jpl@jesusperez.com>"]
edition = "2024"
description = "a nushell plugin called tera"
repository = "https://github.com/JesusPerez/nu_plugin_tera"
license = "MIT"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
# for local development, you can use a path dependency
nu-plugin = { path = "../nushell/crates/nu-plugin" }
nu-protocol = { path = "../nushell/crates/nu-protocol", features = ["plugin"] }
#nu-plugin = "0.104.0"
#nu-protocol = { version = "0.104.0", features = ["plugin"] }
tera = "1.3"
serde_json = "1.0"
[dev-dependencies]
nu-plugin-test-support = { path = "../nushell/crates/nu-plugin-test-support" }
#nu-plugin-test-support = { version = "0.104.0" }

65
src/helpers.rs Normal file
View File

@ -0,0 +1,65 @@
use nu_protocol::{LabeledError, Value};
/// Convert a Nushell Value to a serde_json::Value suitable for Tera context.
///
/// - Records are converted to JSON objects.
/// - Lists, strings, ints, and bools are wrapped in an object with key "value".
/// - Other types return an error.
pub fn value_to_serde_json(value: Value) -> Result<serde_json::Value, LabeledError> {
match value {
Value::Record { val, .. } => {
let record = &*val;
let mut map = serde_json::Map::new();
for (col, val) in record.columns().zip(record.values()) {
map.insert(col.clone(), value_to_serde_json(val.clone())?);
}
Ok(serde_json::Value::Object(map))
}
Value::List { vals, .. } => {
let vec = vals
.into_iter()
.map(value_to_serde_json)
.collect::<Result<Vec<_>, _>>()?;
Ok(serde_json::Value::Array(vec))
}
Value::String { val, .. } => Ok(serde_json::Value::String(val)),
Value::Int { val, .. } => Ok(serde_json::Value::Number(val.into())),
Value::Bool { val, .. } => Ok(serde_json::Value::Bool(val)),
_ => Err(LabeledError::new("Type not supported")
.with_label("Use records, lists or primitives", value.span())),
}
}
/// Removes the top-level 'value' key if it is the only key in the object, and always returns an object (wraps non-objects as { "value": ... }).
pub fn unwrap_value_key(json: serde_json::Value) -> serde_json::Value {
let unwrapped = if let serde_json::Value::Object(mut map) = json {
if map.len() == 1 {
if let Some(inner) = map.remove("value") {
return unwrap_value_key(inner);
}
}
serde_json::Value::Object(map)
} else {
json
};
match unwrapped {
serde_json::Value::Object(_) => unwrapped,
other => {
let mut map = serde_json::Map::new();
map.insert("value".to_string(), other);
serde_json::Value::Object(map)
}
}
}
/// Wraps the top-level value if it is not an object.
pub fn wrap_top_level_if_needed(json: serde_json::Value) -> serde_json::Value {
match json {
serde_json::Value::Object(_) => json,
other => {
let mut map = serde_json::Map::new();
map.insert("value".to_string(), other);
serde_json::Value::Object(map)
}
}
}

183
src/main.rs Normal file
View File

@ -0,0 +1,183 @@
use nu_plugin::{
EngineInterface, EvaluatedCall, MsgPackSerializer, Plugin, PluginCommand, SimplePluginCommand,
serve_plugin,
};
use nu_protocol::{Category, Example, LabeledError, Signature, SyntaxShape, Type, Value};
use std::fs;
use tera::Tera;
//use serde_json::json;
mod helpers;
use crate::helpers::{unwrap_value_key, value_to_serde_json, wrap_top_level_if_needed};
#[cfg(test)]
mod tests;
/// Nushell plugin for rendering Tera templates with structured data.
pub struct TeraPlugin;
impl Plugin for TeraPlugin {
/// Returns the plugin version from Cargo.toml.
fn version(&self) -> String {
// This automatically uses the version of your package from Cargo.toml as the plugin version
// sent to Nushell
env!("CARGO_PKG_VERSION").into()
}
/// Returns the list of commands provided by this plugin.
fn commands(&self) -> Vec<Box<dyn PluginCommand<Plugin = Self>>> {
vec![
// Commands should be added here
Box::new(Render),
]
}
}
/// The main render command for the Tera plugin.
pub struct Render;
impl SimplePluginCommand for Render {
type Plugin = TeraPlugin;
/// The name of the command as used in Nushell.
fn name(&self) -> &str {
"tera-render"
}
/// The Nushell signature for the command, describing its parameters and usage.
fn signature(&self) -> Signature {
Signature::build(PluginCommand::name(self))
.input_output_type(Type::Any, Type::String)
.required("template", SyntaxShape::Filepath, "Ruta al archivo .tera")
// .switch("shout", "(FIXME) Yell it instead", None)
.optional(
"context",
SyntaxShape::Any,
"Datos de contexto (record o JSON path)",
)
.category(Category::Experimental)
}
/// A short description of the command for Nushell help.
fn description(&self) -> &str {
"(FIXME) help text for render"
}
/// Example usages of the command for Nushell help and testing.
fn examples(&self) -> Vec<Example> {
vec![Example {
example: "{ name: 'Akasha', projects: [ {'name': 'TheProject' , 'status': 'active' }] } | tera-render example/template.tera",
description: "Render template.tera with a record as context from the pipeline.\n\n\
template.tera:\n\
Hello, {{ name }}!Projects:\n\
{% for project in projects -%}\n\
- {{ project.name }} ({{ project.status }})\n\
{% endfor %}\n\n\
Other options:\n\
open data.json | wrap value | tera-render template.tera\n\
open data.json | tera-render template.tera\n\
",
result: Some(Value::test_string(
"Hello, Akasha!\nProjects:\n- TheProject (active)\n\n",
)),
}]
}
/// The main entry point for the command. Handles reading the template, context, and rendering.
fn run(
&self,
_plugin: &TeraPlugin,
_engine: &EngineInterface,
call: &EvaluatedCall,
input: &Value,
) -> Result<Value, LabeledError> {
let template_path: String = call.req(0)?;
let context_arg: Option<Value> = call.opt(1)?;
// if call.has_flag("shout")? {
// Read template
let template_content = fs::read_to_string(&template_path)
.map_err(|e| LabeledError::new("Read error").with_label(e.to_string(), call.head))?;
// Get data context (input pipeline or argument)
let context = match context_arg {
Some(val) => {
if let Value::String { val: ref s, .. } = val {
if s.ends_with(".json") {
let file_content = std::fs::read_to_string(s).map_err(|e| {
LabeledError::new("Failed to read JSON file")
.with_label(e.to_string(), val.span())
})?;
let json: serde_json::Value =
serde_json::from_str(&file_content).map_err(|e| {
LabeledError::new("Failed to parse JSON file")
.with_label(e.to_string(), val.span())
})?;
let context_json = unwrap_value_key(wrap_top_level_if_needed(json));
// println!("DEBUG context: {}", context_json);
let mut tera = Tera::default();
tera.add_raw_template(&template_path, &template_content)
.map_err(|e| {
LabeledError::new("Template error")
.with_label(e.to_string(), call.head)
})?;
let context = tera::Context::from_serialize(context_json).map_err(|e| {
LabeledError::new("Tera context error")
.with_label(e.to_string(), val.span())
})?;
let output = tera.render(&template_path, &context).map_err(|e| {
LabeledError::new("Render error").with_label(e.to_string(), call.head)
})?;
return Ok(Value::string(output, call.head));
} else if s.ends_with(".yaml")
|| s.ends_with(".yml")
|| s.ends_with(".toml")
|| s.ends_with(".csv")
|| std::path::Path::new(s).exists()
{
return Err(LabeledError::new("Context is a file path, not data")
.with_label(
format!("You passed a file path ('{}') as context. Use 'open' to read the file: open {} | tera-render ...", s, s),
val.span()
));
}
}
// Default context handling if not a file path string
let context_json =
unwrap_value_key(wrap_top_level_if_needed(value_to_serde_json(val.clone())?));
// println!("DEBUG context: {}", context_json);
tera::Context::from_serialize(context_json).map_err(|e| {
LabeledError::new("Tera context error").with_label(e.to_string(), val.span())
})?
}
None => {
let context_json = unwrap_value_key(wrap_top_level_if_needed(value_to_serde_json(
input.clone(),
)?));
//println!("DEBUG context: {}", context_json);
tera::Context::from_serialize(context_json).map_err(|e| {
LabeledError::new("Tera context error").with_label(e.to_string(), input.span())
})?
}
};
// Render with Tera
let mut tera = Tera::default();
tera.add_raw_template(&template_path, &template_content)
.map_err(|e| {
LabeledError::new("Template error").with_label(e.to_string(), call.head)
})?;
let output = tera
.render(&template_path, &context)
.map_err(|e| LabeledError::new("Render error").with_label(e.to_string(), call.head))?;
Ok(Value::string(output, call.head))
}
}
/// Entry point for the plugin binary.
fn main() {
serve_plugin(&TeraPlugin, MsgPackSerializer);
}

103
src/tests.rs Normal file
View File

@ -0,0 +1,103 @@
// use super::*;
use nu_protocol::{Record, Span, Value};
use tera::Tera;
use crate::helpers::{unwrap_value_key, value_to_serde_json, wrap_top_level_if_needed};
use crate::{Render, TeraPlugin};
/// Runs the plugin test examples using nu_plugin_test_support.
#[test]
fn test_examples() -> Result<(), nu_protocol::ShellError> {
use nu_plugin_test_support::PluginTest;
// This will automatically run the examples specified in your command and compare their actual
// output against what was specified in the example. You can remove this test if the examples
// can't be tested this way, but we recommend including it if possible.
PluginTest::new("tera", TeraPlugin.into())?.test_command_examples(&Render)
}
#[test]
fn test_value_to_serde_json_record() {
let record = Record::from_raw_cols_vals(
vec!["name".to_string(), "age".to_string()],
vec![
Value::string("Akasha", Span::test_data()),
Value::int(42, Span::test_data()),
],
Span::test_data(),
Span::test_data(),
)
.expect("failed to create test record");
let val = Value::record(record, Span::test_data());
let json = value_to_serde_json(val).unwrap();
assert_eq!(json["name"], "Akasha");
assert_eq!(json["age"], 42);
}
#[test]
fn test_value_to_serde_json_list() {
let val = Value::list(
vec![
Value::int(1, Span::test_data()),
Value::int(2, Span::test_data()),
],
Span::test_data(),
);
let json = value_to_serde_json(val).unwrap();
assert_eq!(json, serde_json::json!([1, 2]));
}
#[test]
fn test_value_to_serde_json_string() {
let val = Value::string("hello", Span::test_data());
let json = value_to_serde_json(val).unwrap();
assert_eq!(json, serde_json::json!("hello"));
}
#[test]
fn test_unwrap_value_key_simple() {
let json = serde_json::json!({"value": {"name": "Akasha"}});
let unwrapped = unwrap_value_key(json);
assert_eq!(unwrapped["name"], "Akasha");
}
#[test]
fn test_unwrap_value_key_nested() {
let json = serde_json::json!({"value": {"value": {"name": "Akasha"}}});
let unwrapped = unwrap_value_key(json);
assert_eq!(unwrapped["name"], "Akasha");
}
#[test]
fn test_unwrap_value_key_non_object() {
let json = serde_json::json!(42);
let unwrapped = unwrap_value_key(json);
assert_eq!(unwrapped["value"], 42);
}
#[test]
fn test_unwrap_value_key_object() {
let json = serde_json::json!({"name": "Akasha"});
let unwrapped = unwrap_value_key(json);
assert_eq!(unwrapped["name"], "Akasha");
}
#[test]
fn test_render_pipeline() {
let template = "Hello, {{ name }}!";
let mut tera = Tera::default();
tera.add_raw_template("test", template).unwrap();
let record = Record::from_raw_cols_vals(
vec!["name".to_string()],
vec![Value::string("Akasha", Span::test_data())],
Span::test_data(),
Span::test_data(),
)
.expect("failed to create test record");
let val = Value::record(record, Span::test_data());
let context_json =
unwrap_value_key(wrap_top_level_if_needed(value_to_serde_json(val).unwrap()));
let context = tera::Context::from_serialize(context_json).unwrap();
let output = tera.render("test", &context).unwrap();
assert_eq!(output, "Hello, Akasha!");
}