Skip to main content

index_core/
diagnostics.rs

1//! Telemetry-free diagnostic records.
2
3use std::fmt::{Display, Formatter};
4
5use crate::auth::Redactor;
6use crate::{DocumentQuality, DocumentQualityCategory, IndexDocument, IndexNode};
7
8/// Diagnostic severity.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum DiagnosticSeverity {
11    /// Informational diagnostic.
12    Info,
13    /// Warning diagnostic.
14    Warning,
15    /// Error diagnostic.
16    Error,
17}
18
19impl Display for DiagnosticSeverity {
20    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
21        match self {
22            Self::Info => f.write_str("info"),
23            Self::Warning => f.write_str("warning"),
24            Self::Error => f.write_str("error"),
25        }
26    }
27}
28
29/// Telemetry policy for Index diagnostics.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum TelemetryPolicy {
32    /// Diagnostics stay local and are never transmitted by core crates.
33    LocalOnly,
34}
35
36/// Boundary that produced or observed a diagnostic.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum DiagnosticSource {
39    /// Local file or stdin input.
40    LocalInput,
41    /// Network fetch boundary.
42    Network,
43    /// HTML parser boundary.
44    Parser,
45    /// Readability extraction boundary.
46    Readability,
47    /// Generic transformer fallback.
48    GenericTransformer,
49    /// Site adapter boundary.
50    Adapter,
51    /// Headless fallback boundary.
52    Headless,
53    /// Extraction and serialization boundary.
54    Extraction,
55    /// Renderer or terminal layout boundary.
56    Renderer,
57    /// Local knowledge shelf boundary.
58    Shelf,
59}
60
61impl DiagnosticSource {
62    /// Returns a stable source name.
63    #[must_use]
64    pub const fn as_str(self) -> &'static str {
65        match self {
66            Self::LocalInput => "local-input",
67            Self::Network => "network",
68            Self::Parser => "parser",
69            Self::Readability => "readability",
70            Self::GenericTransformer => "generic-transformer",
71            Self::Adapter => "adapter",
72            Self::Headless => "headless",
73            Self::Extraction => "extraction",
74            Self::Renderer => "renderer",
75            Self::Shelf => "shelf",
76        }
77    }
78}
79
80impl Display for DiagnosticSource {
81    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
82        f.write_str(self.as_str())
83    }
84}
85
86/// Transformation confidence.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum DiagnosticConfidence {
89    /// No useful content was extracted.
90    Failed,
91    /// Some content exists, but the result is likely incomplete.
92    Low,
93    /// The output is usable but may need a fallback or fixture.
94    Medium,
95}
96
97impl DiagnosticConfidence {
98    /// Returns a stable confidence label.
99    #[must_use]
100    pub const fn as_str(self) -> &'static str {
101        match self {
102            Self::Failed => "failed",
103            Self::Low => "low",
104            Self::Medium => "medium",
105        }
106    }
107}
108
109impl Display for DiagnosticConfidence {
110    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
111        f.write_str(self.as_str())
112    }
113}
114
115/// Suggested next action for a failed or low-confidence page.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum DiagnosticAction {
118    /// Retry the operation.
119    Retry,
120    /// Try a headless snapshot fallback.
121    TryHeadless,
122    /// Extract links or structured output for inspection.
123    Extract,
124    /// Create a local redacted capture artifact.
125    Capture,
126    /// Repair the current reader view locally.
127    Repair,
128    /// Add or improve a fixture.
129    AddFixture,
130    /// Search the local knowledge shelf.
131    ShelfSearch,
132}
133
134impl DiagnosticAction {
135    /// Returns user-facing action text.
136    #[must_use]
137    pub const fn label(self) -> &'static str {
138        match self {
139            Self::Retry => "retry the request",
140            Self::TryHeadless => "try headless fallback",
141            Self::Extract => "extract links or JSON for inspection",
142            Self::Capture => "capture a local redacted fixture",
143            Self::Repair => "repair the reader view locally",
144            Self::AddFixture => "add or improve a fixture",
145            Self::ShelfSearch => "search the local knowledge shelf",
146        }
147    }
148
149    /// Returns an exact command or command pattern the user can run locally.
150    #[must_use]
151    pub const fn command(self) -> &'static str {
152        match self {
153            Self::Retry => ":open <url>",
154            Self::TryHeadless => "index --headless <url>",
155            Self::Extract => ":extract links",
156            Self::Capture => ":capture preview",
157            Self::Repair => ":repair promote <region-id>",
158            Self::AddFixture => "index capture --validate <artifact-file>",
159            Self::ShelfSearch => "index shelf search <query>",
160        }
161    }
162}
163
164impl Display for DiagnosticAction {
165    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
166        f.write_str(self.label())
167    }
168}
169
170impl TelemetryPolicy {
171    /// Returns whether the policy permits automatic network transmission.
172    #[must_use]
173    pub const fn allows_network_transmission(self) -> bool {
174        match self {
175            Self::LocalOnly => false,
176        }
177    }
178}
179
180/// Likely cause for a failed or low-confidence page.
181#[derive(Debug, Clone, Copy, PartialEq, Eq)]
182pub enum FailureCause {
183    /// Network fetch or DNS/transport failed.
184    NetworkUnavailable,
185    /// Local parsing failed or produced an unusable structure.
186    ParseFailed,
187    /// A timeout stopped a fetch or rendering attempt.
188    Timeout,
189    /// No useful page content was available.
190    EmptyContent,
191    /// Index did not understand the static page shape.
192    UnsupportedPageShape,
193    /// Extraction or serialization failed.
194    ExtractionFailed,
195    /// Renderer layout or terminal output failed.
196    RendererFailed,
197    /// Local shelf storage or search failed.
198    ShelfUnavailable,
199    /// A document exists but Index has low confidence in its completeness.
200    LowConfidence,
201    /// A security or origin policy rejected the operation.
202    BlockedByPolicy,
203    /// A site adapter declined or failed and generic fallback was used.
204    AdapterMismatch,
205    /// The cause was not specific enough to classify.
206    Unknown,
207}
208
209impl FailureCause {
210    /// Returns the stable cause name.
211    #[must_use]
212    pub const fn as_str(self) -> &'static str {
213        match self {
214            Self::NetworkUnavailable => "network-unavailable",
215            Self::ParseFailed => "parse-failed",
216            Self::Timeout => "timeout",
217            Self::EmptyContent => "empty-content",
218            Self::UnsupportedPageShape => "unsupported-page-shape",
219            Self::ExtractionFailed => "extraction-failed",
220            Self::RendererFailed => "renderer-failed",
221            Self::ShelfUnavailable => "shelf-unavailable",
222            Self::LowConfidence => "low-confidence",
223            Self::BlockedByPolicy => "blocked-by-policy",
224            Self::AdapterMismatch => "adapter-mismatch",
225            Self::Unknown => "unknown",
226        }
227    }
228
229    /// Returns concise user-facing cause text.
230    #[must_use]
231    pub const fn explanation(self) -> &'static str {
232        match self {
233            Self::NetworkUnavailable => "Index could not retrieve the requested page.",
234            Self::ParseFailed => "Index could not parse the supplied content into a safe document.",
235            Self::Timeout => "The operation took longer than the configured budget.",
236            Self::EmptyContent => "The page did not expose readable semantic content.",
237            Self::UnsupportedPageShape => {
238                "The static transformer could not map this page shape confidently."
239            }
240            Self::ExtractionFailed => {
241                "Index could not serialize the document into the requested extraction format."
242            }
243            Self::RendererFailed => {
244                "Index could not lay out the document for the current terminal view."
245            }
246            Self::ShelfUnavailable => {
247                "Index could not read, write, or search the local knowledge shelf."
248            }
249            Self::LowConfidence => "Index produced a partial document and needs review or repair.",
250            Self::BlockedByPolicy => {
251                "A security, origin, sandbox, or URL policy rejected the page."
252            }
253            Self::AdapterMismatch => {
254                "A site-specific adapter did not match confidently, so fallback behavior was used."
255            }
256            Self::Unknown => "Index could not classify the failure precisely.",
257        }
258    }
259
260    /// Classifies a failure from its boundary and reason.
261    #[must_use]
262    pub fn classify(source: DiagnosticSource, reason: &str) -> Self {
263        let reason = reason.to_ascii_lowercase();
264        if reason.contains("timeout") || reason.contains("timed out") {
265            Self::Timeout
266        } else if reason.contains("schema")
267            || reason.contains("json")
268            || reason.contains("markdown")
269            || reason.contains("extract")
270        {
271            Self::ExtractionFailed
272        } else if reason.contains("render")
273            || reason.contains("layout")
274            || reason.contains("terminal")
275            || reason.contains("viewport")
276        {
277            Self::RendererFailed
278        } else if reason.contains("shelf")
279            || reason.contains("saved record")
280            || reason.contains("offline record")
281        {
282            Self::ShelfUnavailable
283        } else if reason.contains("low confidence") || reason.contains("partial document") {
284            Self::LowConfidence
285        } else if reason.contains("parse") || reason.contains("malformed") {
286            Self::ParseFailed
287        } else if reason.contains("denied")
288            || reason.contains("blocked")
289            || reason.contains("unsafe")
290            || reason.contains("policy")
291        {
292            Self::BlockedByPolicy
293        } else if reason.contains("empty")
294            || reason.contains("no readable")
295            || reason.contains("missing readable")
296            || reason.contains("did not contain readable")
297        {
298            Self::EmptyContent
299        } else {
300            match source {
301                DiagnosticSource::Network => Self::NetworkUnavailable,
302                DiagnosticSource::Adapter => Self::AdapterMismatch,
303                DiagnosticSource::Parser => Self::ParseFailed,
304                DiagnosticSource::GenericTransformer | DiagnosticSource::Readability => {
305                    Self::UnsupportedPageShape
306                }
307                DiagnosticSource::Headless => Self::Unknown,
308                DiagnosticSource::Extraction => Self::ExtractionFailed,
309                DiagnosticSource::Renderer => Self::RendererFailed,
310                DiagnosticSource::Shelf => Self::ShelfUnavailable,
311                DiagnosticSource::LocalInput => Self::Unknown,
312            }
313        }
314    }
315}
316
317impl Display for FailureCause {
318    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
319        f.write_str(self.as_str())
320    }
321}
322
323/// Structured diagnostic field.
324#[derive(Debug, Clone, PartialEq, Eq)]
325pub struct DiagnosticField {
326    /// Field key.
327    pub key: String,
328    /// Field value.
329    pub value: String,
330}
331
332impl DiagnosticField {
333    /// Creates a diagnostic field.
334    #[must_use]
335    pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
336        Self {
337            key: key.into(),
338            value: value.into(),
339        }
340    }
341}
342
343/// Local diagnostic record.
344#[derive(Debug, Clone, PartialEq, Eq)]
345pub struct DiagnosticRecord {
346    /// Diagnostic severity.
347    pub severity: DiagnosticSeverity,
348    /// Stable diagnostic code.
349    pub code: String,
350    /// Human-readable message.
351    pub message: String,
352    /// Structured details.
353    pub fields: Vec<DiagnosticField>,
354}
355
356impl DiagnosticRecord {
357    /// Creates a diagnostic record.
358    #[must_use]
359    pub fn new(
360        severity: DiagnosticSeverity,
361        code: impl Into<String>,
362        message: impl Into<String>,
363    ) -> Self {
364        Self {
365            severity,
366            code: code.into(),
367            message: message.into(),
368            fields: Vec::new(),
369        }
370    }
371
372    /// Appends one structured field.
373    #[must_use]
374    pub fn with_field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
375        self.fields.push(DiagnosticField::new(key, value));
376        self
377    }
378
379    /// Returns a redacted copy suitable for logs or support reports.
380    #[must_use]
381    pub fn redacted(&self, redactor: &Redactor) -> Self {
382        Self {
383            severity: self.severity,
384            code: self.code.clone(),
385            message: redactor.redact(&self.message),
386            fields: self
387                .fields
388                .iter()
389                .map(|field| DiagnosticField::new(&field.key, redactor.redact(&field.value)))
390                .collect(),
391        }
392    }
393
394    /// Formats the diagnostic as deterministic local text.
395    #[must_use]
396    pub fn to_local_text(&self) -> String {
397        let mut lines = vec![format!(
398            "{}[{}]: {}",
399            self.severity, self.code, self.message
400        )];
401        for field in &self.fields {
402            lines.push(format!("{}={}", field.key, field.value));
403        }
404        lines.join("\n")
405    }
406}
407
408/// Actionable diagnostic document for failed or low-confidence pages.
409#[derive(Debug, Clone, PartialEq, Eq)]
410pub struct FailureDiagnostic {
411    /// Diagnostic title.
412    pub title: String,
413    /// Boundary that produced the diagnostic.
414    pub source: DiagnosticSource,
415    /// Confidence level.
416    pub confidence: DiagnosticConfidence,
417    /// Short user-facing reason.
418    pub reason: String,
419    /// Likely cause classification.
420    pub cause: FailureCause,
421    /// Fallback that was attempted or selected.
422    pub fallback: Option<String>,
423    /// Steps or boundaries Index tried before failing.
424    pub tried: Vec<String>,
425    /// Suggested next actions.
426    pub actions: Vec<DiagnosticAction>,
427    /// Exact suggested commands.
428    pub commands: Vec<String>,
429    /// Structured diagnostic records.
430    pub records: Vec<DiagnosticRecord>,
431}
432
433impl FailureDiagnostic {
434    /// Creates an actionable diagnostic.
435    #[must_use]
436    pub fn new(
437        title: impl Into<String>,
438        source: DiagnosticSource,
439        confidence: DiagnosticConfidence,
440        reason: impl Into<String>,
441    ) -> Self {
442        let reason = reason.into();
443        let cause = FailureCause::classify(source, &reason);
444        Self {
445            title: title.into(),
446            source,
447            confidence,
448            reason,
449            cause,
450            fallback: None,
451            tried: vec![source.as_str().to_owned()],
452            actions: Vec::new(),
453            commands: Vec::new(),
454            records: Vec::new(),
455        }
456    }
457
458    /// Overrides the likely cause classification.
459    #[must_use]
460    pub fn with_likely_cause(mut self, cause: FailureCause) -> Self {
461        self.cause = cause;
462        self
463    }
464
465    /// Adds fallback information.
466    #[must_use]
467    pub fn with_fallback(mut self, fallback: impl Into<String>) -> Self {
468        self.fallback = Some(fallback.into());
469        self
470    }
471
472    /// Adds a deterministic "what Index tried" entry.
473    #[must_use]
474    pub fn with_tried(mut self, tried: impl Into<String>) -> Self {
475        self.tried.push(tried.into());
476        self
477    }
478
479    /// Adds suggested next actions.
480    #[must_use]
481    pub fn with_actions(mut self, actions: impl IntoIterator<Item = DiagnosticAction>) -> Self {
482        self.actions.extend(actions);
483        self
484    }
485
486    /// Adds one exact suggested command.
487    #[must_use]
488    pub fn with_command(mut self, command: impl Into<String>) -> Self {
489        self.commands.push(command.into());
490        self
491    }
492
493    /// Adds one structured diagnostic record.
494    #[must_use]
495    pub fn with_record(mut self, record: DiagnosticRecord) -> Self {
496        self.records.push(record);
497        self
498    }
499
500    /// Returns a redacted copy suitable for logs and fixture submissions.
501    #[must_use]
502    pub fn redacted(&self, redactor: &Redactor) -> Self {
503        Self {
504            title: redactor.redact(&self.title),
505            source: self.source,
506            confidence: self.confidence,
507            reason: redactor.redact(&self.reason),
508            cause: self.cause,
509            fallback: self.fallback.as_ref().map(|value| redactor.redact(value)),
510            tried: self
511                .tried
512                .iter()
513                .map(|value| redactor.redact(value))
514                .collect(),
515            actions: self.actions.clone(),
516            commands: self
517                .commands
518                .iter()
519                .map(|value| redactor.redact(value))
520                .collect(),
521            records: self
522                .records
523                .iter()
524                .map(|record| record.redacted(redactor))
525                .collect(),
526        }
527    }
528
529    /// Formats deterministic local diagnostic text.
530    #[must_use]
531    pub fn to_local_text(&self) -> String {
532        let mut lines = vec![
533            format!("title={}", self.title),
534            format!("source={}", self.source),
535            format!("confidence={}", self.confidence),
536            format!("reason={}", self.reason),
537            format!("cause={}", self.cause),
538        ];
539        if let Some(fallback) = &self.fallback {
540            lines.push(format!("fallback={fallback}"));
541        }
542        for tried in &self.tried {
543            lines.push(format!("tried={tried}"));
544        }
545        for action in &self.actions {
546            lines.push(format!("action={action}"));
547        }
548        for command in self.suggested_commands() {
549            lines.push(format!("command={command}"));
550        }
551        for record in &self.records {
552            lines.push(record.to_local_text());
553        }
554        lines.join("\n")
555    }
556
557    /// Returns exact suggested commands.
558    #[must_use]
559    pub fn suggested_commands(&self) -> Vec<String> {
560        if !self.commands.is_empty() {
561            return self.commands.clone();
562        }
563        self.actions
564            .iter()
565            .map(|action| action.command().to_owned())
566            .collect()
567    }
568
569    /// Converts the diagnostic into an Index document.
570    #[must_use]
571    pub fn into_document(self) -> IndexDocument {
572        let commands = self.suggested_commands();
573        let mut document = IndexDocument::titled(self.title.clone());
574        document.metadata.quality = Some(DocumentQuality::new(
575            DocumentQualityCategory::Failed,
576            0,
577            [
578                format!("source: {}", self.source),
579                format!("confidence: {}", self.confidence),
580                format!("cause: {}", self.cause),
581                self.reason.clone(),
582            ],
583        ));
584        document.push(IndexNode::Heading {
585            level: 1,
586            text: self.title.clone(),
587        });
588        document.push(IndexNode::Error(self.reason.clone()));
589        document.push(IndexNode::Heading {
590            level: 2,
591            text: "What Index tried".to_owned(),
592        });
593        let mut tried = vec![
594            format!("source: {}", self.source),
595            format!("confidence: {}", self.confidence),
596        ];
597        tried.extend(self.tried.clone());
598        if let Some(fallback) = self.fallback {
599            tried.push(format!("fallback path: {fallback}"));
600        }
601        document.push(IndexNode::List {
602            ordered: false,
603            items: tried,
604        });
605        document.push(IndexNode::Heading {
606            level: 2,
607            text: "Likely cause".to_owned(),
608        });
609        document.push(IndexNode::Paragraph(format!(
610            "{}: {}",
611            self.cause,
612            self.cause.explanation()
613        )));
614        if !commands.is_empty() {
615            document.push(IndexNode::Heading {
616                level: 2,
617                text: "Suggested commands".to_owned(),
618            });
619            document.push(IndexNode::CodeBlock {
620                language: Some("sh".to_owned()),
621                code: commands.join("\n"),
622            });
623        }
624        if !self.actions.is_empty() {
625            document.push(IndexNode::Heading {
626                level: 2,
627                text: "Suggested actions".to_owned(),
628            });
629            document.push(IndexNode::List {
630                ordered: false,
631                items: self
632                    .actions
633                    .into_iter()
634                    .map(|action| action.label().to_owned())
635                    .collect(),
636            });
637        }
638        if !self.records.is_empty() {
639            document.push(IndexNode::Heading {
640                level: 2,
641                text: "Diagnostics".to_owned(),
642            });
643            for record in self.records {
644                document.push(IndexNode::Paragraph(record.to_local_text()));
645            }
646        }
647        document
648    }
649}
650
651#[cfg(test)]
652mod tests {
653    use super::{
654        DiagnosticAction, DiagnosticConfidence, DiagnosticRecord, DiagnosticSeverity,
655        DiagnosticSource, FailureCause, FailureDiagnostic, TelemetryPolicy,
656    };
657    use crate::Redactor;
658
659    #[test]
660    fn telemetry_policy_disallows_automatic_network_transmission() {
661        assert!(!TelemetryPolicy::LocalOnly.allows_network_transmission());
662    }
663
664    #[test]
665    fn diagnostic_record_formats_stable_local_text() {
666        let record = DiagnosticRecord::new(
667            DiagnosticSeverity::Warning,
668            "INDEX-WARN",
669            "content was truncated",
670        )
671        .with_field("url", "https://example.test")
672        .with_field("bytes", "1024");
673
674        assert_eq!(
675            record.to_local_text(),
676            "warning[INDEX-WARN]: content was truncated\nurl=https://example.test\nbytes=1024"
677        );
678    }
679
680    #[test]
681    fn diagnostic_record_redacts_message_and_fields() {
682        let mut redactor = Redactor::new();
683        redactor.add_secret("secret-value");
684        let record = DiagnosticRecord::new(
685            DiagnosticSeverity::Error,
686            "INDEX-AUTH",
687            "Authorization: Bearer secret-value",
688        )
689        .with_field("cookie", "Cookie: session=secret-value")
690        .with_field("path", "/tmp/index");
691
692        let redacted = record.redacted(&redactor);
693
694        assert!(redacted.to_local_text().contains("[REDACTED]"));
695        assert!(!redacted.to_local_text().contains("secret-value"));
696        assert!(redacted.to_local_text().contains("path=/tmp/index"));
697    }
698
699    #[test]
700    fn failure_diagnostic_formats_redacts_and_renders_document() {
701        let mut redactor = Redactor::new();
702        redactor.add_secret("secret-token");
703        let diagnostic = FailureDiagnostic::new(
704            "Unsupported page",
705            DiagnosticSource::Readability,
706            DiagnosticConfidence::Low,
707            "could not understand token=secret-token",
708        )
709        .with_fallback("generic transformer")
710        .with_tried("readability extraction")
711        .with_command(":capture save unsupported.capture")
712        .with_actions([
713            DiagnosticAction::TryHeadless,
714            DiagnosticAction::Extract,
715            DiagnosticAction::Capture,
716            DiagnosticAction::AddFixture,
717        ])
718        .with_record(
719            DiagnosticRecord::new(
720                DiagnosticSeverity::Warning,
721                "INDEX-LOW-CONFIDENCE",
722                "private token secret-token",
723            )
724            .with_field("url", "https://example.test/?token=secret-token"),
725        );
726
727        let local_text = diagnostic.to_local_text();
728        assert!(local_text.contains("source=readability"));
729        assert!(local_text.contains("confidence=low"));
730        assert!(local_text.contains("cause=unsupported-page-shape"));
731        assert!(local_text.contains("tried=readability extraction"));
732        assert!(local_text.contains("action=try headless fallback"));
733        assert!(local_text.contains("command=:capture save unsupported.capture"));
734
735        let redacted = diagnostic.redacted(&redactor);
736        assert!(!redacted.to_local_text().contains("secret-token"));
737        assert!(redacted.to_local_text().contains("[REDACTED]"));
738
739        let document = redacted.into_document();
740        assert_eq!(document.title, "Unsupported page");
741        assert!(!document.is_empty());
742    }
743
744    #[test]
745    fn failure_cause_classification_is_stable() {
746        assert_eq!(
747            FailureCause::classify(DiagnosticSource::Network, "dns failed"),
748            FailureCause::NetworkUnavailable
749        );
750        assert_eq!(
751            FailureCause::classify(DiagnosticSource::Headless, "timed out after 1000ms"),
752            FailureCause::Timeout
753        );
754        assert_eq!(
755            FailureCause::classify(DiagnosticSource::LocalInput, "unsafe scheme denied"),
756            FailureCause::BlockedByPolicy
757        );
758        assert_eq!(
759            FailureCause::classify(DiagnosticSource::GenericTransformer, "no readable content"),
760            FailureCause::EmptyContent
761        );
762        assert_eq!(
763            FailureCause::classify(DiagnosticSource::Adapter, "uncertain detection"),
764            FailureCause::AdapterMismatch
765        );
766        assert_eq!(
767            FailureCause::classify(DiagnosticSource::Parser, "malformed HTML parse failed"),
768            FailureCause::ParseFailed
769        );
770        assert_eq!(
771            FailureCause::classify(DiagnosticSource::Extraction, "JSON schema failure"),
772            FailureCause::ExtractionFailed
773        );
774        assert_eq!(
775            FailureCause::classify(DiagnosticSource::Renderer, "terminal layout overflow"),
776            FailureCause::RendererFailed
777        );
778        assert_eq!(
779            FailureCause::classify(DiagnosticSource::Shelf, "shelf index missing"),
780            FailureCause::ShelfUnavailable
781        );
782        assert_eq!(
783            FailureCause::classify(
784                DiagnosticSource::Readability,
785                "low confidence partial document"
786            ),
787            FailureCause::LowConfidence
788        );
789    }
790
791    #[test]
792    fn failure_document_contains_commands_and_capture_action() {
793        let document = FailureDiagnostic::new(
794            "Failed",
795            DiagnosticSource::Network,
796            DiagnosticConfidence::Failed,
797            "could not fetch",
798        )
799        .with_actions([DiagnosticAction::Retry, DiagnosticAction::Capture])
800        .into_document();
801        let rendered = format!("{:?}", document.nodes);
802
803        assert!(rendered.contains("What Index tried"));
804        assert!(rendered.contains("Likely cause"));
805        assert!(rendered.contains("Suggested commands"));
806        assert!(rendered.contains(":capture preview"));
807    }
808
809    #[test]
810    fn failure_documents_cover_major_boundaries_with_exact_commands() {
811        for (source, reason, command, cause) in [
812            (
813                DiagnosticSource::Parser,
814                "malformed parse input",
815                ":capture preview",
816                FailureCause::ParseFailed,
817            ),
818            (
819                DiagnosticSource::Network,
820                "dns failed",
821                ":open <url>",
822                FailureCause::NetworkUnavailable,
823            ),
824            (
825                DiagnosticSource::GenericTransformer,
826                "unsupported page shape",
827                ":repair promote <region-id>",
828                FailureCause::UnsupportedPageShape,
829            ),
830            (
831                DiagnosticSource::Extraction,
832                "JSON schema failure",
833                ":extract links",
834                FailureCause::ExtractionFailed,
835            ),
836            (
837                DiagnosticSource::Renderer,
838                "terminal layout overflow",
839                ":repair promote <region-id>",
840                FailureCause::RendererFailed,
841            ),
842            (
843                DiagnosticSource::Shelf,
844                "shelf index missing",
845                "index shelf search <query>",
846                FailureCause::ShelfUnavailable,
847            ),
848        ] {
849            let document = FailureDiagnostic::new(
850                "Boundary failed",
851                source,
852                DiagnosticConfidence::Failed,
853                reason,
854            )
855            .with_actions([
856                DiagnosticAction::Retry,
857                DiagnosticAction::Extract,
858                DiagnosticAction::Capture,
859                DiagnosticAction::Repair,
860                DiagnosticAction::ShelfSearch,
861            ])
862            .into_document();
863            let rendered = format!("{:?}", document.nodes);
864
865            assert!(
866                rendered.contains(command),
867                "{source} missing command {command}"
868            );
869            assert!(
870                rendered.contains(cause.as_str()),
871                "{source} missing cause {cause}"
872            );
873            assert!(document.metadata.quality.as_ref().is_some_and(|quality| {
874                quality.category == crate::DocumentQualityCategory::Failed
875            }));
876        }
877    }
878
879    #[test]
880    fn diagnostic_enum_names_are_stable() {
881        assert_eq!(DiagnosticSource::LocalInput.as_str(), "local-input");
882        assert_eq!(DiagnosticSource::Network.to_string(), "network");
883        assert_eq!(DiagnosticSource::Parser.to_string(), "parser");
884        assert_eq!(
885            DiagnosticSource::GenericTransformer.to_string(),
886            "generic-transformer"
887        );
888        assert_eq!(DiagnosticSource::Adapter.to_string(), "adapter");
889        assert_eq!(DiagnosticSource::Headless.to_string(), "headless");
890        assert_eq!(DiagnosticSource::Extraction.to_string(), "extraction");
891        assert_eq!(DiagnosticSource::Renderer.to_string(), "renderer");
892        assert_eq!(DiagnosticSource::Shelf.to_string(), "shelf");
893        assert_eq!(DiagnosticConfidence::Failed.as_str(), "failed");
894        assert_eq!(DiagnosticConfidence::Medium.to_string(), "medium");
895        assert_eq!(DiagnosticAction::Retry.to_string(), "retry the request");
896        assert_eq!(
897            DiagnosticAction::Repair.command(),
898            ":repair promote <region-id>"
899        );
900        assert_eq!(FailureCause::Timeout.to_string(), "timeout");
901        assert_eq!(
902            FailureCause::ShelfUnavailable.to_string(),
903            "shelf-unavailable"
904        );
905    }
906}