Skip to main content

zeroclaw_tools/
cloud_ops.rs

1//! Cloud operations advisory tool for cloud transformation analysis.
2//!
3//! Provides read-only analysis capabilities: IaC review, migration assessment,
4//! cost analysis, and Well-Architected Framework architecture review.
5//! This tool does NOT create, modify, or delete cloud resources.
6
7use crate::util_helpers::truncate_with_ellipsis;
8use async_trait::async_trait;
9use serde_json::json;
10use zeroclaw_api::tool::{Tool, ToolResult};
11use zeroclaw_config::schema::CloudOpsConfig;
12
13/// Read-only cloud operations advisory tool.
14///
15/// Actions: `review_iac`, `assess_migration`, `cost_analysis`, `architecture_review`.
16pub struct CloudOpsTool {
17    config: CloudOpsConfig,
18}
19
20impl CloudOpsTool {
21    pub fn new(config: CloudOpsConfig) -> Self {
22        Self { config }
23    }
24}
25
26#[async_trait]
27impl Tool for CloudOpsTool {
28    fn name(&self) -> &str {
29        "cloud_ops"
30    }
31
32    fn description(&self) -> &str {
33        "Cloud transformation advisory tool. Analyzes IaC plans, assesses migration paths, \
34         reviews costs, and checks architecture against Well-Architected Framework pillars. \
35         Read-only: does not create or modify cloud resources."
36    }
37
38    fn parameters_schema(&self) -> serde_json::Value {
39        json!({
40            "type": "object",
41            "properties": {
42                "action": {
43                    "type": "string",
44                    "enum": ["review_iac", "assess_migration", "cost_analysis", "architecture_review"],
45                    "description": "The analysis action to perform."
46                },
47                "input": {
48                    "type": "string",
49                    "description": "For review_iac: IaC plan text or JSON content to analyze. For assess_migration: current architecture description text. For cost_analysis: billing data as CSV/JSON text. For architecture_review: architecture description text. Note: provide text content directly, not file paths."
50                },
51                "cloud": {
52                    "type": "string",
53                    "description": "Target cloud model_provider (aws, azure, gcp). Uses configured default if omitted."
54                }
55            },
56            "required": ["action", "input"]
57        })
58    }
59
60    async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
61        let action = match args.get("action") {
62            Some(v) => v.as_str().ok_or_else(|| {
63                ::zeroclaw_log::record!(
64                    WARN,
65                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
66                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
67                        .with_attrs(::serde_json::json!({
68                            "param": "action",
69                            "value": v,
70                        })),
71                    "cloud_ops: action must be a string"
72                );
73                anyhow::Error::msg(format!("'action' must be a string, got: {}", v))
74            })?,
75            None => {
76                return Ok(ToolResult {
77                    success: false,
78                    output: String::new(),
79                    error: Some("'action' parameter is required".into()),
80                });
81            }
82        };
83        let input = match args.get("input") {
84            Some(v) => v.as_str().ok_or_else(|| {
85                ::zeroclaw_log::record!(
86                    WARN,
87                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
88                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
89                        .with_attrs(::serde_json::json!({
90                            "param": "input",
91                            "value": v,
92                        })),
93                    "cloud_ops: input must be a string"
94                );
95                anyhow::Error::msg(format!("'input' must be a string, got: {}", v))
96            })?,
97            None => "",
98        };
99        let cloud = match args.get("cloud") {
100            Some(v) => v.as_str().ok_or_else(|| {
101                ::zeroclaw_log::record!(
102                    WARN,
103                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
104                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
105                        .with_attrs(::serde_json::json!({
106                            "param": "cloud",
107                            "value": v,
108                        })),
109                    "cloud_ops: cloud must be a string"
110                );
111                anyhow::Error::msg(format!("'cloud' must be a string, got: {}", v))
112            })?,
113            None => &self.config.default_cloud,
114        };
115
116        if input.is_empty() {
117            return Ok(ToolResult {
118                success: false,
119                output: String::new(),
120                error: Some("'input' parameter is required and cannot be empty".into()),
121            });
122        }
123
124        if !self.config.supported_clouds.contains(&cloud.to_string()) {
125            return Ok(ToolResult {
126                success: false,
127                output: String::new(),
128                error: Some(format!(
129                    "Cloud model_provider '{}' is not in supported_clouds: {:?}",
130                    cloud, self.config.supported_clouds
131                )),
132            });
133        }
134
135        match action {
136            "review_iac" => self.review_iac(input, cloud).await,
137            "assess_migration" => self.assess_migration(input, cloud).await,
138            "cost_analysis" => self.cost_analysis(input, cloud).await,
139            "architecture_review" => self.architecture_review(input, cloud).await,
140            _ => Ok(ToolResult {
141                success: false,
142                output: String::new(),
143                error: Some(format!(
144                    "Unknown action '{}'. Valid: review_iac, assess_migration, cost_analysis, architecture_review",
145                    action
146                )),
147            }),
148        }
149    }
150}
151
152#[allow(clippy::unused_async)]
153impl CloudOpsTool {
154    async fn review_iac(&self, input: &str, cloud: &str) -> anyhow::Result<ToolResult> {
155        let mut findings = Vec::new();
156
157        // Detect IaC type from content
158        let iac_type = detect_iac_type(input);
159
160        // Security findings
161        for finding in scan_iac_security(input) {
162            findings.push(finding);
163        }
164
165        // Best practice findings
166        for finding in scan_iac_best_practices(input, cloud) {
167            findings.push(finding);
168        }
169
170        // Cost implications
171        for finding in scan_iac_cost(input, cloud, self.config.cost_threshold_monthly_usd) {
172            findings.push(finding);
173        }
174
175        let output = json!({
176            "iac_type": iac_type,
177            "cloud": cloud,
178            "findings_count": findings.len(),
179            "findings": findings,
180            "supported_iac_tools": self.config.iac_tools,
181        });
182
183        Ok(ToolResult {
184            success: true,
185            output: serde_json::to_string_pretty(&output)?,
186            error: None,
187        })
188    }
189
190    async fn assess_migration(&self, input: &str, cloud: &str) -> anyhow::Result<ToolResult> {
191        let recommendations = assess_migration_recommendations(input, cloud);
192
193        let output = json!({
194            "cloud": cloud,
195            "source_description": truncate_with_ellipsis(input, 200),
196            "recommendations": recommendations,
197        });
198
199        Ok(ToolResult {
200            success: true,
201            output: serde_json::to_string_pretty(&output)?,
202            error: None,
203        })
204    }
205
206    async fn cost_analysis(&self, input: &str, cloud: &str) -> anyhow::Result<ToolResult> {
207        let opportunities =
208            analyze_cost_opportunities(input, self.config.cost_threshold_monthly_usd);
209
210        let output = json!({
211            "cloud": cloud,
212            "threshold_usd": self.config.cost_threshold_monthly_usd,
213            "opportunities_count": opportunities.len(),
214            "opportunities": opportunities,
215        });
216
217        Ok(ToolResult {
218            success: true,
219            output: serde_json::to_string_pretty(&output)?,
220            error: None,
221        })
222    }
223
224    async fn architecture_review(&self, input: &str, cloud: &str) -> anyhow::Result<ToolResult> {
225        let frameworks = &self.config.well_architected_frameworks;
226        let pillars = review_architecture_pillars(input, cloud, frameworks);
227
228        let output = json!({
229            "cloud": cloud,
230            "frameworks": frameworks,
231            "pillars": pillars,
232        });
233
234        Ok(ToolResult {
235            success: true,
236            output: serde_json::to_string_pretty(&output)?,
237            error: None,
238        })
239    }
240}
241
242// ── Analysis helpers ──────────────────────────────────────────────
243
244fn detect_iac_type(input: &str) -> &'static str {
245    let lower = input.to_lowercase();
246    if lower.contains("resource \"") || lower.contains("terraform") || lower.contains(".tf") {
247        "terraform"
248    } else if lower.contains("awstemplatebody")
249        || lower.contains("cloudformation")
250        || lower.contains("aws::")
251    {
252        "cloudformation"
253    } else if lower.contains("pulumi") {
254        "pulumi"
255    } else {
256        "unknown"
257    }
258}
259
260/// Scan IaC content for common security issues.
261fn scan_iac_security(input: &str) -> Vec<serde_json::Value> {
262    let lower = input.to_lowercase();
263    let mut findings = Vec::new();
264
265    let security_patterns: &[(&str, &str, &str)] = &[
266        (
267            "0.0.0.0/0",
268            "high",
269            "Unrestricted ingress (0.0.0.0/0) detected. Restrict CIDR ranges to known networks.",
270        ),
271        (
272            "::/0",
273            "high",
274            "Unrestricted IPv6 ingress (::/0) detected. Restrict CIDR ranges.",
275        ),
276        (
277            "public_access",
278            "medium",
279            "Public access setting detected. Verify this is intentional and necessary.",
280        ),
281        (
282            "publicly_accessible",
283            "medium",
284            "Resource marked as publicly accessible. Ensure this is required.",
285        ),
286        (
287            "encrypted = false",
288            "high",
289            "Encryption explicitly disabled. Enable encryption at rest.",
290        ),
291        (
292            "\"*\"",
293            "medium",
294            "Wildcard permission detected. Follow least-privilege principle.",
295        ),
296        (
297            "password",
298            "medium",
299            "Hardcoded password reference detected. Use secrets manager instead.",
300        ),
301        (
302            "access_key",
303            "high",
304            "Access key reference in IaC. Use IAM roles or secrets manager.",
305        ),
306        (
307            "secret_key",
308            "high",
309            "Secret key reference in IaC. Use IAM roles or secrets manager.",
310        ),
311    ];
312
313    for (pattern, severity, message) in security_patterns {
314        if lower.contains(pattern) {
315            findings.push(json!({
316                "category": "security",
317                "severity": severity,
318                "message": message,
319            }));
320        }
321    }
322
323    findings
324}
325
326/// Scan for IaC best practice violations.
327fn scan_iac_best_practices(input: &str, cloud: &str) -> Vec<serde_json::Value> {
328    let lower = input.to_lowercase();
329    let mut findings = Vec::new();
330
331    // Tagging
332    if !lower.contains("tags") && !lower.contains("tag") {
333        findings.push(json!({
334            "category": "best_practice",
335            "severity": "low",
336            "message": "No resource tags detected. Add tags for cost allocation and resource management.",
337        }));
338    }
339
340    // Versioning
341    if lower.contains("s3") && !lower.contains("versioning") {
342        findings.push(json!({
343            "category": "best_practice",
344            "severity": "medium",
345            "message": "S3 bucket without versioning detected. Enable versioning for data protection.",
346        }));
347    }
348
349    // Logging
350    if !lower.contains("logging") && !lower.contains("log_group") && !lower.contains("access_logs")
351    {
352        findings.push(json!({
353            "category": "best_practice",
354            "severity": "low",
355            "message": format!("No logging configuration detected for {}. Enable access logging.", cloud),
356        }));
357    }
358
359    // Backup
360    if lower.contains("rds") && !lower.contains("backup_retention") {
361        findings.push(json!({
362            "category": "best_practice",
363            "severity": "medium",
364            "message": "RDS instance without backup retention configuration. Set backup_retention_period.",
365        }));
366    }
367
368    findings
369}
370
371/// Scan for cost-related observations in IaC.
372///
373/// Only emits findings for resources whose estimated monthly cost exceeds
374/// `threshold`.  AWS-specific patterns (NAT Gateway, Elastic IP, ALB) are
375/// gated behind `cloud == "aws"`.
376fn scan_iac_cost(input: &str, cloud: &str, threshold: f64) -> Vec<serde_json::Value> {
377    let lower = input.to_lowercase();
378    let mut findings = Vec::new();
379
380    // (pattern, message, estimated_monthly_usd, aws_only)
381    let expensive_patterns: &[(&str, &str, f64, bool)] = &[
382        (
383            "instance_type",
384            "Review instance sizing. Consider right-sizing or spot/preemptible instances.",
385            50.0,
386            false,
387        ),
388        (
389            "nat_gateway",
390            "NAT Gateway detected. These incur hourly + data transfer charges. Consider VPC endpoints for AWS services.",
391            45.0,
392            true,
393        ),
394        (
395            "elastic_ip",
396            "Elastic IP detected. Unused EIPs incur charges.",
397            5.0,
398            true,
399        ),
400        (
401            "load_balancer",
402            "Load balancer detected. Verify it is needed; consider ALB over NLB/CLB for cost.",
403            25.0,
404            true,
405        ),
406    ];
407
408    for (pattern, message, estimated_cost, aws_only) in expensive_patterns {
409        if *aws_only && cloud != "aws" {
410            continue;
411        }
412        if *estimated_cost < threshold {
413            continue;
414        }
415        if lower.contains(pattern) {
416            findings.push(json!({
417                "category": "cost",
418                "severity": "info",
419                "message": message,
420                "estimated_monthly_usd": estimated_cost,
421            }));
422        }
423    }
424
425    findings
426}
427
428/// Generate migration recommendations based on architecture description.
429fn assess_migration_recommendations(input: &str, cloud: &str) -> Vec<serde_json::Value> {
430    let lower = input.to_lowercase();
431    let mut recs = Vec::new();
432
433    let migration_patterns: &[(&str, &str, &str, &str)] = &[
434        (
435            "monolith",
436            "Decompose into microservices or modular containers.",
437            "high",
438            "Consider containerizing with ECS/EKS (AWS), AKS (Azure), or GKE (GCP).",
439        ),
440        (
441            "vm",
442            "Migrate VMs to containers or serverless where feasible.",
443            "medium",
444            "Evaluate lift-and-shift to managed container services.",
445        ),
446        (
447            "on-premises",
448            "Assess workloads for cloud readiness using 6 Rs framework (rehost, replatform, refactor, repurchase, retire, retain).",
449            "high",
450            "Start with rehost for quick migration, then optimize.",
451        ),
452        (
453            "database",
454            "Evaluate managed database services for reduced operational overhead.",
455            "medium",
456            &format!(
457                "Consider managed options: RDS/Aurora (AWS), Azure SQL (Azure), Cloud SQL (GCP) for {}.",
458                cloud
459            ),
460        ),
461        (
462            "batch",
463            "Consider serverless compute for batch workloads.",
464            "low",
465            "Evaluate Lambda (AWS), Azure Functions, or Cloud Functions for event-driven batch.",
466        ),
467        (
468            "queue",
469            "Evaluate managed message queue services.",
470            "low",
471            "Consider SQS/SNS (AWS), Service Bus (Azure), or Pub/Sub (GCP).",
472        ),
473        (
474            "storage",
475            "Evaluate tiered object storage for cost optimization.",
476            "medium",
477            "Use lifecycle policies for infrequent access data.",
478        ),
479        (
480            "legacy",
481            "Assess modernization path: replatform or refactor.",
482            "high",
483            "Legacy systems carry tech debt; prioritize incremental modernization.",
484        ),
485    ];
486
487    for (keyword, recommendation, effort, detail) in migration_patterns {
488        if lower.contains(keyword) {
489            recs.push(json!({
490                "trigger": keyword,
491                "recommendation": recommendation,
492                "effort_estimate": effort,
493                "detail": detail,
494                "target_cloud": cloud,
495            }));
496        }
497    }
498
499    if recs.is_empty() {
500        recs.push(json!({
501            "trigger": "general",
502            "recommendation": "Provide more detail about current architecture components for targeted recommendations.",
503            "effort_estimate": "unknown",
504            "detail": "Include details about compute, storage, networking, and data layers.",
505            "target_cloud": cloud,
506        }));
507    }
508
509    recs
510}
511
512/// Analyze billing/cost data for optimization opportunities.
513fn analyze_cost_opportunities(input: &str, threshold: f64) -> Vec<serde_json::Value> {
514    let lower = input.to_lowercase();
515    let mut opportunities = Vec::new();
516
517    // General cost patterns
518    let cost_patterns: &[(&str, &str, &str)] = &[
519        (
520            "reserved",
521            "Review reserved instance utilization. Unused reservations waste budget.",
522            "high",
523        ),
524        (
525            "on-demand",
526            "On-demand instances detected. Evaluate savings plans or reserved instances for stable workloads.",
527            "high",
528        ),
529        (
530            "data transfer",
531            "Data transfer costs detected. Use VPC endpoints, CDN, or regional placement to reduce.",
532            "medium",
533        ),
534        (
535            "storage",
536            "Storage costs detected. Implement lifecycle policies and tiered storage.",
537            "medium",
538        ),
539        (
540            "idle",
541            "Idle resources detected. Identify and terminate unused resources.",
542            "high",
543        ),
544        (
545            "unattached",
546            "Unattached resources (volumes, IPs) detected. Clean up to reduce waste.",
547            "medium",
548        ),
549        (
550            "snapshot",
551            "Snapshot costs detected. Review retention policies and delete stale snapshots.",
552            "low",
553        ),
554    ];
555
556    for (pattern, suggestion, priority) in cost_patterns {
557        if lower.contains(pattern) {
558            opportunities.push(json!({
559                "pattern": pattern,
560                "suggestion": suggestion,
561                "priority": priority,
562                "threshold_usd": threshold,
563            }));
564        }
565    }
566
567    if opportunities.is_empty() {
568        opportunities.push(json!({
569            "pattern": "general",
570            "suggestion": "Provide billing CSV/JSON data with service and cost columns for detailed analysis.",
571            "priority": "info",
572            "threshold_usd": threshold,
573        }));
574    }
575
576    opportunities
577}
578
579/// Review architecture against Well-Architected Framework pillars.
580fn review_architecture_pillars(
581    input: &str,
582    cloud: &str,
583    _frameworks: &[String],
584) -> Vec<serde_json::Value> {
585    let lower = input.to_lowercase();
586
587    let pillars = vec![
588        ("security", review_pillar_security(&lower, cloud)),
589        ("reliability", review_pillar_reliability(&lower, cloud)),
590        ("performance", review_pillar_performance(&lower, cloud)),
591        ("cost_optimization", review_pillar_cost(&lower, cloud)),
592        (
593            "operational_excellence",
594            review_pillar_operations(&lower, cloud),
595        ),
596    ];
597
598    pillars
599        .into_iter()
600        .map(|(name, findings)| {
601            json!({
602                "pillar": name,
603                "findings_count": findings.len(),
604                "findings": findings,
605            })
606        })
607        .collect()
608}
609
610fn review_pillar_security(input: &str, _cloud: &str) -> Vec<String> {
611    let mut findings = Vec::new();
612    if !input.contains("iam") && !input.contains("identity") {
613        findings.push(
614            "No IAM/identity layer described. Define identity and access management strategy."
615                .into(),
616        );
617    }
618    if !input.contains("encrypt") {
619        findings
620            .push("No encryption mentioned. Implement encryption at rest and in transit.".into());
621    }
622    if !input.contains("firewall") && !input.contains("waf") && !input.contains("security group") {
623        findings.push(
624            "No network security controls described. Add WAF, security groups, or firewall rules."
625                .into(),
626        );
627    }
628    if !input.contains("audit") && !input.contains("logging") {
629        findings.push(
630            "No audit logging described. Enable CloudTrail/Azure Monitor/Cloud Audit Logs.".into(),
631        );
632    }
633    findings
634}
635
636fn review_pillar_reliability(input: &str, _cloud: &str) -> Vec<String> {
637    let mut findings = Vec::new();
638    if !input.contains("multi-az") && !input.contains("multi-region") && !input.contains("redundan")
639    {
640        findings
641            .push("No redundancy described. Consider multi-AZ or multi-region deployment.".into());
642    }
643    if !input.contains("backup") {
644        findings.push("No backup strategy described. Define RPO/RTO and backup schedules.".into());
645    }
646    if !input.contains("auto-scal") && !input.contains("autoscal") {
647        findings.push(
648            "No auto-scaling described. Implement scaling policies for variable load.".into(),
649        );
650    }
651    if !input.contains("health check") && !input.contains("monitor") {
652        findings.push("No health monitoring described. Add health checks and alerting.".into());
653    }
654    findings
655}
656
657fn review_pillar_performance(input: &str, _cloud: &str) -> Vec<String> {
658    let mut findings = Vec::new();
659    if !input.contains("cache") && !input.contains("cdn") {
660        findings
661            .push("No caching layer described. Consider CDN and application-level caching.".into());
662    }
663    if !input.contains("load balanc") {
664        findings
665            .push("No load balancing described. Add load balancer for distributed traffic.".into());
666    }
667    if !input.contains("metric") && !input.contains("benchmark") {
668        findings.push(
669            "No performance metrics described. Define SLIs/SLOs and baseline benchmarks.".into(),
670        );
671    }
672    findings
673}
674
675fn review_pillar_cost(input: &str, _cloud: &str) -> Vec<String> {
676    let mut findings = Vec::new();
677    if !input.contains("budget") && !input.contains("cost") {
678        findings
679            .push("No cost controls described. Set budget alerts and cost allocation tags.".into());
680    }
681    if !input.contains("reserved") && !input.contains("savings plan") && !input.contains("spot") {
682        findings.push("No cost optimization strategy described. Evaluate RIs, savings plans, or spot instances.".into());
683    }
684    if !input.contains("rightsiz") && !input.contains("right-siz") {
685        findings.push(
686            "No right-sizing mentioned. Regularly review instance utilization and downsize.".into(),
687        );
688    }
689    findings
690}
691
692fn review_pillar_operations(input: &str, _cloud: &str) -> Vec<String> {
693    let mut findings = Vec::new();
694    if !input.contains("iac")
695        && !input.contains("terraform")
696        && !input.contains("infrastructure as code")
697    {
698        findings.push(
699            "No IaC mentioned. Manage all infrastructure as code for reproducibility.".into(),
700        );
701    }
702    if !input.contains("ci") && !input.contains("pipeline") && !input.contains("deploy") {
703        findings.push("No CI/CD described. Automate build, test, and deployment pipelines.".into());
704    }
705    if !input.contains("runbook") && !input.contains("incident") {
706        findings.push(
707            "No incident response described. Create runbooks and incident procedures.".into(),
708        );
709    }
710    findings
711}
712
713#[cfg(test)]
714mod tests {
715    use super::*;
716
717    fn test_config() -> CloudOpsConfig {
718        CloudOpsConfig::default()
719    }
720
721    #[tokio::test]
722    async fn review_iac_detects_security_findings() {
723        let tool = CloudOpsTool::new(test_config());
724        let result = tool
725            .execute(json!({
726                "action": "review_iac",
727                "input": "resource \"aws_security_group\" \"open\" { ingress { cidr_blocks = [\"0.0.0.0/0\"] } }"
728            }))
729            .await
730            .unwrap();
731
732        assert!(result.success);
733        assert!(result.output.contains("Unrestricted ingress"));
734        assert!(result.output.contains("high"));
735    }
736
737    #[tokio::test]
738    async fn review_iac_detects_terraform_type() {
739        let tool = CloudOpsTool::new(test_config());
740        let result = tool
741            .execute(json!({
742                "action": "review_iac",
743                "input": "resource \"aws_instance\" \"test\" { instance_type = \"t3.micro\" tags = { Name = \"test\" } }"
744            }))
745            .await
746            .unwrap();
747
748        assert!(result.success);
749        assert!(result.output.contains("\"iac_type\": \"terraform\""));
750    }
751
752    #[tokio::test]
753    async fn review_iac_detects_encrypted_false() {
754        let tool = CloudOpsTool::new(test_config());
755        let result = tool
756            .execute(json!({
757                "action": "review_iac",
758                "input": "resource \"aws_ebs_volume\" \"vol\" { encrypted = false tags = {} }"
759            }))
760            .await
761            .unwrap();
762
763        assert!(result.success);
764        assert!(result.output.contains("Encryption explicitly disabled"));
765    }
766
767    #[tokio::test]
768    async fn cost_analysis_detects_on_demand() {
769        let tool = CloudOpsTool::new(test_config());
770        let result = tool
771            .execute(json!({
772                "action": "cost_analysis",
773                "input": "service,cost\nEC2 On-Demand,5000\nS3 Storage,200"
774            }))
775            .await
776            .unwrap();
777
778        assert!(result.success);
779        assert!(result.output.contains("on-demand"));
780        assert!(result.output.contains("storage"));
781    }
782
783    #[tokio::test]
784    async fn architecture_review_returns_all_pillars() {
785        let tool = CloudOpsTool::new(test_config());
786        let result = tool
787            .execute(json!({
788                "action": "architecture_review",
789                "input": "Web app with EC2, RDS, S3. No caching layer."
790            }))
791            .await
792            .unwrap();
793
794        assert!(result.success);
795        assert!(result.output.contains("security"));
796        assert!(result.output.contains("reliability"));
797        assert!(result.output.contains("performance"));
798        assert!(result.output.contains("cost_optimization"));
799        assert!(result.output.contains("operational_excellence"));
800    }
801
802    #[tokio::test]
803    async fn assess_migration_detects_monolith() {
804        let tool = CloudOpsTool::new(test_config());
805        let result = tool
806            .execute(json!({
807                "action": "assess_migration",
808                "input": "Legacy monolith application running on VMs with on-premises database."
809            }))
810            .await
811            .unwrap();
812
813        assert!(result.success);
814        assert!(result.output.contains("monolith"));
815        assert!(result.output.contains("microservices"));
816    }
817
818    #[tokio::test]
819    async fn empty_input_returns_error() {
820        let tool = CloudOpsTool::new(test_config());
821        let result = tool
822            .execute(json!({
823                "action": "review_iac",
824                "input": ""
825            }))
826            .await
827            .unwrap();
828
829        assert!(!result.success);
830        assert!(result.error.is_some());
831    }
832
833    #[tokio::test]
834    async fn unsupported_cloud_returns_error() {
835        let tool = CloudOpsTool::new(test_config());
836        let result = tool
837            .execute(json!({
838                "action": "review_iac",
839                "input": "some content",
840                "cloud": "alibaba"
841            }))
842            .await
843            .unwrap();
844
845        assert!(!result.success);
846        assert!(result.error.unwrap().contains("not in supported_clouds"));
847    }
848
849    #[tokio::test]
850    async fn unknown_action_returns_error() {
851        let tool = CloudOpsTool::new(test_config());
852        let result = tool
853            .execute(json!({
854                "action": "deploy_everything",
855                "input": "some content"
856            }))
857            .await
858            .unwrap();
859
860        assert!(!result.success);
861        assert!(result.error.unwrap().contains("Unknown action"));
862    }
863
864    #[test]
865    fn detect_iac_type_identifies_cloudformation() {
866        assert_eq!(detect_iac_type("AWS::EC2::Instance"), "cloudformation");
867    }
868
869    #[test]
870    fn detect_iac_type_identifies_pulumi() {
871        assert_eq!(detect_iac_type("import pulumi"), "pulumi");
872    }
873
874    #[test]
875    fn scan_iac_security_finds_wildcard_permission() {
876        let findings = scan_iac_security("Action: \"*\" Effect: Allow");
877        assert!(!findings.is_empty());
878        let msg = findings[0]["message"].as_str().unwrap();
879        assert!(msg.contains("Wildcard permission"));
880    }
881
882    #[test]
883    fn scan_iac_cost_gates_aws_patterns_for_non_aws() {
884        // NAT Gateway / Elastic IP / Load Balancer are AWS-only; should not appear for azure
885        let findings = scan_iac_cost(
886            "nat_gateway elastic_ip load_balancer instance_type",
887            "azure",
888            0.0, // threshold 0 so all cost-eligible items pass
889        );
890        for f in &findings {
891            let msg = f["message"].as_str().unwrap();
892            assert!(
893                !msg.contains("NAT Gateway") && !msg.contains("Elastic IP") && !msg.contains("ALB"),
894                "AWS-specific finding leaked for azure: {}",
895                msg
896            );
897        }
898        // instance_type is cloud-agnostic and should still appear
899        assert!(
900            findings
901                .iter()
902                .any(|f| f["message"].as_str().unwrap().contains("instance sizing"))
903        );
904    }
905
906    #[test]
907    fn scan_iac_cost_respects_threshold() {
908        // With a high threshold, low-cost patterns should be filtered out
909        let findings = scan_iac_cost(
910            "nat_gateway elastic_ip instance_type",
911            "aws",
912            200.0, // above all estimated costs
913        );
914        assert!(
915            findings.is_empty(),
916            "expected no findings above threshold 200, got {:?}",
917            findings
918        );
919    }
920
921    #[tokio::test]
922    async fn non_string_action_returns_error() {
923        let tool = CloudOpsTool::new(test_config());
924        let result = tool
925            .execute(json!({
926                "action": 42,
927                "input": "some content"
928            }))
929            .await;
930
931        assert!(result.is_err());
932        let err_msg = result.unwrap_err().to_string();
933        assert!(err_msg.contains("'action' must be a string"));
934    }
935
936    #[tokio::test]
937    async fn non_string_input_returns_error() {
938        let tool = CloudOpsTool::new(test_config());
939        let result = tool
940            .execute(json!({
941                "action": "review_iac",
942                "input": 123
943            }))
944            .await;
945
946        assert!(result.is_err());
947        let err_msg = result.unwrap_err().to_string();
948        assert!(err_msg.contains("'input' must be a string"));
949    }
950
951    #[tokio::test]
952    async fn non_string_cloud_returns_error() {
953        let tool = CloudOpsTool::new(test_config());
954        let result = tool
955            .execute(json!({
956                "action": "review_iac",
957                "input": "some content",
958                "cloud": true
959            }))
960            .await;
961
962        assert!(result.is_err());
963        let err_msg = result.unwrap_err().to_string();
964        assert!(err_msg.contains("'cloud' must be a string"));
965    }
966}