collet 0.1.0

Relentless agentic coding orchestrator with zero-drop agent loops
Documentation
//! Conflict detection and resolution for parallel agent results.

use std::collections::HashSet;

use crate::agent::swarm::knowledge::{FileModification, SharedKnowledge};

/// A detected file-level conflict between agents.
#[derive(Debug, Clone)]
pub struct FileConflict {
    /// Path of the conflicting file.
    pub path: String,
    /// Agent IDs that modified this file.
    pub agents: Vec<String>,
    /// Resolution outcome (if resolved).
    pub resolution: Option<ConflictResolutionOutcome>,
}

/// Outcome of conflict resolution.
#[derive(Debug, Clone)]
pub enum ConflictResolutionOutcome {
    /// Automatically merged (non-overlapping edits).
    AutoMerged,
    /// Coordinator LLM resolved the conflict.
    CoordinatorResolved {
        /// Coordinator's reasoning, shown in TUI conflict panel and debug log.
        explanation: String,
    },
    /// User manually resolved the conflict via interactive TUI selection.
    UserResolved { choice: String },
}

/// Detect file-level conflicts from the shared knowledge base.
///
/// Returns files modified by more than one agent.
pub async fn detect_conflicts(knowledge: &SharedKnowledge) -> Vec<FileConflict> {
    let conflicting = knowledge.conflicting_files().await;

    conflicting
        .into_iter()
        .map(|(path, mods)| {
            let agents: Vec<String> = mods
                .iter()
                .map(|m| m.agent_id.clone())
                .collect::<HashSet<_>>()
                .into_iter()
                .collect();

            FileConflict {
                path,
                agents,
                resolution: None,
            }
        })
        .collect()
}

/// Check if two modifications to the same file can potentially be auto-merged.
///
/// Returns true if the second modification was based on the first's output
/// (sequential edits to the same file).
pub fn can_auto_merge(mod_a: &FileModification, mod_b: &FileModification) -> bool {
    use super::knowledge::ModificationType;
    match (&mod_a.modification_type, &mod_b.modification_type) {
        // Can't merge creates or deletes
        (ModificationType::Created, _) | (_, ModificationType::Created) => false,
        (ModificationType::Deleted, _) | (_, ModificationType::Deleted) => false,
        // Sequential edits: second was based on first's output
        (
            ModificationType::Edited { new_hash: h1, .. },
            ModificationType::Edited { old_hash: h2, .. },
        ) => h1 == h2,
    }
}

/// Attempt to auto-merge conflicts where sequential edits allow it.
///
/// Iterates over all detected conflicts and resolves those where every pair of
/// modifications forms a sequential chain (`can_auto_merge` returns true for
/// each consecutive pair). Resolved conflicts are marked with
/// `ConflictResolutionOutcome::AutoMerged` in-place.
///
/// Returns the number of conflicts that were auto-merged.
pub async fn try_auto_merge_conflicts(
    conflicts: &mut [FileConflict],
    knowledge: &SharedKnowledge,
) -> usize {
    let mut resolved = 0;
    for conflict in conflicts.iter_mut() {
        if conflict.resolution.is_some() {
            continue;
        }
        let mods = knowledge.file_modifications(&conflict.path).await;
        if mods.len() >= 2 {
            // All consecutive pairs must be mergeable (sequential edit chain).
            let all_sequential = mods.windows(2).all(|w| can_auto_merge(&w[0], &w[1]));
            if all_sequential {
                conflict.resolution = Some(ConflictResolutionOutcome::AutoMerged);
                resolved += 1;
                let hashes: Vec<u64> = mods.iter().map(|m| m.content_hash).collect();
                tracing::info!(
                    path = %conflict.path,
                    agents = ?conflict.agents,
                    content_hashes = ?hashes,
                    "Auto-merged conflict (sequential edits)"
                );
            }
        }
    }
    resolved
}

/// Summarize conflicts for diagnostics and display.
pub fn conflicts_summary(conflicts: &[FileConflict]) -> Vec<(String, Vec<String>)> {
    conflicts
        .iter()
        .map(|c| (c.path.clone(), c.agents.clone()))
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::agent::swarm::knowledge::ModificationType;
    use std::time::Instant;

    fn make_mod(agent_id: &str, mod_type: ModificationType) -> FileModification {
        FileModification {
            agent_id: agent_id.to_string(),
            modification_type: mod_type,
            content_hash: 0,
            timestamp: Instant::now(),
            content_snapshot: None,
        }
    }

    #[test]
    fn test_can_auto_merge_sequential() {
        let a = make_mod(
            "a1",
            ModificationType::Edited {
                old_hash: 100,
                new_hash: 200,
            },
        );
        let b = make_mod(
            "a2",
            ModificationType::Edited {
                old_hash: 200,
                new_hash: 300,
            },
        );
        assert!(can_auto_merge(&a, &b));
    }

    #[test]
    fn test_cannot_auto_merge_parallel() {
        let a = make_mod(
            "a1",
            ModificationType::Edited {
                old_hash: 100,
                new_hash: 200,
            },
        );
        let b = make_mod(
            "a2",
            ModificationType::Edited {
                old_hash: 100,
                new_hash: 300,
            },
        );
        assert!(!can_auto_merge(&a, &b));
    }

    #[test]
    fn test_cannot_auto_merge_create() {
        let a = make_mod("a1", ModificationType::Created);
        let b = make_mod(
            "a2",
            ModificationType::Edited {
                old_hash: 0,
                new_hash: 100,
            },
        );
        assert!(!can_auto_merge(&a, &b));
    }

    #[tokio::test]
    async fn test_detect_conflicts() {
        let kb = SharedKnowledge::new();
        kb.record_file_modification("a1", "src/lib.rs", ModificationType::Created, 100, None)
            .await;
        kb.record_file_modification("a2", "src/lib.rs", ModificationType::Created, 200, None)
            .await;
        kb.record_file_modification("a1", "src/main.rs", ModificationType::Created, 300, None)
            .await;

        let conflicts = detect_conflicts(&kb).await;
        assert_eq!(conflicts.len(), 1);
        assert_eq!(conflicts[0].path, "src/lib.rs");
        assert_eq!(conflicts[0].agents.len(), 2);
    }

    #[test]
    fn test_conflicts_summary() {
        let conflicts = vec![FileConflict {
            path: "src/lib.rs".to_string(),
            agents: vec!["a1".to_string(), "a2".to_string()],
            resolution: None,
        }];
        let summary = conflicts_summary(&conflicts);
        assert_eq!(summary.len(), 1);
        assert_eq!(summary[0].0, "src/lib.rs");
    }

    #[test]
    fn test_cannot_auto_merge_delete() {
        let a = make_mod(
            "a1",
            ModificationType::Edited {
                old_hash: 100,
                new_hash: 200,
            },
        );
        let b = make_mod("a2", ModificationType::Deleted);
        assert!(!can_auto_merge(&a, &b));
    }

    #[test]
    fn test_resolution_variants_debug() {
        let user = ConflictResolutionOutcome::UserResolved {
            choice: "keep-mine".to_string(),
        };
        let coord = ConflictResolutionOutcome::CoordinatorResolved {
            explanation: "Merged edits".to_string(),
        };
        // Debug formatting reads all fields — ensure they serialize without panic.
        assert!(format!("{user:?}").contains("keep-mine"));
        assert!(format!("{coord:?}").contains("Merged edits"));
    }
}