prodigy 0.4.4

Turn ad-hoc Claude sessions into reproducible development pipelines with parallel AI agents
Documentation
#[cfg(test)]
mod tests {
    use super::super::errors::*;
    use std::path::PathBuf;

    #[test]
    fn test_error_display_messages() {
        let error = MapReduceError::JobInitializationFailed {
            job_id: "job123".to_string(),
            reason: "config invalid".to_string(),
            source: None,
        };
        assert_eq!(
            error.to_string(),
            "Job job123 initialization failed: config invalid"
        );

        let error = MapReduceError::AgentTimeout(Box::new(AgentTimeoutError {
            job_id: "job456".to_string(),
            agent_id: "agent1".to_string(),
            item_id: "item1".to_string(),
            duration_secs: 30,
            last_operation: "processing".to_string(),
        }));
        assert_eq!(
            error.to_string(),
            "Agent timeout: Agent agent1 timeout after 30s"
        );
    }

    #[test]
    fn test_retryable_errors() {
        let timeout = MapReduceError::AgentTimeout(Box::new(AgentTimeoutError {
            job_id: "job1".to_string(),
            agent_id: "agent1".to_string(),
            item_id: "item1".to_string(),
            duration_secs: 60,
            last_operation: "processing".to_string(),
        }));
        assert!(timeout.is_retryable());

        let resource = MapReduceError::ResourceExhausted(Box::new(ResourceExhaustedError {
            job_id: "job1".to_string(),
            agent_id: "agent1".to_string(),
            resource: ResourceType::Memory,
            limit: "1GB".to_string(),
            usage: "1.2GB".to_string(),
        }));
        assert!(resource.is_retryable());

        let worktree = MapReduceError::WorktreeCreationFailed {
            agent_id: "agent1".to_string(),
            reason: "disk full".to_string(),
            source: std::io::Error::other("disk full"),
        };
        assert!(worktree.is_retryable());

        let checkpoint = MapReduceError::CheckpointPersistFailed {
            job_id: "job1".to_string(),
            path: PathBuf::from("/tmp/checkpoint"),
            source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
        };
        assert!(checkpoint.is_retryable());

        let config = MapReduceError::InvalidConfiguration {
            reason: "bad value".to_string(),
            field: "timeout".to_string(),
            value: "-1".to_string(),
        };
        assert!(!config.is_retryable());

        let not_found = MapReduceError::JobNotFound {
            job_id: "missing".to_string(),
        };
        assert!(!not_found.is_retryable());
    }

    #[test]
    fn test_recovery_hints() {
        let resource = MapReduceError::ResourceExhausted(Box::new(ResourceExhaustedError {
            job_id: "job1".to_string(),
            agent_id: "agent1".to_string(),
            resource: ResourceType::Memory,
            limit: "1GB".to_string(),
            usage: "1.2GB".to_string(),
        }));
        let hint = resource.recovery_hint();
        assert!(hint.is_some());
        assert!(hint.unwrap().contains("Memory"));

        let merge = MapReduceError::WorktreeMergeConflict {
            agent_id: "agent1".to_string(),
            branch: "feature".to_string(),
            conflicts: vec!["file1.txt".to_string()],
        };
        let hint = merge.recovery_hint();
        assert_eq!(
            hint,
            Some("Manual conflict resolution required".to_string())
        );

        let subst = MapReduceError::ShellSubstitutionFailed {
            variable: "VAR1".to_string(),
            command: "echo $VAR1".to_string(),
            available_vars: vec!["VAR2".to_string(), "VAR3".to_string()],
        };
        let hint = subst.recovery_hint();
        assert!(hint.is_some());
        assert!(hint.unwrap().contains("VAR1"));

        let timeout = MapReduceError::AgentTimeout(Box::new(AgentTimeoutError {
            job_id: "job1".to_string(),
            agent_id: "agent1".to_string(),
            item_id: "item1".to_string(),
            duration_secs: 60,
            last_operation: "processing".to_string(),
        }));
        let hint = timeout.recovery_hint();
        assert!(hint.is_some());
        assert!(hint.unwrap().contains("timeout"));
    }

    #[test]
    fn test_variant_names() {
        let errors = vec![
            (
                MapReduceError::JobInitializationFailed {
                    job_id: "test".to_string(),
                    reason: "test".to_string(),
                    source: None,
                },
                "JobInitializationFailed",
            ),
            (
                MapReduceError::JobAlreadyExists {
                    job_id: "test".to_string(),
                },
                "JobAlreadyExists",
            ),
            (
                MapReduceError::JobNotFound {
                    job_id: "test".to_string(),
                },
                "JobNotFound",
            ),
            (
                MapReduceError::CheckpointCorrupted {
                    job_id: "test".to_string(),
                    version: 1,
                    details: "test".to_string(),
                },
                "CheckpointCorrupted",
            ),
            (
                MapReduceError::AgentFailed(Box::new(AgentFailedError {
                    job_id: "test".to_string(),
                    agent_id: "test".to_string(),
                    item_id: "test".to_string(),
                    reason: "test".to_string(),
                    worktree: None,
                    duration_ms: 0,
                    source: None,
                })),
                "AgentFailed",
            ),
            (
                MapReduceError::AgentTimeout(Box::new(AgentTimeoutError {
                    job_id: "test".to_string(),
                    agent_id: "test".to_string(),
                    item_id: "test".to_string(),
                    duration_secs: 0,
                    last_operation: "test".to_string(),
                })),
                "AgentTimeout",
            ),
        ];

        for (error, expected_name) in errors {
            assert_eq!(error.variant_name(), expected_name);
        }
    }

    #[test]
    fn test_aggregated_error() {
        let errors = vec![
            MapReduceError::AgentTimeout(Box::new(AgentTimeoutError {
                job_id: "job1".to_string(),
                agent_id: "agent1".to_string(),
                item_id: "item1".to_string(),
                duration_secs: 60,
                last_operation: "processing".to_string(),
            })),
            MapReduceError::AgentTimeout(Box::new(AgentTimeoutError {
                job_id: "job1".to_string(),
                agent_id: "agent2".to_string(),
                item_id: "item2".to_string(),
                duration_secs: 60,
                last_operation: "processing".to_string(),
            })),
            MapReduceError::AgentTimeout(Box::new(AgentTimeoutError {
                job_id: "job1".to_string(),
                agent_id: "agent3".to_string(),
                item_id: "item3".to_string(),
                duration_secs: 60,
                last_operation: "processing".to_string(),
            })),
            MapReduceError::JobNotFound {
                job_id: "job2".to_string(),
            },
        ];

        let aggregated = AggregatedError::new(errors);
        assert_eq!(aggregated.total_count, 4);
        assert_eq!(aggregated.most_common_error(), Some("AgentTimeout"));
        assert_eq!(*aggregated.by_type.get("AgentTimeout").unwrap(), 3);
        assert_eq!(*aggregated.by_type.get("JobNotFound").unwrap(), 1);

        let summary = aggregated.summary();
        assert!(summary.contains("Total errors: 4"));
        assert!(summary.contains("AgentTimeout: 3"));
        assert!(summary.contains("JobNotFound: 1"));
    }

    #[test]
    fn test_error_handler_trait() {
        let handler = DefaultErrorHandler;

        let timeout = MapReduceError::AgentTimeout(Box::new(AgentTimeoutError {
            job_id: "job1".to_string(),
            agent_id: "agent1".to_string(),
            item_id: "item1".to_string(),
            duration_secs: 60,
            last_operation: "processing".to_string(),
        }));
        assert!(handler.should_retry(&timeout));

        let action = handler.handle_error(&timeout);
        match action {
            ErrorAction::Retry { delay } => {
                assert_eq!(delay.as_secs(), 10);
            }
            _ => panic!("Expected Retry action"),
        }

        let config = MapReduceError::InvalidConfiguration {
            reason: "bad".to_string(),
            field: "field".to_string(),
            value: "value".to_string(),
        };
        assert!(!handler.should_retry(&config));

        let action = handler.handle_error(&config);
        match action {
            ErrorAction::Propagate => {}
            _ => panic!("Expected Propagate action"),
        }
    }

    #[test]
    fn test_from_anyhow_conversion() {
        let anyhow_err = anyhow::anyhow!("Test error");
        let mapreduce_err: MapReduceError = anyhow_err.into();

        match mapreduce_err {
            MapReduceError::General { message, source } => {
                assert_eq!(message, "Test error");
                assert!(source.is_none());
            }
            _ => panic!("Expected General error variant"),
        }
    }

    #[test]
    fn test_contextual_error() {
        use chrono::Utc;

        let error = MapReduceError::JobNotFound {
            job_id: "test".to_string(),
        };

        let context = ErrorContext {
            correlation_id: "corr123".to_string(),
            timestamp: Utc::now(),
            hostname: "host1".to_string(),
            thread_id: "thread1".to_string(),
            span_trace: vec![],
        };

        let contextual = error.with_context(context.clone());
        let display = contextual.to_string();
        assert!(display.contains("Job test not found"));
        assert!(display.contains("corr123"));
    }
}