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::ThemeColors};
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 = ThemeColors::default();
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 // TODO: Load theme from theme registry based on theme_name
160 let theme = ThemeColors::default();
161
162 slides_core::printer::print_slides_to_stdout(&slides, &theme, width)?;
163
164 Ok(())
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn cli_present_command() {
173 let cli = ArgParser::parse_from(["slides", "present", "test.md"]);
174 match cli.command {
175 Commands::Present { file, theme } => {
176 assert_eq!(file, PathBuf::from("test.md"));
177 assert_eq!(theme, None);
178 }
179 _ => panic!("Expected Present command"),
180 }
181 }
182
183 #[test]
184 fn cli_present_with_theme() {
185 let cli = ArgParser::parse_from(["slides", "present", "test.md", "--theme", "dark"]);
186 match cli.command {
187 Commands::Present { file, theme } => {
188 assert_eq!(file, PathBuf::from("test.md"));
189 assert_eq!(theme, Some("dark".to_string()));
190 }
191 _ => panic!("Expected Present command"),
192 }
193 }
194
195 #[test]
196 fn cli_print_command() {
197 let cli = ArgParser::parse_from(["slides", "print", "test.md", "-w", "100"]);
198 match cli.command {
199 Commands::Print { file, width, theme } => {
200 assert_eq!(file, PathBuf::from("test.md"));
201 assert_eq!(width, 100);
202 assert_eq!(theme, None);
203 }
204 _ => panic!("Expected Print command"),
205 }
206 }
207
208 #[test]
209 fn cli_init_command() {
210 let cli = ArgParser::parse_from(["slides", "init", "--name", "my-deck.md"]);
211 match cli.command {
212 Commands::Init { path, name } => {
213 assert_eq!(path, PathBuf::from("."));
214 assert_eq!(name, "my-deck.md");
215 }
216 _ => panic!("Expected Init command"),
217 }
218 }
219
220 #[test]
221 fn cli_check_command() {
222 let cli = ArgParser::parse_from(["slides", "check", "test.md", "--strict"]);
223 match cli.command {
224 Commands::Check { file, strict } => {
225 assert_eq!(file, PathBuf::from("test.md"));
226 assert!(strict);
227 }
228 _ => panic!("Expected Check command"),
229 }
230 }
231
232 #[test]
233 fn run_print_with_test_file() {
234 let temp_dir = std::env::temp_dir();
235 let test_file = temp_dir.join("test_slides.md");
236
237 let content = "# Test Slide\n\nThis is a test paragraph.\n\n---\n\n# Second Slide\n\n- Item 1\n- Item 2";
238 std::fs::write(&test_file, content).expect("Failed to write test file");
239
240 let result = run_print(&test_file, 80, None);
241 assert!(result.is_ok());
242
243 std::fs::remove_file(&test_file).ok();
244 }
245
246 #[test]
247 fn run_print_empty_file() {
248 let temp_dir = std::env::temp_dir();
249 let test_file = temp_dir.join("empty_slides.md");
250
251 std::fs::write(&test_file, "").expect("Failed to write test file");
252
253 let result = run_print(&test_file, 80, None);
254 assert!(result.is_err());
255
256 std::fs::remove_file(&test_file).ok();
257 }
258
259 #[test]
260 fn run_print_nonexistent_file() {
261 let test_file = PathBuf::from("/nonexistent/file.md");
262 let result = run_print(&test_file, 80, None);
263 assert!(result.is_err());
264 }
265}