Skip to content
Merged
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
113 changes: 113 additions & 0 deletions e2e/tasks/test_task_monorepo_dots_in_dir
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# Test monorepo tasks with dots in directory names
export MISE_EXPERIMENTAL=1

# Create monorepo root config
cat <<'TOML' >mise.toml
experimental_monorepo_root = true

[tasks.root-task]
run = 'echo "root task executed"'
TOML

# Create projects with dots in directory names
mkdir -p "projects/my.app"
cat <<'TOML' >"projects/my.app/mise.toml"
[tasks.build]
run = 'echo "building my.app"'

[tasks.test]
run = 'echo "testing my.app"'
TOML

mkdir -p "projects/my.service.api"
cat <<'TOML' >"projects/my.service.api/mise.toml"
[tasks.build]
run = 'echo "building my.service.api"'

[tasks.deploy]
depends = ["//projects/my.app:build"]
run = 'echo "deploying my.service.api"'
TOML

mkdir -p "projects/feature.v2.beta"
cat <<'TOML' >"projects/feature.v2.beta/mise.toml"
[tasks.test]
run = 'echo "testing feature.v2.beta"'
TOML

# Test 1: List tasks for directory with single dot
echo "=== Test 1: List tasks for my.app ==="
assert_contains "mise tasks ls --all" "//projects/my.app:build"
assert_contains "mise tasks ls --all" "//projects/my.app:test"

# Test 2: List tasks for directory with multiple dots
echo "=== Test 2: List tasks for my.service.api ==="
assert_contains "mise tasks ls --all" "//projects/my.service.api:build"
assert_contains "mise tasks ls --all" "//projects/my.service.api:deploy"

# Test 3: List tasks for directory with version-like dots
echo "=== Test 3: List tasks for feature.v2.beta ==="
assert_contains "mise tasks ls --all" "//projects/feature.v2.beta:test"

# Test 4: Run task from directory with single dot
echo "=== Test 4: Run build task from my.app ==="
assert_contains "mise run '//projects/my.app:build'" "building my.app"

# Test 5: Run task from directory with multiple dots
echo "=== Test 5: Run build task from my.service.api ==="
assert_contains "mise run '//projects/my.service.api:build'" "building my.service.api"

# Test 6: Run task with dependency across directories with dots
echo "=== Test 6: Run deploy with cross-directory dependency ==="
output=$(mise run '//projects/my.service.api:deploy')
echo "$output"
echo "$output" | grep -q "building my.app" || (echo "FAIL: Dependency task from my.app not run" && exit 1)
echo "$output" | grep -q "deploying my.service.api" || (echo "FAIL: Deploy task not run" && exit 1)

# Test 7: Wildcard matching with dots in directory names
echo "=== Test 7: Wildcard matching ==="
assert_contains "mise run '//projects/...:build'" "building my.app"
assert_contains "mise run '//projects/...:build'" "building my.service.api"

# Test 8: Run all test tasks
echo "=== Test 8: Run all test tasks ==="
output=$(mise run '//...:test')
echo "$output"
echo "$output" | grep -q "testing my.app" || (echo "FAIL: my.app test not run" && exit 1)
echo "$output" | grep -q "testing feature.v2.beta" || (echo "FAIL: feature.v2.beta test not run" && exit 1)

# Test 9: Verify no false positive matches between different projects
echo "=== Test 9: Verify no false positive task matching ==="
# Running //projects/my.app:build should NOT run //projects/my.service.api:build
output=$(mise run '//projects/my.app:build')
echo "$output"
echo "$output" | grep -q "building my.app" || (echo "FAIL: my.app build not run" && exit 1)
# Verify my.service.api:build was NOT run (would contain "building my.service.api")
if echo "$output" | grep -q "building my.service.api"; then
echo "FAIL: False positive - my.service.api:build should not have run"
exit 1
fi
echo "SUCCESS: No false positive matches"

# Test 10: Verify simple patterns can match monorepo tasks
echo "=== Test 10: Simple pattern matching ==="
# Create a simple task in the current directory for comparison
cat <<'TOML' >>mise.toml

[tasks.local-build]
run = 'echo "local build"'
TOML

# Test that pattern "build" matches monorepo tasks
output=$(mise tasks ls --all)
echo "$output"
# Count how many "build" tasks exist (should be at least 3: my.app, my.service.api, and local-build)
build_count=$(echo "$output" | grep -c ":build\|local-build" || true)
if [ "$build_count" -lt 3 ]; then
echo "FAIL: Expected at least 3 build tasks, found $build_count"
exit 1
fi
echo "SUCCESS: Found $build_count build tasks"

echo "=== All tests with dots in directory names passed! ==="
54 changes: 45 additions & 9 deletions src/task/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,12 +254,24 @@ impl Task {

/// prints the task name without an extension
pub fn display_name(&self, all_tasks: &BTreeMap<String, Task>) -> String {
let display_name = self
.name
.rsplitn(2, '.')
.last()
.unwrap_or_default()
.to_string();
// For task names, only strip extensions after the last colon (:)
// This handles monorepo task names like "//projects/my.app:build.sh"
// where we want to strip ".sh" but keep "my.app" intact
let display_name = if let Some((prefix, task_part)) = self.name.rsplit_once(':') {
// Has a colon separator (e.g., "//projects/my.app:build.sh")
// Strip extension from the task part only
let task_without_ext = task_part.rsplitn(2, '.').last().unwrap_or_default();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: File Extension Stripping Bug

The display_name and is_match functions incorrectly strip file extensions. The rsplitn(2, '.').last() call returns the extension (e.g., "sh" for "build.sh") instead of the base name without the extension.

Fix in Cursor Fix in Web

format!("{}:{}", prefix, task_without_ext)
} else {
// No colon separator (e.g., "build.sh")
// Strip extension from the whole name
self.name
.rsplitn(2, '.')
.last()
.unwrap_or_default()
Comment on lines +263 to +271

Copilot AI Oct 6, 2025

Copy link

Choose a reason for hiding this comment

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

The logic is incorrect. rsplitn(2, '.') followed by last() returns the part before the last dot, not after removing the extension. For 'build.sh', this returns 'build.sh' instead of 'build'. Use rsplitn(2, '.').nth(1).unwrap_or(task_part) to get the part before the extension.

Suggested change
let task_without_ext = task_part.rsplitn(2, '.').last().unwrap_or_default();
format!("{}:{}", prefix, task_without_ext)
} else {
// No colon separator (e.g., "build.sh")
// Strip extension from the whole name
self.name
.rsplitn(2, '.')
.last()
.unwrap_or_default()
let task_without_ext = task_part.rsplitn(2, '.').nth(1).unwrap_or(task_part);
format!("{}:{}", prefix, task_without_ext)
} else {
// No colon separator (e.g., "build.sh")
// Strip extension from the whole name
self.name
.rsplitn(2, '.')
.nth(1)
.unwrap_or(&self.name)

Copilot uses AI. Check for mistakes.
.to_string()
};

if all_tasks.contains_key(&display_name) {
// this means another task has the name without an extension so use the full name
self.name.clone()
Expand All @@ -272,9 +284,33 @@ impl Task {
if self.name == pat || self.aliases.contains(&pat.to_string()) {
return true;
}
let pat = pat.rsplitn(2, '.').last().unwrap_or_default();
self.name.rsplitn(2, '.').last().unwrap_or_default() == pat
|| self.aliases.contains(&pat.to_string())

// For pattern matching, we need to handle several cases:
// 1. Simple pattern (e.g., "build") should match monorepo tasks (e.g., "//projects/my.app:build")
// 2. Full pattern (e.g., "//projects/my.app:build") should only match exact path
// 3. Extensions should be stripped for comparison

let matches = if let Some((prefix, task_part)) = self.name.rsplit_once(':') {
// Task name has a colon (e.g., "//projects/my.app:build.sh")
let task_stripped = task_part.rsplitn(2, '.').last().unwrap_or_default();

if let Some((pat_prefix, pat_task)) = pat.rsplit_once(':') {
// Pattern also has a colon - compare full paths
let pat_task_stripped = pat_task.rsplitn(2, '.').last().unwrap_or_default();
prefix == pat_prefix && task_stripped == pat_task_stripped
} else {
// Pattern is simple (no colon) - just compare task names
let pat_stripped = pat.rsplitn(2, '.').last().unwrap_or_default();
task_stripped == pat_stripped
}
} else {
// Simple task name without colon (e.g., "build.sh")
let name_stripped = self.name.rsplitn(2, '.').last().unwrap_or_default();
let pat_stripped = pat.rsplitn(2, '.').last().unwrap_or_default();
name_stripped == pat_stripped
};

matches || self.aliases.contains(&pat.to_string())
}

pub async fn task_dir() -> PathBuf {
Expand Down
Loading