feat: Import file contents from recipe 'file' input type parameter (#4558)
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
use crate::recipe::read_recipe_file_content::RecipeFile;
|
use crate::recipe::read_recipe_file_content::{read_parameter_file_content, RecipeFile};
|
||||||
use crate::recipe::template_recipe::{parse_recipe_content, render_recipe_content_with_params};
|
use crate::recipe::template_recipe::{parse_recipe_content, render_recipe_content_with_params};
|
||||||
use crate::recipe::{
|
use crate::recipe::{
|
||||||
Recipe, RecipeParameter, RecipeParameterRequirement, BUILT_IN_RECIPE_DIR_PARAM,
|
Recipe, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement,
|
||||||
|
BUILT_IN_RECIPE_DIR_PARAM,
|
||||||
};
|
};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
@@ -146,9 +147,20 @@ fn validate_parameters_in_template(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate_optional_parameters(parameters: &Option<Vec<RecipeParameter>>) -> Result<()> {
|
fn validate_optional_parameters(parameters: &Option<Vec<RecipeParameter>>) -> Result<()> {
|
||||||
let optional_params_without_default_values: Vec<String> = parameters
|
let empty_params = vec![];
|
||||||
.as_ref()
|
let params = parameters.as_ref().unwrap_or(&empty_params);
|
||||||
.unwrap_or(&vec![])
|
|
||||||
|
let file_params_with_defaults: Vec<String> = params
|
||||||
|
.iter()
|
||||||
|
.filter(|p| matches!(p.input_type, RecipeParameterInputType::File) && p.default.is_some())
|
||||||
|
.map(|p| p.key.clone())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if !file_params_with_defaults.is_empty() {
|
||||||
|
return Err(anyhow::anyhow!("File parameters cannot have default values to avoid importing sensitive user files: {}", file_params_with_defaults.join(", ")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let optional_params_without_default_values: Vec<String> = params
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|p| {
|
.filter(|p| {
|
||||||
matches!(p.requirement, RecipeParameterRequirement::Optional) && p.default.is_none()
|
matches!(p.requirement, RecipeParameterRequirement::Optional) && p.default.is_none()
|
||||||
@@ -192,6 +204,10 @@ where
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
} else if matches!(param.input_type, RecipeParameterInputType::File) {
|
||||||
|
let file_path = param_map.get(¶m.key).unwrap();
|
||||||
|
let file_content = read_parameter_file_content(file_path)?;
|
||||||
|
param_map.insert(param.key.clone(), file_content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok((param_map, missing_params))
|
Ok((param_map, missing_params))
|
||||||
|
|||||||
@@ -33,6 +33,34 @@ mod tests {
|
|||||||
(temp_dir, recipe_file)
|
(temp_dir, recipe_file)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn setup_test_file(temp_dir: &TempDir, filename: &str, content: &str) -> std::path::PathBuf {
|
||||||
|
let file_path = temp_dir.path().join(filename);
|
||||||
|
std::fs::write(&file_path, content).unwrap();
|
||||||
|
file_path
|
||||||
|
}
|
||||||
|
|
||||||
|
fn setup_yaml_recipe_file(instructions_and_parameters: &str) -> (TempDir, RecipeFile) {
|
||||||
|
let recipe_content = format!(
|
||||||
|
r#"version: "1.0.0"
|
||||||
|
title: "Test Recipe"
|
||||||
|
description: "A test recipe"
|
||||||
|
{}"#,
|
||||||
|
instructions_and_parameters
|
||||||
|
);
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let recipe_path = temp_dir.path().join("test_recipe.yaml");
|
||||||
|
|
||||||
|
std::fs::write(&recipe_path, recipe_content).unwrap();
|
||||||
|
|
||||||
|
let recipe_file = RecipeFile {
|
||||||
|
content: std::fs::read_to_string(&recipe_path).unwrap(),
|
||||||
|
parent_dir: temp_dir.path().to_path_buf(),
|
||||||
|
file_path: recipe_path,
|
||||||
|
};
|
||||||
|
|
||||||
|
(temp_dir, recipe_file)
|
||||||
|
}
|
||||||
|
|
||||||
fn setup_yaml_recipe_files(
|
fn setup_yaml_recipe_files(
|
||||||
parent_content: &str,
|
parent_content: &str,
|
||||||
child_content: &str,
|
child_content: &str,
|
||||||
@@ -439,4 +467,86 @@ instructions: Child instructions
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mod file_parameter_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_recipe_file_parameter_valid_paths() {
|
||||||
|
let instructions_and_parameters = r#"instructions: "Test file content: {{ FILE_PARAM }}"
|
||||||
|
parameters:
|
||||||
|
- key: FILE_PARAM
|
||||||
|
input_type: file
|
||||||
|
requirement: required
|
||||||
|
description: A file parameter"#;
|
||||||
|
|
||||||
|
let (temp_dir, recipe_file) = setup_yaml_recipe_file(instructions_and_parameters);
|
||||||
|
|
||||||
|
let test_content = "Hello from file!\nThis is line 2\n Indented line 3";
|
||||||
|
let test_file_path = setup_test_file(&temp_dir, "test_file.txt", test_content);
|
||||||
|
|
||||||
|
let params = vec![(
|
||||||
|
"FILE_PARAM".to_string(),
|
||||||
|
test_file_path.to_string_lossy().to_string(),
|
||||||
|
)];
|
||||||
|
let result = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT);
|
||||||
|
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let recipe = result.unwrap();
|
||||||
|
|
||||||
|
let instructions = recipe.instructions.as_ref().unwrap();
|
||||||
|
assert!(instructions.contains("Hello from file!"));
|
||||||
|
assert!(instructions.contains("Test file content:"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_recipe_file_parameter_nonexistent_file() {
|
||||||
|
let instructions_and_parameters = r#"instructions: "Test file content: {{ FILE_PARAM }}"
|
||||||
|
parameters:
|
||||||
|
- key: FILE_PARAM
|
||||||
|
input_type: file
|
||||||
|
requirement: required
|
||||||
|
description: A file parameter"#;
|
||||||
|
|
||||||
|
let (_temp_dir, recipe_file) = setup_yaml_recipe_file(instructions_and_parameters);
|
||||||
|
|
||||||
|
let params = vec![(
|
||||||
|
"FILE_PARAM".to_string(),
|
||||||
|
"/nonexistent/path/file.txt".to_string(),
|
||||||
|
)];
|
||||||
|
let result = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT);
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
if let Err(RecipeError::TemplateRendering { source }) = result {
|
||||||
|
assert!(source.to_string().contains("Failed to read parameter file"));
|
||||||
|
} else {
|
||||||
|
panic!("Expected TemplateRendering error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_build_recipe_file_parameter_with_default_rejected() {
|
||||||
|
let instructions_and_parameters = r#"instructions: "Test file content: {{ FILE_PARAM }}"
|
||||||
|
parameters:
|
||||||
|
- key: FILE_PARAM
|
||||||
|
input_type: file
|
||||||
|
requirement: required
|
||||||
|
description: A file parameter
|
||||||
|
default: "/etc/passwd""#;
|
||||||
|
|
||||||
|
let (_temp_dir, recipe_file) = setup_yaml_recipe_file(instructions_and_parameters);
|
||||||
|
|
||||||
|
let params = vec![];
|
||||||
|
let result = build_recipe_from_template(recipe_file, params, NO_USER_PROMPT);
|
||||||
|
|
||||||
|
assert!(result.is_err());
|
||||||
|
if let Err(RecipeError::TemplateRendering { source }) = result {
|
||||||
|
assert!(source
|
||||||
|
.to_string()
|
||||||
|
.contains("File parameters cannot have default values"));
|
||||||
|
} else {
|
||||||
|
panic!("Expected TemplateRendering error for file parameter with default");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -206,6 +206,8 @@ pub enum RecipeParameterInputType {
|
|||||||
Number,
|
Number,
|
||||||
Boolean,
|
Boolean,
|
||||||
Date,
|
Date,
|
||||||
|
/// File parameter that imports content from a file path.
|
||||||
|
/// Cannot have default values to prevent importing sensitive user files.
|
||||||
File,
|
File,
|
||||||
Select,
|
Select,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,3 +58,43 @@ fn convert_path_with_tilde_expansion(path: &Path) -> PathBuf {
|
|||||||
}
|
}
|
||||||
PathBuf::from(path)
|
PathBuf::from(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn read_parameter_file_content<P: AsRef<Path>>(file_path: P) -> Result<String> {
|
||||||
|
let raw_path = file_path.as_ref();
|
||||||
|
let path = convert_path_with_tilde_expansion(raw_path);
|
||||||
|
|
||||||
|
let content = fs::read_to_string(&path)
|
||||||
|
.map_err(|e| anyhow!("Failed to read parameter file {}: {}", path.display(), e))?;
|
||||||
|
|
||||||
|
Ok(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_parameter_file_content_success() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let file_path = temp_dir.path().join("test_file.txt");
|
||||||
|
let content = "Hello World\nSecond line\n Third line";
|
||||||
|
std::fs::write(&file_path, content).unwrap();
|
||||||
|
|
||||||
|
let result = read_parameter_file_content(&file_path);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
|
||||||
|
let expected = "Hello World\nSecond line\n Third line";
|
||||||
|
assert_eq!(result.unwrap(), expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_read_parameter_file_content_nonexistent_file() {
|
||||||
|
let result = read_parameter_file_content("/nonexistent/path/file.txt");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result
|
||||||
|
.unwrap_err()
|
||||||
|
.to_string()
|
||||||
|
.contains("Failed to read parameter file"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user