Skip to main content

st/
daemon_cli.rs

1//! Daemon CLI Handlers - HTTP endpoints for thin-client CLI operations
2//!
3//! This module provides the daemon endpoints that the `st` thin client
4//! calls to perform scanning and formatting operations. All the heavy
5//! lifting happens here in the daemon.
6//!
7//! "The meat stays in the daemon!" - Hue
8
9use 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/// CLI scan request - all options from the CLI
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct CliScanRequest {
53    /// Path to scan (required)
54    pub path: String,
55
56    /// Output mode (classic, ai, quantum, json, etc.)
57    #[serde(default = "default_mode")]
58    pub mode: String,
59
60    /// Max depth (0 = auto based on mode)
61    #[serde(default)]
62    pub depth: usize,
63
64    /// Show hidden files
65    #[serde(default)]
66    pub all: bool,
67
68    /// Respect .gitignore
69    #[serde(default = "default_true")]
70    pub respect_gitignore: bool,
71
72    /// Use default ignores (node_modules, etc.)
73    #[serde(default = "default_true")]
74    pub default_ignores: bool,
75
76    /// Show ignored entries
77    #[serde(default)]
78    pub show_ignored: bool,
79
80    /// Find pattern (regex for filename matching)
81    pub find: Option<String>,
82
83    /// File type filter (e.g., "rs", "py")
84    pub file_type: Option<String>,
85
86    /// Entry type filter ("f" for files, "d" for directories)
87    pub entry_type: Option<String>,
88
89    /// Min file size (e.g., "1M", "500K")
90    pub min_size: Option<String>,
91
92    /// Max file size
93    pub max_size: Option<String>,
94
95    /// Sort field (name, size, date, type)
96    pub sort: Option<String>,
97
98    /// Top N results (used with sort)
99    pub top: Option<usize>,
100
101    /// Search content keyword
102    pub search: Option<String>,
103
104    /// Enable zlib compression on output
105    #[serde(default)]
106    pub compress: bool,
107
108    /// No emoji in output
109    #[serde(default)]
110    pub no_emoji: bool,
111
112    /// Use color in output
113    #[serde(default = "default_true")]
114    pub use_color: bool,
115
116    /// Path display mode (off, relative, full)
117    #[serde(default = "default_path_mode")]
118    pub path_mode: String,
119
120    /// Focus file (for relations mode)
121    pub focus: Option<String>,
122
123    /// Relations filter
124    pub relations_filter: Option<String>,
125
126    /// Show filesystem type indicators
127    #[serde(default)]
128    pub show_filesystems: bool,
129
130    /// Include line content in search results
131    #[serde(default)]
132    pub include_line_content: bool,
133
134    /// Compact JSON output
135    #[serde(default)]
136    pub compact: bool,
137
138    // --- Smart Scanning Options (Phase 2: Intelligent Context-Aware Scanning) ---
139
140    /// Enable smart mode - groups by interest, shows changes, minimal output
141    #[serde(default)]
142    pub smart: bool,
143
144    /// Only show changes since last scan
145    #[serde(default)]
146    pub changes_only: bool,
147
148    /// Minimum interest level to show (0.0-1.0)
149    #[serde(default)]
150    pub min_interest: f32,
151
152    /// Enable security scanning
153    #[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/// CLI scan response
170#[derive(Debug, Serialize, Deserialize)]
171pub struct CliScanResponse {
172    /// Formatted output (ready to print)
173    pub output: String,
174
175    /// Was output compressed?
176    pub compressed: bool,
177
178    /// Stats about the scan
179    pub stats: ScanStats,
180}
181
182/// Scan statistics
183#[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/// Error response
193#[derive(Debug, Serialize)]
194pub struct CliErrorResponse {
195    pub error: String,
196    pub details: Option<String>,
197}
198
199/// Handle CLI scan request
200pub 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    // Build scanner config from request
205    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    // Resolve path
216    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    // Create scanner and scan
226    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    // Select formatter and format output
249    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    // Optionally compress
267    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    // Build stats
296    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    // Record token savings in daemon state (if compressed)
305    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
321/// Handle streaming CLI scan request (SSE) - simplified version
322/// For now, this just returns the full response as JSON
323/// TODO: Implement proper SSE streaming
324pub 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    // For now, just use the regular handler
329    // Real SSE streaming can be added later
330    cli_scan_handler(State(state), Json(req)).await
331}
332
333/// Build ScannerConfig from CliScanRequest
334fn 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    // Determine depth based on mode if not specified
354    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, // TODO: parse date strings
372        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        // Smart scanning options
380        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
390/// Get ideal depth for a given mode
391fn 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, // Default for classic, json, etc.
399    }
400}
401
402/// Parse path display mode
403fn 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
411/// Format output using the appropriate formatter
412fn 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            // The star of the show! Surface what matters, not everything.
497            let formatter = SmartFormatter::new(use_color, !no_emoji)
498                .with_path_mode(path_display);
499            formatter.format(writer, nodes, stats, root_path)?;
500        }
501        // Default to classic for unknown modes
502        _ => {
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}