Skip to main content

teamcraft/mcp/tools/
compare_tools.rs

1//! Revision comparison: the pure diff of two snapshots and the `compare_revisions` tool.
2
3use super::*;
4
5// ---------------------------------------------------------------------------
6// Revision comparison (pure)
7// ---------------------------------------------------------------------------
8
9/// A file of a revision as far as the comparison is concerned.
10#[derive(Clone, Debug)]
11pub struct FileState {
12    pub filename: String,
13    pub sha256: String,
14    pub version: i32,
15    pub size_bytes: i64,
16}
17
18/// A BOM line as far as the comparison is concerned.
19#[derive(Clone, Debug)]
20pub struct LineState {
21    pub child: String,
22    pub name: String,
23    pub find_number: i32,
24    pub quantity: f64,
25}
26
27/// What [`compare`] needs of a revision.
28#[derive(Clone, Debug)]
29pub struct Snapshot {
30    pub label: String,
31    /// (field, value) pairs in display order.
32    pub attributes: Vec<(&'static str, Value)>,
33    pub files: Vec<FileState>,
34    pub bom: Vec<LineState>,
35}
36
37impl Snapshot {
38    pub fn of(rev: &Revision, file_list: &[FileRecord], lines: &[BomLine]) -> Snapshot {
39        Snapshot {
40            label: revisions::label(rev),
41            attributes: vec![
42                ("name", json!(rev.name)),
43                ("description", json!(rev.description)),
44                ("material", json!(rev.material)),
45                ("mass_kg", json!(rev.mass_kg)),
46                ("unit_cost", json!(rev.unit_cost)),
47            ],
48            files: file_list
49                .iter()
50                .map(|f| FileState {
51                    filename: f.filename.clone(),
52                    sha256: f.sha256.clone(),
53                    version: f.version,
54                    size_bytes: f.size_bytes,
55                })
56                .collect(),
57            bom: lines
58                .iter()
59                .map(|l| LineState {
60                    child: l.child_number.clone(),
61                    name: l.child_name.clone(),
62                    find_number: l.find_number,
63                    quantity: l.quantity,
64                })
65                .collect(),
66        }
67    }
68}
69
70/// The BOM lines of one child item, summed up (an item may appear on
71/// several lines).
72struct ChildUse<'a> {
73    name: &'a str,
74    quantity: f64,
75    find_numbers: BTreeSet<i32>,
76}
77
78fn child_uses(lines: &[LineState]) -> BTreeMap<&str, ChildUse<'_>> {
79    let mut out: BTreeMap<&str, ChildUse> = BTreeMap::new();
80    for l in lines {
81        let e = out.entry(l.child.as_str()).or_insert(ChildUse {
82            name: &l.name,
83            quantity: 0.0,
84            find_numbers: BTreeSet::new(),
85        });
86        e.quantity += l.quantity;
87        e.find_numbers.insert(l.find_number);
88    }
89    out
90}
91
92/// What changed from `old` to `new`: attributes, files (matched by name,
93/// compared by content hash) and BOM lines (matched by child item).
94pub fn compare(old: &Snapshot, new: &Snapshot) -> Value {
95    let attributes: Vec<Value> = old
96        .attributes
97        .iter()
98        .zip(&new.attributes)
99        .filter(|((_, a), (_, b))| a != b)
100        .map(|((field, a), (_, b))| json!({ "field": field, "from": a, "to": b }))
101        .collect();
102
103    let key = |f: &FileState| f.filename.to_lowercase();
104    let old_files: BTreeMap<String, &FileState> = old.files.iter().map(|f| (key(f), f)).collect();
105    let new_files: BTreeMap<String, &FileState> = new.files.iter().map(|f| (key(f), f)).collect();
106    let mut files_added = Vec::new();
107    let mut files_changed = Vec::new();
108    let mut files_unchanged = 0usize;
109    for (k, f) in &new_files {
110        match old_files.get(k) {
111            None => files_added.push(json!({
112                "filename": f.filename, "version": f.version, "size_bytes": f.size_bytes,
113            })),
114            Some(o) if o.sha256 != f.sha256 => files_changed.push(json!({
115                "filename": f.filename,
116                "from_version": o.version,
117                "to_version": f.version,
118                "from_size_bytes": o.size_bytes,
119                "to_size_bytes": f.size_bytes,
120            })),
121            Some(_) => files_unchanged += 1,
122        }
123    }
124    let files_removed: Vec<Value> = old_files
125        .iter()
126        .filter(|(k, _)| !new_files.contains_key(*k))
127        .map(|(_, f)| json!({ "filename": f.filename, "version": f.version }))
128        .collect();
129
130    let old_bom = child_uses(&old.bom);
131    let new_bom = child_uses(&new.bom);
132    let mut bom_added = Vec::new();
133    let mut bom_changed = Vec::new();
134    for (child, n) in &new_bom {
135        match old_bom.get(child) {
136            None => bom_added.push(json!({
137                "item": child, "name": n.name, "quantity": n.quantity, "find_numbers": n.find_numbers,
138            })),
139            Some(o) => {
140                let quantity = (o.quantity - n.quantity).abs() > 1e-9;
141                let find = o.find_numbers != n.find_numbers;
142                if quantity || find {
143                    bom_changed.push(json!({
144                        "item": child,
145                        "name": n.name,
146                        "from_quantity": o.quantity,
147                        "to_quantity": n.quantity,
148                        "from_find_numbers": o.find_numbers,
149                        "to_find_numbers": n.find_numbers,
150                    }));
151                }
152            }
153        }
154    }
155    let bom_removed: Vec<Value> = old_bom
156        .iter()
157        .filter(|(c, _)| !new_bom.contains_key(*c))
158        .map(|(c, o)| json!({ "item": c, "name": o.name, "quantity": o.quantity }))
159        .collect();
160
161    let identical = attributes.is_empty()
162        && files_added.is_empty()
163        && files_changed.is_empty()
164        && files_removed.is_empty()
165        && bom_added.is_empty()
166        && bom_changed.is_empty()
167        && bom_removed.is_empty();
168    json!({
169        "from": old.label,
170        "to": new.label,
171        "identical": identical,
172        "attributes": attributes,
173        "files": {
174            "added": files_added,
175            "removed": files_removed,
176            "new_content": files_changed,
177            "unchanged": files_unchanged,
178        },
179        "bom": {
180            "added": bom_added,
181            "removed": bom_removed,
182            "changed": bom_changed,
183        },
184    })
185}
186
187async fn snapshot(st: &AppState, me: &Actor, rev: &Revision) -> AppResult<Snapshot> {
188    let file_list = files::list(&st.db, me, rev.id).await?;
189    let lines = bom::lines(&st.db, me, rev.id).await?;
190    Ok(Snapshot::of(rev, &file_list, &lines))
191}
192
193/// The two revisions of `item` a comparison is about: `to` (default the
194/// latest) and `from` (default the revision before `to`).
195pub(super) async fn revision_pair(
196    st: &AppState,
197    me: &Actor,
198    a: &Args<'_>,
199) -> AppResult<(Revision, Revision)> {
200    let item = items::get_by_number(&st.db, me, &a.req_str("item")?).await?;
201    // newest first
202    let revs = items::revisions(&st.db, me, item.id).await?;
203    let find = |code: &str| {
204        revs.iter()
205            .find(|r| r.code.eq_ignore_ascii_case(code))
206            .ok_or_else(|| AppError::NotFound(format!("{} has no revision {code}.", item.number)))
207    };
208    let to = match a.opt_str("to")? {
209        Some(c) if !c.eq_ignore_ascii_case("latest") => find(&c)?,
210        _ => revs
211            .first()
212            .ok_or_else(|| AppError::NotFound(format!("{} has no revisions.", item.number)))?,
213    };
214    let from = match a.opt_str("from")? {
215        Some(c) => find(&c)?,
216        None => revs.iter().find(|r| r.seq < to.seq).ok_or_else(|| {
217            bad(format!(
218                "{} is the first revision; there is nothing before it to compare with.",
219                revisions::label(to)
220            ))
221        })?,
222    };
223    Ok((from.clone(), to.clone()))
224}
225
226pub(super) async fn compare_revisions(st: &AppState, me: &Actor, a: &Args<'_>) -> AppResult<Value> {
227    let (from, to) = revision_pair(st, me, a).await?;
228    let (from, to) = (&from, &to);
229    let old = snapshot(st, me, from).await?;
230    let new = snapshot(st, me, to).await?;
231    let mut value = compare(&old, &new);
232    value["from_state"] = json!(from.state);
233    value["to_state"] = json!(to.state);
234    Ok(value)
235}