1use anyhow::{Context as _, Result};
2use clap::Parser;
3use serde_json::{Value, json};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7#[derive(Parser, Debug)]
8#[clap(about = "Generate HTML explorer from JSON thread files")]
9struct Args {
10 /// Paths to JSON files or directories. If a directory is provided,
11 /// it will be searched for 'last.messages.json' files up to 2 levels deep.
12 #[clap(long, required = true, num_args = 1..)]
13 input: Vec<PathBuf>,
14
15 /// Path where the output HTML file will be written
16 #[clap(long)]
17 output: PathBuf,
18}
19
20/// Recursively finds files with `target_filename` in `dir_path` up to `max_depth`.
21#[allow(dead_code)]
22fn find_target_files_recursive(
23 dir_path: &Path,
24 target_filename: &str,
25 current_depth: u8,
26 max_depth: u8,
27 found_files: &mut Vec<PathBuf>,
28) -> Result<()> {
29 if current_depth > max_depth {
30 return Ok(());
31 }
32
33 for entry_result in fs::read_dir(dir_path)
34 .with_context(|| format!("Failed to read directory: {}", dir_path.display()))?
35 {
36 let entry = entry_result.with_context(|| {
37 format!("Failed to read directory entry in: {}", dir_path.display())
38 })?;
39 let path = entry.path();
40
41 if path.is_dir() {
42 find_target_files_recursive(
43 &path,
44 target_filename,
45 current_depth + 1,
46 max_depth,
47 found_files,
48 )?;
49 } else if path.is_file()
50 && let Some(filename_osstr) = path.file_name()
51 && let Some(filename_str) = filename_osstr.to_str()
52 && filename_str == target_filename
53 {
54 found_files.push(path);
55 }
56 }
57 Ok(())
58}
59
60pub fn generate_explorer_html(input_paths: &[PathBuf], output_path: &PathBuf) -> Result<String> {
61 if let Some(parent) = output_path.parent()
62 && !parent.exists()
63 {
64 fs::create_dir_all(parent).context(format!(
65 "Failed to create output directory: {}",
66 parent.display()
67 ))?;
68 }
69
70 let template_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/explorer.html");
71 let template_content = fs::read_to_string(&template_path).context(format!(
72 "Template file not found or couldn't be read: {}",
73 template_path.display()
74 ))?;
75
76 if input_paths.is_empty() {
77 println!(
78 "No input JSON files found to process. Explorer will be generated with template defaults or empty data."
79 );
80 }
81
82 let threads = input_paths
83 .iter()
84 .map(|input_path| {
85 let file_content = fs::read_to_string(input_path)
86 .context(format!("Failed to read file: {}", input_path.display()))?;
87 let mut thread_data: Value = file_content
88 .parse::<Value>()
89 .context(format!("Failed to parse JSON from file: {}", input_path.display()))?;
90
91 if let Some(obj) = thread_data.as_object_mut() {
92 obj.insert("filename".to_string(), json!(input_path.display().to_string()));
93 } else {
94 eprintln!("Warning: JSON data in {} is not a root object. Wrapping it to include filename.", input_path.display());
95 thread_data = json!({
96 "original_data": thread_data,
97 "filename": input_path.display().to_string()
98 });
99 }
100 Ok(thread_data)
101 })
102 .collect::<Result<Vec<_>>>()?;
103
104 let all_threads_data = json!({ "threads": threads });
105 let html_content = inject_thread_data(template_content, all_threads_data)?;
106 fs::write(&output_path, &html_content)
107 .context(format!("Failed to write output: {}", output_path.display()))?;
108
109 println!(
110 "Saved data from {} resolved file(s) ({} threads) to {}",
111 input_paths.len(),
112 threads.len(),
113 output_path.display()
114 );
115 Ok(html_content)
116}
117
118fn inject_thread_data(template: String, threads_data: Value) -> Result<String> {
119 let injection_marker = "let threadsData = window.threadsData || { threads: [dummyThread] };";
120 if !template.contains(injection_marker) {
121 anyhow::bail!(
122 "Could not find the thread injection point in the template. Expected: '{}'",
123 injection_marker
124 );
125 }
126
127 let threads_json_string = serde_json::to_string_pretty(&threads_data)
128 .context("Failed to serialize threads data to JSON")?
129 .replace("</script>", r"<\/script>");
130
131 let script_injection = format!("let threadsData = {};", threads_json_string);
132 let final_html = template.replacen(injection_marker, &script_injection, 1);
133
134 Ok(final_html)
135}
136
137#[cfg(not(any(test, doctest)))]
138#[allow(dead_code)]
139fn main() -> Result<()> {
140 let args = Args::parse();
141
142 const DEFAULT_FILENAME: &str = "last.messages.json";
143 const MAX_SEARCH_DEPTH: u8 = 2;
144
145 let mut resolved_input_files: Vec<PathBuf> = Vec::new();
146
147 for input_path_arg in &args.input {
148 if !input_path_arg.exists() {
149 eprintln!(
150 "Warning: Input path {} does not exist. Skipping.",
151 input_path_arg.display()
152 );
153 continue;
154 }
155
156 if input_path_arg.is_dir() {
157 find_target_files_recursive(
158 input_path_arg,
159 DEFAULT_FILENAME,
160 0, // starting depth
161 MAX_SEARCH_DEPTH,
162 &mut resolved_input_files,
163 )
164 .with_context(|| {
165 format!(
166 "Error searching for '{}' files in directory: {}",
167 DEFAULT_FILENAME,
168 input_path_arg.display()
169 )
170 })?;
171 } else if input_path_arg.is_file() {
172 resolved_input_files.push(input_path_arg.clone());
173 }
174 }
175
176 resolved_input_files.sort_unstable();
177 resolved_input_files.dedup();
178
179 println!("No input paths provided/found.");
180
181 generate_explorer_html(&resolved_input_files, &args.output).map(|_| ())
182}