void-cli 0.0.2

CLI for void — anonymous encrypted source control
//! Conflicts command — list conflicted files during a merge.
//!
//! Reads the merge state from `.void/` and reports the list of
//! paths that still have unresolved conflicts.

use std::path::Path;

use serde::Serialize;
use void_core::ops::merge_state::{is_merge_in_progress, read_merge_state};

use crate::context::{find_void_dir, void_err_to_cli};
use crate::output::{run_command, CliError, CliOptions};

/// JSON output for the conflicts command.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ConflictsOutput {
    /// Whether a merge is currently in progress.
    pub merge_in_progress: bool,
    /// CID of the commit being merged (hex).
    pub merge_head: String,
    /// CID of the common ancestor (hex), if any.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub merge_base: Option<String>,
    /// CID of HEAD before merge started (hex).
    pub orig_head: String,
    /// Conflicted file paths.
    pub conflicts: Vec<String>,
    /// Pre-populated merge commit message.
    pub message: String,
}

/// Run the conflicts command.
pub fn run(cwd: &Path, opts: &CliOptions) -> Result<(), CliError> {
    run_command("conflicts", opts, |ctx| {
        let void_dir = find_void_dir(cwd)?;

        if !is_merge_in_progress(&void_dir) {
            return Err(CliError::conflict("no merge in progress"));
        }

        let merge_state = read_merge_state(&void_dir)
            .map_err(void_err_to_cli)?
            .ok_or_else(|| CliError::conflict("no merge in progress"))?;

        // Human-readable output
        if !ctx.use_json() {
            if merge_state.conflicts.is_empty() {
                ctx.info("No conflicts. Ready to commit.");
            } else {
                ctx.info(format!(
                    "{} conflicted file(s):",
                    merge_state.conflicts.len()
                ));
                for path in &merge_state.conflicts {
                    ctx.info(format!("  {}", path));
                }
            }
        }

        Ok(ConflictsOutput {
            merge_in_progress: true,
            merge_head: hex::encode(merge_state.merge_head.as_bytes()),
            merge_base: merge_state.merge_base.as_ref().map(|b| hex::encode(b.as_bytes())),
            orig_head: hex::encode(merge_state.orig_head.as_bytes()),
            conflicts: merge_state.conflicts,
            message: merge_state.message,
        })
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::output::CliOptions;
    use std::fs;
    use tempfile::tempdir;
    use void_core::crypto::{self, CommitCid};
    use void_core::ops::merge_state::{write_merge_state, MergeState};

    fn default_opts() -> CliOptions {
        CliOptions {
            human: true,
            ..Default::default()
        }
    }

    fn setup_test_repo() -> (tempfile::TempDir, std::path::PathBuf, tempfile::TempDir, crate::context::VoidHomeGuard) {
        let dir = tempdir().unwrap();
        let void_dir = dir.path().join(".void");
        fs::create_dir_all(void_dir.join("objects")).unwrap();
        fs::create_dir_all(void_dir.join("refs/heads")).unwrap();

        let key = crypto::generate_key();
        let home = tempdir().unwrap();
        let guard = crate::context::setup_test_manifest(&void_dir, &key, home.path());
        fs::write(void_dir.join("config.json"), "{}").unwrap();

        (dir, void_dir, home, guard)
    }

    #[test]
    fn test_conflicts_no_merge() {
        let (dir, _void_dir, _home, _guard) = setup_test_repo();
        let result = run(dir.path(), &default_opts());
        assert!(result.is_err());
    }

    #[test]
    fn test_conflicts_with_merge() {
        let (dir, void_dir, _home, _guard) = setup_test_repo();

        let state = MergeState {
            merge_head: CommitCid::from_bytes(vec![0x01, 0x02, 0x03]),
            merge_base: None,
            orig_head: CommitCid::from_bytes(vec![0x04, 0x05, 0x06]),
            conflicts: vec!["src/main.rs".to_string(), "README.md".to_string()],
            message: "Merge branch 'feature'".to_string(),
        };
        write_merge_state(&void_dir, &state).unwrap();

        // Should succeed (exits via process in real usage, but run_command returns Ok)
        let result = run(dir.path(), &default_opts());
        assert!(result.is_ok());
    }

    #[test]
    fn test_conflicts_output_serialization() {
        let output = ConflictsOutput {
            merge_in_progress: true,
            merge_head: "010203".to_string(),
            merge_base: None,
            orig_head: "040506".to_string(),
            conflicts: vec!["src/main.rs".to_string()],
            message: "Merge".to_string(),
        };

        let json = serde_json::to_string(&output).unwrap();
        assert!(json.contains("\"mergeInProgress\":true"));
        assert!(json.contains("\"mergeHead\":\"010203\""));
        assert!(!json.contains("mergeBase")); // skip_serializing_if None
        assert!(json.contains("\"conflicts\":[\"src/main.rs\"]"));
    }

    #[test]
    fn test_conflicts_output_with_base() {
        let output = ConflictsOutput {
            merge_in_progress: true,
            merge_head: "aabbcc".to_string(),
            merge_base: Some("ddeeff".to_string()),
            orig_head: "112233".to_string(),
            conflicts: vec![],
            message: "Merge".to_string(),
        };

        let json = serde_json::to_string(&output).unwrap();
        assert!(json.contains("\"mergeBase\":\"ddeeff\""));
    }
}