Skip to main content

raps_cli/commands/admin/
operations.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2025 Dmytro Yemelianov
3
4//! Operation management command implementations
5
6use anyhow::Result;
7use colored::Colorize;
8use serde::Serialize;
9
10use raps_admin::{BulkOperationResult, ItemResult, OperationStatus, StateManager};
11
12use crate::output::OutputFormat;
13
14use super::OperationCommands;
15
16#[derive(Serialize)]
17struct OperationStatusOutput {
18    operation_id: String,
19    operation_type: String,
20    status: String,
21    total: usize,
22    completed: usize,
23    skipped: usize,
24    failed: usize,
25    created_at: String,
26    updated_at: String,
27}
28
29#[derive(Serialize)]
30struct OperationListOutput {
31    operation_id: String,
32    operation_type: String,
33    status: String,
34    progress: String,
35    updated_at: String,
36}
37
38pub(crate) fn format_status(status: &str) -> String {
39    match status.to_lowercase().as_str() {
40        "completed" => status.green().to_string(),
41        "failed" => status.red().to_string(),
42        "inprogress" | "in_progress" => status.yellow().to_string(),
43        "cancelled" => status.dimmed().to_string(),
44        _ => status.to_string(),
45    }
46}
47
48/// Output format for bulk operation results
49#[derive(Serialize)]
50struct BulkResultOutput {
51    operation_id: String,
52    total: usize,
53    completed: usize,
54    skipped: usize,
55    failed: usize,
56    duration_secs: f64,
57    details: Vec<BulkResultDetailOutput>,
58}
59
60#[derive(Serialize)]
61struct BulkResultDetailOutput {
62    project_id: String,
63    project_name: Option<String>,
64    status: String,
65    message: Option<String>,
66    attempts: u32,
67}
68
69/// Display bulk operation results
70pub(crate) fn display_bulk_result(
71    result: &BulkOperationResult,
72    output_format: OutputFormat,
73) -> Result<()> {
74    let details: Vec<BulkResultDetailOutput> = result
75        .details
76        .iter()
77        .map(|d| {
78            let (status, message) = match &d.result {
79                ItemResult::Success => ("success".to_string(), None),
80                ItemResult::Skipped { reason } => ("skipped".to_string(), Some(reason.clone())),
81                ItemResult::Failed { error, .. } => ("failed".to_string(), Some(error.clone())),
82            };
83            BulkResultDetailOutput {
84                project_id: d.project_id.clone(),
85                project_name: d.project_name.clone(),
86                status,
87                message,
88                attempts: d.attempts,
89            }
90        })
91        .collect();
92
93    let output = BulkResultOutput {
94        operation_id: result.operation_id.to_string(),
95        total: result.total,
96        completed: result.completed,
97        skipped: result.skipped,
98        failed: result.failed,
99        duration_secs: result.duration.as_secs_f64(),
100        details,
101    };
102
103    match output_format {
104        OutputFormat::Table => {
105            println!("\n{}", "Bulk Operation Results:".bold());
106            println!("{}", "\u{2500}".repeat(60));
107            println!("{:<15} {}", "Operation:".bold(), output.operation_id.cyan());
108            println!("{:<15} {}", "Total:".bold(), output.total);
109            println!(
110                "{:<15} {}",
111                "Completed:".bold(),
112                output.completed.to_string().green()
113            );
114            println!(
115                "{:<15} {}",
116                "Skipped:".bold(),
117                output.skipped.to_string().yellow()
118            );
119            println!(
120                "{:<15} {}",
121                "Failed:".bold(),
122                output.failed.to_string().red()
123            );
124            println!("{:<15} {:.2}s", "Duration:".bold(), output.duration_secs);
125            println!("{}", "\u{2500}".repeat(60));
126
127            // Show failed items if any
128            if result.failed > 0 {
129                println!("\n{}", "Failed Projects:".red().bold());
130                for detail in &output.details {
131                    if detail.status == "failed" {
132                        let name = detail.project_name.as_deref().unwrap_or(&detail.project_id);
133                        let msg = detail.message.as_deref().unwrap_or("Unknown error");
134                        println!("  {} {} - {}", "\u{2717}".red(), name, msg.dimmed());
135                    }
136                }
137            }
138
139            // Summary
140            println!();
141            if result.failed == 0 && result.total > 0 {
142                println!(
143                    "{} Operation completed successfully!",
144                    "\u{2713}".green().bold()
145                );
146            } else if result.failed > 0 {
147                println!(
148                    "{} Operation completed with {} failure(s)",
149                    "\u{26A0}".yellow().bold(),
150                    result.failed
151                );
152            }
153        }
154        _ => {
155            output_format.write(&output)?;
156        }
157    }
158
159    Ok(())
160}
161
162impl OperationCommands {
163    pub async fn execute(self, output_format: OutputFormat) -> Result<()> {
164        match self {
165            OperationCommands::Status { operation_id } => {
166                let state_manager = StateManager::new()?;
167
168                let op_id = match operation_id {
169                    Some(id) => id,
170                    None => {
171                        // Get most recent operation
172                        let ops = state_manager.list_operations(None).await?;
173                        if ops.is_empty() {
174                            anyhow::bail!("No operations found");
175                        }
176                        ops[0].operation_id
177                    }
178                };
179
180                let state = state_manager.load_operation(op_id).await?;
181
182                let output = OperationStatusOutput {
183                    operation_id: state.operation_id.to_string(),
184                    operation_type: format!("{:?}", state.operation_type),
185                    status: format!("{:?}", state.status),
186                    total: state.project_ids.len(),
187                    completed: state
188                        .results
189                        .values()
190                        .filter(|r| matches!(r.result, raps_admin::ItemResult::Success))
191                        .count(),
192                    skipped: state
193                        .results
194                        .values()
195                        .filter(|r| matches!(r.result, raps_admin::ItemResult::Skipped { .. }))
196                        .count(),
197                    failed: state
198                        .results
199                        .values()
200                        .filter(|r| matches!(r.result, raps_admin::ItemResult::Failed { .. }))
201                        .count(),
202                    created_at: state.created_at.to_rfc3339(),
203                    updated_at: state.updated_at.to_rfc3339(),
204                };
205
206                match output_format {
207                    OutputFormat::Table => {
208                        println!("\n{}", "Operation Status:".bold());
209                        println!("{}", "\u{2500}".repeat(60));
210                        println!("{:<15} {}", "Operation:".bold(), output.operation_id.cyan());
211                        println!("{:<15} {}", "Type:".bold(), output.operation_type);
212                        println!("{:<15} {}", "Status:".bold(), format_status(&output.status));
213                        println!(
214                            "{:<15} {}/{} ({}%)",
215                            "Progress:".bold(),
216                            output.completed + output.skipped + output.failed,
217                            output.total,
218                            if output.total > 0 {
219                                ((output.completed + output.skipped + output.failed) * 100)
220                                    / output.total
221                            } else {
222                                100
223                            }
224                        );
225                        println!(
226                            "{:<15} {}",
227                            "Completed:".bold(),
228                            output.completed.to_string().green()
229                        );
230                        println!(
231                            "{:<15} {}",
232                            "Skipped:".bold(),
233                            output.skipped.to_string().yellow()
234                        );
235                        println!(
236                            "{:<15} {}",
237                            "Failed:".bold(),
238                            output.failed.to_string().red()
239                        );
240                        println!("{:<15} {}", "Created:".bold(), output.created_at);
241                        println!("{:<15} {}", "Updated:".bold(), output.updated_at);
242                        println!("{}", "\u{2500}".repeat(60));
243                    }
244                    _ => {
245                        output_format.write(&output)?;
246                    }
247                }
248
249                Ok(())
250            }
251
252            OperationCommands::Resume {
253                operation_id,
254                concurrency,
255            } => {
256                let state_manager = StateManager::new()?;
257
258                // Find operation to resume
259                let op_id = match operation_id {
260                    Some(id) => id,
261                    None => {
262                        // Get most recent resumable operation
263                        match state_manager.get_resumable_operation().await? {
264                            Some(id) => id,
265                            None => anyhow::bail!("No resumable operation found"),
266                        }
267                    }
268                };
269
270                let state = state_manager.load_operation(op_id).await?;
271
272                // Verify operation can be resumed
273                if state.status != OperationStatus::InProgress
274                    && state.status != OperationStatus::Pending
275                {
276                    anyhow::bail!(
277                        "Operation cannot be resumed (current status: {:?})",
278                        state.status
279                    );
280                }
281
282                let pending = state_manager.get_pending_projects(&state);
283                if pending.is_empty() {
284                    if output_format.supports_colors() {
285                        println!(
286                            "{} Operation {} is already complete",
287                            "\u{2713}".green(),
288                            op_id
289                        );
290                    }
291                    return Ok(());
292                }
293
294                let concurrency_limit = concurrency.unwrap_or(10).min(50);
295
296                if output_format.supports_colors() {
297                    println!(
298                        "\n{} Resuming operation: {}",
299                        "\u{2192}".cyan(),
300                        op_id.to_string().cyan()
301                    );
302                    println!("  Type: {:?}", state.operation_type);
303                    println!(
304                        "  Pending: {}/{} items",
305                        pending.len(),
306                        state.project_ids.len()
307                    );
308                    println!("  Concurrency: {}", concurrency_limit);
309                    println!();
310
311                    // Note: For full resume support, we'd need the original API clients
312                    // For now, just report pending items
313                    println!(
314                        "{} Resume requires re-running with the original command and credentials.",
315                        "\u{26A0}".yellow()
316                    );
317                    println!("  Pending projects:");
318                    for (i, project_id) in pending.iter().take(10).enumerate() {
319                        println!("    {}. {}", i + 1, project_id.dimmed());
320                    }
321                    if pending.len() > 10 {
322                        println!("    ... and {} more", pending.len() - 10);
323                    }
324                }
325
326                Ok(())
327            }
328
329            OperationCommands::Cancel {
330                operation_id,
331                yes: _,
332            } => {
333                let state_manager = StateManager::new()?;
334
335                // Find operation to cancel
336                let op_id = match operation_id {
337                    Some(id) => id,
338                    None => {
339                        // Get most recent in-progress operation
340                        match state_manager.get_resumable_operation().await? {
341                            Some(id) => id,
342                            None => anyhow::bail!("No active operation found to cancel"),
343                        }
344                    }
345                };
346
347                let state = state_manager.load_operation(op_id).await?;
348
349                if output_format.supports_colors() {
350                    println!(
351                        "\n{} Cancelling operation: {}",
352                        "\u{2192}".cyan(),
353                        op_id.to_string().cyan()
354                    );
355                    println!("  Type: {:?}", state.operation_type);
356                    println!("  Current status: {:?}", state.status);
357                }
358
359                // Cancel the operation
360                state_manager.cancel_operation(op_id).await?;
361
362                if output_format.supports_colors() {
363                    let processed = state.results.len();
364                    let total = state.project_ids.len();
365                    println!("\n{} Operation cancelled", "\u{2713}".green());
366                    println!(
367                        "  Processed: {}/{} items before cancellation",
368                        processed, total
369                    );
370                }
371
372                Ok(())
373            }
374
375            OperationCommands::List { status, limit } => {
376                let state_manager = StateManager::new()?;
377
378                let status_filter = status
379                    .as_ref()
380                    .and_then(|s| match s.to_lowercase().as_str() {
381                        "pending" => Some(OperationStatus::Pending),
382                        "in_progress" | "in-progress" => Some(OperationStatus::InProgress),
383                        "completed" => Some(OperationStatus::Completed),
384                        "failed" => Some(OperationStatus::Failed),
385                        "cancelled" => Some(OperationStatus::Cancelled),
386                        _ => None,
387                    });
388
389                let operations = state_manager.list_operations(status_filter).await?;
390                let operations: Vec<_> = operations.into_iter().take(limit).collect();
391
392                if operations.is_empty() {
393                    match output_format {
394                        OutputFormat::Table => println!("{}", "No operations found.".yellow()),
395                        _ => output_format.write(&Vec::<OperationListOutput>::new())?,
396                    }
397                    return Ok(());
398                }
399
400                let outputs: Vec<OperationListOutput> = operations
401                    .iter()
402                    .map(|op| OperationListOutput {
403                        operation_id: op.operation_id.to_string(),
404                        operation_type: format!("{:?}", op.operation_type),
405                        status: format!("{:?}", op.status),
406                        progress: format!("{}/{}", op.completed + op.skipped + op.failed, op.total),
407                        updated_at: op.updated_at.to_rfc3339(),
408                    })
409                    .collect();
410
411                match output_format {
412                    OutputFormat::Table => {
413                        println!("\n{}", "Operations:".bold());
414                        println!("{}", "\u{2500}".repeat(100));
415                        println!(
416                            "{:<38} {:<15} {:<12} {:<12} {}",
417                            "ID".bold(),
418                            "Type".bold(),
419                            "Status".bold(),
420                            "Progress".bold(),
421                            "Updated".bold()
422                        );
423                        println!("{}", "\u{2500}".repeat(100));
424
425                        for op in &outputs {
426                            println!(
427                                "{:<38} {:<15} {:<12} {:<12} {}",
428                                op.operation_id.cyan(),
429                                op.operation_type,
430                                format_status(&op.status),
431                                op.progress,
432                                op.updated_at.dimmed()
433                            );
434                        }
435
436                        println!("{}", "\u{2500}".repeat(100));
437                        println!("{} {} operation(s) found", "\u{2192}".cyan(), outputs.len());
438                    }
439                    _ => {
440                        output_format.write(&outputs)?;
441                    }
442                }
443
444                Ok(())
445            }
446        }
447    }
448}