magical markdown slides
at 9f8b184e4eefac55aaae12c0697e8ee9e6e1eebf 292 lines 9.1 kB view raw
1use clap::{Parser, Subcommand}; 2use ratatui::{Terminal, backend::CrosstermBackend}; 3use slides_core::{parser::parse_slides_with_meta, term::Terminal as SlideTerminal, theme::ThemeRegistry}; 4use slides_tui::App; 5use std::{io, path::PathBuf}; 6use tracing::Level; 7 8/// A modern terminal-based presentation tool 9#[derive(Parser, Debug)] 10#[command(name = "slides")] 11#[command(version, about, long_about = None)] 12struct ArgParser { 13 /// Set logging level (error, warn, info, debug, trace) 14 #[arg(short, long, global = true, default_value = "info")] 15 log_level: Level, 16 17 #[command(subcommand)] 18 command: Commands, 19} 20 21#[derive(Subcommand, Debug)] 22enum Commands { 23 /// Present slides in interactive TUI mode 24 Present { 25 /// Path to the markdown file 26 file: PathBuf, 27 /// Theme to use for presentation 28 #[arg(short, long)] 29 theme: Option<String>, 30 }, 31 32 /// Print slides to stdout with formatting 33 Print { 34 /// Path to the markdown file 35 file: PathBuf, 36 /// Maximum width for output (in characters) 37 #[arg(short, long, default_value = "80")] 38 width: usize, 39 /// Theme to use for coloring 40 #[arg(short, long)] 41 theme: Option<String>, 42 }, 43 44 /// Initialize a new slide deck with example content 45 Init { 46 /// Directory to create the deck in 47 #[arg(default_value = ".")] 48 path: PathBuf, 49 /// Name of the deck file 50 #[arg(short, long, default_value = "slides.md")] 51 name: String, 52 }, 53 54 /// Check slides for errors and lint issues 55 Check { 56 /// Path to the markdown file 57 file: PathBuf, 58 /// Enable strict mode with additional checks 59 #[arg(short, long)] 60 strict: bool, 61 }, 62} 63 64fn main() { 65 let cli = ArgParser::parse(); 66 67 tracing_subscriber::fmt().with_max_level(cli.log_level).init(); 68 69 match cli.command { 70 Commands::Present { file, theme } => { 71 if let Err(e) = run_present(&file, theme) { 72 eprintln!("Error: {}", e); 73 std::process::exit(1); 74 } 75 } 76 77 Commands::Print { file, width, theme } => { 78 if let Err(e) = run_print(&file, width, theme) { 79 eprintln!("Error: {}", e); 80 std::process::exit(1); 81 } 82 } 83 84 Commands::Init { path, name } => { 85 tracing::info!("Initializing new deck: {} in {}", name, path.display()); 86 eprintln!("Init command not yet implemented"); 87 } 88 89 Commands::Check { file, strict } => { 90 tracing::info!("Checking slides: {}", file.display()); 91 if strict { 92 tracing::debug!("Strict mode enabled"); 93 } 94 eprintln!("Check command not yet implemented"); 95 } 96 } 97} 98 99fn run_present(file: &PathBuf, theme_arg: Option<String>) -> io::Result<()> { 100 tracing::info!("Presenting slides from: {}", file.display()); 101 102 let markdown = std::fs::read_to_string(file) 103 .map_err(|e| io::Error::new(e.kind(), format!("Failed to read file {}: {}", file.display(), e)))?; 104 105 let (meta, slides) = parse_slides_with_meta(&markdown) 106 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("Parse error: {}", e)))?; 107 108 if slides.is_empty() { 109 return Err(io::Error::new(io::ErrorKind::InvalidData, "No slides found in file")); 110 } 111 112 let theme_name = theme_arg.unwrap_or_else(|| meta.theme.clone()); 113 tracing::debug!("Using theme: {}", theme_name); 114 115 let theme = ThemeRegistry::get(&theme_name); 116 117 let filename = file 118 .file_name() 119 .and_then(|n| n.to_str()) 120 .unwrap_or("unknown") 121 .to_string(); 122 123 let mut slide_terminal = SlideTerminal::setup()?; 124 125 let result = (|| -> io::Result<()> { 126 let stdout = io::stdout(); 127 let backend = CrosstermBackend::new(stdout); 128 let mut terminal = Terminal::new(backend)?; 129 130 terminal.clear()?; 131 132 let mut app = App::new(slides, theme, filename, meta); 133 app.run(&mut terminal)?; 134 135 Ok(()) 136 })(); 137 138 slide_terminal.restore()?; 139 140 result 141} 142 143fn run_print(file: &PathBuf, width: usize, theme_arg: Option<String>) -> io::Result<()> { 144 tracing::info!("Printing slides from: {} (width: {})", file.display(), width); 145 146 let markdown = std::fs::read_to_string(file) 147 .map_err(|e| io::Error::new(e.kind(), format!("Failed to read file {}: {}", file.display(), e)))?; 148 149 let (meta, slides) = parse_slides_with_meta(&markdown) 150 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("Parse error: {}", e)))?; 151 152 if slides.is_empty() { 153 return Err(io::Error::new(io::ErrorKind::InvalidData, "No slides found in file")); 154 } 155 156 let theme_name = theme_arg.unwrap_or_else(|| meta.theme.clone()); 157 tracing::debug!("Using theme: {}", theme_name); 158 159 let theme = ThemeRegistry::get(&theme_name); 160 161 slides_core::printer::print_slides_to_stdout(&slides, &theme, width)?; 162 163 Ok(()) 164} 165 166#[cfg(test)] 167mod tests { 168 use super::*; 169 170 #[test] 171 fn cli_present_command() { 172 let cli = ArgParser::parse_from(["slides", "present", "test.md"]); 173 match cli.command { 174 Commands::Present { file, theme } => { 175 assert_eq!(file, PathBuf::from("test.md")); 176 assert_eq!(theme, None); 177 } 178 _ => panic!("Expected Present command"), 179 } 180 } 181 182 #[test] 183 fn cli_present_with_theme() { 184 let cli = ArgParser::parse_from(["slides", "present", "test.md", "--theme", "dark"]); 185 match cli.command { 186 Commands::Present { file, theme } => { 187 assert_eq!(file, PathBuf::from("test.md")); 188 assert_eq!(theme, Some("dark".to_string())); 189 } 190 _ => panic!("Expected Present command"), 191 } 192 } 193 194 #[test] 195 fn cli_print_command() { 196 let cli = ArgParser::parse_from(["slides", "print", "test.md", "-w", "100"]); 197 match cli.command { 198 Commands::Print { file, width, theme } => { 199 assert_eq!(file, PathBuf::from("test.md")); 200 assert_eq!(width, 100); 201 assert_eq!(theme, None); 202 } 203 _ => panic!("Expected Print command"), 204 } 205 } 206 207 #[test] 208 fn cli_init_command() { 209 let cli = ArgParser::parse_from(["slides", "init", "--name", "my-deck.md"]); 210 match cli.command { 211 Commands::Init { path, name } => { 212 assert_eq!(path, PathBuf::from(".")); 213 assert_eq!(name, "my-deck.md"); 214 } 215 _ => panic!("Expected Init command"), 216 } 217 } 218 219 #[test] 220 fn cli_check_command() { 221 let cli = ArgParser::parse_from(["slides", "check", "test.md", "--strict"]); 222 match cli.command { 223 Commands::Check { file, strict } => { 224 assert_eq!(file, PathBuf::from("test.md")); 225 assert!(strict); 226 } 227 _ => panic!("Expected Check command"), 228 } 229 } 230 231 #[test] 232 fn run_print_with_test_file() { 233 let temp_dir = std::env::temp_dir(); 234 let test_file = temp_dir.join("test_slides.md"); 235 236 let content = "# Test Slide\n\nThis is a test paragraph.\n\n---\n\n# Second Slide\n\n- Item 1\n- Item 2"; 237 std::fs::write(&test_file, content).expect("Failed to write test file"); 238 239 let result = run_print(&test_file, 80, None); 240 assert!(result.is_ok()); 241 242 std::fs::remove_file(&test_file).ok(); 243 } 244 245 #[test] 246 fn run_print_empty_file() { 247 let temp_dir = std::env::temp_dir(); 248 let test_file = temp_dir.join("empty_slides.md"); 249 250 std::fs::write(&test_file, "").expect("Failed to write test file"); 251 252 let result = run_print(&test_file, 80, None); 253 assert!(result.is_err()); 254 255 std::fs::remove_file(&test_file).ok(); 256 } 257 258 #[test] 259 fn run_print_nonexistent_file() { 260 let test_file = PathBuf::from("/nonexistent/file.md"); 261 let result = run_print(&test_file, 80, None); 262 assert!(result.is_err()); 263 } 264 265 #[test] 266 fn run_print_with_theme_from_frontmatter() { 267 let temp_dir = std::env::temp_dir(); 268 let test_file = temp_dir.join("test_themed_slides.md"); 269 270 let content = "---\ntheme: dark\n---\n# Test Slide\n\nThis is a test paragraph."; 271 std::fs::write(&test_file, content).expect("Failed to write test file"); 272 273 let result = run_print(&test_file, 80, None); 274 assert!(result.is_ok()); 275 276 std::fs::remove_file(&test_file).ok(); 277 } 278 279 #[test] 280 fn run_print_with_theme_override() { 281 let temp_dir = std::env::temp_dir(); 282 let test_file = temp_dir.join("test_override_slides.md"); 283 284 let content = "---\ntheme: light\n---\n# Test Slide\n\nThis is a test paragraph."; 285 std::fs::write(&test_file, content).expect("Failed to write test file"); 286 287 let result = run_print(&test_file, 80, Some("monokai".to_string())); 288 assert!(result.is_ok()); 289 290 std::fs::remove_file(&test_file).ok(); 291 } 292}