Skip to main content

agent_migrate_adapters/
report.rs

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    /// Classifies planned writes against a target root.
69    ///
70    /// # Errors
71    ///
72    /// Returns an error when an existing target resource cannot be read.
73    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}