Skip to main content

ppt_rs/cli/
commands.rs

1//! CLI commands implementation
2
3use crate::generator;
4use std::fs;
5use std::path::PathBuf;
6
7pub struct CreateCommand;
8pub struct FromMarkdownCommand;
9pub struct FromHtmlCommand;
10pub struct InfoCommand;
11pub struct ValidateCommand;
12
13impl CreateCommand {
14    pub fn execute(
15        output: &str,
16        title: Option<&str>,
17        slides: usize,
18        _template: Option<&str>,
19    ) -> Result<(), String> {
20        // Create output directory if needed
21        if let Some(parent) = PathBuf::from(output).parent() {
22            if !parent.as_os_str().is_empty() {
23                fs::create_dir_all(parent)
24                    .map_err(|e| format!("Failed to create directory: {e}"))?;
25            }
26        }
27
28        let title = title.unwrap_or("Presentation");
29
30        // Generate proper PPTX file
31        let pptx_data = generator::create_pptx(title, slides)
32            .map_err(|e| format!("Failed to generate PPTX: {e}"))?;
33
34        // Write to file
35        fs::write(output, pptx_data).map_err(|e| format!("Failed to write file: {e}"))?;
36
37        Ok(())
38    }
39}
40
41impl FromMarkdownCommand {
42    pub fn execute(input: &str, output: &str, title: Option<&str>) -> Result<(), String> {
43        // Read markdown file
44        let md_content =
45            fs::read_to_string(input).map_err(|e| format!("Failed to read markdown file: {e}"))?;
46
47        // Parse markdown into slides using enhanced parser
48        let slides = super::markdown::parse_markdown(&md_content)?;
49
50        if slides.is_empty() {
51            return Err("No slides found in markdown file".to_string());
52        }
53
54        // Create output directory if needed
55        if let Some(parent) = PathBuf::from(output).parent() {
56            if !parent.as_os_str().is_empty() {
57                fs::create_dir_all(parent)
58                    .map_err(|e| format!("Failed to create directory: {e}"))?;
59            }
60        }
61
62        let title = title.unwrap_or("Presentation from Markdown");
63
64        // Generate PPTX with content
65        let pptx_data = generator::create_pptx_with_content(title, slides)
66            .map_err(|e| format!("Failed to generate PPTX: {e}"))?;
67
68        // Write to file
69        fs::write(output, pptx_data).map_err(|e| format!("Failed to write file: {e}"))?;
70
71        Ok(())
72    }
73}
74
75impl FromHtmlCommand {
76    pub fn execute(
77        input: &str,
78        output: &str,
79        title: Option<&str>,
80        max_slides: usize,
81        max_bullets: usize,
82        no_images: bool,
83        no_tables: bool,
84        no_code: bool,
85    ) -> Result<(), String> {
86        // Read HTML file
87        let html_content =
88            std::fs::read_to_string(input).map_err(|e| format!("Failed to read HTML file: {e}"))?;
89
90        // Build options
91        let options = crate::import::HtmlParseOptions::new()
92            .max_slides(max_slides)
93            .max_bullets(max_bullets)
94            .include_images(!no_images)
95            .include_tables(!no_tables)
96            .include_code(!no_code);
97
98        // Parse HTML into slides
99        let slides = crate::import::parse_html_with_options(&html_content, options)?;
100
101        if slides.is_empty() {
102            return Err("No slides found in HTML file".to_string());
103        }
104
105        // Create output directory if needed
106        if let Some(parent) = PathBuf::from(output).parent() {
107            if !parent.as_os_str().is_empty() {
108                fs::create_dir_all(parent)
109                    .map_err(|e| format!("Failed to create directory: {e}"))?;
110            }
111        }
112
113        let title = title.unwrap_or("Presentation from HTML");
114
115        // Generate PPTX with content
116        let pptx_data = generator::create_pptx_with_content(title, slides)
117            .map_err(|e| format!("Failed to generate PPTX: {e}"))?;
118
119        // Write to file
120        fs::write(output, pptx_data).map_err(|e| format!("Failed to write file: {e}"))?;
121
122        Ok(())
123    }
124}
125
126impl InfoCommand {
127    pub fn execute(file: &str) -> Result<(), String> {
128        let metadata = fs::metadata(file).map_err(|e| format!("File not found: {e}"))?;
129
130        let size = metadata.len();
131        let modified = metadata
132            .modified()
133            .ok()
134            .and_then(|t| t.elapsed().ok())
135            .map(|d| format!("{d:?} ago"))
136            .unwrap_or_else(|| "unknown".to_string());
137
138        println!("File Information");
139        println!("================");
140        println!("Path:     {file}");
141        println!("Size:     {size} bytes");
142        println!("Modified: {modified}");
143        let is_file = metadata.is_file();
144        println!("Is file:  {is_file}");
145
146        // Try to read and parse as XML
147        if let Ok(content) = fs::read_to_string(file) {
148            if content.starts_with("<?xml") {
149                println!("\nPresentation Information");
150                println!("========================");
151                if let Some(title_start) = content.find("<title>") {
152                    if let Some(title_end) = content[title_start + 7..].find("</title>") {
153                        let title = &content[title_start + 7..title_start + 7 + title_end];
154                        println!("Title: {title}");
155                    }
156                }
157                if let Some(slides_start) = content.find("count=\"") {
158                    let search_from = slides_start + 7;
159                    if let Some(slides_end) = content[search_from..].find("\"") {
160                        let count_str = &content[search_from..search_from + slides_end];
161                        println!("Slides: {count_str}");
162                    }
163                }
164            }
165        }
166
167        Ok(())
168    }
169}
170
171impl ValidateCommand {
172    /// Validate a PPTX file for ECMA-376 compliance
173    pub fn execute(file: &str) -> Result<(), String> {
174        use std::io::Read;
175        use zip::ZipArchive;
176
177        println!("Validating PPTX file: {file}");
178        println!("{}", "=".repeat(60));
179
180        // Check file exists
181        let metadata = fs::metadata(file).map_err(|e| format!("File not found: {e}"))?;
182
183        if !metadata.is_file() {
184            return Err(format!("Path is not a file: {file}"));
185        }
186
187        // Try to open as ZIP archive
188        let file_handle = fs::File::open(file).map_err(|e| format!("Failed to open file: {e}"))?;
189
190        let mut archive =
191            ZipArchive::new(file_handle).map_err(|e| format!("Invalid ZIP archive: {e}"))?;
192
193        println!("✓ File is a valid ZIP archive");
194        println!("  Total entries: {}", archive.len());
195
196        // Check required files
197        let mut issues = Vec::new();
198        let mut found_files = std::collections::HashSet::new();
199
200        // Collect all file names
201        for i in 0..archive.len() {
202            let file = archive
203                .by_index(i)
204                .map_err(|e| format!("Failed to read archive entry: {e}"))?;
205            found_files.insert(file.name().to_string());
206        }
207
208        // Required files for PPTX
209        let required_files = vec![
210            "[Content_Types].xml",
211            "_rels/.rels",
212            "ppt/presentation.xml",
213            "docProps/core.xml",
214        ];
215
216        println!("\nChecking required files...");
217        for required in &required_files {
218            if found_files.contains(*required) {
219                println!("  ✓ {}", required);
220            } else {
221                println!("  ✗ {} (missing)", required);
222                issues.push(format!("Missing required file: {}", required));
223            }
224        }
225
226        // Check XML validity
227        println!("\nChecking XML validity...");
228        for i in 0..archive.len() {
229            let mut file = archive
230                .by_index(i)
231                .map_err(|e| format!("Failed to read archive entry: {e}"))?;
232
233            let name = file.name().to_string();
234            if name.ends_with(".xml") || name.ends_with(".rels") {
235                let mut content = String::new();
236                file.read_to_string(&mut content)
237                    .map_err(|e| format!("Failed to read XML file {}: {e}", name))?;
238
239                // Basic XML validation (check for well-formedness)
240                if content.trim().is_empty() {
241                    issues.push(format!("Empty XML file: {}", name));
242                    println!("  ⚠ {} (empty)", name);
243                } else if !content.contains("<?xml") && !name.ends_with(".rels") {
244                    // .rels files don't always have XML declaration
245                    if !name.ends_with(".rels") {
246                        issues.push(format!("XML file missing declaration: {}", name));
247                        println!("  ⚠ {} (missing XML declaration)", name);
248                    }
249                } else {
250                    // Check for basic XML structure
251                    if content.contains("<") && content.contains(">") {
252                        println!("  ✓ {} (valid XML)", name);
253                    } else {
254                        issues.push(format!("Invalid XML structure: {}", name));
255                        println!("  ✗ {} (invalid XML)", name);
256                    }
257                }
258            }
259        }
260
261        // Check relationships
262        println!("\nChecking relationships...");
263        if found_files.contains("_rels/.rels") {
264            println!("  ✓ Package relationships found");
265        } else {
266            issues.push("Missing package relationships".to_string());
267            println!("  ✗ Package relationships missing");
268        }
269
270        // Summary
271        println!("\n{}", "=".repeat(60));
272        if issues.is_empty() {
273            println!("✓ Validation PASSED");
274            println!("  File appears to be a valid PPTX file");
275            println!("  ECMA-376 compliance: OK");
276        } else {
277            println!("✗ Validation FAILED");
278            println!("  Found {} issue(s):", issues.len());
279            for issue in &issues {
280                println!("    - {}", issue);
281            }
282            return Err(format!("Validation failed with {} issue(s)", issues.len()));
283        }
284
285        Ok(())
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use std::fs;
293    use std::path::Path;
294
295    #[test]
296    fn test_create_command() {
297        let output = "/tmp/test_presentation.pptx";
298        let result = CreateCommand::execute(output, Some("Test"), 3, None);
299        assert!(result.is_ok());
300        assert!(Path::new(output).exists());
301
302        // Cleanup
303        let _ = fs::remove_file(output);
304    }
305
306    #[test]
307    fn test_escape_xml() {
308        use crate::core::escape_xml;
309        assert_eq!(escape_xml("a & b"), "a &amp; b");
310        assert_eq!(escape_xml("<tag>"), "&lt;tag&gt;");
311        assert_eq!(escape_xml("\"quoted\""), "&quot;quoted&quot;");
312    }
313}