use crate::cli::output::Output;
use crate::core::manifest::Manifest;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::process::Command;
use std::time::Instant;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepResult {
pub name: String,
pub command: String,
pub success: bool,
pub exit_code: Option<i32>,
pub duration_ms: u64,
pub output: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PipelineResult {
pub pipeline: String,
pub success: bool,
pub steps: Vec<StepResult>,
pub total_duration_ms: u64,
pub timestamp: String,
}
pub fn run_ci_run(
workspace_root: &Path,
manifest: &Manifest,
pipeline_name: &str,
json: bool,
) -> anyhow::Result<()> {
let ci_config = manifest
.workspace
.as_ref()
.and_then(|w| w.ci.as_ref())
.and_then(|ci| ci.pipelines.as_ref());
let pipelines = match ci_config {
Some(p) => p,
None => {
anyhow::bail!(
"No CI pipelines defined in manifest. Add a 'workspace.ci.pipelines' section."
);
}
};
let pipeline = match pipelines.get(pipeline_name) {
Some(p) => p,
None => {
let available: Vec<&String> = pipelines.keys().collect();
anyhow::bail!(
"Pipeline '{}' not found. Available: {:?}",
pipeline_name,
available
);
}
};
if !json {
Output::header(&format!("Running pipeline: {}", pipeline_name));
if let Some(ref desc) = pipeline.description {
Output::info(desc);
}
println!();
}
let pipeline_start = Instant::now();
let mut step_results: Vec<StepResult> = Vec::new();
let mut pipeline_success = true;
for step in &pipeline.steps {
if !json {
let spinner = Output::spinner(&format!("Running: {}...", step.name));
let result = run_step(workspace_root, step, manifest);
let step_result = result;
if step_result.success {
spinner.finish_with_message(format!(
"{}: passed ({}ms)",
step.name, step_result.duration_ms
));
} else {
spinner.finish_with_message(format!(
"{}: FAILED (exit {})",
step.name,
step_result.exit_code.unwrap_or(-1)
));
if !step_result.output.is_empty() {
eprintln!("{}", step_result.output);
}
if !step.continue_on_error {
pipeline_success = false;
step_results.push(step_result);
break;
}
pipeline_success = false;
}
step_results.push(step_result);
} else {
let step_result = run_step(workspace_root, step, manifest);
let failed = !step_result.success;
step_results.push(step_result);
if failed && !step.continue_on_error {
pipeline_success = false;
break;
}
if failed {
pipeline_success = false;
}
}
}
let total_duration_ms = pipeline_start.elapsed().as_millis() as u64;
let result = PipelineResult {
pipeline: pipeline_name.to_string(),
success: pipeline_success,
steps: step_results,
total_duration_ms,
timestamp: Utc::now().to_rfc3339(),
};
save_ci_result(workspace_root, &result)?;
if json {
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
println!();
if pipeline_success {
Output::success(&format!(
"Pipeline '{}' passed ({}ms)",
pipeline_name, total_duration_ms
));
} else {
Output::error(&format!(
"Pipeline '{}' failed ({}ms)",
pipeline_name, total_duration_ms
));
}
}
if !pipeline_success {
anyhow::bail!("Pipeline '{}' failed", result.pipeline);
}
Ok(())
}
pub fn run_ci_list(manifest: &Manifest, json: bool) -> anyhow::Result<()> {
let ci_config = manifest
.workspace
.as_ref()
.and_then(|w| w.ci.as_ref())
.and_then(|ci| ci.pipelines.as_ref());
let pipelines = match ci_config {
Some(p) => p,
None => {
if json {
println!("[]");
} else {
println!("No CI pipelines defined.");
}
return Ok(());
}
};
if json {
#[derive(Serialize)]
struct PipelineInfo {
name: String,
description: Option<String>,
steps: usize,
}
let infos: Vec<PipelineInfo> = pipelines
.iter()
.map(|(name, pipeline)| PipelineInfo {
name: name.clone(),
description: pipeline.description.clone(),
steps: pipeline.steps.len(),
})
.collect();
println!("{}", serde_json::to_string_pretty(&infos)?);
} else {
Output::header("CI Pipelines");
println!();
for (name, pipeline) in pipelines {
let desc = pipeline
.description
.as_deref()
.unwrap_or("(no description)");
println!(" {} - {} ({} steps)", name, desc, pipeline.steps.len());
}
}
Ok(())
}
pub fn run_ci_status(workspace_root: &Path, json: bool) -> anyhow::Result<()> {
let results_dir = workspace_root.join(".gitgrip").join("ci-results");
if !results_dir.exists() {
if json {
println!("[]");
} else {
println!("No CI results found. Run 'gr ci run <pipeline>' first.");
}
return Ok(());
}
let mut results: Vec<PipelineResult> = Vec::new();
for entry in std::fs::read_dir(&results_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("json") {
let content = std::fs::read_to_string(&path)?;
if let Ok(result) = serde_json::from_str::<PipelineResult>(&content) {
results.push(result);
}
}
}
results.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
if json {
println!("{}", serde_json::to_string_pretty(&results)?);
} else {
Output::header("CI Status");
println!();
if results.is_empty() {
println!(" No results found.");
} else {
for result in &results {
let status = if result.success { "PASS" } else { "FAIL" };
let passed = result.steps.iter().filter(|s| s.success).count();
let total = result.steps.len();
println!(
" {} {} - {} ({}/{} steps, {}ms) [{}]",
if result.success { "✓" } else { "✗" },
result.pipeline,
status,
passed,
total,
result.total_duration_ms,
result.timestamp
);
}
}
}
Ok(())
}
fn run_step(
workspace_root: &Path,
step: &crate::core::manifest::CiStep,
manifest: &Manifest,
) -> StepResult {
let start = Instant::now();
let cwd = match &step.cwd {
Some(dir) => workspace_root.join(dir),
None => workspace_root.to_path_buf(),
};
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(&step.command).current_dir(&cwd);
if let Some(ref workspace) = manifest.workspace {
if let Some(ref env) = workspace.env {
for (key, value) in env {
cmd.env(key, value);
}
}
}
if let Some(ref env) = step.env {
for (key, value) in env {
cmd.env(key, value);
}
}
match cmd.output() {
Ok(output) => {
let duration_ms = start.elapsed().as_millis() as u64;
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let combined = if stderr.is_empty() {
stdout.to_string()
} else {
format!("{}\n{}", stdout, stderr)
};
StepResult {
name: step.name.clone(),
command: step.command.clone(),
success: output.status.success(),
exit_code: output.status.code(),
duration_ms,
output: combined,
}
}
Err(e) => StepResult {
name: step.name.clone(),
command: step.command.clone(),
success: false,
exit_code: None,
duration_ms: start.elapsed().as_millis() as u64,
output: format!("Failed to execute: {}", e),
},
}
}
fn save_ci_result(workspace_root: &Path, result: &PipelineResult) -> anyhow::Result<()> {
let results_dir = workspace_root.join(".gitgrip").join("ci-results");
std::fs::create_dir_all(&results_dir)?;
let filename = format!("{}.json", result.pipeline);
let path = results_dir.join(filename);
let json = serde_json::to_string_pretty(result)?;
std::fs::write(path, json)?;
Ok(())
}