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 MalformedPnpmWorkspaceYaml {
63 error: String,
65 },
66 SkippedLargeFile {
74 size_bytes: u64,
76 },
77 SkippedMinifiedFile {
83 size_bytes: u64,
85 },
86 SourceReadFailure {
90 error: String,
92 },
93}
94
95impl WorkspaceDiagnosticKind {
96 #[must_use]
98 pub const fn id(&self) -> &'static str {
99 match self {
100 Self::UndeclaredWorkspace => "undeclared-workspace",
101 Self::MalformedPackageJson { .. } => "malformed-package-json",
102 Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
103 Self::MalformedTsconfig { .. } => "malformed-tsconfig",
104 Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
105 Self::MalformedPnpmWorkspaceYaml { .. } => "malformed-pnpm-workspace-yaml",
106 Self::SkippedLargeFile { .. } => "skipped-large-file",
107 Self::SkippedMinifiedFile { .. } => "skipped-minified-file",
108 Self::SourceReadFailure { .. } => "source-read-failure",
109 }
110 }
111
112 #[must_use]
121 pub const fn is_source_discovery(&self) -> bool {
122 matches!(
123 self,
124 Self::SkippedLargeFile { .. }
125 | Self::SkippedMinifiedFile { .. }
126 | Self::SourceReadFailure { .. }
127 )
128 }
129}
130
131#[must_use]
134fn format_size_mb(bytes: u64) -> String {
135 #[expect(
136 clippy::cast_precision_loss,
137 reason = "display-only size figure; precision loss past 2^53 bytes is irrelevant"
138 )]
139 let mb = bytes as f64 / (1024.0 * 1024.0);
140 format!("{mb:.1} MB")
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
150#[cfg_attr(feature = "schema", derive(JsonSchema))]
151pub struct WorkspaceDiagnostic {
152 #[serde(serialize_with = "serde_path::serialize")]
154 pub path: PathBuf,
155 #[serde(flatten)]
157 pub kind: WorkspaceDiagnosticKind,
158 pub message: String,
161}
162
163impl WorkspaceDiagnostic {
164 #[must_use]
178 pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
179 let kind = normalise_payload_paths(root, kind);
180 let message = render_message(root, &path, &kind);
181 Self {
182 path,
183 kind,
184 message,
185 }
186 }
187}
188
189fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
194 let root_str = root.display().to_string();
195 let root_alt = root_str.replace('\\', "/");
196 let normalise = |text: String| -> String {
197 let stripped = text
198 .replace(&format!("{root_str}/"), "")
199 .replace(&format!("{root_alt}/"), "");
200 stripped
201 .replace(&format!("{root_str}\\"), "")
202 .replace(&format!("{root_alt}\\"), "")
203 };
204 match kind {
205 WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
206 WorkspaceDiagnosticKind::MalformedPackageJson {
207 error: normalise(error),
208 }
209 }
210 WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
211 WorkspaceDiagnosticKind::MalformedTsconfig {
212 error: normalise(error),
213 }
214 }
215 WorkspaceDiagnosticKind::SourceReadFailure { error } => {
216 WorkspaceDiagnosticKind::SourceReadFailure {
217 error: normalise(error),
218 }
219 }
220 other => other,
221 }
222}
223
224fn display_relative(root: &Path, path: &Path) -> String {
227 path.strip_prefix(root)
228 .unwrap_or(path)
229 .display()
230 .to_string()
231 .replace('\\', "/")
232}
233
234fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
235 let display = display_relative(root, path);
236 match kind {
237 WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
238 "Directory '{display}' contains package.json but is not declared as a workspace. \
239 Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
240 ),
241 WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
242 "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
243 Fix the JSON syntax or remove '{display}' from the workspaces pattern."
244 ),
245 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
246 "Glob '{pattern}' matched '{display}' but no package.json is present. \
247 Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
248 ),
249 WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
250 "tsconfig.json at '{display}' failed to parse ({error}); \
251 project references will be ignored. Fix the JSON syntax."
252 ),
253 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
254 "tsconfig.json references '{display}' but the directory does not exist. \
255 Update or remove the reference, or restore the missing directory."
256 ),
257 WorkspaceDiagnosticKind::MalformedPnpmWorkspaceYaml { error } => format!(
258 "'{display}' failed to parse ({error}); catalog and override entries \
259 will be ignored. Fix the YAML syntax."
260 ),
261 WorkspaceDiagnosticKind::SkippedLargeFile { size_bytes } => format!(
262 "Skipped '{display}' ({size}): exceeds the max file size limit. \
263 Its imports and exports are not analyzed. Raise the limit with \
264 --max-file-size <MB> (or FALLOW_MAX_FILE_SIZE), or add '{display}' \
265 to ignorePatterns.",
266 size = format_size_mb(*size_bytes)
267 ),
268 WorkspaceDiagnosticKind::SkippedMinifiedFile { size_bytes } => format!(
269 "Skipped '{display}' ({size}): appears to be minified generated JavaScript. \
270 Its imports and exports are not analyzed. Add '{display}' to ignorePatterns, \
271 rename it with a .min.js suffix, or use --max-file-size 0 if this file \
272 should be analyzed.",
273 size = format_size_mb(*size_bytes)
274 ),
275 WorkspaceDiagnosticKind::SourceReadFailure { error } => format!(
276 "Could not read source '{display}' ({error}). Restore the file or its read permissions, \
277 ensure it contains valid UTF-8 text, or add '{display}' to ignorePatterns."
278 ),
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[test]
287 fn skipped_large_file_diagnostic_id_and_message() {
288 let root = Path::new("/project");
289 let diag = WorkspaceDiagnostic::new(
290 root,
291 root.join("src/vendor/app.bundle.js"),
292 WorkspaceDiagnosticKind::SkippedLargeFile {
293 size_bytes: 6 * 1024 * 1024,
294 },
295 );
296 assert_eq!(diag.kind.id(), "skipped-large-file");
297 assert!(
298 diag.message.contains("src/vendor/app.bundle.js"),
299 "message names the project-relative path: {}",
300 diag.message
301 );
302 assert!(
303 diag.message.contains("6.0 MB"),
304 "message reports the size: {}",
305 diag.message
306 );
307 assert!(
308 diag.message.contains("--max-file-size"),
309 "message names the override flag: {}",
310 diag.message
311 );
312 }
313
314 #[test]
315 fn skipped_minified_file_diagnostic_id_and_message() {
316 let root = Path::new("/project");
317 let diag = WorkspaceDiagnostic::new(
318 root,
319 root.join("src/assets/index-abc123.js"),
320 WorkspaceDiagnosticKind::SkippedMinifiedFile {
321 size_bytes: 2 * 1024 * 1024,
322 },
323 );
324 assert_eq!(diag.kind.id(), "skipped-minified-file");
325 assert!(
326 diag.message.contains("src/assets/index-abc123.js"),
327 "message names the project-relative path: {}",
328 diag.message
329 );
330 assert!(
331 diag.message.contains("2.0 MB"),
332 "message reports the size: {}",
333 diag.message
334 );
335 assert!(
336 diag.message.contains("--max-file-size 0"),
337 "message names the opt-out: {}",
338 diag.message
339 );
340 }
341
342 #[test]
343 fn source_read_failure_serializes_typed_error_payload() {
344 let root = Path::new("/project");
345 let diagnostic = WorkspaceDiagnostic::new(
346 root,
347 root.join("src/removed.ts"),
348 WorkspaceDiagnosticKind::SourceReadFailure {
349 error: "No such file or directory".to_string(),
350 },
351 );
352
353 let json = serde_json::to_value(&diagnostic).expect("diagnostic serializes");
354 assert_eq!(json["kind"], "source-read-failure");
355 assert_eq!(
356 json["path"],
357 root.join("src/removed.ts")
358 .display()
359 .to_string()
360 .replace('\\', "/")
361 );
362 assert_eq!(json["error"], "No such file or directory");
363 assert!(
364 json["message"]
365 .as_str()
366 .is_some_and(|message| message.contains("src/removed.ts"))
367 );
368 }
369
370 #[cfg(feature = "schema")]
371 #[test]
372 fn workspace_diagnostic_schema_includes_source_read_failure() {
373 let schema = schemars::schema_for!(WorkspaceDiagnostic);
374 let json = serde_json::to_string(&schema).expect("schema serializes");
375 assert!(json.contains("source-read-failure"));
376 assert!(json.contains("error"));
377 }
378
379 #[test]
380 fn format_size_mb_one_decimal() {
381 assert_eq!(format_size_mb(0), "0.0 MB");
382 assert_eq!(format_size_mb(5 * 1024 * 1024), "5.0 MB");
383 assert_eq!(format_size_mb(1024 * 1024 + 512 * 1024), "1.5 MB");
384 }
385
386 #[test]
387 fn undeclared_workspace_message_has_next_step() {
388 let root = Path::new("/project");
389 let diag = WorkspaceDiagnostic::new(
390 root,
391 root.join("packages/legacy"),
392 WorkspaceDiagnosticKind::UndeclaredWorkspace,
393 );
394 assert_eq!(diag.kind.id(), "undeclared-workspace");
395 assert!(diag.message.contains("packages/legacy"), "{}", diag.message);
396 assert!(
397 diag.message.contains("ignorePatterns"),
398 "next-step hint preserved: {}",
399 diag.message
400 );
401 }
402}