Skip to main content

sqry_cli/commands/
cache.rs

1//! Cache command implementation
2
3use crate::args::{CacheAction, Cli};
4use anyhow::{Context, Result};
5use sqry_core::cache::{CacheConfig, CacheManager, PruneOptions, PruneOutputMode, PruneReport};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10/// Run cache management command
11///
12/// # Errors
13/// Returns an error if cache operations fail or stats cannot be collected.
14pub fn run_cache(cli: &Cli, action: &CacheAction) -> Result<()> {
15    match action {
16        CacheAction::Stats { path } => {
17            let search_path = path.as_deref().unwrap_or(".");
18            show_cache_stats(cli, search_path)
19        }
20        CacheAction::Clear { path, confirm } => {
21            let search_path = path.as_deref().unwrap_or(".");
22            clear_cache(cli, search_path, *confirm);
23            Ok(())
24        }
25        CacheAction::Prune {
26            days,
27            size,
28            dry_run,
29            path,
30        } => prune_cache(cli, *days, size.as_deref(), *dry_run, path.as_deref()),
31        CacheAction::Expand {
32            refresh,
33            crate_name,
34            dry_run,
35            output,
36        } => run_expand_cache(
37            cli,
38            *refresh,
39            crate_name.as_deref(),
40            *dry_run,
41            output.as_deref(),
42        ),
43    }
44}
45
46/// Show cache statistics
47fn show_cache_stats(cli: &Cli, _path: &str) -> Result<()> {
48    // Create cache manager with default config
49    let config = CacheConfig::from_env();
50    let cache = CacheManager::new(config);
51    let stats = cache.stats();
52
53    if cli.json {
54        // JSON output
55        let json_stats = serde_json::json!({
56            "ast_cache": {
57                "hits": stats.hits,
58                "misses": stats.misses,
59                "evictions": stats.evictions,
60                "entry_count": stats.entry_count,
61                "total_bytes": stats.total_bytes,
62                "total_mb": bytes_to_mb_lossy(stats.total_bytes),
63                "hit_rate": stats.hit_rate(),
64            },
65        });
66        println!("{}", serde_json::to_string_pretty(&json_stats)?);
67    } else {
68        // Human-readable output
69        println!("AST Cache Statistics");
70        println!("====================");
71        println!();
72        println!("Performance:");
73        println!("  Hit rate:    {:.1}%", stats.hit_rate() * 100.0);
74        println!("  Hits:        {}", stats.hits);
75        println!("  Misses:      {}", stats.misses);
76        println!("  Evictions:   {}", stats.evictions);
77        println!();
78        println!("Storage:");
79        println!("  Entries:     {}", stats.entry_count);
80        println!(
81            "  Memory:      {:.2} MB",
82            bytes_to_mb_lossy(stats.total_bytes)
83        );
84        println!();
85
86        // Calculate effectiveness
87        print_cache_effectiveness(stats.hits, stats.misses);
88
89        // Show cache location and disk usage
90        let cache_root =
91            std::env::var("SQRY_CACHE_ROOT").unwrap_or_else(|_| ".sqry-cache".to_string());
92        println!("Cache location: {cache_root}");
93
94        // Show disk usage
95        let disk_usage = get_disk_usage(&cache_root);
96        println!();
97        println!("Disk Usage:");
98        println!("  Files:       {}", disk_usage.file_count);
99        println!(
100            "  Total size:  {:.2} MB",
101            bytes_to_mb_lossy(disk_usage.bytes)
102        );
103    }
104
105    Ok(())
106}
107
108/// Print estimated cache effectiveness metrics (time savings from cache hits).
109fn print_cache_effectiveness(hits: usize, misses: usize) {
110    if hits + misses > 0 {
111        let total_accesses = hits + misses;
112        let avg_savings_ms = 50; // Conservative estimate: parsing takes ~50ms
113        let time_saved_ms = hits * avg_savings_ms;
114        let time_saved_sec = time_saved_ms / 1000;
115
116        println!("Estimated Impact:");
117        println!("  Total accesses:  {total_accesses}");
118        println!("  Time saved:      ~{time_saved_sec} seconds ({time_saved_ms} ms)");
119        println!();
120    }
121}
122
123struct DiskUsage {
124    file_count: usize,
125    bytes: u64,
126}
127
128fn get_disk_usage(cache_root: &str) -> DiskUsage {
129    use walkdir::WalkDir;
130
131    let mut file_count = 0;
132    let mut total_bytes = 0u64;
133
134    for entry in WalkDir::new(cache_root)
135        .into_iter()
136        .filter_map(std::result::Result::ok)
137        .filter(|e| e.file_type().is_file())
138    {
139        if let Ok(metadata) = entry.metadata() {
140            total_bytes += metadata.len();
141            file_count += 1;
142        }
143    }
144
145    DiskUsage {
146        file_count,
147        bytes: total_bytes,
148    }
149}
150
151fn u64_to_f64_lossy(value: u64) -> f64 {
152    let narrowed = u32::try_from(value).unwrap_or(u32::MAX);
153    f64::from(narrowed)
154}
155
156fn bytes_to_mb_lossy(bytes: u64) -> f64 {
157    u64_to_f64_lossy(bytes) / 1_048_576.0
158}
159
160/// Clear the cache
161fn clear_cache(_cli: &Cli, _path: &str, confirm: bool) {
162    if !confirm {
163        eprintln!("Error: Cache clear requires --confirm flag for safety");
164        eprintln!();
165        eprintln!("This will delete all cached AST data. Next queries will re-parse files.");
166        eprintln!();
167        eprintln!("To proceed, run:");
168        eprintln!("  sqry cache clear --confirm");
169        std::process::exit(1);
170    }
171
172    // Create cache manager and clear it
173    let config = CacheConfig::from_env();
174    let cache = CacheManager::new(config);
175
176    // Get stats before clearing
177    let stats_before = cache.stats();
178
179    cache.clear();
180
181    // Verify it's cleared
182    let stats_after = cache.stats();
183
184    println!("Cache cleared successfully");
185    println!();
186    println!("Removed:");
187    println!("  Entries:     {}", stats_before.entry_count);
188    println!(
189        "  Memory:      {:.2} MB",
190        bytes_to_mb_lossy(stats_before.total_bytes)
191    );
192    println!();
193    println!("Current stats:");
194    println!("  Entries:     {}", stats_after.entry_count);
195    println!(
196        "  Memory:      {:.2} MB",
197        bytes_to_mb_lossy(stats_after.total_bytes)
198    );
199}
200
201/// Prune the cache based on retention policies
202fn prune_cache(
203    cli: &Cli,
204    days: Option<u64>,
205    size_str: Option<&str>,
206    dry_run: bool,
207    path: Option<&str>,
208) -> Result<()> {
209    let options = build_prune_options(cli, days, size_str, dry_run, path)?;
210    let report = execute_cache_prune(&options)?;
211    write_prune_report(cli, dry_run, &report)?;
212
213    Ok(())
214}
215
216/// Parse byte size from string (e.g., "1GB", "500MB")
217fn parse_byte_size(s: &str) -> Result<u64> {
218    let s = s.trim().to_uppercase();
219
220    // Extract number and unit
221    let (num_str, unit) = if s.ends_with("GB") {
222        (&s[..s.len() - 2], 1024 * 1024 * 1024)
223    } else if s.ends_with("MB") {
224        (&s[..s.len() - 2], 1024 * 1024)
225    } else if s.ends_with("KB") {
226        (&s[..s.len() - 2], 1024)
227    } else if s.ends_with('B') {
228        (&s[..s.len() - 1], 1)
229    } else {
230        // Assume bytes if no unit
231        (&s[..], 1)
232    };
233
234    let num: u64 = num_str.trim().parse().map_err(|_| {
235        anyhow::anyhow!("Invalid size format {s}. Expected formats: 1GB, 500MB, 100KB")
236    })?;
237
238    Ok(num * unit)
239}
240
241fn build_prune_options(
242    cli: &Cli,
243    days: Option<u64>,
244    size_str: Option<&str>,
245    dry_run: bool,
246    path: Option<&str>,
247) -> Result<PruneOptions> {
248    // Parse size if provided
249    let max_size = size_str.map(parse_byte_size).transpose()?;
250
251    // Convert days to Duration
252    let max_age = days.map(|d| Duration::from_secs(d * 24 * 3600));
253
254    // Build prune options
255    let mut options = PruneOptions::new();
256
257    if let Some(age) = max_age {
258        options = options.with_max_age(age);
259    }
260
261    if let Some(size) = max_size {
262        options = options.with_max_size(size);
263    }
264
265    options = options.with_dry_run(dry_run);
266
267    let output_mode = if cli.json {
268        PruneOutputMode::Json
269    } else {
270        PruneOutputMode::Human
271    };
272    options = options.with_output_mode(output_mode);
273
274    if let Some(p) = path {
275        options = options.with_target_dir(PathBuf::from(p));
276    }
277
278    Ok(options)
279}
280
281fn execute_cache_prune(options: &PruneOptions) -> Result<PruneReport> {
282    let config = CacheConfig::from_env();
283    let cache = CacheManager::new(config);
284    cache.prune(options)
285}
286
287fn write_prune_report(cli: &Cli, dry_run: bool, report: &PruneReport) -> Result<()> {
288    if cli.json {
289        println!("{}", serde_json::to_string_pretty(report)?);
290        return Ok(());
291    }
292
293    let header = if dry_run {
294        "Cache Prune Preview (Dry Run)"
295    } else {
296        "Cache Prune Report"
297    };
298    println!("{header}");
299    println!("====================");
300    println!();
301
302    if report.entries_removed == 0 {
303        println!("No entries removed");
304        println!("Cache is within configured limits");
305        return Ok(());
306    }
307
308    println!("Entries:");
309    println!("  Considered:  {}", report.entries_considered);
310    println!("  Removed:     {}", report.entries_removed);
311    println!("  Remaining:   {}", report.remaining_entries);
312    println!();
313    println!("Space:");
314    println!(
315        "  Reclaimed:   {:.2} MB",
316        bytes_to_mb_lossy(report.bytes_removed)
317    );
318    println!(
319        "  Remaining:   {:.2} MB",
320        bytes_to_mb_lossy(report.remaining_bytes)
321    );
322
323    if dry_run {
324        println!();
325        println!("Run without --dry-run to actually delete files");
326    }
327
328    Ok(())
329}
330
331// =============================================================================
332// Expand cache implementation
333// =============================================================================
334
335/// Default expand cache directory relative to workspace root.
336const DEFAULT_EXPAND_CACHE_DIR: &str = ".sqry/expand-cache";
337
338/// Maximum allowed expansion output size per file (10 MB).
339const MAX_EXPANSION_SIZE_BYTES: usize = 10 * 1024 * 1024;
340
341/// Allowed character pattern for symbol names in expand cache (security validation).
342fn is_valid_symbol_name(name: &str) -> bool {
343    name.chars()
344        .all(|c| c.is_alphanumeric() || matches!(c, '_' | ':' | '<' | '>' | ' ' | '&' | '\''))
345}
346
347/// Result of expanding a single crate.
348#[derive(Debug)]
349struct CrateExpandResult {
350    crate_name: String,
351    symbols_found: usize,
352    generated_symbols: usize,
353    cached: bool,
354    skipped_reason: Option<String>,
355}
356
357/// Expand cache entry persisted as JSON.
358#[derive(Debug, serde::Serialize, serde::Deserialize)]
359struct ExpandCacheEntry {
360    crate_name: String,
361    rust_version: String,
362    generated_at: String,
363    source_hash: String,
364    files: HashMap<String, ExpandCacheFileEntry>,
365}
366
367/// Per-file entry in the expand cache.
368#[derive(Debug, serde::Serialize, serde::Deserialize)]
369struct ExpandCacheFileEntry {
370    original_symbols: Vec<String>,
371    expanded_symbols: Vec<String>,
372    generated_symbols: Vec<String>,
373    confidence: String,
374}
375
376/// Run the expand cache command.
377///
378/// Generates or refreshes the macro expansion cache by running `cargo expand`
379/// for workspace crates and diffing original vs expanded symbols.
380///
381/// # Errors
382///
383/// Returns an error if `cargo-expand` is not installed, the workspace cannot
384/// be discovered, or cache files cannot be written.
385fn run_expand_cache(
386    cli: &Cli,
387    refresh: bool,
388    crate_name: Option<&str>,
389    dry_run: bool,
390    output: Option<&Path>,
391) -> Result<()> {
392    use sqry_lang_rust::macro_expander::MacroExpander;
393
394    // Check cargo-expand availability
395    if !MacroExpander::is_cargo_expand_available() {
396        anyhow::bail!(
397            "cargo-expand is not installed.\n\
398             Install with: cargo install cargo-expand\n\
399             \n\
400             cargo-expand is required to generate macro expansion output.\n\
401             It runs rustc to expand all macros in a crate."
402        );
403    }
404
405    // Discover workspace root
406    let workspace_root = discover_workspace_root()?;
407    let cache_dir = output.map_or_else(
408        || workspace_root.join(DEFAULT_EXPAND_CACHE_DIR),
409        Path::to_path_buf,
410    );
411
412    // Discover workspace crates
413    let crates = discover_workspace_crates(&workspace_root)?;
414
415    // Filter to specific crate if requested
416    let target_crates: Vec<_> = if let Some(name) = crate_name {
417        let found: Vec<_> = crates.iter().filter(|(n, _)| n == name).cloned().collect();
418        if found.is_empty() {
419            let available: Vec<_> = crates.iter().map(|(n, _)| n.as_str()).collect();
420            anyhow::bail!(
421                "Crate '{}' not found in workspace.\nAvailable crates: {}",
422                name,
423                available.join(", ")
424            );
425        }
426        found
427    } else {
428        crates
429    };
430
431    // Dry run: just list what would be expanded
432    if dry_run {
433        print_dry_run_plan(cli, &target_crates, &cache_dir, refresh)?;
434        return Ok(());
435    }
436
437    // Ensure cache directory exists
438    std::fs::create_dir_all(&cache_dir).with_context(|| {
439        format!(
440            "Failed to create expand cache directory: {}",
441            cache_dir.display()
442        )
443    })?;
444
445    // Expand each crate
446    let mut results = Vec::new();
447    for (name, path) in &target_crates {
448        let result = expand_single_crate(name, path, &workspace_root, &cache_dir, refresh)?;
449        results.push(result);
450    }
451
452    // Report results
453    print_expand_results(cli, &results, &cache_dir)?;
454
455    Ok(())
456}
457
458/// Discover the workspace root by looking for a `Cargo.toml` with `[workspace]`.
459fn discover_workspace_root() -> Result<PathBuf> {
460    let output = std::process::Command::new("cargo")
461        .args(["metadata", "--format-version=1", "--no-deps"])
462        .output()
463        .context("Failed to run cargo metadata")?;
464
465    if !output.status.success() {
466        let stderr = String::from_utf8_lossy(&output.stderr);
467        anyhow::bail!("cargo metadata failed: {stderr}");
468    }
469
470    let metadata: serde_json::Value =
471        serde_json::from_slice(&output.stdout).context("Failed to parse cargo metadata output")?;
472
473    let root = metadata["workspace_root"]
474        .as_str()
475        .context("workspace_root not found in cargo metadata")?;
476
477    Ok(PathBuf::from(root))
478}
479
480/// Discover all workspace crates (name, manifest path).
481fn discover_workspace_crates(workspace_root: &Path) -> Result<Vec<(String, PathBuf)>> {
482    let output = std::process::Command::new("cargo")
483        .args(["metadata", "--format-version=1", "--no-deps"])
484        .current_dir(workspace_root)
485        .output()
486        .context("Failed to run cargo metadata")?;
487
488    if !output.status.success() {
489        let stderr = String::from_utf8_lossy(&output.stderr);
490        anyhow::bail!("cargo metadata failed: {stderr}");
491    }
492
493    let metadata: serde_json::Value =
494        serde_json::from_slice(&output.stdout).context("Failed to parse cargo metadata")?;
495
496    let packages = metadata["packages"]
497        .as_array()
498        .context("No packages in workspace")?;
499
500    let mut crates = Vec::new();
501    for pkg in packages {
502        let name = pkg["name"].as_str().unwrap_or("<unknown>").to_string();
503        let manifest_path = pkg["manifest_path"]
504            .as_str()
505            .map(PathBuf::from)
506            .unwrap_or_default();
507        // Get the crate directory from manifest path
508        let crate_dir = manifest_path
509            .parent()
510            .unwrap_or(workspace_root)
511            .to_path_buf();
512        crates.push((name, crate_dir));
513    }
514
515    crates.sort_by(|a, b| a.0.cmp(&b.0));
516    Ok(crates)
517}
518
519/// Compute SHA-256 hash of all Rust source files in a crate directory.
520fn compute_source_hash(crate_dir: &Path) -> Result<String> {
521    use sha2::{Digest, Sha256};
522    use walkdir::WalkDir;
523
524    let mut hasher = Sha256::new();
525    let mut file_count = 0u64;
526
527    let mut paths: Vec<PathBuf> = WalkDir::new(crate_dir)
528        .into_iter()
529        .filter_map(std::result::Result::ok)
530        .filter(|e| e.file_type().is_file() && e.path().extension().is_some_and(|ext| ext == "rs"))
531        .map(walkdir::DirEntry::into_path)
532        .collect();
533
534    // Sort for deterministic hashing
535    paths.sort();
536
537    for path in &paths {
538        let content =
539            std::fs::read(path).with_context(|| format!("Failed to read {}", path.display()))?;
540        hasher.update(&content);
541        file_count += 1;
542    }
543
544    // Include file count to detect additions/deletions
545    hasher.update(file_count.to_le_bytes());
546
547    Ok(format!("{:x}", hasher.finalize()))
548}
549
550/// Check if a cached entry is fresh (source hash matches).
551fn is_cache_fresh(cache_path: &Path, current_hash: &str) -> bool {
552    let Ok(content) = std::fs::read_to_string(cache_path) else {
553        return false;
554    };
555    let Ok(entry) = serde_json::from_str::<ExpandCacheEntry>(&content) else {
556        return false;
557    };
558    entry.source_hash == current_hash
559}
560
561/// Expand a single crate and write the cache entry.
562fn expand_single_crate(
563    crate_name: &str,
564    crate_dir: &Path,
565    workspace_root: &Path,
566    cache_dir: &Path,
567    refresh: bool,
568) -> Result<CrateExpandResult> {
569    // Compute source hash
570    let source_hash = compute_source_hash(crate_dir)
571        .with_context(|| format!("Failed to compute source hash for {crate_name}"))?;
572
573    // Check freshness
574    let cache_file = cache_dir.join(format!("{crate_name}.json"));
575    if !refresh && is_cache_fresh(&cache_file, &source_hash) {
576        return Ok(CrateExpandResult {
577            crate_name: crate_name.to_string(),
578            symbols_found: 0,
579            generated_symbols: 0,
580            cached: true,
581            skipped_reason: Some("cache is fresh".to_string()),
582        });
583    }
584
585    // Run cargo expand
586    let expand_output = run_cargo_expand(crate_name, crate_dir)?;
587
588    // Check size limit
589    if expand_output.len() > MAX_EXPANSION_SIZE_BYTES {
590        return Ok(CrateExpandResult {
591            crate_name: crate_name.to_string(),
592            symbols_found: 0,
593            generated_symbols: 0,
594            cached: false,
595            skipped_reason: Some(format!(
596                "expansion output too large ({} bytes, limit {})",
597                expand_output.len(),
598                MAX_EXPANSION_SIZE_BYTES
599            )),
600        });
601    }
602
603    // Parse expanded output to extract symbols
604    let expanded_symbols = extract_rust_symbols_from_source(&expand_output);
605
606    // Find original symbols from source files
607    let original_symbols = collect_original_symbols(crate_dir)?;
608
609    // Diff to find generated symbols
610    let generated: Vec<String> = expanded_symbols
611        .iter()
612        .filter(|s| !original_symbols.contains(s))
613        .filter(|s| is_valid_symbol_name(s))
614        .cloned()
615        .collect();
616
617    let generated_count = generated.len();
618    let total_expanded = expanded_symbols.len();
619
620    // Compute relative file path for the entry
621    let relative_src = crate_dir
622        .strip_prefix(workspace_root)
623        .unwrap_or(crate_dir)
624        .join("src/lib.rs");
625
626    // Build cache entry
627    let entry = ExpandCacheEntry {
628        crate_name: crate_name.to_string(),
629        rust_version: get_rust_version(),
630        generated_at: chrono_now_utc(),
631        source_hash,
632        files: {
633            let mut map = HashMap::new();
634            map.insert(
635                relative_src.to_string_lossy().to_string(),
636                ExpandCacheFileEntry {
637                    original_symbols: original_symbols.into_iter().collect(),
638                    expanded_symbols: expanded_symbols.into_iter().collect(),
639                    generated_symbols: generated,
640                    confidence: "heuristic".to_string(),
641                },
642            );
643            map
644        },
645    };
646
647    // Write cache file
648    let json =
649        serde_json::to_string_pretty(&entry).context("Failed to serialize expand cache entry")?;
650    std::fs::write(&cache_file, json)
651        .with_context(|| format!("Failed to write cache file: {}", cache_file.display()))?;
652
653    Ok(CrateExpandResult {
654        crate_name: crate_name.to_string(),
655        symbols_found: total_expanded,
656        generated_symbols: generated_count,
657        cached: false,
658        skipped_reason: None,
659    })
660}
661
662/// Run `cargo expand` for a specific crate.
663fn run_cargo_expand(crate_name: &str, crate_dir: &Path) -> Result<String> {
664    let output = std::process::Command::new("cargo")
665        .args(["expand", "--lib"])
666        .current_dir(crate_dir)
667        .output()
668        .with_context(|| format!("Failed to execute cargo expand for {crate_name}"))?;
669
670    if !output.status.success() {
671        let stderr = String::from_utf8_lossy(&output.stderr);
672        // Try without --lib (might be a binary crate)
673        let output2 = std::process::Command::new("cargo")
674            .arg("expand")
675            .current_dir(crate_dir)
676            .output()
677            .with_context(|| format!("Failed to execute cargo expand for {crate_name}"))?;
678
679        if !output2.status.success() {
680            let stderr2 = String::from_utf8_lossy(&output2.stderr);
681            anyhow::bail!(
682                "cargo expand failed for '{crate_name}':\n  --lib: {}\n  default: {}",
683                stderr.lines().next().unwrap_or("unknown error"),
684                stderr2.lines().next().unwrap_or("unknown error")
685            );
686        }
687        return Ok(String::from_utf8_lossy(&output2.stdout).to_string());
688    }
689
690    Ok(String::from_utf8_lossy(&output.stdout).to_string())
691}
692
693/// Extract symbol names from Rust source using simple heuristic parsing.
694///
695/// This extracts function, struct, enum, trait, impl, type, const, and static
696/// declarations by scanning for declaration keywords. It's a lightweight
697/// alternative to full tree-sitter parsing for the expand cache use case.
698fn extract_rust_symbols_from_source(source: &str) -> Vec<String> {
699    let mut symbols = Vec::new();
700
701    for line in source.lines() {
702        let trimmed = line.trim();
703
704        // Skip comments and empty lines
705        if trimmed.is_empty() || trimmed.starts_with("//") || trimmed.starts_with("/*") {
706            continue;
707        }
708
709        // Extract declaration names
710        if let Some(name) = extract_decl_name(trimmed, "fn ") {
711            symbols.push(name);
712        } else if let Some(name) = extract_decl_name(trimmed, "struct ") {
713            symbols.push(name);
714        } else if let Some(name) = extract_decl_name(trimmed, "enum ") {
715            symbols.push(name);
716        } else if let Some(name) = extract_decl_name(trimmed, "trait ") {
717            symbols.push(name);
718        } else if let Some(name) = extract_decl_name(trimmed, "type ") {
719            symbols.push(name);
720        } else if let Some(name) = extract_decl_name(trimmed, "const ") {
721            symbols.push(name);
722        } else if let Some(name) = extract_decl_name(trimmed, "static ") {
723            symbols.push(name);
724        } else if let Some(name) = extract_decl_name(trimmed, "mod ") {
725            symbols.push(name);
726        }
727    }
728
729    symbols.sort();
730    symbols.dedup();
731    symbols
732}
733
734/// Extract declaration name from a line starting with a keyword.
735fn extract_decl_name(line: &str, keyword: &str) -> Option<String> {
736    // Strip visibility modifiers
737    let stripped = line
738        .strip_prefix("pub(crate) ")
739        .or_else(|| line.strip_prefix("pub(super) "))
740        .or_else(|| line.strip_prefix("pub(in "))
741        .or_else(|| line.strip_prefix("pub "))
742        .unwrap_or(line);
743
744    // Strip async/unsafe modifiers (not const — it's both a modifier and keyword)
745    let stripped = stripped
746        .strip_prefix("async ")
747        .or_else(|| stripped.strip_prefix("unsafe "))
748        .unwrap_or(stripped);
749
750    // Only strip "const " as modifier when keyword is NOT "const " itself
751    let stripped = if keyword == "const " {
752        stripped
753    } else {
754        stripped.strip_prefix("const ").unwrap_or(stripped)
755    };
756
757    if !stripped.starts_with(keyword) {
758        return None;
759    }
760
761    let rest = &stripped[keyword.len()..];
762    let name: String = rest
763        .chars()
764        .take_while(|c| c.is_alphanumeric() || *c == '_')
765        .collect();
766
767    if name.is_empty() { None } else { Some(name) }
768}
769
770/// Collect original symbols from source files in a crate directory.
771fn collect_original_symbols(crate_dir: &Path) -> Result<Vec<String>> {
772    use walkdir::WalkDir;
773
774    let mut all_symbols = Vec::new();
775
776    for entry in WalkDir::new(crate_dir)
777        .into_iter()
778        .filter_map(std::result::Result::ok)
779        .filter(|e| e.file_type().is_file() && e.path().extension().is_some_and(|ext| ext == "rs"))
780    {
781        let content = std::fs::read_to_string(entry.path())
782            .with_context(|| format!("Failed to read {}", entry.path().display()))?;
783        let symbols = extract_rust_symbols_from_source(&content);
784        all_symbols.extend(symbols);
785    }
786
787    all_symbols.sort();
788    all_symbols.dedup();
789    Ok(all_symbols)
790}
791
792/// Get the current Rust compiler version.
793fn get_rust_version() -> String {
794    std::process::Command::new("rustc")
795        .arg("--version")
796        .output()
797        .ok()
798        .and_then(|o| {
799            if o.status.success() {
800                String::from_utf8(o.stdout).ok()
801            } else {
802                None
803            }
804        })
805        .map_or_else(|| "unknown".to_string(), |v| v.trim().to_string())
806}
807
808/// Get current UTC timestamp as ISO 8601 string.
809fn chrono_now_utc() -> String {
810    // Use system time to avoid adding a chrono dependency
811    use std::time::SystemTime;
812    let now = SystemTime::now()
813        .duration_since(SystemTime::UNIX_EPOCH)
814        .unwrap_or_default();
815    format!("{}Z", now.as_secs())
816}
817
818/// Print dry-run plan without actually expanding.
819fn print_dry_run_plan(
820    cli: &Cli,
821    crates: &[(String, PathBuf)],
822    cache_dir: &Path,
823    refresh: bool,
824) -> Result<()> {
825    if cli.json {
826        let plan = serde_json::json!({
827            "action": "expand",
828            "dry_run": true,
829            "refresh": refresh,
830            "cache_dir": cache_dir.display().to_string(),
831            "crates": crates.iter().map(|(name, path)| {
832                let hash = compute_source_hash(path).unwrap_or_default();
833                let cache_file = cache_dir.join(format!("{name}.json"));
834                let fresh = is_cache_fresh(&cache_file, &hash);
835                serde_json::json!({
836                    "name": name,
837                    "path": path.display().to_string(),
838                    "cache_fresh": fresh,
839                    "would_expand": refresh || !fresh,
840                })
841            }).collect::<Vec<_>>(),
842        });
843        println!("{}", serde_json::to_string_pretty(&plan)?);
844    } else {
845        println!("Macro Expansion Plan (Dry Run)");
846        println!("==============================");
847        println!();
848        println!("Cache directory: {}", cache_dir.display());
849        println!(
850            "Refresh mode:   {}",
851            if refresh { "force" } else { "incremental" }
852        );
853        println!();
854        println!("Crates ({}):", crates.len());
855
856        for (name, path) in crates {
857            let hash = compute_source_hash(path).unwrap_or_default();
858            let cache_file = cache_dir.join(format!("{name}.json"));
859            let fresh = is_cache_fresh(&cache_file, &hash);
860
861            let status = if fresh && !refresh {
862                "skip (cache fresh)"
863            } else if fresh && refresh {
864                "expand (--refresh)"
865            } else {
866                "expand (no cache)"
867            };
868
869            println!("  {name:30} {status}");
870        }
871
872        println!();
873        println!("Run without --dry-run to execute expansion.");
874    }
875
876    Ok(())
877}
878
879/// Print expand results summary.
880fn print_expand_results(cli: &Cli, results: &[CrateExpandResult], cache_dir: &Path) -> Result<()> {
881    if cli.json {
882        let json = serde_json::json!({
883            "cache_dir": cache_dir.display().to_string(),
884            "results": results.iter().map(|r| {
885                serde_json::json!({
886                    "crate": r.crate_name,
887                    "symbols_found": r.symbols_found,
888                    "generated_symbols": r.generated_symbols,
889                    "cached": r.cached,
890                    "skipped_reason": r.skipped_reason,
891                })
892            }).collect::<Vec<_>>(),
893        });
894        println!("{}", serde_json::to_string_pretty(&json)?);
895    } else {
896        println!("Macro Expansion Results");
897        println!("=======================");
898        println!();
899        println!("Cache directory: {}", cache_dir.display());
900        println!();
901
902        let mut expanded = 0;
903        let mut skipped = 0;
904        let mut total_generated = 0;
905
906        for r in results {
907            if let Some(reason) = &r.skipped_reason {
908                println!("  {}: skipped ({reason})", r.crate_name);
909                skipped += 1;
910            } else {
911                println!(
912                    "  {}: {} symbols ({} generated)",
913                    r.crate_name, r.symbols_found, r.generated_symbols
914                );
915                expanded += 1;
916                total_generated += r.generated_symbols;
917            }
918        }
919
920        println!();
921        println!("Summary:");
922        println!("  Expanded: {expanded}");
923        println!("  Skipped:  {skipped}");
924        println!("  Total generated symbols: {total_generated}");
925    }
926
927    Ok(())
928}
929
930#[cfg(test)]
931mod tests {
932    use super::*;
933
934    #[test]
935    fn test_is_valid_symbol_name() {
936        assert!(is_valid_symbol_name("MyStruct"));
937        assert!(is_valid_symbol_name("my_crate::MyStruct"));
938        assert!(is_valid_symbol_name("my_crate::<MyStruct as Debug>::fmt"));
939        assert!(is_valid_symbol_name("some_fn"));
940        assert!(is_valid_symbol_name("CONSTANT_NAME"));
941        // Control characters should be rejected
942        assert!(!is_valid_symbol_name("bad\x00name"));
943        assert!(!is_valid_symbol_name("bad\nname"));
944        // Shell metacharacters should be rejected
945        assert!(!is_valid_symbol_name("$(evil)"));
946        assert!(!is_valid_symbol_name("`backtick`"));
947        assert!(!is_valid_symbol_name("semi;colon"));
948        assert!(!is_valid_symbol_name("pipe|char"));
949    }
950
951    #[test]
952    fn test_extract_decl_name_fn() {
953        assert_eq!(
954            extract_decl_name("fn main() {", "fn "),
955            Some("main".to_string())
956        );
957        assert_eq!(
958            extract_decl_name("pub fn foo() {", "fn "),
959            Some("foo".to_string())
960        );
961        assert_eq!(
962            extract_decl_name("pub(crate) fn bar() {", "fn "),
963            Some("bar".to_string())
964        );
965        assert_eq!(
966            extract_decl_name("async fn baz() {", "fn "),
967            Some("baz".to_string())
968        );
969        assert_eq!(
970            extract_decl_name("pub async fn qux() {", "fn "),
971            Some("qux".to_string())
972        );
973    }
974
975    #[test]
976    fn test_extract_decl_name_struct() {
977        assert_eq!(
978            extract_decl_name("struct Foo {", "struct "),
979            Some("Foo".to_string())
980        );
981        assert_eq!(
982            extract_decl_name("pub struct Bar;", "struct "),
983            Some("Bar".to_string())
984        );
985    }
986
987    #[test]
988    fn test_extract_decl_name_no_match() {
989        assert_eq!(extract_decl_name("let x = 5;", "fn "), None);
990        assert_eq!(extract_decl_name("// fn foo", "fn "), None);
991    }
992
993    #[test]
994    fn test_extract_rust_symbols_from_source() {
995        let source = r"
996pub fn hello() {}
997struct MyStruct {
998    field: i32,
999}
1000enum Color { Red, Green, Blue }
1001const MAX: usize = 100;
1002mod inner {}
1003";
1004        let symbols = extract_rust_symbols_from_source(source);
1005        assert!(symbols.contains(&"hello".to_string()));
1006        assert!(symbols.contains(&"MyStruct".to_string()));
1007        assert!(symbols.contains(&"Color".to_string()));
1008        assert!(symbols.contains(&"MAX".to_string()));
1009        assert!(symbols.contains(&"inner".to_string()));
1010    }
1011
1012    #[test]
1013    fn test_expand_cache_entry_roundtrip() {
1014        let entry = ExpandCacheEntry {
1015            crate_name: "test_crate".to_string(),
1016            rust_version: "rustc 1.94.0".to_string(),
1017            generated_at: "1234567890Z".to_string(),
1018            source_hash: "abc123".to_string(),
1019            files: {
1020                let mut map = HashMap::new();
1021                map.insert(
1022                    "src/lib.rs".to_string(),
1023                    ExpandCacheFileEntry {
1024                        original_symbols: vec!["Foo".to_string()],
1025                        expanded_symbols: vec!["Foo".to_string(), "Foo_fmt".to_string()],
1026                        generated_symbols: vec!["Foo_fmt".to_string()],
1027                        confidence: "heuristic".to_string(),
1028                    },
1029                );
1030                map
1031            },
1032        };
1033
1034        let json = serde_json::to_string_pretty(&entry).unwrap();
1035        let parsed: ExpandCacheEntry = serde_json::from_str(&json).unwrap();
1036        assert_eq!(parsed.crate_name, "test_crate");
1037        assert_eq!(parsed.source_hash, "abc123");
1038        assert_eq!(parsed.files.len(), 1);
1039
1040        let file_entry = parsed.files.get("src/lib.rs").unwrap();
1041        assert_eq!(file_entry.generated_symbols, vec!["Foo_fmt"]);
1042    }
1043
1044    #[test]
1045    fn test_is_cache_fresh_nonexistent() {
1046        assert!(!is_cache_fresh(
1047            Path::new("/nonexistent/cache.json"),
1048            "abc123"
1049        ));
1050    }
1051
1052    #[test]
1053    fn test_is_cache_fresh_matching_hash() {
1054        let dir = tempfile::tempdir().unwrap();
1055        let cache_path = dir.path().join("test.json");
1056        let entry = ExpandCacheEntry {
1057            crate_name: "test".to_string(),
1058            rust_version: "1.94.0".to_string(),
1059            generated_at: "0Z".to_string(),
1060            source_hash: "hash123".to_string(),
1061            files: HashMap::new(),
1062        };
1063        let json = serde_json::to_string(&entry).unwrap();
1064        std::fs::write(&cache_path, json).unwrap();
1065
1066        assert!(is_cache_fresh(&cache_path, "hash123"));
1067        assert!(!is_cache_fresh(&cache_path, "different_hash"));
1068    }
1069
1070    #[test]
1071    fn test_compute_source_hash_deterministic() {
1072        let dir = tempfile::tempdir().unwrap();
1073        let src_dir = dir.path().join("src");
1074        std::fs::create_dir_all(&src_dir).unwrap();
1075        std::fs::write(src_dir.join("lib.rs"), "fn main() {}").unwrap();
1076        std::fs::write(src_dir.join("helper.rs"), "fn helper() {}").unwrap();
1077
1078        let hash1 = compute_source_hash(dir.path()).unwrap();
1079        let hash2 = compute_source_hash(dir.path()).unwrap();
1080        assert_eq!(hash1, hash2, "Hashes should be deterministic");
1081        assert!(!hash1.is_empty());
1082    }
1083
1084    #[test]
1085    fn test_compute_source_hash_changes_on_modification() {
1086        let dir = tempfile::tempdir().unwrap();
1087        std::fs::write(dir.path().join("lib.rs"), "fn main() {}").unwrap();
1088
1089        let hash1 = compute_source_hash(dir.path()).unwrap();
1090
1091        std::fs::write(dir.path().join("lib.rs"), "fn main() { println!() }").unwrap();
1092        let hash2 = compute_source_hash(dir.path()).unwrap();
1093
1094        assert_ne!(hash1, hash2, "Hash should change when source changes");
1095    }
1096}