use std::fmt::{Display, Formatter};
use crate::auth::Redactor;
use crate::{DocumentQuality, DocumentQualityCategory, IndexDocument, IndexNode};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticSeverity {
Info,
Warning,
Error,
}
impl Display for DiagnosticSeverity {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::Info => f.write_str("info"),
Self::Warning => f.write_str("warning"),
Self::Error => f.write_str("error"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TelemetryPolicy {
LocalOnly,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticSource {
LocalInput,
Network,
Parser,
Readability,
GenericTransformer,
Adapter,
Headless,
Extraction,
Renderer,
Shelf,
}
impl DiagnosticSource {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::LocalInput => "local-input",
Self::Network => "network",
Self::Parser => "parser",
Self::Readability => "readability",
Self::GenericTransformer => "generic-transformer",
Self::Adapter => "adapter",
Self::Headless => "headless",
Self::Extraction => "extraction",
Self::Renderer => "renderer",
Self::Shelf => "shelf",
}
}
}
impl Display for DiagnosticSource {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticConfidence {
Failed,
Low,
Medium,
}
impl DiagnosticConfidence {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::Failed => "failed",
Self::Low => "low",
Self::Medium => "medium",
}
}
}
impl Display for DiagnosticConfidence {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticAction {
Retry,
TryHeadless,
Extract,
Capture,
Repair,
AddFixture,
ShelfSearch,
}
impl DiagnosticAction {
#[must_use]
pub const fn label(self) -> &'static str {
match self {
Self::Retry => "retry the request",
Self::TryHeadless => "try headless fallback",
Self::Extract => "extract links or JSON for inspection",
Self::Capture => "capture a local redacted fixture",
Self::Repair => "repair the reader view locally",
Self::AddFixture => "add or improve a fixture",
Self::ShelfSearch => "search the local knowledge shelf",
}
}
#[must_use]
pub const fn command(self) -> &'static str {
match self {
Self::Retry => ":open <url>",
Self::TryHeadless => "index --headless <url>",
Self::Extract => ":extract links",
Self::Capture => ":capture preview",
Self::Repair => ":repair promote <region-id>",
Self::AddFixture => "index capture --validate <artifact-file>",
Self::ShelfSearch => "index shelf search <query>",
}
}
}
impl Display for DiagnosticAction {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
impl TelemetryPolicy {
#[must_use]
pub const fn allows_network_transmission(self) -> bool {
match self {
Self::LocalOnly => false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FailureCause {
NetworkUnavailable,
ParseFailed,
Timeout,
EmptyContent,
UnsupportedPageShape,
ExtractionFailed,
RendererFailed,
ShelfUnavailable,
LowConfidence,
BlockedByPolicy,
AdapterMismatch,
Unknown,
}
impl FailureCause {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::NetworkUnavailable => "network-unavailable",
Self::ParseFailed => "parse-failed",
Self::Timeout => "timeout",
Self::EmptyContent => "empty-content",
Self::UnsupportedPageShape => "unsupported-page-shape",
Self::ExtractionFailed => "extraction-failed",
Self::RendererFailed => "renderer-failed",
Self::ShelfUnavailable => "shelf-unavailable",
Self::LowConfidence => "low-confidence",
Self::BlockedByPolicy => "blocked-by-policy",
Self::AdapterMismatch => "adapter-mismatch",
Self::Unknown => "unknown",
}
}
#[must_use]
pub const fn explanation(self) -> &'static str {
match self {
Self::NetworkUnavailable => "Index could not retrieve the requested page.",
Self::ParseFailed => "Index could not parse the supplied content into a safe document.",
Self::Timeout => "The operation took longer than the configured budget.",
Self::EmptyContent => "The page did not expose readable semantic content.",
Self::UnsupportedPageShape => {
"The static transformer could not map this page shape confidently."
}
Self::ExtractionFailed => {
"Index could not serialize the document into the requested extraction format."
}
Self::RendererFailed => {
"Index could not lay out the document for the current terminal view."
}
Self::ShelfUnavailable => {
"Index could not read, write, or search the local knowledge shelf."
}
Self::LowConfidence => "Index produced a partial document and needs review or repair.",
Self::BlockedByPolicy => {
"A security, origin, sandbox, or URL policy rejected the page."
}
Self::AdapterMismatch => {
"A site-specific adapter did not match confidently, so fallback behavior was used."
}
Self::Unknown => "Index could not classify the failure precisely.",
}
}
#[must_use]
pub fn classify(source: DiagnosticSource, reason: &str) -> Self {
let reason = reason.to_ascii_lowercase();
if reason.contains("timeout") || reason.contains("timed out") {
Self::Timeout
} else if reason.contains("schema")
|| reason.contains("json")
|| reason.contains("markdown")
|| reason.contains("extract")
{
Self::ExtractionFailed
} else if reason.contains("render")
|| reason.contains("layout")
|| reason.contains("terminal")
|| reason.contains("viewport")
{
Self::RendererFailed
} else if reason.contains("shelf")
|| reason.contains("saved record")
|| reason.contains("offline record")
{
Self::ShelfUnavailable
} else if reason.contains("low confidence") || reason.contains("partial document") {
Self::LowConfidence
} else if reason.contains("parse") || reason.contains("malformed") {
Self::ParseFailed
} else if reason.contains("denied")
|| reason.contains("blocked")
|| reason.contains("unsafe")
|| reason.contains("policy")
{
Self::BlockedByPolicy
} else if reason.contains("empty")
|| reason.contains("no readable")
|| reason.contains("missing readable")
|| reason.contains("did not contain readable")
{
Self::EmptyContent
} else {
match source {
DiagnosticSource::Network => Self::NetworkUnavailable,
DiagnosticSource::Adapter => Self::AdapterMismatch,
DiagnosticSource::Parser => Self::ParseFailed,
DiagnosticSource::GenericTransformer | DiagnosticSource::Readability => {
Self::UnsupportedPageShape
}
DiagnosticSource::Headless => Self::Unknown,
DiagnosticSource::Extraction => Self::ExtractionFailed,
DiagnosticSource::Renderer => Self::RendererFailed,
DiagnosticSource::Shelf => Self::ShelfUnavailable,
DiagnosticSource::LocalInput => Self::Unknown,
}
}
}
}
impl Display for FailureCause {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiagnosticField {
pub key: String,
pub value: String,
}
impl DiagnosticField {
#[must_use]
pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
Self {
key: key.into(),
value: value.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiagnosticRecord {
pub severity: DiagnosticSeverity,
pub code: String,
pub message: String,
pub fields: Vec<DiagnosticField>,
}
impl DiagnosticRecord {
#[must_use]
pub fn new(
severity: DiagnosticSeverity,
code: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
severity,
code: code.into(),
message: message.into(),
fields: Vec::new(),
}
}
#[must_use]
pub fn with_field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.fields.push(DiagnosticField::new(key, value));
self
}
#[must_use]
pub fn redacted(&self, redactor: &Redactor) -> Self {
Self {
severity: self.severity,
code: self.code.clone(),
message: redactor.redact(&self.message),
fields: self
.fields
.iter()
.map(|field| DiagnosticField::new(&field.key, redactor.redact(&field.value)))
.collect(),
}
}
#[must_use]
pub fn to_local_text(&self) -> String {
let mut lines = vec![format!(
"{}[{}]: {}",
self.severity, self.code, self.message
)];
for field in &self.fields {
lines.push(format!("{}={}", field.key, field.value));
}
lines.join("\n")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FailureDiagnostic {
pub title: String,
pub source: DiagnosticSource,
pub confidence: DiagnosticConfidence,
pub reason: String,
pub cause: FailureCause,
pub fallback: Option<String>,
pub tried: Vec<String>,
pub actions: Vec<DiagnosticAction>,
pub commands: Vec<String>,
pub records: Vec<DiagnosticRecord>,
}
impl FailureDiagnostic {
#[must_use]
pub fn new(
title: impl Into<String>,
source: DiagnosticSource,
confidence: DiagnosticConfidence,
reason: impl Into<String>,
) -> Self {
let reason = reason.into();
let cause = FailureCause::classify(source, &reason);
Self {
title: title.into(),
source,
confidence,
reason,
cause,
fallback: None,
tried: vec![source.as_str().to_owned()],
actions: Vec::new(),
commands: Vec::new(),
records: Vec::new(),
}
}
#[must_use]
pub fn with_likely_cause(mut self, cause: FailureCause) -> Self {
self.cause = cause;
self
}
#[must_use]
pub fn with_fallback(mut self, fallback: impl Into<String>) -> Self {
self.fallback = Some(fallback.into());
self
}
#[must_use]
pub fn with_tried(mut self, tried: impl Into<String>) -> Self {
self.tried.push(tried.into());
self
}
#[must_use]
pub fn with_actions(mut self, actions: impl IntoIterator<Item = DiagnosticAction>) -> Self {
self.actions.extend(actions);
self
}
#[must_use]
pub fn with_command(mut self, command: impl Into<String>) -> Self {
self.commands.push(command.into());
self
}
#[must_use]
pub fn with_record(mut self, record: DiagnosticRecord) -> Self {
self.records.push(record);
self
}
#[must_use]
pub fn redacted(&self, redactor: &Redactor) -> Self {
Self {
title: redactor.redact(&self.title),
source: self.source,
confidence: self.confidence,
reason: redactor.redact(&self.reason),
cause: self.cause,
fallback: self.fallback.as_ref().map(|value| redactor.redact(value)),
tried: self
.tried
.iter()
.map(|value| redactor.redact(value))
.collect(),
actions: self.actions.clone(),
commands: self
.commands
.iter()
.map(|value| redactor.redact(value))
.collect(),
records: self
.records
.iter()
.map(|record| record.redacted(redactor))
.collect(),
}
}
#[must_use]
pub fn to_local_text(&self) -> String {
let mut lines = vec![
format!("title={}", self.title),
format!("source={}", self.source),
format!("confidence={}", self.confidence),
format!("reason={}", self.reason),
format!("cause={}", self.cause),
];
if let Some(fallback) = &self.fallback {
lines.push(format!("fallback={fallback}"));
}
for tried in &self.tried {
lines.push(format!("tried={tried}"));
}
for action in &self.actions {
lines.push(format!("action={action}"));
}
for command in self.suggested_commands() {
lines.push(format!("command={command}"));
}
for record in &self.records {
lines.push(record.to_local_text());
}
lines.join("\n")
}
#[must_use]
pub fn suggested_commands(&self) -> Vec<String> {
if !self.commands.is_empty() {
return self.commands.clone();
}
self.actions
.iter()
.map(|action| action.command().to_owned())
.collect()
}
#[must_use]
pub fn into_document(self) -> IndexDocument {
let commands = self.suggested_commands();
let mut document = IndexDocument::titled(self.title.clone());
document.metadata.quality = Some(DocumentQuality::new(
DocumentQualityCategory::Failed,
0,
[
format!("source: {}", self.source),
format!("confidence: {}", self.confidence),
format!("cause: {}", self.cause),
self.reason.clone(),
],
));
document.push(IndexNode::Heading {
level: 1,
text: self.title.clone(),
});
document.push(IndexNode::Error(self.reason.clone()));
document.push(IndexNode::Heading {
level: 2,
text: "What Index tried".to_owned(),
});
let mut tried = vec![
format!("source: {}", self.source),
format!("confidence: {}", self.confidence),
];
tried.extend(self.tried.clone());
if let Some(fallback) = self.fallback {
tried.push(format!("fallback path: {fallback}"));
}
document.push(IndexNode::List {
ordered: false,
items: tried,
});
document.push(IndexNode::Heading {
level: 2,
text: "Likely cause".to_owned(),
});
document.push(IndexNode::Paragraph(format!(
"{}: {}",
self.cause,
self.cause.explanation()
)));
if !commands.is_empty() {
document.push(IndexNode::Heading {
level: 2,
text: "Suggested commands".to_owned(),
});
document.push(IndexNode::CodeBlock {
language: Some("sh".to_owned()),
code: commands.join("\n"),
});
}
if !self.actions.is_empty() {
document.push(IndexNode::Heading {
level: 2,
text: "Suggested actions".to_owned(),
});
document.push(IndexNode::List {
ordered: false,
items: self
.actions
.into_iter()
.map(|action| action.label().to_owned())
.collect(),
});
}
if !self.records.is_empty() {
document.push(IndexNode::Heading {
level: 2,
text: "Diagnostics".to_owned(),
});
for record in self.records {
document.push(IndexNode::Paragraph(record.to_local_text()));
}
}
document
}
}
#[cfg(test)]
mod tests {
use super::{
DiagnosticAction, DiagnosticConfidence, DiagnosticRecord, DiagnosticSeverity,
DiagnosticSource, FailureCause, FailureDiagnostic, TelemetryPolicy,
};
use crate::Redactor;
#[test]
fn telemetry_policy_disallows_automatic_network_transmission() {
assert!(!TelemetryPolicy::LocalOnly.allows_network_transmission());
}
#[test]
fn diagnostic_record_formats_stable_local_text() {
let record = DiagnosticRecord::new(
DiagnosticSeverity::Warning,
"INDEX-WARN",
"content was truncated",
)
.with_field("url", "https://example.test")
.with_field("bytes", "1024");
assert_eq!(
record.to_local_text(),
"warning[INDEX-WARN]: content was truncated\nurl=https://example.test\nbytes=1024"
);
}
#[test]
fn diagnostic_record_redacts_message_and_fields() {
let mut redactor = Redactor::new();
redactor.add_secret("secret-value");
let record = DiagnosticRecord::new(
DiagnosticSeverity::Error,
"INDEX-AUTH",
"Authorization: Bearer secret-value",
)
.with_field("cookie", "Cookie: session=secret-value")
.with_field("path", "/tmp/index");
let redacted = record.redacted(&redactor);
assert!(redacted.to_local_text().contains("[REDACTED]"));
assert!(!redacted.to_local_text().contains("secret-value"));
assert!(redacted.to_local_text().contains("path=/tmp/index"));
}
#[test]
fn failure_diagnostic_formats_redacts_and_renders_document() {
let mut redactor = Redactor::new();
redactor.add_secret("secret-token");
let diagnostic = FailureDiagnostic::new(
"Unsupported page",
DiagnosticSource::Readability,
DiagnosticConfidence::Low,
"could not understand token=secret-token",
)
.with_fallback("generic transformer")
.with_tried("readability extraction")
.with_command(":capture save unsupported.capture")
.with_actions([
DiagnosticAction::TryHeadless,
DiagnosticAction::Extract,
DiagnosticAction::Capture,
DiagnosticAction::AddFixture,
])
.with_record(
DiagnosticRecord::new(
DiagnosticSeverity::Warning,
"INDEX-LOW-CONFIDENCE",
"private token secret-token",
)
.with_field("url", "https://example.test/?token=secret-token"),
);
let local_text = diagnostic.to_local_text();
assert!(local_text.contains("source=readability"));
assert!(local_text.contains("confidence=low"));
assert!(local_text.contains("cause=unsupported-page-shape"));
assert!(local_text.contains("tried=readability extraction"));
assert!(local_text.contains("action=try headless fallback"));
assert!(local_text.contains("command=:capture save unsupported.capture"));
let redacted = diagnostic.redacted(&redactor);
assert!(!redacted.to_local_text().contains("secret-token"));
assert!(redacted.to_local_text().contains("[REDACTED]"));
let document = redacted.into_document();
assert_eq!(document.title, "Unsupported page");
assert!(!document.is_empty());
}
#[test]
fn failure_cause_classification_is_stable() {
assert_eq!(
FailureCause::classify(DiagnosticSource::Network, "dns failed"),
FailureCause::NetworkUnavailable
);
assert_eq!(
FailureCause::classify(DiagnosticSource::Headless, "timed out after 1000ms"),
FailureCause::Timeout
);
assert_eq!(
FailureCause::classify(DiagnosticSource::LocalInput, "unsafe scheme denied"),
FailureCause::BlockedByPolicy
);
assert_eq!(
FailureCause::classify(DiagnosticSource::GenericTransformer, "no readable content"),
FailureCause::EmptyContent
);
assert_eq!(
FailureCause::classify(DiagnosticSource::Adapter, "uncertain detection"),
FailureCause::AdapterMismatch
);
assert_eq!(
FailureCause::classify(DiagnosticSource::Parser, "malformed HTML parse failed"),
FailureCause::ParseFailed
);
assert_eq!(
FailureCause::classify(DiagnosticSource::Extraction, "JSON schema failure"),
FailureCause::ExtractionFailed
);
assert_eq!(
FailureCause::classify(DiagnosticSource::Renderer, "terminal layout overflow"),
FailureCause::RendererFailed
);
assert_eq!(
FailureCause::classify(DiagnosticSource::Shelf, "shelf index missing"),
FailureCause::ShelfUnavailable
);
assert_eq!(
FailureCause::classify(
DiagnosticSource::Readability,
"low confidence partial document"
),
FailureCause::LowConfidence
);
}
#[test]
fn failure_document_contains_commands_and_capture_action() {
let document = FailureDiagnostic::new(
"Failed",
DiagnosticSource::Network,
DiagnosticConfidence::Failed,
"could not fetch",
)
.with_actions([DiagnosticAction::Retry, DiagnosticAction::Capture])
.into_document();
let rendered = format!("{:?}", document.nodes);
assert!(rendered.contains("What Index tried"));
assert!(rendered.contains("Likely cause"));
assert!(rendered.contains("Suggested commands"));
assert!(rendered.contains(":capture preview"));
}
#[test]
fn failure_documents_cover_major_boundaries_with_exact_commands() {
for (source, reason, command, cause) in [
(
DiagnosticSource::Parser,
"malformed parse input",
":capture preview",
FailureCause::ParseFailed,
),
(
DiagnosticSource::Network,
"dns failed",
":open <url>",
FailureCause::NetworkUnavailable,
),
(
DiagnosticSource::GenericTransformer,
"unsupported page shape",
":repair promote <region-id>",
FailureCause::UnsupportedPageShape,
),
(
DiagnosticSource::Extraction,
"JSON schema failure",
":extract links",
FailureCause::ExtractionFailed,
),
(
DiagnosticSource::Renderer,
"terminal layout overflow",
":repair promote <region-id>",
FailureCause::RendererFailed,
),
(
DiagnosticSource::Shelf,
"shelf index missing",
"index shelf search <query>",
FailureCause::ShelfUnavailable,
),
] {
let document = FailureDiagnostic::new(
"Boundary failed",
source,
DiagnosticConfidence::Failed,
reason,
)
.with_actions([
DiagnosticAction::Retry,
DiagnosticAction::Extract,
DiagnosticAction::Capture,
DiagnosticAction::Repair,
DiagnosticAction::ShelfSearch,
])
.into_document();
let rendered = format!("{:?}", document.nodes);
assert!(
rendered.contains(command),
"{source} missing command {command}"
);
assert!(
rendered.contains(cause.as_str()),
"{source} missing cause {cause}"
);
assert!(document.metadata.quality.as_ref().is_some_and(|quality| {
quality.category == crate::DocumentQualityCategory::Failed
}));
}
}
#[test]
fn diagnostic_enum_names_are_stable() {
assert_eq!(DiagnosticSource::LocalInput.as_str(), "local-input");
assert_eq!(DiagnosticSource::Network.to_string(), "network");
assert_eq!(DiagnosticSource::Parser.to_string(), "parser");
assert_eq!(
DiagnosticSource::GenericTransformer.to_string(),
"generic-transformer"
);
assert_eq!(DiagnosticSource::Adapter.to_string(), "adapter");
assert_eq!(DiagnosticSource::Headless.to_string(), "headless");
assert_eq!(DiagnosticSource::Extraction.to_string(), "extraction");
assert_eq!(DiagnosticSource::Renderer.to_string(), "renderer");
assert_eq!(DiagnosticSource::Shelf.to_string(), "shelf");
assert_eq!(DiagnosticConfidence::Failed.as_str(), "failed");
assert_eq!(DiagnosticConfidence::Medium.to_string(), "medium");
assert_eq!(DiagnosticAction::Retry.to_string(), "retry the request");
assert_eq!(
DiagnosticAction::Repair.command(),
":repair promote <region-id>"
);
assert_eq!(FailureCause::Timeout.to_string(), "timeout");
assert_eq!(
FailureCause::ShelfUnavailable.to_string(),
"shelf-unavailable"
);
}
}