shipit 1.0.0

A CLI for managing git releases
Documentation
use std::io;

use chrono::Utc;
use owo_colors::OwoColorize;

use crate::cli::InitArgs;
use crate::error::ShipItError;
use crate::settings::Settings;

const AGENT_GUIDE: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/AI.md"));
const CLAUDE_MD_START: &str = "<!-- shipit:start -->";
const CLAUDE_MD_END: &str = "<!-- shipit:end -->";

fn prompt_line(label: &str) -> Result<String, ShipItError> {
    crate::output::print_token_prompt(label);
    let mut input = String::new();
    io::stdin()
        .read_line(&mut input)
        .map_err(|e| ShipItError::Error(format!("Failed to read input: {}", e)))?;
    Ok(input.trim().to_string())
}

/// Write (or update) the shipit agent guide section in `CLAUDE.md`.
///
/// If `CLAUDE.md` already contains a shipit section (delimited by
/// `<!-- shipit:start -->` / `<!-- shipit:end -->`), the section is replaced
/// in-place so the rest of the file is preserved. Otherwise the section is
/// appended.
fn write_claude_md(dir: &std::path::Path) -> Result<std::path::PathBuf, ShipItError> {
    let claude_md_path = dir.join("CLAUDE.md");

    let section = format!(
        "{}\n<!-- Generated by shipit v{} — do not edit this section manually -->\n\n{}\n{}",
        CLAUDE_MD_START,
        env!("CARGO_PKG_VERSION"),
        AGENT_GUIDE.trim_end(),
        CLAUDE_MD_END,
    );

    let existing = if claude_md_path.exists() {
        std::fs::read_to_string(&claude_md_path)
            .map_err(|e| ShipItError::Error(format!("Failed to read CLAUDE.md: {}", e)))?
    } else {
        String::new()
    };

    let updated = if existing.contains(CLAUDE_MD_START) {
        // Replace the existing shipit section
        let before = &existing[..existing.find(CLAUDE_MD_START).unwrap()];
        let after_marker = &existing[existing.find(CLAUDE_MD_END).unwrap() + CLAUDE_MD_END.len()..];
        format!("{}\n\n{}{}", before.trim_end_matches('\n'), section, after_marker)
    } else if existing.is_empty() {
        section
    } else {
        format!("{}\n\n{}", existing.trim_end(), section)
    };

    std::fs::write(&claude_md_path, updated)
        .map_err(|e| ShipItError::Error(format!("Failed to write CLAUDE.md: {}", e)))?;

    Ok(claude_md_path)
}

pub fn init(args: InitArgs) -> Result<(), ShipItError> {
    let dir = args
        .dir
        .map(std::path::PathBuf::from)
        .unwrap_or_else(|| std::env::current_dir().expect("Failed to get current directory"));

    // Always write/update CLAUDE.md so re-running init after a shipit upgrade
    // refreshes the embedded agent guide.
    let claude_md_path = write_claude_md(&dir)?;
    crate::output::print_success(&format!("Agent guide written to: {}", claude_md_path.display().bold()));

    let path = dir.join("shipit.toml");

    if path.exists() {
        eprintln!(
            "{} Config already exists at: {}",
            "!".yellow().bold(),
            path.display().bold()
        );
        return Ok(());
    }

    let mut settings = Settings::default();

    // --- Platform Domain ---
    eprintln!();
    eprintln!("{}", "Platform Domain".bold().cyan());
    eprintln!("  A platform is a remote Git repository service (e.g. GitHub or GitLab).");
    eprintln!("  Only {} and {} are currently supported.", "github.com".bold(), "gitlab.com".bold());

    let domain = if let Some(domain) = args.platform_domain {
        domain
    } else {
        eprintln!();
        prompt_line("Platform domain (e.g. github.com):")?
    };

    if !domain.trim().is_empty() {
        settings.platform.domain = domain.trim().to_string();
        crate::output::print_success("Platform domain saved.");
    } else {
        crate::output::print_skipped("Platform domain skipped.");
    }

    // --- Platform Token ---
    eprintln!();
    eprintln!("{}", "Platform Personal Access Token".bold().cyan());

    let token = if let Some(token) = args.platform_token {
        token
    } else {
        eprintln!();
        prompt_line("Platform token (leave blank to skip):")?
    };

    if !token.trim().is_empty() {
        settings.platform.token = token.trim().to_string();
        crate::output::print_success("Platform token saved.");
    } else {
        crate::output::print_skipped("Platform token skipped.");
    }

    eprintln!();
    confy::store_path(&path, &settings)
        .map_err(|e| ShipItError::Error(format!("Failed to write config: {}", e)))?;

    let existing = std::fs::read_to_string(&path)
        .map_err(|e| ShipItError::Error(format!("Failed to read config: {}", e)))?;
    let versioned = format!(
        "# Generated by shipit v{} on {}\n{}",
        env!("CARGO_PKG_VERSION"),
        Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
        existing
    );
    std::fs::write(&path, versioned)
        .map_err(|e| ShipItError::Error(format!("Failed to write config: {}", e)))?;

    crate::output::print_success(&format!("Config written to: {}", path.display().bold()));

    let plans_dir = dir.join(".shipit").join("plans");
    std::fs::create_dir_all(&plans_dir)
        .map_err(|e| ShipItError::Error(format!("Failed to create .shipit/plans directory: {}", e)))?;
    crate::output::print_success(&format!("Plans directory created at: {}", plans_dir.display().bold()));

    update_gitignore(&dir)?;

    Ok(())
}

/// Append shipit entries to `.gitignore` if they are not already present.
///
/// Adds `shipit.toml` (contains the platform token) and `.shipit/` (ephemeral
/// plan files) under a clearly marked shipit block so users can easily find and
/// remove them if they prefer to commit these files.
fn update_gitignore(dir: &std::path::Path) -> Result<(), ShipItError> {
    const ENTRIES: &[&str] = &["shipit.toml", ".shipit/"];

    let gitignore_path = dir.join(".gitignore");
    let existing = if gitignore_path.exists() {
        std::fs::read_to_string(&gitignore_path)
            .map_err(|e| ShipItError::Error(format!("Failed to read .gitignore: {}", e)))?
    } else {
        String::new()
    };

    let missing: Vec<&str> = ENTRIES
        .iter()
        .copied()
        .filter(|entry| !existing.lines().any(|line| line.trim() == *entry))
        .collect();

    if missing.is_empty() {
        return Ok(());
    }

    let block = format!(
        "\n# shipit\n{}\n",
        missing.join("\n")
    );

    let updated = format!("{}{}", existing.trim_end_matches('\n'), block);
    std::fs::write(&gitignore_path, updated)
        .map_err(|e| ShipItError::Error(format!("Failed to write .gitignore: {}", e)))?;

    crate::output::print_success(&format!(
        ".gitignore updated with: {}",
        missing.join(", ").bold()
    ));

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::update_gitignore;
    use tempfile::TempDir;

    #[test]
    fn test_creates_gitignore_when_absent() {
        let dir = TempDir::new().unwrap();
        update_gitignore(dir.path()).unwrap();

        let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
        assert!(content.contains("shipit.toml"));
        assert!(content.contains(".shipit/"));
        assert!(content.contains("# shipit"));
    }

    #[test]
    fn test_appends_to_existing_gitignore() {
        let dir = TempDir::new().unwrap();
        std::fs::write(dir.path().join(".gitignore"), "node_modules/\n.env\n").unwrap();

        update_gitignore(dir.path()).unwrap();

        let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
        assert!(content.contains("node_modules/"));
        assert!(content.contains(".env"));
        assert!(content.contains("shipit.toml"));
        assert!(content.contains(".shipit/"));
    }

    #[test]
    fn test_idempotent_when_entries_already_present() {
        let dir = TempDir::new().unwrap();
        std::fs::write(dir.path().join(".gitignore"), "# shipit\nshipit.toml\n.shipit/\n").unwrap();

        update_gitignore(dir.path()).unwrap();

        let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
        assert_eq!(content.matches("shipit.toml").count(), 1, "shipit.toml should not be duplicated");
        assert_eq!(content.matches(".shipit/").count(), 1, ".shipit/ should not be duplicated");
    }

    #[test]
    fn test_only_missing_entries_are_added() {
        let dir = TempDir::new().unwrap();
        std::fs::write(dir.path().join(".gitignore"), "shipit.toml\n").unwrap();

        update_gitignore(dir.path()).unwrap();

        let content = std::fs::read_to_string(dir.path().join(".gitignore")).unwrap();
        assert_eq!(content.matches("shipit.toml").count(), 1, "shipit.toml should not be duplicated");
        assert!(content.contains(".shipit/"), ".shipit/ should have been added");
    }
}