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