use std::collections::BTreeMap;
use serde::Serialize;
use super::labels::source_label_for;
use crate::resolver::{
FallbackPolicy, MismatchPolicy, OverrideOrigin, ResolutionOverrides, Resolver,
};
use crate::tool::node::{ManifestSource, detect_pm_from_manifest};
use crate::types::{DetectionWarning, PackageManager, ProjectContext, TaskSource};
#[derive(Debug, Serialize)]
pub(crate) struct Project<'a> {
pub schema_version: u32,
pub root: String,
pub ecosystems: Vec<&'static str>,
pub detected: Detected<'a>,
pub overrides: OverridesView,
pub signals: Signals,
pub decisions: Decisions,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub tasks: Vec<TaskInfo<'a>>,
pub warnings: Vec<WarningInfo>,
}
impl<'a> Project<'a> {
#[cfg(test)]
pub(crate) fn build(ctx: &'a ProjectContext, overrides: &ResolutionOverrides) -> Self {
Self::build_with_schema(ctx, overrides, super::CURRENT_VERSION)
}
pub(crate) fn build_with_schema(
ctx: &'a ProjectContext,
overrides: &ResolutionOverrides,
schema_version: u32,
) -> Self {
let manifest_decl = detect_pm_from_manifest(&ctx.root);
let manifest_pm = manifest_decl.as_ref().map(|d| ManifestPm {
pm: d.pm.label(),
source: match d.source {
ManifestSource::PackageManager => "packageManager",
ManifestSource::DevEngines => "devEngines.packageManager",
},
version: d.version.clone(),
on_fail: d.on_fail.label(),
});
let (decisions, resolver_warnings) = decisions_for(ctx, overrides);
let warnings = ctx
.warnings
.iter()
.chain(resolver_warnings.iter())
.map(WarningInfo::from_warning)
.collect();
let tasks = ctx
.tasks
.iter()
.map(|t| TaskInfo {
name: &t.name,
source: source_label_for(t.source, schema_version),
description: t.description.as_deref(),
alias_of: t.alias_of.as_deref(),
passthrough_to: t.passthrough_to.map(crate::types::TaskRunner::label),
})
.collect();
Self {
schema_version,
root: ctx.root.display().to_string(),
ecosystems: ctx
.package_managers
.iter()
.map(|pm| pm.ecosystem().label())
.collect(),
detected: Detected::from_ctx(ctx),
overrides: OverridesView::from_resolution_overrides(overrides),
signals: Signals {
node: NodeSignals {
lockfile_pm: ctx.primary_node_pm().map(PackageManager::label),
manifest_pm,
path_probe: path_probe_map(),
},
},
decisions,
tasks,
warnings,
}
}
pub(crate) fn into_info_view(mut self) -> Self {
self.tasks.clear();
self
}
pub(crate) fn into_list_view(self, source: Option<TaskSource>) -> TaskListView<'a> {
let target = source.map(|s| source_label_for(s, self.schema_version));
let tasks = self
.tasks
.into_iter()
.filter(|t| target.is_none_or(|expected| expected == t.source))
.collect();
TaskListView {
schema_version: self.schema_version,
root: self.root,
tasks,
}
}
}
#[derive(Debug, Serialize)]
pub(crate) struct TaskListView<'a> {
pub schema_version: u32,
pub root: String,
pub tasks: Vec<TaskInfo<'a>>,
}
#[derive(Debug, Serialize)]
pub(crate) struct Detected<'a> {
pub package_managers: Vec<&'static str>,
pub task_runners: Vec<&'static str>,
pub node_version: Option<NodeVersionInfo<'a>>,
pub current_node: Option<&'a str>,
pub monorepo: bool,
}
impl<'a> Detected<'a> {
fn from_ctx(ctx: &'a ProjectContext) -> Self {
Self {
package_managers: ctx.package_managers.iter().map(|pm| pm.label()).collect(),
task_runners: ctx.task_runners.iter().map(|tr| tr.label()).collect(),
node_version: ctx.node_version.as_ref().map(|nv| NodeVersionInfo {
expected: &nv.expected,
source: nv.source,
}),
current_node: ctx.current_node.as_deref(),
monorepo: ctx.is_monorepo,
}
}
}
#[derive(Debug, Serialize)]
pub(crate) struct NodeVersionInfo<'a> {
pub expected: &'a str,
pub source: &'static str,
}
#[derive(Debug, Serialize)]
pub(crate) struct OverridesView {
pub pm: Option<PmOverrideInfo>,
pub pm_by_ecosystem: BTreeMap<String, PmOverrideInfo>,
pub runner: Option<RunnerOverrideInfo>,
pub prefer_runners: Vec<&'static str>,
pub fallback: &'static str,
pub on_mismatch: &'static str,
pub explain: bool,
pub no_warnings: bool,
}
impl OverridesView {
fn from_resolution_overrides(overrides: &ResolutionOverrides) -> Self {
let mut pm_by_eco = BTreeMap::new();
for (eco, pm_override) in &overrides.pm_by_ecosystem {
pm_by_eco.insert(
eco.label().to_string(),
PmOverrideInfo {
pm: pm_override.pm.label(),
origin: origin_label(&pm_override.origin),
},
);
}
Self {
pm: overrides.pm.as_ref().map(|o| PmOverrideInfo {
pm: o.pm.label(),
origin: origin_label(&o.origin),
}),
pm_by_ecosystem: pm_by_eco,
runner: overrides.runner.as_ref().map(|o| RunnerOverrideInfo {
runner: o.runner.label(),
origin: origin_label(&o.origin),
}),
prefer_runners: overrides.prefer_runners.iter().map(|r| r.label()).collect(),
fallback: fallback_label(overrides.fallback),
on_mismatch: mismatch_label(overrides.on_mismatch),
explain: overrides.explain,
no_warnings: overrides.no_warnings,
}
}
}
#[derive(Debug, Serialize)]
pub(crate) struct PmOverrideInfo {
pub pm: &'static str,
pub origin: String,
}
#[derive(Debug, Serialize)]
pub(crate) struct RunnerOverrideInfo {
pub runner: &'static str,
pub origin: String,
}
#[derive(Debug, Serialize)]
pub(crate) struct Signals {
pub node: NodeSignals,
}
#[derive(Debug, Serialize)]
pub(crate) struct NodeSignals {
pub lockfile_pm: Option<&'static str>,
pub manifest_pm: Option<ManifestPm>,
pub path_probe: BTreeMap<&'static str, Option<String>>,
}
#[derive(Debug, Serialize)]
pub(crate) struct ManifestPm {
pub pm: &'static str,
pub source: &'static str,
pub version: Option<String>,
pub on_fail: &'static str,
}
#[derive(Debug, Serialize)]
pub(crate) struct Decisions {
pub node_pm: NodePmDecision,
}
#[derive(Debug, Serialize)]
#[serde(untagged)]
pub(crate) enum NodePmDecision {
Resolved {
pm: &'static str,
via: String,
},
Error {
error: String,
},
}
#[derive(Debug, Serialize)]
pub(crate) struct TaskInfo<'a> {
pub name: &'a str,
pub source: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub alias_of: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
pub passthrough_to: Option<&'static str>,
}
#[derive(Debug, Serialize)]
pub(crate) struct WarningInfo {
pub source: &'static str,
pub detail: String,
}
impl WarningInfo {
fn from_warning(warning: &DetectionWarning) -> Self {
Self {
source: warning.source(),
detail: warning.detail(),
}
}
}
fn decisions_for(
ctx: &ProjectContext,
overrides: &ResolutionOverrides,
) -> (Decisions, Vec<DetectionWarning>) {
match Resolver::new(ctx, overrides).resolve_node_pm() {
Ok(decision) => {
let warnings = decision.warnings.clone();
(
Decisions {
node_pm: NodePmDecision::Resolved {
pm: decision.pm.label(),
via: decision.describe(),
},
},
warnings,
)
}
Err(err) => (
Decisions {
node_pm: NodePmDecision::Error {
error: format!("{err}"),
},
},
Vec::new(),
),
}
}
fn origin_label(origin: &OverrideOrigin) -> String {
match origin {
OverrideOrigin::CliFlag => "cli".to_string(),
OverrideOrigin::EnvVar => "env".to_string(),
OverrideOrigin::ConfigFile { path } => format!("config:{}", path.display()),
}
}
const fn fallback_label(policy: FallbackPolicy) -> &'static str {
match policy {
FallbackPolicy::Probe => "probe",
FallbackPolicy::Npm => "npm",
FallbackPolicy::Error => "error",
}
}
const fn mismatch_label(policy: MismatchPolicy) -> &'static str {
match policy {
MismatchPolicy::Warn => "warn",
MismatchPolicy::Error => "error",
MismatchPolicy::Ignore => "ignore",
}
}
const PATH_PROBE_PMS: [PackageManager; 4] = [
PackageManager::Bun,
PackageManager::Pnpm,
PackageManager::Yarn,
PackageManager::Npm,
];
fn path_probe_map() -> BTreeMap<&'static str, Option<String>> {
use std::env;
use std::thread;
let path = env::var_os("PATH").unwrap_or_default();
let pathext = env::var_os("PATHEXT");
let pathext_ref = pathext.as_deref();
thread::scope(|s| {
let mut handles = Vec::with_capacity(PATH_PROBE_PMS.len());
for pm in &PATH_PROBE_PMS {
let path = &path;
handles.push(s.spawn(move || {
let resolved =
crate::resolver::probe_path_for_doctor(pm.label(), path, pathext_ref)
.map(|p| p.display().to_string());
(pm.label(), resolved)
}));
}
let mut map = BTreeMap::new();
for handle in handles {
let (label, resolved) = handle.join().expect("path probe thread panicked");
map.insert(label, resolved);
}
map
})
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::Project;
use crate::resolver::ResolutionOverrides;
use crate::types::{PackageManager, ProjectContext, Task, TaskSource};
fn empty_context(root: &str) -> ProjectContext {
ProjectContext {
root: PathBuf::from(root),
package_managers: vec![PackageManager::Pnpm],
task_runners: Vec::new(),
tasks: Vec::new(),
node_version: None,
current_node: None,
is_monorepo: false,
warnings: Vec::new(),
}
}
#[test]
fn project_serializes_schema_version_field() {
let ctx = empty_context("/tmp/test");
let overrides = ResolutionOverrides::default();
let project = Project::build(&ctx, &overrides);
let value = serde_json::to_value(&project).expect("Project should serialize to JSON");
assert_eq!(value["schema_version"], 2);
assert_eq!(value["root"], "/tmp/test");
assert!(
value["ecosystems"]
.as_array()
.is_some_and(|a| !a.is_empty())
);
}
#[test]
fn info_view_drops_tasks_array() {
let mut ctx = empty_context("/tmp/test");
ctx.tasks.push(Task {
name: "build".to_string(),
source: TaskSource::PackageJson,
run_target: None,
description: None,
alias_of: None,
passthrough_to: None,
});
let project = Project::build(&ctx, &ResolutionOverrides::default()).into_info_view();
let value = serde_json::to_value(&project).expect("info view should serialize");
assert!(value.get("tasks").is_none(), "info view should omit tasks");
}
#[test]
fn list_view_filters_by_source() {
let mut ctx = empty_context("/tmp/test");
ctx.tasks.push(Task {
name: "build".to_string(),
source: TaskSource::PackageJson,
run_target: None,
description: None,
alias_of: None,
passthrough_to: None,
});
ctx.tasks.push(Task {
name: "fmt".to_string(),
source: TaskSource::Justfile,
run_target: None,
description: None,
alias_of: None,
passthrough_to: None,
});
let project = Project::build(&ctx, &ResolutionOverrides::default());
let view = project.into_list_view(Some(TaskSource::Justfile));
assert_eq!(view.tasks.len(), 1);
assert_eq!(view.tasks[0].name, "fmt");
}
#[test]
fn build_with_schema_serializes_v1_labels_for_tasks() {
let ctx = ProjectContext {
root: PathBuf::from("/tmp/test"),
package_managers: Vec::new(),
task_runners: Vec::new(),
tasks: vec![Task {
name: "fmt".to_string(),
source: TaskSource::Justfile,
run_target: None,
description: None,
alias_of: None,
passthrough_to: None,
}],
node_version: None,
current_node: None,
is_monorepo: false,
warnings: Vec::new(),
};
let v1 = Project::build_with_schema(&ctx, &ResolutionOverrides::default(), 1);
let v1_json = serde_json::to_value(&v1).expect("v1 serialization");
assert_eq!(v1_json["schema_version"], 1);
assert_eq!(v1_json["tasks"][0]["source"], "justfile");
let v2 = Project::build_with_schema(&ctx, &ResolutionOverrides::default(), 2);
let v2_json = serde_json::to_value(&v2).expect("v2 serialization");
assert_eq!(v2_json["schema_version"], 2);
assert_eq!(v2_json["tasks"][0]["source"], "just");
}
}