1use std::path::{Path, PathBuf};
16
17#[cfg(feature = "schema")]
18use schemars::JsonSchema;
19use serde::{Deserialize, Serialize};
20
21use crate::serde_path;
22
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
29#[cfg_attr(feature = "schema", derive(JsonSchema))]
30#[serde(tag = "kind", rename_all = "kebab-case")]
31pub enum WorkspaceDiagnosticKind {
32 UndeclaredWorkspace,
37 MalformedPackageJson {
40 error: String,
42 },
43 GlobMatchedNoPackageJson {
47 pattern: String,
49 },
50 MalformedTsconfig {
53 error: String,
55 },
56 TsconfigReferenceDirMissing,
59 SkippedLargeFile {
67 size_bytes: u64,
69 },
70 SkippedMinifiedFile {
76 size_bytes: u64,
78 },
79}
80
81impl WorkspaceDiagnosticKind {
82 #[must_use]
84 pub const fn id(&self) -> &'static str {
85 match self {
86 Self::UndeclaredWorkspace => "undeclared-workspace",
87 Self::MalformedPackageJson { .. } => "malformed-package-json",
88 Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
89 Self::MalformedTsconfig { .. } => "malformed-tsconfig",
90 Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
91 Self::SkippedLargeFile { .. } => "skipped-large-file",
92 Self::SkippedMinifiedFile { .. } => "skipped-minified-file",
93 }
94 }
95
96 #[must_use]
105 pub const fn is_source_discovery(&self) -> bool {
106 matches!(
107 self,
108 Self::SkippedLargeFile { .. } | Self::SkippedMinifiedFile { .. }
109 )
110 }
111}
112
113#[must_use]
116fn format_size_mb(bytes: u64) -> String {
117 #[expect(
118 clippy::cast_precision_loss,
119 reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
120 )]
121 let mb = bytes as f64 / (1024.0 * 1024.0);
122 format!("{mb:.1} MB")
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
132#[cfg_attr(feature = "schema", derive(JsonSchema))]
133pub struct WorkspaceDiagnostic {
134 #[serde(serialize_with = "serde_path::serialize")]
136 pub path: PathBuf,
137 #[serde(flatten)]
139 pub kind: WorkspaceDiagnosticKind,
140 pub message: String,
143}
144
145impl WorkspaceDiagnostic {
146 #[must_use]
160 pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
161 let kind = normalise_payload_paths(root, kind);
162 let message = render_message(root, &path, &kind);
163 Self {
164 path,
165 kind,
166 message,
167 }
168 }
169}
170
171fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
176 let root_str = root.display().to_string();
177 let root_alt = root_str.replace('\\', "/");
178 let normalise = |text: String| -> String {
179 let stripped = text
180 .replace(&format!("{root_str}/"), "")
181 .replace(&format!("{root_alt}/"), "");
182 stripped
183 .replace(&format!("{root_str}\\"), "")
184 .replace(&format!("{root_alt}\\"), "")
185 };
186 match kind {
187 WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
188 WorkspaceDiagnosticKind::MalformedPackageJson {
189 error: normalise(error),
190 }
191 }
192 WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
193 WorkspaceDiagnosticKind::MalformedTsconfig {
194 error: normalise(error),
195 }
196 }
197 other => other,
198 }
199}
200
201fn display_relative(root: &Path, path: &Path) -> String {
204 path.strip_prefix(root)
205 .unwrap_or(path)
206 .display()
207 .to_string()
208 .replace('\\', "/")
209}
210
211fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
212 let display = display_relative(root, path);
213 match kind {
214 WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
215 "Directory '{display}' contains package.json but is not declared as a workspace. \
216 Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
217 ),
218 WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
219 "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
220 Fix the JSON syntax or remove '{display}' from the workspaces pattern."
221 ),
222 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
223 "Glob '{pattern}' matched '{display}' but no package.json is present. \
224 Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
225 ),
226 WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
227 "tsconfig.json at '{display}' failed to parse ({error}); \
228 project references will be ignored. Fix the JSON syntax."
229 ),
230 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
231 "tsconfig.json references '{display}' but the directory does not exist. \
232 Update or remove the reference, or restore the missing directory."
233 ),
234 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes } => format!(
235 "Skipped '{display}' ({size}): exceeds the max file size limit. \
236 Its imports and exports are not analyzed. Raise the limit with \
237 --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add '{display}' \
238 to ignorePatterns.",
239 size = format_size_mb(*size_bytes)
240 ),
241 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes } => format!(
242 "Skipped '{display}' ({size}): appears to be minified generated JavaScript. \
243 Its imports and exports are not analyzed. Add '{display}' to ignorePatterns, \
244 rename it with a .min.js suffix, or use --max-file-size 0 if this file \
245 should be analyzed.",
246 size = format_size_mb(*size_bytes)
247 ),
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn skipped_large_file_diagnostic_id_and_message() {
257 let root = Path::new("/project");
258 let diag = WorkspaceDiagnostic::new(
259 root,
260 root.join("src/vendor/app.bundle.js"),
261 WorkspaceDiagnosticKind::SkippedLargeFile {
262 size_bytes: 6 * 1024 * 1024,
263 },
264 );
265 assert_eq!(diag.kind.id(), "skipped-large-file");
266 assert!(
267 diag.message.contains("src/vendor/app.bundle.js"),
268 "message names the project-relative path: {}",
269 diag.message
270 );
271 assert!(
272 diag.message.contains("6.0 MB"),
273 "message reports the size: {}",
274 diag.message
275 );
276 assert!(
277 diag.message.contains("--max-file-size"),
278 "message names the override flag: {}",
279 diag.message
280 );
281 }
282
283 #[test]
284 fn skipped_minified_file_diagnostic_id_and_message() {
285 let root = Path::new("/project");
286 let diag = WorkspaceDiagnostic::new(
287 root,
288 root.join("src/assets/index-abc123.js"),
289 WorkspaceDiagnosticKind::SkippedMinifiedFile {
290 size_bytes: 2 * 1024 * 1024,
291 },
292 );
293 assert_eq!(diag.kind.id(), "skipped-minified-file");
294 assert!(
295 diag.message.contains("src/assets/index-abc123.js"),
296 "message names the project-relative path: {}",
297 diag.message
298 );
299 assert!(
300 diag.message.contains("2.0 MB"),
301 "message reports the size: {}",
302 diag.message
303 );
304 assert!(
305 diag.message.contains("--max-file-size 0"),
306 "message names the opt-out: {}",
307 diag.message
308 );
309 }
310
311 #[test]
312 fn format_size_mb_one_decimal() {
313 assert_eq!(format_size_mb(0), "0.0 MB");
314 assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
315 assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
316 }
317
318 #[test]
319 fn undeclared_workspace_message_has_next_step() {
320 let root = Path::new("/project");
321 let diag = WorkspaceDiagnostic::new(
322 root,
323 root.join("packages/legacy"),
324 WorkspaceDiagnosticKind::UndeclaredWorkspace,
325 );
326 assert_eq!(diag.kind.id(), "undeclared-workspace");
327 assert!(diag.message.contains("packages/legacy"), "{}", diag.message);
328 assert!(
329 diag.message.contains("ignorePatterns"),
330 "next-step hint preserved: {}",
331 diag.message
332 );
333 }
334}