void-cli 0.0.2

CLI for void — anonymous encrypted source control
//! Status command implementation.
//!
//! Shows the working tree status including staged changes, unstaged modifications,
//! and untracked files.

use std::io::IsTerminal;
use std::path::Path;

use serde::Serialize;
use void_core::workspace::stage::{status_workspace, StatusOptions};

use crate::context::{build_void_context, void_err_to_cli};
use crate::observer::ProgressObserver;
use crate::output::{run_command, CliError, CliOptions};

/// JSON output structure for status command (nested format matching TS CLI).
#[derive(Debug, Clone, Serialize)]
pub struct StatusOutput {
    pub staged: StagedChanges,
    pub unstaged: UnstagedChanges,
    pub untracked: Vec<String>,
    /// Whether the working tree is clean (no staged, unstaged, or untracked changes).
    pub clean: bool,
}

/// Staged changes structure.
#[derive(Debug, Clone, Serialize)]
pub struct StagedChanges {
    pub added: Vec<String>,
    pub modified: Vec<String>,
    pub deleted: Vec<String>,
}

/// Unstaged changes structure.
#[derive(Debug, Clone, Serialize)]
pub struct UnstagedChanges {
    pub modified: Vec<String>,
    pub deleted: Vec<String>,
}

/// ANSI color codes for terminal output.
mod colors {
    pub const GREEN: &str = "\x1b[32m";
    pub const RED: &str = "\x1b[31m";
    pub const RESET: &str = "\x1b[0m";

    /// Get color code only if colors are enabled.
    pub fn green(use_colors: bool) -> &'static str {
        if use_colors {
            GREEN
        } else {
            ""
        }
    }

    /// Get color code only if colors are enabled.
    pub fn red(use_colors: bool) -> &'static str {
        if use_colors {
            RED
        } else {
            ""
        }
    }

    /// Get reset code only if colors are enabled.
    pub fn reset(use_colors: bool) -> &'static str {
        if use_colors {
            RESET
        } else {
            ""
        }
    }
}

/// Run the status command.
///
/// # Arguments
///
/// * `cwd` - Current working directory
/// * `paths` - Paths to check status for (empty means all)
/// * `opts` - CLI options
pub fn run(cwd: &Path, paths: Vec<String>, opts: &CliOptions) -> Result<(), CliError> {
    // Filter empty/whitespace paths - empty means "all"
    let paths: Vec<String> = paths.into_iter().filter(|p| !p.trim().is_empty()).collect();

    run_command("status", opts, |ctx| {
        ctx.progress("Checking status...");

        let void_ctx = build_void_context(cwd)?;

        // Create progress observer as Arc<ProgressObserver> so we can call finish() after
        let observer: std::sync::Arc<ProgressObserver> = if ctx.use_json() {
            std::sync::Arc::new(ProgressObserver::new_hidden())
        } else {
            std::sync::Arc::new(ProgressObserver::new("Scanning files..."))
        };

        let status_opts = StatusOptions {
            ctx: void_ctx,
            patterns: paths,
            observer: Some(
                observer.clone() as std::sync::Arc<dyn void_core::support::events::VoidObserver>
            ),
        };

        let result = status_workspace(status_opts).map_err(void_err_to_cli)?;

        // Finish the progress observer explicitly to clear the spinner
        observer.finish();

        // Check if stderr is a TTY for color output (since all output goes to stderr)
        let use_colors = std::io::stderr().is_terminal();

        // For human-readable output, print the status
        if !ctx.use_json() {
            print_human_status(&result, use_colors, ctx);
        }

        // Calculate clean field
        let clean = result.staged_added.is_empty()
            && result.staged_modified.is_empty()
            && result.staged_deleted.is_empty()
            && result.unstaged_modified.is_empty()
            && result.unstaged_deleted.is_empty()
            && result.untracked.is_empty();

        Ok(StatusOutput {
            staged: StagedChanges {
                added: result.staged_added,
                modified: result.staged_modified,
                deleted: result.staged_deleted,
            },
            unstaged: UnstagedChanges {
                modified: result.unstaged_modified,
                deleted: result.unstaged_deleted,
            },
            untracked: result.untracked,
            clean,
        })
    })
}

/// Print human-readable status output.
///
/// # Arguments
///
/// * `result` - Status result from workspace
/// * `use_colors` - Whether to use ANSI color codes (based on TTY check)
/// * `ctx` - Command context for output
fn print_human_status(
    result: &void_core::workspace::stage::StatusResult,
    use_colors: bool,
    ctx: &mut crate::output::CommandContext,
) {
    let has_staged = !result.staged_added.is_empty()
        || !result.staged_modified.is_empty()
        || !result.staged_deleted.is_empty();
    let has_unstaged = !result.unstaged_modified.is_empty() || !result.unstaged_deleted.is_empty();
    let has_untracked = !result.untracked.is_empty();

    if !has_staged && !has_unstaged && !has_untracked {
        ctx.info("nothing to commit, working tree clean");
        return;
    }

    // Staged changes (green)
    if has_staged {
        ctx.info("Changes to be committed:");
        for path in &result.staged_added {
            ctx.info(format!(
                "  {}new file:   {}{}",
                colors::green(use_colors),
                path,
                colors::reset(use_colors)
            ));
        }
        for path in &result.staged_modified {
            ctx.info(format!(
                "  {}modified:   {}{}",
                colors::green(use_colors),
                path,
                colors::reset(use_colors)
            ));
        }
        for path in &result.staged_deleted {
            ctx.info(format!(
                "  {}deleted:    {}{}",
                colors::green(use_colors),
                path,
                colors::reset(use_colors)
            ));
        }
        ctx.info("");
    }

    // Unstaged changes (red)
    if has_unstaged {
        ctx.info("Changes not staged for commit:");
        for path in &result.unstaged_modified {
            ctx.info(format!(
                "  {}modified:   {}{}",
                colors::red(use_colors),
                path,
                colors::reset(use_colors)
            ));
        }
        for path in &result.unstaged_deleted {
            ctx.info(format!(
                "  {}deleted:    {}{}",
                colors::red(use_colors),
                path,
                colors::reset(use_colors)
            ));
        }
        ctx.info("");
    }

    // Untracked files (red)
    if has_untracked {
        ctx.info("Untracked files:");
        for path in &result.untracked {
            ctx.info(format!(
                "  {}{}{}",
                colors::red(use_colors),
                path,
                colors::reset(use_colors)
            ));
        }
        ctx.info("");
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_status_output_serialization() {
        let output = StatusOutput {
            staged: StagedChanges {
                added: vec!["new_file.rs".to_string()],
                modified: vec!["modified_file.rs".to_string()],
                deleted: vec!["deleted_file.rs".to_string()],
            },
            unstaged: UnstagedChanges {
                modified: vec!["changed.rs".to_string()],
                deleted: vec!["removed.rs".to_string()],
            },
            untracked: vec!["untrackedfile.txt".to_string()],
            clean: false,
        };

        let json = serde_json::to_string(&output).unwrap();

        // Verify nested structure field names
        assert!(json.contains("\"staged\""));
        assert!(json.contains("\"unstaged\""));
        assert!(json.contains("\"untracked\""));
        assert!(json.contains("\"added\""));
        assert!(json.contains("\"modified\""));
        assert!(json.contains("\"deleted\""));
        assert!(json.contains("\"clean\":false"));

        // Verify values
        assert!(json.contains("new_file.rs"));
        assert!(json.contains("modified_file.rs"));
        assert!(json.contains("deleted_file.rs"));
        assert!(json.contains("changed.rs"));
        assert!(json.contains("removed.rs"));
        assert!(json.contains("untrackedfile.txt"));
    }

    #[test]
    fn test_empty_status_output_serialization() {
        let output = StatusOutput {
            staged: StagedChanges {
                added: vec![],
                modified: vec![],
                deleted: vec![],
            },
            unstaged: UnstagedChanges {
                modified: vec![],
                deleted: vec![],
            },
            untracked: vec![],
            clean: true,
        };

        let json = serde_json::to_string(&output).unwrap();
        assert!(json.contains("\"staged\""));
        assert!(json.contains("\"unstaged\""));
        assert!(json.contains("\"untracked\":[]"));
        assert!(json.contains("\"clean\":true"));
    }

    #[test]
    fn test_colors_with_tty() {
        assert_eq!(colors::green(true), "\x1b[32m");
        assert_eq!(colors::red(true), "\x1b[31m");
        assert_eq!(colors::reset(true), "\x1b[0m");
    }

    #[test]
    fn test_colors_without_tty() {
        assert_eq!(colors::green(false), "");
        assert_eq!(colors::red(false), "");
        assert_eq!(colors::reset(false), "");
    }
}