1use crate::formatters::{
10 ai::AiFormatter,
11 classic::ClassicFormatter,
12 csv::CsvFormatter,
13 digest::DigestFormatter,
14 hex::HexFormatter,
15 json::JsonFormatter,
16 ls::LsFormatter,
17 markdown::MarkdownFormatter,
18 marqant::MarqantFormatter,
19 mermaid::{MermaidFormatter, MermaidStyle},
20 projects::ProjectsFormatter,
21 quantum::QuantumFormatter,
22 semantic::SemanticFormatter,
23 smart::SmartFormatter,
24 stats::StatsFormatter,
25 tsv::TsvFormatter,
26 waste::WasteFormatter,
27 Formatter, PathDisplayMode,
28};
29use crate::{parse_size, Scanner, ScannerConfig, TreeStats};
30use anyhow::{Context, Result};
31use axum::{
32 extract::State,
33 http::StatusCode,
34 response::IntoResponse,
35 Json,
36};
37use base64::{engine::general_purpose, Engine as _};
38use flate2::write::ZlibEncoder;
39use flate2::Compression;
40use regex::Regex;
41use serde::{Deserialize, Serialize};
42use std::io::Write;
43use std::path::PathBuf;
44use std::sync::Arc;
45use std::time::Instant;
46use tokio::sync::RwLock;
47
48use crate::daemon::DaemonState;
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct CliScanRequest {
53 pub path: String,
55
56 #[serde(default = "default_mode")]
58 pub mode: String,
59
60 #[serde(default)]
62 pub depth: usize,
63
64 #[serde(default)]
66 pub all: bool,
67
68 #[serde(default = "default_true")]
70 pub respect_gitignore: bool,
71
72 #[serde(default = "default_true")]
74 pub default_ignores: bool,
75
76 #[serde(default)]
78 pub show_ignored: bool,
79
80 pub find: Option<String>,
82
83 pub file_type: Option<String>,
85
86 pub entry_type: Option<String>,
88
89 pub min_size: Option<String>,
91
92 pub max_size: Option<String>,
94
95 pub sort: Option<String>,
97
98 pub top: Option<usize>,
100
101 pub search: Option<String>,
103
104 #[serde(default)]
106 pub compress: bool,
107
108 #[serde(default)]
110 pub no_emoji: bool,
111
112 #[serde(default = "default_true")]
114 pub use_color: bool,
115
116 #[serde(default = "default_path_mode")]
118 pub path_mode: String,
119
120 pub focus: Option<String>,
122
123 pub relations_filter: Option<String>,
125
126 #[serde(default)]
128 pub show_filesystems: bool,
129
130 #[serde(default)]
132 pub include_line_content: bool,
133
134 #[serde(default)]
136 pub compact: bool,
137
138 #[serde(default)]
142 pub smart: bool,
143
144 #[serde(default)]
146 pub changes_only: bool,
147
148 #[serde(default)]
150 pub min_interest: f32,
151
152 #[serde(default = "default_true")]
154 pub security: bool,
155}
156
157fn default_mode() -> String {
158 "classic".to_string()
159}
160
161fn default_true() -> bool {
162 true
163}
164
165fn default_path_mode() -> String {
166 "relative".to_string()
167}
168
169#[derive(Debug, Serialize, Deserialize)]
171pub struct CliScanResponse {
172 pub output: String,
174
175 pub compressed: bool,
177
178 pub stats: ScanStats,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct ScanStats {
185 pub total_files: u64,
186 pub total_dirs: u64,
187 pub total_size: u64,
188 pub scan_time_ms: u64,
189 pub format_time_ms: u64,
190}
191
192#[derive(Debug, Serialize)]
194pub struct CliErrorResponse {
195 pub error: String,
196 pub details: Option<String>,
197}
198
199pub async fn cli_scan_handler(
201 State(state): State<Arc<RwLock<DaemonState>>>,
202 Json(req): Json<CliScanRequest>,
203) -> Result<impl IntoResponse, (StatusCode, Json<CliErrorResponse>)> {
204 let config = build_scanner_config(&req).map_err(|e| {
206 (
207 StatusCode::BAD_REQUEST,
208 Json(CliErrorResponse {
209 error: "Invalid request".to_string(),
210 details: Some(e.to_string()),
211 }),
212 )
213 })?;
214
215 let path = PathBuf::from(&req.path);
217 let path = if path.is_absolute() {
218 path
219 } else {
220 std::env::current_dir()
221 .unwrap_or_else(|_| PathBuf::from("."))
222 .join(&path)
223 };
224
225 let scanner = Scanner::new(&path, config).map_err(|e| {
227 (
228 StatusCode::BAD_REQUEST,
229 Json(CliErrorResponse {
230 error: "Failed to create scanner".to_string(),
231 details: Some(e.to_string()),
232 }),
233 )
234 })?;
235
236 let scan_start = Instant::now();
237 let (nodes, tree_stats) = scanner.scan().map_err(|e| {
238 (
239 StatusCode::INTERNAL_SERVER_ERROR,
240 Json(CliErrorResponse {
241 error: "Scan failed".to_string(),
242 details: Some(e.to_string()),
243 }),
244 )
245 })?;
246 let scan_time = scan_start.elapsed();
247
248 let format_start = Instant::now();
250 let path_display = parse_path_mode(&req.path_mode);
251
252 let mut output_buffer = Vec::new();
253 format_output(&req, &mut output_buffer, &nodes, &tree_stats, &path, path_display).map_err(
254 |e| {
255 (
256 StatusCode::INTERNAL_SERVER_ERROR,
257 Json(CliErrorResponse {
258 error: "Format failed".to_string(),
259 details: Some(e.to_string()),
260 }),
261 )
262 },
263 )?;
264 let format_time = format_start.elapsed();
265
266 let (output, compressed) = if req.compress {
268 let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
269 encoder.write_all(&output_buffer).map_err(|e| {
270 (
271 StatusCode::INTERNAL_SERVER_ERROR,
272 Json(CliErrorResponse {
273 error: "Compression failed".to_string(),
274 details: Some(e.to_string()),
275 }),
276 )
277 })?;
278 let compressed_data = encoder.finish().map_err(|e| {
279 (
280 StatusCode::INTERNAL_SERVER_ERROR,
281 Json(CliErrorResponse {
282 error: "Compression failed".to_string(),
283 details: Some(e.to_string()),
284 }),
285 )
286 })?;
287 (general_purpose::STANDARD.encode(&compressed_data), true)
288 } else {
289 (
290 String::from_utf8_lossy(&output_buffer).to_string(),
291 false,
292 )
293 };
294
295 let stats = ScanStats {
297 total_files: tree_stats.total_files,
298 total_dirs: tree_stats.total_dirs,
299 total_size: tree_stats.total_size,
300 scan_time_ms: scan_time.as_millis() as u64,
301 format_time_ms: format_time.as_millis() as u64,
302 };
303
304 if compressed {
306 let savings = output_buffer.len().saturating_sub(output.len()) as u64;
307 if let Ok(mut state) = state.try_write() {
308 state
309 .credits
310 .record_savings(savings, &format!("CLI scan: {}", req.path));
311 }
312 }
313
314 Ok(Json(CliScanResponse {
315 output,
316 compressed,
317 stats,
318 }))
319}
320
321pub async fn cli_stream_handler(
325 State(state): State<Arc<RwLock<DaemonState>>>,
326 Json(req): Json<CliScanRequest>,
327) -> Result<impl IntoResponse, (StatusCode, Json<CliErrorResponse>)> {
328 cli_scan_handler(State(state), Json(req)).await
331}
332
333fn build_scanner_config(req: &CliScanRequest) -> Result<ScannerConfig> {
335 let find_pattern = if let Some(ref pattern) = req.find {
336 Some(Regex::new(pattern).context("Invalid find pattern regex")?)
337 } else {
338 None
339 };
340
341 let min_size = if let Some(ref s) = req.min_size {
342 Some(parse_size(s).context("Invalid min_size")?)
343 } else {
344 None
345 };
346
347 let max_size = if let Some(ref s) = req.max_size {
348 Some(parse_size(s).context("Invalid max_size")?)
349 } else {
350 None
351 };
352
353 let max_depth = if req.depth == 0 {
355 get_ideal_depth_for_mode(&req.mode)
356 } else {
357 req.depth
358 };
359
360 Ok(ScannerConfig {
361 max_depth,
362 follow_symlinks: false,
363 respect_gitignore: req.respect_gitignore,
364 show_hidden: req.all,
365 show_ignored: req.show_ignored,
366 find_pattern,
367 file_type_filter: req.file_type.clone(),
368 entry_type_filter: req.entry_type.clone(),
369 min_size,
370 max_size,
371 newer_than: None, older_than: None,
373 use_default_ignores: req.default_ignores,
374 search_keyword: req.search.clone(),
375 show_filesystems: req.show_filesystems,
376 sort_field: req.sort.clone(),
377 top_n: req.top,
378 include_line_content: req.include_line_content,
379 compute_interest: req.smart,
381 security_scan: req.security,
382 min_interest: req.min_interest,
383 track_traversal: req.smart,
384 changes_only: req.changes_only,
385 compare_state: None,
386 smart_mode: req.smart,
387 })
388}
389
390fn get_ideal_depth_for_mode(mode: &str) -> usize {
392 match mode.to_lowercase().as_str() {
393 "quantum" | "quantum_semantic" => 10,
394 "ai" | "semantic" | "smart" => 5,
395 "digest" | "stats" => 20,
396 "relations" => 3,
397 "projects" => 5,
398 _ => 3, }
400}
401
402fn parse_path_mode(mode: &str) -> PathDisplayMode {
404 match mode.to_lowercase().as_str() {
405 "off" | "none" => PathDisplayMode::Off,
406 "full" | "absolute" => PathDisplayMode::Full,
407 _ => PathDisplayMode::Relative,
408 }
409}
410
411fn format_output(
413 req: &CliScanRequest,
414 writer: &mut dyn Write,
415 nodes: &[crate::FileNode],
416 stats: &TreeStats,
417 root_path: &std::path::Path,
418 path_display: PathDisplayMode,
419) -> Result<()> {
420 let mode = req.mode.to_lowercase();
421 let no_emoji = req.no_emoji;
422 let use_color = req.use_color;
423
424 match mode.as_str() {
425 "classic" => {
426 let formatter = ClassicFormatter::new(no_emoji, use_color, path_display);
427 formatter.format(writer, nodes, stats, root_path)?;
428 }
429 "hex" => {
430 let formatter = HexFormatter::new(
431 use_color,
432 no_emoji,
433 req.show_ignored,
434 path_display,
435 req.show_filesystems,
436 );
437 formatter.format(writer, nodes, stats, root_path)?;
438 }
439 "json" => {
440 let formatter = JsonFormatter::new(req.compact);
441 formatter.format(writer, nodes, stats, root_path)?;
442 }
443 "ls" => {
444 let formatter = LsFormatter::new(!no_emoji, use_color);
445 formatter.format(writer, nodes, stats, root_path)?;
446 }
447 "ai" => {
448 let formatter = AiFormatter::new(no_emoji, path_display);
449 formatter.format(writer, nodes, stats, root_path)?;
450 }
451 "stats" => {
452 let formatter = StatsFormatter::new();
453 formatter.format(writer, nodes, stats, root_path)?;
454 }
455 "csv" => {
456 let formatter = CsvFormatter::new();
457 formatter.format(writer, nodes, stats, root_path)?;
458 }
459 "tsv" => {
460 let formatter = TsvFormatter::new();
461 formatter.format(writer, nodes, stats, root_path)?;
462 }
463 "digest" => {
464 let formatter = DigestFormatter::new();
465 formatter.format(writer, nodes, stats, root_path)?;
466 }
467 "quantum" => {
468 let formatter = QuantumFormatter::new();
469 formatter.format(writer, nodes, stats, root_path)?;
470 }
471 "semantic" => {
472 let formatter = SemanticFormatter::new(path_display, no_emoji);
473 formatter.format(writer, nodes, stats, root_path)?;
474 }
475 "projects" => {
476 let formatter = ProjectsFormatter::new();
477 formatter.format(writer, nodes, stats, root_path)?;
478 }
479 "mermaid" => {
480 let formatter = MermaidFormatter::new(MermaidStyle::Flowchart, no_emoji, path_display);
481 formatter.format(writer, nodes, stats, root_path)?;
482 }
483 "markdown" => {
484 let formatter = MarkdownFormatter::new(path_display, no_emoji, true, true, true);
485 formatter.format(writer, nodes, stats, root_path)?;
486 }
487 "waste" => {
488 let formatter = WasteFormatter::new();
489 formatter.format(writer, nodes, stats, root_path)?;
490 }
491 "marqant" => {
492 let formatter = MarqantFormatter::new(path_display, no_emoji);
493 formatter.format(writer, nodes, stats, root_path)?;
494 }
495 "smart" => {
496 let formatter = SmartFormatter::new(use_color, !no_emoji)
498 .with_path_mode(path_display);
499 formatter.format(writer, nodes, stats, root_path)?;
500 }
501 _ => {
503 let formatter = ClassicFormatter::new(no_emoji, use_color, path_display);
504 formatter.format(writer, nodes, stats, root_path)?;
505 }
506 }
507
508 Ok(())
509}