chore: add source

This commit is contained in:
Jesús Pérex 2025-06-26 23:52:06 +01:00
parent af18c7929b
commit 5a4d9df16e
5 changed files with 2704 additions and 0 deletions

2227
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

23
Cargo.toml Normal file
View File

@ -0,0 +1,23 @@
[package]
name = "nu_plugin_kcl"
version = "0.1.0"
authors = ["Jesús Pérez <jpl@jesusperez.com>"]
edition = "2024"
description = "Nushell plugin for KCL CLI wrapper"
repository = "https://github.com/jesusperez/nu_plugin_kcl"
license = "MIT"
[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"] }
anyhow = "1.0"
tempfile = "3"
[dev-dependencies]
nu-plugin-test-support = { path = "../nushell/crates/nu-plugin-test-support" }
#nu-plugin-test-support = { version = "0.104.0" }

144
src/helpers.rs Normal file
View File

@ -0,0 +1,144 @@
// Helper functions using KCL CLI
use anyhow::Result;
use std::process::Command;
/// Run a KCL file using the KCL CLI.
///
/// # Arguments
/// * `file` - Path to the KCL file to execute.
/// * `format` - Output format (e.g., "yaml" or "json").
/// * `output` - Optional output file path.
/// * `defines` - List of variable definitions (e.g., ["foo=bar"]).
///
/// # Returns
/// * `Ok(String)` with the output or output file path on success.
/// * `Err(anyhow::Error)` if the KCL command fails.
pub(crate) fn run_kcl_command(
file: &str,
format: &str,
output: &Option<String>,
defines: &[String],
) -> Result<String> {
let mut cmd = Command::new("kcl");
cmd.arg("run").arg(file).arg("--format").arg(format);
// Add defined variables
for define in defines {
cmd.arg("-D").arg(define);
}
// Add output file if specified
if let Some(output_file) = output {
cmd.arg("-o").arg(output_file);
}
let output_res = cmd
.output()
.map_err(|e| anyhow::anyhow!("Error executing kcl: {}", e))?;
if output_res.status.success() {
if let Some(output_file) = output {
Ok(format!("{}", output_file))
} else {
Ok(format!(
"✅ {}",
String::from_utf8_lossy(&output_res.stdout)
))
}
} else {
Err(anyhow::anyhow!(
"❌: {}",
String::from_utf8_lossy(&output_res.stderr)
))
}
}
/// Format a KCL file using the KCL CLI.
///
/// # Arguments
/// * `file` - Path to the KCL file to format.
///
/// # Returns
/// * `Ok(String)` with a success message if formatting succeeds.
/// * `Err(anyhow::Error)` if formatting fails.
pub(crate) fn format_kcl_file(file: &str) -> Result<String> {
let output = Command::new("kcl")
.arg("fmt")
.arg(file)
.output()
.map_err(|e| anyhow::anyhow!("Error executing kcl fmt: {}", e))?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"KCL format failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
Ok(format!("✅ File formatted: {}", file))
}
/// Validate all KCL files in a directory using the KCL CLI.
///
/// # Arguments
/// * `dir` - Path to the directory to search for KCL files.
///
/// # Returns
/// * `Ok(String)` with a summary of validation results if all files are valid or no files found.
/// * `Err(anyhow::Error)` if validation fails for any file or if the find command fails.
pub(crate) fn validate_kcl_project(dir: &str) -> Result<String> {
// Find KCL files in directory
let find_output = Command::new("find")
.arg(dir)
.arg("-name")
.arg("*.k")
.arg("-type")
.arg("f")
.output()
.map_err(|e| anyhow::anyhow!("Error finding KCL files: {}", e))?;
let files = String::from_utf8_lossy(&find_output.stdout);
let kcl_files: Vec<&str> = files.lines().filter(|line| !line.is_empty()).collect();
if kcl_files.is_empty() {
return Ok(format!("No KCL files found in {}", dir));
}
let mut results = Vec::new();
let mut all_valid = true;
for file in &kcl_files {
let output = Command::new("kcl")
.arg("run")
.arg(file)
.arg("--format")
.arg("yaml")
.output();
match output {
Ok(output) if output.status.success() => {
results.push(format!("{}", file));
}
Ok(output) => {
results.push(format!(
"❌ {}: {}",
file,
String::from_utf8_lossy(&output.stderr)
));
all_valid = false;
}
Err(e) => {
results.push(format!("{}: Execution error: {}", file, e));
all_valid = false;
}
}
}
let summary = if all_valid {
format!("✅ All {} files are valid", kcl_files.len())
} else {
format!("❌ Errors found in some files")
};
Ok(format!("{}\n\n{}", summary, results.join("\n")))
}

228
src/main.rs Normal file
View File

@ -0,0 +1,228 @@
use nu_plugin::{
EngineInterface, EvaluatedCall, MsgPackSerializer, Plugin, PluginCommand, SimplePluginCommand,
serve_plugin,
};
use nu_protocol::{Category, Example, LabeledError, Signature, SyntaxShape, Type, Value};
use anyhow::Result;
mod helpers;
#[cfg(test)]
mod tests;
use crate::helpers::{format_kcl_file, run_kcl_command, validate_kcl_project};
/// Nushell plugin for running, formatting, and validating KCL files using the KCL CLI.
///
/// This plugin provides three commands:
/// - `kcl-run`: Execute KCL files and return their output.
/// - `kcl-format`: Format KCL files.
/// - `kcl-validate`: Validate all KCL files in a directory.
///
/// See each command struct for more details and usage examples.
struct KclWrapperPlugin;
/// Implements the Nushell Plugin trait for the KCL wrapper plugin.
impl Plugin for KclWrapperPlugin {
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()
}
fn commands(&self) -> Vec<Box<dyn PluginCommand<Plugin = Self>>> {
vec![Box::new(KclRun), Box::new(KclFormat), Box::new(KclValidate)]
}
}
/// Command to execute KCL files using the KCL CLI.
///
/// # Usage
/// ```nu
/// kcl-run myfile.k -D foo=bar -f json
/// ```
///
/// See `examples()` for more.
struct KclRun;
impl SimplePluginCommand for KclRun {
type Plugin = KclWrapperPlugin;
fn name(&self) -> &str {
"kcl-run"
}
fn signature(&self) -> Signature {
Signature::build(PluginCommand::name(self))
.input_output_type(Type::Any, Type::String)
.required("file", SyntaxShape::Filepath, "KCL file to execute")
.named(
"format",
SyntaxShape::String,
"Output format (yaml/json)",
Some('f'),
)
.named("output", SyntaxShape::Filepath, "Output file", Some('o'))
.named(
"define",
SyntaxShape::String,
"Variables to define (key=value)",
Some('D'),
)
.category(Category::Experimental)
}
fn description(&self) -> &str {
"Execute KCL files using the CLI wrapper"
}
fn examples(&self) -> Vec<Example> {
vec![Example {
example: "kcl-run myfile.k -D foo=bar -f json",
description: "Run 'myfile.k' with variable 'foo=bar' and output as JSON.",
result: Some(Value::test_string("{\n \"foo\": \"bar\"\n}")),
}]
}
fn run(
&self,
_plugin: &KclWrapperPlugin,
_engine: &EngineInterface,
call: &EvaluatedCall,
_input: &Value,
) -> Result<Value, LabeledError> {
let file_path: String = call.req(0)?;
let format = call
.get_flag_value("format")
.and_then(|v| v.as_str().ok().map(|s| s.to_string()))
.unwrap_or_else(|| "yaml".to_string());
let output = call
.get_flag_value("output")
.and_then(|v| v.as_str().ok().map(|s| s.to_string()));
let defines: Vec<String> = call
.get_flag_value("define")
.and_then(|v| v.as_list().ok().map(|list| list.to_vec()))
.map(|list| {
list.into_iter()
.filter_map(|v| v.as_str().ok().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
match run_kcl_command(&file_path, &format, &output, &defines) {
Ok(result) => Ok(Value::string(result, call.head)),
Err(e) => {
Err(LabeledError::new("Error executing KCL").with_label(e.to_string(), call.head))
}
}
}
}
/// Command to format KCL files using the KCL CLI.
///
/// # Usage
/// ```nu
/// kcl-format myfile.k
/// ```
///
/// See `examples()` for more.
struct KclFormat;
impl SimplePluginCommand for KclFormat {
type Plugin = KclWrapperPlugin;
fn name(&self) -> &str {
"kcl-format"
}
fn description(&self) -> &str {
"Format KCL files"
}
fn signature(&self) -> Signature {
Signature::build(PluginCommand::name(self))
.input_output_type(Type::String, Type::String)
.required("file", SyntaxShape::Filepath, "KCL file to format")
.category(Category::Experimental)
}
fn run(
&self,
_plugin: &KclWrapperPlugin,
_engine: &EngineInterface,
call: &EvaluatedCall,
_input: &Value,
) -> Result<Value, LabeledError> {
let file_path: String = call.req(0)?;
match format_kcl_file(&file_path) {
Ok(result) => Ok(Value::string(result, call.head)),
Err(e) => {
Err(LabeledError::new("Error formatting KCL").with_label(e.to_string(), call.head))
}
}
}
fn examples(&self) -> Vec<Example> {
vec![Example {
example: "kcl-format myfile.k",
description: "Format the KCL file 'myfile.k'.",
result: Some(Value::test_string("✅ File formatted: myfile.k")),
}]
}
}
/// Command to validate all KCL files in a directory using the KCL CLI.
///
/// # Usage
/// ```nu
/// kcl-validate ./project_dir
/// ```
///
/// See `examples()` for more.
struct KclValidate;
impl SimplePluginCommand for KclValidate {
type Plugin = KclWrapperPlugin;
fn name(&self) -> &str {
"kcl-validate"
}
fn description(&self) -> &str {
"kcl validate"
}
fn signature(&self) -> Signature {
Signature::build(PluginCommand::name(self))
.input_output_type(Type::Any, Type::String)
.optional("dir", SyntaxShape::Directory, "Directory to validate")
.category(Category::Experimental)
}
fn run(
&self,
_plugin: &KclWrapperPlugin,
_engine: &EngineInterface,
call: &EvaluatedCall,
_input: &Value,
) -> Result<Value, LabeledError> {
let dir = call.opt::<String>(0)?.unwrap_or_else(|| ".".to_string());
match validate_kcl_project(&dir) {
Ok(result) => Ok(Value::string(result, call.head)),
Err(e) => Err(LabeledError::new("Error validating KCL project")
.with_label(e.to_string(), call.head)),
}
}
fn examples(&self) -> Vec<Example> {
vec![Example {
example: "kcl-validate ./project_dir",
description: "Validate all KCL files in the directory './project_dir'.",
result: Some(Value::test_string(
"✅ All 3 files are valid\n\n✅ ./project_dir/main.k\n✅ ./project_dir/vars.k\n✅ ./project_dir/other.k",
)),
}]
}
}
/// Entry point for the KCL Nushell plugin.
///
/// This function registers the plugin and its commands with Nushell.
fn main() {
serve_plugin(&KclWrapperPlugin, MsgPackSerializer);
}

82
src/tests.rs Normal file
View File

@ -0,0 +1,82 @@
/// Unit tests for KCL plugin helpers.
///
/// These tests check the behavior of running, formatting, and validating KCL files
/// using the KCL CLI. All tests are skipped if the `kcl` binary is not installed.
#[cfg(test)]
mod tests {
// use super::*;
use crate::helpers::{format_kcl_file, run_kcl_command, validate_kcl_project};
use std::io::Write;
use std::process::Command;
use tempfile::{NamedTempFile, tempdir};
/// Returns true if the `kcl` CLI is installed and available in PATH.
fn kcl_installed() -> bool {
Command::new("kcl").arg("--version").output().is_ok()
}
/// Test that running a valid KCL file with `run_kcl_command` succeeds.
#[test]
fn test_run_kcl_command_success() {
if !kcl_installed() {
return;
}
let mut file = NamedTempFile::new().expect("Failed to create temp KCL file");
writeln!(file, "a = 1").expect("Failed to write KCL code to temp file");
let path = file
.path()
.to_str()
.expect("Temp file path is not valid UTF-8");
let res = run_kcl_command(path, "yaml", &None, &[]);
assert!(res.is_ok(), "Expected Ok, got: {:?}", res);
let out = res.expect("run_kcl_command returned Err unexpectedly");
assert!(out.contains("a = 1") || out.contains("") || out.contains("a: 1"));
}
/// Test that formatting a valid KCL file with `format_kcl_file` succeeds.
#[test]
fn test_format_kcl_file_success() {
if !kcl_installed() {
return;
}
let mut file = NamedTempFile::new().expect("Failed to create temp KCL file");
writeln!(file, "a = 1").expect("Failed to write KCL code to temp file");
let path = file
.path()
.to_str()
.expect("Temp file path is not valid UTF-8");
let res = format_kcl_file(path);
assert!(res.is_ok(), "Expected Ok, got: {:?}", res);
let out = res.expect("format_kcl_file returned Err unexpectedly");
assert!(out.contains("formatted"));
}
/// Test that validating a directory with a valid KCL file using `validate_kcl_project` succeeds.
#[test]
fn test_validate_kcl_project_success() {
if !kcl_installed() {
return;
}
let dir = tempdir().expect("Failed to create temp dir");
let file_path = dir.path().join("test.k");
std::fs::write(&file_path, "a = 1").expect("Failed to write KCL code to temp file");
let res = validate_kcl_project(
dir.path()
.to_str()
.expect("Temp dir path is not valid UTF-8"),
);
assert!(res.is_ok(), "Expected Ok, got: {:?}", res);
let out = res.expect("validate_kcl_project returned Err unexpectedly");
assert!(out.contains("valid") || out.contains(""));
}
/// Test that running a nonexistent KCL file with `run_kcl_command` returns an error.
#[test]
fn test_run_kcl_command_fail() {
if !kcl_installed() {
return;
}
let res = run_kcl_command("nonexistent.k", "yaml", &None, &[]);
assert!(res.is_err(), "Expected Err, got: {:?}", res);
}
}