use std::path::PathBuf;
use anyhow::Result;
use colored::Colorize;
use serde::Serialize;
use crate::cmd::run::{
ResolvedPythonPm, allowed_runner_sources, resolve_python_pm, runner_constraint_error,
select_task_entry, source_depth, source_priority,
};
use crate::resolver::{ResolutionOverrides, ResolveError, ResolvedPm, Resolver};
use crate::types::{ProjectContext, Task, TaskSource};
pub(crate) fn why(
ctx: &ProjectContext,
overrides: &ResolutionOverrides,
task: &str,
json: bool,
schema_version: u32,
) -> Result<()> {
let candidates: Vec<&Task> = ctx.tasks.iter().filter(|t| t.name == task).collect();
let restricted: Vec<&Task> = allowed_runner_sources(overrides).map_or_else(
|| candidates.clone(),
|allowed| {
candidates
.iter()
.copied()
.filter(|t| allowed.contains(&t.source))
.collect()
},
);
if restricted.is_empty()
&& let Some(reason) = runner_constraint_error(overrides, &candidates)
{
return Err(reason.into());
}
let selected = (!restricted.is_empty()).then(|| select_task_entry(ctx, overrides, &restricted));
let pm_decision = pm_decision_for_selected(ctx, overrides, selected);
if json && schema_version >= 3 {
let report = build_report_v3(
task,
&candidates,
selected,
pm_decision.as_ref(),
overrides,
ctx,
schema_version,
);
println!("{}", serde_json::to_string_pretty(&report)?);
} else if json {
let report = build_report(
task,
&candidates,
selected,
pm_decision.as_ref(),
overrides,
ctx,
schema_version,
);
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
print_human(
task,
&candidates,
selected,
pm_decision.as_ref(),
overrides,
ctx,
);
}
Ok(())
}
enum PmDecision {
Node(Result<ResolvedPm, ResolveError>),
Python(Result<ResolvedPythonPm, String>),
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Serialize)]
pub(super) struct WhyReport<'a> {
#[serde(rename = "$schema", skip_serializing_if = "str::is_empty")]
#[cfg_attr(
feature = "schema",
schemars(description = "URI of the JSON Schema that describes this payload.")
)]
schema: String,
#[cfg_attr(
feature = "schema",
schemars(description = "Schema contract version for this JSON payload.")
)]
schema_version: u32,
task: &'a str,
candidates: Vec<WhyCandidate<'a>>,
selected: Option<WhyCandidate<'a>>,
pm_resolution: Option<PmResolution>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Serialize)]
struct WhyCandidate<'a> {
source: &'static str,
source_priority: u16,
depth: Option<usize>,
display_order: u8,
is_alias: bool,
alias_of: Option<&'a str>,
description: Option<&'a str>,
passthrough_to: Option<&'static str>,
source_dir: Option<String>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Serialize)]
#[serde(untagged)]
enum PmResolution {
Resolved {
pm: &'static str,
via: String,
warnings: Vec<WhyWarning>,
},
Error {
error: String,
},
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Serialize)]
struct WhyWarning {
source: &'static str,
detail: String,
}
fn pm_decision_for_selected(
ctx: &ProjectContext,
overrides: &ResolutionOverrides,
selected: Option<&Task>,
) -> Option<PmDecision> {
match selected.map(|task| task.source) {
Some(TaskSource::PackageJson) => {
Some(PmDecision::Node(Resolver::new(ctx, overrides).resolve_node_pm()))
}
Some(TaskSource::PyprojectScripts) => Some(PmDecision::Python(
resolve_python_pm(ctx, overrides).ok_or_else(|| {
"no Python package manager detected to run pyproject scripts; install uv, poetry, or pipenv"
.to_string()
}),
)),
_ => None,
}
}
fn build_report<'a>(
task: &'a str,
candidates: &[&'a Task],
selected: Option<&'a Task>,
pm_decision: Option<&PmDecision>,
overrides: &ResolutionOverrides,
ctx: &ProjectContext,
schema_version: u32,
) -> WhyReport<'a> {
WhyReport {
schema: String::new(),
schema_version,
task,
candidates: candidates
.iter()
.map(|candidate| candidate_json(candidate, overrides, ctx, schema_version))
.collect::<Vec<_>>(),
selected: selected.map(|task| candidate_json(task, overrides, ctx, schema_version)),
pm_resolution: pm_decision.map(pm_resolution),
}
}
fn pm_resolution(decision: &PmDecision) -> PmResolution {
match decision {
PmDecision::Node(Ok(decision)) => PmResolution::Resolved {
pm: decision.pm.label(),
via: decision.describe(),
warnings: decision
.warnings
.iter()
.map(|warning| WhyWarning {
source: warning.source(),
detail: warning.detail(),
})
.collect(),
},
PmDecision::Node(Err(err)) => PmResolution::Error {
error: format!("{err}"),
},
PmDecision::Python(Ok(decision)) => PmResolution::Resolved {
pm: decision.pm.label(),
via: decision.describe(),
warnings: Vec::new(),
},
PmDecision::Python(Err(err)) => PmResolution::Error { error: err.clone() },
}
}
fn candidate_json<'a>(
task: &'a Task,
overrides: &ResolutionOverrides,
ctx: &ProjectContext,
schema_version: u32,
) -> WhyCandidate<'a> {
let depth = source_depth(ctx, task.source);
let depth = if depth == usize::MAX {
None
} else {
Some(depth)
};
WhyCandidate {
source: crate::schema::labels::source_label_for(task.source, schema_version),
source_priority: source_priority(overrides, task.source),
depth,
display_order: task.source.display_order(),
is_alias: task.alias_of.is_some(),
alias_of: task.alias_of.as_deref(),
description: task.description.as_deref(),
passthrough_to: task.passthrough_to.map(crate::types::TaskRunner::label),
source_dir: source_dir_for_task(task, ctx).map(|path| path.display().to_string()),
}
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))]
pub(super) struct WhyReportV3<'a> {
#[serde(rename = "$schema", skip_serializing_if = "str::is_empty")]
#[cfg_attr(
feature = "schema",
schemars(description = "URI of the JSON Schema that describes this payload.")
)]
schema: String,
#[cfg_attr(
feature = "schema",
schemars(description = "Schema contract version for this JSON payload.")
)]
schema_version: u32,
#[cfg_attr(
feature = "schema",
schemars(description = "Payload discriminator; always \"runner.why\".")
)]
kind: &'static str,
#[cfg_attr(
feature = "schema",
schemars(description = "Project root the query ran against.")
)]
root: String,
#[cfg_attr(
feature = "schema",
schemars(description = "The task selector as the user typed it.")
)]
query: &'a str,
pm_resolution: Option<PmResolution>,
selected: Option<WhyCandidateV3<'a>>,
candidates: Vec<WhyCandidateV3<'a>>,
decision: WhyDecisionV3,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))]
struct WhyCandidateV3<'a> {
task: WhyTaskV3<'a>,
#[serde(rename = "match")]
matched: WhyMatchV3<'a>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))]
struct WhyTaskV3<'a> {
name: &'a str,
#[cfg_attr(
feature = "schema",
schemars(
description = "Stable task identity: `<scope>:<kind>#<name>`. The `#` boundary keeps a task name containing `:` (e.g. `fmt:update`) unambiguous. Scope is `root` until workspace-member scoping lands."
)
)]
fqn: String,
#[cfg_attr(
feature = "schema",
schemars(
description = "Tool family that would execute the task (e.g. `cargo`, `just`, `node`)."
)
)]
provider: &'static str,
#[cfg_attr(
feature = "schema",
schemars(description = "Task mechanism label (v3 source label, e.g. `cargo-alias`).")
)]
kind: &'static str,
#[cfg_attr(
feature = "schema",
schemars(description = "Config file the task was extracted from, when resolvable.")
)]
source: Option<String>,
#[cfg_attr(
feature = "schema",
schemars(
description = "Locator inside the source file: a key path for structured configs (`alias.t`, `scripts.test`), the target/recipe name for flat files."
)
)]
source_pointer: Option<String>,
description: Option<&'a str>,
#[cfg_attr(
feature = "schema",
schemars(description = "Names of sibling alias tasks that resolve to this task.")
)]
aliases: Vec<&'a str>,
#[cfg_attr(
feature = "schema",
schemars(
description = "Raw definition target: alias expansion or tool-specific run target."
)
)]
definition: Option<&'a str>,
#[cfg_attr(
feature = "schema",
schemars(
description = "Effective command preview. Null when it depends on a PM resolution that was not performed for this candidate."
)
)]
resolved: Option<String>,
cwd: String,
#[cfg_attr(
feature = "schema",
schemars(
description = "Task dependencies. Always empty today: no extractor records dependency edges yet."
)
)]
dependencies: Vec<String>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))]
struct WhyMatchV3<'a> {
selector: &'a str,
#[cfg_attr(
feature = "schema",
schemars(description = "How the selector matched. `why` matches exact names only today.")
)]
matched_by: &'static str,
depth: Option<usize>,
display_order: u8,
source_priority: u16,
is_alias: bool,
passthrough_to: Option<&'static str>,
}
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[derive(Debug, Serialize)]
#[cfg_attr(feature = "schema", schemars(deny_unknown_fields))]
struct WhyDecisionV3 {
#[cfg_attr(
feature = "schema",
schemars(
description = "Selection branch taken: `single-candidate`, `ranked`, `filtered`, or `exec-fallback`."
)
)]
strategy: &'static str,
reason: String,
}
fn build_report_v3<'a>(
query: &'a str,
candidates: &[&'a Task],
selected: Option<&'a Task>,
pm_decision: Option<&PmDecision>,
overrides: &ResolutionOverrides,
ctx: &'a ProjectContext,
schema_version: u32,
) -> WhyReportV3<'a> {
let candidate_v3 = |task: &'a Task| WhyCandidateV3 {
task: task_v3(task, ctx, pm_decision, selected, schema_version),
matched: match_v3(query, task, overrides, ctx),
};
WhyReportV3 {
schema: String::new(),
schema_version,
kind: "runner.why",
root: ctx.root.display().to_string(),
query,
pm_resolution: pm_decision.map(pm_resolution),
selected: selected.map(candidate_v3),
candidates: candidates.iter().copied().map(candidate_v3).collect(),
decision: decision_v3(candidates, selected),
}
}
fn task_v3<'a>(
task: &'a Task,
ctx: &'a ProjectContext,
pm_decision: Option<&PmDecision>,
selected: Option<&Task>,
schema_version: u32,
) -> WhyTaskV3<'a> {
let kind = crate::schema::labels::source_label_for(task.source, schema_version);
let is_selected = selected.is_some_and(|sel| std::ptr::eq(sel, task));
WhyTaskV3 {
name: &task.name,
fqn: crate::schema::labels::fqn(task.source, &task.name, schema_version),
provider: provider_label(task.source),
kind,
source: source_dir_for_task(task, ctx).map(|path| path.display().to_string()),
source_pointer: source_pointer(task),
description: task.description.as_deref(),
aliases: ctx
.tasks
.iter()
.filter(|other| {
other.source == task.source && other.alias_of.as_deref() == Some(&task.name)
})
.map(|other| other.name.as_str())
.collect(),
definition: task.alias_of.as_deref().or(task.run_target.as_deref()),
resolved: resolved_command(task, pm_decision.filter(|_| is_selected)),
cwd: ctx.root.display().to_string(),
dependencies: Vec::new(),
}
}
fn match_v3<'a>(
selector: &'a str,
task: &Task,
overrides: &ResolutionOverrides,
ctx: &ProjectContext,
) -> WhyMatchV3<'a> {
let depth = source_depth(ctx, task.source);
WhyMatchV3 {
selector,
matched_by: "name",
depth: (depth != usize::MAX).then_some(depth),
display_order: task.source.display_order(),
source_priority: source_priority(overrides, task.source),
is_alias: task.alias_of.is_some(),
passthrough_to: task.passthrough_to.map(crate::types::TaskRunner::label),
}
}
fn decision_v3(candidates: &[&Task], selected: Option<&Task>) -> WhyDecisionV3 {
if candidates.is_empty() {
return WhyDecisionV3 {
strategy: "exec-fallback",
reason: "no task matched; `runner run` would route the name through the primary \
package manager's exec primitive"
.to_string(),
};
}
if selected.is_none() {
return WhyDecisionV3 {
strategy: "filtered",
reason: "every candidate was filtered out by --runner/RUNNER_RUNNER restrictions"
.to_string(),
};
}
if candidates.len() == 1 {
return WhyDecisionV3 {
strategy: "single-candidate",
reason: "exact task name matched one candidate".to_string(),
};
}
WhyDecisionV3 {
strategy: "ranked",
reason: format!(
"{} candidates; lowest (source_priority, source_depth, display_order, alias-last) \
key wins",
candidates.len()
),
}
}
const fn provider_label(source: TaskSource) -> &'static str {
match source {
TaskSource::PackageJson => "node",
TaskSource::DenoJson => "deno",
TaskSource::TurboJson => "turbo",
TaskSource::Makefile => "make",
TaskSource::Justfile => "just",
TaskSource::Taskfile => "task",
TaskSource::CargoAliases => "cargo",
TaskSource::GoPackage => "go",
TaskSource::BaconToml => "bacon",
TaskSource::MiseToml => "mise",
TaskSource::PyprojectScripts => "python",
}
}
fn source_pointer(task: &Task) -> Option<String> {
let name = &task.name;
match task.source {
TaskSource::CargoAliases => Some(format!("alias.{name}")),
TaskSource::PackageJson => Some(format!("scripts.{name}")),
TaskSource::DenoJson
| TaskSource::TurboJson
| TaskSource::Taskfile
| TaskSource::MiseToml => Some(format!("tasks.{name}")),
TaskSource::BaconToml => Some(format!("jobs.{name}")),
TaskSource::PyprojectScripts => Some(format!("project.scripts.{name}")),
TaskSource::Makefile | TaskSource::Justfile => Some(name.clone()),
TaskSource::GoPackage => None,
}
}
fn resolved_command(task: &Task, pm_decision: Option<&PmDecision>) -> Option<String> {
let name = &task.name;
match task.source {
TaskSource::CargoAliases => Some(task.alias_of.as_deref().map_or_else(
|| format!("cargo {name}"),
|expansion| format!("cargo {expansion}"),
)),
TaskSource::DenoJson => Some(format!("deno task {name}")),
TaskSource::TurboJson => Some(format!("turbo run {name}")),
TaskSource::Makefile => Some(format!("make {name}")),
TaskSource::Justfile => Some(format!("just {name}")),
TaskSource::Taskfile => Some(format!("task {name}")),
TaskSource::BaconToml => Some(format!("bacon {name}")),
TaskSource::MiseToml => Some(format!("mise run {name}")),
TaskSource::GoPackage => Some(format!(
"go run {target}",
target = task.run_target.as_deref().unwrap_or(name)
)),
TaskSource::PackageJson => match pm_decision {
Some(PmDecision::Node(Ok(decision))) => {
Some(format!("{pm} run {name}", pm = decision.pm.label()))
}
_ => None,
},
TaskSource::PyprojectScripts => match pm_decision {
Some(PmDecision::Python(Ok(decision))) => {
Some(format!("{pm} run {name}", pm = decision.pm.label()))
}
_ => None,
},
}
}
fn source_dir_for_task(task: &Task, ctx: &ProjectContext) -> Option<PathBuf> {
use crate::tool;
match task.source {
TaskSource::PackageJson => tool::node::find_manifest_upwards(&ctx.root),
TaskSource::DenoJson => tool::deno::find_config_upwards(&ctx.root),
TaskSource::TurboJson => tool::turbo::find_config(&ctx.root),
TaskSource::Makefile => tool::files::find_first(&ctx.root, tool::make::FILENAMES),
TaskSource::Justfile => tool::just::find_file(&ctx.root),
TaskSource::Taskfile => tool::files::find_first(&ctx.root, tool::go_task::FILENAMES),
TaskSource::CargoAliases => tool::cargo_aliases::find_anchor(&ctx.root),
TaskSource::GoPackage => tool::go_pm::find_file(&ctx.root),
TaskSource::BaconToml => tool::files::find_first(&ctx.root, tool::bacon::FILENAMES),
TaskSource::MiseToml => tool::mise::find_file(&ctx.root),
TaskSource::PyprojectScripts => tool::python::find_pyproject_upwards(&ctx.root),
}
}
fn print_human(
task: &str,
candidates: &[&Task],
selected: Option<&Task>,
pm_decision: Option<&PmDecision>,
overrides: &ResolutionOverrides,
ctx: &ProjectContext,
) {
println!("{} {}", "runner why".bold(), task.bold());
println!();
if candidates.is_empty() {
println!(
" {}",
"No task with that name in any detected source.".dimmed()
);
println!(
" {}",
"Without a match, `runner run` would treat it as a command and route through the \
primary PM's exec primitive (npx-style)."
.dimmed()
);
return;
}
println!("{}", "Candidates".bold());
for c in candidates {
let depth = source_depth(ctx, c.source);
let depth_label = if depth == usize::MAX {
"—".to_string()
} else {
depth.to_string()
};
let alias_tag = c
.alias_of
.as_deref()
.map_or(String::new(), |target| format!(" → {target}"));
let passthrough_tag = c.passthrough_to.map_or(String::new(), |r| {
format!(" (passthrough to {})", r.label())
});
println!(
" {} {} [priority={}, depth={}, order={}]{}{}",
"·".dimmed(),
c.source.label().bold(),
source_priority(overrides, c.source),
depth_label,
c.source.display_order(),
alias_tag,
passthrough_tag,
);
}
println!();
if let Some(sel) = selected {
println!(
"{} {} {}",
"Selected".bold(),
"→".dimmed(),
sel.source.label().green()
);
println!(
" {}",
"key: (source_priority, depth, display_order, alias_last)".dimmed()
);
}
if let Some(res) = pm_decision {
println!();
println!("{}", "PM resolution".bold());
match res {
PmDecision::Node(Ok(decision)) => {
println!(" {}", decision.describe());
for w in &decision.warnings {
println!(" {} {w}", "warn:".yellow().bold());
}
}
PmDecision::Node(Err(err)) => {
println!(" {} {err}", "error:".red().bold());
}
PmDecision::Python(Ok(decision)) => println!(" {}", decision.describe()),
PmDecision::Python(Err(err)) => println!(" {} {err}", "error:".red().bold()),
}
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::{PmDecision, build_report, build_report_v3, pm_decision_for_selected, why};
use crate::resolver::{DiagnosticFlags, ResolutionOverrides};
use crate::types::{PackageManager, ProjectContext, Task, TaskSource};
fn context(tasks: Vec<Task>) -> ProjectContext {
ProjectContext {
root: PathBuf::from("/tmp/test"),
package_managers: Vec::new(),
task_runners: Vec::new(),
tasks,
node_version: None,
current_node: None,
is_monorepo: false,
warnings: Vec::new(),
}
}
fn task(name: &str, source: TaskSource) -> Task {
Task {
name: name.to_string(),
source,
run_target: None,
description: None,
alias_of: None,
passthrough_to: None,
}
}
#[test]
fn why_handles_missing_task() {
let ctx = context(vec![]);
why(
&ctx,
&ResolutionOverrides::default(),
"build",
true,
crate::schema::CURRENT_VERSION,
)
.expect("why should succeed even when task is missing");
}
#[test]
fn why_with_multiple_candidates_renders_both_formats() {
let ctx = context(vec![
task("build", TaskSource::PackageJson),
task("build", TaskSource::Justfile),
]);
let version = crate::schema::CURRENT_VERSION;
why(
&ctx,
&ResolutionOverrides::default(),
"build",
true,
version,
)
.expect("json should succeed");
why(
&ctx,
&ResolutionOverrides::default(),
"build",
false,
version,
)
.expect("human should succeed");
}
#[test]
fn why_rejects_runner_constraint_mismatch() {
let ctx = context(vec![task("build", TaskSource::PackageJson)]);
let overrides = ResolutionOverrides::from_cli_and_env(
None,
Some("just"),
None,
None,
DiagnosticFlags::default(),
crate::cli::ChainFailureFlags::default(),
None,
)
.expect("runner override should parse");
let err = why(
&ctx,
&overrides,
"build",
true,
crate::schema::CURRENT_VERSION,
)
.expect_err("why should mirror run runner constraints");
assert!(format!("{err}").contains("no candidate task is registered"));
}
#[test]
fn why_pyproject_script_reports_detected_python_pm() {
let mut ctx = context(vec![task("greenpy", TaskSource::PyprojectScripts)]);
ctx.package_managers.push(PackageManager::Uv);
let selected = ctx.tasks.first();
let pm_decision = pm_decision_for_selected(&ctx, &ResolutionOverrides::default(), selected)
.expect("pyproject task should resolve PM diagnostics");
let report = build_report(
"greenpy",
&[&ctx.tasks[0]],
selected,
Some(&pm_decision),
&ResolutionOverrides::default(),
&ctx,
crate::schema::CURRENT_VERSION,
);
let report = serde_json::to_value(report).expect("why report should serialize");
assert_eq!(report["pm_resolution"]["pm"], serde_json::json!("uv"));
assert!(
report["pm_resolution"]["via"]
.as_str()
.is_some_and(|via| via.contains("detected Python project"))
);
}
#[test]
fn v3_report_describes_cargo_alias_like_the_committed_example() {
let mut alias = task("t", TaskSource::CargoAliases);
alias.alias_of = Some("test".to_string());
let ctx = context(vec![alias]);
let candidates = vec![&ctx.tasks[0]];
let selected = ctx.tasks.first();
let report = build_report_v3(
"t",
&candidates,
selected,
None,
&ResolutionOverrides::default(),
&ctx,
3,
);
let json = serde_json::to_value(&report).expect("v3 report should serialize");
assert_eq!(json["schema_version"], 3);
assert_eq!(json["kind"], "runner.why");
assert_eq!(json["query"], "t");
assert_eq!(json["pm_resolution"], serde_json::Value::Null);
let task = &json["selected"]["task"];
assert_eq!(task["name"], "t");
assert_eq!(task["fqn"], "root:cargo-alias#t");
assert_eq!(task["provider"], "cargo");
assert_eq!(task["kind"], "cargo-alias");
assert_eq!(task["source_pointer"], "alias.t");
assert_eq!(task["definition"], "test");
assert_eq!(task["resolved"], "cargo test");
assert_eq!(task["dependencies"], serde_json::json!([]));
let matched = &json["selected"]["match"];
assert_eq!(matched["selector"], "t");
assert_eq!(matched["matched_by"], "name");
assert_eq!(matched["is_alias"], true);
assert_eq!(json["candidates"].as_array().map(Vec::len), Some(1));
assert_eq!(json["decision"]["strategy"], "single-candidate");
assert_eq!(
json["decision"]["reason"],
"exact task name matched one candidate"
);
}
#[test]
fn v3_report_uses_exec_fallback_decision_when_nothing_matches() {
let ctx = context(vec![]);
let report = build_report_v3(
"nope",
&[],
None,
None,
&ResolutionOverrides::default(),
&ctx,
3,
);
let json = serde_json::to_value(&report).expect("v3 report should serialize");
assert_eq!(json["selected"], serde_json::Value::Null);
assert_eq!(json["candidates"], serde_json::json!([]));
assert_eq!(json["decision"]["strategy"], "exec-fallback");
}
#[test]
fn v3_report_ranks_multiple_candidates() {
let ctx = context(vec![
task("build", TaskSource::PackageJson),
task("build", TaskSource::Justfile),
]);
let candidates: Vec<&Task> = ctx.tasks.iter().collect();
let report = build_report_v3(
"build",
&candidates,
ctx.tasks.first(),
None,
&ResolutionOverrides::default(),
&ctx,
3,
);
let json = serde_json::to_value(&report).expect("v3 report should serialize");
assert_eq!(json["decision"]["strategy"], "ranked");
assert_eq!(json["candidates"].as_array().map(Vec::len), Some(2));
assert_eq!(
json["candidates"][0]["task"]["resolved"],
serde_json::Value::Null
);
assert_eq!(json["candidates"][1]["task"]["resolved"], "just build");
}
#[test]
fn v3_report_resolves_selected_pyproject_script_through_python_pm() {
let mut ctx = context(vec![task("greenpy", TaskSource::PyprojectScripts)]);
ctx.package_managers.push(PackageManager::Uv);
let selected = ctx.tasks.first();
let pm_decision = pm_decision_for_selected(&ctx, &ResolutionOverrides::default(), selected)
.expect("pyproject task should resolve PM diagnostics");
let candidates = vec![&ctx.tasks[0]];
let report = build_report_v3(
"greenpy",
&candidates,
selected,
Some(&pm_decision),
&ResolutionOverrides::default(),
&ctx,
3,
);
let json = serde_json::to_value(&report).expect("v3 report should serialize");
assert_eq!(json["selected"]["task"]["provider"], "python");
assert_eq!(json["selected"]["task"]["resolved"], "uv run greenpy");
assert_eq!(
json["selected"]["task"]["source_pointer"],
"project.scripts.greenpy"
);
}
#[test]
fn v3_report_collects_sibling_aliases() {
let mut shortcut = task("f", TaskSource::Justfile);
shortcut.alias_of = Some("fmt".to_string());
let ctx = context(vec![task("fmt", TaskSource::Justfile), shortcut]);
let candidates = vec![&ctx.tasks[0]];
let report = build_report_v3(
"fmt",
&candidates,
ctx.tasks.first(),
None,
&ResolutionOverrides::default(),
&ctx,
3,
);
let json = serde_json::to_value(&report).expect("v3 report should serialize");
assert_eq!(
json["selected"]["task"]["aliases"],
serde_json::json!(["f"])
);
}
#[test]
fn why_pyproject_script_reports_python_pm_override() {
let ctx = context(vec![task("greenpy", TaskSource::PyprojectScripts)]);
let overrides = ResolutionOverrides::from_cli_and_env(
Some("uv"),
None,
None,
None,
DiagnosticFlags::default(),
crate::cli::ChainFailureFlags::default(),
None,
)
.expect("PM override should parse");
let selected = ctx.tasks.first();
let pm_decision = pm_decision_for_selected(&ctx, &overrides, selected)
.expect("pyproject task should resolve PM diagnostics");
match pm_decision {
PmDecision::Python(Ok(decision)) => {
assert_eq!(decision.pm, PackageManager::Uv);
assert!(decision.describe().contains("--pm"));
}
PmDecision::Python(Err(err)) => panic!("override should resolve: {err}"),
PmDecision::Node(_) => panic!("pyproject script should use Python PM resolver"),
}
}
}