Skip to main content

st/formatters/
ai.rs

1use super::{hex::HexFormatter, Formatter, PathDisplayMode, StreamingFormatter};
2use crate::context::detect_project_context;
3use crate::scanner::{FileNode, TreeStats};
4use anyhow::Result;
5use sha2::{Digest, Sha256};
6use std::io::Write;
7use std::path::Path;
8
9pub struct AiFormatter {
10    hex_formatter: HexFormatter,
11}
12
13impl AiFormatter {
14    pub fn new(no_emoji: bool, _path_mode: PathDisplayMode) -> Self {
15        Self {
16            // AI format should always use PathDisplayMode::Off for maximum compactness
17            // unless explicitly requested otherwise
18            hex_formatter: HexFormatter::new(false, no_emoji, true, PathDisplayMode::Off, false),
19        }
20    }
21
22    /// Calculate a SHA256 hash of the tree structure for consistency verification
23    fn calculate_tree_hash(&self, nodes: &[FileNode]) -> String {
24        let mut hasher = Sha256::new();
25
26        // Hash each node's key properties in a deterministic way
27        for node in nodes {
28            // Hash: depth, name, type (dir/file), size, permissions
29            hasher.update(node.depth.to_le_bytes());
30            hasher.update(
31                node.path
32                    .file_name()
33                    .unwrap_or_default()
34                    .to_string_lossy()
35                    .as_bytes(),
36            );
37            hasher.update([if node.is_dir { 1 } else { 0 }]);
38            hasher.update(node.size.to_le_bytes());
39            hasher.update(node.permissions.to_le_bytes());
40        }
41
42        // Return first 16 chars of hex for brevity
43        let result = hasher.finalize();
44        hex::encode(&result[..8])
45    }
46}
47
48impl Formatter for AiFormatter {
49    fn format(
50        &self,
51        writer: &mut dyn Write,
52        nodes: &[FileNode],
53        stats: &TreeStats,
54        root_path: &Path,
55    ) -> Result<()> {
56        // First print the hex tree header
57        writeln!(writer, "TREE_HEX_V1:")?;
58
59        // Optionally add project context if detected
60        if let Some(context) = detect_project_context(root_path) {
61            writeln!(writer, "CONTEXT: {}", context)?;
62        }
63
64        // Calculate SHA256 hash of the tree structure
65        let tree_hash = self.calculate_tree_hash(nodes);
66        writeln!(writer, "HASH: {}", tree_hash)?;
67
68        // Use hex formatter for the tree
69        self.hex_formatter.format(writer, nodes, stats, root_path)?;
70
71        // Then print compact statistics - all in hex for consistency
72        writeln!(writer, "\nSTATS:")?;
73        writeln!(
74            writer,
75            "F:{:x} D:{:x} S:{:x} ({:.1}MB)",
76            stats.total_files,
77            stats.total_dirs,
78            stats.total_size,
79            stats.total_size as f64 / (1024.0 * 1024.0)
80        )?;
81
82        // File type summary (top 10) - counts in hex
83        if !stats.file_types.is_empty() {
84            let mut types: Vec<_> = stats.file_types.iter().collect();
85            types.sort_by(|a, b| b.1.cmp(a.1));
86
87            let types_str: Vec<String> = types
88                .iter()
89                .take(10)
90                .map(|(ext, count)| format!("{}:{:x}", ext, count))
91                .collect();
92
93            writeln!(writer, "TYPES: {}", types_str.join(" "))?;
94        }
95
96        // Largest files (top 5)
97        if !stats.largest_files.is_empty() {
98            let large_str: Vec<String> = stats
99                .largest_files
100                .iter()
101                .take(5)
102                .map(|(size, path)| {
103                    let name = path
104                        .file_name()
105                        .unwrap_or(path.as_os_str())
106                        .to_string_lossy();
107                    format!("{}:{:x}", name, size)
108                })
109                .collect();
110
111            writeln!(writer, "LARGE: {}", large_str.join(" "))?;
112        }
113
114        // Date range
115        if !stats.oldest_files.is_empty() && !stats.newest_files.is_empty() {
116            let oldest = stats.oldest_files[0]
117                .0
118                .duration_since(std::time::UNIX_EPOCH)
119                .unwrap_or_default()
120                .as_secs();
121            let newest = stats.newest_files[0]
122                .0
123                .duration_since(std::time::UNIX_EPOCH)
124                .unwrap_or_default()
125                .as_secs();
126
127            writeln!(writer, "DATES: {:x}-{:x}", oldest, newest)?;
128        }
129
130        writeln!(writer, "END_AI")?;
131
132        Ok(())
133    }
134}
135
136impl StreamingFormatter for AiFormatter {
137    fn start_stream(&self, writer: &mut dyn Write, root_path: &Path) -> Result<()> {
138        // Print header
139        writeln!(writer, "TREE_HEX_V1:")?;
140
141        // Optionally add project context if detected
142        if let Some(context) = detect_project_context(root_path) {
143            writeln!(writer, "CONTEXT: {}", context)?;
144        }
145
146        // Note: We can't calculate hash in streaming mode
147        writeln!(writer, "HASH: STREAMING")?;
148        writer.flush()?;
149        Ok(())
150    }
151
152    fn format_node(&self, writer: &mut dyn Write, node: &FileNode, root_path: &Path) -> Result<()> {
153        self.hex_formatter.format_node(writer, node, root_path)
154    }
155
156    fn end_stream(
157        &self,
158        writer: &mut dyn Write,
159        stats: &TreeStats,
160        _root_path: &Path,
161    ) -> Result<()> {
162        // Print statistics at the end
163        writeln!(writer, "\nSTATS:")?;
164        writeln!(
165            writer,
166            "F:{:x} D:{:x} S:{:x} ({:.1}MB)",
167            stats.total_files,
168            stats.total_dirs,
169            stats.total_size,
170            stats.total_size as f64 / (1024.0 * 1024.0)
171        )?;
172
173        // File type summary (top 10) - counts in hex
174        if !stats.file_types.is_empty() {
175            let mut types: Vec<_> = stats.file_types.iter().collect();
176            types.sort_by(|a, b| b.1.cmp(a.1));
177
178            let types_str: Vec<String> = types
179                .iter()
180                .take(10)
181                .map(|(ext, count)| format!("{}:{:x}", ext, count))
182                .collect();
183
184            writeln!(writer, "TYPES: {}", types_str.join(" "))?;
185        }
186
187        // Largest files (top 5)
188        if !stats.largest_files.is_empty() {
189            let large_str: Vec<String> = stats
190                .largest_files
191                .iter()
192                .take(5)
193                .map(|(size, path)| {
194                    let name = path
195                        .file_name()
196                        .unwrap_or(path.as_os_str())
197                        .to_string_lossy();
198                    format!("{}:{:x}", name, size)
199                })
200                .collect();
201
202            writeln!(writer, "LARGE: {}", large_str.join(" "))?;
203        }
204
205        // Date range
206        if !stats.oldest_files.is_empty() && !stats.newest_files.is_empty() {
207            let oldest = stats.oldest_files[0]
208                .0
209                .duration_since(std::time::UNIX_EPOCH)
210                .unwrap_or_default()
211                .as_secs();
212            let newest = stats.newest_files[0]
213                .0
214                .duration_since(std::time::UNIX_EPOCH)
215                .unwrap_or_default()
216                .as_secs();
217
218            writeln!(writer, "DATES: {:x}-{:x}", oldest, newest)?;
219        }
220
221        writeln!(writer, "END_AI")?;
222        writer.flush()?;
223
224        Ok(())
225    }
226}