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