zim-studio 1.2.0

A Terminal-Based Audio Project Scaffold and Metadata System
Documentation
use crate::config::Config;
use crate::project;
use dialoguer::{Confirm, Input, theme::ColorfulTheme};
use indicatif::{ProgressBar, ProgressStyle};
use owo_colors::OwoColorize;
use std::error::Error;
use std::path::Path;
use std::thread;
use std::time::Duration;

pub fn handle_new(
    name: Option<&str>,
    path: Option<&str>,
    zimignore_template: Option<&str>,
    no_zimignore: bool,
    interactive: bool,
) -> Result<(), Box<dyn Error>> {
    // Load configuration
    let config = Config::load()?;

    // Generate project name if not provided
    let project_name = match name {
        Some(n) => n.to_string(),
        None => {
            let generated = generate_project_name(path)?;
            let use_generated = Confirm::with_theme(&ColorfulTheme::default())
                .with_prompt(format!("Use auto-generated name '{}'?", generated.cyan()))
                .default(true)
                .interact()?;

            if use_generated {
                generated
            } else {
                Input::<String>::with_theme(&ColorfulTheme::default())
                    .with_prompt("Enter project name")
                    .interact_text()?
            }
        }
    };

    // Validate project name
    if project_name.is_empty() {
        return Err("Project name cannot be empty".into());
    }

    // Normalize project name if enabled
    let original_name = project_name.clone();
    let project_name = if config.normalize_project_names {
        normalize_project_name(&project_name)
    } else {
        project_name
    };

    // Create project path
    let parent_dir = match path {
        Some(p) => shellexpand::tilde(p).to_string(),
        None => std::env::current_dir()?.to_string_lossy().to_string(),
    };
    let project_path = Path::new(&parent_dir).join(&project_name);

    // Check if project already exists
    if project_path.exists() {
        eprintln!(
            "{} Project '{}' already exists at {}",
            "Error:".red().bold(),
            project_name.yellow(),
            project_path.display().to_string().cyan()
        );
        return Err("Project already exists".into());
    }

    println!(
        "{} {}",
        "Creating new project:".bright_black(),
        project_name.cyan().bold()
    );
    if config.normalize_project_names && original_name != project_name {
        println!(
            "  {} {}",
            "(normalized from:".bright_black(),
            original_name.yellow()
        );
    }
    println!(
        "{} {}",
        "Location:".bright_black(),
        project_path.display().to_string().cyan()
    );
    println!();

    let pb = ProgressBar::new(3);
    pb.set_style(
        ProgressStyle::default_bar()
            .template("{spinner:.cyan} [{bar:30.cyan/blue}] {msg}")
            .unwrap()
            .progress_chars("█▓░"),
    );

    // Create project structure
    pb.set_message("Creating project structure...");
    project::create_project_structure(&project_path, &config.default_folders, &config)?;
    thread::sleep(Duration::from_millis(100));
    pb.inc(1);

    // Create .gitignore
    if !config.default_gitignore.is_empty() {
        pb.set_message("Creating .gitignore...");
        project::create_gitignore(&project_path, &config.default_gitignore)?;
        thread::sleep(Duration::from_millis(100));
    }
    pb.inc(1);

    // Create .zimignore
    if !no_zimignore {
        pb.set_message("Creating .zimignore...");
        let zimignore_content = get_zimignore_content(zimignore_template, interactive)?;
        project::create_zimignore(&project_path, &zimignore_content)?;
        thread::sleep(Duration::from_millis(100));
    }

    // Create project metadata file
    pb.set_message("Creating project metadata...");
    let display_name = if original_name != project_name {
        Some(original_name.as_str())
    } else {
        None
    };
    project::create_project_metadata(
        &project_path,
        &project_name,
        &config.default_artist,
        display_name,
    )?;
    thread::sleep(Duration::from_millis(100));
    pb.inc(1);

    pb.finish_and_clear();

    println!(
        "\n{} Project '{}' created successfully!",
        "".green().bold(),
        project_name.cyan().bold()
    );
    println!("\n{}", "Project structure:".yellow().bold());
    print_tree(&project_path, "", true)?;

    println!("\n{}", "Next steps:".yellow().bold());
    println!(
        "  {} {}",
        "$".bright_black(),
        format!("cd {}", project_path.display()).cyan()
    );
    println!("  {} {}", "$".bright_black(), "git init".cyan());
    println!(
        "  {} {}",
        "#".bright_black(),
        "Start creating music!".bright_black().italic()
    );

    Ok(())
}

fn generate_project_name(path: Option<&str>) -> Result<String, Box<dyn Error>> {
    let date = chrono::Local::now().format("%Y%m%d");

    // Determine parent directory
    let parent_dir = match path {
        Some(p) => shellexpand::tilde(p).to_string(),
        None => std::env::current_dir()?.to_string_lossy().to_string(),
    };

    let mut counter = 1;
    loop {
        let name = format!("{date}-{counter:03}");
        let project_path = Path::new(&parent_dir).join(&name);
        if !project_path.exists() {
            return Ok(name);
        }
        counter += 1;
    }
}

fn print_tree(dir: &Path, prefix: &str, _is_last: bool) -> Result<(), Box<dyn Error>> {
    let entries = std::fs::read_dir(dir)?;
    let mut entries: Vec<_> = entries.collect::<Result<_, _>>()?;
    entries.sort_by_key(|e| e.file_name());

    let entry_count = entries.len();

    for (index, entry) in entries.iter().enumerate() {
        let path = entry.path();
        let file_name = entry.file_name();
        let file_name_str = file_name.to_string_lossy();

        let is_last_entry = index == entry_count - 1;
        let connector = if is_last_entry {
            "└── ".bright_black().to_string()
        } else {
            "├── ".bright_black().to_string()
        };

        let styled_name = if path.is_dir() {
            file_name_str.blue().bold().to_string()
        } else if file_name_str.ends_with(".md") {
            file_name_str.green().to_string()
        } else {
            file_name_str.to_string()
        };
        println!("{prefix}{connector}{styled_name}");

        if path.is_dir() && !file_name_str.starts_with('.') {
            let extension = if is_last_entry {
                "    ".to_string()
            } else {
                "".bright_black().to_string()
            };
            let new_prefix = format!("{prefix}{extension}");

            // Only recurse one level deep for cleaner output
            if prefix.is_empty() {
                print_tree(&path, &new_prefix, is_last_entry)?;
            }
        }
    }

    Ok(())
}

fn normalize_project_name(name: &str) -> String {
    // Convert to lowercase, replace spaces with underscores, remove punctuation
    name.to_lowercase()
        .chars()
        .map(|c| {
            if c.is_alphanumeric() || c == '-' {
                c
            } else if c.is_whitespace() {
                '_'
            } else {
                // Remove punctuation by replacing with nothing
                '\0'
            }
        })
        .filter(|&c| c != '\0')
        .collect::<String>()
        // Clean up multiple underscores
        .split('_')
        .filter(|s| !s.is_empty())
        .collect::<Vec<_>>()
        .join("_")
}

fn get_zimignore_content(
    template_path: Option<&str>,
    interactive: bool,
) -> Result<String, Box<dyn Error>> {
    use std::fs;

    let content = if let Some(template_path) = template_path {
        // Use custom template file
        fs::read_to_string(template_path)?
    } else {
        // Use default template
        Config::load_default_zimignore()?
    };

    if interactive {
        // Allow user to edit the content interactively
        println!("\n{}", "Customize .zimignore content:".yellow().bold());
        println!("{}", "(Leave empty to use default)".bright_black());

        let custom_content = Input::<String>::with_theme(&ColorfulTheme::default())
            .with_prompt("Additional patterns (comma-separated)")
            .allow_empty(true)
            .interact_text()?;

        if !custom_content.trim().is_empty() {
            let mut final_content = content;
            final_content.push_str("\n# Custom patterns for this project\n");
            for pattern in custom_content.split(',') {
                let pattern = pattern.trim();
                if !pattern.is_empty() {
                    final_content.push_str(&format!("{pattern}\n"));
                }
            }
            Ok(final_content)
        } else {
            Ok(content)
        }
    } else {
        Ok(content)
    }
}