git-send 0.1.6

Commit and push changes with a single command
//! # git-send
//!
//! Stage, commit, pull (with rebase), and push your repository with one command.
//!
//! `git-send` is a small Rust utility that keeps your workflow consistent by
//! composing the most common Git plumbing steps into a single operation.

// Allow multiple versions of windows-sys from transitive dependencies
// colored v3.0.0 uses windows-sys v0.59.0
// clap v4.5.53 uses windows-sys v0.61.2 (via anstream)
// This is safe as these are transitive dependencies with compatible interfaces
#![allow(clippy::multiple_crate_versions)]

mod commit;
mod config;
mod git;
mod hooks;
mod interactive;
mod output;
mod status;
mod types;
mod workflow;

use colored::Colorize;

fn main() {
    if let Err(e) = workflow::run() {
        eprintln!("{}", format!("Error: {e:#}").red().bold());
        std::process::exit(1);
    }
}

#[cfg(test)]
mod tests {
    use anyhow::Result;

    use crate::commit::{is_conventional_commit, is_wip_commit};
    use crate::git::GitRunner;
    use crate::interactive::is_truthy;
    use crate::status::{get_repo_status, parse_repo_status};
    use crate::types::BlockingState;

    // Mock GitRunner for testing
    struct MockGitRunner {
        status_output: String,
    }

    impl GitRunner for MockGitRunner {
        fn run(&self, _args: &[&str]) -> Result<()> {
            Ok(())
        }

        fn run_output(&self, args: &[&str]) -> Result<String> {
            if args.contains(&"status") && args.contains(&"--porcelain") {
                return Ok(self.status_output.clone());
            }
            Ok(String::new())
        }

        fn run_output_full(&self, _args: &[&str]) -> Result<(String, String)> {
            Ok((String::new(), String::new()))
        }

        fn run_status(&self, _args: &[&str]) -> Result<bool> {
            Ok(true)
        }
    }

    #[test]
    fn test_parse_repo_status_empty() {
        let status = parse_repo_status("");
        assert!(!status.has_any_changes());
        assert_eq!(status.total_files(), 0);
    }

    #[test]
    fn test_parse_repo_status_staged() {
        let output = "M  src/main.rs\nA  src/lib.rs";
        let status = parse_repo_status(output);
        assert!(status.has_staged());
        assert!(!status.has_unstaged());
        assert!(!status.has_untracked());
        assert_eq!(status.staged.len(), 2);
    }

    #[test]
    fn test_parse_repo_status_unstaged() {
        let output = " M src/main.rs\n D src/lib.rs";
        let status = parse_repo_status(output);
        assert!(!status.has_staged());
        assert!(status.has_unstaged());
        assert!(!status.has_untracked());
        assert_eq!(status.unstaged.len(), 2);
    }

    #[test]
    fn test_parse_repo_status_untracked() {
        let output = "?? new_file.rs\n?? another.txt";
        let status = parse_repo_status(output);
        assert!(!status.has_staged());
        assert!(!status.has_unstaged());
        assert!(status.has_untracked());
        assert_eq!(status.untracked.len(), 2);
    }

    #[test]
    fn test_parse_repo_status_mixed() {
        let output = "M  staged.rs\n M unstaged.rs\n?? untracked.rs";
        let status = parse_repo_status(output);
        assert!(status.has_staged());
        assert!(status.has_unstaged());
        assert!(status.has_untracked());
        assert_eq!(status.total_files(), 3);
    }

    #[test]
    fn test_get_repo_status() {
        let mock = MockGitRunner {
            status_output: "M  src/main.rs\n?? test.txt".to_string(),
        };
        let status = get_repo_status(&mock).unwrap();
        assert!(status.has_staged());
        assert!(status.has_untracked());
        assert_eq!(status.total_files(), 2);
    }

    #[test]
    fn test_is_conventional_commit() {
        assert!(is_conventional_commit("feat: add new feature"));
        assert!(is_conventional_commit("fix: resolve bug"));
        assert!(is_conventional_commit("docs: update readme"));
        assert!(is_conventional_commit("feat(scope): add feature"));
        assert!(!is_conventional_commit("random commit message"));
        assert!(!is_conventional_commit("Feature: new thing"));
    }

    #[test]
    fn test_is_wip_commit() {
        assert!(is_wip_commit("WIP: working on feature"));
        assert!(is_wip_commit("wip something"));
        assert!(is_wip_commit("work in progress"));
        assert!(is_wip_commit("fixup! previous commit"));
        assert!(!is_wip_commit("feat: complete feature"));
        assert!(!is_wip_commit("fix: resolve issue"));
    }

    #[test]
    fn test_is_truthy() {
        assert!(is_truthy("1"));
        assert!(is_truthy("true"));
        assert!(is_truthy("TRUE"));
        assert!(is_truthy("yes"));
        assert!(is_truthy("YES"));
        assert!(is_truthy("on"));
        assert!(!is_truthy("0"));
        assert!(!is_truthy("false"));
        assert!(!is_truthy("no"));
        assert!(!is_truthy(""));
    }

    #[test]
    fn test_blocking_state_display() {
        assert_eq!(format!("{}", BlockingState::Rebase), "Rebase");
        assert_eq!(format!("{}", BlockingState::Merge), "Merge");
        assert_eq!(format!("{}", BlockingState::Conflicts), "Merge conflicts");
    }
}