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