tloop 0.0.5

Tauri plugin for Arduino integration — flash firmware, stream serial data, detect boards from your desktop app
use crate::{arduino, state::LoopState, LoopFeature};
use tauri::State;

#[derive(Debug, serde::Serialize, serde::Deserialize)]
pub struct BoardInfo {
    pub name: String,
    pub port: String,
    pub fqbn: String,
}

/// Parsea la salida JSON de `arduino-cli board list --format json`.
///
/// Soporta dos formatos:
///   • Antiguo (< 0.35): array plano   `[{"address":"COM3","boards":[...]}]`
///   • Moderno (≥ 0.35): objeto         `{"detected_ports":[{"port":{"address":"COM3"},"boards":[...]}]}`
///
/// Los puertos sin placa reconocida se omiten (FQBN vacío) para mantener
/// compatibilidad con el comportamiento previo del comando `loop connected-boards`.
fn parse_board_list_json(json: &serde_json::Value) -> Vec<BoardInfo> {
    let mut result = vec![];

    // ── Formato moderno: {"detected_ports": [...]} ────────────────────────
    if let Some(ports) = json.get("detected_ports").and_then(|v| v.as_array()) {
        for entry in ports {
            let port = entry
                .get("port")
                .and_then(|p| p.get("address"))
                .and_then(|a| a.as_str())
                .unwrap_or("")
                .to_string();

            if port.is_empty() {
                continue;
            }

            // Preferir "matching_boards" (más fiable en ≥ 0.35) o caer en "boards"
            let board_list = entry
                .get("matching_boards")
                .or_else(|| entry.get("boards"))
                .and_then(|v| v.as_array());

            if let Some(bl) = board_list {
                for board in bl {
                    let fqbn = board["fqbn"].as_str().unwrap_or("").to_string();
                    if !fqbn.is_empty() {
                        result.push(BoardInfo {
                            name: board["name"].as_str().unwrap_or("Unknown").to_string(),
                            fqbn,
                            port: port.clone(),
                        });
                    }
                }
            }
        }
        return result;
    }

    // ── Formato antiguo: [{"address":"COM3","boards":[...]}] ──────────────
    if let Some(entries) = json.as_array() {
        for entry in entries {
            let port = entry["address"].as_str().unwrap_or("").to_string();
            if let Some(boards) = entry["boards"].as_array() {
                for board in boards {
                    let fqbn = board["fqbn"].as_str().unwrap_or("").to_string();
                    if !fqbn.is_empty() {
                        result.push(BoardInfo {
                            name: board["name"].as_str().unwrap_or("Unknown").to_string(),
                            fqbn,
                            port: port.clone(),
                        });
                    }
                }
            }
        }
    }

    result
}

/// Llama a `arduino-cli board list --format json` y parsea el resultado.
/// Compatible con las versiones antigua (< 0.35) y moderna (≥ 0.35) de arduino-cli.
pub fn detect_boards(cli_path: &std::path::Path) -> Result<Vec<BoardInfo>, String> {
    let output = arduino::run(cli_path, &["board", "list", "--format", "json"])
        .map_err(|e| e.to_string())?;

    let json: serde_json::Value =
        serde_json::from_slice(&output.stdout).map_err(|e| e.to_string())?;

    Ok(parse_board_list_json(&json))
}

#[tauri::command]
pub fn loop_connected_boards(state: State<LoopState>) -> Result<Vec<BoardInfo>, String> {
    if !state.has_feature(&LoopFeature::ConnectedBoards) {
        return Err("feature connected_boards not enabled in loop.config.toml".into());
    }
    detect_boards(&state.arduino_cli_path)
}

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

    #[test]
    fn board_info_serializes() {
        let b = BoardInfo {
            name: "Arduino Uno".into(),
            port: "COM3".into(),
            fqbn: "arduino:avr:uno".into(),
        };
        let json = serde_json::to_string(&b).unwrap();
        let roundtrip: BoardInfo = serde_json::from_str(&json).unwrap();
        assert_eq!(roundtrip.name, "Arduino Uno");
        assert_eq!(roundtrip.port, "COM3");
        assert_eq!(roundtrip.fqbn, "arduino:avr:uno");
    }

    #[test]
    fn parse_old_format_flat_array() {
        // Formato antiguo (arduino-cli < 0.35): array plano con "address"
        let json: serde_json::Value = serde_json::json!([
            {
                "address": "COM3",
                "boards": [{ "name": "Arduino Uno", "fqbn": "arduino:avr:uno" }]
            }
        ]);
        let boards = parse_board_list_json(&json);
        assert_eq!(boards.len(), 1);
        assert_eq!(boards[0].port, "COM3");
        assert_eq!(boards[0].fqbn, "arduino:avr:uno");
    }

    #[test]
    fn parse_new_format_detected_ports() {
        // Formato moderno (arduino-cli ≥ 0.35): objeto con "detected_ports"
        let json: serde_json::Value = serde_json::json!({
            "detected_ports": [
                {
                    "port": { "address": "COM4", "protocol": "serial" },
                    "matching_boards": [{ "name": "Arduino Mega", "fqbn": "arduino:avr:mega" }]
                }
            ]
        });
        let boards = parse_board_list_json(&json);
        assert_eq!(boards.len(), 1);
        assert_eq!(boards[0].port, "COM4");
        assert_eq!(boards[0].fqbn, "arduino:avr:mega");
    }

    #[test]
    fn parse_new_format_falls_back_to_boards_field() {
        // Si no hay "matching_boards", usa "boards"
        let json: serde_json::Value = serde_json::json!({
            "detected_ports": [
                {
                    "port": { "address": "/dev/ttyUSB0" },
                    "boards": [{ "name": "Arduino Nano", "fqbn": "arduino:avr:nano" }]
                }
            ]
        });
        let boards = parse_board_list_json(&json);
        assert_eq!(boards.len(), 1);
        assert_eq!(boards[0].port, "/dev/ttyUSB0");
    }

    #[test]
    fn parse_skips_entries_with_empty_fqbn() {
        // Puerto sin placa reconocida → no se incluye (por compatibilidad)
        let json: serde_json::Value = serde_json::json!({
            "detected_ports": [
                {
                    "port": { "address": "COM5" },
                    "matching_boards": [{ "name": "", "fqbn": "" }]
                }
            ]
        });
        let boards = parse_board_list_json(&json);
        assert_eq!(boards.len(), 0);
    }

    #[test]
    fn parse_empty_input_returns_empty_vec() {
        let json: serde_json::Value = serde_json::json!([]);
        let boards = parse_board_list_json(&json);
        assert_eq!(boards.len(), 0);
    }

    #[test]
    #[ignore = "requires bundled arduino-cli binary; run with -- --ignored"]
    fn detect_boards_parses_empty_json() {
        // Sin Arduino conectado devuelve [] sin error
        let tmp = std::env::temp_dir().join("loop_boards_test");
        let cli = crate::arduino::extract_arduino_cli(tmp).unwrap();
        let result = detect_boards(&cli);
        assert!(result.is_ok(), "detect_boards failed: {:?}", result.err());
    }
}