stardive 0.1.1

Companion CLI for the Stardive API
use std::path::PathBuf;

use anyhow::{Context, Result, anyhow};
use eframe::{App, Frame, NativeOptions, egui};
use reqwest::blocking::multipart::{Form, Part};
use stardive_core::types::FileListResponse;

pub fn run_file_gui(base_url: String, api_key: Option<String>) -> Result<()> {
    let options = NativeOptions::default();
    eframe::run_native(
        "Stardive File Manager",
        options,
        Box::new(|_cc| Ok(Box::new(FileGuiApp::new(base_url, api_key)))),
    )
    .map_err(|err| anyhow!("failed to launch gui: {err}"))?;
    Ok(())
}

struct FileGuiApp {
    base_url: String,
    api_key: Option<String>,
    pending_files: Vec<PathBuf>,
    files: Vec<String>,
    status: String,
    download_target: String,
}

impl FileGuiApp {
    fn new(base_url: String, api_key: Option<String>) -> Self {
        Self {
            base_url,
            api_key,
            pending_files: Vec::new(),
            files: Vec::new(),
            status: "Drop files into this window, then click upload".to_string(),
            download_target: ".".to_string(),
        }
    }

    fn auth_client(&self) -> Result<reqwest::blocking::Client> {
        let mut headers = reqwest::header::HeaderMap::new();
        if let Some(key) = &self.api_key {
            headers.insert(
                reqwest::header::AUTHORIZATION,
                reqwest::header::HeaderValue::from_str(&format!("Bearer {key}"))?,
            );
        }
        reqwest::blocking::Client::builder()
            .default_headers(headers)
            .build()
            .context("failed to build gui http client")
    }

    fn refresh_files(&mut self) -> Result<()> {
        let client = self.auth_client()?;
        let url = format!("{}/v1/files", self.base_url.trim_end_matches('/'));
        let response = client
            .get(url)
            .send()
            .context("failed to fetch file list")?;
        if !response.status().is_success() {
            let body = response.text().unwrap_or_default();
            return Err(anyhow!("list request failed: {}", body));
        }
        let list = response
            .json::<FileListResponse>()
            .context("invalid file list response")?;
        self.files = list
            .files
            .iter()
            .map(|f| format!("{} ({}, {} bytes)", f.id, f.original_name, f.size))
            .collect();
        Ok(())
    }

    fn upload_pending(&mut self) -> Result<()> {
        let client = self.auth_client()?;
        let url = format!("{}/v1/files", self.base_url.trim_end_matches('/'));

        let files = std::mem::take(&mut self.pending_files);
        for path in files {
            let name = path
                .file_name()
                .map(|v| v.to_string_lossy().to_string())
                .ok_or_else(|| anyhow!("invalid path"))?;
            let bytes = std::fs::read(&path)
                .with_context(|| format!("failed to read {}", path.display()))?;
            let part = Part::bytes(bytes).file_name(name);
            let form = Form::new().part("file", part);

            let resp = client
                .post(&url)
                .multipart(form)
                .send()
                .context("upload request failed")?;
            if !resp.status().is_success() {
                let body = resp.text().unwrap_or_default();
                return Err(anyhow!("upload failed: {}", body));
            }
        }

        Ok(())
    }

    fn download_file(&mut self, id: &str) -> Result<()> {
        let client = self.auth_client()?;
        let url = format!("{}/v1/files/{}", self.base_url.trim_end_matches('/'), id);
        let resp = client.get(url).send().context("download request failed")?;
        if !resp.status().is_success() {
            let body = resp.text().unwrap_or_default();
            return Err(anyhow!("download failed: {}", body));
        }
        let bytes = resp.bytes().context("failed to read download")?;
        let target = PathBuf::from(&self.download_target).join(id);
        std::fs::write(&target, bytes)
            .with_context(|| format!("failed to write {}", target.display()))?;
        Ok(())
    }
}

impl App for FileGuiApp {
    fn update(&mut self, ctx: &egui::Context, _frame: &mut Frame) {
        let dropped = ctx.input(|i| i.raw.dropped_files.clone());
        for file in dropped {
            if let Some(path) = file.path {
                self.pending_files.push(path);
            }
        }

        egui::CentralPanel::default().show(ctx, |ui| {
            ui.heading("Stardive File Manager");
            ui.label("Drag and drop files into the window, then upload.");

            ui.horizontal(|ui| {
                if ui.button("Refresh").clicked() {
                    match self.refresh_files() {
                        Ok(_) => self.status = "file list refreshed".to_string(),
                        Err(err) => self.status = err.to_string(),
                    }
                }

                if ui.button("Upload Pending").clicked() {
                    match self.upload_pending() {
                        Ok(_) => self.status = "upload complete".to_string(),
                        Err(err) => self.status = err.to_string(),
                    }
                }
            });

            ui.separator();
            ui.label("Pending uploads:");
            for path in &self.pending_files {
                ui.label(path.display().to_string());
            }

            ui.separator();
            ui.horizontal(|ui| {
                ui.label("Download target dir:");
                ui.text_edit_singleline(&mut self.download_target);
            });

            ui.separator();
            ui.label("Remote files:");
            for line in self.files.clone() {
                ui.horizontal(|ui| {
                    ui.label(&line);
                    if ui.button("Download").clicked() {
                        let id = line
                            .split_whitespace()
                            .next()
                            .unwrap_or_default()
                            .to_string();
                        match self.download_file(&id) {
                            Ok(_) => self.status = format!("downloaded {}", id),
                            Err(err) => self.status = err.to_string(),
                        }
                    }
                });
            }

            ui.separator();
            ui.label(format!("Status: {}", self.status));
        });
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn gui_app_initializes() {
        let app = FileGuiApp::new("https://api.stardive.space".to_string(), None);
        assert!(app.pending_files.is_empty());
    }

    #[test]
    fn parse_id_from_label_format() {
        let value = "abc123 (name.txt, 10 bytes)";
        let parsed = value.split_whitespace().next().unwrap_or_default();
        assert_eq!(parsed, "abc123");
    }

    #[test]
    fn value_type_is_available_for_future_gui_extensions() {
        let _v = serde_json::Value::Null;
    }
}