Skip to main content

repoctl_core/
diagnostic.rs

1//! Stable diagnostics and error types.
2
3use std::fmt::{Display, Formatter};
4
5use camino::Utf8PathBuf;
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9/// Severity level for a repoctl diagnostic.
10#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
11#[serde(rename_all = "kebab-case")]
12pub enum Severity {
13    /// Informational diagnostic that does not affect success.
14    Info,
15    /// Warning diagnostic that should be reviewed but does not fail validation.
16    Warning,
17    /// Error diagnostic that fails the requested operation.
18    Error,
19}
20
21/// One-based source span for diagnostics when line and column are known.
22#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
23#[serde(rename_all = "camelCase")]
24pub struct SourceSpan {
25    /// One-based line number.
26    pub line: usize,
27    /// One-based column number.
28    pub column: usize,
29}
30
31/// File and optional span associated with a diagnostic.
32#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
33#[serde(rename_all = "camelCase")]
34pub struct DiagnosticSource {
35    /// Repo-relative or absolute path depending on the caller boundary.
36    pub path: Box<str>,
37    /// Optional line and column.
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub span: Option<SourceSpan>,
40}
41
42/// Stable, actionable diagnostic returned by repoctl operations.
43#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
44#[serde(rename_all = "camelCase")]
45pub struct Diagnostic {
46    /// Stable machine-readable code.
47    pub code: Box<str>,
48    /// Severity for exit-code and renderer decisions.
49    pub severity: Severity,
50    /// Human-readable summary.
51    pub message: Box<str>,
52    /// Source location if the diagnostic can be tied to one file.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub source: Option<Box<DiagnosticSource>>,
55    /// Project context when known.
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub project: Option<Box<str>>,
58    /// Workspace context when known.
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub workspace: Option<Box<str>>,
61    /// Remediation hint when a clear next step exists.
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub help: Option<Box<str>>,
64}
65
66impl Diagnostic {
67    /// Creates an error diagnostic.
68    pub fn error(code: impl Into<String>, message: impl Into<String>) -> Self {
69        Self {
70            code: code.into().into_boxed_str(),
71            severity: Severity::Error,
72            message: message.into().into_boxed_str(),
73            source: None,
74            project: None,
75            workspace: None,
76            help: None,
77        }
78    }
79
80    /// Creates a warning diagnostic.
81    pub fn warning(code: impl Into<String>, message: impl Into<String>) -> Self {
82        Self {
83            code: code.into().into_boxed_str(),
84            severity: Severity::Warning,
85            message: message.into().into_boxed_str(),
86            source: None,
87            project: None,
88            workspace: None,
89            help: None,
90        }
91    }
92
93    /// Adds source path context.
94    #[must_use]
95    pub fn with_path(mut self, path: impl Into<String>) -> Self {
96        self.source = Some(Box::new(DiagnosticSource {
97            path: path.into().into_boxed_str(),
98            span: None,
99        }));
100        self
101    }
102
103    /// Adds project context.
104    #[must_use]
105    pub fn with_project(mut self, project: impl Into<String>) -> Self {
106        self.project = Some(project.into().into_boxed_str());
107        self
108    }
109
110    /// Adds workspace context.
111    #[must_use]
112    pub fn with_workspace(mut self, workspace: impl Into<String>) -> Self {
113        self.workspace = Some(workspace.into().into_boxed_str());
114        self
115    }
116
117    /// Adds remediation help.
118    #[must_use]
119    pub fn with_help(mut self, help: impl Into<String>) -> Self {
120        self.help = Some(help.into().into_boxed_str());
121        self
122    }
123}
124
125impl Display for Diagnostic {
126    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
127        if let Some(source) = &self.source {
128            write!(
129                f,
130                "{} [{}] {}: {}",
131                self.code,
132                severity_label(&self.severity),
133                source.path,
134                self.message
135            )
136        } else {
137            write!(
138                f,
139                "{} [{}] {}",
140                self.code,
141                severity_label(&self.severity),
142                self.message
143            )
144        }
145    }
146}
147
148/// A report containing zero or more diagnostics.
149#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
150#[serde(rename_all = "camelCase")]
151pub struct ValidationReport {
152    /// Diagnostics produced by the operation.
153    pub diagnostics: Vec<Diagnostic>,
154}
155
156impl ValidationReport {
157    /// Creates a report from diagnostics.
158    pub fn new(diagnostics: Vec<Diagnostic>) -> Self {
159        Self { diagnostics }
160    }
161
162    /// Returns true when no error-severity diagnostics are present.
163    pub fn is_success(&self) -> bool {
164        !self
165            .diagnostics
166            .iter()
167            .any(|diagnostic| diagnostic.severity == Severity::Error)
168    }
169
170    /// Adds one diagnostic.
171    pub fn push(&mut self, diagnostic: Diagnostic) {
172        self.diagnostics.push(diagnostic);
173    }
174}
175
176/// Error type used by repoctl facade and capability services.
177#[derive(Debug, Error)]
178pub enum RepoctlError {
179    /// A single controlled diagnostic.
180    #[error("{diagnostic}")]
181    Diagnostic {
182        /// Diagnostic payload.
183        diagnostic: Box<Diagnostic>,
184    },
185    /// Multiple controlled diagnostics.
186    #[error("repoctl produced {} diagnostics", diagnostics.len())]
187    Diagnostics {
188        /// Diagnostic payloads.
189        diagnostics: Vec<Diagnostic>,
190    },
191    /// Filesystem operation failed.
192    #[error("I/O error at {path}: {source}")]
193    Io {
194        /// Path being accessed.
195        path: Utf8PathBuf,
196        /// Source error.
197        #[source]
198        source: std::io::Error,
199    },
200    /// External environment or toolchain is unavailable.
201    #[error("environment error: {0}")]
202    Environment(String),
203    /// Internal bug surfaced as a controlled error.
204    #[error("internal error: {0}")]
205    Internal(String),
206}
207
208impl RepoctlError {
209    /// Creates a diagnostic error.
210    pub fn diagnostic(diagnostic: Diagnostic) -> Self {
211        Self::Diagnostic {
212            diagnostic: Box::new(diagnostic),
213        }
214    }
215
216    /// Creates an I/O error with path context.
217    pub fn io(path: impl Into<Utf8PathBuf>, source: std::io::Error) -> Self {
218        Self::Io {
219            path: path.into(),
220            source,
221        }
222    }
223
224    /// Returns diagnostics embedded in the error, if any.
225    pub fn diagnostics(&self) -> Vec<Diagnostic> {
226        match self {
227            Self::Diagnostic { diagnostic } => vec![(**diagnostic).clone()],
228            Self::Diagnostics { diagnostics } => diagnostics.clone(),
229            Self::Io { path, source } => vec![Diagnostic::error(
230                "repoctl.io",
231                format!("I/O error at {path}: {source}"),
232            )],
233            Self::Environment(message) => {
234                vec![Diagnostic::error("repoctl.environment", message.clone())]
235            }
236            Self::Internal(message) => vec![Diagnostic::error("repoctl.internal", message.clone())],
237        }
238    }
239}
240
241fn severity_label(severity: &Severity) -> &'static str {
242    match severity {
243        Severity::Info => "info",
244        Severity::Warning => "warning",
245        Severity::Error => "error",
246    }
247}