1#![forbid(unsafe_code)]
2#![allow(clippy::nursery)]
4#![allow(clippy::pedantic)]
5
6pub mod browser;
59
60#[cfg(feature = "github")]
61pub mod github;
62
63use std::io;
64use std::path::Path;
65
66use glamour::{Style as GlamourStyle, TermRenderer};
67
68#[derive(Debug, Clone)]
86pub struct Config {
87 pager: bool,
88 width: Option<usize>,
89 style: String,
90 line_numbers: bool,
91 preserve_newlines: bool,
92}
93
94impl Config {
95 pub fn new() -> Self {
97 Self {
98 pager: true,
99 width: None,
100 style: "dark".to_string(),
101 line_numbers: false,
102 preserve_newlines: false,
103 }
104 }
105
106 pub fn pager(mut self, enabled: bool) -> Self {
108 self.pager = enabled;
109 self
110 }
111
112 pub fn width(mut self, width: usize) -> Self {
114 self.width = Some(width);
115 self
116 }
117
118 pub fn style(mut self, style: impl Into<String>) -> Self {
120 self.style = style.into();
121 self
122 }
123
124 pub fn line_numbers(mut self, enabled: bool) -> Self {
126 self.line_numbers = enabled;
127 self
128 }
129
130 pub fn preserve_newlines(mut self, enabled: bool) -> Self {
132 self.preserve_newlines = enabled;
133 self
134 }
135
136 fn glamour_style(&self) -> io::Result<GlamourStyle> {
137 parse_style(&self.style).ok_or_else(|| {
138 io::Error::new(
139 io::ErrorKind::InvalidInput,
140 format!("unknown style: {}", self.style),
141 )
142 })
143 }
144
145 fn renderer(&self) -> io::Result<TermRenderer> {
146 let style = self.glamour_style()?;
147 let mut renderer = TermRenderer::new()
148 .with_style(style)
149 .with_preserved_newlines(self.preserve_newlines);
150 if let Some(width) = self.width {
151 renderer = renderer.with_word_wrap(width);
152 }
153 #[cfg(feature = "syntax-highlighting")]
155 if self.line_numbers {
156 renderer.set_line_numbers(true);
157 }
158 Ok(renderer)
159 }
160}
161
162impl Default for Config {
163 fn default() -> Self {
164 Self::new()
165 }
166}
167
168#[derive(Debug)]
183pub struct Reader {
184 config: Config,
185}
186
187impl Reader {
188 pub fn new(config: Config) -> Self {
190 Self { config }
191 }
192
193 pub fn config(&self) -> &Config {
195 &self.config
196 }
197
198 pub fn read_file<P: AsRef<Path>>(&self, path: P) -> io::Result<String> {
200 let markdown = std::fs::read_to_string(path)?;
201 self.render_markdown(&markdown)
202 }
203
204 pub fn render_markdown(&self, markdown: &str) -> io::Result<String> {
206 let renderer = self.config.renderer()?;
207 Ok(trim_rendered_output(&renderer.render(markdown)))
209 }
210}
211
212#[derive(Debug, Default)]
225pub struct Stash {
226 documents: Vec<String>,
227}
228
229impl Stash {
230 pub fn new() -> Self {
232 Self::default()
233 }
234
235 pub fn add(&mut self, path: impl Into<String>) {
237 self.documents.push(path.into());
238 }
239
240 pub fn documents(&self) -> &[String] {
242 &self.documents
243 }
244}
245
246pub mod prelude {
248 pub use crate::browser::{BrowserConfig, Entry, FileBrowser, FileSelectedMsg};
249 pub use crate::{Config, Reader, Stash};
250}
251
252fn trim_rendered_output(s: &str) -> String {
253 let mut out = String::new();
254 let mut iter = s.split('\n').peekable();
255 while let Some(line) = iter.next() {
256 out.push_str(line.trim());
257 if iter.peek().is_some() {
258 out.push('\n');
259 }
260 }
261 out
262}
263
264fn parse_style(style: &str) -> Option<GlamourStyle> {
265 match style.trim().to_ascii_lowercase().as_str() {
266 "dark" => Some(GlamourStyle::Dark),
267 "light" => Some(GlamourStyle::Light),
268 "ascii" => Some(GlamourStyle::Ascii),
269 "pink" => Some(GlamourStyle::Pink),
270 "auto" => Some(GlamourStyle::Auto),
271 "no-tty" | "notty" | "no_tty" => Some(GlamourStyle::NoTty),
272 _ => None,
273 }
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
285 fn parse_style_accepts_known_values() {
286 let cases = ["dark", "light", "ascii", "pink", "auto", "no-tty", "no_tty"];
287 for style in cases {
288 assert!(parse_style(style).is_some(), "style {style} should parse");
289 }
290 }
291
292 #[test]
293 fn parse_style_is_case_insensitive() {
294 assert!(parse_style("DARK").is_some());
295 assert!(parse_style("Dark").is_some());
296 assert!(parse_style("LIGHT").is_some());
297 assert!(parse_style("NoTTY").is_some());
298 }
299
300 #[test]
301 fn parse_style_trims_whitespace() {
302 assert!(parse_style(" dark ").is_some());
303 assert!(parse_style("\tdark\n").is_some());
304 }
305
306 #[test]
307 fn parse_style_returns_none_for_unknown() {
308 assert!(parse_style("unknown").is_none());
309 assert!(parse_style("").is_none());
310 assert!(parse_style("dracula").is_none());
311 }
312
313 #[test]
318 fn config_default_values() {
319 let config = Config::new();
320 assert!(config.pager);
321 assert!(config.width.is_none());
322 assert_eq!(config.style, "dark");
323 }
324
325 #[test]
326 fn config_default_trait() {
327 let config = Config::default();
328 assert!(config.pager);
329 assert_eq!(config.style, "dark");
330 }
331
332 #[test]
333 fn config_pager_sets_value() {
334 let config = Config::new().pager(false);
335 assert!(!config.pager);
336
337 let config = Config::new().pager(true);
338 assert!(config.pager);
339 }
340
341 #[test]
342 fn config_width_sets_value() {
343 let config = Config::new().width(80);
344 assert_eq!(config.width, Some(80));
345
346 let config = Config::new().width(120);
347 assert_eq!(config.width, Some(120));
348 }
349
350 #[test]
351 fn config_style_sets_value() {
352 let config = Config::new().style("light");
353 assert_eq!(config.style, "light");
354
355 let config = Config::new().style(String::from("pink"));
356 assert_eq!(config.style, "pink");
357 }
358
359 #[test]
360 fn config_builder_chaining() {
361 let config = Config::new().pager(false).width(100).style("ascii");
362
363 assert!(!config.pager);
364 assert_eq!(config.width, Some(100));
365 assert_eq!(config.style, "ascii");
366 }
367
368 #[test]
369 fn config_glamour_style_valid() {
370 let config = Config::new().style("dark");
371 assert!(config.glamour_style().is_ok());
372 }
373
374 #[test]
375 fn config_rejects_unknown_style() {
376 let config = Config::new().style("unknown");
377 let err = config.glamour_style().unwrap_err();
378 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
379 }
380
381 #[test]
382 fn config_renderer_creates_renderer() {
383 let config = Config::new().style("dark").width(80);
384 let result = config.renderer();
385 assert!(result.is_ok());
386 }
387
388 #[test]
389 fn config_renderer_fails_on_invalid_style() {
390 let config = Config::new().style("invalid");
391 let result = config.renderer();
392 assert!(result.is_err());
393 }
394
395 #[test]
400 fn reader_new_stores_config() {
401 let config = Config::new().style("light").width(100);
402 let reader = Reader::new(config);
403
404 assert_eq!(reader.config().style, "light");
405 assert_eq!(reader.config().width, Some(100));
406 }
407
408 #[test]
409 fn reader_render_markdown_basic() {
410 let config = Config::new().style("dark");
411 let reader = Reader::new(config);
412
413 let result = reader.render_markdown("# Hello World");
414 assert!(result.is_ok());
415 let output = result.unwrap();
416 assert!(!output.is_empty());
417 }
418
419 #[test]
420 fn reader_render_markdown_empty_input() {
421 let config = Config::new().style("dark");
422 let reader = Reader::new(config);
423
424 let result = reader.render_markdown("");
425 assert!(result.is_ok());
426 }
427
428 #[test]
429 fn reader_render_markdown_complex() {
430 let config = Config::new().style("dark").width(80);
431 let reader = Reader::new(config);
432
433 let markdown = r#"
434# Heading
435
436Some **bold** and *italic* text.
437
438- List item 1
439- List item 2
440
441```rust
442fn main() {}
443```
444"#;
445
446 let result = reader.render_markdown(markdown);
447 assert!(result.is_ok());
448 }
449
450 #[test]
451 fn reader_render_fails_on_invalid_style() {
452 let config = Config::new().style("invalid");
453 let reader = Reader::new(config);
454
455 let result = reader.render_markdown("# Test");
456 assert!(result.is_err());
457 }
458
459 #[test]
460 fn reader_read_file_nonexistent() {
461 let config = Config::new().style("dark");
462 let reader = Reader::new(config);
463
464 let result = reader.read_file("/nonexistent/path/file.md");
465 assert!(result.is_err());
466 }
467
468 #[test]
473 fn stash_new_is_empty() {
474 let stash = Stash::new();
475 assert!(stash.documents().is_empty());
476 }
477
478 #[test]
479 fn stash_default_is_empty() {
480 let stash = Stash::default();
481 assert!(stash.documents().is_empty());
482 }
483
484 #[test]
485 fn stash_add_single_document() {
486 let mut stash = Stash::new();
487 stash.add("/path/to/file.md");
488
489 assert_eq!(stash.documents().len(), 1);
490 assert_eq!(stash.documents()[0], "/path/to/file.md");
491 }
492
493 #[test]
494 fn stash_add_multiple_documents() {
495 let mut stash = Stash::new();
496 stash.add("file1.md");
497 stash.add("file2.md");
498 stash.add("file3.md");
499
500 assert_eq!(stash.documents().len(), 3);
501 assert_eq!(stash.documents(), &["file1.md", "file2.md", "file3.md"]);
502 }
503
504 #[test]
505 fn stash_add_accepts_string() {
506 let mut stash = Stash::new();
507 stash.add(String::from("owned.md"));
508
509 assert_eq!(stash.documents()[0], "owned.md");
510 }
511
512 #[test]
513 fn stash_add_accepts_str() {
514 let mut stash = Stash::new();
515 stash.add("borrowed.md");
516
517 assert_eq!(stash.documents()[0], "borrowed.md");
518 }
519}