Skip to main content

teamcraft/services/
insights.rs

1//! Read-only analyses for engineers and agents: the health of a BOM and
2//! files stored more than once. Deterministic – they report facts from the
3//! database, the reader decides what to do.
4
5use std::collections::{BTreeMap, HashMap, HashSet};
6
7use serde::Serialize;
8use sqlx::PgPool;
9use uuid::Uuid;
10
11use crate::{
12    auth::Actor,
13    domain::RevisionRule,
14    error::AppResult,
15    models::Revision,
16    services::{
17        access,
18        bom::{self, BomNode},
19        revisions,
20    },
21};
22
23/// What can be wrong with a BOM line.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
25#[serde(rename_all = "snake_case")]
26pub enum Issue {
27    /// The revision rule finds no revision (e.g. never released).
28    Unresolved,
29    /// The chosen revision is obsolete.
30    Obsolete,
31    /// The chosen revision is not released (in work or in review).
32    NotReleased,
33    /// A part without children and without a mass.
34    MissingMass,
35    /// The chosen revision has no files.
36    NoFiles,
37    /// The chosen revision is checked out by someone.
38    CheckedOut,
39    /// The item has an unresolved change propagation (check needed).
40    CheckNeeded,
41    /// The structure loops back to an ancestor.
42    Cycle,
43    /// The child is in a project group the reader may not see.
44    Restricted,
45}
46
47#[derive(Clone, Debug, Serialize)]
48pub struct Finding {
49    pub issue: Issue,
50    pub level: usize,
51    /// Item numbers from the top line down to this one.
52    pub path: Vec<String>,
53    pub item_number: String,
54    pub revision: Option<String>,
55    pub detail: String,
56}
57
58#[derive(Clone, Debug, Serialize)]
59pub struct BomHealth {
60    pub root: String,
61    pub rule: RevisionRule,
62    pub line_count: usize,
63    pub counts: BTreeMap<Issue, usize>,
64    pub findings: Vec<Finding>,
65    /// More findings than reported.
66    pub truncated: bool,
67}
68
69/// Findings kept per report.
70const MAX_FINDINGS: usize = 500;
71
72fn walk<'a>(
73    nodes: &'a [BomNode],
74    path: &mut Vec<String>,
75    out: &mut Vec<(&'a BomNode, Vec<String>)>,
76) {
77    for n in nodes {
78        path.push(n.item_number.clone());
79        out.push((n, path.clone()));
80        walk(&n.children, path, out);
81        path.pop();
82    }
83}
84
85/// Checks every line of the expanded BOM below a revision.
86pub async fn bom_health(
87    pool: &PgPool,
88    actor: &Actor,
89    root_revision_id: Uuid,
90    rule: RevisionRule,
91) -> AppResult<BomHealth> {
92    let tree = bom::tree(pool, actor, root_revision_id, rule).await?;
93    let mut lines = Vec::new();
94    walk(&tree.children, &mut Vec::new(), &mut lines);
95
96    let rev_ids: Vec<Uuid> = lines
97        .iter()
98        .filter_map(|(n, _)| n.revision.as_ref().map(|r| r.id))
99        .collect::<HashSet<_>>()
100        .into_iter()
101        .collect();
102    let item_ids: Vec<Uuid> = lines
103        .iter()
104        .map(|(n, _)| n.item_id)
105        .collect::<HashSet<_>>()
106        .into_iter()
107        .collect();
108    let with_files: HashSet<Uuid> = sqlx::query_scalar::<_, Uuid>(
109        "SELECT DISTINCT revision_id FROM files WHERE revision_id = ANY($1)",
110    )
111    .bind(&rev_ids)
112    .fetch_all(pool)
113    .await?
114    .into_iter()
115    .collect();
116    let checked_out: HashMap<Uuid, String> = sqlx::query_as::<_, Revision>(&format!(
117        "{} WHERE r.id = ANY($1) AND r.checked_out_by IS NOT NULL",
118        revisions::REVISION_SELECT
119    ))
120    .bind(&rev_ids)
121    .fetch_all(pool)
122    .await?
123    .into_iter()
124    .map(|r| (r.id, r.checked_out_by_name.unwrap_or_default()))
125    .collect();
126    let check_needed: HashSet<Uuid> = sqlx::query_scalar::<_, Uuid>(
127        "SELECT DISTINCT item_id FROM impacts WHERE resolved_at IS NULL AND item_id = ANY($1)",
128    )
129    .bind(&item_ids)
130    .fetch_all(pool)
131    .await?
132    .into_iter()
133    .collect();
134
135    let mut findings = Vec::new();
136    let mut counts: BTreeMap<Issue, usize> = BTreeMap::new();
137    for (n, path) in &lines {
138        let code = n.revision.as_ref().map(|r| r.code.clone());
139        let mut add = |issue: Issue, detail: String| {
140            *counts.entry(issue).or_default() += 1;
141            findings.push(Finding {
142                issue,
143                level: n.level,
144                path: path.clone(),
145                item_number: n.item_number.clone(),
146                revision: code.clone(),
147                detail,
148            });
149        };
150        if n.cycle {
151            add(
152                Issue::Cycle,
153                "the structure loops back to an ancestor".into(),
154            );
155        }
156        if n.restricted {
157            add(
158                Issue::Restricted,
159                "restricted item – cannot be checked".into(),
160            );
161            continue;
162        }
163        match &n.revision {
164            None => add(
165                Issue::Unresolved,
166                format!("no revision matches the rule {}", rule.as_str()),
167            ),
168            Some(r) => {
169                match r.state.as_str() {
170                    "obsolete" => add(
171                        Issue::Obsolete,
172                        format!("{}/{} is obsolete", n.item_number, r.code),
173                    ),
174                    "released" => {}
175                    other => add(
176                        Issue::NotReleased,
177                        format!(
178                            "{}/{} is {}",
179                            n.item_number,
180                            r.code,
181                            other.replace('_', " ")
182                        ),
183                    ),
184                }
185                // Documents and software carry no mass.
186                if n.children.is_empty()
187                    && r.mass_kg.is_none()
188                    && !matches!(n.item_type.as_str(), "document" | "software")
189                {
190                    add(
191                        Issue::MissingMass,
192                        format!("{}/{} has no mass", n.item_number, r.code),
193                    );
194                }
195                if !with_files.contains(&r.id) && n.item_type != "software" {
196                    add(
197                        Issue::NoFiles,
198                        format!("{}/{} has no files", n.item_number, r.code),
199                    );
200                }
201                if let Some(who) = checked_out.get(&r.id) {
202                    add(
203                        Issue::CheckedOut,
204                        format!("{}/{} is checked out by {who}", n.item_number, r.code),
205                    );
206                }
207            }
208        }
209        if check_needed.contains(&n.item_id) {
210            add(
211                Issue::CheckNeeded,
212                format!(
213                    "{} has an open check after a change of something it uses",
214                    n.item_number
215                ),
216            );
217        }
218    }
219    let truncated = findings.len() > MAX_FINDINGS;
220    findings.truncate(MAX_FINDINGS);
221    Ok(BomHealth {
222        root: format!("{}/{}", tree.root_item_number, tree.root.code),
223        rule,
224        line_count: tree.line_count,
225        counts,
226        findings,
227        truncated,
228    })
229}
230
231/// A file whose content is stored in more than one item.
232#[derive(Clone, Debug, Serialize, sqlx::FromRow)]
233pub struct DuplicateFile {
234    pub sha256: String,
235    pub filename: String,
236    pub size_bytes: i64,
237    pub item_number: String,
238    pub revision_code: String,
239    pub revision_state: String,
240}
241
242/// Files of the latest revisions whose content (SHA-256) also belongs to the
243/// latest revision of another item – copies that drift apart over time. With
244/// `revision_id`, only the contents of that revision's files; a revision the
245/// actor may not see is "not found" (otherwise the answer would tell which
246/// visible files share its content).
247pub async fn duplicate_files(
248    pool: &PgPool,
249    actor: &Actor,
250    revision_id: Option<Uuid>,
251    limit: i64,
252) -> AppResult<Vec<DuplicateFile>> {
253    if let Some(id) = revision_id {
254        revisions::get(pool, actor, id).await?;
255    }
256    Ok(sqlx::query_as::<_, DuplicateFile>(&format!(
257        "WITH latest AS (
258             SELECT f.sha256, f.filename, f.size_bytes, i.number AS item_number, r.item_id,
259                    r.code AS revision_code, r.state AS revision_state
260             FROM files f
261             JOIN revisions r ON r.id = f.revision_id
262             JOIN items i ON i.id = r.item_id
263             WHERE r.seq = (SELECT MAX(x.seq) FROM revisions x WHERE x.item_id = r.item_id)
264               AND {}
265         ),
266         shared AS (
267             SELECT sha256 FROM latest GROUP BY sha256 HAVING COUNT(DISTINCT item_id) > 1
268         )
269         SELECT l.sha256, l.filename, l.size_bytes, l.item_number, l.revision_code, l.revision_state
270         FROM latest l JOIN shared s ON s.sha256 = l.sha256
271         WHERE $1::uuid IS NULL OR l.sha256 IN (SELECT sha256 FROM files WHERE revision_id = $1)
272         ORDER BY l.size_bytes DESC, l.sha256, l.item_number
273         LIMIT $2",
274        access::item_visible("i", 3)
275    ))
276    .bind(revision_id)
277    .bind(limit.clamp(1, 1000))
278    .bind(&actor.hidden_projects)
279    .fetch_all(pool)
280    .await?)
281}