Skip to main content

verbs/
resolve.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Typed report contract for inspectable conflict resolution.
3
4use objects::object::{Agent, Attribution, ConflictRange, ConflictRegion, ConflictSide, Principal};
5use oplog::ConflictResolutionMode;
6use schemars::JsonSchema;
7use serde::Serialize;
8
9use crate::{
10    HeddleReport, MachineOutputKind, OutputDiscriminator, ReportContract, schema_for_report,
11};
12
13#[derive(Clone, Debug, Serialize, JsonSchema)]
14pub struct ResolveReport {
15    pub output_kind: String,
16    pub message: Option<String>,
17    pub resolved: Vec<String>,
18    pub remaining: Vec<String>,
19    /// Path-level compatibility/progress surface, including structural conflicts.
20    pub conflict_paths: Vec<String>,
21    /// Content conflicts expanded into independently addressable regions.
22    pub conflicts: Vec<ConflictRegionReport>,
23    /// Resolution operations produced by this command invocation.
24    pub resolutions: Vec<ConflictResolutionReport>,
25    pub continued: bool,
26    pub continuation_status: Option<String>,
27    pub continuation_message: Option<String>,
28    pub next_action: Option<String>,
29    pub recommended_action: Option<String>,
30    /// Concurrent source heads of the current Thread, listed by `--heads`
31    /// and still unresolved after `--pick` / `--merge`.
32    pub source_heads: Option<crate::source_heads::SourceHeadsReport>,
33    /// What `--pick` or `--merge` did.
34    pub head_resolution: Option<crate::source_heads::SourceHeadResolutionReport>,
35}
36
37impl ResolveReport {
38    pub const CONTRACT: ReportContract = ReportContract {
39        schema_name: "resolve",
40        machine_output_kind: MachineOutputKind::Json,
41        output_discriminator: Some(OutputDiscriminator {
42            field: "output_kind",
43            value: "resolve",
44        }),
45        schema: schema_for_report::<Self>,
46    };
47}
48
49impl HeddleReport for ResolveReport {
50    const CONTRACT: ReportContract = Self::CONTRACT;
51}
52
53#[derive(Clone, Debug, Serialize, JsonSchema)]
54pub struct ConflictRegionReport {
55    pub id: String,
56    pub path: String,
57    pub symbol: Option<String>,
58    pub occurrence: u32,
59    pub merged_range: ConflictRangeReport,
60    pub base: ConflictSideReport,
61    pub ours: ConflictSideReport,
62    pub theirs: ConflictSideReport,
63}
64
65impl ConflictRegionReport {
66    pub fn new(
67        conflict: &ConflictRegion,
68        base: &Attribution,
69        ours: &Attribution,
70        theirs: &Attribution,
71    ) -> Self {
72        Self {
73            id: conflict.id.clone(),
74            path: conflict.path.clone(),
75            symbol: conflict.symbol.clone(),
76            occurrence: conflict.occurrence,
77            merged_range: conflict.merged_range.into(),
78            base: ConflictSideReport::new(&conflict.base, base),
79            ours: ConflictSideReport::new(&conflict.ours, ours),
80            theirs: ConflictSideReport::new(&conflict.theirs, theirs),
81        }
82    }
83}
84
85#[derive(Clone, Copy, Debug, Serialize, JsonSchema)]
86pub struct ConflictRangeReport {
87    pub start_line: u32,
88    pub end_line: u32,
89}
90
91impl From<ConflictRange> for ConflictRangeReport {
92    fn from(range: ConflictRange) -> Self {
93        Self {
94            start_line: range.start_line,
95            end_line: range.end_line,
96        }
97    }
98}
99
100#[derive(Clone, Debug, Serialize, JsonSchema)]
101pub struct ConflictSideReport {
102    pub source_state: String,
103    pub producer: ClaimedProducerReport,
104    pub blob_id: Option<String>,
105    pub range: ConflictRangeReport,
106    pub hunk_hash: String,
107}
108
109impl ConflictSideReport {
110    pub fn new(side: &ConflictSide, attribution: &Attribution) -> Self {
111        Self {
112            source_state: side.source_state.to_string_full(),
113            producer: attribution.into(),
114            blob_id: side.blob_id.map(|id| id.to_hex()),
115            range: side.range.into(),
116            hunk_hash: side.hunk_hash.to_hex(),
117        }
118    }
119}
120
121#[derive(Clone, Debug, Serialize, JsonSchema)]
122pub struct ClaimedProducerReport {
123    pub attribution: &'static str,
124    pub kind: ResolverKindReport,
125    pub principal: PrincipalReport,
126    pub agent: Option<AgentReport>,
127}
128
129impl From<&Attribution> for ClaimedProducerReport {
130    fn from(attribution: &Attribution) -> Self {
131        let actor = ResolverAttributionReport::from(attribution);
132        Self {
133            attribution: "claimed",
134            kind: actor.kind,
135            principal: actor.principal,
136            agent: actor.agent,
137        }
138    }
139}
140
141#[derive(Clone, Debug, Serialize, JsonSchema)]
142pub struct ConflictResolutionReport {
143    pub conflict_id: String,
144    pub path: String,
145    pub resolution: String,
146    pub mode: ConflictResolutionModeReport,
147    pub resolver: ResolverAttributionReport,
148}
149
150impl ConflictResolutionReport {
151    pub fn new(
152        conflict_id: impl Into<String>,
153        path: impl Into<String>,
154        resolver: &Attribution,
155        mode: ConflictResolutionMode,
156    ) -> Self {
157        Self {
158            conflict_id: conflict_id.into(),
159            path: path.into(),
160            resolution: mode.as_str().to_string(),
161            mode: mode.into(),
162            resolver: resolver.into(),
163        }
164    }
165}
166
167#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
168#[serde(rename_all = "snake_case")]
169pub enum ConflictResolutionModeReport {
170    Ours,
171    Theirs,
172    Edit,
173    Auto,
174}
175
176impl From<ConflictResolutionMode> for ConflictResolutionModeReport {
177    fn from(mode: ConflictResolutionMode) -> Self {
178        match mode {
179            ConflictResolutionMode::Ours => Self::Ours,
180            ConflictResolutionMode::Theirs => Self::Theirs,
181            ConflictResolutionMode::Edit => Self::Edit,
182            ConflictResolutionMode::Auto => Self::Auto,
183        }
184    }
185}
186
187impl std::fmt::Display for ConflictResolutionModeReport {
188    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189        let value = match self {
190            Self::Ours => "ours",
191            Self::Theirs => "theirs",
192            Self::Edit => "edit",
193            Self::Auto => "auto",
194        };
195        formatter.write_str(value)
196    }
197}
198
199#[derive(Clone, Debug, Serialize, JsonSchema)]
200pub struct ResolverAttributionReport {
201    pub kind: ResolverKindReport,
202    pub principal: PrincipalReport,
203    pub agent: Option<AgentReport>,
204}
205
206#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, JsonSchema)]
207#[serde(rename_all = "snake_case")]
208pub enum ResolverKindReport {
209    Human,
210    Agent,
211}
212
213impl From<&Attribution> for ResolverAttributionReport {
214    fn from(attribution: &Attribution) -> Self {
215        Self {
216            kind: if attribution.agent.is_some() {
217                ResolverKindReport::Agent
218            } else {
219                ResolverKindReport::Human
220            },
221            principal: (&attribution.principal).into(),
222            agent: attribution.agent.as_ref().map(Into::into),
223        }
224    }
225}
226
227#[derive(Clone, Debug, Serialize, JsonSchema)]
228pub struct PrincipalReport {
229    pub name: String,
230    pub email: String,
231}
232
233impl From<&Principal> for PrincipalReport {
234    fn from(principal: &Principal) -> Self {
235        Self {
236            name: principal.name_lossy().into_owned(),
237            email: principal.email_lossy().into_owned(),
238        }
239    }
240}
241
242#[derive(Clone, Debug, Serialize, JsonSchema)]
243pub struct AgentReport {
244    pub provider: String,
245    pub model: String,
246    pub session_id: Option<String>,
247    pub segment_id: Option<String>,
248    pub policy_id: Option<String>,
249}
250
251impl From<&Agent> for AgentReport {
252    fn from(agent: &Agent) -> Self {
253        Self {
254            provider: agent.provider.clone(),
255            model: agent.model.clone(),
256            session_id: agent.session_id.clone(),
257            segment_id: agent.segment_id.clone(),
258            policy_id: agent.policy_id.clone(),
259        }
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn resolver_kind_distinguishes_human_and_agent_without_dropping_identity() {
269        let principal = Principal::new("Ada", "ada@example.com");
270        let human = ResolverAttributionReport::from(&Attribution::human(principal.clone()));
271        assert_eq!(human.kind, ResolverKindReport::Human);
272        assert!(human.agent.is_none());
273
274        let agent = ResolverAttributionReport::from(&Attribution::with_agent(
275            principal,
276            Agent::new("openai", "gpt-resolver"),
277        ));
278        assert_eq!(agent.kind, ResolverKindReport::Agent);
279        assert_eq!(agent.principal.email, "ada@example.com");
280        assert_eq!(agent.agent.unwrap().provider, "openai");
281    }
282
283    #[test]
284    fn resolution_report_preserves_automatic_vs_edited_mode() {
285        let resolver = Attribution::human(Principal::new("Ada", "ada@example.com"));
286        let automatic = ConflictResolutionReport::new(
287            "conflict-a",
288            "src/lib.rs",
289            &resolver,
290            ConflictResolutionMode::Auto,
291        );
292        let edited = ConflictResolutionReport::new(
293            "conflict-b",
294            "src/lib.rs",
295            &resolver,
296            ConflictResolutionMode::Edit,
297        );
298        assert_eq!(automatic.mode, ConflictResolutionModeReport::Auto);
299        assert_eq!(edited.mode, ConflictResolutionModeReport::Edit);
300    }
301}