sea_core/projection/
buf.rs

1//! Buf.build CLI Integration
2//!
3//! This module provides optional integration with the buf.build CLI for
4//! linting, breaking change detection, and code generation.
5//!
6//! All buf operations gracefully degrade if the buf CLI is not installed.
7
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11/// Result type for buf operations.
12pub type BufResult<T> = Result<T, BufError>;
13
14/// Errors from buf CLI operations.
15#[derive(Debug, Clone)]
16pub enum BufError {
17    /// Buf CLI not found in PATH
18    NotInstalled,
19    /// Buf command failed with exit code
20    CommandFailed { exit_code: i32, stderr: String },
21    /// IO error
22    IoError(String),
23    /// Configuration error
24    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
45/// Buf.build CLI integration.
46///
47/// Provides optional integration with the buf CLI for enhanced proto workflows.
48/// All operations gracefully degrade if buf is not installed.
49pub struct BufIntegration {
50    /// Path to buf binary (or None to search PATH)
51    buf_path: Option<PathBuf>,
52}
53
54impl Default for BufIntegration {
55    fn default() -> Self {
56        Self::new()
57    }
58}
59
60impl BufIntegration {
61    /// Create a new BufIntegration with default settings.
62    pub fn new() -> Self {
63        Self { buf_path: None }
64    }
65
66    /// Create with a specific buf binary path.
67    pub fn with_path(path: impl Into<PathBuf>) -> Self {
68        Self {
69            buf_path: Some(path.into()),
70        }
71    }
72
73    /// Check if buf CLI is available.
74    pub fn is_available(&self) -> bool {
75        self.get_version().is_ok()
76    }
77
78    /// Get buf CLI version.
79    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    /// Lint proto files in a directory.
88    ///
89    /// Returns Ok with lint output, or gracefully degrades if buf not installed.
90    pub fn lint(&self, proto_dir: &Path) -> BufResult<BufLintResult> {
91        // Check if buf.yaml exists, create temporary one if not
92        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        // Clean up temporary config
104        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    /// Check for breaking changes between two proto directories.
124    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    /// Generate a buf.yaml configuration file content.
148    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    /// Generate a buf.gen.yaml for code generation.
164    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/// Result of buf lint operation.
227#[derive(Debug, Clone)]
228pub struct BufLintResult {
229    /// Whether lint passed with no issues
230    pub success: bool,
231    /// List of lint issues found
232    pub issues: Vec<BufLintIssue>,
233    /// Raw output from buf
234    pub raw_output: String,
235}
236
237impl BufLintResult {
238    /// Create a skipped result (when buf not available).
239    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/// A single lint issue.
249#[derive(Debug, Clone)]
250pub struct BufLintIssue {
251    /// Issue message
252    pub message: String,
253    /// File path (if available)
254    pub file: Option<String>,
255    /// Line number (if available)
256    pub line: Option<u32>,
257    /// Lint rule ID (if available)
258    pub rule: Option<String>,
259}
260
261/// Result of buf breaking check.
262#[derive(Debug, Clone)]
263pub struct BufBreakingResult {
264    /// Whether the changes are compatible
265    pub is_compatible: bool,
266    /// List of breaking violations
267    pub violations: Vec<String>,
268    /// Raw output from buf
269    pub raw_output: String,
270}
271
272/// Supported languages for buf code generation.
273#[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    /// Get plugin configuration for buf.gen.yaml.
287    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    /// Parse from string.
325    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        // Test that it handles missing binary
394        let buf = BufIntegration::with_path("non_existent_binary");
395        assert!(!buf.is_available());
396
397        // Should return NotInstalled error
398        let result = buf.get_version();
399        match result {
400            Err(BufError::NotInstalled) => (),
401            _ => panic!("Expected NotInstalled error, got {:?}", result),
402        }
403    }
404}