Skip to main content

rustio_core/admin/
suggestions.rs

1//! Actionable suggestion engine — 0.7.1.
2//!
3//! Derives concrete *add-this-field* suggestions from
4//! `(schema, context)`. Every suggestion is a thin descriptor the
5//! admin UI can turn into a button; clicking the button runs the
6//! planner, the review layer, and finally the executor — the
7//! standard chain. Nothing here bypasses safety gates.
8//!
9//! ## Scope (0.7.1)
10//!
11//! Only `AddField` suggestions for industry-required fields that a
12//! model is missing. Destructive / renaming / type-changing
13//! suggestions are explicitly out of scope — they need their own
14//! review pass and are deferred.
15//!
16//! ## What this module does NOT do
17//!
18//! - It does not call the planner or executor itself. It only
19//!   produces structured data (`Suggestion`) that describes what the
20//!   user could opt into. Wiring lives in `admin.rs`.
21//! - It does not touch the filesystem or database.
22
23use crate::admin::entry_builder::DynamicAdminEntry;
24use crate::admin::AdminEntry;
25use crate::ai::ContextConfig;
26
27/// How sure the suggestion engine is that this is the right action.
28///
29/// - [`Confidence::High`] — the field is explicitly listed in an
30///   industry convention. We know the name; the type comes from
31///   the planner's deterministic rules.
32/// - [`Confidence::Medium`] — the suggestion was inferred from a
33///   looser signal (heuristic, pattern match). Reserved for
34///   0.7.x+ when we start producing suggestions from data shape
35///   rather than explicit convention lists.
36///
37/// Rendered as a small badge next to the action button so the
38/// operator sees, before clicking, how trustworthy the proposal is.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Confidence {
41    High,
42    Medium,
43}
44
45impl Confidence {
46    pub fn as_str(self) -> &'static str {
47        match self {
48            Confidence::High => "High",
49            Confidence::Medium => "Medium",
50        }
51    }
52    /// CSS pill class reusing the existing status palette.
53    pub fn pill_class(self) -> &'static str {
54        match self {
55            Confidence::High => "badge-success",
56            Confidence::Medium => "badge-warning",
57        }
58    }
59}
60
61/// One proposed action shown next to a dashboard alert.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct Suggestion {
64    /// The model's display name (e.g. `"Applicants"`). Used for the
65    /// button label.
66    pub model_display: String,
67    /// The model's singular form (e.g. `"Applicant"`). Used in the
68    /// planner prompt.
69    pub model_singular: String,
70    /// The URL slug under `/admin/<admin_name>` — also used as the
71    /// routing key under `/admin/suggestions/<admin_name>/<field>`.
72    pub admin_name: String,
73    /// Field name the suggestion would add.
74    pub field: String,
75    /// Natural-language prompt handed to the planner when the user
76    /// accepts. Example: `"add annual_income to applicants"`.
77    pub prompt: String,
78    /// One-line human rationale shown beside the button ("Housing
79    /// industry convention", "GDPR retention required", …).
80    pub reason: String,
81    /// Short verb tag for the action type. Today always
82    /// `"add_field"`; reserved so future variants (`"make_required"`
83    /// etc.) can land without changing this struct.
84    pub action: &'static str,
85    /// How confident the engine is that this is the right move.
86    pub confidence: Confidence,
87}
88
89impl Suggestion {
90    /// Stable URL key under `/admin/suggestions/<admin_name>/<field>`.
91    /// Used by the dashboard to render the href and by the route
92    /// handler to re-derive + validate on both GET and POST.
93    pub fn url_path(&self) -> String {
94        format!(
95            "/admin/suggestions/{admin}/{field}",
96            admin = self.admin_name,
97            field = self.field,
98        )
99    }
100}
101
102/// Enumerate every suggestion for the current project. Empty when
103/// no context is loaded or when no model overlaps the industry's
104/// convention list. Deterministic: iteration follows the order of
105/// `entries` then `industry_schema.required_fields`.
106pub fn derive_suggestions(
107    entries: &[AdminEntry],
108    context: Option<&ContextConfig>,
109) -> Vec<Suggestion> {
110    let Some(ctx) = context else {
111        return Vec::new();
112    };
113    let Some(schema) = ctx.industry_schema() else {
114        return Vec::new();
115    };
116    let industry = ctx.industry.as_deref().unwrap_or("").to_string();
117
118    let mut out: Vec<Suggestion> = Vec::new();
119    for entry in entries.iter().filter(|e| !e.core) {
120        let field_names: Vec<&str> = entry.fields.iter().map(|f| f.name).collect();
121
122        // Same gate the dashboard uses: only surface suggestions on a
123        // model that already adopts *some* convention. Otherwise a
124        // `Widget` model under `industry=housing` would nag about
125        // personnummer, which is noise.
126        let covers_any = schema
127            .required_fields
128            .iter()
129            .any(|req| field_names.contains(&req.as_str()));
130        if !covers_any {
131            continue;
132        }
133
134        for req in &schema.required_fields {
135            if field_names.contains(&req.as_str()) {
136                continue;
137            }
138            let prompt = format!("add {req} to {admin}", admin = entry.admin_name);
139            out.push(Suggestion {
140                model_display: entry.display_name.to_string(),
141                model_singular: entry.singular_name.to_string(),
142                admin_name: entry.admin_name.to_string(),
143                field: req.clone(),
144                prompt,
145                reason: format!("{industry} industry convention"),
146                action: "add_field",
147                // Industry-required fields are explicit, named
148                // conventions — the engine isn't guessing. That's
149                // High confidence.
150                confidence: Confidence::High,
151            });
152        }
153    }
154    out
155}
156
157/// Look up a specific suggestion by `(admin_name, field)`. Returns
158/// `None` if the pair isn't in the current derived set — this is how
159/// the route handlers reject crafted URLs. An operator can only
160/// click through suggestions the engine actually produced.
161pub fn find_suggestion(
162    entries: &[AdminEntry],
163    context: Option<&ContextConfig>,
164    admin_name: &str,
165    field: &str,
166) -> Option<Suggestion> {
167    derive_suggestions(entries, context)
168        .into_iter()
169        .find(|s| s.admin_name == admin_name && s.field == field)
170}
171
172/// 0.7.3 schema-backed variant. Same rules as [`derive_suggestions`]
173/// but reads field names from [`DynamicAdminEntry`], which the admin
174/// builds fresh from [`crate::admin::schema_cache`] on every
175/// dashboard render. When the cache sees an updated
176/// `rustio.schema.json` — e.g. after `rustio ai apply` +
177/// `rustio schema` + `[Reload schema]` — the suggestion for the
178/// just-added field disappears on the next response, without
179/// restarting the process.
180pub fn derive_suggestions_from_entries(
181    _entries: &[DynamicAdminEntry],
182    _context: Option<&ContextConfig>,
183) -> Vec<Suggestion> {
184    // TODO(phase-5-followup): real implementation is deferred until the
185    // entry_builder module is fully ported from OLD. The stub returns an
186    // empty Vec so callers compile and behave as "no suggestions" — same
187    // outcome as the no-context branch of derive_suggestions. Unblocks
188    // once DynamicAdminEntry gains its real shape + build_admin_entries
189    // reads a live Schema.
190    Vec::new()
191}
192
193/// Schema-backed counterpart to [`find_suggestion`].
194pub fn find_suggestion_from_entries(
195    _entries: &[DynamicAdminEntry],
196    _context: Option<&ContextConfig>,
197    _admin_name: &str,
198    _field: &str,
199) -> Option<Suggestion> {
200    // TODO(phase-5-followup): stubbed for the same reason as
201    // derive_suggestions_from_entries — returns None until entry_builder
202    // lands.
203    None
204}
205
206/// 0.8.0 — propose linking an orphan `<thing>_id` column to a known
207/// model when the schema has no [`Relation`](crate::schema::Relation)
208/// recorded for it. Fired from `&Schema` directly because relation
209/// metadata lives there (not on admin entries).
210///
211/// Matching rule: take the column name, strip the trailing `_id`, and
212/// look for a model whose `singular_name` (case-insensitively) matches
213/// the singularised remainder. If multiple models match — or none do —
214/// skip: refusing is safer than guessing.
215///
216/// Deterministic: iteration follows `schema.models` then
217/// `model.fields` order. No I/O, no allocation beyond the returned vec.
218pub fn derive_relation_suggestions(schema: &crate::schema::Schema) -> Vec<Suggestion> {
219    let mut out: Vec<Suggestion> = Vec::new();
220    for model in schema.models.iter().filter(|m| !m.core) {
221        for field in &model.fields {
222            if field.name == "id" || !field.name.ends_with("_id") {
223                continue;
224            }
225            if field.relation.is_some() {
226                continue;
227            }
228            let stem = &field.name[..field.name.len() - 3];
229            if stem.is_empty() {
230                continue;
231            }
232            // Find the target: prefer a singular_name match; fall back
233            // to the model name. Refuse on ambiguity or no match.
234            let mut candidates: Vec<&crate::schema::SchemaModel> = schema
235                .models
236                .iter()
237                .filter(|m| {
238                    m.singular_name.eq_ignore_ascii_case(stem) || m.name.eq_ignore_ascii_case(stem)
239                })
240                .collect();
241            candidates.dedup_by(|a, b| a.name == b.name);
242            if candidates.len() != 1 {
243                continue;
244            }
245            let target = candidates[0];
246            if target.name == model.name {
247                // Self-joins ("parent_id" on Category → Category) are
248                // legitimate but easy to propose by accident. Skip in
249                // 0.8.0 — the user can still type the prompt by hand.
250                continue;
251            }
252            out.push(Suggestion {
253                model_display: model.display_name.clone(),
254                model_singular: model.singular_name.clone(),
255                admin_name: model.admin_name.clone(),
256                field: field.name.clone(),
257                prompt: format!(
258                    "link {from} to {to}",
259                    from = model.singular_name,
260                    to = target.singular_name,
261                ),
262                reason: format!(
263                    "`{}` looks like a foreign key to `{}` but no relation is recorded.",
264                    field.name, target.singular_name,
265                ),
266                action: "add_relation",
267                // We inferred the target from a naming convention,
268                // not from an explicit schema link. That's Medium —
269                // the operator sees the confidence pill and decides.
270                confidence: Confidence::Medium,
271            });
272        }
273    }
274    out
275}
276
277/// Companion to [`derive_relation_suggestions`] — locate one by
278/// `(admin_name, field)`. Same rejection-of-crafted-URLs story as
279/// [`find_suggestion`].
280pub fn find_relation_suggestion(
281    schema: &crate::schema::Schema,
282    admin_name: &str,
283    field: &str,
284) -> Option<Suggestion> {
285    derive_relation_suggestions(schema)
286        .into_iter()
287        .find(|s| s.admin_name == admin_name && s.field == field)
288}