Skip to main content

rdf_compare/web/
mod.rs

1//! Local web viewer for rdf-compare.
2
3pub mod api;
4pub mod assets;
5
6use crate::cli::InputFormat;
7use crate::diff::{DiffResult, compute_diff, load_diff_file};
8use anyhow::{Context, Result};
9use std::net::SocketAddr;
10use std::path::PathBuf;
11use std::sync::Arc;
12use tokio::sync::Mutex;
13
14/// Shared application state.
15#[derive(Clone, Default)]
16pub struct AppState {
17    pub data: Arc<Mutex<Option<Arc<DiffResult>>>>,
18}
19
20/// Server lifecycle wrapper.
21pub struct Server {
22    pub addr: SocketAddr,
23    handle: tokio::task::JoinHandle<()>,
24}
25
26impl Server {
27    pub async fn join(self) -> Result<()> {
28        self.handle.await.context("web server task panicked")?;
29        Ok(())
30    }
31}
32
33/// Spec describing what the viewer should preload before it starts serving.
34pub enum Preload {
35    None,
36    Files {
37        file_a: PathBuf,
38        file_b: PathBuf,
39        format_a: Option<InputFormat>,
40        format_b: Option<InputFormat>,
41        graph_a: Option<String>,
42        graph_b: Option<String>,
43        ignore_blank_nodes: bool,
44    },
45    Diff {
46        diff: PathBuf,
47        format: Option<InputFormat>,
48        graph_a: Option<String>,
49        graph_b: Option<String>,
50    },
51    Loaded(DiffResult),
52}
53
54pub async fn build_state(preload: Preload) -> Result<AppState> {
55    let state = AppState::default();
56    match preload {
57        Preload::None => {}
58        Preload::Loaded(mut d) => {
59            d.sort_rows();
60            *state.data.lock().await = Some(Arc::new(d));
61        }
62        Preload::Files {
63            file_a,
64            file_b,
65            format_a,
66            format_b,
67            graph_a,
68            graph_b,
69            ignore_blank_nodes,
70        } => {
71            let inputs = crate::diff::DiffInputs {
72                file_a,
73                file_b,
74                format_a,
75                format_b,
76                graph_a,
77                graph_b,
78                ignore_blank_nodes,
79            };
80            let mut result = tokio::task::spawn_blocking(move || compute_diff(&inputs))
81                .await
82                .context("diff task panicked")??;
83            result.sort_rows();
84            *state.data.lock().await = Some(Arc::new(result));
85        }
86        Preload::Diff {
87            diff,
88            format,
89            graph_a,
90            graph_b,
91        } => {
92            let inputs = crate::diff::LoadDiffInputs {
93                diff,
94                format,
95                graph_a,
96                graph_b,
97            };
98            let mut result = tokio::task::spawn_blocking(move || load_diff_file(&inputs))
99                .await
100                .context("load task panicked")??;
101            result.sort_rows();
102            *state.data.lock().await = Some(Arc::new(result));
103        }
104    }
105    Ok(state)
106}
107
108/// Start the HTTP server. Returns once the listener is bound.
109pub async fn start(bind: &str, state: AppState) -> Result<Server> {
110    let listener = tokio::net::TcpListener::bind(bind)
111        .await
112        .with_context(|| format!("failed to bind {bind}"))?;
113    let addr = listener.local_addr().context("local_addr")?;
114    let app = api::router(state);
115    let handle = tokio::spawn(async move {
116        if let Err(e) = axum::serve(listener, app).await {
117            eprintln!("web server error: {e}");
118        }
119    });
120    Ok(Server { addr, handle })
121}
122
123/// Synchronous helper used by `main.rs`. Builds a current-thread Tokio runtime,
124/// starts the server, optionally opens the browser, and blocks until ctrl-c.
125pub fn run_blocking(bind: &str, open: bool, preload: Preload) -> Result<()> {
126    let rt = tokio::runtime::Builder::new_current_thread()
127        .enable_all()
128        .build()
129        .context("failed to build tokio runtime")?;
130    rt.block_on(async move {
131        let state = build_state(preload).await?;
132        let server = start(bind, state).await?;
133        let url = format!("http://{}/", server.addr);
134        eprintln!("rdf-compare viewer listening on {url}");
135        if open && let Err(e) = webbrowser::open(&url) {
136            eprintln!("could not open browser: {e}");
137        }
138        // wait for ctrl-c or server task end
139        tokio::select! {
140            r = tokio::signal::ctrl_c() => {
141                r.context("ctrl-c handler failed")?;
142                eprintln!("shutting down");
143                Ok::<(), anyhow::Error>(())
144            }
145            _ = server.handle => Ok(()),
146        }
147    })
148}