use serde::Serialize;
use similar::{ChangeTag, TextDiff};
use tracing::instrument;
use crate::error::OxidizedError;
use crate::oxidized::{OxidizedBackend, OxidizedClient};
use super::enrich_node_not_found;
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct LineChange {
pub line_num: usize,
pub content: String,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct Modification {
pub v1_line_start: usize,
pub v1_line_end: usize,
pub v2_line_start: usize,
pub v2_line_end: usize,
pub old_content: Vec<String>,
pub new_content: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Default, PartialEq)]
pub struct DiffSummary {
pub lines_added: usize,
pub lines_removed: usize,
pub modification_blocks: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct DiffResult {
pub node: String,
pub version1: String,
pub version2: String,
pub identical: bool,
pub summary: DiffSummary,
pub additions: Vec<LineChange>,
pub deletions: Vec<LineChange>,
pub modifications: Vec<Modification>,
pub unified_diff: String,
}
impl DiffResult {
pub fn to_llm_format(&self) -> String {
let mut output = String::new();
output.push_str(&format!("## Configuration Diff: {}\n", self.node));
output.push_str(&format!(
"Comparing version {} to {}\n\n",
self.version1, self.version2
));
if self.identical {
output.push_str("### Result\nConfigurations are identical. No changes detected.\n");
return output;
}
output.push_str("### Summary\n");
output.push_str(&format!("- Lines added: {}\n", self.summary.lines_added));
output.push_str(&format!(
"- Lines removed: {}\n",
self.summary.lines_removed
));
output.push_str(&format!(
"- Modification blocks: {}\n\n",
self.summary.modification_blocks
));
output.push_str("### Unified Diff\n```diff\n");
output.push_str(&self.unified_diff);
if !self.unified_diff.ends_with('\n') {
output.push('\n');
}
output.push_str("```\n");
if !self.additions.is_empty() {
output.push_str("\n### Pure Additions\n");
for change in &self.additions {
output.push_str(&format!(
"+ [line {}] {}\n",
change.line_num, change.content
));
}
}
if !self.deletions.is_empty() {
output.push_str("\n### Pure Deletions\n");
for change in &self.deletions {
output.push_str(&format!(
"- [line {}] {}\n",
change.line_num, change.content
));
}
}
output
}
}
pub fn compute_diff(
config1: &str,
config2: &str,
) -> (
Vec<LineChange>,
Vec<LineChange>,
Vec<Modification>,
DiffSummary,
String,
) {
let diff = TextDiff::from_lines(config1, config2);
let mut additions = Vec::new();
let mut deletions = Vec::new();
let mut modifications = Vec::new();
let mut lines_added = 0usize;
let mut lines_removed = 0usize;
let mut current_mod: Option<(usize, usize, Vec<String>, Vec<String>)> = None;
for change in diff.iter_all_changes() {
let line_content = change.value().trim_end_matches('\n').to_string();
match change.tag() {
ChangeTag::Delete => {
lines_removed += 1;
let old_line = change.old_index().unwrap_or(0) + 1;
if let Some((_mod_start, _, ref mut old_lines, ref new_lines)) = current_mod {
old_lines.push(line_content);
if new_lines.is_empty() {
}
} else {
current_mod = Some((old_line, 0, vec![line_content], vec![]));
}
}
ChangeTag::Insert => {
lines_added += 1;
let new_line = change.new_index().unwrap_or(0) + 1;
if let Some((mod_start, _, ref old_lines, ref mut new_lines)) = current_mod {
if new_lines.is_empty() {
current_mod =
Some((mod_start, new_line, old_lines.clone(), vec![line_content]));
} else {
new_lines.push(line_content);
}
} else {
additions.push(LineChange {
line_num: new_line,
content: line_content,
});
}
}
ChangeTag::Equal => {
if let Some((v1_start, v2_start, old_lines, new_lines)) = current_mod.take() {
if !new_lines.is_empty() {
modifications.push(Modification {
v1_line_start: v1_start,
v1_line_end: v1_start + old_lines.len().saturating_sub(1),
v2_line_start: v2_start,
v2_line_end: v2_start + new_lines.len().saturating_sub(1),
old_content: old_lines,
new_content: new_lines,
});
} else {
for (i, content) in old_lines.into_iter().enumerate() {
deletions.push(LineChange {
line_num: v1_start + i,
content,
});
}
}
}
}
}
}
if let Some((v1_start, v2_start, old_lines, new_lines)) = current_mod.take() {
if !new_lines.is_empty() {
modifications.push(Modification {
v1_line_start: v1_start,
v1_line_end: v1_start + old_lines.len().saturating_sub(1),
v2_line_start: v2_start,
v2_line_end: v2_start + new_lines.len().saturating_sub(1),
old_content: old_lines,
new_content: new_lines,
});
} else {
for (i, content) in old_lines.into_iter().enumerate() {
deletions.push(LineChange {
line_num: v1_start + i,
content,
});
}
}
}
let unified_diff = diff
.unified_diff()
.context_radius(3)
.header(&format!("version {}", "1"), &format!("version {}", "2"))
.to_string();
let summary = DiffSummary {
lines_added,
lines_removed,
modification_blocks: modifications.len(),
};
(additions, deletions, modifications, summary, unified_diff)
}
#[instrument(skip(backend), fields(node = %node, version1 = %version1, version2 = %version2))]
pub async fn diff_configs(
backend: &OxidizedClient,
node: &str,
version1: &str,
version2: &str,
) -> Result<DiffResult, OxidizedError> {
let (config1_result, config2_result) = tokio::join!(
backend.get_node_version(node, version1),
backend.get_node_version(node, version2)
);
let config1 = match config1_result {
Ok(c) => c,
Err(OxidizedError::NodeNotFound(node_name, _)) => {
return Err(enrich_node_not_found(backend, node_name).await);
}
Err(e) => return Err(e),
};
let config2 = match config2_result {
Ok(c) => c,
Err(OxidizedError::NodeNotFound(node_name, _)) => {
return Err(enrich_node_not_found(backend, node_name).await);
}
Err(e) => return Err(e),
};
if config1 == config2 {
tracing::info!(node = %node, "Configurations are identical");
return Ok(DiffResult {
node: node.to_string(),
version1: version1.to_string(),
version2: version2.to_string(),
identical: true,
summary: DiffSummary::default(),
additions: vec![],
deletions: vec![],
modifications: vec![],
unified_diff: String::new(),
});
}
let (additions, deletions, modifications, summary, unified_diff) =
compute_diff(&config1, &config2);
tracing::info!(
node = %node,
added = summary.lines_added,
removed = summary.lines_removed,
mod_blocks = summary.modification_blocks,
"Diff computed successfully"
);
Ok(DiffResult {
node: node.to_string(),
version1: version1.to_string(),
version2: version2.to_string(),
identical: false,
summary,
additions,
deletions,
modifications,
unified_diff,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compute_diff_identical_configs() {
let config = "line1\nline2\nline3";
let (additions, deletions, modifications, summary, unified) = compute_diff(config, config);
assert!(additions.is_empty());
assert!(deletions.is_empty());
assert!(modifications.is_empty());
assert_eq!(summary.lines_added, 0);
assert_eq!(summary.lines_removed, 0);
assert_eq!(summary.modification_blocks, 0);
assert!(
unified.is_empty() || !unified.contains('-') && !unified.contains('+'),
"Identical files should have no changes in unified diff"
);
}
#[test]
fn test_compute_diff_additions_only() {
let config1 = "line1\nline2\n";
let config2 = "line1\nline2\nline3\nline4\n";
let (additions, deletions, modifications, summary, unified) =
compute_diff(config1, config2);
assert!(summary.lines_added >= 2, "Should add at least 2 lines");
assert_eq!(summary.lines_removed, 0);
assert!(deletions.is_empty());
let total_new_lines = additions.len()
+ modifications
.iter()
.map(|m| m.new_content.len())
.sum::<usize>();
assert!(total_new_lines >= 2, "Should have at least 2 new lines");
assert!(unified.contains('+'), "Unified diff should show additions");
}
#[test]
fn test_compute_diff_deletions_only() {
let config1 = "line1\nline2\nline3\nline4\n";
let config2 = "line1\nline2\n";
let (additions, deletions, modifications, summary, unified) =
compute_diff(config1, config2);
assert!(summary.lines_removed >= 2, "Should remove at least 2 lines");
assert_eq!(summary.lines_added, 0);
assert!(additions.is_empty());
let total_removed = deletions.len()
+ modifications
.iter()
.map(|m| m.old_content.len())
.sum::<usize>();
assert!(total_removed >= 2, "Should have at least 2 removed lines");
assert!(unified.contains('-'), "Unified diff should show deletions");
}
#[test]
fn test_compute_diff_modifications() {
let config1 = "hostname SW-01\ninterface Gi0/1\n ip address 10.0.0.1";
let config2 = "hostname SW-01\ninterface Gi0/1\n ip address 10.0.0.2";
let (_additions, _deletions, modifications, summary, unified) =
compute_diff(config1, config2);
assert!(
summary.lines_added >= 1 || !modifications.is_empty(),
"Should detect the IP change"
);
assert!(
summary.lines_removed >= 1 || !modifications.is_empty(),
"Should detect the IP change"
);
assert!(
unified.contains("10.0.0.1") || unified.contains("10.0.0.2"),
"Unified diff should reference the IP addresses"
);
}
#[test]
fn test_compute_diff_mixed_changes() {
let config1 = "line1\nold_line\nline3";
let config2 = "line1\nnew_line\nline3\nline4";
let (additions, _deletions, modifications, summary, _unified) =
compute_diff(config1, config2);
assert!(
summary.lines_added >= 1,
"Should detect at least one addition"
);
let has_changes =
!modifications.is_empty() || !additions.is_empty() || summary.lines_removed > 0;
assert!(has_changes, "Should detect the changes");
}
#[test]
fn test_compute_diff_empty_configs() {
let (additions, deletions, modifications, summary, _unified) = compute_diff("", "");
assert!(additions.is_empty());
assert!(deletions.is_empty());
assert!(modifications.is_empty());
assert_eq!(summary, DiffSummary::default());
}
#[test]
fn test_compute_diff_empty_to_content() {
let config2 = "line1\nline2";
let (additions, deletions, _modifications, summary, unified) = compute_diff("", config2);
assert_eq!(summary.lines_added, 2);
assert!(deletions.is_empty());
assert!(
additions.len() >= 2 || summary.lines_added == 2,
"Should add 2 lines"
);
assert!(unified.contains('+'), "Should show additions in unified");
}
#[test]
fn test_compute_diff_content_to_empty() {
let config1 = "line1\nline2";
let (additions, deletions, _modifications, summary, unified) = compute_diff(config1, "");
assert_eq!(summary.lines_removed, 2);
assert!(additions.is_empty());
assert!(
deletions.len() >= 2 || summary.lines_removed == 2,
"Should remove 2 lines"
);
assert!(unified.contains('-'), "Should show deletions in unified");
}
#[test]
fn test_compute_diff_real_network_config() {
let config1 = r#"!
hostname SW-Core-01
!
interface GigabitEthernet0/1
description Uplink to Router
ip address 192.168.1.1 255.255.255.0
no shutdown
!
interface GigabitEthernet0/2
description Server Farm
ip address 10.0.0.1 255.255.255.0
no shutdown
!
end"#;
let config2 = r#"!
hostname SW-Core-01
!
interface GigabitEthernet0/1
description Uplink to Router-New
ip address 192.168.1.2 255.255.255.0
no shutdown
!
interface GigabitEthernet0/2
description Server Farm
ip address 10.0.0.1 255.255.255.0
no shutdown
!
interface GigabitEthernet0/3
description New Interface
ip address 172.16.0.1 255.255.255.0
no shutdown
!
end"#;
let (_additions, _deletions, _modifications, summary, unified) =
compute_diff(config1, config2);
assert!(summary.lines_added > 0, "Should detect additions");
assert!(
unified.contains("Router-New") || unified.contains("172.16.0.1"),
"Should show new content"
);
assert!(
unified.contains('-') && unified.contains('+'),
"Should have both deletions and additions"
);
}
#[test]
fn test_diff_result_llm_format_identical() {
let result = DiffResult {
node: "SW-Core-01".to_string(),
version1: "abc123".to_string(),
version2: "def456".to_string(),
identical: true,
summary: DiffSummary::default(),
additions: vec![],
deletions: vec![],
modifications: vec![],
unified_diff: String::new(),
};
let output = result.to_llm_format();
assert!(output.contains("## Configuration Diff: SW-Core-01"));
assert!(output.contains("abc123"));
assert!(output.contains("def456"));
assert!(output.contains("Configurations are identical"));
}
#[test]
fn test_diff_result_llm_format_with_changes() {
let result = DiffResult {
node: "SW-Core-01".to_string(),
version1: "abc123".to_string(),
version2: "def456".to_string(),
identical: false,
summary: DiffSummary {
lines_added: 2,
lines_removed: 1,
modification_blocks: 1,
},
additions: vec![LineChange {
line_num: 5,
content: "new line".to_string(),
}],
deletions: vec![LineChange {
line_num: 3,
content: "removed line".to_string(),
}],
modifications: vec![Modification {
v1_line_start: 2,
v1_line_end: 2,
v2_line_start: 2,
v2_line_end: 2,
old_content: vec!["old value".to_string()],
new_content: vec!["new value".to_string()],
}],
unified_diff: "@@ -1,3 +1,4 @@\n line1\n-old value\n+new value\n line3\n+new line"
.to_string(),
};
let output = result.to_llm_format();
assert!(output.contains("### Summary"));
assert!(output.contains("- Lines added: 2"));
assert!(output.contains("- Lines removed: 1"));
assert!(output.contains("- Modification blocks: 1"));
assert!(output.contains("### Unified Diff"));
assert!(output.contains("```diff"));
assert!(output.contains("### Pure Additions"));
assert!(output.contains("+ [line 5] new line"));
assert!(output.contains("### Pure Deletions"));
assert!(output.contains("- [line 3] removed line"));
}
#[test]
fn test_diff_result_llm_format_unified_only() {
let result = DiffResult {
node: "SW-01".to_string(),
version1: "v1".to_string(),
version2: "v2".to_string(),
identical: false,
summary: DiffSummary {
lines_added: 1,
lines_removed: 1,
modification_blocks: 1,
},
additions: vec![], deletions: vec![], modifications: vec![Modification {
v1_line_start: 1,
v1_line_end: 1,
v2_line_start: 1,
v2_line_end: 1,
old_content: vec!["old".to_string()],
new_content: vec!["new".to_string()],
}],
unified_diff: "@@ -1 +1 @@\n-old\n+new".to_string(),
};
let output = result.to_llm_format();
assert!(output.contains("### Unified Diff"));
assert!(!output.contains("### Pure Additions"));
assert!(!output.contains("### Pure Deletions"));
}
#[test]
fn test_diff_result_serializes() {
let result = DiffResult {
node: "SW-01".to_string(),
version1: "v1".to_string(),
version2: "v2".to_string(),
identical: false,
summary: DiffSummary {
lines_added: 1,
lines_removed: 0,
modification_blocks: 0,
},
additions: vec![LineChange {
line_num: 1,
content: "test".to_string(),
}],
deletions: vec![],
modifications: vec![],
unified_diff: "+test".to_string(),
};
let json = serde_json::to_string(&result).expect("Should serialize");
assert!(json.contains("\"node\":\"SW-01\""));
assert!(json.contains("\"identical\":false"));
assert!(json.contains("\"lines_added\":1"));
assert!(json.contains("\"unified_diff\""));
}
#[test]
fn test_line_change_serializes() {
let change = LineChange {
line_num: 42,
content: "test content".to_string(),
};
let json = serde_json::to_string(&change).expect("Should serialize");
assert!(json.contains("\"line_num\":42"));
assert!(json.contains("\"content\":\"test content\""));
}
#[test]
fn test_modification_serializes() {
let modification = Modification {
v1_line_start: 10,
v1_line_end: 12,
v2_line_start: 10,
v2_line_end: 11,
old_content: vec!["old1".to_string(), "old2".to_string(), "old3".to_string()],
new_content: vec!["new1".to_string(), "new2".to_string()],
};
let json = serde_json::to_string(&modification).expect("Should serialize");
assert!(json.contains("\"v1_line_start\":10"));
assert!(json.contains("\"v1_line_end\":12"));
assert!(json.contains("\"old_content\""));
assert!(json.contains("\"new_content\""));
}
#[test]
fn test_diff_summary_default() {
let summary = DiffSummary::default();
assert_eq!(summary.lines_added, 0);
assert_eq!(summary.lines_removed, 0);
assert_eq!(summary.modification_blocks, 0);
}
#[test]
fn test_diff_summary_equality() {
let summary1 = DiffSummary {
lines_added: 1,
lines_removed: 2,
modification_blocks: 3,
};
let summary2 = DiffSummary {
lines_added: 1,
lines_removed: 2,
modification_blocks: 3,
};
assert_eq!(summary1, summary2);
}
#[test]
fn test_compute_diff_whitespace_only_changes() {
let config1 = "line1\nline2\nline3";
let config2 = "line1\nline2 \nline3";
let (_additions, _deletions, _modifications, summary, _unified) =
compute_diff(config1, config2);
assert!(
summary.lines_added > 0 || summary.lines_removed > 0,
"Should detect whitespace change"
);
}
#[test]
fn test_compute_diff_large_config() {
let mut config1 = String::new();
let mut config2 = String::new();
for i in 0..1000 {
config1.push_str(&format!("line {}\n", i));
config2.push_str(&format!("line {}\n", i));
}
config2 = config2.replace("line 500", "modified line 500");
config2 = config2.replace("line 750", "modified line 750");
config2.push_str("extra line 1000\n");
let (_additions, _deletions, _modifications, summary, unified) =
compute_diff(&config1, &config2);
assert!(summary.lines_added >= 1, "Should detect additions");
assert!(
summary.lines_removed >= 2,
"Should detect the modifications"
);
assert!(
!unified.is_empty(),
"Should generate unified diff for large files"
);
}
}