spikes 0.4.0

Drop-in feedback collection for static HTML mockups
use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::auth::{get_api_base, AuthConfig};
use crate::error::{map_http_error, map_network_error, Error, Result};
use crate::spike::Spike;

pub struct UnshareOptions {
    pub slug: String,
    pub force: bool,
    pub json: bool,
}

#[derive(Debug, Deserialize)]
struct ShareInfo {
    id: String,
    #[allow(dead_code)]
    slug: String,
    #[serde(default)]
    exported_spikes: Vec<Spike>,
}

#[derive(Debug, Serialize)]
struct UnshareResult {
    success: bool,
    slug: String,
    spikes_saved: usize,
    backup_path: Option<String>,
}

pub fn run(options: UnshareOptions) -> Result<()> {
    let token = AuthConfig::token()?
        .ok_or_else(|| {
            Error::Io(std::io::Error::new(
                std::io::ErrorKind::NotFound,
                "Not logged in. Run 'spikes login' first.",
            ))
        })?;

    // Get share info first to retrieve the ID and spikes
    let share_info = fetch_share_info(&token, &options.slug)?;

    // Confirm unless --force
    if !options.force && !options.json {
        print!(
            "Delete share '{}'? This cannot be undone. [y/N] ",
            options.slug
        );
        io::stdout().flush()?;

        let mut input = String::new();
        io::stdin().read_line(&mut input)?;

        if !input.trim().eq_ignore_ascii_case("y") {
            println!("Cancelled.");
            return Ok(());
        }
    }

    // Delete the share
    delete_share(&token, &share_info.id)?;

    // Save exported spikes to .spikes/{slug}.jsonl
    let backup_path = save_exported_spikes(&options.slug, &share_info.exported_spikes)?;

    let result = UnshareResult {
        success: true,
        slug: options.slug.clone(),
        spikes_saved: share_info.exported_spikes.len(),
        backup_path: backup_path.clone(),
    };

    if options.json {
        println!(
            "{}",
            serde_json::to_string_pretty(&result).expect("Failed to serialize to JSON")
        );
    } else {
        println!();
        println!("  🗡️  Share deleted");
        println!();
        println!("  Slug:           {}", options.slug);
        println!("  Spikes saved:   {}", share_info.exported_spikes.len());
        if let Some(path) = backup_path {
            println!("  Backup:         {}", path);
        }
        println!();
    }

    Ok(())
}

fn fetch_share_info(token: &str, slug: &str) -> Result<ShareInfo> {
    let api_base = get_api_base();
    let url = format!("{}/shares/{}", api_base.trim_end_matches('/'), slug);

    let response = match ureq::get(&url)
        .set("Authorization", &format!("Bearer {}", token))
        .call()
    {
        Ok(resp) => resp,
        Err(ureq::Error::Status(status, response)) => {
            let body = response.into_string().ok();
            return Err(map_http_error(status, body.as_deref()));
        }
        Err(e) => return Err(map_network_error(&e.to_string())),
    };

    let status = response.status();

    if status != 200 {
        let body = response.into_string().ok();
        return Err(map_http_error(status, body.as_deref()));
    }

    let body = response
        .into_string()
        .map_err(|e| Error::RequestFailed(format!("Failed to read response: {}", e)))?;

    let share_info: ShareInfo = serde_json::from_str(&body)?;
    Ok(share_info)
}

fn delete_share(token: &str, id: &str) -> Result<()> {
    let api_base = get_api_base();
    let url = format!("{}/shares/{}", api_base.trim_end_matches('/'), id);

    let response = match ureq::delete(&url)
        .set("Authorization", &format!("Bearer {}", token))
        .call()
    {
        Ok(resp) => resp,
        Err(ureq::Error::Status(status, response)) => {
            let body = response.into_string().ok();
            return Err(map_http_error(status, body.as_deref()));
        }
        Err(e) => return Err(map_network_error(&e.to_string())),
    };

    let status = response.status();

    if status != 200 && status != 204 {
        let body = response.into_string().ok();
        return Err(map_http_error(status, body.as_deref()));
    }

    Ok(())
}

fn save_exported_spikes(slug: &str, spikes: &[Spike]) -> Result<Option<String>> {
    if spikes.is_empty() {
        return Ok(None);
    }

    let spikes_dir = Path::new(".spikes");
    if !spikes_dir.exists() {
        fs::create_dir_all(spikes_dir)?;
    }

    let backup_path = spikes_dir.join(format!("{}.jsonl", slug));

    let mut file = OpenOptions::new()
        .create(true)
        .write(true)
        .truncate(true)
        .open(&backup_path)?;

    for spike in spikes {
        let mut json = serde_json::to_string(spike)?;
        json.push('\n');
        file.write_all(json.as_bytes())?;
    }

    Ok(Some(backup_path.to_string_lossy().to_string()))
}