jump-start 0.1.1-alpha-4

The CLI for jump-start: A shortcut to your favorite code
Documentation
use crate::Config;
use crate::LocalStarterGroupLookup;
use crate::config::resolve_instance_path;
use crate::starter::get_starter_command;
use crate::starter::parse_starters;
use anyhow::Result;
use std::env;
use std::fs;
use std::path::Path;

fn generate_readme_section(groups: &LocalStarterGroupLookup) -> String {
    let mut output: Vec<String> = Vec::new();

    output.push("\n<!-- NOTE: The starters section of this readme is auto-generated by .github/workflows/deploy.yml -->".to_string());
    output.push("\nJump to group:".to_string());

    // Sort groups by group name
    let mut sorted_groups: Vec<_> = groups.iter().collect();
    sorted_groups.sort_by_key(|(key, _)| *key);

    for (group_name, _) in &sorted_groups {
        output.push(format!("- [{}](#{})", group_name, group_name));
    }

    output.push("\n---\n".to_string());

    for (group_name, group_data) in &sorted_groups {
        output.push(format!("### {}\n", group_name));

        // Sort starters by name
        let mut sorted_data: Vec<_> = group_data.iter().collect();
        sorted_data.sort_by_key(|data| &data.name);

        for data in sorted_data {
            output.push(format!("{}/**{}**", data.group, data.name));

            let github_username = env::var("GITHUB_USERNAME").unwrap_or_default();
            let github_repo = env::var("GITHUB_REPO").unwrap_or_default();
            let degit_mode = env::var("DEGIT_MODE").unwrap_or_default();

            output.push(format!(
                "
```
{}
```
",
                get_starter_command(data, &github_username, &github_repo, &degit_mode)
            ));

            if let Some(config) = &data.config {
                if let Some(description) = &config.description {
                    if !description.is_empty() {
                        output.push(description.clone());
                    }
                }
            }

            output.push("---".to_string());
        }
    }

    output.join("\n")
}

fn rewrite_readme_section(existing_content: &str, section: &str, new_content: &str) -> String {
    let lines: Vec<&str> = existing_content.split('\n').collect();
    let mut rewritten_lines: Vec<String> = Vec::new();
    let mut is_in_section = false;

    for line in lines {
        if line == section {
            is_in_section = true;
            rewritten_lines.push(line.to_string());
            rewritten_lines.push("".to_string());
            rewritten_lines.push(new_content.to_string());
            rewritten_lines.push("".to_string());
        } else {
            if line.starts_with("## ") {
                is_in_section = false;
            }

            if !is_in_section {
                rewritten_lines.push(line.to_string());
            }
        }
    }

    rewritten_lines.join("\n")
}

pub fn update_readme(config: Config, instance_path: Option<&str>) -> Result<()> {
    let path = resolve_instance_path(&config, instance_path);
    println!("Using instance at {:?}", path);

    let groups = parse_starters(&path)?;
    let starters_section = generate_readme_section(&groups);

    let readme_path = Path::new(&path).join("README.md");
    let existing_readme = fs::read_to_string(&readme_path)
        .map_err(|e| anyhow::anyhow!("Failed to read README.md: {}", e))?;

    let updated_readme = rewrite_readme_section(&existing_readme, "## Starters", &starters_section);

    fs::write(&readme_path, updated_readme)
        .map_err(|e| anyhow::anyhow!("Failed to write updated README.md: {}", e))?;

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::LocalStarter;
    use crate::starter::StarterConfig;
    use std::collections::HashMap;

    fn create_test_starter(group: &str, name: &str, description: Option<&str>) -> LocalStarter {
        let config = if let Some(desc) = description {
            Some(StarterConfig {
                description: Some(desc.to_string()),
                default_dir: None,
                main_file: None,
                preview: None,
            })
        } else {
            None
        };

        LocalStarter {
            path: format!("{}/{}", group, name),
            group: group.to_string(),
            name: name.to_string(),
            config,
        }
    }

    #[test]
    fn test_generate_readme_section_empty() {
        let groups = HashMap::new();
        let result = generate_readme_section(&groups);

        assert!(result.contains("auto-generated by .github/workflows/deploy.yml"));
        assert!(result.contains("Jump to group:"));
        assert!(!result.contains("###"));
    }

    #[test]
    fn test_generate_readme_section_single_group() {
        let mut groups = HashMap::new();
        let starter = create_test_starter("react", "basic-app", Some("A React starter"));
        groups.insert("react".to_string(), vec![starter]);

        let result = generate_readme_section(&groups);

        assert!(result.contains("- [react](#react)"));
        assert!(result.contains("### react"));
        assert!(result.contains("react/**basic-app**"));
        assert!(result.contains("A React starter"));
    }

    #[test]
    fn test_generate_readme_section_multiple_groups_sorted() {
        let mut groups = HashMap::new();

        let react_starter = create_test_starter("react", "app", Some("React app"));
        let node_starter = create_test_starter("node", "server", Some("Node server"));
        let angular_starter = create_test_starter("angular", "component", None);

        groups.insert("react".to_string(), vec![react_starter]);
        groups.insert("node".to_string(), vec![node_starter]);
        groups.insert("angular".to_string(), vec![angular_starter]);

        let result = generate_readme_section(&groups);

        // Check groups are sorted alphabetically
        let angular_pos = result.find("- [angular](#angular)").unwrap();
        let node_pos = result.find("- [node](#node)").unwrap();
        let react_pos = result.find("- [react](#react)").unwrap();

        assert!(angular_pos < node_pos);
        assert!(node_pos < react_pos);
    }

    #[test]
    fn test_generate_readme_section_multiple_starters_in_group_sorted() {
        let mut groups = HashMap::new();

        let starter1 = create_test_starter("react", "zebra-app", Some("Z starter"));
        let starter2 = create_test_starter("react", "alpha-app", Some("A starter"));
        let starter3 = create_test_starter("react", "beta-app", Some("B starter"));

        groups.insert("react".to_string(), vec![starter1, starter2, starter3]);

        let result = generate_readme_section(&groups);

        // Check starters are sorted alphabetically within the group
        let alpha_pos = result.find("react/**alpha-app**").unwrap();
        let beta_pos = result.find("react/**beta-app**").unwrap();
        let zebra_pos = result.find("react/**zebra-app**").unwrap();

        assert!(alpha_pos < beta_pos);
        assert!(beta_pos < zebra_pos);
    }

    #[test]
    fn test_generate_readme_section_with_degit_command() {
        use crate::starter::get_starter_command;

        let mut groups = HashMap::new();
        let starter = create_test_starter("react", "app", Some("Test starter"));
        groups.insert("react".to_string(), vec![starter.clone()]);

        // Test that get_starter_command works correctly with degit mode
        let degit_command = get_starter_command(&starter, "testuser", "testrepo", "true");
        assert_eq!(degit_command, "npx degit testuser/testrepo#react/app app");

        let jump_start_command = get_starter_command(&starter, "testuser", "testrepo", "false");
        assert_eq!(jump_start_command, "jump-start use react/app");
    }

    #[test]
    fn test_generate_readme_section_default_command() {
        let mut groups = HashMap::new();
        let starter = create_test_starter("react", "app", Some("Test starter"));
        groups.insert("react".to_string(), vec![starter]);

        let result = generate_readme_section(&groups);

        // With no DEGIT_MODE set (or set to something other than "true"),
        // should use jump-start command
        assert!(result.contains("jump-start use react/app"));
        assert!(!result.contains("npx degit"));
    }

    #[test]
    fn test_generate_readme_section_no_description() {
        let mut groups = HashMap::new();
        let starter = create_test_starter("react", "app", None);
        groups.insert("react".to_string(), vec![starter]);

        let result = generate_readme_section(&groups);

        assert!(result.contains("react/**app**"));
        assert!(result.contains("jump-start use react/app"));

        // Just verify the basic structure is correct
        assert!(result.contains("react/**app**"));
        assert!(result.contains("jump-start use react/app"));
        assert!(result.contains("---"));
    }

    #[test]
    fn test_rewrite_readme_section_basic() {
        let existing_content = r#"# Test README

## Installation

Install instructions here.

## Starters

Old starters content
that spans multiple lines.

## Contributing

Contributing guidelines.
"#;

        let new_content = "New starters content\nwith multiple lines.";
        let result = rewrite_readme_section(existing_content, "## Starters", new_content);

        assert!(result.contains("# Test README"));
        assert!(result.contains("## Installation"));
        assert!(result.contains("Install instructions here"));
        assert!(result.contains("## Contributing"));
        assert!(result.contains("Contributing guidelines"));
        assert!(result.contains("New starters content\nwith multiple lines."));
        assert!(!result.contains("Old starters content"));
    }

    #[test]
    fn test_rewrite_readme_section_no_existing_section() {
        let existing_content = r#"# Test README

## Installation

Install instructions here.

## Contributing

Contributing guidelines.
"#;

        let new_content = "New starters content";
        let result = rewrite_readme_section(existing_content, "## Starters", new_content);

        // Should return original content unchanged if section not found
        assert_eq!(result, existing_content);
    }

    #[test]
    fn test_rewrite_readme_section_multiple_sections() {
        let existing_content = r#"# Test README

## Installation

Install instructions.

## Starters

Old starters content.

## Usage

Usage instructions.

## API

API documentation.
"#;

        let new_content = "Updated starters content.";
        let result = rewrite_readme_section(existing_content, "## Starters", new_content);

        assert!(result.contains("## Installation"));
        assert!(result.contains("Install instructions"));
        assert!(result.contains("## Usage"));
        assert!(result.contains("Usage instructions"));
        assert!(result.contains("## API"));
        assert!(result.contains("API documentation"));
        assert!(result.contains("Updated starters content."));
        assert!(!result.contains("Old starters content"));
    }

    #[test]
    fn test_rewrite_readme_section_preserves_empty_lines() {
        let existing_content = r#"# Test README


## Installation

Install instructions.


## Starters

Old content.


## Contributing

Contributing.


"#;

        let new_content = "New content.";
        let result = rewrite_readme_section(existing_content, "## Starters", new_content);

        // Check basic structure is preserved
        assert!(result.contains("# Test README"));
        assert!(result.contains("## Installation"));
        assert!(result.contains("Install instructions"));
        assert!(result.contains("## Starters"));
        assert!(result.contains("New content."));
        assert!(result.contains("## Contributing"));
    }
}