cargo-changeset 0.1.0

A cargo subcommand for managing changesets
use std::path::Path;

use changeset_core::PackageInfo;
use changeset_operations::OperationError;
use changeset_operations::operations::{
    GraduationDirectInput, GraduationDirectOperation, GraduationEvent, GraduationManageOperation,
    PrereleaseDirectInput, PrereleaseDirectOperation, PrereleaseEvent, PrereleaseManageOperation,
};
use changeset_operations::providers::{FileSystemProjectProvider, FileSystemReleaseStateIO};
use changeset_operations::traits::{
    GraduationAction, GraduationInteractionProvider, MenuSelection, PrereleaseAction,
    PrereleaseInteractionProvider,
};
use dialoguer::{Input, Select};

use super::{ManageArgs, ManageCommand, ManageGraduationArgs, ManagePrereleaseArgs};
use crate::environment::is_interactive;
use crate::error::{CliError, Result};

pub(crate) fn run(args: ManageArgs, start_path: &Path) -> Result<()> {
    match args.command {
        ManageCommand::Prerelease(prerelease_args) => run_prerelease(prerelease_args, start_path),
        ManageCommand::Graduation(graduation_args) => run_graduation(graduation_args, start_path),
    }
}

fn run_prerelease(args: ManagePrereleaseArgs, start_path: &Path) -> Result<()> {
    let no_flags_provided =
        args.add.is_empty() && args.remove.is_empty() && args.graduate.is_empty() && !args.list;

    let events = if no_flags_provided {
        if !is_interactive() {
            return Err(CliError::NotATty);
        }

        let interaction = TerminalManageInteractionProvider;
        let operation = PrereleaseManageOperation::new(
            FileSystemProjectProvider::new(),
            FileSystemReleaseStateIO::new(),
            interaction,
        );
        operation.execute(start_path)?
    } else {
        let operation = PrereleaseDirectOperation::new(
            FileSystemProjectProvider::new(),
            FileSystemReleaseStateIO::new(),
        );
        let input = PrereleaseDirectInput::new(args.add, args.remove, args.graduate, args.list);
        operation.execute(start_path, &input)?
    };

    print_prerelease_events(&events);
    Ok(())
}

fn run_graduation(args: ManageGraduationArgs, start_path: &Path) -> Result<()> {
    let no_flags_provided = args.add.is_empty() && args.remove.is_empty() && !args.list;

    let events = if no_flags_provided {
        if !is_interactive() {
            return Err(CliError::NotATty);
        }

        let interaction = TerminalManageInteractionProvider;
        let operation = GraduationManageOperation::new(
            FileSystemProjectProvider::new(),
            FileSystemReleaseStateIO::new(),
            interaction,
        );
        operation.execute(start_path)?
    } else {
        let operation = GraduationDirectOperation::new(
            FileSystemProjectProvider::new(),
            FileSystemReleaseStateIO::new(),
        );
        let input = GraduationDirectInput::new(args.add, args.remove, args.list);
        operation.execute(start_path, &input)?
    };

    print_graduation_events(&events);
    Ok(())
}

fn print_prerelease_events(events: &[PrereleaseEvent]) {
    for event in events {
        match event {
            PrereleaseEvent::DisplayState(items) => {
                println!();
                if items.is_empty() {
                    println!("(No packages in pre-release mode)");
                } else {
                    println!("Pre-release configuration (.changeset/pre-release.toml):");
                    let mut sorted = items.clone();
                    sorted.sort_by(|a, b| a.0.cmp(&b.0));
                    for (crate_name, tag) in &sorted {
                        println!("  {crate_name}: {tag}");
                    }
                }
                println!();
            }
            PrereleaseEvent::Added { crate_name, tag } => {
                println!("Added {crate_name} to pre-release configuration with tag '{tag}'");
            }
            PrereleaseEvent::Removed { crate_name } => {
                println!("Removed {crate_name} from pre-release configuration");
            }
            PrereleaseEvent::MovedToGraduation { crate_name } => {
                println!("Moved {crate_name} to graduation queue");
            }
            PrereleaseEvent::AllPackagesInPrerelease => {
                println!("All packages are already in pre-release mode.");
            }
            PrereleaseEvent::NoPrereleasePackages => {
                println!("No packages are currently in pre-release mode.");
            }
            PrereleaseEvent::NoEligibleForGraduation => {
                println!(
                    "No eligible packages for graduation (must be 0.x stable version and not already queued)."
                );
            }
        }
    }
}

fn print_graduation_events(events: &[GraduationEvent]) {
    for event in events {
        match event {
            GraduationEvent::DisplayState(items) => {
                println!();
                if items.is_empty() {
                    println!("(No packages queued for graduation)");
                } else {
                    println!("Graduation queue (.changeset/graduation.toml):");
                    let mut sorted = items.clone();
                    sorted.sort();
                    for crate_name in &sorted {
                        println!("  - {crate_name}");
                    }
                }
                println!();
            }
            GraduationEvent::Added { crate_name } => {
                println!("Added {crate_name} to graduation queue");
            }
            GraduationEvent::Removed { crate_name } => {
                println!("Removed {crate_name} from graduation queue");
            }
            GraduationEvent::NoEligibleForGraduation => {
                println!(
                    "No eligible packages for graduation (must be 0.x stable version and not already queued)."
                );
            }
            GraduationEvent::NoGraduationPackages => {
                println!("No packages are currently queued for graduation.");
            }
        }
    }
}

struct TerminalManageInteractionProvider;

impl PrereleaseInteractionProvider for TerminalManageInteractionProvider {
    fn select_prerelease_action(
        &self,
    ) -> changeset_operations::Result<MenuSelection<PrereleaseAction>> {
        let options = [
            "Add crate to pre-release",
            "Remove crate from pre-release",
            "Graduate crate (move to graduation queue)",
            "Done",
        ];

        let selection = Select::new()
            .with_prompt("What would you like to do?")
            .items(options)
            .default(0)
            .interact_opt()
            .map_err(dialoguer_to_operation_error)?;

        Ok(match selection {
            Some(0) => MenuSelection::Selected(PrereleaseAction::Add),
            Some(1) => MenuSelection::Selected(PrereleaseAction::Remove),
            Some(2) => MenuSelection::Selected(PrereleaseAction::Graduate),
            _ => MenuSelection::Selected(PrereleaseAction::Done),
        })
    }

    fn select_package_for_prerelease(
        &self,
        available: &[&PackageInfo],
    ) -> changeset_operations::Result<MenuSelection<usize>> {
        let items: Vec<String> = available
            .iter()
            .map(|p| format!("{} ({})", p.name, p.version))
            .collect();

        let selection = Select::new()
            .with_prompt("Select a crate to add to pre-release")
            .items(&items)
            .interact_opt()
            .map_err(dialoguer_to_operation_error)?;

        Ok(match selection {
            Some(index) => MenuSelection::Selected(index),
            None => MenuSelection::Cancelled,
        })
    }

    fn get_prerelease_tag(&self) -> changeset_operations::Result<String> {
        let tag: String = Input::new()
            .with_prompt("Enter pre-release tag (e.g., alpha, beta, rc)")
            .interact_text()
            .map_err(dialoguer_to_operation_error)?;

        Ok(tag)
    }

    fn select_package_to_remove_prerelease(
        &self,
        items: &[(&str, &str)],
    ) -> changeset_operations::Result<MenuSelection<usize>> {
        let display_items: Vec<String> = items
            .iter()
            .map(|(name, tag)| format!("{name}: {tag}"))
            .collect();

        let selection = Select::new()
            .with_prompt("Select a crate to remove from pre-release")
            .items(&display_items)
            .interact_opt()
            .map_err(dialoguer_to_operation_error)?;

        Ok(match selection {
            Some(index) => MenuSelection::Selected(index),
            None => MenuSelection::Cancelled,
        })
    }
}

impl GraduationInteractionProvider for TerminalManageInteractionProvider {
    fn select_graduation_action(
        &self,
    ) -> changeset_operations::Result<MenuSelection<GraduationAction>> {
        let options = [
            "Add crate to graduation queue",
            "Remove crate from graduation queue",
            "Done",
        ];

        let selection = Select::new()
            .with_prompt("What would you like to do?")
            .items(options)
            .default(0)
            .interact_opt()
            .map_err(dialoguer_to_operation_error)?;

        Ok(match selection {
            Some(0) => MenuSelection::Selected(GraduationAction::Add),
            Some(1) => MenuSelection::Selected(GraduationAction::Remove),
            _ => MenuSelection::Selected(GraduationAction::Done),
        })
    }

    fn select_package_for_graduation(
        &self,
        eligible: &[&PackageInfo],
    ) -> changeset_operations::Result<MenuSelection<usize>> {
        let items: Vec<String> = eligible
            .iter()
            .map(|p| format!("{} ({})", p.name, p.version))
            .collect();

        let selection = Select::new()
            .with_prompt("Select a crate to graduate (move to graduation queue)")
            .items(&items)
            .interact_opt()
            .map_err(dialoguer_to_operation_error)?;

        Ok(match selection {
            Some(index) => MenuSelection::Selected(index),
            None => MenuSelection::Cancelled,
        })
    }

    fn select_package_to_remove_graduation(
        &self,
        items: &[String],
    ) -> changeset_operations::Result<MenuSelection<usize>> {
        let selection = Select::new()
            .with_prompt("Select a crate to remove from graduation queue")
            .items(items)
            .interact_opt()
            .map_err(dialoguer_to_operation_error)?;

        Ok(match selection {
            Some(index) => MenuSelection::Selected(index),
            None => MenuSelection::Cancelled,
        })
    }
}

fn dialoguer_to_operation_error(e: dialoguer::Error) -> OperationError {
    match e {
        dialoguer::Error::IO(io_err) => OperationError::Io(io_err),
    }
}

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

    mod dialoguer_conversion {
        use super::*;
        use std::io;

        #[test]
        fn converts_io_error_to_operation_io_variant() {
            let io_err = io::Error::new(io::ErrorKind::BrokenPipe, "pipe closed");
            let dialoguer_err = dialoguer::Error::IO(io_err);

            let result = dialoguer_to_operation_error(dialoguer_err);

            assert!(matches!(result, OperationError::Io(_)));
        }

        #[test]
        fn preserves_io_error_kind() {
            let io_err = io::Error::new(io::ErrorKind::PermissionDenied, "access denied");
            let dialoguer_err = dialoguer::Error::IO(io_err);

            let result = dialoguer_to_operation_error(dialoguer_err);

            match result {
                OperationError::Io(inner) => {
                    assert_eq!(inner.kind(), io::ErrorKind::PermissionDenied);
                }
                other => panic!("expected OperationError::Io, got {other:?}"),
            }
        }

        #[test]
        fn preserves_error_chain() {
            let io_err = io::Error::other("chain test");
            let dialoguer_err = dialoguer::Error::IO(io_err);

            let result = dialoguer_to_operation_error(dialoguer_err);

            let source = std::error::Error::source(&result);
            assert!(
                source.is_some(),
                "error chain should be preserved through conversion"
            );
        }

        #[test]
        fn preserves_io_error_message() {
            let io_err = io::Error::other("terminal unavailable");
            let dialoguer_err = dialoguer::Error::IO(io_err);

            let result = dialoguer_to_operation_error(dialoguer_err);

            assert!(
                result.to_string().contains("IO error"),
                "expected display to contain 'IO error', got: {}",
                result
            );
            match result {
                OperationError::Io(inner) => {
                    assert_eq!(inner.to_string(), "terminal unavailable");
                }
                other => panic!("expected OperationError::Io, got {other:?}"),
            }
        }
    }
}