diff --git a/docs/cli.md b/docs/cli.md index 3cf5d99a1..9bbc30e23 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -10,6 +10,7 @@ With `pixi` you can install packages in global space or local to the environment | `install` | Installs all dependencies of the project in its environment | | `run` | Runs the given command in a project's environment | | `shell` | Starts a shell in the project's environment | +| `tasks` | Manage tasks in your `pixi.toml` file | ### Initialize a new project This command is used to create a new project. @@ -59,6 +60,30 @@ pixi run --manifest-path ~/myproject python pixi run build ``` +### Create a task from a command +If you want to make a shorthand for a specific command you can add a task for it +```bash +pixi task add cow cowpy "Hello User" +``` + +This adds the following to the `pixi.toml`: + +```toml +[tasks] +cow = "cowpy \"Hello User\"" +``` +Which you can then run with the `run` command: + +```bash +pixi run cow +``` + +To remove a task you can use the `task remove` command: + +```bash +pixi task remove cow +``` + ### Start a shell in the environment This command starts a new shell in the project's environment. To exit the pixi shell, simply run exit diff --git a/examples/cpp-sdl/pixi.toml b/examples/cpp-sdl/pixi.toml index 35948a32f..8c4cb9ab0 100644 --- a/examples/cpp-sdl/pixi.toml +++ b/examples/cpp-sdl/pixi.toml @@ -6,7 +6,7 @@ authors = ["Bas Zalmstra "] channels = ["conda-forge"] platforms = ["win-64", "linux-64", "osx-64", "osx-arm64"] -[commands] +[tasks] # Configures CMake configure = { cmd = [ "cmake", diff --git a/examples/flask-hello-world/pixi.toml b/examples/flask-hello-world/pixi.toml index 149ea1a3d..f395d6b3a 100644 --- a/examples/flask-hello-world/pixi.toml +++ b/examples/flask-hello-world/pixi.toml @@ -6,7 +6,7 @@ authors = ["Wolf Vollprecht "] channels = ["conda-forge"] platforms = ["linux-64", "win-64", "osx-64", "osx-arm64"] -[commands] +[tasks] start = "python -m flask run --port=5050" [dependencies] diff --git a/examples/opencv/pixi.toml b/examples/opencv/pixi.toml index bead7f6b8..21298dbe4 100644 --- a/examples/opencv/pixi.toml +++ b/examples/opencv/pixi.toml @@ -6,7 +6,7 @@ authors = ["Ruben Arts "] channels = ["conda-forge"] platforms = ["linux-64", "win-64", "osx-64", "osx-arm64"] -[commands] +[tasks] start = "python webcam_capture.py" calibrate = "python calibrate.py" diff --git a/examples/turtlesim/pixi.toml b/examples/turtlesim/pixi.toml index bad79b489..11a83c728 100644 --- a/examples/turtlesim/pixi.toml +++ b/examples/turtlesim/pixi.toml @@ -6,7 +6,7 @@ authors = ["Ruben Arts "] channels = ["conda-forge", "robostack-staging"] platforms = ["linux-64", "win-64", "osx-64", "osx-arm64"] -[commands] +[tasks] start = "ros2 run turtlesim turtlesim_node" teleop = "ros2 run turtlesim turtle_teleop_key" diff --git a/src/cli/init.rs b/src/cli/init.rs index 0b5f58d3b..f8460fad2 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -31,7 +31,7 @@ authors = ["{{ author[0] }} <{{ author[1] }}>"] channels = ["{{ channels|join("\", \"") }}"] platforms = ["{{ platform }}"] -[commands] +[tasks] [dependencies] "#; @@ -141,7 +141,7 @@ mod tests { ); assert_eq!( get_dir(std::env::current_dir().unwrap()).unwrap(), - PathBuf::from(std::env::current_dir().unwrap().canonicalize().unwrap()) + std::env::current_dir().unwrap().canonicalize().unwrap() ); } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 16cf8fcf8..5a0643edb 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -14,6 +14,7 @@ pub mod init; pub mod install; pub mod run; pub mod shell; +pub mod task; #[derive(Parser, Debug)] #[command(version, about, long_about = None)] @@ -49,7 +50,9 @@ pub enum Command { #[clap(alias = "g")] Global(global::Args), Auth(auth::Args), + #[clap(alias = "i")] Install(install::Args), + Task(task::Args), } fn completion(args: CompletionCommand) -> Result<(), Error> { @@ -104,5 +107,6 @@ pub async fn execute_command(command: Command) -> Result<(), Error> { Command::Auth(cmd) => auth::execute(cmd).await, Command::Install(cmd) => install::execute(cmd).await, Command::Shell(cmd) => shell::execute(cmd).await, + Command::Task(cmd) => task::execute(cmd), } } diff --git a/src/cli/run.rs b/src/cli/run.rs index d1b9604e2..b96ce920f 100644 --- a/src/cli/run.rs +++ b/src/cli/run.rs @@ -12,17 +12,14 @@ use rattler_conda_types::Platform; use crate::prefix::Prefix; use crate::progress::await_in_progress; use crate::project::environment::get_metadata_env; -use crate::{ - command::{CmdArgs, Command, ProcessCmd}, - environment::get_up_to_date_prefix, - Project, -}; +use crate::task::{CmdArgs, Execute, Task}; +use crate::{environment::get_up_to_date_prefix, Project}; use rattler_shell::{ activation::{ActivationVariables, Activator, PathModificationBehaviour}, shell::ShellEnum, }; -// Run output which includes the information gotten from the deno task shell run. +/// Runs task in project. #[derive(Default)] pub struct RunOutput { pub exit_code: i32, @@ -30,64 +27,64 @@ pub struct RunOutput { pub stderr: String, } -/// Runs command in project. +/// Runs task in project. #[derive(Parser, Debug, Default)] #[clap(trailing_var_arg = true, arg_required_else_help = true)] pub struct Args { - /// The command you want to run in the projects environment. - pub command: Vec, + /// The task you want to run in the projects environment. + pub task: Vec, /// The path to 'pixi.toml' #[arg(long)] pub manifest_path: Option, } -pub fn order_commands( - commands: Vec, +pub fn order_tasks( + tasks: Vec, project: &Project, -) -> anyhow::Result)>> { - let command: Vec<_> = commands.iter().map(|c| c.to_string()).collect(); +) -> anyhow::Result)>> { + let tasks: Vec<_> = tasks.iter().map(|c| c.to_string()).collect(); // Find the command in the project. - let (command_name, command, additional_args) = command + let (task_name, task, additional_args) = tasks .first() .and_then(|cmd_name| { - project.command_opt(cmd_name).map(|cmd| { + project.task_opt(cmd_name).map(|cmd| { ( Some(cmd_name.clone()), cmd.clone(), - command[1..].iter().cloned().collect_vec(), + tasks[1..].iter().cloned().collect_vec(), ) }) }) .unwrap_or_else(|| { ( None, - Command::Process(ProcessCmd { - cmd: CmdArgs::Multiple(commands), + Task::Execute(Execute { + cmd: CmdArgs::Multiple(tasks), depends_on: vec![], }), Vec::new(), ) }); - // Perform post order traversal of the commands and their `depends_on` to make sure they are + // Perform post order traversal of the tasks and their `depends_on` to make sure they are // executed in the right order. let mut s1 = VecDeque::new(); let mut s2 = VecDeque::new(); let mut added = HashSet::new(); // Add the command specified on the command line first - s1.push_back((command, additional_args)); - if let Some(command_name) = command_name { - added.insert(command_name); + s1.push_back((task, additional_args)); + if let Some(task_name) = task_name { + added.insert(task_name); } - while let Some((command, additional_args)) = s1.pop_back() { + while let Some((task, additional_args)) = s1.pop_back() { // Get the dependencies of the command - let depends_on = match &command { - Command::Process(process) => process.depends_on.as_slice(), - Command::Alias(alias) => &alias.depends_on, + let depends_on = match &task { + Task::Execute(process) => process.depends_on.as_slice(), + Task::Alias(alias) => &alias.depends_on, _ => &[], }; @@ -95,7 +92,7 @@ pub fn order_commands( for dependency in depends_on.iter() { if !added.contains(dependency) { let cmd = project - .command_opt(dependency) + .task_opt(dependency) .ok_or_else(|| anyhow::anyhow!("failed to find dependency {}", dependency))? .clone(); @@ -104,21 +101,21 @@ pub fn order_commands( } } - s2.push_back((command, additional_args)) + s2.push_back((task, additional_args)) } Ok(s2) } -pub async fn create_script(command: Command, args: Vec) -> anyhow::Result { - // Construct the script from the command - let command = match command { - Command::Process(ProcessCmd { +pub async fn create_script(task: Task, args: Vec) -> anyhow::Result { + // Construct the script from the task + let task = match task { + Task::Execute(Execute { cmd: CmdArgs::Single(cmd), .. }) - | Command::Plain(cmd) => cmd, - Command::Process(ProcessCmd { + | Task::Plain(cmd) => cmd, + Task::Execute(Execute { cmd: CmdArgs::Multiple(args), .. }) => quote_arguments(args), @@ -129,11 +126,12 @@ pub async fn create_script(command: Command, args: Vec) -> anyhow::Resul // Append the command line arguments let cli_args = quote_arguments(args); - let full_script = format!("{command} {cli_args}"); + let full_script = format!("{task} {cli_args}"); // Parse the shell command deno_task_shell::parser::parse(full_script.trim()) } + /// Executes the given command withing the specified project and with the given environment. pub async fn execute_script( script: SequentialList, @@ -185,10 +183,10 @@ pub async fn execute(args: Args) -> anyhow::Result<()> { let project = Project::load_or_else_discover(args.manifest_path.as_deref())?; // Get the correctly ordered commands - let mut ordered_commands = order_commands(args.command, &project)?; + let mut ordered_commands = order_tasks(args.task, &project)?; // Get the environment to run the commands in. - let command_env = get_command_env(&project).await?; + let command_env = get_task_env(&project).await?; // Execute the commands in the correct order while let Some((command, args)) = ordered_commands.pop_back() { @@ -206,7 +204,7 @@ pub async fn execute(args: Args) -> anyhow::Result<()> { /// activation scripts from the environment and stores the environment variables it added, it adds /// environment variables set by the project and merges all of that with the system environment /// variables. -pub async fn get_command_env(project: &Project) -> anyhow::Result> { +pub async fn get_task_env(project: &Project) -> anyhow::Result> { // Get the prefix which we can then activate. let prefix = get_up_to_date_prefix(project).await?; diff --git a/src/cli/task.rs b/src/cli/task.rs new file mode 100644 index 000000000..352c7b583 --- /dev/null +++ b/src/cli/task.rs @@ -0,0 +1,166 @@ +use crate::task::{Alias, CmdArgs, Execute, Task}; +use crate::Project; +use clap::Parser; +use itertools::Itertools; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +pub enum Operation { + /// Add a command to the project + #[clap(alias = "a")] + Add(AddArgs), + + /// Remove a command from the project + #[clap(alias = "r")] + Remove(RemoveArgs), + + /// Alias another specific command + #[clap(alias = "@")] + Alias(AliasArgs), +} + +#[derive(Parser, Debug)] +#[clap(arg_required_else_help = true)] +pub struct RemoveArgs { + /// Task names to remove + pub names: Vec, +} + +#[derive(Parser, Debug)] +#[clap(arg_required_else_help = true)] +pub struct AddArgs { + /// Task name + pub name: String, + + /// One or more commands to actually execute + #[clap(required = true, num_args = 1..)] + pub commands: Vec, + + /// Depends on these other commands + #[clap(long)] + #[clap(num_args = 1..)] + pub depends_on: Option>, +} + +#[derive(Parser, Debug)] +#[clap(arg_required_else_help = true)] +pub struct AliasArgs { + /// Alias name + pub alias: String, + + /// Depends on these tasks to execute + #[clap(required = true, num_args = 1..)] + pub depends_on: Vec, +} + +impl From for Task { + fn from(value: AddArgs) -> Self { + let depends_on = value.depends_on.unwrap_or_default(); + + if depends_on.is_empty() { + Self::Plain(if value.commands.len() == 1 { + value.commands[0].clone() + } else { + shlex::join(value.commands.iter().map(AsRef::as_ref)) + }) + } else { + Self::Execute(Execute { + cmd: CmdArgs::Single(if value.commands.len() == 1 { + value.commands[0].clone() + } else { + shlex::join(value.commands.iter().map(AsRef::as_ref)) + }), + depends_on, + }) + } + } +} + +impl From for Task { + fn from(value: AliasArgs) -> Self { + Self::Alias(Alias { + depends_on: value.depends_on, + }) + } +} + +/// Command management in project +#[derive(Parser, Debug)] +#[clap(trailing_var_arg = true, arg_required_else_help = true)] +pub struct Args { + /// Add, remove, or update a task + #[clap(subcommand)] + pub operation: Operation, + + /// The path to 'pixi.toml' + #[arg(long)] + pub manifest_path: Option, +} + +pub fn execute(args: Args) -> anyhow::Result<()> { + let mut project = Project::load_or_else_discover(args.manifest_path.as_deref())?; + match args.operation { + Operation::Add(args) => { + let name = args.name.clone(); + let task: Task = args.into(); + project.add_task(&name, task.clone())?; + eprintln!( + "{}Added task {}: {}", + console::style(console::Emoji("✔ ", "+")).green(), + console::style(&name).bold(), + task, + ); + } + Operation::Remove(args) => { + let mut to_remove = Vec::new(); + for name in args.names.iter() { + if project.task_opt(name).is_none() { + eprintln!( + "{}Task {} does not exist", + console::style(console::Emoji("❌ ", "X")).red(), + console::style(&name).bold(), + ); + continue; + } + // Check if task has dependencies + let depends_on = project.task_depends_on(name); + if !depends_on.is_empty() && !args.names.contains(name) { + eprintln!( + "{}: {}", + console::style("Warning, the following task/s depend on this task") + .yellow(), + console::style(depends_on.iter().to_owned().join(", ")).bold() + ); + eprintln!( + "{}", + console::style("Be sure to modify these after the removal\n").yellow() + ); + } + // Safe to remove + to_remove.push(name); + } + + for name in to_remove { + project.remove_task(name)?; + eprintln!( + "{}Removed task {} ", + console::style(console::Emoji("❌ ", "X")).yellow(), + console::style(&name).bold(), + ); + } + } + Operation::Alias(args) => { + let name = args.alias.clone(); + let task: Task = args.into(); + project.add_task(&name, task.clone())?; + eprintln!( + "{} Added alias {}: {}", + console::style("@").blue(), + console::style(&name).bold(), + task, + ); + } + }; + + Ok(()) +} diff --git a/src/command/mod.rs b/src/command/mod.rs deleted file mode 100644 index d75b9aab1..000000000 --- a/src/command/mod.rs +++ /dev/null @@ -1,41 +0,0 @@ -use serde::Deserialize; -use serde_with::{formats::PreferMany, serde_as, OneOrMany}; - -/// Represents different types of scripts -#[derive(Debug, Clone, Deserialize)] -#[serde(untagged)] -pub enum Command { - Plain(String), - Process(ProcessCmd), - Alias(AliasCmd), -} - -/// A command script executes a single command from the environment -#[serde_as] -#[derive(Debug, Clone, Deserialize)] -pub struct ProcessCmd { - // 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 commands that should be run before this one - #[serde(default)] - #[serde_as(deserialize_as = "OneOrMany<_, PreferMany>")] - pub depends_on: Vec, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(untagged)] -pub enum CmdArgs { - Single(String), - Multiple(Vec), -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -#[serde_as] -pub struct AliasCmd { - /// A list of commands that should be run before this one - #[serde_as(deserialize_as = "OneOrMany<_, PreferMany>")] - pub depends_on: Vec, -} diff --git a/src/lib.rs b/src/lib.rs index 60696b1a6..acceaf6da 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,4 @@ pub mod cli; -pub mod command; pub mod config; pub mod consts; pub mod environment; @@ -8,6 +7,7 @@ pub mod progress; pub mod project; pub mod repodata; pub mod report_error; +pub mod task; pub mod util; pub mod virtual_packages; diff --git a/src/project/manifest.rs b/src/project/manifest.rs index c59a0b987..ddd723cb9 100644 --- a/src/project/manifest.rs +++ b/src/project/manifest.rs @@ -1,6 +1,6 @@ -use crate::command::Command; use crate::consts::PROJECT_MANIFEST; use crate::report_error::ReportError; +use crate::task::Task; use ::serde::Deserialize; use ariadne::{ColorGenerator, Fmt, Label, Report, ReportKind, Source}; use indexmap::IndexMap; @@ -20,9 +20,9 @@ pub struct ProjectManifest { /// Information about the project pub project: ProjectMetadata, - /// Commands defined in the project + /// Tasks defined in the project #[serde(default)] - pub commands: HashMap, + pub tasks: HashMap, /// Additional system requirements #[serde(default, rename = "system-requirements")] diff --git a/src/project/mod.rs b/src/project/mod.rs index bc725c4ff..7a624db38 100644 --- a/src/project/mod.rs +++ b/src/project/mod.rs @@ -6,6 +6,7 @@ use crate::consts; use crate::consts::PROJECT_MANIFEST; use crate::project::manifest::{ProjectManifest, TargetMetadata, TargetSelector}; use crate::report_error::ReportError; +use crate::task::{CmdArgs, Task}; use anyhow::Context; use ariadne::{Label, Report, ReportKind, Source}; use indexmap::IndexMap; @@ -17,6 +18,7 @@ use std::{ env, fs, path::{Path, PathBuf}, }; + use toml_edit::{Array, Document, Item, Table, TomlError, Value}; /// A project represented by a pixi.toml file. @@ -28,6 +30,39 @@ pub struct Project { pub manifest: ProjectManifest, } +/// Returns a task a a toml item +fn task_as_toml(task: Task) -> Item { + match task { + Task::Plain(str) => Item::Value(str.into()), + Task::Execute(process) => { + let mut table = Table::new().into_inline_table(); + match process.cmd { + CmdArgs::Single(cmd_str) => { + table.insert("cmd", cmd_str.into()); + } + CmdArgs::Multiple(cmd_strs) => { + table.insert("cmd", Value::Array(Array::from_iter(cmd_strs.into_iter()))); + } + } + if !process.depends_on.is_empty() { + table.insert( + "depends_on", + Value::Array(Array::from_iter(process.depends_on.into_iter())), + ); + } + Item::Value(Value::InlineTable(table)) + } + Task::Alias(alias) => { + let mut table = Table::new().into_inline_table(); + table.insert( + "depends_on", + Value::Array(Array::from_iter(alias.depends_on.into_iter())), + ); + Item::Value(Value::InlineTable(table)) + } + } +} + impl Project { /// Discovers the project manifest file in the current directory or any of the parent /// directories. @@ -54,6 +89,83 @@ impl Project { }) } + /// Find task dependencies + pub fn task_depends_on(&self, name: impl AsRef) -> Vec<&String> { + let task = self.manifest.tasks.get(name.as_ref()); + if task.is_some() { + self.manifest + .tasks + .iter() + .filter(|(_, c)| c.depends_on().contains(&name.as_ref().to_string())) + .map(|(name, _)| name) + .collect() + } else { + vec![] + } + } + + /// Add a task to the project + pub fn add_task(&mut self, name: impl AsRef, task: Task) -> anyhow::Result<()> { + if self.manifest.tasks.contains_key(name.as_ref()) { + anyhow::bail!("task {} already exists", name.as_ref()); + }; + + let tasks_table = &mut self.doc["tasks"]; + // If it doesnt exist create a proper table + if tasks_table.is_none() { + *tasks_table = Item::Table(Table::new()); + } + + // Cast the item into a table + let tasks_table = tasks_table.as_table_like_mut().ok_or_else(|| { + anyhow::anyhow!("tasks in {} are malformed", consts::PROJECT_MANIFEST) + })?; + + let depends_on = task.depends_on(); + + for depends in depends_on { + if !self.manifest.tasks.contains_key(depends) { + anyhow::bail!( + "task '{}' for the depends on for '{}' does not exist", + depends, + name.as_ref(), + ); + } + } + + // Add the task to the table + tasks_table.insert(name.as_ref(), task_as_toml(task.clone())); + + self.manifest.tasks.insert(name.as_ref().to_string(), task); + + self.save()?; + + Ok(()) + } + + /// Remove a task from the project, and the tasks that depend on it + pub fn remove_task(&mut self, name: impl AsRef) -> anyhow::Result<()> { + self.manifest + .tasks + .get(name.as_ref()) + .ok_or_else(|| anyhow::anyhow!("task {} does not exist", name.as_ref()))?; + + let tasks_table = &mut self.doc["tasks"]; + if tasks_table.is_none() { + anyhow::bail!("internal data-structure inconsistent with toml"); + } + let tasks_table = tasks_table.as_table_like_mut().ok_or_else(|| { + anyhow::anyhow!("tasks in {} are malformed", consts::PROJECT_MANIFEST) + })?; + + // If it does not exist in toml, consider this ok as we want to remove it anyways + tasks_table.remove(name.as_ref()); + + self.save()?; + + Ok(()) + } + pub fn load_or_else_discover(manifest_path: Option<&Path>) -> anyhow::Result { let project = match manifest_path { Some(path) => Project::load(path)?, @@ -385,9 +497,9 @@ impl Project { self.manifest.project.platforms.as_ref().as_slice() } - /// Get the command with the specified name or `None` if no such command exists. - pub fn command_opt(&self, name: &str) -> Option<&crate::command::Command> { - self.manifest.commands.get(name) + /// Get the task with the specified name or `None` if no such task exists. + pub fn task_opt(&self, name: &str) -> Option<&Task> { + self.manifest.tasks.get(name) } /// Get the system requirements defined under the `system-requirements` section of the project manifest. diff --git a/src/project/snapshots/pixi__project__manifest__test__dependency_types.snap b/src/project/snapshots/pixi__project__manifest__test__dependency_types.snap index 4ea9f5b51..6a366ef24 100644 --- a/src/project/snapshots/pixi__project__manifest__test__dependency_types.snap +++ b/src/project/snapshots/pixi__project__manifest__test__dependency_types.snap @@ -1,6 +1,5 @@ --- source: src/project/manifest.rs -assertion_line: 337 expression: "toml_edit::de::from_str::(&contents).expect(\"parsing should succeed!\")" --- ProjectManifest { @@ -18,7 +17,7 @@ ProjectManifest { value: [], }, }, - commands: {}, + tasks: {}, system_requirements: SystemRequirements { windows: None, unix: None, diff --git a/src/project/snapshots/pixi__project__manifest__test__target_specific.snap b/src/project/snapshots/pixi__project__manifest__test__target_specific.snap index 539694d0c..4771ff41d 100644 --- a/src/project/snapshots/pixi__project__manifest__test__target_specific.snap +++ b/src/project/snapshots/pixi__project__manifest__test__target_specific.snap @@ -1,6 +1,5 @@ --- source: src/project/manifest.rs -assertion_line: 316 expression: "toml_edit::de::from_str::(&contents).expect(\"parsing should succeed!\")" --- ProjectManifest { @@ -18,7 +17,7 @@ ProjectManifest { value: [], }, }, - commands: {}, + tasks: {}, system_requirements: SystemRequirements { windows: None, unix: None, diff --git a/src/task/mod.rs b/src/task/mod.rs new file mode 100644 index 000000000..85eb9b30f --- /dev/null +++ b/src/task/mod.rs @@ -0,0 +1,84 @@ +use itertools::Itertools; +use serde::Deserialize; +use serde_with::{formats::PreferMany, serde_as, OneOrMany}; +use std::fmt::{Display, Formatter}; + +/// Represents different types of scripts +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum Task { + Plain(String), + Execute(Execute), + Alias(Alias), +} + +impl Task { + pub fn depends_on(&self) -> &[String] { + match self { + Task::Plain(_) => &[], + Task::Execute(cmd) => &cmd.depends_on, + Task::Alias(cmd) => &cmd.depends_on, + } + } +} + +/// A command script executes a single command from the environment +#[serde_as] +#[derive(Debug, Clone, 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 commands that should be run before this one + #[serde(default)] + #[serde_as(deserialize_as = "OneOrMany<_, PreferMany>")] + pub depends_on: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum CmdArgs { + Single(String), + Multiple(Vec), +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +#[serde_as] +pub struct Alias { + /// A list of commands that should be run before this one + #[serde_as(deserialize_as = "OneOrMany<_, PreferMany>")] + pub depends_on: Vec, +} + +impl Display for Task { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + Task::Plain(cmd) => { + write!(f, "{}", cmd)?; + } + Task::Execute(cmd) => { + match &cmd.cmd { + CmdArgs::Single(cmd) => write!(f, "{}", cmd)?, + CmdArgs::Multiple(mult) => write!(f, "{}", mult.join(" "))?, + }; + if !cmd.depends_on.is_empty() { + write!(f, ", ")?; + } + } + _ => {} + }; + + let depends_on = self.depends_on(); + if !depends_on.is_empty() { + if depends_on.len() == 1 { + write!(f, "depends_on = '{}'", depends_on.iter().join(",")) + } else { + write!(f, "depends_on = [{}]", depends_on.iter().join(",")) + } + } else { + Ok(()) + } + } +} diff --git a/tests/common/builders.rs b/tests/common/builders.rs new file mode 100644 index 000000000..ac03ad642 --- /dev/null +++ b/tests/common/builders.rs @@ -0,0 +1,152 @@ +//! Contains builders for the CLI commands +//! We are using a builder pattern here to make it easier to write tests. +//! And are kinda abusing the `IntoFuture` trait to make it easier to execute as close +//! as we can get to the command line args +//! +//! # Using IntoFuture +//! +//! When `.await` is called on an object that is not a `Future` the compiler will first check if the +//! type implements `IntoFuture`. If it does it will call the `IntoFuture::into_future()` method and +//! await the resulting `Future`. We can abuse this behavior in builder patterns because the +//! `into_future` method can also be used as a `finish` function. This allows you to reduce the +//! required code. +//! +//! ```rust +//! impl IntoFuture for InitBuilder { +//! type Output = anyhow::Result<()>; +//! type IntoFuture = Pin + Send + 'static>>; +//! +//! fn into_future(self) -> Self::IntoFuture { +//! Box::pin(init::execute(self.args)) +//! } +//! } +//! +//! ``` + +use crate::common::IntoMatchSpec; +use pixi::cli::add::SpecType; +use pixi::cli::{add, init, task}; +use std::future::{Future, IntoFuture}; +use std::path::{Path, PathBuf}; +use std::pin::Pin; +use url::Url; + +/// Strings from an iterator +pub fn string_from_iter(iter: impl IntoIterator>) -> Vec { + iter.into_iter().map(|s| s.as_ref().to_string()).collect() +} + +/// Contains the arguments to pass to `init::execute()`. Call `.await` to call the CLI execute +/// method and await the result at the same time. +pub struct InitBuilder { + pub args: init::Args, +} + +impl InitBuilder { + pub fn with_channel(mut self, channel: impl ToString) -> Self { + self.args.channels.push(channel.to_string()); + self + } + + pub fn with_local_channel(self, channel: impl AsRef) -> Self { + self.with_channel(Url::from_directory_path(channel).unwrap()) + } +} + +impl IntoFuture for InitBuilder { + type Output = anyhow::Result<()>; + type IntoFuture = Pin + Send + 'static>>; + + fn into_future(self) -> Self::IntoFuture { + Box::pin(init::execute(self.args)) + } +} + +/// Contains the arguments to pass to `add::execute()`. Call `.await` to call the CLI execute method +/// and await the result at the same time. +pub struct AddBuilder { + pub args: add::Args, +} + +impl AddBuilder { + pub fn with_spec(mut self, spec: impl IntoMatchSpec) -> Self { + self.args.specs.push(spec.into()); + self + } + + /// Set as a host + pub fn set_type(mut self, t: SpecType) -> Self { + match t { + SpecType::Host => { + self.args.host = true; + self.args.build = false; + } + SpecType::Build => { + self.args.host = false; + self.args.build = true; + } + SpecType::Run => { + self.args.host = false; + self.args.build = false; + } + } + self + } +} + +impl IntoFuture for AddBuilder { + type Output = anyhow::Result<()>; + type IntoFuture = Pin + Send + 'static>>; + + fn into_future(self) -> Self::IntoFuture { + Box::pin(add::execute(self.args)) + } +} + +pub struct TaskAddBuilder { + pub manifest_path: Option, + pub args: task::AddArgs, +} + +impl TaskAddBuilder { + /// Execute these commands + pub fn with_commands(mut self, commands: impl IntoIterator>) -> Self { + self.args.commands = string_from_iter(commands); + self + } + + /// Depends on these commands + pub fn with_depends_on(mut self, depends: impl IntoIterator>) -> Self { + self.args.depends_on = Some(string_from_iter(depends)); + self + } + + /// Execute the CLI command + pub fn execute(self) -> anyhow::Result<()> { + task::execute(task::Args { + operation: task::Operation::Add(self.args), + manifest_path: self.manifest_path, + }) + } +} + +pub struct TaskAliasBuilder { + pub manifest_path: Option, + pub args: task::AliasArgs, +} + +impl TaskAliasBuilder { + /// Depends on these commands + pub fn with_depends_on(mut self, depends: impl IntoIterator>) -> Self { + self.args.depends_on = string_from_iter(depends); + self + } + + /// Execute the CLI command + pub fn execute(self) -> anyhow::Result<()> { + task::execute(task::Args { + operation: task::Operation::Alias(self.args), + manifest_path: self.manifest_path, + }) + } +} diff --git a/tests/common/mod.rs b/tests/common/mod.rs index e25890541..197421143 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -1,22 +1,23 @@ #![allow(dead_code)] +pub mod builders; pub mod package_database; -use pixi::cli::add::SpecType; +use crate::common::builders::{AddBuilder, InitBuilder, TaskAddBuilder, TaskAliasBuilder}; use pixi::cli::install::Args; use pixi::cli::run::{ - create_script, execute_script_with_output, get_command_env, order_commands, RunOutput, + create_script, execute_script_with_output, get_task_env, order_tasks, RunOutput, }; -use pixi::cli::{add, init, run}; +use pixi::cli::task::{AddArgs, AliasArgs}; +use pixi::cli::{add, init, run, task}; use pixi::{consts, Project}; use rattler_conda_types::conda_lock::CondaLock; use rattler_conda_types::{MatchSpec, Version}; -use std::future::{Future, IntoFuture}; + use std::path::{Path, PathBuf}; -use std::pin::Pin; +use std::process::Output; use std::str::FromStr; use tempfile::TempDir; -use url::Url; /// To control the pixi process pub struct PixiControl { @@ -24,6 +25,22 @@ pub struct PixiControl { tmpdir: TempDir, } +pub struct RunResult { + output: Output, +} + +impl RunResult { + /// Was the output successful + pub fn success(&self) -> bool { + self.output.status.success() + } + + /// Get the output + pub fn stdout(&self) -> &str { + std::str::from_utf8(&self.output.stdout).expect("could not get output") + } +} + /// MatchSpecs from an iterator pub fn string_from_iter(iter: impl IntoIterator>) -> Vec { iter.into_iter().map(|s| s.as_ref().to_string()).collect() @@ -108,16 +125,16 @@ impl PixiControl { /// Run a command pub async fn run(&self, mut args: run::Args) -> anyhow::Result { args.manifest_path = args.manifest_path.or_else(|| Some(self.manifest_path())); - let mut commands = order_commands(args.command, &self.project().unwrap())?; + let mut tasks = order_tasks(args.task, &self.project().unwrap())?; let project = self.project().unwrap(); - let command_env = get_command_env(&project).await.unwrap(); + let task_env = get_task_env(&project).await.unwrap(); - while let Some((command, args)) = commands.pop_back() { + while let Some((command, args)) = tasks.pop_back() { let script = create_script(command, args).await; if let Ok(script) = script { - let output = execute_script_with_output(script, &project, &command_env, None).await; - if commands.is_empty() { + let output = execute_script_with_output(script, &project, &task_env, None).await; + if tasks.is_empty() { return Ok(output); } } @@ -137,82 +154,49 @@ impl PixiControl { pub async fn lock_file(&self) -> anyhow::Result { pixi::environment::load_lock_for_manifest_path(&self.manifest_path()).await } -} - -/// Contains the arguments to pass to `init::execute()`. Call `.await` to call the CLI execute -/// method and await the result at the same time. -pub struct InitBuilder { - args: init::Args, -} - -impl InitBuilder { - pub fn with_channel(mut self, channel: impl ToString) -> Self { - self.args.channels.push(channel.to_string()); - self - } - - pub fn with_local_channel(self, channel: impl AsRef) -> Self { - self.with_channel(Url::from_directory_path(channel).unwrap()) - } -} -// When `.await` is called on an object that is not a `Future` the compiler will first check if the -// type implements `IntoFuture`. If it does it will call the `IntoFuture::into_future()` method and -// await the resulting `Future`. We can abuse this behavior in builder patterns because the -// `into_future` method can also be used as a `finish` function. This allows you to reduce the -// required code. -impl IntoFuture for InitBuilder { - type Output = anyhow::Result<()>; - type IntoFuture = Pin + Send + 'static>>; - - fn into_future(self) -> Self::IntoFuture { - Box::pin(init::execute(self.args)) + pub fn tasks(&self) -> TasksControl { + TasksControl { pixi: self } } } -/// Contains the arguments to pass to `add::execute()`. Call `.await` to call the CLI execute method -/// and await the result at the same time. -pub struct AddBuilder { - args: add::Args, +pub struct TasksControl<'a> { + /// Reference to the pixi control + pixi: &'a PixiControl, } -impl AddBuilder { - pub fn with_spec(mut self, spec: impl IntoMatchSpec) -> Self { - self.args.specs.push(spec.into()); - self +impl TasksControl<'_> { + /// Add a task + pub fn add(&self, name: impl ToString) -> TaskAddBuilder { + TaskAddBuilder { + manifest_path: Some(self.pixi.manifest_path()), + args: AddArgs { + name: name.to_string(), + commands: vec![], + depends_on: None, + }, + } } - /// Set as a host - pub fn set_type(mut self, t: SpecType) -> Self { - match t { - SpecType::Host => { - self.args.host = true; - self.args.build = false; - } - SpecType::Build => { - self.args.host = false; - self.args.build = true; - } - SpecType::Run => { - self.args.host = false; - self.args.build = false; - } - } - self + /// Remove a task + pub async fn remove(&self, name: impl ToString) -> anyhow::Result<()> { + task::execute(task::Args { + manifest_path: Some(self.pixi.manifest_path()), + operation: task::Operation::Remove(task::RemoveArgs { + names: vec![name.to_string()], + }), + }) } -} -// When `.await` is called on an object that is not a `Future` the compiler will first check if the -// type implements `IntoFuture`. If it does it will call the `IntoFuture::into_future()` method and -// await the resulting `Future`. We can abuse this behavior in builder patterns because the -// `into_future` method can also be used as a `finish` function. This allows you to reduce the -// required code. -impl IntoFuture for AddBuilder { - type Output = anyhow::Result<()>; - type IntoFuture = Pin + Send + 'static>>; - - fn into_future(self) -> Self::IntoFuture { - Box::pin(add::execute(self.args)) + /// Alias one or multiple tasks + pub fn alias(&self, name: impl ToString) -> TaskAliasBuilder { + TaskAliasBuilder { + manifest_path: Some(self.pixi.manifest_path()), + args: AliasArgs { + alias: name.to_string(), + depends_on: vec![], + }, + } } } diff --git a/tests/install_tests.rs b/tests/install_tests.rs index be67ede36..46ced883d 100644 --- a/tests/install_tests.rs +++ b/tests/install_tests.rs @@ -1,7 +1,7 @@ mod common; +use crate::common::builders::string_from_iter; use crate::common::package_database::{Package, PackageDatabase}; -use crate::common::string_from_iter; use common::{LockFileExt, PixiControl}; use pixi::cli::run; use tempfile::TempDir; @@ -22,7 +22,7 @@ async fn install_run_python() { // Check if python is installed and can be run let result = pixi .run(run::Args { - command: string_from_iter(["python", "--version"]), + task: string_from_iter(["python", "--version"]), ..Default::default() }) .await diff --git a/tests/task_tests.rs b/tests/task_tests.rs new file mode 100644 index 000000000..d73058324 --- /dev/null +++ b/tests/task_tests.rs @@ -0,0 +1,59 @@ +use crate::common::PixiControl; +use pixi::task::{CmdArgs, Task}; + +mod common; + +#[tokio::test] +pub async fn add_remove_task() { + let pixi = PixiControl::new().unwrap(); + pixi.init().await.unwrap(); + + // Simple task + pixi.tasks() + .add("test") + .with_commands(["echo hello"]) + .execute() + .unwrap(); + + let project = pixi.project().unwrap(); + let task = project.manifest.tasks.get("test").unwrap(); + assert!(matches!(task, Task::Plain(s) if s == "echo hello")); + + // Remove the task + pixi.tasks().remove("test").await.unwrap(); + assert_eq!(pixi.project().unwrap().manifest.tasks.len(), 0); +} + +#[tokio::test] +pub async fn add_command_types() { + let pixi = PixiControl::new().unwrap(); + pixi.init().await.unwrap(); + + // Add a command with dependencies + pixi.tasks() + .add("test") + .with_commands(["echo hello"]) + .execute() + .unwrap(); + pixi.tasks() + .add("test2") + .with_commands(["echo hello", "echo bonjour"]) + .with_depends_on(["test"]) + .execute() + .unwrap(); + + let project = pixi.project().unwrap(); + let task = project.manifest.tasks.get("test2").unwrap(); + assert!(matches!(task, Task::Execute(cmd) if matches!(cmd.cmd, CmdArgs::Single(_)))); + assert!(matches!(task, Task::Execute(cmd) if !cmd.depends_on.is_empty())); + + // Create an alias + pixi.tasks() + .alias("testing") + .with_depends_on(["test"]) + .execute() + .unwrap(); + let project = pixi.project().unwrap(); + let task = project.manifest.tasks.get("testing").unwrap(); + assert!(matches!(task, Task::Alias(a) if a.depends_on.get(0).unwrap() == "test")); +}