syncable_cli/agent/tools/
output_store.rs

1//! RAG Storage Layer for Tool Outputs
2//!
3//! Stores full tool outputs to disk for later retrieval by the agent.
4//! Implements the storage part of the RAG (Retrieval-Augmented Generation) pattern.
5//!
6//! ## Session Tracking
7//!
8//! All stored outputs are tracked in a session registry, so the agent always knows
9//! what data is available for retrieval. Every compressed output includes the full
10//! list of available refs.
11
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14use std::fs;
15use std::path::PathBuf;
16use std::sync::Mutex;
17use std::time::{SystemTime, UNIX_EPOCH};
18
19/// Directory where outputs are stored
20const OUTPUT_DIR: &str = "/tmp/syncable-cli/outputs";
21
22/// Maximum age of stored outputs in seconds (1 hour)
23const MAX_AGE_SECS: u64 = 3600;
24
25/// Session registry entry - tracks what's available for retrieval
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SessionRef {
28    /// Reference ID for retrieval
29    pub ref_id: String,
30    /// Tool that generated this output
31    pub tool: String,
32    /// What this output contains (brief description)
33    pub contains: String,
34    /// Summary counts (e.g., "47 issues: 3 critical, 12 high")
35    pub summary: String,
36    /// Timestamp when stored
37    pub timestamp: u64,
38    /// Size in bytes
39    pub size_bytes: usize,
40}
41
42/// Global session registry - tracks all stored outputs in current session
43static SESSION_REGISTRY: Mutex<Vec<SessionRef>> = Mutex::new(Vec::new());
44
45/// Register a new output in the session registry
46pub fn register_session_ref(
47    ref_id: &str,
48    tool: &str,
49    contains: &str,
50    summary: &str,
51    size_bytes: usize,
52) {
53    if let Ok(mut registry) = SESSION_REGISTRY.lock() {
54        // Remove any existing entry for this ref_id (in case of re-runs)
55        registry.retain(|r| r.ref_id != ref_id);
56
57        registry.push(SessionRef {
58            ref_id: ref_id.to_string(),
59            tool: tool.to_string(),
60            contains: contains.to_string(),
61            summary: summary.to_string(),
62            timestamp: SystemTime::now()
63                .duration_since(UNIX_EPOCH)
64                .map(|d| d.as_secs())
65                .unwrap_or(0),
66            size_bytes,
67        });
68    }
69}
70
71/// Get all session refs for inclusion in compressed outputs
72pub fn get_session_refs() -> Vec<SessionRef> {
73    SESSION_REGISTRY
74        .lock()
75        .map(|r| r.clone())
76        .unwrap_or_default()
77}
78
79/// Clear old entries from session registry (called periodically)
80pub fn cleanup_session_registry() {
81    let now = SystemTime::now()
82        .duration_since(UNIX_EPOCH)
83        .map(|d| d.as_secs())
84        .unwrap_or(0);
85
86    if let Ok(mut registry) = SESSION_REGISTRY.lock() {
87        registry.retain(|r| now - r.timestamp < MAX_AGE_SECS);
88    }
89}
90
91/// Format session refs as a user-friendly string for the agent
92pub fn format_session_refs_for_agent() -> String {
93    let refs = get_session_refs();
94
95    if refs.is_empty() {
96        return String::new();
97    }
98
99    let mut output = String::from("\nšŸ“¦ AVAILABLE DATA FOR RETRIEVAL:\n");
100    output.push_str("─────────────────────────────────\n");
101
102    for r in &refs {
103        let age = SystemTime::now()
104            .duration_since(UNIX_EPOCH)
105            .map(|d| d.as_secs())
106            .unwrap_or(0)
107            .saturating_sub(r.timestamp);
108
109        let age_str = if age < 60 {
110            format!("{}s ago", age)
111        } else {
112            format!("{}m ago", age / 60)
113        };
114
115        output.push_str(&format!(
116            "\n• {} [{}]\n  Contains: {}\n  Summary: {}\n  Retrieve: retrieve_output(\"{}\") or with query\n",
117            r.ref_id, age_str, r.contains, r.summary, r.ref_id
118        ));
119    }
120
121    output.push_str("\n─────────────────────────────────\n");
122    output.push_str(
123        "Query examples: \"severity:critical\", \"file:deployment.yaml\", \"code:DL3008\"\n",
124    );
125
126    output
127}
128
129/// Generate a short unique reference ID
130fn generate_ref_id() -> String {
131    let timestamp = SystemTime::now()
132        .duration_since(UNIX_EPOCH)
133        .map(|d| d.as_millis())
134        .unwrap_or(0);
135
136    // Use last 8 chars of timestamp + random suffix
137    let ts_part = format!("{:x}", timestamp)
138        .chars()
139        .rev()
140        .take(6)
141        .collect::<String>();
142    let rand_part: String = (0..4)
143        .map(|_| {
144            let idx = (timestamp as usize + rand_simple()) % 36;
145            "abcdefghijklmnopqrstuvwxyz0123456789"
146                .chars()
147                .nth(idx)
148                .unwrap()
149        })
150        .collect();
151
152    format!("{}_{}", ts_part, rand_part)
153}
154
155/// Simple pseudo-random number (no external deps)
156fn rand_simple() -> usize {
157    let ptr = Box::into_raw(Box::new(0u8));
158    let addr = ptr as usize;
159    unsafe { drop(Box::from_raw(ptr)) };
160    addr.wrapping_mul(1103515245).wrapping_add(12345) % (1 << 31)
161}
162
163/// Ensure output directory exists
164fn ensure_output_dir() -> std::io::Result<PathBuf> {
165    let path = PathBuf::from(OUTPUT_DIR);
166    if !path.exists() {
167        fs::create_dir_all(&path)?;
168    }
169    Ok(path)
170}
171
172/// Store output to disk and return reference ID
173///
174/// # Arguments
175/// * `output` - The JSON value to store
176/// * `tool_name` - Name of the tool (used as prefix in ref_id)
177///
178/// # Returns
179/// Reference ID that can be used to retrieve the output later
180pub fn store_output(output: &Value, tool_name: &str) -> String {
181    let ref_id = format!("{}_{}", tool_name, generate_ref_id());
182
183    if let Ok(dir) = ensure_output_dir() {
184        let path = dir.join(format!("{}.json", ref_id));
185
186        // Store with metadata
187        let stored = serde_json::json!({
188            "ref_id": ref_id,
189            "tool": tool_name,
190            "timestamp": SystemTime::now()
191                .duration_since(UNIX_EPOCH)
192                .map(|d| d.as_secs())
193                .unwrap_or(0),
194            "data": output
195        });
196
197        if let Ok(json_str) = serde_json::to_string(&stored) {
198            let _ = fs::write(&path, json_str);
199        }
200    }
201
202    ref_id
203}
204
205/// Retrieve stored output by reference ID
206///
207/// # Arguments
208/// * `ref_id` - The reference ID returned from `store_output`
209///
210/// # Returns
211/// The stored JSON value, or None if not found
212pub fn retrieve_output(ref_id: &str) -> Option<Value> {
213    let path = PathBuf::from(OUTPUT_DIR).join(format!("{}.json", ref_id));
214
215    if !path.exists() {
216        return None;
217    }
218
219    let content = fs::read_to_string(&path).ok()?;
220    let stored: Value = serde_json::from_str(&content).ok()?;
221
222    // Return just the data portion
223    stored.get("data").cloned()
224}
225
226/// Retrieve and filter output by query
227///
228/// # Arguments
229/// * `ref_id` - The reference ID
230/// * `query` - Optional filter query (e.g., "severity:critical", "file:path", "code:DL3008")
231///
232/// For analyze_project outputs, supports:
233/// - section:summary - Top-level info
234/// - section:projects - List projects
235/// - section:frameworks - All frameworks
236/// - section:languages - All languages
237/// - section:services - All services
238/// - project:name - Specific project details
239/// - service:name - Specific service
240/// - language:Go - Language details
241/// - framework:* - Framework details
242/// - compact:true - Compacted output (default for analyze_project)
243///
244/// # Returns
245/// Filtered JSON value, or None if not found
246pub fn retrieve_filtered(ref_id: &str, query: Option<&str>) -> Option<Value> {
247    let data = retrieve_output(ref_id)?;
248
249    // Check if this is an analyze_project output
250    if is_analyze_project_output(&data) {
251        return retrieve_analyze_project(&data, query);
252    }
253
254    let query = match query {
255        Some(q) if !q.is_empty() => q,
256        _ => return Some(data),
257    };
258
259    // Parse query
260    let (filter_type, filter_value) = parse_query(query);
261
262    // Find issues/findings array in data
263    let issues = find_issues_array(&data)?;
264
265    // Filter issues
266    let filtered: Vec<Value> = issues
267        .iter()
268        .filter(|issue| matches_filter(issue, &filter_type, &filter_value))
269        .cloned()
270        .collect();
271
272    Some(serde_json::json!({
273        "query": query,
274        "total_matches": filtered.len(),
275        "results": filtered
276    }))
277}
278
279/// Parse a query string into type and value
280fn parse_query(query: &str) -> (String, String) {
281    if let Some(idx) = query.find(':') {
282        let (t, v) = query.split_at(idx);
283        (t.to_lowercase(), v[1..].to_string())
284    } else {
285        // Treat as general search term
286        ("any".to_string(), query.to_string())
287    }
288}
289
290/// Find issues/findings array in a JSON value
291fn find_issues_array(data: &Value) -> Option<Vec<Value>> {
292    let issue_fields = [
293        "issues",
294        "findings",
295        "violations",
296        "warnings",
297        "errors",
298        "recommendations",
299        "results",
300    ];
301
302    for field in &issue_fields {
303        if let Some(arr) = data.get(field).and_then(|v| v.as_array()) {
304            return Some(arr.clone());
305        }
306    }
307
308    // Check if data itself is an array
309    if let Some(arr) = data.as_array() {
310        return Some(arr.clone());
311    }
312
313    None
314}
315
316/// Check if an issue matches a filter
317fn matches_filter(issue: &Value, filter_type: &str, filter_value: &str) -> bool {
318    match filter_type {
319        "severity" | "level" => {
320            let sev = issue
321                .get("severity")
322                .or_else(|| issue.get("level"))
323                .and_then(|v| v.as_str())
324                .unwrap_or("");
325            sev.to_lowercase().contains(&filter_value.to_lowercase())
326        }
327        "file" | "path" => {
328            let file = issue
329                .get("file")
330                .or_else(|| issue.get("path"))
331                .or_else(|| issue.get("filename"))
332                .and_then(|v| v.as_str())
333                .unwrap_or("");
334            file.to_lowercase().contains(&filter_value.to_lowercase())
335        }
336        "code" | "rule" => {
337            let code = issue
338                .get("code")
339                .or_else(|| issue.get("rule"))
340                .or_else(|| issue.get("rule_id"))
341                .and_then(|v| v.as_str())
342                .unwrap_or("");
343            code.to_lowercase().contains(&filter_value.to_lowercase())
344        }
345        "container" | "resource" => {
346            let container = issue
347                .get("container")
348                .or_else(|| issue.get("resource"))
349                .or_else(|| issue.get("name"))
350                .and_then(|v| v.as_str())
351                .unwrap_or("");
352            container
353                .to_lowercase()
354                .contains(&filter_value.to_lowercase())
355        }
356        "any" | _ => {
357            // Search in all string values
358            let issue_str = serde_json::to_string(issue).unwrap_or_default();
359            issue_str
360                .to_lowercase()
361                .contains(&filter_value.to_lowercase())
362        }
363    }
364}
365
366// ============================================================================
367// Smart Retrieval for different output types
368// ============================================================================
369
370/// Output type detection for smart retrieval
371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
372pub enum OutputType {
373    /// MonorepoAnalysis - has "projects" array and/or "is_monorepo"
374    MonorepoAnalysis,
375    /// ProjectAnalysis - flat structure with "languages" + "analysis_metadata"
376    ProjectAnalysis,
377    /// LintResult - has "failures" array (kubelint, hadolint, dclint, helmlint)
378    LintResult,
379    /// OptimizationResult - has "recommendations" array (k8s_optimize)
380    OptimizationResult,
381    /// Generic - fallback for unknown structures
382    Generic,
383}
384
385/// Detect the output type for smart retrieval routing
386pub fn detect_output_type(data: &Value) -> OutputType {
387    // MonorepoAnalysis: has projects array or is_monorepo flag
388    if data.get("projects").is_some() || data.get("is_monorepo").is_some() {
389        return OutputType::MonorepoAnalysis;
390    }
391
392    // ProjectAnalysis: has languages array + analysis_metadata (flat structure)
393    if data.get("languages").is_some() && data.get("analysis_metadata").is_some() {
394        return OutputType::ProjectAnalysis;
395    }
396
397    // LintResult: has failures array
398    if data.get("failures").is_some() {
399        return OutputType::LintResult;
400    }
401
402    // OptimizationResult: has recommendations array
403    if data.get("recommendations").is_some() {
404        return OutputType::OptimizationResult;
405    }
406
407    OutputType::Generic
408}
409
410/// Check if data is an analyze_project output (either type)
411fn is_analyze_project_output(data: &Value) -> bool {
412    matches!(
413        detect_output_type(data),
414        OutputType::MonorepoAnalysis | OutputType::ProjectAnalysis
415    )
416}
417
418/// Smart retrieval for analyze_project outputs
419/// Supports queries like:
420/// - section:summary - Top-level info without nested data
421/// - section:projects - List project names and categories
422/// - project:name - Get specific project details (compacted)
423/// - service:name - Get specific service details
424/// - language:Go - Get language details for a specific language
425/// - framework:* - List all detected frameworks
426/// - compact:true - Strip file arrays, return counts
427pub fn retrieve_analyze_project(data: &Value, query: Option<&str>) -> Option<Value> {
428    let query = query.unwrap_or("compact:true");
429    let (query_type, query_value) = parse_query(query);
430
431    match query_type.as_str() {
432        "section" => match query_value.as_str() {
433            "summary" => Some(extract_summary(data)),
434            "projects" => Some(extract_projects_list(data)),
435            "frameworks" => Some(extract_all_frameworks(data)),
436            "languages" => Some(extract_all_languages(data)),
437            "services" => Some(extract_all_services(data)),
438            _ => Some(compact_analyze_output(data)),
439        },
440        "project" => extract_project_by_name(data, &query_value),
441        "service" => extract_service_by_name(data, &query_value),
442        "language" => extract_language_details(data, &query_value),
443        "framework" => extract_framework_details(data, &query_value),
444        "compact" => Some(compact_analyze_output(data)),
445        _ => {
446            // Default: return compacted output
447            Some(compact_analyze_output(data))
448        }
449    }
450}
451
452/// Extract top-level summary without nested data
453fn extract_summary(data: &Value) -> Value {
454    let mut summary = serde_json::Map::new();
455
456    // Handle MonorepoAnalysis structure
457    if let Some(root) = data.get("root_path").and_then(|v| v.as_str()) {
458        summary.insert("root_path".to_string(), Value::String(root.to_string()));
459    }
460    if let Some(mono) = data.get("is_monorepo").and_then(|v| v.as_bool()) {
461        summary.insert("is_monorepo".to_string(), Value::Bool(mono));
462    }
463
464    // Handle ProjectAnalysis structure (flat)
465    if let Some(root) = data.get("project_root").and_then(|v| v.as_str()) {
466        summary.insert("project_root".to_string(), Value::String(root.to_string()));
467    }
468    if let Some(arch) = data.get("architecture_type").and_then(|v| v.as_str()) {
469        summary.insert("architecture_type".to_string(), Value::String(arch.to_string()));
470    }
471
472    // Count projects (MonorepoAnalysis)
473    if let Some(projects) = data.get("projects").and_then(|v| v.as_array()) {
474        summary.insert("project_count".to_string(), Value::Number(projects.len().into()));
475
476        // Extract project names
477        let names: Vec<Value> = projects
478            .iter()
479            .filter_map(|p| p.get("name").and_then(|n| n.as_str()))
480            .map(|n| Value::String(n.to_string()))
481            .collect();
482        summary.insert("project_names".to_string(), Value::Array(names));
483    }
484
485    // Extract languages (ProjectAnalysis flat structure)
486    if let Some(languages) = data.get("languages").and_then(|v| v.as_array()) {
487        let names: Vec<Value> = languages
488            .iter()
489            .filter_map(|l| l.get("name").and_then(|n| n.as_str()))
490            .map(|n| Value::String(n.to_string()))
491            .collect();
492        summary.insert("languages".to_string(), Value::Array(names));
493    }
494
495    // Extract technologies (ProjectAnalysis flat structure)
496    if let Some(techs) = data.get("technologies").and_then(|v| v.as_array()) {
497        let names: Vec<Value> = techs
498            .iter()
499            .filter_map(|t| t.get("name").and_then(|n| n.as_str()))
500            .map(|n| Value::String(n.to_string()))
501            .collect();
502        summary.insert("technologies".to_string(), Value::Array(names));
503    }
504
505    // Extract services (ProjectAnalysis flat structure) - include names, not just count
506    if let Some(services) = data.get("services").and_then(|v| v.as_array()) {
507        summary.insert("services_count".to_string(), Value::Number(services.len().into()));
508        // Include service names so agent knows what microservices exist
509        let service_names: Vec<Value> = services
510            .iter()
511            .filter_map(|s| s.get("name").and_then(|n| n.as_str()))
512            .map(|n| Value::String(n.to_string()))
513            .collect();
514        if !service_names.is_empty() {
515            summary.insert("services".to_string(), Value::Array(service_names));
516        }
517    }
518
519    Value::Object(summary)
520}
521
522/// Extract list of projects with basic info (no file arrays)
523fn extract_projects_list(data: &Value) -> Value {
524    let projects = data.get("projects").and_then(|v| v.as_array());
525
526    let list: Vec<Value> = projects
527        .map(|arr| {
528            arr.iter()
529                .map(|p| {
530                    let mut proj = serde_json::Map::new();
531                    if let Some(name) = p.get("name") {
532                        proj.insert("name".to_string(), name.clone());
533                    }
534                    if let Some(path) = p.get("path") {
535                        proj.insert("path".to_string(), path.clone());
536                    }
537                    if let Some(cat) = p.get("project_category") {
538                        proj.insert("category".to_string(), cat.clone());
539                    }
540                    // Add language/framework counts
541                    if let Some(analysis) = p.get("analysis") {
542                        if let Some(langs) = analysis.get("languages").and_then(|v| v.as_array()) {
543                            let lang_names: Vec<Value> = langs
544                                .iter()
545                                .filter_map(|l| l.get("name").and_then(|n| n.as_str()))
546                                .map(|n| Value::String(n.to_string()))
547                                .collect();
548                            proj.insert("languages".to_string(), Value::Array(lang_names));
549                        }
550                        if let Some(fws) = analysis.get("frameworks").and_then(|v| v.as_array()) {
551                            let fw_names: Vec<Value> = fws
552                                .iter()
553                                .filter_map(|f| f.get("name").and_then(|n| n.as_str()))
554                                .map(|n| Value::String(n.to_string()))
555                                .collect();
556                            proj.insert("frameworks".to_string(), Value::Array(fw_names));
557                        }
558                    }
559                    Value::Object(proj)
560                })
561                .collect()
562        })
563        .unwrap_or_default();
564
565    serde_json::json!({
566        "total_projects": list.len(),
567        "projects": list
568    })
569}
570
571/// Extract specific project by name
572fn extract_project_by_name(data: &Value, name: &str) -> Option<Value> {
573    let projects = data.get("projects").and_then(|v| v.as_array())?;
574
575    let project = projects.iter().find(|p| {
576        p.get("name")
577            .and_then(|n| n.as_str())
578            .map(|n| n.to_lowercase().contains(&name.to_lowercase()))
579            .unwrap_or(false)
580    })?;
581
582    Some(compact_project(project))
583}
584
585/// Extract specific service by name
586fn extract_service_by_name(data: &Value, name: &str) -> Option<Value> {
587    let projects = data.get("projects").and_then(|v| v.as_array())?;
588
589    for project in projects {
590        if let Some(services) = project
591            .get("analysis")
592            .and_then(|a| a.get("services"))
593            .and_then(|s| s.as_array())
594        {
595            if let Some(service) = services.iter().find(|s| {
596                s.get("name")
597                    .and_then(|n| n.as_str())
598                    .map(|n| n.to_lowercase().contains(&name.to_lowercase()))
599                    .unwrap_or(false)
600            }) {
601                return Some(service.clone());
602            }
603        }
604    }
605    None
606}
607
608/// Extract language detection details (with file count instead of file list)
609fn extract_language_details(data: &Value, lang_name: &str) -> Option<Value> {
610    let mut results = Vec::new();
611
612    // Helper to process a languages array
613    let process_languages = |languages: &[Value], proj_name: &str, results: &mut Vec<Value>| {
614        for lang in languages {
615            let name = lang.get("name").and_then(|n| n.as_str()).unwrap_or("");
616            if lang_name == "*" || name.to_lowercase().contains(&lang_name.to_lowercase()) {
617                let mut compact_lang = serde_json::Map::new();
618                if !proj_name.is_empty() {
619                    compact_lang.insert("project".to_string(), Value::String(proj_name.to_string()));
620                }
621                compact_lang.insert("name".to_string(), lang.get("name").cloned().unwrap_or(Value::Null));
622                compact_lang.insert("version".to_string(), lang.get("version").cloned().unwrap_or(Value::Null));
623                compact_lang.insert("confidence".to_string(), lang.get("confidence").cloned().unwrap_or(Value::Null));
624
625                // Replace file array with count
626                if let Some(files) = lang.get("files").and_then(|f| f.as_array()) {
627                    compact_lang.insert("file_count".to_string(), Value::Number(files.len().into()));
628                }
629
630                results.push(Value::Object(compact_lang));
631            }
632        }
633    };
634
635    // Handle ProjectAnalysis flat structure (languages at top level)
636    if let Some(languages) = data.get("languages").and_then(|v| v.as_array()) {
637        process_languages(languages, "", &mut results);
638    }
639
640    // Handle MonorepoAnalysis structure (languages nested in projects)
641    if let Some(projects) = data.get("projects").and_then(|v| v.as_array()) {
642        for project in projects {
643            let proj_name = project
644                .get("name")
645                .and_then(|n| n.as_str())
646                .unwrap_or("unknown");
647
648            if let Some(languages) = project
649                .get("analysis")
650                .and_then(|a| a.get("languages"))
651                .and_then(|l| l.as_array())
652            {
653                process_languages(languages, proj_name, &mut results);
654            }
655        }
656    }
657
658    Some(serde_json::json!({
659        "query": format!("language:{}", lang_name),
660        "total_matches": results.len(),
661        "results": results
662    }))
663}
664
665/// Extract framework/technology details
666fn extract_framework_details(data: &Value, fw_name: &str) -> Option<Value> {
667    let mut results = Vec::new();
668
669    // Helper to process a frameworks/technologies array
670    let process_techs = |techs: &[Value], proj_name: &str, results: &mut Vec<Value>| {
671        for tech in techs {
672            let name = tech.get("name").and_then(|n| n.as_str()).unwrap_or("");
673            if fw_name == "*" || name.to_lowercase().contains(&fw_name.to_lowercase()) {
674                let mut compact_fw = serde_json::Map::new();
675                if !proj_name.is_empty() {
676                    compact_fw.insert("project".to_string(), Value::String(proj_name.to_string()));
677                }
678                if let Some(v) = tech.get("name") {
679                    compact_fw.insert("name".to_string(), v.clone());
680                }
681                if let Some(v) = tech.get("version") {
682                    compact_fw.insert("version".to_string(), v.clone());
683                }
684                if let Some(v) = tech.get("category") {
685                    compact_fw.insert("category".to_string(), v.clone());
686                }
687                results.push(Value::Object(compact_fw));
688            }
689        }
690    };
691
692    // Handle ProjectAnalysis flat structure (technologies at top level)
693    if let Some(techs) = data.get("technologies").and_then(|v| v.as_array()) {
694        process_techs(techs, "", &mut results);
695    }
696
697    // Also check frameworks field (deprecated but may exist)
698    if let Some(fws) = data.get("frameworks").and_then(|v| v.as_array()) {
699        process_techs(fws, "", &mut results);
700    }
701
702    // Handle MonorepoAnalysis structure (frameworks nested in projects)
703    if let Some(projects) = data.get("projects").and_then(|v| v.as_array()) {
704        for project in projects {
705            let proj_name = project
706                .get("name")
707                .and_then(|n| n.as_str())
708                .unwrap_or("unknown");
709
710            if let Some(frameworks) = project
711                .get("analysis")
712                .and_then(|a| a.get("frameworks"))
713                .and_then(|f| f.as_array())
714            {
715                process_techs(frameworks, proj_name, &mut results);
716            }
717        }
718    }
719
720    Some(serde_json::json!({
721        "query": format!("framework:{}", fw_name),
722        "total_matches": results.len(),
723        "results": results
724    }))
725}
726
727/// Extract all frameworks across all projects
728fn extract_all_frameworks(data: &Value) -> Value {
729    extract_framework_details(data, "*").unwrap_or(serde_json::json!({"results": []}))
730}
731
732/// Extract all languages across all projects
733fn extract_all_languages(data: &Value) -> Value {
734    extract_language_details(data, "*").unwrap_or(serde_json::json!({"results": []}))
735}
736
737/// Extract all services across all projects
738/// In a monorepo, projects ARE services - so we return projects data
739fn extract_all_services(data: &Value) -> Value {
740    // In monorepos, projects = services. Return projects list as services.
741    // This is because the `services` field in ProjectAnalysis was never implemented.
742    extract_projects_list(data)
743}
744
745/// Compact entire analyze_project output (strip file arrays)
746fn compact_analyze_output(data: &Value) -> Value {
747    let mut result = serde_json::Map::new();
748
749    // Handle MonorepoAnalysis structure
750    if let Some(v) = data.get("root_path") {
751        result.insert("root_path".to_string(), v.clone());
752    }
753    if let Some(v) = data.get("is_monorepo") {
754        result.insert("is_monorepo".to_string(), v.clone());
755    }
756
757    // Compact projects (MonorepoAnalysis)
758    if let Some(projects) = data.get("projects").and_then(|v| v.as_array()) {
759        let compacted: Vec<Value> = projects.iter().map(|p| compact_project(p)).collect();
760        result.insert("projects".to_string(), Value::Array(compacted));
761        return Value::Object(result);
762    }
763
764    // Handle ProjectAnalysis flat structure
765    if let Some(v) = data.get("project_root") {
766        result.insert("project_root".to_string(), v.clone());
767    }
768    if let Some(v) = data.get("architecture_type") {
769        result.insert("architecture_type".to_string(), v.clone());
770    }
771    if let Some(v) = data.get("project_type") {
772        result.insert("project_type".to_string(), v.clone());
773    }
774
775    // Compact languages (replace files array with count)
776    if let Some(languages) = data.get("languages").and_then(|v| v.as_array()) {
777        let compacted: Vec<Value> = languages
778            .iter()
779            .map(|lang| {
780                let mut compact_lang = serde_json::Map::new();
781                for key in &["name", "version", "confidence"] {
782                    if let Some(v) = lang.get(*key) {
783                        compact_lang.insert(key.to_string(), v.clone());
784                    }
785                }
786                // Replace files array with count
787                if let Some(files) = lang.get("files").and_then(|f| f.as_array()) {
788                    compact_lang.insert("file_count".to_string(), Value::Number(files.len().into()));
789                }
790                Value::Object(compact_lang)
791            })
792            .collect();
793        result.insert("languages".to_string(), Value::Array(compacted));
794    }
795
796    // Include technologies (usually not huge)
797    if let Some(techs) = data.get("technologies").and_then(|v| v.as_array()) {
798        let compacted: Vec<Value> = techs
799            .iter()
800            .map(|tech| {
801                let mut compact_tech = serde_json::Map::new();
802                for key in &["name", "version", "category", "confidence"] {
803                    if let Some(v) = tech.get(*key) {
804                        compact_tech.insert(key.to_string(), v.clone());
805                    }
806                }
807                Value::Object(compact_tech)
808            })
809            .collect();
810        result.insert("technologies".to_string(), Value::Array(compacted));
811    }
812
813    // Include services (usually small)
814    if let Some(services) = data.get("services").and_then(|v| v.as_array()) {
815        result.insert("services".to_string(), Value::Array(services.clone()));
816    }
817
818    // Include analysis_metadata
819    if let Some(meta) = data.get("analysis_metadata") {
820        result.insert("analysis_metadata".to_string(), meta.clone());
821    }
822
823    Value::Object(result)
824}
825
826/// Compact a single project (strip file arrays, replace with counts)
827fn compact_project(project: &Value) -> Value {
828    let mut compact = serde_json::Map::new();
829
830    // Copy basic fields
831    for key in &["name", "path", "project_category"] {
832        if let Some(v) = project.get(*key) {
833            compact.insert(key.to_string(), v.clone());
834        }
835    }
836
837    // Compact analysis
838    if let Some(analysis) = project.get("analysis") {
839        let mut compact_analysis = serde_json::Map::new();
840
841        // Copy project_root
842        if let Some(v) = analysis.get("project_root") {
843            compact_analysis.insert("project_root".to_string(), v.clone());
844        }
845
846        // Compact languages (strip files, add file_count)
847        if let Some(languages) = analysis.get("languages").and_then(|v| v.as_array()) {
848            let compacted: Vec<Value> = languages
849                .iter()
850                .map(|lang| {
851                    let mut compact_lang = serde_json::Map::new();
852                    for key in &["name", "version", "confidence"] {
853                        if let Some(v) = lang.get(*key) {
854                            compact_lang.insert(key.to_string(), v.clone());
855                        }
856                    }
857                    // Replace files array with count
858                    if let Some(files) = lang.get("files").and_then(|f| f.as_array()) {
859                        compact_lang.insert("file_count".to_string(), Value::Number(files.len().into()));
860                    }
861                    Value::Object(compact_lang)
862                })
863                .collect();
864            compact_analysis.insert("languages".to_string(), Value::Array(compacted));
865        }
866
867        // Copy frameworks, databases, services as-is (usually not huge)
868        for key in &["frameworks", "databases", "services", "build_tools", "package_managers"] {
869            if let Some(v) = analysis.get(*key) {
870                compact_analysis.insert(key.to_string(), v.clone());
871            }
872        }
873
874        compact.insert("analysis".to_string(), Value::Object(compact_analysis));
875    }
876
877    Value::Object(compact)
878}
879
880/// List all stored outputs
881pub fn list_outputs() -> Vec<OutputInfo> {
882    let dir = match ensure_output_dir() {
883        Ok(d) => d,
884        Err(_) => return Vec::new(),
885    };
886
887    let mut outputs = Vec::new();
888
889    if let Ok(entries) = fs::read_dir(&dir) {
890        for entry in entries.flatten() {
891            if let Some(filename) = entry.file_name().to_str() {
892                if filename.ends_with(".json") {
893                    let ref_id = filename.trim_end_matches(".json").to_string();
894
895                    // Read metadata
896                    if let Ok(content) = fs::read_to_string(entry.path()) {
897                        if let Ok(stored) = serde_json::from_str::<Value>(&content) {
898                            let tool = stored
899                                .get("tool")
900                                .and_then(|v| v.as_str())
901                                .unwrap_or("unknown")
902                                .to_string();
903                            let timestamp = stored
904                                .get("timestamp")
905                                .and_then(|v| v.as_u64())
906                                .unwrap_or(0);
907                            let size = content.len();
908
909                            outputs.push(OutputInfo {
910                                ref_id,
911                                tool,
912                                timestamp,
913                                size_bytes: size,
914                            });
915                        }
916                    }
917                }
918            }
919        }
920    }
921
922    // Sort by timestamp (newest first)
923    outputs.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
924    outputs
925}
926
927/// Information about a stored output
928#[derive(Debug, Clone)]
929pub struct OutputInfo {
930    pub ref_id: String,
931    pub tool: String,
932    pub timestamp: u64,
933    pub size_bytes: usize,
934}
935
936/// Clean up old stored outputs
937pub fn cleanup_old_outputs() {
938    let dir = match ensure_output_dir() {
939        Ok(d) => d,
940        Err(_) => return,
941    };
942
943    let now = SystemTime::now()
944        .duration_since(UNIX_EPOCH)
945        .map(|d| d.as_secs())
946        .unwrap_or(0);
947
948    if let Ok(entries) = fs::read_dir(&dir) {
949        for entry in entries.flatten() {
950            if let Ok(content) = fs::read_to_string(entry.path()) {
951                if let Ok(stored) = serde_json::from_str::<Value>(&content) {
952                    let timestamp = stored
953                        .get("timestamp")
954                        .and_then(|v| v.as_u64())
955                        .unwrap_or(0);
956
957                    if now - timestamp > MAX_AGE_SECS {
958                        let _ = fs::remove_file(entry.path());
959                    }
960                }
961            }
962        }
963    }
964}
965
966#[cfg(test)]
967mod tests {
968    use super::*;
969
970    #[test]
971    fn test_store_and_retrieve() {
972        let data = serde_json::json!({
973            "issues": [
974                { "code": "test1", "severity": "high", "file": "test.yaml" }
975            ]
976        });
977
978        let ref_id = store_output(&data, "test_tool");
979        assert!(ref_id.starts_with("test_tool_"));
980
981        let retrieved = retrieve_output(&ref_id);
982        assert!(retrieved.is_some());
983        assert_eq!(retrieved.unwrap(), data);
984    }
985
986    #[test]
987    fn test_filtered_retrieval() {
988        let data = serde_json::json!({
989            "issues": [
990                { "code": "DL3008", "severity": "warning", "file": "Dockerfile1" },
991                { "code": "DL3009", "severity": "info", "file": "Dockerfile2" },
992                { "code": "DL3008", "severity": "warning", "file": "Dockerfile3" }
993            ]
994        });
995
996        let ref_id = store_output(&data, "filter_test");
997
998        // Filter by code
999        let filtered = retrieve_filtered(&ref_id, Some("code:DL3008"));
1000        assert!(filtered.is_some());
1001        let results = filtered.unwrap();
1002        assert_eq!(results["total_matches"], 2);
1003
1004        // Filter by severity
1005        let filtered = retrieve_filtered(&ref_id, Some("severity:info"));
1006        assert!(filtered.is_some());
1007        let results = filtered.unwrap();
1008        assert_eq!(results["total_matches"], 1);
1009    }
1010
1011    #[test]
1012    fn test_parse_query() {
1013        assert_eq!(
1014            parse_query("severity:critical"),
1015            ("severity".to_string(), "critical".to_string())
1016        );
1017        assert_eq!(
1018            parse_query("searchterm"),
1019            ("any".to_string(), "searchterm".to_string())
1020        );
1021    }
1022
1023    #[test]
1024    fn test_analyze_project_detection() {
1025        let analyze_data = serde_json::json!({
1026            "root_path": "/test",
1027            "is_monorepo": true,
1028            "projects": []
1029        });
1030        assert!(is_analyze_project_output(&analyze_data));
1031
1032        let lint_data = serde_json::json!({
1033            "issues": [{ "code": "DL3008" }]
1034        });
1035        assert!(!is_analyze_project_output(&lint_data));
1036    }
1037
1038    #[test]
1039    fn test_analyze_project_summary() {
1040        let data = serde_json::json!({
1041            "root_path": "/test/monorepo",
1042            "is_monorepo": true,
1043            "projects": [
1044                { "name": "api-gateway", "path": "services/api" },
1045                { "name": "web-app", "path": "apps/web" }
1046            ]
1047        });
1048
1049        let summary = extract_summary(&data);
1050        assert_eq!(summary["root_path"], "/test/monorepo");
1051        assert_eq!(summary["is_monorepo"], true);
1052        assert_eq!(summary["project_count"], 2);
1053    }
1054
1055    #[test]
1056    fn test_analyze_project_compact() {
1057        // Simulates massive analyze_project output with 1000s of files
1058        let files: Vec<String> = (0..1000).map(|i| format!("/src/file{}.ts", i)).collect();
1059
1060        let data = serde_json::json!({
1061            "root_path": "/test",
1062            "is_monorepo": false,
1063            "projects": [{
1064                "name": "test-project",
1065                "path": "",
1066                "project_category": "Api",
1067                "analysis": {
1068                    "project_root": "/test",
1069                    "languages": [{
1070                        "name": "TypeScript",
1071                        "version": "5.0",
1072                        "confidence": 0.95,
1073                        "files": files
1074                    }],
1075                    "frameworks": [{
1076                        "name": "React",
1077                        "version": "18.0"
1078                    }]
1079                }
1080            }]
1081        });
1082
1083        let ref_id = store_output(&data, "analyze_project_test");
1084
1085        // Default retrieval should return compacted output
1086        let result = retrieve_filtered(&ref_id, None);
1087        assert!(result.is_some());
1088
1089        let compacted = result.unwrap();
1090
1091        // Verify files array was replaced with file_count
1092        let project = &compacted["projects"][0];
1093        let lang = &project["analysis"]["languages"][0];
1094        assert_eq!(lang["name"], "TypeScript");
1095        assert_eq!(lang["file_count"], 1000);
1096        assert!(lang.get("files").is_none()); // No files array
1097
1098        // The compacted JSON should be much smaller
1099        let compacted_str = serde_json::to_string(&compacted).unwrap();
1100        let original_str = serde_json::to_string(&data).unwrap();
1101        assert!(compacted_str.len() < original_str.len() / 10); // At least 10x smaller
1102    }
1103
1104    #[test]
1105    fn test_analyze_project_section_queries() {
1106        let data = serde_json::json!({
1107            "root_path": "/test",
1108            "is_monorepo": true,
1109            "projects": [{
1110                "name": "api-service",
1111                "path": "services/api",
1112                "project_category": "Api",
1113                "analysis": {
1114                    "languages": [{
1115                        "name": "Go",
1116                        "version": "1.21",
1117                        "confidence": 0.9,
1118                        "files": ["/main.go", "/handler.go"]
1119                    }],
1120                    "frameworks": [{
1121                        "name": "Gin",
1122                        "version": "1.9",
1123                        "category": "Web"
1124                    }],
1125                    "services": [{
1126                        "name": "api-http",
1127                        "type": "http",
1128                        "port": 8080
1129                    }]
1130                }
1131            }]
1132        });
1133
1134        let ref_id = store_output(&data, "analyze_query_test");
1135
1136        // Test section:projects
1137        let projects = retrieve_filtered(&ref_id, Some("section:projects"));
1138        assert!(projects.is_some());
1139        assert_eq!(projects.as_ref().unwrap()["total_projects"], 1);
1140
1141        // Test section:frameworks
1142        let frameworks = retrieve_filtered(&ref_id, Some("section:frameworks"));
1143        assert!(frameworks.is_some());
1144        assert_eq!(frameworks.as_ref().unwrap()["total_matches"], 1);
1145        assert_eq!(frameworks.as_ref().unwrap()["results"][0]["name"], "Gin");
1146
1147        // Test section:languages
1148        let languages = retrieve_filtered(&ref_id, Some("section:languages"));
1149        assert!(languages.is_some());
1150        assert_eq!(languages.as_ref().unwrap()["total_matches"], 1);
1151        assert_eq!(languages.as_ref().unwrap()["results"][0]["name"], "Go");
1152        // Files should be replaced with count
1153        assert_eq!(languages.as_ref().unwrap()["results"][0]["file_count"], 2);
1154
1155        // Test language:Go specific query
1156        let go = retrieve_filtered(&ref_id, Some("language:Go"));
1157        assert!(go.is_some());
1158        assert_eq!(go.as_ref().unwrap()["total_matches"], 1);
1159
1160        // Test framework:Gin specific query
1161        let gin = retrieve_filtered(&ref_id, Some("framework:Gin"));
1162        assert!(gin.is_some());
1163        assert_eq!(gin.as_ref().unwrap()["total_matches"], 1);
1164    }
1165}