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 if let Some(filename_osstr) = path.file_name() {
51 if let Some(filename_str) = filename_osstr.to_str() {
52 if filename_str == target_filename {
53 found_files.push(path);
54 }
55 }
56 }
57 }
58 }
59 Ok(())
60}
61
62pub fn generate_explorer_html(input_paths: &[PathBuf], output_path: &PathBuf) -> Result<String> {
63 if let Some(parent) = output_path.parent() {
64 if !parent.exists() {
65 fs::create_dir_all(parent).context(format!(
66 "Failed to create output directory: {}",
67 parent.display()
68 ))?;
69 }
70 }
71
72 let template_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/explorer.html");
73 let template_content = fs::read_to_string(&template_path).context(format!(
74 "Template file not found or couldn't be read: {}",
75 template_path.display()
76 ))?;
77
78 if input_paths.is_empty() {
79 println!(
80 "No input JSON files found to process. Explorer will be generated with template defaults or empty data."
81 );
82 }
83
84 let threads = input_paths
85 .iter()
86 .map(|input_path| {
87 let file_content = fs::read_to_string(input_path)
88 .context(format!("Failed to read file: {}", input_path.display()))?;
89 let mut thread_data: Value = file_content
90 .parse::<Value>()
91 .context(format!("Failed to parse JSON from file: {}", input_path.display()))?;
92
93 if let Some(obj) = thread_data.as_object_mut() {
94 obj.insert("filename".to_string(), json!(input_path.display().to_string()));
95 } else {
96 eprintln!("Warning: JSON data in {} is not a root object. Wrapping it to include filename.", input_path.display());
97 thread_data = json!({
98 "original_data": thread_data,
99 "filename": input_path.display().to_string()
100 });
101 }
102 Ok(thread_data)
103 })
104 .collect::<Result<Vec<_>>>()?;
105
106 let all_threads_data = json!({ "threads": threads });
107 let html_content = inject_thread_data(template_content, all_threads_data)?;
108 fs::write(&output_path, &html_content)
109 .context(format!("Failed to write output: {}", output_path.display()))?;
110
111 println!(
112 "Saved data from {} resolved file(s) ({} threads) to {}",
113 input_paths.len(),
114 threads.len(),
115 output_path.display()
116 );
117 Ok(html_content)
118}
119
120fn inject_thread_data(template: String, threads_data: Value) -> Result<String> {
121 let injection_marker = "let threadsData = window.threadsData || { threads: [dummyThread] };";
122 if !template.contains(injection_marker) {
123 anyhow::bail!(
124 "Could not find the thread injection point in the template. Expected: '{}'",
125 injection_marker
126 );
127 }
128
129 let threads_json_string = serde_json::to_string_pretty(&threads_data)
130 .context("Failed to serialize threads data to JSON")?
131 .replace("</script>", r"<\/script>");
132
133 let script_injection = format!("let threadsData = {};", threads_json_string);
134 let final_html = template.replacen(injection_marker, &script_injection, 1);
135
136 Ok(final_html)
137}
138
139#[cfg(not(any(test, doctest)))]
140#[allow(dead_code)]
141fn main() -> Result<()> {
142 let args = Args::parse();
143
144 const DEFAULT_FILENAME: &str = "last.messages.json";
145 const MAX_SEARCH_DEPTH: u8 = 2;
146
147 let mut resolved_input_files: Vec<PathBuf> = Vec::new();
148
149 for input_path_arg in &args.input {
150 if !input_path_arg.exists() {
151 eprintln!(
152 "Warning: Input path {} does not exist. Skipping.",
153 input_path_arg.display()
154 );
155 continue;
156 }
157
158 if input_path_arg.is_dir() {
159 find_target_files_recursive(
160 input_path_arg,
161 DEFAULT_FILENAME,
162 0, // starting depth
163 MAX_SEARCH_DEPTH,
164 &mut resolved_input_files,
165 )
166 .with_context(|| {
167 format!(
168 "Error searching for '{}' files in directory: {}",
169 DEFAULT_FILENAME,
170 input_path_arg.display()
171 )
172 })?;
173 } else if input_path_arg.is_file() {
174 resolved_input_files.push(input_path_arg.clone());
175 }
176 }
177
178 resolved_input_files.sort_unstable();
179 resolved_input_files.dedup();
180
181 println!("No input paths provided/found.");
182
183 generate_explorer_html(&resolved_input_files, &args.output).map(|_| ())
184}