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