Skip to main content

vantage_cli_util/
vista_cli.rs

1//! Generic model-driven CLI runner, Vista edition.
2//!
3//! Mirrors [`crate::model_cli`] but drives a [`Vista`] instead of an
4//! `AnyTable`. The token shapes and flow are identical (`model_name`,
5//! `field=value`, `[N]`, `:relation`, `=col1,col2`) — only the
6//! underlying type changes.
7//!
8//! See `model_cli` for the full token-shape reference; this module
9//! repeats only the behaviour notes that differ for Vista.
10
11use ciborium::Value as CborValue;
12use indexmap::IndexMap;
13use vantage_core::{Result, error};
14use vantage_dataset::traits::ReadableValueSet;
15use vantage_types::Record;
16use vantage_vista::{ReferenceKind, Vista};
17
18/// Whether the current state is a list of records or a single record.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Mode {
21    List,
22    Single,
23}
24
25/// Resolves model identifiers (singular/plural names, ARNs) to
26/// `Vista`s. Implemented per-backend.
27pub trait ModelFactory {
28    /// Resolve a model name (e.g. `users` or `user`).
29    /// Singular names should return [`Mode::Single`], plural
30    /// [`Mode::List`]. Returns `None` for unknown names.
31    fn for_name(&self, name: &str) -> Option<(Vista, Mode)>;
32
33    /// Resolve an ARN to a single-record `Vista` with any required
34    /// conditions already applied. Backends without an ARN syntax
35    /// can leave the default `None`.
36    fn for_arn(&self, _arn: &str) -> Option<Vista> {
37        None
38    }
39}
40
41/// Backend hook for printing list and single-record results.
42pub trait Renderer {
43    fn render_list(
44        &self,
45        vista: &Vista,
46        records: &IndexMap<String, Record<CborValue>>,
47        column_override: Option<&[String]>,
48    );
49    fn render_record(
50        &self,
51        vista: &Vista,
52        id: &str,
53        record: &Record<CborValue>,
54        relations: &[String],
55    );
56}
57
58#[derive(Debug)]
59enum Token {
60    ModelName(String, Option<usize>),
61    Arn(String),
62    Condition(String, String, Option<usize>),
63    Relation(String, Option<usize>),
64    Index(usize),
65    Columns(Vec<String>, Option<usize>),
66}
67
68fn split_index_suffix(s: &str) -> (&str, Option<usize>) {
69    if let Some(stripped) = s.strip_suffix(']')
70        && let Some(open) = stripped.rfind('[')
71    {
72        let inner = &stripped[open + 1..];
73        if !inner.is_empty()
74            && inner.chars().all(|c| c.is_ascii_digit())
75            && let Ok(n) = inner.parse::<usize>()
76        {
77            return (&stripped[..open], Some(n));
78        }
79    }
80    (s, None)
81}
82
83fn parse_token(arg: &str) -> Result<Token> {
84    if arg.is_empty() {
85        return Err(error!("Empty argument"));
86    }
87    if arg.starts_with("arn:") {
88        return Ok(Token::Arn(arg.to_string()));
89    }
90    if let Some(rest) = arg.strip_prefix(':') {
91        let (rel, idx) = split_index_suffix(rest);
92        if rel.is_empty() {
93            return Err(error!(format!("Empty relation name in token `{arg}`")));
94        }
95        return Ok(Token::Relation(rel.to_string(), idx));
96    }
97    if arg.starts_with('[') {
98        let (_, idx) = split_index_suffix(arg);
99        let idx = idx.ok_or_else(|| error!(format!("Invalid index token `{arg}`")))?;
100        return Ok(Token::Index(idx));
101    }
102    if let Some(rest) = arg.strip_prefix('=') {
103        let (cols_part, idx) = split_index_suffix(rest);
104        if cols_part.is_empty() {
105            return Err(error!(format!(
106                "Empty column list in token `{arg}` — write `=col1,col2`"
107            )));
108        }
109        let cols: Vec<String> = cols_part
110            .split(',')
111            .map(|s| s.trim().to_string())
112            .filter(|s| !s.is_empty())
113            .collect();
114        if cols.is_empty() {
115            return Err(error!(format!("Empty column list in token `{arg}`")));
116        }
117        return Ok(Token::Columns(cols, idx));
118    }
119    if let Some(eq_pos) = arg.find('=') {
120        let field = arg[..eq_pos].to_string();
121        if field.is_empty() {
122            return Err(error!(format!("Empty field name in token `{arg}`")));
123        }
124        let value_part = &arg[eq_pos + 1..];
125        let (value, idx) =
126            if value_part.starts_with('"') && value_part.ends_with('"') && value_part.len() >= 2 {
127                (value_part[1..value_part.len() - 1].to_string(), None)
128            } else {
129                let (v, i) = split_index_suffix(value_part);
130                (v.to_string(), i)
131            };
132        return Ok(Token::Condition(field, value, idx));
133    }
134    let (name, idx) = split_index_suffix(arg);
135    Ok(Token::ModelName(name.to_string(), idx))
136}
137
138/// Run a Vista-backed model-driven CLI.
139///
140/// `args` is the list of positional arguments after any global flags
141/// have been stripped out.
142pub async fn run<F: ModelFactory, R: Renderer>(
143    factory: &F,
144    renderer: &R,
145    args: &[String],
146) -> Result<()> {
147    if args.is_empty() {
148        return Err(error!(
149            "No model specified — pass a model name (e.g. `users`) or an ARN"
150        ));
151    }
152
153    let mut tokens: Vec<Token> = args.iter().map(|s| parse_token(s)).collect::<Result<_>>()?;
154    let first = tokens.remove(0);
155    let mut column_override: Option<Vec<String>> = None;
156
157    let (mut vista, mut mode) = match first {
158        Token::ModelName(name, idx) => {
159            let (v, m) = factory
160                .for_name(&name)
161                .ok_or_else(|| error!(format!("Unknown model `{name}`")))?;
162            if let Some(i) = idx {
163                apply_index(v, i).await?
164            } else {
165                (v, m)
166            }
167        }
168        Token::Arn(arn) => {
169            let v = factory
170                .for_arn(&arn)
171                .ok_or_else(|| error!(format!("Cannot resolve ARN `{arn}`")))?;
172            (v, Mode::Single)
173        }
174        Token::Condition(_, _, _)
175        | Token::Relation(_, _)
176        | Token::Index(_)
177        | Token::Columns(_, _) => {
178            return Err(error!(format!(
179                "First argument must be a model name or ARN, got `{}`",
180                args[0]
181            )));
182        }
183    };
184
185    for token in tokens {
186        match token {
187            Token::Condition(field, value, idx) => {
188                // `id=value` is a sugared "fetch this specific record":
189                // resolve to the actual id field and force single-record
190                // mode. Anything else is just a regular eq filter.
191                let is_id_alias = field == "id";
192                let resolved_field = if is_id_alias {
193                    vista.get_id_column().map(str::to_string).ok_or_else(|| {
194                        error!(format!(
195                            "`id=` used but vista `{}` has no id column",
196                            vista.name()
197                        ))
198                    })?
199                } else {
200                    field.clone()
201                };
202                vista.add_condition_eq(&resolved_field, string_to_cbor(&value))?;
203                if is_id_alias {
204                    mode = Mode::Single;
205                }
206                if let Some(i) = idx {
207                    let (v, m) = apply_index(vista, i).await?;
208                    vista = v;
209                    mode = m;
210                }
211            }
212            Token::Index(i) => {
213                let (v, m) = apply_index(vista, i).await?;
214                vista = v;
215                mode = m;
216            }
217            Token::Relation(rel, idx) => {
218                if mode != Mode::Single {
219                    return Err(error!(format!(
220                        "Cannot traverse `:{rel}` from list mode — narrow to a single record first (add a filter or `[N]`)"
221                    )));
222                }
223                // Inspect the source vista's reference catalogue *before*
224                // traversal so we know whether the child should render as a
225                // single record (HasOne) or a list (HasMany). The catalogue
226                // combines foreign resolvers, YAML metadata, and the
227                // wrapped table's typed refs — see `Vista::list_references`.
228                let child_kind = vista
229                    .list_references()
230                    .into_iter()
231                    .find(|(name, _)| name == &rel)
232                    .map(|(_, k)| k);
233                // Row-based traversal needs the actual parent record on hand,
234                // so we fetch it from the narrowed vista before handing it to
235                // `get_ref`. `Mode::Single` guarantees a single row matches.
236                let (_id, parent_row) = vista.get_some_value().await?.ok_or_else(|| {
237                    error!(format!(
238                        "Cannot traverse `:{rel}` — narrowed vista has no matching record"
239                    ))
240                })?;
241                vista = vista.get_ref(&rel, &parent_row)?;
242                mode = match child_kind {
243                    Some(ReferenceKind::HasOne) => Mode::Single,
244                    // `HasMany` and unknown (e.g. relation missing from
245                    // `list_references` but accepted by the shell) both
246                    // render as list — list is the safe default since it
247                    // tolerates zero/one/many rows.
248                    _ => Mode::List,
249                };
250                // A new vista means the column override no longer
251                // applies — drop it so the child renders with its own
252                // default columns until a new override appears.
253                column_override = None;
254                if let Some(i) = idx {
255                    let (v, m) = apply_index(vista, i).await?;
256                    vista = v;
257                    mode = m;
258                }
259            }
260            Token::Columns(cols, idx) => {
261                column_override = Some(cols);
262                if let Some(i) = idx {
263                    let (v, m) = apply_index(vista, i).await?;
264                    vista = v;
265                    mode = m;
266                }
267            }
268            Token::ModelName(_, _) | Token::Arn(_) => {
269                return Err(error!(
270                    "Model name or ARN may only appear as the first argument"
271                ));
272            }
273        }
274    }
275
276    match mode {
277        Mode::List => {
278            let records = vista.list_values().await?;
279            renderer.render_list(&vista, &records, column_override.as_deref());
280        }
281        Mode::Single => {
282            let (id, record) = vista
283                .get_some_value()
284                .await?
285                .ok_or_else(|| error!("No record found"))?;
286            let relations: Vec<String> = vista
287                .get_references()
288                .iter()
289                .map(|s| s.to_string())
290                .collect();
291            renderer.render_record(&vista, &id, &record, &relations);
292        }
293    }
294
295    Ok(())
296}
297
298/// CLI tokens carry filter values as plain strings. Coerce them into
299/// the cheapest matching CBOR scalar: integer if it parses, else
300/// float, else booleans, else text. Drivers translate further at
301/// their own boundary.
302fn string_to_cbor(value: &str) -> CborValue {
303    if let Ok(i) = value.parse::<i64>() {
304        CborValue::Integer(i.into())
305    } else if let Ok(f) = value.parse::<f64>() {
306        CborValue::Float(f)
307    } else if value == "true" {
308        CborValue::Bool(true)
309    } else if value == "false" {
310        CborValue::Bool(false)
311    } else {
312        CborValue::Text(value.to_string())
313    }
314}
315
316/// List the vista, take the Nth row, narrow the vista to that row by
317/// adding `eq(id_field, that_id)`. Returns the narrowed vista in
318/// single-record mode so subsequent traversals see one parent.
319async fn apply_index(mut vista: Vista, index: usize) -> Result<(Vista, Mode)> {
320    let records = vista.list_values().await?;
321    let total = records.len();
322    let (id, _record) = records.into_iter().nth(index).ok_or_else(|| {
323        error!(format!(
324            "Index [{index}] out of bounds — only {total} record(s) match"
325        ))
326    })?;
327    let id_field = vista.get_id_column().map(str::to_string).ok_or_else(|| {
328        error!(format!(
329            "Cannot apply index — vista `{}` has no id column",
330            vista.name()
331        ))
332    })?;
333    vista.add_condition_eq(&id_field, string_to_cbor(&id))?;
334    Ok((vista, Mode::Single))
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn token_split_index_suffix() {
343        assert_eq!(split_index_suffix("users"), ("users", None));
344        assert_eq!(split_index_suffix("users[0]"), ("users", Some(0)));
345        assert_eq!(split_index_suffix("[3]"), ("", Some(3)));
346        assert_eq!(split_index_suffix("foo[bar]"), ("foo[bar]", None));
347    }
348
349    #[test]
350    fn token_parse_relation() {
351        match parse_token(":albums[2]").unwrap() {
352            Token::Relation(r, i) => {
353                assert_eq!(r, "albums");
354                assert_eq!(i, Some(2));
355            }
356            t => panic!("expected Relation, got {t:?}"),
357        }
358    }
359}