magical markdown slides
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}