use std::{ borrow::Cow, collections::HashMap, convert::Infallible, fmt::{Display, Formatter}, ops::Deref, path::{Path, PathBuf}, str::FromStr, }; use crate::PixiPlatform; use crate::workspace::JINJA_ENV; use indexmap::IndexMap; use itertools::Itertools; use miette::{Diagnostic, SourceSpan}; use serde::{Deserialize, Serialize}; use thiserror::Error; use toml_edit::{Array, InlineTable, Item, Table, Value}; use crate::EnvironmentName; /// Represents a task name #[derive(Debug, Clone, Serialize, Eq, PartialEq, Hash, Ord, PartialOrd, Deserialize)] pub struct TaskName(String); impl TaskName { pub fn as_str(&self) -> &str { self.0.as_str() } } impl Display for TaskName { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.as_str()) } } impl From<&str> for TaskName { fn from(name: &str) -> Self { TaskName(name.to_string()) } } impl From for TaskName { fn from(name: String) -> Self { TaskName(name) } } /// A task dependency with optional args #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct Dependency { pub task_name: TaskName, pub args: Option>, pub environment: Option, } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum DependencyArg { Positional(TemplateString), Named(String, TemplateString), } impl Dependency { pub fn new( s: &str, args: Option>, environment: Option, ) -> Self { Dependency { task_name: TaskName(s.to_string()), args, environment, } } pub fn new_without_env(s: &str, args: Option>) -> Self { Dependency { task_name: TaskName(s.to_string()), args, environment: None, } } pub fn render_args( &self, context: &TaskRenderContext, ) -> Result>, TemplateStringError> { match &self.args { Some(task_args) => { let mut result = Vec::new(); for arg in task_args { match arg { DependencyArg::Positional(val) => { result.push(TypedDependencyArg::Positional(val.render(context)?)); } DependencyArg::Named(key, val) => { result .push(TypedDependencyArg::Named(key.clone(), val.render(context)?)); } } } Ok(Some(result)) } None => Ok(None), } } } impl From<&str> for Dependency { fn from(s: &str) -> Self { Dependency::new_without_env(s, None) } } impl std::fmt::Display for Dependency { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match &self.args { Some(args) if !args.is_empty() => write!(f, "{} with args", self.task_name), _ => write!(f, "{}", self.task_name), } } } impl FromStr for TaskName { type Err = Infallible; fn from_str(s: &str) -> Result { Ok(TaskName(s.to_string())) } } #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub struct TypedDependency { pub task_name: TaskName, pub args: Option>, pub environment: Option, } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, PartialOrd, Ord)] pub enum TypedDependencyArg { Positional(String), Named(String, String), } impl TypedDependency { pub fn from_dependency( dependency: &Dependency, context: &TaskRenderContext, ) -> Result { Ok(TypedDependency { task_name: dependency.task_name.clone(), args: dependency.render_args(context)?, environment: dependency.environment.clone(), }) } } /// Represents different types of scripts #[derive(Debug, Clone, Serialize, Deserialize)] pub enum Task { Plain(TemplateString), Execute(Box), Alias(Alias), Custom(Custom), } impl Task { /// Returns the names of the task that this task depends on pub fn depends_on(&self) -> &[Dependency] { match self { Task::Plain(_) | Task::Custom(_) => &[], Task::Execute(cmd) => &cmd.depends_on, Task::Alias(cmd) => &cmd.depends_on, } } /// If this command is an execute command, returns the `Execute` task. pub fn as_execute(&self) -> Option<&Execute> { match self { Task::Execute(execute) => Some(execute), _ => None, } } /// Returns true if this task is directly executable pub fn is_executable(&self) -> bool { match self { Task::Plain(_) | Task::Custom(_) | Task::Execute(_) => true, Task::Alias(_) => false, } } /// Returns the command to execute. pub fn as_command(&self) -> Option { match self { Task::Plain(str) => Some(CmdArgs::Single(str.clone())), Task::Custom(custom) => Some(custom.cmd.clone()), Task::Execute(exe) => Some(exe.cmd.clone()), Task::Alias(_) => None, } } /// Returns the command to execute as a single string. pub fn as_single_command( &self, context: &TaskRenderContext, ) -> Result>, TemplateStringError> { match self { Task::Plain(str) => match str.render(context) { Ok(rendered) => Ok(Some(Cow::Owned(rendered))), Err(e) => Err(e), }, Task::Custom(custom) => { if custom.templated { custom.cmd.as_single(context) } else { custom.cmd.as_single_no_render() } } Task::Execute(exe) => exe.cmd.as_single(context), Task::Alias(_) => Ok(None), } } pub fn as_single_command_no_render(&self) -> Result>, TemplateStringError> { match self { Task::Plain(str) => Ok(Some(Cow::Owned(str.source().to_string()))), Task::Custom(custom) => custom.cmd.as_single_no_render(), Task::Execute(exe) => exe.cmd.as_single_no_render(), Task::Alias(_) => Ok(None), } } /// Returns the environment variables for the task to run in. pub fn env(&self) -> Option<&IndexMap> { match self { Task::Plain(_) => None, Task::Custom(_) => None, Task::Execute(exe) => exe.env.as_ref(), Task::Alias(_) => None, } } /// Returns the working directory for the task to run in. pub fn working_directory(&self) -> Option<&Path> { match self { Task::Plain(_) => None, Task::Custom(custom) => custom.cwd.as_deref(), Task::Execute(exe) => exe.cwd.as_deref(), Task::Alias(_) => None, } } /// Returns the description of the task. pub fn description(&self) -> Option<&str> { match self { Task::Plain(_) => None, Task::Custom(_) => None, Task::Execute(exe) => exe.description.as_deref(), Task::Alias(cmd) => cmd.description.as_deref(), } } /// True if this task is a custom task instead of something defined in a /// project. pub fn is_custom(&self) -> bool { matches!(self, Task::Custom(_)) } /// True if this task is meant to run in a clean environment, stripped of /// all non required variables. pub fn clean_env(&self) -> bool { match self { Task::Plain(_) => false, Task::Custom(_) => false, Task::Execute(execute) => execute.clean_env, Task::Alias(_) => false, } } /// Returns the inputs of the task. pub fn inputs(&self) -> Option<&GlobPatterns> { match self { Task::Execute(exe) => exe.inputs.as_ref(), _ => None, } } /// Returns the outputs of the task. pub fn outputs(&self) -> Option<&GlobPatterns> { match self { Task::Execute(exe) => exe.outputs.as_ref(), _ => None, } } // Returns the default environment of the task pub fn default_environment(&self) -> Option<&EnvironmentName> { match self { Task::Execute(exe) => exe.default_environment.as_ref(), _ => None, } } /// Returns the environments the task refers to, through its /// `default-environment` and the `environment` of its dependencies. pub fn referenced_environments(&self) -> impl Iterator { self.default_environment().into_iter().chain( self.depends_on() .iter() .filter_map(|d| d.environment.as_ref()), ) } /// Returns the arguments of the task. pub fn args(&self) -> Option<&[TaskArg]> { match self { Task::Execute(exe) => exe.args.as_deref(), Task::Alias(alias) => alias.args.as_deref(), _ => None, } } } /// A list of glob patterns that can be used as input or output for a task #[derive(Debug, Clone, Eq, PartialEq, Serialize, Hash, Deserialize, Default)] pub struct GlobPatterns(Vec); impl GlobPatterns { pub fn new(patterns: Vec) -> Self { GlobPatterns(patterns) } /// Renders the glob patterns using the provided context pub fn render( &self, context: &TaskRenderContext, ) -> Result, TemplateStringError> { self.0 .iter() .map(|i| i.render(context).map(RenderedString::from)) .collect::, _>>() } } impl Deref for GlobPatterns { type Target = [TemplateString]; fn deref(&self) -> &Self::Target { &self.0 } } /// A command script executes a single command from the environment #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Execute { /// A list of arguments, the first argument denotes the command to run. When /// deserializing both an array of strings and a single string are /// supported. pub cmd: CmdArgs, /// A list of glob patterns that should be watched for changes before this /// command is run pub inputs: Option, /// A list of glob patterns that are generated by this command pub outputs: Option, /// A list of commands that should be run before this one // BREAK: Make the remove the alias and force kebab-case pub depends_on: Vec, /// The working directory for the command relative to the root of the /// project. pub cwd: Option, /// A list of environment variables to set before running the command pub env: Option>, /// A default environment to run the task in. pub default_environment: Option, /// A description of the task pub description: Option, /// Isolate the task from the running machine pub clean_env: bool, /// The arguments to pass to the task pub args: Option>, } impl From for Task { fn from(value: Execute) -> Self { Task::Execute(Box::new(value)) } } #[derive(Debug, Clone, Eq, Hash, PartialEq, Serialize, Deserialize)] pub struct ArgName(String); impl FromStr for ArgName { type Err = String; fn from_str(s: &str) -> Result { if s.contains('-') { Err(format!( "'{s}' is not a valid argument name since it contains the character '-'" )) } else { Ok(ArgName(s.to_string())) } } } impl ArgName { pub fn as_str(&self) -> &str { &self.0 } } #[derive(Debug, Clone, Eq, Hash, PartialEq, Serialize, Deserialize)] pub struct TaskArg { /// The name of the argument pub name: ArgName, /// The default value of the argument pub default: Option, /// The allowed values for the argument pub choices: Option>, } impl TaskArg { /// Returns whether `value` is allowed by this argument's choices (if any). pub fn is_valid_value(&self, value: &str) -> bool { self.choices .as_ref() .is_none_or(|choices| choices.iter().any(|c| c == value)) } } impl std::str::FromStr for TaskArg { type Err = String; fn from_str(s: &str) -> Result { Ok(TaskArg { name: ArgName::from_str(s)?, default: None, choices: None, }) } } /// A custom command script executes a single command in the environment #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Custom { /// A list of arguments, the first argument denotes the command to run. When /// deserializing both an array of strings and a single string are /// supported. pub cmd: CmdArgs, /// The working directory for the command relative to the root of the /// project. pub cwd: Option, /// Whether to render the command through the template engine. /// CLI commands default to false to avoid unexpected template errors. pub templated: bool, } impl From for Task { fn from(value: Custom) -> Self { Task::Custom(value) } } #[derive(Debug, Clone, Serialize, Eq, PartialEq, Hash)] pub struct TypedArg { pub name: String, pub value: String, } impl Display for TypedArg { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { write!(f, "{} = {}", self.name, self.value) } } // Static default environment name for use in Default impl static DEFAULT_ENV: EnvironmentName = EnvironmentName::Default; /// Context for rendering task templates with MiniJinja. /// /// This struct contains system-provided variables that are automatically /// available in task templates, in addition to user-provided arguments. #[derive(Debug, Clone)] pub struct TaskRenderContext<'a> { /// The platform for which the task is being rendered. /// This corresponds to the environment's best platform. `None` when no /// workspace platform supports the current system. pub platform: Option<&'a PixiPlatform>, /// The name of the environment for which the task is being rendered. pub environment_name: &'a EnvironmentName, /// The absolute path to the manifest file. pub manifest_path: Option<&'a Path>, /// The arguments to use for rendering. pub args: Option<&'a ArgValues>, /// The current working directory when pixi was invoked. pub init_cwd: Option<&'a Path>, } impl Default for TaskRenderContext<'_> { fn default() -> Self { Self { platform: None, environment_name: &DEFAULT_ENV, manifest_path: None, args: None, init_cwd: None, } } } impl<'a> TaskRenderContext<'a> { /// Builds a MiniJinja context value from this render context. /// /// The context always includes the pixi system variables. /// User arguments are added when TypedArgs are provided. pub fn to_jinja_context(&self) -> minijinja::Value { // Build the context map with user arguments if available let mut context_map: HashMap = match self.args { Some(ArgValues::TypedArgs { args, .. }) => args .iter() .map(|arg| (arg.name.clone(), minijinja::Value::from(arg.value.as_str()))) .collect(), _ => HashMap::new(), }; // Create the pixi object with system-provided variables let mut pixi_vars: HashMap = HashMap::new(); // Add platform as a string let subdir = self.platform.map(|p| p.subdir()); pixi_vars.insert( "platform".to_string(), minijinja::Value::from( self.platform .map(|p| p.name().to_string()) .unwrap_or_default(), ), ); // Add platform detection boolean flags pixi_vars.insert( "is_win".to_string(), minijinja::Value::from(subdir.is_some_and(|s| s.is_windows())), ); pixi_vars.insert( "is_unix".to_string(), minijinja::Value::from(subdir.is_some_and(|s| s.is_unix())), ); pixi_vars.insert( "is_linux".to_string(), minijinja::Value::from(subdir.is_some_and(|s| s.is_linux())), ); pixi_vars.insert( "is_osx".to_string(), minijinja::Value::from(subdir.is_some_and(|s| s.is_osx())), ); // Add environment object let mut env_vars: HashMap = HashMap::new(); env_vars.insert( "name".to_string(), minijinja::Value::from(self.environment_name.as_str()), ); pixi_vars.insert( "environment".to_string(), minijinja::Value::from_serialize(&env_vars), ); // Add manifest path if available if let Some(path) = self.manifest_path { pixi_vars.insert( "manifest_path".to_string(), minijinja::Value::from(path.display().to_string()), ); } // Add pixi version pixi_vars.insert( "version".to_string(), minijinja::Value::from(pixi_consts::consts::PIXI_VERSION), ); if let Some(cwd) = self.init_cwd { pixi_vars.insert( "init_cwd".to_string(), minijinja::Value::from(cwd.display().to_string()), ); } context_map.insert( "pixi".to_string(), minijinja::Value::from_serialize(&pixi_vars), ); minijinja::Value::from_serialize(&context_map) } } /// A string that contains placeholders to be rendered using the `minijinja` templating engine. #[derive(Debug, Clone, Eq, PartialEq, Serialize, Hash, Deserialize)] pub struct TemplateString(String); impl From<&str> for TemplateString { fn from(value: &str) -> Self { TemplateString(value.to_string()) } } impl From for TemplateString { fn from(value: String) -> Self { TemplateString(value) } } impl TemplateString { pub fn new(value: String) -> Self { TemplateString(value) } pub fn render(&self, context: &TaskRenderContext) -> Result { let jinja_context = context.to_jinja_context(); JINJA_ENV .render_str(&self.0, jinja_context) .map_err(|e| TemplateStringError { src: self.0.clone(), err_span: e.range().unwrap_or_default().into(), }) } pub fn source(&self) -> &str { &self.0 } } /// A rendered string where placeholders were already replaced by arguments #[derive(Debug, Clone, Eq, PartialEq, Serialize, Hash)] pub struct RenderedString(String); impl RenderedString { pub fn inner(&self) -> &str { &self.0 } } impl Deref for RenderedString { type Target = str; fn deref(&self) -> &Self::Target { &self.0 } } impl From<&str> for RenderedString { fn from(value: &str) -> Self { RenderedString(value.to_string()) } } impl From for RenderedString { fn from(value: String) -> Self { RenderedString(value) } } impl RenderedString { /// Creates a new rendered string from the given value. // TODO: In theory, this should check if no placeholders are left in the string pub fn new(value: String) -> Self { RenderedString(value) } } /// Represents the arguments to pass to a task #[derive(Debug, Clone, Serialize, Eq, PartialEq, Hash)] pub enum ArgValues { FreeFormArgs(Vec), TypedArgs { args: Vec, /// Extra passthrough args appended after `--` (empty if none) extra: Vec, }, } impl ArgValues { pub fn is_empty(&self) -> bool { match self { ArgValues::FreeFormArgs(args) => args.is_empty(), ArgValues::TypedArgs { args, extra } => args.is_empty() && extra.is_empty(), } } /// Returns the extra passthrough arguments. /// /// For free-form args, this is the full list of args. /// For typed args, this is only the extra args passed after `--`. pub fn extra_args(&self) -> &[String] { match self { ArgValues::FreeFormArgs(args) => args, ArgValues::TypedArgs { extra, .. } => extra, } } } impl Default for ArgValues { fn default() -> Self { Self::FreeFormArgs(Vec::new()) } } impl Display for ArgValues { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { ArgValues::FreeFormArgs(args) => write!(f, "{}", args.iter().join(", ")), ArgValues::TypedArgs { args, extra } => { write!(f, "{}", args.iter().join(", "))?; if !extra.is_empty() { write!(f, " -- {}", extra.iter().join(", "))?; } Ok(()) } } } } #[derive(Debug, Diagnostic, Error, Clone)] #[error("failed to replace argument placeholders")] pub struct TemplateStringError { #[source_code] src: String, #[label = "this part can't be replaced"] err_span: SourceSpan, } impl TemplateStringError { pub fn get_source(&self) -> &str { &self.src } } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum CmdArgs { Single(TemplateString), Multiple(Vec), } impl From> for CmdArgs { fn from(value: Vec) -> Self { CmdArgs::Multiple(value) } } impl From for CmdArgs { fn from(value: TemplateString) -> Self { CmdArgs::Single(value) } } impl CmdArgs { /// Returns a single string representation of the command arguments. pub fn as_single( &self, context: &TaskRenderContext, ) -> Result>, TemplateStringError> { match self { CmdArgs::Single(cmd) => Ok(Some(Cow::Owned(cmd.render(context)?))), CmdArgs::Multiple(args) => { let mut rendered_args = Vec::new(); for arg in args { let rendered = arg.render(context)?; rendered_args.push(quote(&rendered).to_string()); } Ok(Some(Cow::Owned(rendered_args.join(" ")))) } } } /// Returns a single string representation of the command arguments. pub fn into_single( self, context: &TaskRenderContext, ) -> Result, TemplateStringError> { match self { CmdArgs::Single(cmd) => cmd.render(context).map(Some), CmdArgs::Multiple(args) => { let rendered_args = args .iter() .map(|arg| { Ok(match arg.render(context) { Ok(rendered) => quote(&rendered).to_string(), Err(e) => return Err(e), }) }) .collect::, _>>()?; Ok(Some(rendered_args.join(" "))) } } } pub fn as_single_no_render(&self) -> Result>, TemplateStringError> { match self { CmdArgs::Single(cmd) => Ok(Some(Cow::Owned(cmd.source().to_string()))), CmdArgs::Multiple(args) => Ok(Some(Cow::Owned( args.iter().map(|arg| arg.source().to_string()).join(" "), ))), } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Alias { /// A list of commands that should be run before this one pub depends_on: Vec, /// A description of the task. pub description: Option, /// A list of arguments to pass to the task. pub args: Option>, } impl Display for Task { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { Task::Plain(cmd) => { write!(f, "{}", cmd.source())?; } Task::Execute(execute) => match &execute.cmd { CmdArgs::Single(cmd) => write!(f, "{}", cmd.source())?, CmdArgs::Multiple(mult) => { write!(f, "{}", mult.iter().map(|arg| arg.source()).join(" "))? } }, _ => {} }; let depends_on = self.depends_on(); if !depends_on.is_empty() { if depends_on.len() == 1 { write!(f, ", depends-on = '{}'", depends_on[0])?; } else { write!(f, ", depends-on = [{}]", depends_on.iter().format(","))?; } } let default_environment = self.default_environment(); if let Some(default_environment) = default_environment { write!(f, ", default_environment = {default_environment}")?; } let env = self.env(); if let Some(env) = env && !env.is_empty() { write!(f, ", env = {env:?}")?; } let description = self.description(); if let Some(description) = description { write!(f, ", description = {description:?}")?; } Ok(()) } } /// Quotes a string argument if it requires quotes to be able to be properly /// represented in our shell implementation. pub fn quote(in_str: &str) -> Cow<'_, str> { if in_str.is_empty() { "\"\"".into() } else if in_str.contains(['\t', '\r', '\n', ' ', '[', ']']) { let mut out: String = String::with_capacity(in_str.len() + 2); out.push('"'); for c in in_str.chars() { match c { '"' | '\\' => out.push('\\'), _ => (), } out.push(c); } out.push('"'); out.into() } else { in_str.into() } } impl From for Item { fn from(value: Task) -> Self { match value { Task::Plain(str) => Item::Value(str.source().into()), Task::Execute(process) => { let mut table = Table::new().into_inline_table(); match &process.cmd { CmdArgs::Single(cmd_str) => { table.insert("cmd", cmd_str.source().into()); } CmdArgs::Multiple(cmd_strs) => { table.insert( "cmd", Value::Array(Array::from_iter(cmd_strs.iter().map(|arg| arg.source()))), ); } } if let Some(args) = &process.args { let mut args_array = Array::new(); for arg in args { if arg.default.is_some() || arg.choices.is_some() { let mut arg_table = Table::new().into_inline_table(); arg_table.insert("arg", arg.name.as_str().into()); if let Some(default) = &arg.default { arg_table.insert("default", default.into()); } if let Some(choices) = &arg.choices { arg_table.insert( "choices", Value::Array(Array::from_iter(choices.iter())), ); } args_array.push(Value::InlineTable(arg_table)); } else { args_array.push(Value::String(toml_edit::Formatted::new( arg.name.as_str().to_string(), ))); } } table.insert("args", Value::Array(args_array)); } if let Some(default_environment) = &process.default_environment { table.insert( "default-environment", default_environment.as_str().to_string().into(), ); } if !process.depends_on.is_empty() { table.insert( "depends-on", Value::Array(Array::from_iter(process.depends_on.iter().map(|dep| { match &dep.args { Some(args) if !args.is_empty() => { let mut table = Table::new().into_inline_table(); table.insert("task", dep.task_name.to_string().into()); table.insert( "args", Value::Array(Array::from_iter(args.iter().map(|arg| { match arg { DependencyArg::Positional(val) => { Value::from(val.source().to_string()) } DependencyArg::Named(name, val) => { let mut table = InlineTable::new(); table.insert( name, Value::from(val.source().to_string()), ); Value::InlineTable(table) } } }))), ); Value::InlineTable(table) } _ => Value::from(dep.task_name.to_string()), } }))), ); } if let Some(cwd) = &process.cwd { table.insert("cwd", cwd.to_string_lossy().to_string().into()); } if let Some(env) = &process.env { table.insert( "env", Value::InlineTable(env.iter().map(|(k, v)| (k, v.source())).collect()), ); } if let Some(description) = &process.description { table.insert("description", description.into()); } if process.clean_env { table.insert("clean-env", true.into()); } Item::Value(Value::InlineTable(table)) } Task::Alias(alias) => { if alias.args.is_some() { let mut table = Table::new().into_inline_table(); if let Some(args_vec) = &alias.args { let mut args = Vec::new(); for arg in args_vec { if arg.default.is_some() || arg.choices.is_some() { let mut arg_table = Table::new().into_inline_table(); arg_table.insert("arg", arg.name.as_str().into()); if let Some(default) = &arg.default { arg_table.insert("default", default.into()); } if let Some(choices) = &arg.choices { arg_table.insert( "choices", Value::Array(Array::from_iter(choices.iter())), ); } args.push(Value::InlineTable(arg_table)); } else { args.push(Value::String(toml_edit::Formatted::new( arg.name.as_str().to_string(), ))); } } table.insert("args", Value::Array(Array::from_iter(args))); } let mut deps = Vec::new(); for dep in alias.depends_on.iter() { let mut dep_table = Table::new().into_inline_table(); dep_table.insert("task", dep.task_name.to_string().into()); if let Some(args) = &dep.args { dep_table.insert( "args", Value::Array(Array::from_iter(args.iter().map(|arg| match arg { DependencyArg::Positional(val) => { Value::from(val.source().to_string()) } DependencyArg::Named(name, val) => { let mut table = InlineTable::new(); table.insert(name, Value::from(val.source().to_string())); Value::InlineTable(table) } }))), ); } if let Some(env) = &dep.environment { dep_table.insert("environment", env.to_string().into()); } deps.push(Value::InlineTable(dep_table)); } table.insert("depends-on", Value::Array(Array::from_iter(deps))); if let Some(description) = &alias.description { table.insert("description", description.into()); } Item::Value(Value::InlineTable(table)) } else { let mut array = Array::new(); for dep in alias.depends_on.iter() { let mut table = Table::new().into_inline_table(); table.insert("task", dep.task_name.to_string().into()); if let Some(args) = &dep.args { table.insert( "args", Value::Array(Array::from_iter(args.iter().map(|arg| match arg { DependencyArg::Positional(val) => { Value::from(val.source().to_string()) } DependencyArg::Named(name, val) => { let mut table = InlineTable::new(); table.insert(name, Value::from(val.source().to_string())); Value::InlineTable(table) } }))), ); } if let Some(env) = &dep.environment { table.insert("environment", env.to_string().into()); } array.push(Value::InlineTable(table)); } if let Some(description) = &alias.description { let mut table = Table::new().into_inline_table(); table.insert("depends-on", Value::Array(array)); table.insert("description", description.into()); Item::Value(Value::InlineTable(table)) } else { Item::Value(Value::Array(array)) } } } _ => Item::None, } } } #[cfg(test)] mod tests { use insta::assert_snapshot; use rattler_conda_types::Subdir; use crate::PixiPlatform; use crate::task::{Alias, Dependency, DependencyArg, Task}; use super::quote; use super::{ArgValues, TaskRenderContext, TemplateString, TypedArg}; #[test] fn test_quote() { assert_eq!(quote("foobar"), "foobar"); assert_eq!(quote("foo bar"), "\"foo bar\""); assert_eq!(quote("\""), "\""); assert_eq!(quote("foo \" bar"), "\"foo \\\" bar\""); assert_eq!(quote(""), "\"\""); assert_eq!(quote("$PATH"), "$PATH"); assert_eq!( quote("PATH=\"$PATH;build/Debug\""), "PATH=\"$PATH;build/Debug\"" ); assert_eq!(quote("name=[64,64]"), "\"name=[64,64]\""); } #[test] fn test_table_from_dependency_args() { let positional_arg = DependencyArg::Positional("foo".into()); let named_arg = DependencyArg::Named("bar".into(), "baz".into()); let args = vec![positional_arg, named_arg]; let dep = Dependency::new("depTask", Some(args), None); let alias = Alias { depends_on: vec![dep], description: None, args: None, }; let task = Task::Alias(alias); let toml = toml_edit::Item::from(task); assert_snapshot!(toml.to_string(), @r###"[{ task = "depTask", args = ["foo", { bar = "baz" }] }]"###); } #[test] fn test_template_string_pixi_vars_always_available() { // pixi variables should always be available, even without typed args let t = TemplateString::from("echo {{ pixi.platform }}"); let current_platform = PixiPlatform::from_subdir(Subdir::current().unwrap_or(Subdir::NoArch)); // No args -> pixi.platform still works let context = TaskRenderContext { platform: Some(¤t_platform), ..TaskRenderContext::default() }; let rendered = t .render(&context) .expect("pixi.platform should be available without args"); assert_eq!( rendered, format!("echo {}", Subdir::current().unwrap_or(Subdir::NoArch)) ); // Free-form args -> pixi.platform still works let free_args = ArgValues::FreeFormArgs(vec!["bar".into()]); let context = TaskRenderContext { platform: Some(¤t_platform), args: Some(&free_args), ..TaskRenderContext::default() }; let rendered = t .render(&context) .expect("pixi.platform should be available with free-form args"); assert_eq!( rendered, format!("echo {}", Subdir::current().unwrap_or(Subdir::NoArch)) ); } #[test] fn test_template_string_undefined_var_errors() { // Undefined user variables should cause an error let t = TemplateString::from("echo {{ foo }}"); let context = TaskRenderContext::default(); let result = t.render(&context); assert!(result.is_err(), "undefined variable should cause error"); } #[test] fn test_template_string_pixi_variables() { // Test all pixi system variables use crate::EnvironmentName; use std::path::PathBuf; use std::str::FromStr; let env_name = EnvironmentName::from_str("test-env").unwrap(); let manifest_path = PathBuf::from("/tmp/pixi.toml"); let args = ArgValues::TypedArgs { args: vec![], extra: vec![], }; let linux64_platform = PixiPlatform::from_subdir(Subdir::Linux64); let context = TaskRenderContext { platform: Some(&linux64_platform), environment_name: &env_name, manifest_path: Some(&manifest_path), args: Some(&args), init_cwd: None, }; // Test platform let t = TemplateString::from("{{ pixi.platform }}"); assert_eq!(t.render(&context).unwrap(), "linux-64"); // Test is_linux let t = TemplateString::from("{{ pixi.is_linux }}"); assert_eq!(t.render(&context).unwrap(), "True"); // Test is_win let t = TemplateString::from("{{ pixi.is_win }}"); assert_eq!(t.render(&context).unwrap(), "False"); // Test environment let t = TemplateString::from("{{ pixi.environment.name }}"); assert_eq!(t.render(&context).unwrap(), "test-env"); // Test manifest_path let t = TemplateString::from("{{ pixi.manifest_path }}"); assert_eq!(t.render(&context).unwrap(), "/tmp/pixi.toml"); // Test version let t = TemplateString::from("{{ pixi.version }}"); assert!(t.render(&context).unwrap().contains(".")); } #[test] fn test_template_string_renders_with_typed_args() { let t = TemplateString::from("echo {{ foo }}"); let args = ArgValues::TypedArgs { args: vec![TypedArg { name: "foo".into(), value: "bar".into(), }], extra: vec![], }; let context = TaskRenderContext { args: Some(&args), ..TaskRenderContext::default() }; let rendered = t.render(&context).expect("should render with typed args"); assert_eq!(rendered, "echo bar"); } #[test] fn test_template_string_renders_platform_variable() { let t = TemplateString::from("echo {{ pixi.platform }}"); let args = ArgValues::TypedArgs { args: vec![], extra: vec![], }; let linux64_platform = PixiPlatform::from_subdir(Subdir::Linux64); let context = TaskRenderContext { platform: Some(&linux64_platform), args: Some(&args), ..TaskRenderContext::default() }; let rendered = t.render(&context).expect("should render platform variable"); assert_eq!(rendered, "echo linux-64"); } #[test] fn test_template_string_renders_with_platform_and_args() { let t = TemplateString::from("build-{{ pixi.platform }}-{{ version }}"); let args = ArgValues::TypedArgs { args: vec![TypedArg { name: "version".into(), value: "1.0.0".into(), }], extra: vec![], }; let linux64_platform = PixiPlatform::from_subdir(Subdir::Linux64); let context = TaskRenderContext { platform: Some(&linux64_platform), args: Some(&args), ..TaskRenderContext::default() }; let rendered = t .render(&context) .expect("should render with platform and args"); assert_eq!(rendered, "build-linux-64-1.0.0"); } #[test] fn test_template_string_renders_init_cwd() { let t = TemplateString::from("{{ pixi.init_cwd }}/test"); let cwd = std::env::current_dir().unwrap(); let context = TaskRenderContext { init_cwd: Some(&cwd), ..TaskRenderContext::default() }; let rendered = t.render(&context).expect("should render init_cwd"); assert_eq!(rendered, format!("{}/test", cwd.display())); } } /// Pins how MiniJinja renders booleans and `none` in task templates. /// /// Since MiniJinja 2.22 these render the way Jinja2 does, as `True`, `False` /// and `None`, on every path that turns a value into text. These tests exist /// so that stays uniform: a partial fix that changes one path and not the /// others is worse than either spelling. #[cfg(test)] mod jinja_rendering_tests { use std::str::FromStr; use rattler_conda_types::Subdir; use super::{ArgValues, TaskRenderContext, TemplateString, TypedArg}; use crate::{EnvironmentName, PixiPlatform}; fn render(template: &str) -> String { let env_name = EnvironmentName::from_str("test-env").unwrap(); let platform = PixiPlatform::from_subdir(Subdir::Linux64); let context = TaskRenderContext { platform: Some(&platform), environment_name: &env_name, manifest_path: None, args: None, init_cwd: None, }; TemplateString::from(template) .render(&context) .expect("template should render") } #[test] fn booleans_render_the_same_on_every_path() { assert_eq!(render("{{ pixi.is_linux }}"), "True"); assert_eq!(render("{{ pixi.is_win }}"), "False"); assert_eq!(render("{{ pixi.is_linux | string }}"), "True"); assert_eq!(render(r#"{{ "x=" ~ pixi.is_win }}"#), "x=False"); assert_eq!(render("{{ [pixi.is_linux] }}"), "[True]"); assert_eq!(render("{{ {'w': pixi.is_win} }}"), "{\"w\": False}"); assert_eq!(render("{{ true }}"), "True"); assert_eq!(render("{{ false }}"), "False"); } #[test] fn none_renders_the_same_on_every_path() { assert_eq!(render("{{ none }}"), "None"); assert_eq!(render("{{ none | string }}"), "None"); assert_eq!(render(r#"{{ "x=" ~ none }}"#), "x=None"); } /// The documented way to get the lowercase spelling a shell expects. #[test] fn lower_filter_gives_the_shell_spelling() { assert_eq!(render("{{ pixi.is_linux | lower }}"), "true"); assert_eq!(render("{{ pixi.is_win | lower }}"), "false"); assert_eq!(render(r#"{{ ("x=" ~ pixi.is_win) | lower }}"#), "x=false"); } /// `tojson` is JSON, so it stays lowercase regardless of display spelling. #[test] fn tojson_stays_json() { assert_eq!(render("{{ pixi.is_linux | tojson }}"), "true"); assert_eq!(render("{{ pixi.is_win | tojson }}"), "false"); } /// Branching on a boolean is unaffected, and is the recommended form. #[test] fn if_guards_still_branch_correctly() { assert_eq!( render("{% if pixi.is_linux %}yes{% else %}no{% endif %}"), "yes" ); assert_eq!( render("{% if pixi.is_win %}yes{% else %}no{% endif %}"), "no" ); } /// Task args are always strings, so a `--flag true` arg is unaffected by /// the display spelling. Recorded so a future typed-arg change is caught. #[test] fn typed_args_are_strings_not_booleans() { let env_name = EnvironmentName::from_str("test-env").unwrap(); let platform = PixiPlatform::from_subdir(Subdir::Linux64); let args = ArgValues::TypedArgs { args: vec![TypedArg { name: "flag".into(), value: "true".into(), }], extra: vec![], }; let context = TaskRenderContext { platform: Some(&platform), environment_name: &env_name, manifest_path: None, args: Some(&args), init_cwd: None, }; assert_eq!( TemplateString::from("{{ flag }}|{{ flag | string }}") .render(&context) .unwrap(), "true|true" ); } }