ostool 0.15.0

A tool for operating system development
Documentation
pub mod client;
pub mod config;
pub mod config_tui;
pub mod global_config;
pub mod serial_stream;
pub mod session;
pub mod terminal;

use std::path::Path;

use anyhow::Context as _;

use crate::board::{
    client::{BoardServerClient, BoardTypeSummary},
    config::BoardRunConfig,
    config_tui::run_board_config_tui,
    global_config::LoadedBoardGlobalConfig,
    session::BoardSession,
};
use crate::{
    Tool,
    build::config::{BuildConfig, BuildSystem, Cargo},
};

#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RunBoardOptions {
    pub board_type: Option<String>,
    pub server: Option<String>,
    pub port: Option<u16>,
}

pub async fn fetch_board_types(server: &str, port: u16) -> anyhow::Result<Vec<BoardTypeSummary>> {
    let client = BoardServerClient::new(server, port)?;
    let mut boards = client
        .list_board_types()
        .await
        .context("failed to list board types")?;
    boards.sort_by(|a, b| a.board_type.cmp(&b.board_type));
    Ok(boards)
}

pub fn render_board_table(boards: &[BoardTypeSummary]) -> String {
    if boards.is_empty() {
        return "No board types found.".to_string();
    }

    let type_width = boards
        .iter()
        .map(|item| item.board_type.len())
        .max()
        .unwrap_or(10)
        .max("BOARD TYPE".len());
    let avail_width = boards
        .iter()
        .map(|item| item.available.to_string().len())
        .max()
        .unwrap_or(1)
        .max("AVAILABLE".len());
    let total_width = boards
        .iter()
        .map(|item| item.total.to_string().len())
        .max()
        .unwrap_or(1)
        .max("TOTAL".len());

    let mut lines = Vec::with_capacity(boards.len() + 1);
    lines.push(format!(
        "{:<type_width$}  {:>avail_width$}  {:>total_width$}  TAGS",
        "BOARD TYPE",
        "AVAILABLE",
        "TOTAL",
        type_width = type_width,
        avail_width = avail_width,
        total_width = total_width,
    ));

    for item in boards {
        let tags = if item.tags.is_empty() {
            "-".to_string()
        } else {
            item.tags.join(",")
        };
        lines.push(format!(
            "{:<type_width$}  {:>avail_width$}  {:>total_width$}  {}",
            item.board_type,
            item.available,
            item.total,
            tags,
            type_width = type_width,
            avail_width = avail_width,
            total_width = total_width,
        ));
    }

    lines.join("\n")
}

pub async fn list_boards(server: &str, port: u16) -> anyhow::Result<()> {
    let boards = fetch_board_types(server, port).await?;
    println!("{}", render_board_table(&boards));
    Ok(())
}

pub fn config() -> anyhow::Result<()> {
    run_board_config_tui()
}

pub fn load_board_global_config_with_notice() -> anyhow::Result<LoadedBoardGlobalConfig> {
    let loaded = LoadedBoardGlobalConfig::load_or_create()?;
    if loaded.created {
        println!("Created default board config: {}", loaded.path.display());
    }
    Ok(loaded)
}

pub async fn acquire_board_session(
    server: &str,
    port: u16,
    board_type: &str,
) -> anyhow::Result<(BoardServerClient, BoardSession)> {
    let client = BoardServerClient::new(server, port)?;
    let session = BoardSession::acquire(client.clone(), board_type)
        .await
        .with_context(|| format!("failed to acquire board type `{board_type}`"))?;
    Ok((client, session))
}

pub async fn connect_board(server: &str, port: u16, board_type: &str) -> anyhow::Result<()> {
    let (client, session) = acquire_board_session(server, port, board_type).await?;
    print_allocated_board_session(&session, board_type);

    let result = if session.info().serial_available {
        let ws_path = session
            .info()
            .ws_url
            .as_deref()
            .ok_or_else(|| anyhow::anyhow!("server did not return a serial websocket URL"))?;
        let ws_url = client.resolve_ws_url(ws_path)?;
        terminal::run_serial_terminal(ws_url).await
    } else {
        let lease_expires_at = session.current_lease_expires_at().await;
        println!("Board has no serial configuration; keeping session alive until Ctrl+C.");
        println!("  lease_expires_at: {lease_expires_at}");
        tokio::signal::ctrl_c()
            .await
            .context("failed to wait for Ctrl+C")?;
        Ok(())
    };

    finalize_session(session, result).await
}

fn print_allocated_board_session(session: &BoardSession, board_type: &str) {
    println!("Allocated board session:");
    println!("  board_type: {board_type}");
    println!("  board_id: {}", session.info().board_id);
    println!("  session_id: {}", session.info().session_id);
    println!("  lease_expires_at: {}", session.info().lease_expires_at);
    println!("  boot_mode: {}", session.info().boot_mode);
}

async fn finalize_session(
    session: BoardSession,
    run_result: anyhow::Result<()>,
) -> anyhow::Result<()> {
    let release_result = session.release().await;
    match (run_result, release_result) {
        (Ok(()), Ok(())) => Ok(()),
        (Err(err), Ok(())) => Err(err),
        (Ok(()), Err(err)) => Err(err),
        (Err(run_err), Err(release_err)) => Err(run_err.context(format!(
            "additionally failed to release board session: {release_err:#}"
        ))),
    }
}

impl Tool {
    pub fn default_board_run_config(&self) -> BoardRunConfig {
        BoardRunConfig::default()
    }

    pub async fn read_board_run_config_from_path_for_cargo(
        &mut self,
        cargo: &Cargo,
        path: &Path,
    ) -> anyhow::Result<BoardRunConfig> {
        self.sync_cargo_context(cargo);
        let path = self.replace_path_variables(path.to_path_buf())?;
        BoardRunConfig::read_from_path(self, path)
    }

    pub async fn ensure_board_run_config_in_dir_for_cargo(
        &mut self,
        cargo: &Cargo,
        dir: &Path,
    ) -> anyhow::Result<BoardRunConfig> {
        self.sync_cargo_context(cargo);
        let dir = self.replace_path_variables(dir.to_path_buf())?;
        BoardRunConfig::load_or_create(self, Some(dir.join(".board.toml"))).await
    }

    pub async fn ensure_board_run_config_in_dir(
        &mut self,
        dir: &Path,
    ) -> anyhow::Result<BoardRunConfig> {
        let dir = self.replace_path_variables(dir.to_path_buf())?;
        BoardRunConfig::load_or_create(self, Some(dir.join(".board.toml"))).await
    }

    pub async fn read_board_run_config_from_path(
        &mut self,
        path: &Path,
    ) -> anyhow::Result<BoardRunConfig> {
        let path = self.replace_path_variables(path.to_path_buf())?;
        BoardRunConfig::read_from_path(self, path)
    }

    pub async fn run_board(
        &mut self,
        build_config: &BuildConfig,
        board_config: &BoardRunConfig,
        options: RunBoardOptions,
    ) -> anyhow::Result<()> {
        self.run_board_with_build_config(build_config, board_config, options)
            .await
    }

    pub async fn cargo_run_board(
        &mut self,
        cargo: &Cargo,
        board_config: &BoardRunConfig,
        options: RunBoardOptions,
    ) -> anyhow::Result<()> {
        self.sync_cargo_context(cargo);
        self.run_board_with_build_config(
            &BuildConfig {
                system: BuildSystem::Cargo(cargo.clone()),
            },
            board_config,
            options,
        )
        .await
    }

    async fn run_board_with_build_config(
        &mut self,
        build_config: &BuildConfig,
        board_config: &BoardRunConfig,
        options: RunBoardOptions,
    ) -> anyhow::Result<()> {
        self.prepare_runtime_artifacts(build_config, false).await?;
        self.run_prepared_board(board_config, options).await
    }

    async fn run_prepared_board(
        &mut self,
        board_config: &BoardRunConfig,
        options: RunBoardOptions,
    ) -> anyhow::Result<()> {
        let global_config = load_board_global_config_with_notice()?;
        let mut board_config = board_config.clone();
        board_config.apply_overrides(
            self,
            options.board_type.as_deref(),
            options.server.as_deref(),
            options.port,
        )?;

        let (server, port) = board_config.resolve_server(None, None, &global_config.board);
        let (client, session) =
            acquire_board_session(&server, port, &board_config.board_type).await?;
        print_allocated_board_session(&session, &board_config.board_type);

        let run_result = match session.info().boot_mode.as_str() {
            "uboot" => {
                self.run_uboot_remote(&board_config, client, session.info().clone())
                    .await
            }
            other => Err(anyhow!(
                "unsupported board boot mode `{other}`; only `uboot` is supported"
            )),
        };

        finalize_session(session, run_result).await
    }
}

#[cfg(test)]
mod tests {
    use super::{RunBoardOptions, render_board_table};
    use crate::board::client::BoardTypeSummary;

    #[test]
    fn run_board_args_default_to_no_overrides() {
        assert_eq!(RunBoardOptions::default().board_type, None);
    }

    #[test]
    fn render_board_table_formats_rows() {
        let rendered = render_board_table(&[BoardTypeSummary {
            board_type: "rk3568".into(),
            tags: vec!["arm64".into(), "lab".into()],
            total: 3,
            available: 2,
        }]);

        assert!(rendered.contains("BOARD TYPE"));
        assert!(rendered.contains("rk3568"));
        assert!(rendered.contains("arm64,lab"));
    }

    #[test]
    fn render_board_table_handles_empty_results() {
        assert_eq!(render_board_table(&[]), "No board types found.");
    }
}