1use std::path::{Path, PathBuf};
9use std::process::Command;
10
11pub type BufResult<T> = Result<T, BufError>;
13
14#[derive(Debug, Clone)]
16pub enum BufError {
17 NotInstalled,
19 CommandFailed { exit_code: i32, stderr: String },
21 IoError(String),
23 ConfigError(String),
25}
26
27impl std::fmt::Display for BufError {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 match self {
30 BufError::NotInstalled => write!(
31 f,
32 "buf CLI not found. Install from: https://buf.build/docs/installation"
33 ),
34 BufError::CommandFailed { exit_code, stderr } => {
35 write!(f, "buf command failed (exit {}): {}", exit_code, stderr)
36 }
37 BufError::IoError(msg) => write!(f, "IO error: {}", msg),
38 BufError::ConfigError(msg) => write!(f, "Configuration error: {}", msg),
39 }
40 }
41}
42
43impl std::error::Error for BufError {}
44
45pub struct BufIntegration {
50 buf_path: Option<PathBuf>,
52}
53
54impl Default for BufIntegration {
55 fn default() -> Self {
56 Self::new()
57 }
58}
59
60impl BufIntegration {
61 pub fn new() -> Self {
63 Self { buf_path: None }
64 }
65
66 pub fn with_path(path: impl Into<PathBuf>) -> Self {
68 Self {
69 buf_path: Some(path.into()),
70 }
71 }
72
73 pub fn is_available(&self) -> bool {
75 self.get_version().is_ok()
76 }
77
78 pub fn get_version(&self) -> BufResult<String> {
80 let output = self
81 .run_buf(&["--version"])
82 .map_err(|_| BufError::NotInstalled)?;
83
84 Ok(output.trim().to_string())
85 }
86
87 pub fn lint(&self, proto_dir: &Path) -> BufResult<BufLintResult> {
91 let buf_yaml = proto_dir.join("buf.yaml");
93 let temp_config = if !buf_yaml.exists() {
94 let config = Self::generate_buf_yaml();
95 std::fs::write(&buf_yaml, &config).map_err(|e| BufError::IoError(e.to_string()))?;
96 Some(buf_yaml.clone())
97 } else {
98 None
99 };
100
101 let result = self.run_buf(&["lint", proto_dir.to_str().unwrap_or(".")]);
102
103 if let Some(path) = temp_config {
105 let _ = std::fs::remove_file(path);
106 }
107
108 match result {
109 Ok(output) => Ok(BufLintResult {
110 success: true,
111 issues: vec![],
112 raw_output: output,
113 }),
114 Err(BufError::CommandFailed { stderr, .. }) => Ok(BufLintResult {
115 success: false,
116 issues: Self::parse_lint_issues(&stderr),
117 raw_output: stderr,
118 }),
119 Err(e) => Err(e),
120 }
121 }
122
123 pub fn breaking_check(&self, new_dir: &Path, old_dir: &Path) -> BufResult<BufBreakingResult> {
125 let result = self.run_buf(&[
126 "breaking",
127 new_dir.to_str().unwrap_or("."),
128 "--against",
129 old_dir.to_str().unwrap_or("."),
130 ]);
131
132 match result {
133 Ok(output) => Ok(BufBreakingResult {
134 is_compatible: true,
135 violations: vec![],
136 raw_output: output,
137 }),
138 Err(BufError::CommandFailed { stderr, .. }) => Ok(BufBreakingResult {
139 is_compatible: false,
140 violations: Self::parse_breaking_violations(&stderr),
141 raw_output: stderr,
142 }),
143 Err(e) => Err(e),
144 }
145 }
146
147 pub fn generate_buf_yaml() -> String {
149 r#"version: v1
150breaking:
151 use:
152 - FILE
153lint:
154 use:
155 - DEFAULT
156 except:
157 - PACKAGE_VERSION_SUFFIX
158 - PACKAGE_DIRECTORY_MATCH
159"#
160 .to_string()
161 }
162
163 pub fn generate_buf_gen_yaml(languages: &[BufLanguage]) -> String {
165 let mut plugins = String::new();
166
167 for lang in languages {
168 plugins.push_str(&lang.to_plugin_config());
169 }
170
171 format!(
172 r#"version: v1
173plugins:
174{}"#,
175 plugins
176 )
177 }
178
179 fn run_buf(&self, args: &[&str]) -> BufResult<String> {
180 let buf_cmd = self
181 .buf_path
182 .as_ref()
183 .map(|p| p.to_string_lossy().to_string())
184 .unwrap_or_else(|| "buf".to_string());
185
186 let output = Command::new(&buf_cmd).args(args).output().map_err(|e| {
187 if e.kind() == std::io::ErrorKind::NotFound {
188 BufError::NotInstalled
189 } else {
190 BufError::IoError(e.to_string())
191 }
192 })?;
193
194 if output.status.success() {
195 Ok(String::from_utf8_lossy(&output.stdout).to_string())
196 } else {
197 Err(BufError::CommandFailed {
198 exit_code: output.status.code().unwrap_or(-1),
199 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
200 })
201 }
202 }
203
204 fn parse_lint_issues(output: &str) -> Vec<BufLintIssue> {
205 output
206 .lines()
207 .filter(|l| !l.is_empty())
208 .map(|line| BufLintIssue {
209 message: line.to_string(),
210 file: None,
211 line: None,
212 rule: None,
213 })
214 .collect()
215 }
216
217 fn parse_breaking_violations(output: &str) -> Vec<String> {
218 output
219 .lines()
220 .filter(|l| !l.is_empty())
221 .map(|s| s.to_string())
222 .collect()
223 }
224}
225
226#[derive(Debug, Clone)]
228pub struct BufLintResult {
229 pub success: bool,
231 pub issues: Vec<BufLintIssue>,
233 pub raw_output: String,
235}
236
237impl BufLintResult {
238 pub fn skipped(reason: &str) -> Self {
240 Self {
241 success: true,
242 issues: vec![],
243 raw_output: format!("Skipped: {}", reason),
244 }
245 }
246}
247
248#[derive(Debug, Clone)]
250pub struct BufLintIssue {
251 pub message: String,
253 pub file: Option<String>,
255 pub line: Option<u32>,
257 pub rule: Option<String>,
259}
260
261#[derive(Debug, Clone)]
263pub struct BufBreakingResult {
264 pub is_compatible: bool,
266 pub violations: Vec<String>,
268 pub raw_output: String,
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
274pub enum BufLanguage {
275 Go,
276 Rust,
277 Python,
278 TypeScript,
279 Java,
280 CSharp,
281 Cpp,
282 Swift,
283}
284
285impl BufLanguage {
286 pub fn to_plugin_config(&self) -> String {
288 match self {
289 BufLanguage::Go => r#" - plugin: buf.build/protocolbuffers/go
290 out: gen/go
291"#
292 .to_string(),
293 BufLanguage::Rust => r#" - plugin: buf.build/community/neoeinstein-prost
294 out: gen/rust
295"#
296 .to_string(),
297 BufLanguage::Python => r#" - plugin: buf.build/protocolbuffers/python
298 out: gen/python
299"#
300 .to_string(),
301 BufLanguage::TypeScript => r#" - plugin: buf.build/community/stephenh-ts-proto
302 out: gen/typescript
303"#
304 .to_string(),
305 BufLanguage::Java => r#" - plugin: buf.build/protocolbuffers/java
306 out: gen/java
307"#
308 .to_string(),
309 BufLanguage::CSharp => r#" - plugin: buf.build/protocolbuffers/csharp
310 out: gen/csharp
311"#
312 .to_string(),
313 BufLanguage::Cpp => r#" - plugin: buf.build/protocolbuffers/cpp
314 out: gen/cpp
315"#
316 .to_string(),
317 BufLanguage::Swift => r#" - plugin: buf.build/apple/swift
318 out: gen/swift
319"#
320 .to_string(),
321 }
322 }
323
324 pub fn parse(s: &str) -> Option<Self> {
326 match s.to_lowercase().as_str() {
327 "go" | "golang" => Some(BufLanguage::Go),
328 "rust" | "rs" => Some(BufLanguage::Rust),
329 "python" | "py" => Some(BufLanguage::Python),
330 "typescript" | "ts" => Some(BufLanguage::TypeScript),
331 "java" => Some(BufLanguage::Java),
332 "csharp" | "cs" | "c#" => Some(BufLanguage::CSharp),
333 "cpp" | "c++" => Some(BufLanguage::Cpp),
334 "swift" => Some(BufLanguage::Swift),
335 _ => None,
336 }
337 }
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 #[test]
345 fn test_generate_buf_yaml() {
346 let yaml = BufIntegration::generate_buf_yaml();
347 assert!(yaml.contains("version: v1"));
348 assert!(yaml.contains("breaking:"));
349 assert!(yaml.contains("lint:"));
350 assert!(yaml.contains("DEFAULT"));
351 }
352
353 #[test]
354 fn test_generate_buf_gen_yaml() {
355 let yaml = BufIntegration::generate_buf_gen_yaml(&[BufLanguage::Go, BufLanguage::Rust]);
356 assert!(yaml.contains("version: v1"));
357 assert!(yaml.contains("plugins:"));
358 assert!(yaml.contains("protocolbuffers/go"));
359 assert!(yaml.contains("neoeinstein-prost"));
360 }
361
362 #[test]
363 fn test_buf_language_from_str() {
364 assert_eq!(BufLanguage::parse("go"), Some(BufLanguage::Go));
365 assert_eq!(BufLanguage::parse("rust"), Some(BufLanguage::Rust));
366 assert_eq!(BufLanguage::parse("py"), Some(BufLanguage::Python));
367 assert_eq!(BufLanguage::parse("ts"), Some(BufLanguage::TypeScript));
368 assert_eq!(BufLanguage::parse("invalid"), None);
369 }
370
371 #[test]
372 fn test_buf_lint_result_skipped() {
373 let result = BufLintResult::skipped("buf not installed");
374 assert!(result.success);
375 assert!(result.issues.is_empty());
376 assert!(result.raw_output.contains("Skipped"));
377 }
378
379 #[test]
380 fn test_buf_error_display() {
381 let err = BufError::NotInstalled;
382 assert!(err.to_string().contains("buf CLI not found"));
383
384 let err2 = BufError::CommandFailed {
385 exit_code: 1,
386 stderr: "some error".to_string(),
387 };
388 assert!(err2.to_string().contains("exit 1"));
389 }
390
391 #[test]
392 fn test_run_buf_not_found() {
393 let buf = BufIntegration::with_path("non_existent_binary");
395 assert!(!buf.is_available());
396
397 let result = buf.get_version();
399 match result {
400 Err(BufError::NotInstalled) => (),
401 _ => panic!("Expected NotInstalled error, got {:?}", result),
402 }
403 }
404}