Skip to main content

eska_presentation/
diagnostics.rs

1//! Shared presentation of project manifests, selectors and filesystem failures.
2
3use std::{io, path::Path};
4
5pub mod application;
6pub mod global;
7
8use crate::{
9    localization::{LocalizationValue, Localizer},
10    path_output,
11};
12use eska_project_core::{
13    config::{
14        InvalidMemberPathReason, InvalidSourceReason, ManifestConfigError, ProjectConfigError,
15        WorkspaceConfigError,
16    },
17    project::{
18        InvalidPathReason, ProjectPathError,
19        build::{BuildSettingsError, InvalidArtifactsDirectoryReason},
20        discovery::{ContextDiscoveryError, DiscoveryError},
21        onboarding::WorkspaceEnrollmentError,
22        selection::SelectionError,
23    },
24    workflow::PolicyError,
25};
26
27/// Keep existing consumers plain while new/import explicitly opt into terminal path links.
28#[must_use]
29pub fn present_workspace_enrollment_error(
30    error: &WorkspaceEnrollmentError,
31    localizer: &Localizer,
32) -> String {
33    present_workspace_enrollment_error_with_links(error, localizer, false)
34}
35
36/// Keep existing consumers plain while new/import explicitly opt into terminal path links.
37#[must_use]
38pub fn present_project_error(error: &DiscoveryError, localizer: &Localizer) -> String {
39    present_project_error_with_links(error, localizer, false)
40}
41
42/// Keep existing consumers plain while new/import explicitly opt into terminal path links.
43#[must_use]
44pub fn present_context_error(error: &ContextDiscoveryError, localizer: &Localizer) -> String {
45    present_context_error_with_links(error, localizer, false)
46}
47
48/// Present enrollment failures with an explicit output-stream link policy.
49#[must_use]
50pub fn present_workspace_enrollment_error_with_links(
51    error: &WorkspaceEnrollmentError,
52    localizer: &Localizer,
53    hyperlinks: bool,
54) -> String {
55    match error {
56        WorkspaceEnrollmentError::ApplicationRequired { available } => localizer.format(
57            "workspace-onboarding-application-required",
58            &[("names", LocalizationValue::Text(&available.join(", ")))],
59        ),
60        WorkspaceEnrollmentError::InvalidApplication { name } => localizer.format(
61            "workspace-onboarding-application-invalid",
62            &[("name", LocalizationValue::Text(name))],
63        ),
64        WorkspaceEnrollmentError::ConfigurationAlreadySet { application } => localizer.format(
65            "workspace-onboarding-configuration-set",
66            &[("name", LocalizationValue::Text(application))],
67        ),
68        WorkspaceEnrollmentError::Io { path, source } => {
69            io_message(localizer, path, source, hyperlinks)
70        }
71        WorkspaceEnrollmentError::Config(_)
72        | WorkspaceEnrollmentError::TomlEdit(_)
73        | WorkspaceEnrollmentError::InvalidComposition => {
74            localizer.text("workspace-onboarding-config-invalid")
75        }
76        WorkspaceEnrollmentError::NonUtf8MemberPath { path } => path_message(
77            localizer,
78            "workspace-onboarding-path-utf8",
79            path,
80            hyperlinks,
81        ),
82        WorkspaceEnrollmentError::OutsideWorkspace { workspace, member } => localizer.format(
83            "workspace-member-outside-root",
84            &[
85                (
86                    "workspace",
87                    LocalizationValue::Text(&path_output::render(workspace, hyperlinks)),
88                ),
89                (
90                    "member",
91                    LocalizationValue::Text(&path_output::render(member, hyperlinks)),
92                ),
93            ],
94        ),
95        WorkspaceEnrollmentError::DuplicateMember { path } => {
96            path_message(localizer, "workspace-member-duplicate", path, hyperlinks)
97        }
98        WorkspaceEnrollmentError::NestedMembers { first, second } => localizer.format(
99            "workspace-members-nested",
100            &[
101                (
102                    "first",
103                    LocalizationValue::Text(&path_output::render(first, hyperlinks)),
104                ),
105                (
106                    "second",
107                    LocalizationValue::Text(&path_output::render(second, hyperlinks)),
108                ),
109            ],
110        ),
111        WorkspaceEnrollmentError::DuplicateName { name } => localizer.format(
112            "workspace-member-name-duplicate",
113            &[("name", LocalizationValue::Text(name.as_str()))],
114        ),
115        WorkspaceEnrollmentError::ManifestChanged { path } => path_message(
116            localizer,
117            "workspace-onboarding-manifest-changed",
118            path,
119            hyperlinks,
120        ),
121    }
122}
123
124/// Render invalid selectors without changing stable member identifiers.
125#[must_use]
126pub fn present_selection_error(error: &SelectionError, localizer: &Localizer) -> String {
127    match error {
128        SelectionError::InvalidName(error) => localizer.format(
129            "project-selector-name-invalid",
130            &[("name", LocalizationValue::Text(error.value()))],
131        ),
132        SelectionError::DuplicateSelector { name } => localizer.format(
133            "project-selector-duplicate",
134            &[("name", LocalizationValue::Text(name.as_str()))],
135        ),
136        SelectionError::UnknownProject { name } => localizer.format(
137            "project-selector-unknown",
138            &[("name", LocalizationValue::Text(name.as_str()))],
139        ),
140        SelectionError::WorkspaceSelectorForStandalone => {
141            localizer.text("project-selector-standalone")
142        }
143        SelectionError::ConflictingSelectors => localizer.text("project-selector-conflict"),
144        SelectionError::ExplicitProjectRequired | SelectionError::SingleProjectRequired => {
145            localizer.text("project-selector-single-required")
146        }
147    }
148}
149
150/// Present project failures with stream-specific filesystem links.
151#[must_use]
152pub fn present_project_error_with_links(
153    error: &DiscoveryError,
154    localizer: &Localizer,
155    hyperlinks: bool,
156) -> String {
157    match error {
158        DiscoveryError::NotFound { start } => {
159            path_message(localizer, "project-not-found", start, hyperlinks)
160        }
161        DiscoveryError::Io { path, source } => io_message(localizer, path, source, hyperlinks),
162        DiscoveryError::StartNotDirectory { path } => {
163            path_message(localizer, "project-start-not-directory", path, hyperlinks)
164        }
165        DiscoveryError::ConfigNotFile { path } => {
166            path_message(localizer, "project-config-not-file", path, hyperlinks)
167        }
168        DiscoveryError::SourceNotDirectory { path } => {
169            path_message(localizer, "project-source-not-directory", path, hyperlinks)
170        }
171        DiscoveryError::Config { path, source } => {
172            config_message(localizer, path, source, hyperlinks)
173        }
174    }
175}
176
177/// Retain links through nested workspace and project discovery failures.
178#[must_use]
179pub fn present_context_error_with_links(
180    error: &ContextDiscoveryError,
181    localizer: &Localizer,
182    hyperlinks: bool,
183) -> String {
184    match error {
185        ContextDiscoveryError::Project(error) => {
186            present_project_error_with_links(error, localizer, hyperlinks)
187        }
188        ContextDiscoveryError::Manifest { path, source } => {
189            manifest_message(localizer, path, source, hyperlinks)
190        }
191        ContextDiscoveryError::MemberNotDirectory { path } => path_message(
192            localizer,
193            "workspace-member-not-directory",
194            path,
195            hyperlinks,
196        ),
197        ContextDiscoveryError::MemberOutsideWorkspace { workspace, member } => localizer.format(
198            "workspace-member-outside-root",
199            &[
200                (
201                    "workspace",
202                    LocalizationValue::Text(&path_output::render(workspace, hyperlinks)),
203                ),
204                (
205                    "member",
206                    LocalizationValue::Text(&path_output::render(member, hyperlinks)),
207                ),
208            ],
209        ),
210        ContextDiscoveryError::DuplicateMember { path } => {
211            path_message(localizer, "workspace-member-duplicate", path, hyperlinks)
212        }
213        ContextDiscoveryError::NestedMembers { first, second } => localizer.format(
214            "workspace-members-nested",
215            &[
216                (
217                    "first",
218                    LocalizationValue::Text(&path_output::render(first, hyperlinks)),
219                ),
220                (
221                    "second",
222                    LocalizationValue::Text(&path_output::render(second, hyperlinks)),
223                ),
224            ],
225        ),
226        ContextDiscoveryError::MemberManifestNotProject { path } => path_message(
227            localizer,
228            "workspace-member-project-required",
229            path,
230            hyperlinks,
231        ),
232        ContextDiscoveryError::MemberNameMissing { path } => path_message(
233            localizer,
234            "workspace-member-name-required",
235            path,
236            hyperlinks,
237        ),
238        ContextDiscoveryError::DuplicateName { name } => localizer.format(
239            "workspace-member-name-duplicate",
240            &[("name", LocalizationValue::Text(name.as_str()))],
241        ),
242        ContextDiscoveryError::MemberWorkflowUnsupported { path } => path_message(
243            localizer,
244            "workspace-member-workflow-unsupported",
245            path,
246            hyperlinks,
247        ),
248        ContextDiscoveryError::MemberArtifactsDirectoryUnsupported { path } => path_message(
249            localizer,
250            "workspace-member-artifacts-directory-unsupported",
251            path,
252            hyperlinks,
253        ),
254        ContextDiscoveryError::UnlistedProject { project, workspace } => localizer.format(
255            "workspace-project-unlisted",
256            &[
257                (
258                    "project",
259                    LocalizationValue::Text(&path_output::render(project, hyperlinks)),
260                ),
261                (
262                    "workspace",
263                    LocalizationValue::Text(&path_output::render(workspace, hyperlinks)),
264                ),
265            ],
266        ),
267        ContextDiscoveryError::ResolvedBuild(_) => localizer.text("workspace-build-invalid"),
268    }
269}
270
271/// Render manifest diagnostics using the caller's output-stream policy.
272fn manifest_message(
273    localizer: &Localizer,
274    path: &Path,
275    error: &ManifestConfigError,
276    hyperlinks: bool,
277) -> String {
278    match error {
279        ManifestConfigError::Io { path, source } => io_message(localizer, path, source, hyperlinks),
280        ManifestConfigError::Toml(_) => {
281            path_message(localizer, "project-config-invalid", path, hyperlinks)
282        }
283        ManifestConfigError::KindMissing => {
284            path_message(localizer, "manifest-kind-missing", path, hyperlinks)
285        }
286        ManifestConfigError::KindAmbiguous => {
287            path_message(localizer, "manifest-kind-ambiguous", path, hyperlinks)
288        }
289        ManifestConfigError::Project(error) => config_message(localizer, path, error, hyperlinks),
290        ManifestConfigError::Workspace(error) => {
291            workspace_config_message(localizer, path, error, hyperlinks)
292        }
293    }
294}
295
296/// Carry link policy through workspace configuration diagnostics.
297fn workspace_config_message(
298    localizer: &Localizer,
299    manifest_path: &Path,
300    error: &WorkspaceConfigError,
301    hyperlinks: bool,
302) -> String {
303    match error {
304        WorkspaceConfigError::InvalidAnalysis(error) => {
305            application::present(localizer, manifest_path, error, hyperlinks)
306        }
307        WorkspaceConfigError::Io { path, source } => {
308            io_message(localizer, path, source, hyperlinks)
309        }
310        WorkspaceConfigError::Toml(_) => path_message(
311            localizer,
312            "workspace-config-invalid",
313            manifest_path,
314            hyperlinks,
315        ),
316        WorkspaceConfigError::InvalidBuild(_) => path_message(
317            localizer,
318            "workspace-build-invalid",
319            manifest_path,
320            hyperlinks,
321        ),
322        WorkspaceConfigError::InvalidWorkflow(error) => {
323            config_message(localizer, manifest_path, error, hyperlinks)
324        }
325        WorkspaceConfigError::InvalidMemberPath { path, reason } => {
326            let key = match reason {
327                InvalidMemberPathReason::Empty => "workspace-member-path-empty",
328                InvalidMemberPathReason::Absolute => "workspace-member-path-relative-required",
329                InvalidMemberPathReason::ContainsParentTraversal => {
330                    "workspace-member-path-parent-traversal"
331                }
332            };
333            configured_path_message(localizer, key, path, manifest_path, hyperlinks)
334        }
335    }
336}
337
338/// Carry link policy through nested project configuration diagnostics.
339pub fn config_message(
340    localizer: &Localizer,
341    path: &Path,
342    error: &ProjectConfigError,
343    hyperlinks: bool,
344) -> String {
345    let manifest_path = path;
346    match error {
347        ProjectConfigError::InvalidAnalysis(error) => {
348            application::present(localizer, path, error, hyperlinks)
349        }
350        ProjectConfigError::InvalidName(error) => localizer.format(
351            "project-name-invalid",
352            &[
353                (
354                    "path",
355                    LocalizationValue::Text(&path_output::render(path, hyperlinks)),
356                ),
357                ("value", LocalizationValue::Text(error.value())),
358            ],
359        ),
360        ProjectConfigError::InvalidBuild(error) => match error {
361            BuildSettingsError::InvalidPlatformVersion { value } => localizer.format(
362                "project-build-version-invalid",
363                &[
364                    (
365                        "path",
366                        LocalizationValue::Text(&path_output::render(path, hyperlinks)),
367                    ),
368                    ("value", LocalizationValue::Text(value)),
369                ],
370            ),
371            BuildSettingsError::InvalidArtifactsDirectory { path, reason } => {
372                let key = match reason {
373                    InvalidArtifactsDirectoryReason::Empty => "project-build-path-empty",
374                    InvalidArtifactsDirectoryReason::Absolute => {
375                        "project-build-path-relative-required"
376                    }
377                    InvalidArtifactsDirectoryReason::ContainsParentTraversal => {
378                        "project-build-path-parent-traversal"
379                    }
380                };
381                configured_path_message(localizer, key, path, manifest_path, hyperlinks)
382            }
383        },
384        ProjectConfigError::InvalidWorkflow(error) => {
385            workflow_message(localizer, path, error, hyperlinks)
386        }
387        ProjectConfigError::UnknownWorkflow { value } => value_message(
388            localizer,
389            "project-workflow-unknown",
390            path,
391            value,
392            hyperlinks,
393        ),
394        ProjectConfigError::Io { path, source } => io_message(localizer, path, source, hyperlinks),
395        // Parser/OS diagnostics are not translated. Do not leak their English
396        // Display output into localized project diagnostics.
397        ProjectConfigError::Toml(_) => {
398            path_message(localizer, "project-config-invalid", path, hyperlinks)
399        }
400        ProjectConfigError::UnknownProjectType { value } => {
401            value_message(localizer, "project-type-unknown", path, value, hyperlinks)
402        }
403        ProjectConfigError::UnknownSourceFormat { value } => {
404            value_message(localizer, "project-format-unknown", path, value, hyperlinks)
405        }
406        ProjectConfigError::InvalidSource { path, reason } => {
407            let key = match reason {
408                InvalidSourceReason::Empty => "project-path-empty",
409                InvalidSourceReason::Absolute => "project-path-relative-required",
410                InvalidSourceReason::ContainsParentTraversal => "project-path-parent-traversal",
411            };
412            configured_path_message(localizer, key, path, manifest_path, hyperlinks)
413        }
414        ProjectConfigError::ProjectPath(error) => match error {
415            ProjectPathError::InvalidPath { path, reason, .. } => {
416                let key = match reason {
417                    InvalidPathReason::NotAbsolute => "project-path-absolute-required",
418                    InvalidPathReason::ContainsParentTraversal => "project-path-parent-traversal",
419                };
420                configured_path_message(localizer, key, path, manifest_path, hyperlinks)
421            }
422            ProjectPathError::SourceOutsideRoot { root, source } => localizer.format(
423                "project-source-outside-root",
424                &[
425                    (
426                        "root",
427                        LocalizationValue::Text(&path_output::render(root, hyperlinks)),
428                    ),
429                    (
430                        "source",
431                        LocalizationValue::Text(&path_output::render(source, hyperlinks)),
432                    ),
433                ],
434            ),
435        },
436    }
437}
438
439/// Render a workflow failure against its configuration path.
440fn workflow_message(
441    localizer: &Localizer,
442    path: &Path,
443    error: &PolicyError,
444    hyperlinks: bool,
445) -> String {
446    match error {
447        PolicyError::InvalidValue { field, value } => localizer.format(
448            "project-workflow-value-invalid",
449            &[
450                (
451                    "path",
452                    LocalizationValue::Text(&path_output::render(path, hyperlinks)),
453                ),
454                ("field", LocalizationValue::Text(field.as_str())),
455                ("value", LocalizationValue::Text(value)),
456            ],
457        ),
458        PolicyError::MissingField { field } => localizer.format(
459            "project-workflow-field-missing",
460            &[
461                (
462                    "path",
463                    LocalizationValue::Text(&path_output::render(path, hyperlinks)),
464                ),
465                ("field", LocalizationValue::Text(field.as_str())),
466            ],
467        ),
468        error => path_message(
469            localizer,
470            match error {
471                PolicyError::ExtendsRequiresCustom => "project-workflow-custom-required",
472                PolicyError::CustomBase => "project-workflow-custom-base",
473                PolicyError::PublishRequired => "project-workflow-publish-required",
474                PolicyError::IntegrationRequiredForDeletion => {
475                    "project-workflow-integration-required"
476                }
477                _ => "project-workflow-invalid",
478            },
479            path,
480            hyperlinks,
481        ),
482    }
483}
484
485/// Localize OS failure kinds without losing the affected path.
486#[must_use]
487pub fn io_message(
488    localizer: &Localizer,
489    path: &Path,
490    error: &io::Error,
491    hyperlinks: bool,
492) -> String {
493    let reason = localizer.text(match error.kind() {
494        io::ErrorKind::NotFound => "project-io-not-found",
495        io::ErrorKind::PermissionDenied => "project-io-permission-denied",
496        io::ErrorKind::InvalidData => "project-io-invalid-data",
497        io::ErrorKind::NotADirectory => "project-io-not-directory",
498        _ => "project-io-other",
499    });
500    localizer.format(
501        "project-io-error",
502        &[
503            (
504                "path",
505                LocalizationValue::Text(&path_output::render(path, hyperlinks)),
506            ),
507            ("reason", LocalizationValue::Text(&reason)),
508        ],
509    )
510}
511
512/// Link the complete path argument independently of surrounding punctuation.
513#[must_use]
514pub fn path_message(localizer: &Localizer, key: &str, path: &Path, hyperlinks: bool) -> String {
515    localizer.format(
516        key,
517        &[(
518            "path",
519            LocalizationValue::Text(&path_output::render(path, hyperlinks)),
520        )],
521    )
522}
523
524/// Resolve a config value against its manifest directory, retaining the original label.
525fn configured_path_message(
526    localizer: &Localizer,
527    key: &str,
528    path: &Path,
529    manifest: &Path,
530    hyperlinks: bool,
531) -> String {
532    let root = manifest.parent().unwrap_or_else(|| Path::new("."));
533    localizer.format(
534        key,
535        &[(
536            "path",
537            LocalizationValue::Text(&path_output::render_link(
538                path,
539                &root.join(path),
540                hyperlinks,
541            )),
542        )],
543    )
544}
545
546/// Keep invalid configuration values separate from the linked manifest path.
547fn value_message(
548    localizer: &Localizer,
549    key: &str,
550    path: &Path,
551    value: &str,
552    hyperlinks: bool,
553) -> String {
554    localizer.format(
555        key,
556        &[
557            (
558                "path",
559                LocalizationValue::Text(&path_output::render(path, hyperlinks)),
560            ),
561            ("value", LocalizationValue::Text(value)),
562        ],
563    )
564}