1use std::fmt::Write as _;
2use std::path::Path;
3
4use agent_migrate_core::{
5 AgentKind, BundleRelativePath, ChangeKind, Compatibility, ConflictPolicy, FileKind, SecretRef,
6 WritePlan,
7};
8use serde::Serialize;
9
10use crate::AdapterError;
11
12#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
13pub struct ResourceConversion {
14 pub source_path: BundleRelativePath,
15 pub kind: FileKind,
16 pub compatibility: Compatibility,
17 pub target_path: Option<BundleRelativePath>,
18 pub change: ChangeKind,
19}
20
21#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
22pub struct ReportSummary {
23 pub added: usize,
24 pub identical: usize,
25 pub merged: usize,
26 pub replaced: usize,
27 pub renamed: usize,
28 pub skipped: usize,
29 pub conflicts: usize,
30 pub warnings: usize,
31 pub blocked: usize,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
35pub struct ConversionReport {
36 pub schema_version: &'static str,
37 pub source: AgentKind,
38 pub target: AgentKind,
39 pub resources: Vec<ResourceConversion>,
40 pub secret_references: Vec<SecretRef>,
41 pub summary: ReportSummary,
42}
43
44impl ConversionReport {
45 pub(crate) fn new(
46 source: AgentKind,
47 target: AgentKind,
48 resources: Vec<ResourceConversion>,
49 mut secret_references: Vec<SecretRef>,
50 ) -> Self {
51 secret_references.sort_by(|left, right| {
52 left.logical_location
53 .cmp(&right.logical_location)
54 .then_with(|| left.name.cmp(&right.name))
55 });
56 let mut report = Self {
57 schema_version: "1.0",
58 source,
59 target,
60 resources,
61 secret_references,
62 summary: ReportSummary::default(),
63 };
64 report.recount();
65 report
66 }
67
68 pub fn classify_changes(
74 &mut self,
75 root: &Path,
76 plan: &WritePlan,
77 policy: ConflictPolicy,
78 ) -> Result<(), AdapterError> {
79 for resource in &mut self.resources {
80 resource.change = classify(resource.target_path.as_ref(), root, plan, policy)?;
81 }
82 self.recount();
83 Ok(())
84 }
85
86 #[must_use]
87 pub fn to_markdown(&self) -> String {
88 let mut output = format!(
89 "# Agent Config Migrate Report\n\nSchema: {}\n\nSource: `{}` \nTarget: `{}`\n\n| Change | Kind | Source | Target | Compatibility |\n| --- | --- | --- | --- | --- |\n",
90 self.schema_version, self.source, self.target
91 );
92 for resource in &self.resources {
93 if writeln!(
94 output,
95 "| {:?} | {:?} | `{}` | `{}` | `{}` |",
96 resource.change,
97 resource.kind,
98 escape(&resource.source_path.to_string()),
99 resource
100 .target_path
101 .as_ref()
102 .map_or_else(|| "-".to_owned(), ToString::to_string),
103 compatibility_name(&resource.compatibility),
104 )
105 .is_err()
106 {
107 return output;
108 }
109 }
110 output.push_str("\n## Required Secrets\n\n");
111 for reference in &self.secret_references {
112 if writeln!(
113 output,
114 "- `{}` at `{}` (required: {})",
115 escape(&reference.name),
116 escape(&reference.logical_location),
117 reference.required
118 )
119 .is_err()
120 {
121 return output;
122 }
123 }
124 output
125 }
126
127 fn recount(&mut self) {
128 let mut summary = ReportSummary::default();
129 for resource in &self.resources {
130 match resource.change {
131 ChangeKind::Add => summary.added += 1,
132 ChangeKind::Identical => summary.identical += 1,
133 ChangeKind::Merge => summary.merged += 1,
134 ChangeKind::Replace => summary.replaced += 1,
135 ChangeKind::Rename => summary.renamed += 1,
136 ChangeKind::Skip => summary.skipped += 1,
137 ChangeKind::Conflict => summary.conflicts += 1,
138 }
139 match &resource.compatibility {
140 Compatibility::Lossy { .. } | Compatibility::PreservedUnavailable { .. } => {
141 summary.warnings += 1;
142 }
143 Compatibility::Blocked { .. } => summary.blocked += 1,
144 Compatibility::Exact | Compatibility::Converted | Compatibility::Bridged { .. } => {
145 }
146 }
147 }
148 self.summary = summary;
149 }
150}
151
152fn classify(
153 path: Option<&BundleRelativePath>,
154 root: &Path,
155 plan: &WritePlan,
156 policy: ConflictPolicy,
157) -> Result<ChangeKind, AdapterError> {
158 let Some(path) = path else {
159 return Ok(ChangeKind::Skip);
160 };
161 let Some(write) = plan.get(path) else {
162 return Ok(ChangeKind::Skip);
163 };
164 let target = root.join(path.as_path());
165 if !target.exists() {
166 return Ok(ChangeKind::Add);
167 }
168 let existing = std::fs::read(&target).map_err(|source| AdapterError::Io {
169 path: target,
170 source,
171 })?;
172 if existing == write.content {
173 return Ok(ChangeKind::Identical);
174 }
175 Ok(match policy {
176 ConflictPolicy::Fail => ChangeKind::Conflict,
177 ConflictPolicy::Replace => ChangeKind::Replace,
178 ConflictPolicy::Merge => ChangeKind::Merge,
179 ConflictPolicy::Rename => ChangeKind::Rename,
180 })
181}
182
183fn compatibility_name(value: &Compatibility) -> &'static str {
184 match value {
185 Compatibility::Exact => "exact",
186 Compatibility::Converted => "converted",
187 Compatibility::Bridged { .. } => "bridged",
188 Compatibility::Lossy { .. } => "lossy",
189 Compatibility::PreservedUnavailable { .. } => "preserved_unavailable",
190 Compatibility::Blocked { .. } => "blocked",
191 }
192}
193
194fn escape(value: &str) -> String {
195 value.replace(['\n', '\r'], " ").replace('|', "\\|")
196}