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        _ => {
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(
470            "architecture_type".to_string(),
471            Value::String(arch.to_string()),
472        );
473    }
474
475    // Count projects (MonorepoAnalysis)
476    if let Some(projects) = data.get("projects").and_then(|v| v.as_array()) {
477        summary.insert(
478            "project_count".to_string(),
479            Value::Number(projects.len().into()),
480        );
481
482        // Extract project names
483        let names: Vec<Value> = projects
484            .iter()
485            .filter_map(|p| p.get("name").and_then(|n| n.as_str()))
486            .map(|n| Value::String(n.to_string()))
487            .collect();
488        summary.insert("project_names".to_string(), Value::Array(names));
489    }
490
491    // Extract languages (ProjectAnalysis flat structure)
492    if let Some(languages) = data.get("languages").and_then(|v| v.as_array()) {
493        let names: Vec<Value> = languages
494            .iter()
495            .filter_map(|l| l.get("name").and_then(|n| n.as_str()))
496            .map(|n| Value::String(n.to_string()))
497            .collect();
498        summary.insert("languages".to_string(), Value::Array(names));
499    }
500
501    // Extract technologies (ProjectAnalysis flat structure)
502    if let Some(techs) = data.get("technologies").and_then(|v| v.as_array()) {
503        let names: Vec<Value> = techs
504            .iter()
505            .filter_map(|t| t.get("name").and_then(|n| n.as_str()))
506            .map(|n| Value::String(n.to_string()))
507            .collect();
508        summary.insert("technologies".to_string(), Value::Array(names));
509    }
510
511    // Extract services (ProjectAnalysis flat structure) - include names, not just count
512    if let Some(services) = data.get("services").and_then(|v| v.as_array()) {
513        summary.insert(
514            "services_count".to_string(),
515            Value::Number(services.len().into()),
516        );
517        // Include service names so agent knows what microservices exist
518        let service_names: Vec<Value> = services
519            .iter()
520            .filter_map(|s| s.get("name").and_then(|n| n.as_str()))
521            .map(|n| Value::String(n.to_string()))
522            .collect();
523        if !service_names.is_empty() {
524            summary.insert("services".to_string(), Value::Array(service_names));
525        }
526    }
527
528    Value::Object(summary)
529}
530
531/// Extract list of projects with basic info (no file arrays)
532fn extract_projects_list(data: &Value) -> Value {
533    let projects = data.get("projects").and_then(|v| v.as_array());
534
535    let list: Vec<Value> = projects
536        .map(|arr| {
537            arr.iter()
538                .map(|p| {
539                    let mut proj = serde_json::Map::new();
540                    if let Some(name) = p.get("name") {
541                        proj.insert("name".to_string(), name.clone());
542                    }
543                    if let Some(path) = p.get("path") {
544                        proj.insert("path".to_string(), path.clone());
545                    }
546                    if let Some(cat) = p.get("project_category") {
547                        proj.insert("category".to_string(), cat.clone());
548                    }
549                    // Add language/framework counts
550                    if let Some(analysis) = p.get("analysis") {
551                        if let Some(langs) = analysis.get("languages").and_then(|v| v.as_array()) {
552                            let lang_names: Vec<Value> = langs
553                                .iter()
554                                .filter_map(|l| l.get("name").and_then(|n| n.as_str()))
555                                .map(|n| Value::String(n.to_string()))
556                                .collect();
557                            proj.insert("languages".to_string(), Value::Array(lang_names));
558                        }
559                        if let Some(fws) = analysis.get("frameworks").and_then(|v| v.as_array()) {
560                            let fw_names: Vec<Value> = fws
561                                .iter()
562                                .filter_map(|f| f.get("name").and_then(|n| n.as_str()))
563                                .map(|n| Value::String(n.to_string()))
564                                .collect();
565                            proj.insert("frameworks".to_string(), Value::Array(fw_names));
566                        }
567                    }
568                    Value::Object(proj)
569                })
570                .collect()
571        })
572        .unwrap_or_default();
573
574    serde_json::json!({
575        "total_projects": list.len(),
576        "projects": list
577    })
578}
579
580/// Extract specific project by name
581fn extract_project_by_name(data: &Value, name: &str) -> Option<Value> {
582    let projects = data.get("projects").and_then(|v| v.as_array())?;
583
584    let project = projects.iter().find(|p| {
585        p.get("name")
586            .and_then(|n| n.as_str())
587            .map(|n| n.to_lowercase().contains(&name.to_lowercase()))
588            .unwrap_or(false)
589    })?;
590
591    Some(compact_project(project))
592}
593
594/// Extract specific service by name
595fn extract_service_by_name(data: &Value, name: &str) -> Option<Value> {
596    let projects = data.get("projects").and_then(|v| v.as_array())?;
597
598    for project in projects {
599        if let Some(services) = project
600            .get("analysis")
601            .and_then(|a| a.get("services"))
602            .and_then(|s| s.as_array())
603            && let Some(service) = services.iter().find(|s| {
604                s.get("name")
605                    .and_then(|n| n.as_str())
606                    .map(|n| n.to_lowercase().contains(&name.to_lowercase()))
607                    .unwrap_or(false)
608            })
609        {
610            return Some(service.clone());
611        }
612    }
613    None
614}
615
616/// Extract language detection details (with file count instead of file list)
617fn extract_language_details(data: &Value, lang_name: &str) -> Option<Value> {
618    let mut results = Vec::new();
619
620    // Helper to process a languages array
621    let process_languages = |languages: &[Value], proj_name: &str, results: &mut Vec<Value>| {
622        for lang in languages {
623            let name = lang.get("name").and_then(|n| n.as_str()).unwrap_or("");
624            if lang_name == "*" || name.to_lowercase().contains(&lang_name.to_lowercase()) {
625                let mut compact_lang = serde_json::Map::new();
626                if !proj_name.is_empty() {
627                    compact_lang
628                        .insert("project".to_string(), Value::String(proj_name.to_string()));
629                }
630                compact_lang.insert(
631                    "name".to_string(),
632                    lang.get("name").cloned().unwrap_or(Value::Null),
633                );
634                compact_lang.insert(
635                    "version".to_string(),
636                    lang.get("version").cloned().unwrap_or(Value::Null),
637                );
638                compact_lang.insert(
639                    "confidence".to_string(),
640                    lang.get("confidence").cloned().unwrap_or(Value::Null),
641                );
642
643                // Replace file array with count
644                if let Some(files) = lang.get("files").and_then(|f| f.as_array()) {
645                    compact_lang
646                        .insert("file_count".to_string(), Value::Number(files.len().into()));
647                }
648
649                results.push(Value::Object(compact_lang));
650            }
651        }
652    };
653
654    // Handle ProjectAnalysis flat structure (languages at top level)
655    if let Some(languages) = data.get("languages").and_then(|v| v.as_array()) {
656        process_languages(languages, "", &mut results);
657    }
658
659    // Handle MonorepoAnalysis structure (languages nested in projects)
660    if let Some(projects) = data.get("projects").and_then(|v| v.as_array()) {
661        for project in projects {
662            let proj_name = project
663                .get("name")
664                .and_then(|n| n.as_str())
665                .unwrap_or("unknown");
666
667            if let Some(languages) = project
668                .get("analysis")
669                .and_then(|a| a.get("languages"))
670                .and_then(|l| l.as_array())
671            {
672                process_languages(languages, proj_name, &mut results);
673            }
674        }
675    }
676
677    Some(serde_json::json!({
678        "query": format!("language:{}", lang_name),
679        "total_matches": results.len(),
680        "results": results
681    }))
682}
683
684/// Extract framework/technology details
685fn extract_framework_details(data: &Value, fw_name: &str) -> Option<Value> {
686    let mut results = Vec::new();
687
688    // Helper to process a frameworks/technologies array
689    let process_techs = |techs: &[Value], proj_name: &str, results: &mut Vec<Value>| {
690        for tech in techs {
691            let name = tech.get("name").and_then(|n| n.as_str()).unwrap_or("");
692            if fw_name == "*" || name.to_lowercase().contains(&fw_name.to_lowercase()) {
693                let mut compact_fw = serde_json::Map::new();
694                if !proj_name.is_empty() {
695                    compact_fw.insert("project".to_string(), Value::String(proj_name.to_string()));
696                }
697                if let Some(v) = tech.get("name") {
698                    compact_fw.insert("name".to_string(), v.clone());
699                }
700                if let Some(v) = tech.get("version") {
701                    compact_fw.insert("version".to_string(), v.clone());
702                }
703                if let Some(v) = tech.get("category") {
704                    compact_fw.insert("category".to_string(), v.clone());
705                }
706                results.push(Value::Object(compact_fw));
707            }
708        }
709    };
710
711    // Handle ProjectAnalysis flat structure (technologies at top level)
712    if let Some(techs) = data.get("technologies").and_then(|v| v.as_array()) {
713        process_techs(techs, "", &mut results);
714    }
715
716    // Also check frameworks field (deprecated but may exist)
717    if let Some(fws) = data.get("frameworks").and_then(|v| v.as_array()) {
718        process_techs(fws, "", &mut results);
719    }
720
721    // Handle MonorepoAnalysis structure (frameworks nested in projects)
722    if let Some(projects) = data.get("projects").and_then(|v| v.as_array()) {
723        for project in projects {
724            let proj_name = project
725                .get("name")
726                .and_then(|n| n.as_str())
727                .unwrap_or("unknown");
728
729            if let Some(frameworks) = project
730                .get("analysis")
731                .and_then(|a| a.get("frameworks"))
732                .and_then(|f| f.as_array())
733            {
734                process_techs(frameworks, proj_name, &mut results);
735            }
736        }
737    }
738
739    Some(serde_json::json!({
740        "query": format!("framework:{}", fw_name),
741        "total_matches": results.len(),
742        "results": results
743    }))
744}
745
746/// Extract all frameworks across all projects
747fn extract_all_frameworks(data: &Value) -> Value {
748    extract_framework_details(data, "*").unwrap_or(serde_json::json!({"results": []}))
749}
750
751/// Extract all languages across all projects
752fn extract_all_languages(data: &Value) -> Value {
753    extract_language_details(data, "*").unwrap_or(serde_json::json!({"results": []}))
754}
755
756/// Extract all services across all projects
757/// In a monorepo, projects ARE services - so we return projects data
758fn extract_all_services(data: &Value) -> Value {
759    // In monorepos, projects = services. Return projects list as services.
760    // This is because the `services` field in ProjectAnalysis was never implemented.
761    extract_projects_list(data)
762}
763
764/// Compact entire analyze_project output (strip file arrays)
765fn compact_analyze_output(data: &Value) -> Value {
766    let mut result = serde_json::Map::new();
767
768    // Handle MonorepoAnalysis structure
769    if let Some(v) = data.get("root_path") {
770        result.insert("root_path".to_string(), v.clone());
771    }
772    if let Some(v) = data.get("is_monorepo") {
773        result.insert("is_monorepo".to_string(), v.clone());
774    }
775
776    // Compact projects (MonorepoAnalysis)
777    if let Some(projects) = data.get("projects").and_then(|v| v.as_array()) {
778        let compacted: Vec<Value> = projects.iter().map(compact_project).collect();
779        result.insert("projects".to_string(), Value::Array(compacted));
780        return Value::Object(result);
781    }
782
783    // Handle ProjectAnalysis flat structure
784    if let Some(v) = data.get("project_root") {
785        result.insert("project_root".to_string(), v.clone());
786    }
787    if let Some(v) = data.get("architecture_type") {
788        result.insert("architecture_type".to_string(), v.clone());
789    }
790    if let Some(v) = data.get("project_type") {
791        result.insert("project_type".to_string(), v.clone());
792    }
793
794    // Compact languages (replace files array with count)
795    if let Some(languages) = data.get("languages").and_then(|v| v.as_array()) {
796        let compacted: Vec<Value> = languages
797            .iter()
798            .map(|lang| {
799                let mut compact_lang = serde_json::Map::new();
800                for key in &["name", "version", "confidence"] {
801                    if let Some(v) = lang.get(*key) {
802                        compact_lang.insert(key.to_string(), v.clone());
803                    }
804                }
805                // Replace files array with count
806                if let Some(files) = lang.get("files").and_then(|f| f.as_array()) {
807                    compact_lang
808                        .insert("file_count".to_string(), Value::Number(files.len().into()));
809                }
810                Value::Object(compact_lang)
811            })
812            .collect();
813        result.insert("languages".to_string(), Value::Array(compacted));
814    }
815
816    // Include technologies (usually not huge)
817    if let Some(techs) = data.get("technologies").and_then(|v| v.as_array()) {
818        let compacted: Vec<Value> = techs
819            .iter()
820            .map(|tech| {
821                let mut compact_tech = serde_json::Map::new();
822                for key in &["name", "version", "category", "confidence"] {
823                    if let Some(v) = tech.get(*key) {
824                        compact_tech.insert(key.to_string(), v.clone());
825                    }
826                }
827                Value::Object(compact_tech)
828            })
829            .collect();
830        result.insert("technologies".to_string(), Value::Array(compacted));
831    }
832
833    // Include services (usually small)
834    if let Some(services) = data.get("services").and_then(|v| v.as_array()) {
835        result.insert("services".to_string(), Value::Array(services.clone()));
836    }
837
838    // Include analysis_metadata
839    if let Some(meta) = data.get("analysis_metadata") {
840        result.insert("analysis_metadata".to_string(), meta.clone());
841    }
842
843    Value::Object(result)
844}
845
846/// Compact a single project (strip file arrays, replace with counts)
847fn compact_project(project: &Value) -> Value {
848    let mut compact = serde_json::Map::new();
849
850    // Copy basic fields
851    for key in &["name", "path", "project_category"] {
852        if let Some(v) = project.get(*key) {
853            compact.insert(key.to_string(), v.clone());
854        }
855    }
856
857    // Compact analysis
858    if let Some(analysis) = project.get("analysis") {
859        let mut compact_analysis = serde_json::Map::new();
860
861        // Copy project_root
862        if let Some(v) = analysis.get("project_root") {
863            compact_analysis.insert("project_root".to_string(), v.clone());
864        }
865
866        // Compact languages (strip files, add file_count)
867        if let Some(languages) = analysis.get("languages").and_then(|v| v.as_array()) {
868            let compacted: Vec<Value> = languages
869                .iter()
870                .map(|lang| {
871                    let mut compact_lang = serde_json::Map::new();
872                    for key in &["name", "version", "confidence"] {
873                        if let Some(v) = lang.get(*key) {
874                            compact_lang.insert(key.to_string(), v.clone());
875                        }
876                    }
877                    // Replace files array with count
878                    if let Some(files) = lang.get("files").and_then(|f| f.as_array()) {
879                        compact_lang
880                            .insert("file_count".to_string(), Value::Number(files.len().into()));
881                    }
882                    Value::Object(compact_lang)
883                })
884                .collect();
885            compact_analysis.insert("languages".to_string(), Value::Array(compacted));
886        }
887
888        // Copy frameworks, databases, services as-is (usually not huge)
889        for key in &[
890            "frameworks",
891            "databases",
892            "services",
893            "build_tools",
894            "package_managers",
895        ] {
896            if let Some(v) = analysis.get(*key) {
897                compact_analysis.insert(key.to_string(), v.clone());
898            }
899        }
900
901        compact.insert("analysis".to_string(), Value::Object(compact_analysis));
902    }
903
904    Value::Object(compact)
905}
906
907/// List all stored outputs
908pub fn list_outputs() -> Vec<OutputInfo> {
909    let dir = match ensure_output_dir() {
910        Ok(d) => d,
911        Err(_) => return Vec::new(),
912    };
913
914    let mut outputs = Vec::new();
915
916    if let Ok(entries) = fs::read_dir(&dir) {
917        for entry in entries.flatten() {
918            if let Some(filename) = entry.file_name().to_str()
919                && filename.ends_with(".json")
920            {
921                let ref_id = filename.trim_end_matches(".json").to_string();
922
923                // Read metadata
924                if let Ok(content) = fs::read_to_string(entry.path())
925                    && let Ok(stored) = serde_json::from_str::<Value>(&content)
926                {
927                    let tool = stored
928                        .get("tool")
929                        .and_then(|v| v.as_str())
930                        .unwrap_or("unknown")
931                        .to_string();
932                    let timestamp = stored
933                        .get("timestamp")
934                        .and_then(|v| v.as_u64())
935                        .unwrap_or(0);
936                    let size = content.len();
937
938                    outputs.push(OutputInfo {
939                        ref_id,
940                        tool,
941                        timestamp,
942                        size_bytes: size,
943                    });
944                }
945            }
946        }
947    }
948
949    // Sort by timestamp (newest first)
950    outputs.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
951    outputs
952}
953
954/// Information about a stored output
955#[derive(Debug, Clone)]
956pub struct OutputInfo {
957    pub ref_id: String,
958    pub tool: String,
959    pub timestamp: u64,
960    pub size_bytes: usize,
961}
962
963/// Clean up old stored outputs
964pub fn cleanup_old_outputs() {
965    let dir = match ensure_output_dir() {
966        Ok(d) => d,
967        Err(_) => return,
968    };
969
970    let now = SystemTime::now()
971        .duration_since(UNIX_EPOCH)
972        .map(|d| d.as_secs())
973        .unwrap_or(0);
974
975    if let Ok(entries) = fs::read_dir(&dir) {
976        for entry in entries.flatten() {
977            if let Ok(content) = fs::read_to_string(entry.path())
978                && let Ok(stored) = serde_json::from_str::<Value>(&content)
979            {
980                let timestamp = stored
981                    .get("timestamp")
982                    .and_then(|v| v.as_u64())
983                    .unwrap_or(0);
984
985                if now - timestamp > MAX_AGE_SECS {
986                    let _ = fs::remove_file(entry.path());
987                }
988            }
989        }
990    }
991}
992
993#[cfg(test)]
994mod tests {
995    use super::*;
996
997    #[test]
998    fn test_store_and_retrieve() {
999        let data = serde_json::json!({
1000            "issues": [
1001                { "code": "test1", "severity": "high", "file": "test.yaml" }
1002            ]
1003        });
1004
1005        let ref_id = store_output(&data, "test_tool");
1006        assert!(ref_id.starts_with("test_tool_"));
1007
1008        let retrieved = retrieve_output(&ref_id);
1009        assert!(retrieved.is_some());
1010        assert_eq!(retrieved.unwrap(), data);
1011    }
1012
1013    #[test]
1014    fn test_filtered_retrieval() {
1015        let data = serde_json::json!({
1016            "issues": [
1017                { "code": "DL3008", "severity": "warning", "file": "Dockerfile1" },
1018                { "code": "DL3009", "severity": "info", "file": "Dockerfile2" },
1019                { "code": "DL3008", "severity": "warning", "file": "Dockerfile3" }
1020            ]
1021        });
1022
1023        let ref_id = store_output(&data, "filter_test");
1024
1025        // Filter by code
1026        let filtered = retrieve_filtered(&ref_id, Some("code:DL3008"));
1027        assert!(filtered.is_some());
1028        let results = filtered.unwrap();
1029        assert_eq!(results["total_matches"], 2);
1030
1031        // Filter by severity
1032        let filtered = retrieve_filtered(&ref_id, Some("severity:info"));
1033        assert!(filtered.is_some());
1034        let results = filtered.unwrap();
1035        assert_eq!(results["total_matches"], 1);
1036    }
1037
1038    #[test]
1039    fn test_parse_query() {
1040        assert_eq!(
1041            parse_query("severity:critical"),
1042            ("severity".to_string(), "critical".to_string())
1043        );
1044        assert_eq!(
1045            parse_query("searchterm"),
1046            ("any".to_string(), "searchterm".to_string())
1047        );
1048    }
1049
1050    #[test]
1051    fn test_analyze_project_detection() {
1052        let analyze_data = serde_json::json!({
1053            "root_path": "/test",
1054            "is_monorepo": true,
1055            "projects": []
1056        });
1057        assert!(is_analyze_project_output(&analyze_data));
1058
1059        let lint_data = serde_json::json!({
1060            "issues": [{ "code": "DL3008" }]
1061        });
1062        assert!(!is_analyze_project_output(&lint_data));
1063    }
1064
1065    #[test]
1066    fn test_analyze_project_summary() {
1067        let data = serde_json::json!({
1068            "root_path": "/test/monorepo",
1069            "is_monorepo": true,
1070            "projects": [
1071                { "name": "api-gateway", "path": "services/api" },
1072                { "name": "web-app", "path": "apps/web" }
1073            ]
1074        });
1075
1076        let summary = extract_summary(&data);
1077        assert_eq!(summary["root_path"], "/test/monorepo");
1078        assert_eq!(summary["is_monorepo"], true);
1079        assert_eq!(summary["project_count"], 2);
1080    }
1081
1082    #[test]
1083    fn test_analyze_project_compact() {
1084        // Simulates massive analyze_project output with 1000s of files
1085        let files: Vec<String> = (0..1000).map(|i| format!("/src/file{}.ts", i)).collect();
1086
1087        let data = serde_json::json!({
1088            "root_path": "/test",
1089            "is_monorepo": false,
1090            "projects": [{
1091                "name": "test-project",
1092                "path": "",
1093                "project_category": "Api",
1094                "analysis": {
1095                    "project_root": "/test",
1096                    "languages": [{
1097                        "name": "TypeScript",
1098                        "version": "5.0",
1099                        "confidence": 0.95,
1100                        "files": files
1101                    }],
1102                    "frameworks": [{
1103                        "name": "React",
1104                        "version": "18.0"
1105                    }]
1106                }
1107            }]
1108        });
1109
1110        let ref_id = store_output(&data, "analyze_project_test");
1111
1112        // Default retrieval should return compacted output
1113        let result = retrieve_filtered(&ref_id, None);
1114        assert!(result.is_some());
1115
1116        let compacted = result.unwrap();
1117
1118        // Verify files array was replaced with file_count
1119        let project = &compacted["projects"][0];
1120        let lang = &project["analysis"]["languages"][0];
1121        assert_eq!(lang["name"], "TypeScript");
1122        assert_eq!(lang["file_count"], 1000);
1123        assert!(lang.get("files").is_none()); // No files array
1124
1125        // The compacted JSON should be much smaller
1126        let compacted_str = serde_json::to_string(&compacted).unwrap();
1127        let original_str = serde_json::to_string(&data).unwrap();
1128        assert!(compacted_str.len() < original_str.len() / 10); // At least 10x smaller
1129    }
1130
1131    #[test]
1132    fn test_analyze_project_section_queries() {
1133        let data = serde_json::json!({
1134            "root_path": "/test",
1135            "is_monorepo": true,
1136            "projects": [{
1137                "name": "api-service",
1138                "path": "services/api",
1139                "project_category": "Api",
1140                "analysis": {
1141                    "languages": [{
1142                        "name": "Go",
1143                        "version": "1.21",
1144                        "confidence": 0.9,
1145                        "files": ["/main.go", "/handler.go"]
1146                    }],
1147                    "frameworks": [{
1148                        "name": "Gin",
1149                        "version": "1.9",
1150                        "category": "Web"
1151                    }],
1152                    "services": [{
1153                        "name": "api-http",
1154                        "type": "http",
1155                        "port": 8080
1156                    }]
1157                }
1158            }]
1159        });
1160
1161        let ref_id = store_output(&data, "analyze_query_test");
1162
1163        // Test section:projects
1164        let projects = retrieve_filtered(&ref_id, Some("section:projects"));
1165        assert!(projects.is_some());
1166        assert_eq!(projects.as_ref().unwrap()["total_projects"], 1);
1167
1168        // Test section:frameworks
1169        let frameworks = retrieve_filtered(&ref_id, Some("section:frameworks"));
1170        assert!(frameworks.is_some());
1171        assert_eq!(frameworks.as_ref().unwrap()["total_matches"], 1);
1172        assert_eq!(frameworks.as_ref().unwrap()["results"][0]["name"], "Gin");
1173
1174        // Test section:languages
1175        let languages = retrieve_filtered(&ref_id, Some("section:languages"));
1176        assert!(languages.is_some());
1177        assert_eq!(languages.as_ref().unwrap()["total_matches"], 1);
1178        assert_eq!(languages.as_ref().unwrap()["results"][0]["name"], "Go");
1179        // Files should be replaced with count
1180        assert_eq!(languages.as_ref().unwrap()["results"][0]["file_count"], 2);
1181
1182        // Test language:Go specific query
1183        let go = retrieve_filtered(&ref_id, Some("language:Go"));
1184        assert!(go.is_some());
1185        assert_eq!(go.as_ref().unwrap()["total_matches"], 1);
1186
1187        // Test framework:Gin specific query
1188        let gin = retrieve_filtered(&ref_id, Some("framework:Gin"));
1189        assert!(gin.is_some());
1190        assert_eq!(gin.as_ref().unwrap()["total_matches"], 1);
1191    }
1192}