cargo-changeset 0.1.6

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

use dialoguer::{Input, Select};

use changeset_core::PackageInfo;
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 super::{ManageArgs, ManageCommand, ManageGraduationArgs, ManagePrereleaseArgs};
use crate::environment::is_interactive;
use crate::error::{CliError, Result};
use crate::interaction::select_from_options;
use crate::output::CliWriter;

struct TerminalManageInteractionProvider;

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

        let selection = select_from_options("What would you like to do?", &options, 0)
            .map_err(super::cli_error_to_operation_error)?;

        Ok(MenuSelection::Selected(
            selection.unwrap_or(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(super::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(super::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(super::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 = [
            (GraduationAction::Add, "Add crate to graduation queue"),
            (
                GraduationAction::Remove,
                "Remove crate from graduation queue",
            ),
            (GraduationAction::Done, "Done"),
        ];

        let selection = select_from_options("What would you like to do?", &options, 0)
            .map_err(super::cli_error_to_operation_error)?;

        Ok(MenuSelection::Selected(
            selection.unwrap_or(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(super::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(super::dialoguer_to_operation_error)?;

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

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

fn run_prerelease(
    args: ManagePrereleaseArgs,
    start_path: &Path,
    writer: &dyn CliWriter,
) -> 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, writer);
    Ok(())
}

fn run_graduation(
    args: ManageGraduationArgs,
    start_path: &Path,
    writer: &dyn CliWriter,
) -> 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, writer);
    Ok(())
}

fn print_prerelease_events(events: &[PrereleaseEvent], writer: &dyn CliWriter) {
    for event in events {
        match event {
            PrereleaseEvent::DisplayState(items) => {
                writer.blank();
                if items.is_empty() {
                    writer.line("(No packages in pre-release mode)");
                } else {
                    writer.heading("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 {
                        writer.indented(&format!("{crate_name}: {tag}"));
                    }
                }
                writer.blank();
            }
            PrereleaseEvent::Added { crate_name, tag } => {
                writer.line(&format!(
                    "Added {crate_name} to pre-release configuration with tag '{tag}'"
                ));
            }
            PrereleaseEvent::Removed { crate_name } => {
                writer.line(&format!(
                    "Removed {crate_name} from pre-release configuration"
                ));
            }
            PrereleaseEvent::MovedToGraduation { crate_name } => {
                writer.line(&format!("Moved {crate_name} to graduation queue"));
            }
            PrereleaseEvent::AllPackagesInPrerelease => {
                writer.line("All packages are already in pre-release mode.");
            }
            PrereleaseEvent::NoPrereleasePackages => {
                writer.line("No packages are currently in pre-release mode.");
            }
            PrereleaseEvent::NoEligibleForGraduation => {
                writer.line(
                    "No eligible packages for graduation (must be 0.x stable version and not already queued).",
                );
            }
        }
    }
}

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

#[cfg(test)]
mod tests {
    use changeset_operations::operations::{GraduationEvent, PrereleaseEvent};

    use super::{print_graduation_events, print_prerelease_events};
    use crate::output::{BufferCliWriter, OutputEntry};

    #[test]
    fn prerelease_display_state_empty_shows_no_packages_message() {
        let writer = BufferCliWriter::new();
        let events = vec![PrereleaseEvent::DisplayState(vec![])];

        print_prerelease_events(&events, &writer);

        let text = writer.stdout_text();
        assert!(text.contains("(No packages in pre-release mode)"));
    }

    #[test]
    fn prerelease_display_state_with_entries_shows_sorted() {
        let writer = BufferCliWriter::new();
        let events = vec![PrereleaseEvent::DisplayState(vec![
            ("z-crate".to_string(), "beta".to_string()),
            ("a-crate".to_string(), "alpha".to_string()),
        ])];

        print_prerelease_events(&events, &writer);

        let text = writer.stdout_text();
        assert!(text.contains("Pre-release configuration"));
        let a_pos = text.find("a-crate").expect("a-crate present");
        let z_pos = text.find("z-crate").expect("z-crate present");
        assert!(a_pos < z_pos, "entries should be sorted");
    }

    #[test]
    fn prerelease_added_shows_confirmation() {
        let writer = BufferCliWriter::new();
        let events = vec![PrereleaseEvent::Added {
            crate_name: "my-crate".to_string(),
            tag: "rc".to_string(),
        }];

        print_prerelease_events(&events, &writer);

        let text = writer.stdout_text();
        assert!(text.contains("Added my-crate to pre-release configuration with tag 'rc'"));
    }

    #[test]
    fn graduation_display_state_empty() {
        let writer = BufferCliWriter::new();
        let events = vec![GraduationEvent::DisplayState(vec![])];

        print_graduation_events(&events, &writer);

        let text = writer.stdout_text();
        assert!(text.contains("(No packages queued for graduation)"));
    }

    #[test]
    fn graduation_display_state_with_entries_shows_sorted_list_items() {
        let writer = BufferCliWriter::new();
        let events = vec![GraduationEvent::DisplayState(vec![
            "z-pkg".to_string(),
            "a-pkg".to_string(),
        ])];

        print_graduation_events(&events, &writer);

        let entries = writer.stdout_entries();
        assert!(entries.contains(&OutputEntry::ListItem("a-pkg".to_string())));
        assert!(entries.contains(&OutputEntry::ListItem("z-pkg".to_string())));
    }

    #[test]
    fn graduation_added_shows_confirmation() {
        let writer = BufferCliWriter::new();
        let events = vec![GraduationEvent::Added {
            crate_name: "my-lib".to_string(),
        }];

        print_graduation_events(&events, &writer);

        let text = writer.stdout_text();
        assert!(text.contains("Added my-lib to graduation queue"));
    }
}