rototo 0.1.0-alpha.6

Control plane for runtime configuration of your application.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;

use serde::Serialize;

use crate::error::{Result, RototoError};
use crate::lint::{ModelEntityRef, ModelReferenceVia, PackageSemanticModel};

use super::github::package_repo_path;
use super::store::PackageRecord;

/* The inventory derives from rototo's semantic model — the console does not
parse package files itself. */

/// Browser inventory for one staged package.
///
/// This is rebuilt from `PackageSemanticModel` plus a lightweight context
/// directory scan whenever a package or branch screen loads. It is not stored;
/// the source files and the staged semantic model own its lifecycle.
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PackageInventory {
    pub variables: Vec<VariableInventoryItem>,
    pub qualifiers: Vec<QualifierInventoryItem>,
    pub catalogs: Vec<CatalogInventoryItem>,
    pub catalog_entries: Vec<CatalogEntryInventoryItem>,
    pub linters: Vec<LinterInventoryItem>,
    pub context: ContextInventory,
}

/// Variable row in the console inventory.
///
/// It gives the UI enough resolved metadata to list, filter, and draw
/// references without reparsing TOML. Each item is derived from one semantic
/// variable model and disappears when that model no longer exists.
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VariableInventoryItem {
    pub id: String,
    pub path: String,
    pub description: Option<String>,
    pub declaration: String,
    pub default_value: Option<String>,
    pub rule_count: usize,
    pub qualifier_references: Vec<String>,
    /// Distinct string values selected by rules. For catalog-typed variables
    /// these name catalog values; primitive literals are not inventory links.
    pub rule_values: Vec<String>,
    pub catalog_reference: Option<String>,
}

/// Qualifier row in the console inventory.
///
/// It exists so screens can show named runtime conditions and their references
/// while leaving condition semantics to the Rust model. The item is regenerated
/// for each staged checkout.
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct QualifierInventoryItem {
    pub id: String,
    pub path: String,
    pub description: Option<String>,
    pub qualifier_references: Vec<String>,
}

/// Catalog row in the console inventory.
///
/// The console uses this projection to connect catalog declarations, schema
/// references, and entry counts. It is derived from the semantic model and
/// never cached separately from the staged package view.
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CatalogInventoryItem {
    pub id: String,
    pub path: String,
    pub description: Option<String>,
    pub schema: Option<String>,
    pub entry_count: usize,
}

/// Catalog value row in the console inventory.
///
/// This binds the catalog id and value name to the source path the editor can
/// open. It is rebuilt from catalog value models for each staged checkout.
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CatalogEntryInventoryItem {
    pub catalog_id: String,
    pub key: String,
    pub id: String,
    pub path: String,
}

/// Custom linter row in the console inventory.
///
/// It lets the UI navigate Lua lint scripts and show the rules declared by
/// each script. The item is derived from linter models and source paths, not a
/// persisted console table.
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LinterInventoryItem {
    pub id: String,
    pub title: Option<String>,
    pub path: Option<String>,
    pub kind: &'static str,
}

/// Evaluation context schemas and saved samples discovered for one package.
///
/// The semantic model owns `evaluation-contexts/<id>.schema.json` and
/// `evaluation-contexts/<id>-samples/*.json`. The projection is rebuilt with the
/// inventory and used for preview inputs.
#[derive(Clone, Debug, Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ContextInventory {
    pub evaluation_contexts: Vec<EvaluationContextInventoryItem>,
    pub samples: Vec<EvaluationContextSampleInventoryItem>,
    pub example_count: usize,
    pub examples: Vec<String>,
}

#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EvaluationContextInventoryItem {
    pub id: String,
    pub path: String,
    pub title: Option<String>,
    pub description: Option<String>,
    pub entry_count: usize,
}

#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EvaluationContextSampleInventoryItem {
    pub evaluation_context_id: String,
    pub key: String,
    pub id: String,
    pub path: String,
}

/// Source text loaded for one package definition file.
///
/// The editor receives this per request after the route validates that the path
/// belongs to the staged package. It is discarded once the response is sent.
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PackageDefinition {
    pub path: String,
    pub text: String,
    pub language: &'static str,
}

pub async fn inspect_package_inventory(
    package: &PackageRecord,
    model: &PackageSemanticModel,
    _staged_root: &Path,
) -> Result<PackageInventory> {
    let context = inspect_context(package, model);
    Ok(inventory_from_model(package, model, context))
}

pub async fn read_package_definition(
    package: &PackageRecord,
    staged_root: &Path,
    path: &str,
) -> Result<PackageDefinition> {
    let local_path = package_local_path(package, path)?;
    let text = tokio::fs::read_to_string(staged_root.join(&local_path))
        .await
        .map_err(|err| RototoError::new(format!("failed to read {path}: {err}")))?;
    Ok(PackageDefinition {
        path: path.to_owned(),
        text,
        language: language_for_path(path),
    })
}

fn inventory_from_model(
    package: &PackageRecord,
    model: &PackageSemanticModel,
    context: ContextInventory,
) -> PackageInventory {
    let repo_path = |path: &str| package_repo_path(&package.path, path);

    let variables = model
        .variables
        .iter()
        .map(|variable| {
            let rules: &[crate::lint::RuleModel] = variable
                .resolve
                .as_ref()
                .map(|resolve| resolve.rules.as_slice())
                .unwrap_or_default();
            let qualifier_references =
                distinct_sorted(model.references.iter().filter_map(|reference| {
                    match (&reference.from, &reference.to, &reference.via) {
                        (
                            ModelEntityRef::Variable { id: variable_id },
                            ModelEntityRef::Qualifier { id: qualifier_id },
                            ModelReferenceVia::RuleCondition { .. },
                        ) if variable_id == &variable.id => Some(qualifier_id.clone()),
                        _ => None,
                    }
                }));
            VariableInventoryItem {
                id: variable.id.clone(),
                path: repo_path(&variable.location.path),
                description: variable.description.clone(),
                declaration: declaration_label(&variable.declaration),
                default_value: (variable.declaration.kind == "catalog")
                    .then(|| {
                        variable
                            .resolve
                            .as_ref()
                            .and_then(|resolve| resolve.default.as_ref())
                            .and_then(|default| default.value.as_ref())
                            .and_then(|value| value.as_str())
                            .map(str::to_owned)
                    })
                    .flatten(),
                rule_count: rules.len(),
                qualifier_references,
                rule_values: if variable.declaration.kind == "catalog" {
                    distinct_sorted(
                        rules
                            .iter()
                            .filter_map(|rule| rule.value.as_ref())
                            .filter_map(|value| value.value.as_ref())
                            .filter_map(|value| value.as_str())
                            .map(str::to_owned),
                    )
                } else {
                    Vec::new()
                },
                catalog_reference: (variable.declaration.kind == "catalog")
                    .then(|| variable.declaration.value.clone())
                    .flatten(),
            }
        })
        .collect();

    let mut qualifier_edges: BTreeMap<&str, Vec<String>> = BTreeMap::new();
    for reference in &model.references {
        if let (
            crate::lint::ModelEntityRef::Qualifier { id: from },
            crate::lint::ModelEntityRef::Qualifier { id: to },
        ) = (&reference.from, &reference.to)
        {
            qualifier_edges.entry(from).or_default().push(to.clone());
        }
    }
    let qualifiers = model
        .qualifiers
        .iter()
        .map(|qualifier| QualifierInventoryItem {
            id: qualifier.id.clone(),
            path: repo_path(&qualifier.location.path),
            description: qualifier.description.clone(),
            qualifier_references: distinct_sorted(
                qualifier_edges
                    .get(qualifier.id.as_str())
                    .cloned()
                    .unwrap_or_default()
                    .into_iter(),
            ),
        })
        .collect();

    let mut entry_counts: BTreeMap<&str, usize> = BTreeMap::new();
    for entry in &model.catalog_entries {
        *entry_counts.entry(entry.catalog.as_str()).or_default() += 1;
    }
    let catalogs = model
        .catalogs
        .iter()
        .map(|catalog| CatalogInventoryItem {
            id: catalog.id.clone(),
            path: repo_path(&catalog.location.path),
            description: catalog.description.clone(),
            schema: Some(catalog.path.clone()),
            entry_count: entry_counts
                .get(catalog.id.as_str())
                .copied()
                .unwrap_or_default(),
        })
        .collect();

    let catalog_entries = model
        .catalog_entries
        .iter()
        .map(|entry| CatalogEntryInventoryItem {
            catalog_id: entry.catalog.clone(),
            key: entry.key.clone(),
            id: format!("{}/{}", entry.catalog, entry.key),
            path: repo_path(&entry.location.path),
        })
        .collect();

    let linters = model
        .linters
        .iter()
        .map(|linter| {
            let file_name = linter.path.rsplit('/').next().unwrap_or(&linter.path);
            let titles: BTreeSet<&str> = linter
                .rules
                .iter()
                .map(|rule| rule.title.as_str())
                .collect();
            LinterInventoryItem {
                id: file_name
                    .rsplit_once('.')
                    .map(|(stem, _)| stem.to_owned())
                    .unwrap_or_else(|| file_name.to_owned()),
                title: (!titles.is_empty())
                    .then(|| titles.into_iter().collect::<Vec<_>>().join(" · ")),
                path: Some(repo_path(&linter.path)),
                kind: "script",
            }
        })
        .collect();

    PackageInventory {
        variables,
        qualifiers,
        catalogs,
        catalog_entries,
        linters,
        context,
    }
}

fn declaration_label(declaration: &crate::lint::DeclarationModel) -> String {
    match declaration.kind.as_str() {
        "primitive" => declaration
            .value
            .clone()
            .unwrap_or_else(|| "undeclared".to_owned()),
        "catalog" => format!("catalog:{}", declaration.value.as_deref().unwrap_or("?")),
        "schema" => format!("schema:{}", declaration.value.as_deref().unwrap_or("?")),
        "missing" => "undeclared".to_owned(),
        other => other.to_owned(),
    }
}

fn inspect_context(package: &PackageRecord, model: &PackageSemanticModel) -> ContextInventory {
    let repo_path = |path: &str| package_repo_path(&package.path, path);
    let mut entry_counts: BTreeMap<&str, usize> = BTreeMap::new();
    for entry in &model.evaluation_context_samples {
        *entry_counts
            .entry(entry.evaluation_context.as_str())
            .or_default() += 1;
    }
    let evaluation_contexts = model
        .evaluation_contexts
        .iter()
        .map(|context| EvaluationContextInventoryItem {
            id: context.id.clone(),
            path: repo_path(&context.path),
            title: context.title.clone(),
            description: context.description.clone(),
            entry_count: entry_counts
                .get(context.id.as_str())
                .copied()
                .unwrap_or_default(),
        })
        .collect();
    let samples = model
        .evaluation_context_samples
        .iter()
        .map(|entry| EvaluationContextSampleInventoryItem {
            evaluation_context_id: entry.evaluation_context.clone(),
            key: entry.key.clone(),
            id: format!("{}/{}", entry.evaluation_context, entry.key),
            path: repo_path(&entry.path),
        })
        .collect::<Vec<_>>();
    let mut examples = samples
        .iter()
        .map(|entry| entry.path.clone())
        .collect::<Vec<_>>();
    examples.sort();
    ContextInventory {
        evaluation_contexts,
        example_count: samples.len(),
        examples,
        samples,
    }
}

/// Maps a repo path to a staged-checkout-relative path, rejecting anything
/// that escapes the package.
pub fn package_local_path(package: &PackageRecord, path: &str) -> Result<String> {
    if path.starts_with('/') || path.split('/').any(|segment| segment == "..") {
        return Err(RototoError::new(
            "package definition path must stay inside the package",
        ));
    }
    if package.path == "." {
        return Ok(path.to_owned());
    }
    let prefix = format!("{}/", package.path);
    path.strip_prefix(&prefix)
        .map(str::to_owned)
        .ok_or_else(|| RototoError::new("package definition path does not belong to this package"))
}

pub fn language_for_path(path: &str) -> &'static str {
    if path.ends_with(".toml") {
        "toml"
    } else if path.ends_with(".json") {
        "json"
    } else if path.ends_with(".lua") {
        "lua"
    } else {
        "text"
    }
}

fn distinct_sorted(values: impl Iterator<Item = String>) -> Vec<String> {
    let set: BTreeSet<String> = values.collect();
    set.into_iter().collect()
}