Skip to main content

weave_cli/
wire.rs

1//! The findings wire format, v1.1.0 — serialisable form of
2//! `crates/weave-mcp/schema/weave-findings.schema.json`.
3//!
4//! `weave-mcp/src/findings.rs` produces the **per-merge** document (one merge
5//! of one file, `scope: "merge"`, declared version 1.0.0 because it uses no
6//! 1.1 field). This module produces the **repo-scope** document (`weave check`,
7//! `scope: "repo"`), which needs the same vocabulary from a crate the MCP
8//! server can depend on. Both are the same schema; the difference is which
9//! optional fields are populated.
10//!
11//! v1.1.0 is a *minor* bump over v1.0.0: it adds the optional top-level
12//! `scope` and the optional `finding.side`. A v1.0.0 document is still valid
13//! under it, which is exactly what minor is supposed to mean.
14
15use serde::{Deserialize, Serialize};
16
17use weave_core::conflict::{ConflictKind, EntityConflict};
18
19/// The version a document that uses a 1.1 field must declare.
20pub const SCHEMA_VERSION_1_1: &str = "1.1.0";
21
22// ---------------------------------------------------------------------------
23// Conflict repair lines — one owner for both producers
24// ---------------------------------------------------------------------------
25
26/// What a document calls the two sides of the comparison it describes.
27///
28/// This is the *only* thing that legitimately differs between the two producers
29/// of a `class=CONFLICT` finding: `weave-mcp` describes a merge, whose sides are
30/// ours and theirs; `weave patch` describes a patch landing on a target, whose
31/// sides are the target and the patch. Everything else — which kinds exist,
32/// what each one means, what to do about it — is one fact, and used to be
33/// written down twice in prose that had already drifted apart in wording.
34///
35/// Passing the vocabulary in is what lets there be one owner: the audience is a
36/// parameter, not a reason to fork the mapping.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct Sides {
39    /// Sentence-initial form — "Ours", "The target".
40    pub ours: &'static str,
41    pub theirs: &'static str,
42    /// Mid-sentence form — "ours", "the target".
43    pub ours_mid: &'static str,
44    pub theirs_mid: &'static str,
45}
46
47impl Sides {
48    /// A three-way merge of two branches.
49    pub const MERGE: Sides = Sides {
50        ours: "Ours",
51        theirs: "Theirs",
52        ours_mid: "ours",
53        theirs_mid: "theirs",
54    };
55    /// A patch applied to a target file.
56    pub(crate) const PATCH: Sides = Sides {
57        ours: "The target",
58        theirs: "The patch",
59        ours_mid: "the target",
60        theirs_mid: "the patch",
61    };
62
63    fn initial(self, is_ours: bool) -> &'static str {
64        if is_ours {
65            self.ours
66        } else {
67            self.theirs
68        }
69    }
70    fn mid(self, is_ours: bool) -> &'static str {
71        if is_ours {
72            self.ours_mid
73        } else {
74            self.theirs_mid
75        }
76    }
77}
78
79/// The wire `kind` and the repair line for one conflict.
80///
81/// The kind comes from [`ConflictKind::wire_kind`] — weave-core owns the tag,
82/// because the exhaustive match belongs next to the enum. The prose is owned
83/// here, because it is schema-side and audience-dependent.
84///
85/// Every line below is *mechanical*: it restates the three-way fact weave
86/// already established and names the step that follows from it, and it stops
87/// there. None of them chooses a side, guesses intent, or recommends one
88/// resolution over another — that is the same discipline the marker's
89/// `refused_by:` line keeps, stated at the length a findings document can
90/// afford. A finding whose suggestion only says "there is a conflict" has told
91/// an agent nothing it could not already see; a finding that guesses has told
92/// it something weave does not know.
93pub fn conflict_advice(c: &EntityConflict, sides: Sides) -> (&'static str, String) {
94    let name = &c.entity_name;
95    let suggestion = match &c.kind {
96        ConflictKind::RenameRename {
97            base_name,
98            ours_name,
99            theirs_name,
100        } => format!(
101            "{} renamed `{base_name}` to `{ours_name}` while {} renamed it to `{theirs_name}`. \
102             Pick one name, then rewrite every call site to it.",
103            sides.ours, sides.theirs_mid
104        ),
105        ConflictKind::RenameModify {
106            old_name,
107            new_name,
108            renamed_in_ours,
109        } => format!(
110            "{} renamed `{old_name}` to `{new_name}` while the other edited its body. \
111             Keep the new name and replay the body edit onto it.",
112            sides.initial(*renamed_in_ours)
113        ),
114        ConflictKind::ModifyDelete { modified_in_ours } => format!(
115            "`{name}` was deleted on one side and modified on the other ({} kept it). \
116             Decide whether it still has a reason to exist before resolving.",
117            sides.mid(*modified_in_ours)
118        ),
119        ConflictKind::BothAdded => format!(
120            "`{name}` was added independently by {} and {}. Reconcile them into one definition.",
121            sides.ours_mid, sides.theirs_mid
122        ),
123        ConflictKind::BothModified => format!(
124            "{} and {} both changed the body of `{name}` in ways weave will not guess between.",
125            sides.ours, sides.theirs_mid
126        ),
127    };
128    (c.kind.wire_kind(), suggestion)
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
132pub struct Findings {
133    pub schema_version: String,
134    pub file: String,
135    /// `merge` — this document describes one merge of one file.
136    /// `repo`  — this document describes a repo-scope binding check.
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub scope: Option<String>,
139    pub clean: bool,
140    pub confidence: String,
141    pub findings: Vec<Finding>,
142    pub ops: Vec<Op>,
143    pub bindings: Bindings,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
147pub struct Finding {
148    pub class: String,
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub kind: Option<String>,
151    pub entity: String,
152    pub entity_type: String,
153    pub source: String,
154    /// Which side of the merge caused the finding. v1.1.
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub side: Option<String>,
157    #[serde(skip_serializing_if = "Option::is_none")]
158    pub complexity: Option<String>,
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub confidence: Option<String>,
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub base: Option<String>,
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub ours: Option<String>,
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub theirs: Option<String>,
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub suggestion: Option<String>,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub warning_kind: Option<String>,
171    #[serde(default, skip_serializing_if = "Vec::is_empty")]
172    pub related: Vec<RelatedRef>,
173}
174
175impl Finding {
176    pub(crate) fn derived(class: &str, entity: &str, entity_type: &str) -> Self {
177        Finding {
178            class: class.to_string(),
179            kind: None,
180            entity: entity.to_string(),
181            entity_type: entity_type.to_string(),
182            source: "derived".to_string(),
183            side: None,
184            complexity: None,
185            confidence: None,
186            base: None,
187            ours: None,
188            theirs: None,
189            suggestion: None,
190            warning_kind: None,
191            related: Vec::new(),
192        }
193    }
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
197pub struct RelatedRef {
198    pub entity: String,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub entity_type: Option<String>,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub file: Option<String>,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
206pub struct Op {
207    pub entity: String,
208    pub entity_type: String,
209    pub op: String,
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub from: Option<String>,
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub to: Option<String>,
214    #[serde(default, skip_serializing_if = "is_false")]
215    pub conflicted: bool,
216    #[serde(default, skip_serializing_if = "is_false")]
217    pub fallback: bool,
218}
219
220#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
221pub struct Bindings {
222    pub renames: Vec<Rename>,
223    pub references: Vec<Reference>,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
227pub struct Rename {
228    pub from: String,
229    pub to: String,
230    pub side: String,
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
234pub struct Reference {
235    pub entity: String,
236    pub defined_in_output: bool,
237    pub referenced_in_output: bool,
238}
239
240fn is_false(b: &bool) -> bool {
241    !*b
242}