use std::path::{Path, PathBuf};
#[cfg(feature = "schema")]
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::serde_path;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum WorkspaceDiagnosticKind {
UndeclaredWorkspace,
MalformedPackageJson {
error: String,
},
GlobMatchedNoPackageJson {
pattern: String,
},
MalformedTsconfig {
error: String,
},
TsconfigReferenceDirMissing,
SkippedLargeFile {
size_bytes: u64,
},
SkippedMinifiedFile {
size_bytes: u64,
},
}
impl WorkspaceDiagnosticKind {
#[must_use]
pub const fn id(&self) -> &'static str {
match self {
Self::UndeclaredWorkspace => "undeclared-workspace",
Self::MalformedPackageJson { .. } => "malformed-package-json",
Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
Self::MalformedTsconfig { .. } => "malformed-tsconfig",
Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
Self::SkippedLargeFile { .. } => "skipped-large-file",
Self::SkippedMinifiedFile { .. } => "skipped-minified-file",
}
}
#[must_use]
pub const fn is_source_discovery(&self) -> bool {
matches!(
self,
Self::SkippedLargeFile { .. } | Self::SkippedMinifiedFile { .. }
)
}
}
#[must_use]
fn format_size_mb(bytes: u64) -> String {
#[expect(
clippy::cast_precision_loss,
reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
)]
let mb = bytes as f64 / (1024.0 * 1024.0);
format!("{mb:.1} MB")
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(JsonSchema))]
pub struct WorkspaceDiagnostic {
#[serde(serialize_with = "serde_path::serialize")]
pub path: PathBuf,
#[serde(flatten)]
pub kind: WorkspaceDiagnosticKind,
pub message: String,
}
impl WorkspaceDiagnostic {
#[must_use]
pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
let kind = normalise_payload_paths(root, kind);
let message = render_message(root, &path, &kind);
Self {
path,
kind,
message,
}
}
}
fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
let root_str = root.display().to_string();
let root_alt = root_str.replace('\\', "/");
let normalise = |text: String| -> String {
let stripped = text
.replace(&format!("{root_str}/"), "")
.replace(&format!("{root_alt}/"), "");
stripped
.replace(&format!("{root_str}\\"), "")
.replace(&format!("{root_alt}\\"), "")
};
match kind {
WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
WorkspaceDiagnosticKind::MalformedPackageJson {
error: normalise(error),
}
}
WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
WorkspaceDiagnosticKind::MalformedTsconfig {
error: normalise(error),
}
}
other => other,
}
}
fn display_relative(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.display()
.to_string()
.replace('\\', "/")
}
fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
let display = display_relative(root, path);
match kind {
WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
"Directory '{display}' contains package.json but is not declared as a workspace. \
Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
),
WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
"Dropped workspace '{display}': package.json is not valid JSON ({error}). \
Fix the JSON syntax or remove '{display}' from the workspaces pattern."
),
WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
"Glob '{pattern}' matched '{display}' but no package.json is present. \
Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
),
WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
"tsconfig.json at '{display}' failed to parse ({error}); \
project references will be ignored. Fix the JSON syntax."
),
WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
"tsconfig.json references '{display}' but the directory does not exist. \
Update or remove the reference, or restore the missing directory."
),
WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes } => format!(
"Skipped '{display}' ({size}): exceeds the max file size limit. \
Its imports and exports are not analyzed. Raise the limit with \
--max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add '{display}' \
to ignorePatterns.",
size = format_size_mb(*size_bytes)
),
WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes } => format!(
"Skipped '{display}' ({size}): appears to be minified generated JavaScript. \
Its imports and exports are not analyzed. Add '{display}' to ignorePatterns, \
rename it with a .min.js suffix, or use --max-file-size 0 if this file \
should be analyzed.",
size = format_size_mb(*size_bytes)
),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn skipped_large_file_diagnostic_id_and_message() {
let root = Path::new("/project");
let diag = WorkspaceDiagnostic::new(
root,
root.join("src/vendor/app.bundle.js"),
WorkspaceDiagnosticKind::SkippedLargeFile {
size_bytes: 6 * 1024 * 1024,
},
);
assert_eq!(diag.kind.id(), "skipped-large-file");
assert!(
diag.message.contains("src/vendor/app.bundle.js"),
"message names the project-relative path: {}",
diag.message
);
assert!(
diag.message.contains("6.0 MB"),
"message reports the size: {}",
diag.message
);
assert!(
diag.message.contains("--max-file-size"),
"message names the override flag: {}",
diag.message
);
}
#[test]
fn skipped_minified_file_diagnostic_id_and_message() {
let root = Path::new("/project");
let diag = WorkspaceDiagnostic::new(
root,
root.join("src/assets/index-abc123.js"),
WorkspaceDiagnosticKind::SkippedMinifiedFile {
size_bytes: 2 * 1024 * 1024,
},
);
assert_eq!(diag.kind.id(), "skipped-minified-file");
assert!(
diag.message.contains("src/assets/index-abc123.js"),
"message names the project-relative path: {}",
diag.message
);
assert!(
diag.message.contains("2.0 MB"),
"message reports the size: {}",
diag.message
);
assert!(
diag.message.contains("--max-file-size 0"),
"message names the opt-out: {}",
diag.message
);
}
#[test]
fn format_size_mb_one_decimal() {
assert_eq!(format_size_mb(0), "0.0 MB");
assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
}
#[test]
fn undeclared_workspace_message_has_next_step() {
let root = Path::new("/project");
let diag = WorkspaceDiagnostic::new(
root,
root.join("packages/legacy"),
WorkspaceDiagnosticKind::UndeclaredWorkspace,
);
assert_eq!(diag.kind.id(), "undeclared-workspace");
assert!(diag.message.contains("packages/legacy"), "{}", diag.message);
assert!(
diag.message.contains("ignorePatterns"),
"next-step hint preserved: {}",
diag.message
);
}
}