1use anyhow::{Context, Result};
9use clap::Subcommand;
10use colored::Colorize;
11use serde::{Deserialize, Serialize};
12use std::path::PathBuf;
13use std::process::Command;
14
15use crate::output::OutputFormat;
16#[derive(Debug, Subcommand)]
19pub enum PipelineCommands {
20 Run {
22 file: PathBuf,
24
25 #[arg(short, long)]
27 continue_on_error: bool,
28
29 #[arg(short, long)]
31 dry_run: bool,
32 },
33
34 Validate {
36 file: PathBuf,
38 },
39
40 Sample {
42 #[arg(long = "out-file", default_value = "pipeline.yaml")]
44 out_file: PathBuf,
45 },
46}
47
48#[derive(Debug, Clone, Deserialize, Serialize)]
50pub struct Pipeline {
51 pub name: String,
53 #[serde(default)]
55 pub description: Option<String>,
56 #[serde(default)]
58 pub variables: std::collections::HashMap<String, String>,
59 pub steps: Vec<PipelineStep>,
61}
62
63#[derive(Debug, Clone, Deserialize, Serialize)]
65pub struct PipelineStep {
66 pub name: String,
68 pub command: String,
70 #[serde(default)]
72 pub continue_on_error: bool,
73 #[serde(default)]
75 pub condition: Option<String>,
76}
77
78impl PipelineCommands {
79 pub async fn execute(self, output_format: OutputFormat) -> Result<()> {
80 match self {
81 PipelineCommands::Run {
82 file,
83 continue_on_error,
84 dry_run,
85 } => run_pipeline(&file, continue_on_error, dry_run, output_format).await,
86 PipelineCommands::Validate { file } => validate_pipeline(&file, output_format),
87 PipelineCommands::Sample { out_file } => generate_sample(&out_file, output_format),
88 }
89 }
90}
91
92fn load_pipeline(file: &PathBuf) -> Result<Pipeline> {
93 let content = if file.as_os_str() == "-" {
94 use std::io::Read;
95 let mut buf = String::new();
96 std::io::stdin()
97 .lock()
98 .read_to_string(&mut buf)
99 .context("Failed to read pipeline from stdin")?;
100 buf
101 } else {
102 std::fs::read_to_string(file)
103 .with_context(|| format!("Failed to read pipeline file: {}", file.display()))?
104 };
105
106 let is_yaml = file.as_os_str() == "-"
108 || file
109 .extension()
110 .map(|e| e == "yaml" || e == "yml")
111 .unwrap_or(false);
112
113 let pipeline: Pipeline = if is_yaml {
114 serde_yaml::from_str(&content)
115 .with_context(|| format!("Failed to parse YAML pipeline: {}", file.display()))?
116 } else {
117 serde_json::from_str(&content)
118 .with_context(|| format!("Failed to parse JSON pipeline: {}", file.display()))?
119 };
120
121 Ok(pipeline)
122}
123
124async fn run_pipeline(
125 file: &PathBuf,
126 global_continue_on_error: bool,
127 dry_run: bool,
128 output_format: OutputFormat,
129) -> Result<()> {
130 let pipeline = load_pipeline(file)?;
131
132 if output_format.supports_colors() {
133 println!("\n{} {}", "Pipeline:".bold(), pipeline.name.cyan());
134 if let Some(ref desc) = pipeline.description {
135 println!(" {}", desc.dimmed());
136 }
137 println!("{}", "─".repeat(60));
138 }
139
140 let mut passed = 0;
141 let mut failed = 0;
142 let mut skipped = 0;
143
144 for (i, step) in pipeline.steps.iter().enumerate() {
145 let step_num = i + 1;
146
147 if output_format.supports_colors() {
148 println!(
149 "\n[{}/{}] {}",
150 step_num,
151 pipeline.steps.len(),
152 step.name.bold()
153 );
154 println!(" {} {}", "Command:".dimmed(), step.command.cyan());
155 }
156
157 if let Some(ref condition) = step.condition {
159 if !evaluate_condition(condition) {
161 if output_format.supports_colors() {
162 println!(" {} Condition not met, skipping", "→".yellow());
163 }
164 skipped += 1;
165 continue;
166 }
167 }
168
169 if dry_run {
170 if output_format.supports_colors() {
171 println!(" {} Would execute: raps {}", "→".dimmed(), step.command);
172 }
173 passed += 1;
174 continue;
175 }
176
177 let mut command = step.command.clone();
179 for (key, value) in &pipeline.variables {
180 const SHELL_META: &[char] = &['|', '&', ';', '$', '`', '(', ')', '{', '}', '<', '>'];
182 if value.contains(SHELL_META) {
183 anyhow::bail!("Pipeline variable '{}' contains shell metacharacters", key);
184 }
185 command = command.replace(&format!("${{{}}}", key), value);
186 command = command.replace(&format!("${}", key), value);
187 }
188
189 let result = execute_raps_command(&command);
191
192 match result {
193 Ok(0) => {
194 if output_format.supports_colors() {
195 println!(" {} Success", "✓".green().bold());
196 }
197 passed += 1;
198 }
199 Ok(exit_code) => {
200 if output_format.supports_colors() {
201 println!(" {} Failed (exit code: {})", "✗".red().bold(), exit_code);
202 }
203 failed += 1;
204
205 if !step.continue_on_error && !global_continue_on_error {
206 anyhow::bail!(
207 "Pipeline aborted at step '{}' (exit code: {})",
208 step.name,
209 exit_code
210 );
211 }
212 }
213 Err(e) => {
214 if output_format.supports_colors() {
215 println!(" {} Error: {}", "✗".red().bold(), e);
216 }
217 failed += 1;
218
219 if !step.continue_on_error && !global_continue_on_error {
220 anyhow::bail!("Pipeline aborted at step '{}': {e}", step.name);
221 }
222 }
223 }
224 }
225
226 if output_format.supports_colors() {
228 println!("\n{}", "─".repeat(60));
229 println!("{}", "Pipeline Summary:".bold());
230 println!(
231 " {} {} passed, {} {} failed, {} {} skipped",
232 "✓".green(),
233 passed,
234 "✗".red(),
235 failed,
236 "→".yellow(),
237 skipped
238 );
239 }
240
241 #[derive(Serialize)]
242 struct PipelineResult {
243 success: bool,
244 passed: usize,
245 failed: usize,
246 skipped: usize,
247 }
248
249 let result = PipelineResult {
252 success: true,
253 passed,
254 failed,
255 skipped,
256 };
257
258 if !matches!(output_format, OutputFormat::Table) {
259 output_format.write(&result)?;
260 }
261
262 Ok(())
263}
264
265fn execute_raps_command(command: &str) -> Result<i32> {
266 let exe_path = std::env::current_exe().context("Failed to get current executable path")?;
268
269 let args = shlex::split(command)
271 .ok_or_else(|| anyhow::anyhow!("Invalid quoting in pipeline command: {}", command))?;
272
273 let output = Command::new(&exe_path)
275 .args(&args)
276 .output()
277 .context("Failed to execute command")?;
278
279 if !output.stdout.is_empty() {
281 print!("{}", String::from_utf8_lossy(&output.stdout));
282 }
283 if !output.stderr.is_empty() {
284 eprint!("{}", String::from_utf8_lossy(&output.stderr));
285 }
286
287 Ok(output.status.code().unwrap_or(-1))
288}
289
290fn evaluate_condition(condition: &str) -> bool {
291 let trimmed = condition.trim().to_lowercase();
294 !trimmed.is_empty() && trimmed != "false" && trimmed != "0"
295}
296
297fn validate_pipeline(file: &PathBuf, output_format: OutputFormat) -> Result<()> {
298 let pipeline = load_pipeline(file)?;
299
300 #[derive(Serialize)]
301 struct ValidationResult {
302 valid: bool,
303 name: String,
304 steps_count: usize,
305 warnings: Vec<String>,
306 }
307
308 let mut warnings = Vec::new();
309
310 for (i, step) in pipeline.steps.iter().enumerate() {
312 if step.command.is_empty() {
313 warnings.push(format!("Step {} '{}' has empty command", i + 1, step.name));
314 }
315 }
316
317 let result = ValidationResult {
318 valid: warnings.is_empty(),
319 name: pipeline.name.clone(),
320 steps_count: pipeline.steps.len(),
321 warnings: warnings.clone(),
322 };
323
324 match output_format {
325 OutputFormat::Table => {
326 if warnings.is_empty() {
327 println!(
328 "{} Pipeline '{}' is valid!",
329 "✓".green().bold(),
330 pipeline.name
331 );
332 println!(" {} {} steps", "Steps:".bold(), result.steps_count);
333 } else {
334 println!("{} Pipeline has warnings:", "!".yellow().bold());
335 for warning in &warnings {
336 println!(" {} {}", "•".yellow(), warning);
337 }
338 }
339 }
340 _ => {
341 output_format.write(&result)?;
342 }
343 }
344
345 Ok(())
346}
347
348fn generate_sample(output: &PathBuf, output_format: OutputFormat) -> Result<()> {
349 let ts = std::time::SystemTime::now()
350 .duration_since(std::time::UNIX_EPOCH)
351 .unwrap_or_default()
352 .as_millis();
353 let bucket_name = format!("raps-sample-{ts}");
354 let sample = Pipeline {
355 name: "Sample Pipeline".to_string(),
356 description: Some("Example pipeline demonstrating raps automation".to_string()),
357 variables: [
358 ("BUCKET".to_string(), bucket_name),
359 ("PROJECT_ID".to_string(), "12345".to_string()),
360 ]
361 .into_iter()
362 .collect(),
363 steps: vec![
364 PipelineStep {
365 name: "List buckets".to_string(),
366 command: "bucket list".to_string(),
367 continue_on_error: false,
368 condition: None,
369 },
370 PipelineStep {
371 name: "Create bucket".to_string(),
372 command: "bucket create -k ${BUCKET} -p transient -r US".to_string(),
373 continue_on_error: true,
374 condition: None,
375 },
376 PipelineStep {
377 name: "List objects".to_string(),
378 command: "object list ${BUCKET}".to_string(),
379 continue_on_error: false,
380 condition: None,
381 },
382 PipelineStep {
383 name: "Delete bucket".to_string(),
384 command: "bucket delete ${BUCKET} -y".to_string(),
385 continue_on_error: true,
386 condition: None,
387 },
388 ],
389 };
390
391 let content = if output.extension().map(|e| e == "json").unwrap_or(false) {
392 serde_json::to_string_pretty(&sample)?
393 } else {
394 serde_yaml::to_string(&sample)?
395 };
396
397 std::fs::write(output, &content)
398 .with_context(|| format!("Failed to write sample pipeline to {}", output.display()))?;
399
400 match output_format {
401 OutputFormat::Table => {
402 println!(
403 "{} Sample pipeline written to {}",
404 "✓".green().bold(),
405 output.display().to_string().cyan()
406 );
407 }
408 _ => {
409 #[derive(Serialize)]
410 struct SampleOutput {
411 success: bool,
412 path: String,
413 }
414 output_format.write(&SampleOutput {
415 success: true,
416 path: output.display().to_string(),
417 })?;
418 }
419 }
420
421 Ok(())
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 #[test]
429 fn test_pipeline_deserialization_yaml() {
430 let yaml = r#"
431name: Test Pipeline
432description: A test pipeline
433variables:
434 BUCKET: test-bucket
435steps:
436 - name: Step 1
437 command: bucket list
438 - name: Step 2
439 command: object list ${BUCKET}
440 continue_on_error: true
441"#;
442
443 let pipeline: Pipeline = serde_yaml::from_str(yaml).unwrap();
444 assert_eq!(pipeline.name, "Test Pipeline");
445 assert_eq!(pipeline.steps.len(), 2);
446 assert_eq!(
447 pipeline.variables.get("BUCKET"),
448 Some(&"test-bucket".to_string())
449 );
450 assert!(!pipeline.steps[0].continue_on_error);
451 assert!(pipeline.steps[1].continue_on_error);
452 }
453
454 #[test]
455 fn test_pipeline_deserialization_json() {
456 let json = r#"{
457 "name": "Test Pipeline",
458 "steps": [
459 {"name": "Step 1", "command": "bucket list"}
460 ]
461 }"#;
462
463 let pipeline: Pipeline = serde_json::from_str(json).unwrap();
464 assert_eq!(pipeline.name, "Test Pipeline");
465 assert_eq!(pipeline.steps.len(), 1);
466 }
467
468 #[test]
469 fn test_evaluate_condition_truthy() {
470 assert!(evaluate_condition("true"));
471 assert!(evaluate_condition("1"));
472 assert!(evaluate_condition("yes"));
473 assert!(evaluate_condition("anything"));
474 }
475
476 #[test]
477 fn test_evaluate_condition_falsy() {
478 assert!(!evaluate_condition("false"));
479 assert!(!evaluate_condition("0"));
480 assert!(!evaluate_condition(""));
481 assert!(!evaluate_condition(" "));
482 }
483
484 #[test]
485 fn test_pipeline_step_defaults() {
486 let yaml = r#"
487name: Test
488command: bucket list
489"#;
490 let step: PipelineStep = serde_yaml::from_str(yaml).unwrap();
491 assert!(!step.continue_on_error);
492 assert!(step.condition.is_none());
493 }
494}