Skip to main content

smith_config/
diff.rs

1//! Behavior pack diff analysis engine
2//!
3//! This module provides human-readable analysis of changes between behavior packs,
4//! including capability enablement/disablement, parameter changes, and risk deltas.
5
6use crate::behavior::{BehaviorMode, BehaviorPack};
7use anyhow::Result;
8use serde::{Deserialize, Serialize};
9use std::collections::HashSet;
10
11/// Diff analysis result for behavior pack changes
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct BehaviorPackDiff {
14    /// Comparison metadata
15    pub metadata: DiffMetadata,
16    /// Changes to capability enablement
17    pub capability_changes: CapabilityChanges,
18    /// Parameter changes for capabilities
19    pub parameter_changes: Vec<ParameterChange>,
20    /// Guard configuration changes
21    pub guard_changes: GuardChanges,
22    /// Risk assessment of the changes
23    pub risk_analysis: RiskAnalysis,
24    /// Summary of the changes
25    pub summary: DiffSummary,
26}
27
28/// Metadata about the diff comparison
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct DiffMetadata {
31    /// Name of the original behavior pack
32    pub from_pack: String,
33    /// Name of the new behavior pack
34    pub to_pack: String,
35    /// Original execution mode
36    pub from_mode: BehaviorMode,
37    /// New execution mode
38    pub to_mode: BehaviorMode,
39    /// Timestamp of the comparison
40    pub timestamp: String,
41}
42
43/// Changes to enabled capabilities
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct CapabilityChanges {
46    /// Newly enabled atoms
47    pub atoms_enabled: Vec<String>,
48    /// Newly disabled atoms
49    pub atoms_disabled: Vec<String>,
50    /// Newly enabled macros
51    pub macros_enabled: Vec<String>,
52    /// Newly disabled macros
53    pub macros_disabled: Vec<String>,
54    /// Newly enabled playbooks
55    pub playbooks_enabled: Vec<String>,
56    /// Newly disabled playbooks
57    pub playbooks_disabled: Vec<String>,
58}
59
60/// A change to capability parameters
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct ParameterChange {
63    /// Name of the capability
64    pub capability: String,
65    /// Parameter key that changed
66    pub key: String,
67    /// Previous value (if any)
68    pub old_value: Option<serde_json::Value>,
69    /// New value (if any)
70    pub new_value: Option<serde_json::Value>,
71    /// Type of change
72    pub change_type: ParameterChangeType,
73}
74
75/// Type of parameter change
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
77#[serde(rename_all = "snake_case")]
78pub enum ParameterChangeType {
79    /// Parameter was added
80    Added,
81    /// Parameter was removed
82    Removed,
83    /// Parameter value was modified
84    Modified,
85}
86
87/// Changes to guard configurations
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct GuardChanges {
90    /// Changes to atom guards
91    pub atom_guards: Vec<GuardChange>,
92    /// Changes to macro guards
93    pub macro_guards: Vec<GuardChange>,
94    /// Changes to playbook guards
95    pub playbook_guards: Vec<GuardChange>,
96}
97
98/// A specific guard configuration change
99#[derive(Debug, Clone, Serialize, Deserialize)]
100pub struct GuardChange {
101    /// Name of the guard setting
102    pub setting: String,
103    /// Previous value
104    pub old_value: serde_json::Value,
105    /// New value
106    pub new_value: serde_json::Value,
107    /// Impact assessment
108    pub impact: GuardImpact,
109}
110
111/// Impact level of a guard change
112#[derive(Debug, Clone, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub enum GuardImpact {
115    /// Change makes the system more secure/restrictive
116    Restrictive,
117    /// Change makes the system less secure/more permissive
118    Permissive,
119    /// Change has neutral security impact
120    Neutral,
121}
122
123/// Risk analysis of the behavior pack changes
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct RiskAnalysis {
126    /// Overall risk level
127    pub overall_risk: RiskLevel,
128    /// Security scope changes
129    pub security_scope: ScopeRiskAnalysis,
130    /// Resource limit changes
131    pub resource_limits: ResourceRiskAnalysis,
132    /// Mode transition risk
133    pub mode_risk: ModeRiskAnalysis,
134    /// Silent scope expansion detected
135    pub scope_expansion_detected: bool,
136}
137
138/// Risk levels for changes
139#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
140#[serde(rename_all = "snake_case")]
141pub enum RiskLevel {
142    Low,
143    Medium,
144    High,
145    Critical,
146}
147
148/// Security scope risk analysis
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct ScopeRiskAnalysis {
151    /// File system scope changes
152    pub filesystem_changes: Vec<String>,
153    /// Network scope changes
154    pub network_changes: Vec<String>,
155    /// Risk level for scope changes
156    pub risk_level: RiskLevel,
157}
158
159/// Resource limit risk analysis
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct ResourceRiskAnalysis {
162    /// Memory limit changes
163    pub memory_limit_changes: Vec<String>,
164    /// Time limit changes
165    pub time_limit_changes: Vec<String>,
166    /// Risk level for resource changes
167    pub risk_level: RiskLevel,
168}
169
170/// Mode transition risk analysis
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct ModeRiskAnalysis {
173    /// Risk level of the mode change
174    pub risk_level: RiskLevel,
175    /// Description of the mode change impact
176    pub description: String,
177}
178
179/// High-level summary of the diff
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct DiffSummary {
182    /// Total number of capability changes
183    pub total_capability_changes: usize,
184    /// Total number of parameter changes
185    pub total_parameter_changes: usize,
186    /// Total number of guard changes
187    pub total_guard_changes: usize,
188    /// Whether manual review is recommended
189    pub requires_review: bool,
190    /// Human-readable summary description
191    pub description: String,
192}
193
194impl BehaviorPackDiff {
195    /// Compare two behavior packs and generate a diff analysis
196    pub fn compare(from: &BehaviorPack, to: &BehaviorPack) -> Result<Self> {
197        let metadata = DiffMetadata {
198            from_pack: from.name.clone(),
199            to_pack: to.name.clone(),
200            from_mode: from.mode.clone(),
201            to_mode: to.mode.clone(),
202            timestamp: chrono::Utc::now().to_rfc3339(),
203        };
204
205        let capability_changes = Self::analyze_capability_changes(from, to);
206        let parameter_changes = Self::analyze_parameter_changes(from, to);
207        let guard_changes = Self::analyze_guard_changes(from, to);
208        let risk_analysis = Self::analyze_risk(
209            &capability_changes,
210            &parameter_changes,
211            &guard_changes,
212            &metadata,
213        );
214        let summary = Self::generate_summary(
215            &capability_changes,
216            &parameter_changes,
217            &guard_changes,
218            &risk_analysis,
219        );
220
221        Ok(Self {
222            metadata,
223            capability_changes,
224            parameter_changes,
225            guard_changes,
226            risk_analysis,
227            summary,
228        })
229    }
230
231    /// Analyze changes to capability enablement
232    fn analyze_capability_changes(from: &BehaviorPack, to: &BehaviorPack) -> CapabilityChanges {
233        let from_atoms: HashSet<_> = from.enable.atoms.iter().cloned().collect();
234        let to_atoms: HashSet<_> = to.enable.atoms.iter().cloned().collect();
235
236        let from_macros: HashSet<_> = from.enable.macros.iter().cloned().collect();
237        let to_macros: HashSet<_> = to.enable.macros.iter().cloned().collect();
238
239        let from_playbooks: HashSet<_> = from.enable.playbooks.iter().cloned().collect();
240        let to_playbooks: HashSet<_> = to.enable.playbooks.iter().cloned().collect();
241
242        CapabilityChanges {
243            atoms_enabled: to_atoms.difference(&from_atoms).cloned().collect(),
244            atoms_disabled: from_atoms.difference(&to_atoms).cloned().collect(),
245            macros_enabled: to_macros.difference(&from_macros).cloned().collect(),
246            macros_disabled: from_macros.difference(&to_macros).cloned().collect(),
247            playbooks_enabled: to_playbooks.difference(&from_playbooks).cloned().collect(),
248            playbooks_disabled: from_playbooks.difference(&to_playbooks).cloned().collect(),
249        }
250    }
251
252    /// Analyze changes to capability parameters
253    fn analyze_parameter_changes(from: &BehaviorPack, to: &BehaviorPack) -> Vec<ParameterChange> {
254        let mut changes = Vec::new();
255
256        // Find all capabilities that appear in either config
257        let mut all_capabilities = HashSet::new();
258        all_capabilities.extend(from.params.keys());
259        all_capabilities.extend(to.params.keys());
260
261        for capability in all_capabilities {
262            let from_params = from.params.get(capability);
263            let to_params = to.params.get(capability);
264
265            match (from_params, to_params) {
266                (None, Some(to_val)) => {
267                    // Capability parameters added
268                    if let Some(obj) = to_val.as_object() {
269                        for (key, value) in obj {
270                            changes.push(ParameterChange {
271                                capability: capability.clone(),
272                                key: key.clone(),
273                                old_value: None,
274                                new_value: Some(value.clone()),
275                                change_type: ParameterChangeType::Added,
276                            });
277                        }
278                    }
279                }
280                (Some(_from_val), None) => {
281                    // Capability parameters removed
282                    changes.push(ParameterChange {
283                        capability: capability.clone(),
284                        key: "*".to_string(), // Indicate all parameters removed
285                        old_value: from_params.cloned(),
286                        new_value: None,
287                        change_type: ParameterChangeType::Removed,
288                    });
289                }
290                (Some(from_val), Some(to_val)) => {
291                    // Compare parameter objects
292                    Self::compare_param_objects(capability, from_val, to_val, &mut changes);
293                }
294                (None, None) => {
295                    // This shouldn't happen given our iteration logic
296                }
297            }
298        }
299
300        changes
301    }
302
303    /// Compare parameter objects for a specific capability
304    fn compare_param_objects(
305        capability: &str,
306        from_val: &serde_json::Value,
307        to_val: &serde_json::Value,
308        changes: &mut Vec<ParameterChange>,
309    ) {
310        if let (Some(from_obj), Some(to_obj)) = (from_val.as_object(), to_val.as_object()) {
311            let mut all_keys = HashSet::new();
312            all_keys.extend(from_obj.keys());
313            all_keys.extend(to_obj.keys());
314
315            for key in all_keys {
316                let from_key_val = from_obj.get(key);
317                let to_key_val = to_obj.get(key);
318
319                match (from_key_val, to_key_val) {
320                    (None, Some(new_val)) => {
321                        changes.push(ParameterChange {
322                            capability: capability.to_string(),
323                            key: key.clone(),
324                            old_value: None,
325                            new_value: Some(new_val.clone()),
326                            change_type: ParameterChangeType::Added,
327                        });
328                    }
329                    (Some(old_val), None) => {
330                        changes.push(ParameterChange {
331                            capability: capability.to_string(),
332                            key: key.clone(),
333                            old_value: Some(old_val.clone()),
334                            new_value: None,
335                            change_type: ParameterChangeType::Removed,
336                        });
337                    }
338                    (Some(old_val), Some(new_val)) => {
339                        if old_val != new_val {
340                            changes.push(ParameterChange {
341                                capability: capability.to_string(),
342                                key: key.clone(),
343                                old_value: Some(old_val.clone()),
344                                new_value: Some(new_val.clone()),
345                                change_type: ParameterChangeType::Modified,
346                            });
347                        }
348                    }
349                    (None, None) => {
350                        // This shouldn't happen
351                    }
352                }
353            }
354        }
355    }
356
357    /// Analyze changes to guard configurations
358    fn analyze_guard_changes(from: &BehaviorPack, to: &BehaviorPack) -> GuardChanges {
359        let mut guard_changes = GuardChanges {
360            atom_guards: Vec::new(),
361            macro_guards: Vec::new(),
362            playbook_guards: Vec::new(),
363        };
364
365        // Compare atom guards
366        if let (Some(from_atom_guards), Some(to_atom_guards)) =
367            (&from.guards.atoms, &to.guards.atoms)
368        {
369            if from_atom_guards.default_max_bytes != to_atom_guards.default_max_bytes {
370                guard_changes.atom_guards.push(GuardChange {
371                    setting: "default_max_bytes".to_string(),
372                    old_value: serde_json::json!(from_atom_guards.default_max_bytes),
373                    new_value: serde_json::json!(to_atom_guards.default_max_bytes),
374                    impact: if to_atom_guards.default_max_bytes > from_atom_guards.default_max_bytes
375                    {
376                        GuardImpact::Permissive
377                    } else {
378                        GuardImpact::Restrictive
379                    },
380                });
381            }
382
383            if from_atom_guards.require_justification != to_atom_guards.require_justification {
384                guard_changes.atom_guards.push(GuardChange {
385                    setting: "require_justification".to_string(),
386                    old_value: serde_json::json!(from_atom_guards.require_justification),
387                    new_value: serde_json::json!(to_atom_guards.require_justification),
388                    impact: if to_atom_guards.require_justification {
389                        GuardImpact::Restrictive
390                    } else {
391                        GuardImpact::Permissive
392                    },
393                });
394            }
395        }
396
397        // Compare playbook guards
398        if let (Some(from_pb_guards), Some(to_pb_guards)) =
399            (&from.guards.playbooks, &to.guards.playbooks)
400        {
401            if from_pb_guards.max_steps != to_pb_guards.max_steps {
402                guard_changes.playbook_guards.push(GuardChange {
403                    setting: "max_steps".to_string(),
404                    old_value: serde_json::json!(from_pb_guards.max_steps),
405                    new_value: serde_json::json!(to_pb_guards.max_steps),
406                    impact: if to_pb_guards.max_steps > from_pb_guards.max_steps {
407                        GuardImpact::Permissive
408                    } else {
409                        GuardImpact::Restrictive
410                    },
411                });
412            }
413        }
414
415        guard_changes
416    }
417
418    /// Analyze risk implications of the changes
419    fn analyze_risk(
420        cap_changes: &CapabilityChanges,
421        param_changes: &[ParameterChange],
422        guard_changes: &GuardChanges,
423        metadata: &DiffMetadata,
424    ) -> RiskAnalysis {
425        let mut risk_factors = Vec::new();
426
427        // Mode change risk
428        let mode_risk = Self::assess_mode_risk(&metadata.from_mode, &metadata.to_mode);
429        risk_factors.push(mode_risk.risk_level.clone());
430
431        // Capability expansion risk
432        let total_new_capabilities = cap_changes.atoms_enabled.len()
433            + cap_changes.macros_enabled.len()
434            + cap_changes.playbooks_enabled.len();
435
436        if total_new_capabilities > 0 {
437            risk_factors.push(match total_new_capabilities {
438                1..=2 => RiskLevel::Low,
439                3..=5 => RiskLevel::Medium,
440                _ => RiskLevel::High,
441            });
442        }
443
444        // Silent scope expansion detection
445        let scope_expansion_detected = Self::detect_silent_scope_expansion(param_changes);
446        if scope_expansion_detected {
447            risk_factors.push(RiskLevel::Critical);
448        }
449
450        // Guard permissiveness risk
451        let permissive_guard_changes = guard_changes
452            .atom_guards
453            .iter()
454            .chain(guard_changes.macro_guards.iter())
455            .chain(guard_changes.playbook_guards.iter())
456            .filter(|change| matches!(change.impact, GuardImpact::Permissive))
457            .count();
458
459        if permissive_guard_changes > 0 {
460            risk_factors.push(RiskLevel::Medium);
461        }
462
463        // Determine overall risk
464        let overall_risk = risk_factors.into_iter().max().unwrap_or(RiskLevel::Low);
465
466        RiskAnalysis {
467            overall_risk,
468            security_scope: ScopeRiskAnalysis {
469                filesystem_changes: Self::extract_filesystem_changes(param_changes),
470                network_changes: Self::extract_network_changes(param_changes),
471                risk_level: if scope_expansion_detected {
472                    RiskLevel::High
473                } else {
474                    RiskLevel::Low
475                },
476            },
477            resource_limits: ResourceRiskAnalysis {
478                memory_limit_changes: Self::extract_memory_changes(param_changes, guard_changes),
479                time_limit_changes: Self::extract_time_changes(param_changes),
480                risk_level: RiskLevel::Low, // Conservative for now
481            },
482            mode_risk,
483            scope_expansion_detected,
484        }
485    }
486
487    /// Assess risk of mode changes
488    fn assess_mode_risk(from_mode: &BehaviorMode, to_mode: &BehaviorMode) -> ModeRiskAnalysis {
489        use BehaviorMode::*;
490
491        match (from_mode, to_mode) {
492            (Strict, Explore) => ModeRiskAnalysis {
493                risk_level: RiskLevel::Medium,
494                description: "Transition from strict to explore mode enables direct atom usage"
495                    .to_string(),
496            },
497            (Strict, Shadow) => ModeRiskAnalysis {
498                risk_level: RiskLevel::Low,
499                description: "Transition to shadow mode - no actual execution".to_string(),
500            },
501            (Explore, Strict) => ModeRiskAnalysis {
502                risk_level: RiskLevel::Low,
503                description: "Transition to strict mode - more restrictive".to_string(),
504            },
505            (Explore, Shadow) => ModeRiskAnalysis {
506                risk_level: RiskLevel::Low,
507                description: "Transition to shadow mode - no actual execution".to_string(),
508            },
509            (Shadow, Strict) => ModeRiskAnalysis {
510                risk_level: RiskLevel::Low,
511                description: "Transition from shadow to strict mode".to_string(),
512            },
513            (Shadow, Explore) => ModeRiskAnalysis {
514                risk_level: RiskLevel::Medium,
515                description: "Transition from shadow to explore mode - enables execution"
516                    .to_string(),
517            },
518            (Strict, Strict) | (Explore, Explore) | (Shadow, Shadow) => ModeRiskAnalysis {
519                risk_level: RiskLevel::Low,
520                description: "No mode change".to_string(),
521            },
522        }
523    }
524
525    /// Detect silent scope expansion in parameter changes
526    fn detect_silent_scope_expansion(param_changes: &[ParameterChange]) -> bool {
527        for change in param_changes {
528            match &change.key as &str {
529                "hosts" => {
530                    if let (Some(old_val), Some(new_val)) = (&change.old_value, &change.new_value) {
531                        if Self::array_expanded(old_val, new_val) {
532                            return true;
533                        }
534                    }
535                }
536                "paths" | "allowed_paths" => {
537                    if let (Some(old_val), Some(new_val)) = (&change.old_value, &change.new_value) {
538                        if Self::array_expanded(old_val, new_val) {
539                            return true;
540                        }
541                    }
542                }
543                "max_bytes" | "timeout_ms" => {
544                    if let (Some(old_val), Some(new_val)) = (&change.old_value, &change.new_value) {
545                        if Self::numeric_increased(old_val, new_val) {
546                            return true;
547                        }
548                    }
549                }
550                _ => {}
551            }
552        }
553        false
554    }
555
556    /// Check if an array parameter expanded
557    fn array_expanded(old_val: &serde_json::Value, new_val: &serde_json::Value) -> bool {
558        if let (Some(old_arr), Some(new_arr)) = (old_val.as_array(), new_val.as_array()) {
559            new_arr.len() > old_arr.len()
560        } else {
561            false
562        }
563    }
564
565    /// Check if a numeric parameter increased
566    fn numeric_increased(old_val: &serde_json::Value, new_val: &serde_json::Value) -> bool {
567        if let (Some(old_num), Some(new_num)) = (old_val.as_f64(), new_val.as_f64()) {
568            new_num > old_num
569        } else {
570            false
571        }
572    }
573
574    /// Extract filesystem-related changes from parameters
575    fn extract_filesystem_changes(param_changes: &[ParameterChange]) -> Vec<String> {
576        param_changes
577            .iter()
578            .filter(|change| matches!(change.key.as_str(), "paths" | "allowed_paths" | "max_bytes"))
579            .map(|change| {
580                format!(
581                    "{}.{}: {:?} → {:?}",
582                    change.capability, change.key, change.old_value, change.new_value
583                )
584            })
585            .collect()
586    }
587
588    /// Extract network-related changes from parameters
589    fn extract_network_changes(param_changes: &[ParameterChange]) -> Vec<String> {
590        param_changes
591            .iter()
592            .filter(|change| matches!(change.key.as_str(), "hosts" | "timeout_ms"))
593            .map(|change| {
594                format!(
595                    "{}.{}: {:?} → {:?}",
596                    change.capability, change.key, change.old_value, change.new_value
597                )
598            })
599            .collect()
600    }
601
602    /// Extract memory-related changes
603    fn extract_memory_changes(
604        param_changes: &[ParameterChange],
605        guard_changes: &GuardChanges,
606    ) -> Vec<String> {
607        let mut changes = Vec::new();
608
609        // From parameters
610        for change in param_changes {
611            if change.key.contains("memory") || change.key.contains("max_bytes") {
612                changes.push(format!(
613                    "{}.{}: {:?} → {:?}",
614                    change.capability, change.key, change.old_value, change.new_value
615                ));
616            }
617        }
618
619        // From guards
620        for guard_change in &guard_changes.atom_guards {
621            if guard_change.setting == "default_max_bytes" {
622                changes.push(format!(
623                    "guards.atoms.{}: {:?} → {:?}",
624                    guard_change.setting, guard_change.old_value, guard_change.new_value
625                ));
626            }
627        }
628
629        changes
630    }
631
632    /// Extract time-related changes
633    fn extract_time_changes(param_changes: &[ParameterChange]) -> Vec<String> {
634        param_changes
635            .iter()
636            .filter(|change| change.key.contains("timeout") || change.key.contains("duration"))
637            .map(|change| {
638                format!(
639                    "{}.{}: {:?} → {:?}",
640                    change.capability, change.key, change.old_value, change.new_value
641                )
642            })
643            .collect()
644    }
645
646    /// Generate a high-level summary of the diff
647    fn generate_summary(
648        cap_changes: &CapabilityChanges,
649        param_changes: &[ParameterChange],
650        guard_changes: &GuardChanges,
651        risk_analysis: &RiskAnalysis,
652    ) -> DiffSummary {
653        let total_capability_changes = cap_changes.atoms_enabled.len()
654            + cap_changes.atoms_disabled.len()
655            + cap_changes.macros_enabled.len()
656            + cap_changes.macros_disabled.len()
657            + cap_changes.playbooks_enabled.len()
658            + cap_changes.playbooks_disabled.len();
659
660        let total_guard_changes = guard_changes.atom_guards.len()
661            + guard_changes.macro_guards.len()
662            + guard_changes.playbook_guards.len();
663
664        let requires_review = matches!(
665            risk_analysis.overall_risk,
666            RiskLevel::High | RiskLevel::Critical
667        ) || risk_analysis.scope_expansion_detected
668            || total_capability_changes > 5;
669
670        let description = Self::generate_description(
671            total_capability_changes,
672            param_changes.len(),
673            total_guard_changes,
674            &risk_analysis.overall_risk,
675        );
676
677        DiffSummary {
678            total_capability_changes,
679            total_parameter_changes: param_changes.len(),
680            total_guard_changes,
681            requires_review,
682            description,
683        }
684    }
685
686    /// Generate human-readable description
687    fn generate_description(
688        cap_changes: usize,
689        param_changes: usize,
690        guard_changes: usize,
691        risk_level: &RiskLevel,
692    ) -> String {
693        let mut parts = Vec::new();
694
695        if cap_changes > 0 {
696            parts.push(format!("{} capability changes", cap_changes));
697        }
698
699        if param_changes > 0 {
700            parts.push(format!("{} parameter changes", param_changes));
701        }
702
703        if guard_changes > 0 {
704            parts.push(format!("{} guard changes", guard_changes));
705        }
706
707        let changes_desc = if parts.is_empty() {
708            "No changes detected".to_string()
709        } else {
710            parts.join(", ")
711        };
712
713        let risk_desc = match risk_level {
714            RiskLevel::Low => "low risk",
715            RiskLevel::Medium => "medium risk",
716            RiskLevel::High => "high risk",
717            RiskLevel::Critical => "CRITICAL risk",
718        };
719
720        format!("{} ({})", changes_desc, risk_desc)
721    }
722
723    /// Generate human-readable diff report
724    pub fn to_report(&self) -> String {
725        let mut report = String::new();
726
727        report.push_str("# Behavior Pack Diff Report\n\n");
728        report.push_str(&format!(
729            "**From:** {} ({})\n",
730            self.metadata.from_pack,
731            format!("{:?}", self.metadata.from_mode).to_lowercase()
732        ));
733        report.push_str(&format!(
734            "**To:** {} ({})\n",
735            self.metadata.to_pack,
736            format!("{:?}", self.metadata.to_mode).to_lowercase()
737        ));
738        report.push_str(&format!("**Timestamp:** {}\n\n", self.metadata.timestamp));
739
740        // Summary
741        report.push_str("## Summary\n\n");
742        report.push_str(&format!("{}\n\n", self.summary.description));
743
744        // Total changes summary
745        report.push_str(&format!(
746            "**Total Changes:** {} capabilities, {} parameters, {} guards\n\n",
747            self.summary.total_capability_changes,
748            self.summary.total_parameter_changes,
749            self.summary.total_guard_changes
750        ));
751
752        if self.summary.requires_review {
753            report.push_str("⚠️  **Manual review required**\n\n");
754        }
755
756        // Risk Analysis
757        report.push_str("## Risk Analysis\n\n");
758        report.push_str(&format!(
759            "**Risk Level:** {:?}\n",
760            self.risk_analysis.overall_risk
761        ));
762
763        if self.risk_analysis.scope_expansion_detected {
764            report.push_str("🚨 **Silent scope expansion detected**\n");
765        }
766
767        report.push_str(&format!(
768            "**Mode Change Risk:** {:?} - {}\n\n",
769            self.risk_analysis.mode_risk.risk_level, self.risk_analysis.mode_risk.description
770        ));
771
772        // Capability Changes
773        if self.summary.total_capability_changes > 0 {
774            report.push_str("## Capability Changes\n\n");
775
776            if !self.capability_changes.atoms_enabled.is_empty() {
777                report.push_str(&format!(
778                    "**Atoms Enabled:** {}\n",
779                    self.capability_changes.atoms_enabled.join(", ")
780                ));
781            }
782            if !self.capability_changes.atoms_disabled.is_empty() {
783                report.push_str(&format!(
784                    "**Atoms Disabled:** {}\n",
785                    self.capability_changes.atoms_disabled.join(", ")
786                ));
787            }
788            if !self.capability_changes.macros_enabled.is_empty() {
789                report.push_str(&format!(
790                    "**Macros Enabled:** {}\n",
791                    self.capability_changes.macros_enabled.join(", ")
792                ));
793            }
794            if !self.capability_changes.macros_disabled.is_empty() {
795                report.push_str(&format!(
796                    "**Macros Disabled:** {}\n",
797                    self.capability_changes.macros_disabled.join(", ")
798                ));
799            }
800            if !self.capability_changes.playbooks_enabled.is_empty() {
801                report.push_str(&format!(
802                    "**Playbooks Enabled:** {}\n",
803                    self.capability_changes.playbooks_enabled.join(", ")
804                ));
805            }
806            if !self.capability_changes.playbooks_disabled.is_empty() {
807                report.push_str(&format!(
808                    "**Playbooks Disabled:** {}\n",
809                    self.capability_changes.playbooks_disabled.join(", ")
810                ));
811            }
812            report.push('\n');
813        }
814
815        // Parameter Changes
816        if !self.parameter_changes.is_empty() {
817            report.push_str("## Parameter Changes\n\n");
818            for change in &self.parameter_changes {
819                let change_symbol = match change.change_type {
820                    ParameterChangeType::Added => "➕",
821                    ParameterChangeType::Removed => "➖",
822                    ParameterChangeType::Modified => "🔄",
823                };
824                report.push_str(&format!(
825                    "{} **{}.{}:** {:?} → {:?}\n",
826                    change_symbol,
827                    change.capability,
828                    change.key,
829                    change.old_value,
830                    change.new_value
831                ));
832            }
833            report.push('\n');
834        }
835
836        // Guard Changes
837        if self.summary.total_guard_changes > 0 {
838            report.push_str("## Guard Changes\n\n");
839            for change in &self.guard_changes.atom_guards {
840                let impact_symbol = match change.impact {
841                    GuardImpact::Restrictive => "🔒",
842                    GuardImpact::Permissive => "🔓",
843                    GuardImpact::Neutral => "⚖️",
844                };
845                report.push_str(&format!(
846                    "{} **atoms.{}:** {:?} → {:?}\n",
847                    impact_symbol, change.setting, change.old_value, change.new_value
848                ));
849            }
850            report.push('\n');
851        }
852
853        report
854    }
855}
856
857// Add chrono dependency for timestamp generation
858// This would need to be added to Cargo.toml:
859// chrono = { version = "0.4", features = ["serde"] }
860
861#[cfg(test)]
862mod tests {
863    use super::*;
864    use crate::behavior::{EnabledCapabilities, GuardConfig};
865    use std::collections::HashMap;
866
867    #[test]
868    fn test_behavior_pack_diff_no_changes() {
869        let pack1 = BehaviorPack {
870            name: "test-pack".to_string(),
871            mode: BehaviorMode::Strict,
872            enable: EnabledCapabilities::default(),
873            params: HashMap::new(),
874            guards: GuardConfig::default(),
875        };
876
877        let pack2 = pack1.clone();
878
879        let diff = BehaviorPackDiff::compare(&pack1, &pack2).unwrap();
880        assert_eq!(diff.summary.total_capability_changes, 0);
881        assert_eq!(diff.summary.total_parameter_changes, 0);
882        assert!(matches!(diff.risk_analysis.overall_risk, RiskLevel::Low));
883    }
884
885    #[test]
886    fn test_capability_changes_detection() {
887        let pack1 = BehaviorPack {
888            name: "pack1".to_string(),
889            mode: BehaviorMode::Strict,
890            enable: EnabledCapabilities {
891                atoms: vec!["atom1".to_string()],
892                macros: vec!["macro1".to_string()],
893                playbooks: vec!["playbook1".to_string()],
894            },
895            params: HashMap::new(),
896            guards: GuardConfig::default(),
897        };
898
899        let pack2 = BehaviorPack {
900            name: "pack2".to_string(),
901            mode: BehaviorMode::Strict,
902            enable: EnabledCapabilities {
903                atoms: vec!["atom1".to_string(), "atom2".to_string()],
904                macros: vec!["macro2".to_string()],
905                playbooks: vec!["playbook1".to_string()],
906            },
907            params: HashMap::new(),
908            guards: GuardConfig::default(),
909        };
910
911        let diff = BehaviorPackDiff::compare(&pack1, &pack2).unwrap();
912
913        assert_eq!(diff.capability_changes.atoms_enabled, vec!["atom2"]);
914        assert_eq!(diff.capability_changes.macros_enabled, vec!["macro2"]);
915        assert_eq!(diff.capability_changes.macros_disabled, vec!["macro1"]);
916        assert!(diff.summary.total_capability_changes > 0);
917    }
918
919    #[test]
920    fn test_scope_expansion_detection() {
921        let mut params1 = HashMap::new();
922        params1.insert(
923            "http.fetch.v1".to_string(),
924            serde_json::json!({
925                "hosts": ["api.example.com"]
926            }),
927        );
928
929        let mut params2 = HashMap::new();
930        params2.insert(
931            "http.fetch.v1".to_string(),
932            serde_json::json!({
933                "hosts": ["api.example.com", "untrusted.com"]
934            }),
935        );
936
937        let pack1 = BehaviorPack {
938            name: "pack1".to_string(),
939            mode: BehaviorMode::Strict,
940            enable: EnabledCapabilities::default(),
941            params: params1,
942            guards: GuardConfig::default(),
943        };
944
945        let pack2 = BehaviorPack {
946            name: "pack2".to_string(),
947            mode: BehaviorMode::Strict,
948            enable: EnabledCapabilities::default(),
949            params: params2,
950            guards: GuardConfig::default(),
951        };
952
953        let diff = BehaviorPackDiff::compare(&pack1, &pack2).unwrap();
954        assert!(diff.risk_analysis.scope_expansion_detected);
955        assert!(matches!(
956            diff.risk_analysis.overall_risk,
957            RiskLevel::Critical
958        ));
959    }
960}