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