1use std::path::{Path, PathBuf};
15
16#[cfg(feature = "schema")]
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19
20use crate::serde_path;
21
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
28#[cfg_attr(feature = "schema", derive(JsonSchema))]
29#[serde(tag = "kind", rename_all = "kebab-case")]
30pub enum WorkspaceDiagnosticKind {
31 UndeclaredWorkspace,
36 MalformedPackageJson {
39 error: String,
41 },
42 GlobMatchedNoPackageJson {
46 pattern: String,
48 },
49 MalformedTsconfig {
52 error: String,
54 },
55 TsconfigReferenceDirMissing,
58 SkippedLargeFile {
66 size_bytes: u64,
68 },
69 SkippedMinifiedFile {
75 size_bytes: u64,
77 },
78 SourceReadFailure {
82 error: String,
84 },
85}
86
87impl WorkspaceDiagnosticKind {
88 #[must_use]
90 pub const fn id(&self) -> &'static str {
91 match self {
92 Self::UndeclaredWorkspace => "undeclared-workspace",
93 Self::MalformedPackageJson { .. } => "malformed-package-json",
94 Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
95 Self::MalformedTsconfig { .. } => "malformed-tsconfig",
96 Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
97 Self::SkippedLargeFile { .. } => "skipped-large-file",
98 Self::SkippedMinifiedFile { .. } => "skipped-minified-file",
99 Self::SourceReadFailure { .. } => "source-read-failure",
100 }
101 }
102
103 #[must_use]
112 pub const fn is_source_discovery(&self) -> bool {
113 matches!(
114 self,
115 Self::SkippedLargeFile { .. }
116 | Self::SkippedMinifiedFile { .. }
117 | Self::SourceReadFailure { .. }
118 )
119 }
120}
121
122#[must_use]
125fn format_size_mb(bytes: u64) -> String {
126 #[expect(
127 clippy::cast_precision_loss,
128 reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
129 )]
130 let mb = bytes as f64 / (1024.0 * 1024.0);
131 format!("{mb:.1} MB")
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
141#[cfg_attr(feature = "schema", derive(JsonSchema))]
142pub struct WorkspaceDiagnostic {
143 #[serde(serialize_with = "serde_path::serialize")]
145 pub path: PathBuf,
146 #[serde(flatten)]
148 pub kind: WorkspaceDiagnosticKind,
149 pub message: String,
152}
153
154impl WorkspaceDiagnostic {
155 #[must_use]
169 pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
170 let kind = normalise_payload_paths(root, kind);
171 let message = render_message(root, &path, &kind);
172 Self {
173 path,
174 kind,
175 message,
176 }
177 }
178}
179
180fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
185 let root_str = root.display().to_string();
186 let root_alt = root_str.replace('\\', "/");
187 let normalise = |text: String| -> String {
188 let stripped = text
189 .replace(&format!("{root_str}/"), "")
190 .replace(&format!("{root_alt}/"), "");
191 stripped
192 .replace(&format!("{root_str}\\"), "")
193 .replace(&format!("{root_alt}\\"), "")
194 };
195 match kind {
196 WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
197 WorkspaceDiagnosticKind::MalformedPackageJson {
198 error: normalise(error),
199 }
200 }
201 WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
202 WorkspaceDiagnosticKind::MalformedTsconfig {
203 error: normalise(error),
204 }
205 }
206 WorkspaceDiagnosticKind::SourceReadFailure { error } => {
207 WorkspaceDiagnosticKind::SourceReadFailure {
208 error: normalise(error),
209 }
210 }
211 other => other,
212 }
213}
214
215fn display_relative(root: &Path, path: &Path) -> String {
218 path.strip_prefix(root)
219 .unwrap_or(path)
220 .display()
221 .to_string()
222 .replace('\\', "/")
223}
224
225fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
226 let display = display_relative(root, path);
227 match kind {
228 WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
229 "Directory '{display}' contains package.json but is not declared as a workspace. \
230 Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
231 ),
232 WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
233 "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
234 Fix the JSON syntax or remove '{display}' from the workspaces pattern."
235 ),
236 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
237 "Glob '{pattern}' matched '{display}' but no package.json is present. \
238 Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
239 ),
240 WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
241 "tsconfig.json at '{display}' failed to parse ({error}); \
242 project references will be ignored. Fix the JSON syntax."
243 ),
244 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
245 "tsconfig.json references '{display}' but the directory does not exist. \
246 Update or remove the reference, or restore the missing directory."
247 ),
248 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes } => format!(
249 "Skipped '{display}' ({size}): exceeds the max file size limit. \
250 Its imports and exports are not analyzed. Raise the limit with \
251 --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add '{display}' \
252 to ignorePatterns.",
253 size = format_size_mb(*size_bytes)
254 ),
255 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes } => format!(
256 "Skipped '{display}' ({size}): appears to be minified generated JavaScript. \
257 Its imports and exports are not analyzed. Add '{display}' to ignorePatterns, \
258 rename it with a .min.js suffix, or use --max-file-size 0 if this file \
259 should be analyzed.",
260 size = format_size_mb(*size_bytes)
261 ),
262 WorkspaceDiagnosticKind::SourceReadFailure { error } => format!(
263 "Could not read source '{display}' ({error}). Restore the file or its read permissions, \
264 ensure it contains valid UTF-8 text, or add '{display}' to ignorePatterns."
265 ),
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
274 fn skipped_large_file_diagnostic_id_and_message() {
275 let root = Path::new("/project");
276 let diag = WorkspaceDiagnostic::new(
277 root,
278 root.join("src/vendor/app.bundle.js"),
279 WorkspaceDiagnosticKind::SkippedLargeFile {
280 size_bytes: 6 * 1024 * 1024,
281 },
282 );
283 assert_eq!(diag.kind.id(), "skipped-large-file");
284 assert!(
285 diag.message.contains("src/vendor/app.bundle.js"),
286 "message names the project-relative path: {}",
287 diag.message
288 );
289 assert!(
290 diag.message.contains("6.0 MB"),
291 "message reports the size: {}",
292 diag.message
293 );
294 assert!(
295 diag.message.contains("--max-file-size"),
296 "message names the override flag: {}",
297 diag.message
298 );
299 }
300
301 #[test]
302 fn skipped_minified_file_diagnostic_id_and_message() {
303 let root = Path::new("/project");
304 let diag = WorkspaceDiagnostic::new(
305 root,
306 root.join("src/assets/index-abc123.js"),
307 WorkspaceDiagnosticKind::SkippedMinifiedFile {
308 size_bytes: 2 * 1024 * 1024,
309 },
310 );
311 assert_eq!(diag.kind.id(), "skipped-minified-file");
312 assert!(
313 diag.message.contains("src/assets/index-abc123.js"),
314 "message names the project-relative path: {}",
315 diag.message
316 );
317 assert!(
318 diag.message.contains("2.0 MB"),
319 "message reports the size: {}",
320 diag.message
321 );
322 assert!(
323 diag.message.contains("--max-file-size 0"),
324 "message names the opt-out: {}",
325 diag.message
326 );
327 }
328
329 #[test]
330 fn source_read_failure_serializes_typed_error_payload() {
331 let root = Path::new("/project");
332 let diagnostic = WorkspaceDiagnostic::new(
333 root,
334 root.join("src/removed.ts"),
335 WorkspaceDiagnosticKind::SourceReadFailure {
336 error: "No such file or directory".to_string(),
337 },
338 );
339
340 let json = serde_json::to_value(&diagnostic).expect("diagnostic serializes");
341 assert_eq!(json["kind"], "source-read-failure");
342 assert_eq!(
343 json["path"],
344 root.join("src/removed.ts")
345 .display()
346 .to_string()
347 .replace('\\', "/")
348 );
349 assert_eq!(json["error"], "No such file or directory");
350 assert!(
351 json["message"]
352 .as_str()
353 .is_some_and(|message| message.contains("src/removed.ts"))
354 );
355 }
356
357 #[cfg(feature = "schema")]
358 #[test]
359 fn workspace_diagnostic_schema_includes_source_read_failure() {
360 let schema = schemars::schema_for!(WorkspaceDiagnostic);
361 let json = serde_json::to_string(&schema).expect("schema serializes");
362 assert!(json.contains("source-read-failure"));
363 assert!(json.contains("error"));
364 }
365
366 #[test]
367 fn format_size_mb_one_decimal() {
368 assert_eq!(format_size_mb(0), "0.0 MB");
369 assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
370 assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
371 }
372
373 #[test]
374 fn undeclared_workspace_message_has_next_step() {
375 let root = Path::new("/project");
376 let diag = WorkspaceDiagnostic::new(
377 root,
378 root.join("packages/legacy"),
379 WorkspaceDiagnosticKind::UndeclaredWorkspace,
380 );
381 assert_eq!(diag.kind.id(), "undeclared-workspace");
382 assert!(diag.message.contains("packages/legacy"), "{}", diag.message);
383 assert!(
384 diag.message.contains("ignorePatterns"),
385 "next-step hint preserved: {}",
386 diag.message
387 );
388 }
389}