Skip to main content

ito_core/viewer/
html.rs

1//! HTML browser viewer backend.
2//!
3//! Converts a markdown document to standalone HTML via `pandoc` and opens it in
4//! the system default browser (`open` on macOS, `xdg-open` on Linux).
5
6use std::process::Command;
7
8use crate::errors::{CoreError, CoreResult};
9
10use super::ViewerBackend;
11use super::util::command_on_path;
12
13/// Render markdown as HTML in the system browser via `pandoc`.
14pub struct HtmlViewer;
15
16/// Return the platform-specific command for opening a file in the default application.
17///
18/// Returns `open` on macOS and `xdg-open` on Linux/other Unix systems.
19/// Windows is not currently supported.
20fn browser_opener() -> &'static str {
21    if cfg!(target_os = "macos") {
22        "open"
23    } else {
24        "xdg-open"
25    }
26}
27
28impl ViewerBackend for HtmlViewer {
29    fn name(&self) -> &str {
30        "html"
31    }
32
33    fn description(&self) -> &str {
34        "Open the proposal as HTML in the system browser (requires pandoc)"
35    }
36
37    fn is_available(&self) -> bool {
38        command_on_path("pandoc") && command_on_path(browser_opener())
39    }
40
41    fn availability_hint(&self) -> Option<String> {
42        if !command_on_path("pandoc") {
43            return Some(
44                "pandoc is required for the HTML viewer. \
45                 Install it from https://pandoc.org/installing.html"
46                    .to_string(),
47            );
48        }
49        let opener = browser_opener();
50        if !command_on_path(opener) {
51            return Some(format!(
52                "'{opener}' is required to open the browser. \
53                 Please install it or open the HTML file manually."
54            ));
55        }
56        None
57    }
58
59    fn open(&self, content: &str) -> CoreResult<()> {
60        if !command_on_path("pandoc") {
61            return Err(CoreError::not_found(
62                "pandoc is required for the HTML viewer. \
63                 Install it from https://pandoc.org/installing.html",
64            ));
65        }
66
67        let opener = browser_opener();
68        if !command_on_path(opener) {
69            return Err(CoreError::not_found(format!(
70                "'{opener}' is required to open the browser. \
71                 Please install it or open the HTML file manually.",
72            )));
73        }
74
75        // Write the markdown content to a temporary file.
76        let mut md_file = tempfile::Builder::new()
77            .prefix("ito-viewer-")
78            .suffix(".md")
79            .tempfile()
80            .map_err(|e| CoreError::io("creating temporary markdown file", e))?;
81        std::io::Write::write_all(&mut md_file, content.as_bytes())
82            .map_err(|e| CoreError::io("writing temporary markdown file", e))?;
83
84        // Build the output HTML path alongside the markdown tempfile.
85        // This HTML file intentionally outlives this function: `open`/`xdg-open`
86        // returns immediately while the browser reads the file asynchronously.
87        // Cleanup is left to the OS temp directory reaper.
88        let html_path = md_file.path().with_extension("html");
89
90        // Convert markdown to standalone HTML via pandoc.
91        let pandoc_output = Command::new("pandoc")
92            .arg("--standalone")
93            .arg("--from=markdown")
94            .arg("--to=html5")
95            .arg("-o")
96            .arg(&html_path)
97            .arg(md_file.path())
98            .output()
99            .map_err(|e| CoreError::io("spawning pandoc", e))?;
100
101        if !pandoc_output.status.success() {
102            return Err(CoreError::process(format!(
103                "pandoc failed: {}",
104                String::from_utf8_lossy(&pandoc_output.stderr).trim()
105            )));
106        }
107
108        // Open the HTML file in the system browser.
109        let open_output = Command::new(opener)
110            .arg(&html_path)
111            .output()
112            .map_err(|e| CoreError::io(format!("spawning {opener}"), e))?;
113
114        if !open_output.status.success() {
115            return Err(CoreError::process(format!(
116                "{opener} failed: {}",
117                String::from_utf8_lossy(&open_output.stderr).trim()
118            )));
119        }
120
121        Ok(())
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn html_viewer_reports_expected_name() {
131        assert_eq!(HtmlViewer.name(), "html");
132    }
133
134    #[test]
135    fn html_viewer_reports_expected_description() {
136        let desc = HtmlViewer.description();
137        assert!(
138            desc.contains("HTML"),
139            "description should mention HTML: {desc}"
140        );
141        assert!(
142            desc.contains("pandoc"),
143            "description should mention pandoc: {desc}"
144        );
145    }
146
147    #[test]
148    fn html_viewer_availability_depends_on_pandoc() {
149        // This test validates the code path rather than the environment.
150        // If pandoc is not on PATH, is_available() returns false.
151        let viewer = HtmlViewer;
152        let pandoc_present = command_on_path("pandoc");
153        let opener_present = command_on_path(browser_opener());
154        assert_eq!(viewer.is_available(), pandoc_present && opener_present);
155    }
156
157    #[test]
158    fn html_viewer_open_errors_when_pandoc_missing() {
159        // Only run when pandoc is genuinely absent.
160        if command_on_path("pandoc") {
161            return;
162        }
163        let result = HtmlViewer.open("# Test");
164        assert!(result.is_err());
165        let err = result.unwrap_err().to_string();
166        assert!(err.contains("pandoc"), "error should mention pandoc: {err}");
167        assert!(
168            err.contains("https://pandoc.org"),
169            "error should include install hint: {err}"
170        );
171    }
172}