Skip to content

feat: enhance pixi task add CLI command to support issue #3828 #3884

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions crates/pixi_manifest/src/task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,64 @@ impl From<&str> for Dependency {
}
}

impl FromStr for Dependency {
type Err = String;

fn from_str(s: &str) -> Result<Self, Self::Err> {
let parts: Vec<&str> = s.split(':').collect();

match parts.len() {
1 => {
// Simple task name: "task_name"
Ok(Dependency::new(parts[0], None, None))
}
2 => {
// Two parts: could be "task_name:env_name" or "task_name::args"
if parts[1].is_empty() {
// "task_name::" - no environment, but might have args in a third part (malformed)
Err("Invalid dependency format. Use 'task_name' or 'task_name:env_name' or 'task_name::arg1,arg2'".to_string())
} else {
// "task_name:env_name"
let env_name = EnvironmentName::from_str(parts[1]).map_err(|e| e.to_string())?;
Ok(Dependency::new(parts[0], None, Some(env_name)))
}
}
3 => {
// Three parts: "task_name:env_name:args" or "task_name::args"
if parts[1].is_empty() {
// "task_name::args" format
let args = if parts[2].is_empty() {
None
} else {
let arg_strings: Vec<TemplateString> = parts[2]
.split(',')
.map(|arg| arg.trim().into())
.collect();
Some(arg_strings)
};
Ok(Dependency::new(parts[0], args, None))
} else {
// "task_name:env_name:args" format
let env_name = EnvironmentName::from_str(parts[1]).map_err(|e| e.to_string())?;
let args = if parts[2].is_empty() {
None
} else {
let arg_strings: Vec<TemplateString> = parts[2]
.split(',')
.map(|arg| arg.trim().into())
.collect();
Some(arg_strings)
};
Ok(Dependency::new(parts[0], args, Some(env_name)))
}
}
_ => {
Err("Invalid dependency format. Too many colons. Use 'task_name', 'task_name:env_name', or 'task_name:env_name:arg1,arg2'".to_string())
}
}
}
}

impl std::fmt::Display for Dependency {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.args {
Expand Down
13 changes: 8 additions & 5 deletions docs/reference/cli/pixi/task/add.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,20 @@ Add a command to the workspace

## Usage
```
pixi task add [OPTIONS] <NAME> <COMMAND>...
pixi task add [OPTIONS] <NAME> [COMMAND]...
```

## Arguments
- <a id="arg-<NAME>" href="#arg-<NAME>">`<NAME>`</a>
: Task name
<br>**required**: `true`
- <a id="arg-<COMMAND>" href="#arg-<COMMAND>">`<COMMAND>`</a>
: One or more commands to actually execute
: One or more commands to actually execute. If no commands are provided but dependencies are specified, an alias will be created
<br>May be provided more than once.
<br>**required**: `true`

## Options
- <a id="arg---depends-on" href="#arg---depends-on">`--depends-on <DEPENDS_ON>`</a>
: Depends on these other commands
: Depends on these other commands. Format: 'task_name', 'task_name:env_name', or 'task_name:env_name:arg1,arg2'
<br>May be provided more than once.
- <a id="arg---platform" href="#arg---platform">`--platform (-p) <PLATFORM>`</a>
: The platform for which the task should be added
Expand All @@ -38,7 +37,11 @@ pixi task add [OPTIONS] <NAME> <COMMAND>...
- <a id="arg---clean-env" href="#arg---clean-env">`--clean-env`</a>
: Isolate the task from the shell environment, and only use the pixi environment to run the task
- <a id="arg---arg" href="#arg---arg">`--arg <ARGS>`</a>
: The arguments to pass to the task
: The arguments to pass to the task. Format: 'arg_name' or 'arg_name=default_value'
<br>May be provided more than once.
- <a id="arg---inputs" href="#arg---inputs">`--inputs <INPUTS>`</a>
: Input glob patterns for the task. Format: 'pattern1,pattern2,pattern3'
- <a id="arg---outputs" href="#arg---outputs">`--outputs <OUTPUTS>`</a>
: Output glob patterns for the task. Format: 'pattern1,pattern2,pattern3'

--8<-- "docs/reference/cli/pixi/task/add_extender:example"
138 changes: 113 additions & 25 deletions src/cli/task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ use itertools::Itertools;
use miette::IntoDiagnostic;
use pixi_manifest::{
EnvironmentName, FeatureName,
task::{Alias, CmdArgs, Dependency, Execute, Task, TaskArg, TaskName, quote},
task::{
Alias, CmdArgs, Dependency, Execute, GlobPatterns, Task, TaskArg, TaskName, TemplateString,
quote,
},
};
use rattler_conda_types::Platform;
use serde::Serialize;
Expand Down Expand Up @@ -61,18 +64,53 @@ pub struct RemoveArgs {
pub feature: Option<String>,
}

/// Parse a dependency specification that can include task name, environment, and arguments
/// Format: "task_name" or "task_name:env_name" or "task_name::arg1,arg2" or "task_name:env_name:arg1,arg2"
fn parse_dependency(s: &str) -> Result<Dependency, Box<dyn Error + Send + Sync + 'static>> {
Dependency::from_str(s).map_err(|e| e.into())
}

/// Parse a task argument with optional default value
/// Format: "arg_name" or "arg_name=default_value"
fn parse_task_arg(s: &str) -> Result<TaskArg, Box<dyn Error + Send + Sync + 'static>> {
if let Some((name, default)) = s.split_once('=') {
// "arg_name=default_value"
let arg_name = pixi_manifest::task::ArgName::from_str(name)?;
Ok(TaskArg {
name: arg_name,
default: Some(default.to_string()),
})
} else {
// "arg_name"
let arg_name = pixi_manifest::task::ArgName::from_str(s)?;
Ok(TaskArg {
name: arg_name,
default: None,
})
}
}

/// Parse a glob pattern list
/// Format: "pattern1,pattern2,pattern3"
fn parse_glob_patterns(s: &str) -> Result<GlobPatterns, Box<dyn Error + Send + Sync + 'static>> {
let patterns: Vec<TemplateString> = s.split(',').map(|pattern| pattern.trim().into()).collect();
Ok(GlobPatterns::new(patterns))
}

#[derive(Parser, Debug, Clone)]
#[clap(arg_required_else_help = true)]
pub struct AddArgs {
/// Task name.
pub name: TaskName,

/// One or more commands to actually execute.
#[clap(required = true, num_args = 1.., id = "COMMAND")]
/// If no commands are provided but dependencies are specified, an alias will be created.
#[clap(num_args = 0.., id = "COMMAND")]
pub commands: Vec<String>,

/// Depends on these other commands.
#[clap(long)]
/// Format: 'task_name', 'task_name:env_name', or 'task_name:env_name:arg1,arg2'
#[clap(long, value_parser = parse_dependency)]
#[clap(num_args = 1..)]
pub depends_on: Option<Vec<Dependency>>,

Expand Down Expand Up @@ -102,9 +140,39 @@ pub struct AddArgs {
#[arg(long)]
pub clean_env: bool,

/// The arguments to pass to the task
#[arg(long = "arg", action = clap::ArgAction::Append)]
/// The arguments to pass to the task.
/// Format: 'arg_name' or 'arg_name=default_value'
#[arg(long = "arg", action = clap::ArgAction::Append, value_parser = parse_task_arg)]
pub args: Option<Vec<TaskArg>>,

/// Input glob patterns for the task.
/// Format: 'pattern1,pattern2,pattern3'
#[arg(long, value_parser = parse_glob_patterns)]
pub inputs: Option<GlobPatterns>,

/// Output glob patterns for the task.
/// Format: 'pattern1,pattern2,pattern3'
#[arg(long, value_parser = parse_glob_patterns)]
pub outputs: Option<GlobPatterns>,
}

impl AddArgs {
/// Validates that either commands or dependencies are provided
pub fn validate(&self) -> miette::Result<()> {
let has_commands = !self.commands.is_empty();
let has_dependencies = self
.depends_on
.as_ref()
.is_some_and(|deps| !deps.is_empty());

if !has_commands && !has_dependencies {
return Err(miette::miette!(
"Either commands or dependencies (--depends-on) must be provided"
));
}

Ok(())
}
}

/// Parse a single key-value pair
Expand Down Expand Up @@ -162,40 +230,57 @@ pub struct ListArgs {
impl From<AddArgs> for Task {
fn from(value: AddArgs) -> Self {
let depends_on = value.depends_on.unwrap_or_default();
// description or none
let description = value.description;

// Check if we have any commands
let has_commands = !value.commands.is_empty();
let has_dependencies = !depends_on.is_empty();

// Convert the arguments into a single string representation
let cmd_args = value
.commands
.iter()
.exactly_one()
.map(|c| c.to_string())
.unwrap_or_else(|_| {
// Simply concatenate all arguments
value
.commands
.iter()
.map(|arg| quote(arg).into_owned())
.join(" ")
});
let cmd_args = if has_commands {
value
.commands
.iter()
.exactly_one()
.map(|c| c.to_string())
.unwrap_or_else(|_| {
// Simply concatenate all arguments
value
.commands
.iter()
.map(|arg| quote(arg).into_owned())
.join(" ")
})
} else {
String::new()
};

// Determine the type of task to create:
// 1. If no commands but has dependencies -> Alias
// 2. If has commands but no other complex features -> Plain
// 3. Otherwise -> Execute

// Depending on whether the task has a command, and depends_on or not we create
// a plain or complex, or alias command.
if cmd_args.trim().is_empty() && !depends_on.is_empty() {
if !has_commands && has_dependencies {
// Create an alias task
Self::Alias(Alias {
depends_on,
description,
args: value.args,
})
} else if depends_on.is_empty()
} else if has_commands
&& depends_on.is_empty()
&& value.cwd.is_none()
&& value.env.is_empty()
&& description.is_none()
&& value.args.is_none()
&& value.inputs.is_none()
&& value.outputs.is_none()
&& !value.clean_env
{
// Create a plain task (simple command with no extra features)
Self::Plain(cmd_args.into())
} else {
// Create an execute task (command with extra features)
let clean_env = value.clean_env;
let cwd = value.cwd;
let env = if value.env.is_empty() {
Expand All @@ -212,8 +297,8 @@ impl From<AddArgs> for Task {
Self::Execute(Box::new(Execute {
cmd: CmdArgs::Single(cmd_args.into()),
depends_on,
inputs: None,
outputs: None,
inputs: value.inputs,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inputs and outputs are not serialized when adding e.g.:

pixi task add test10 --inputs "*.rs" --outputs "target" echo hello

results in:

test10 = { cmd = "echo hello" }

outputs: value.outputs,
cwd,
env,
description,
Expand Down Expand Up @@ -474,6 +559,9 @@ async fn remove_tasks(mut workspace: WorkspaceMut, args: RemoveArgs) -> miette::
}

async fn add_task(mut workspace: WorkspaceMut, args: AddArgs) -> miette::Result<()> {
// Validate the arguments
args.validate()?;

let name = &args.name;
let task: Task = args.clone().into();
let feature = args
Expand Down
2 changes: 2 additions & 0 deletions tests/integration_rust/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,8 @@ impl TasksControl<'_> {
description: None,
clean_env: false,
args: None,
inputs: None,
outputs: None,
},
}
}
Expand Down
Loading