Skip to main content

vantage_cli_util/
model_cli.rs

1//! Generic model-driven CLI runner.
2//!
3//! Drives an `AnyTable` from positional argv tokens — model name, ARN,
4//! `field=value` filters, `[N]` index selectors, and `:relation`
5//! traversals. Backend specifics (which names map to which tables, how
6//! ARNs are parsed, how records are rendered) are injected through the
7//! [`ModelFactory`] and [`Renderer`] traits.
8//!
9//! ## Token forms
10//!
11//! - `arn:...` — ARN; resolved via [`ModelFactory::for_arn`]; drops
12//!   straight into single-record mode.
13//! - `iam.user`, `iam.users` — model name (singular drops into single
14//!   mode, plural into list mode).
15//! - `field=value` or `field="quoted value"` — adds an equality
16//!   condition. Multiple are ANDed.
17//! - `[N]` — selects the Nth record from a list (zero-indexed) and
18//!   narrows the table to that record. Switches to single-record mode.
19//! - `:relation` — traverses a relation registered via `with_many` /
20//!   `with_one`. Only allowed in single-record mode (so the deferred
21//!   child query yields a single foreign-key value); switches to list
22//!   mode for the child table.
23//! - `=col1,col2,...` — overrides the visible columns in list mode.
24//!   Stays in effect for the rest of the run; later relation
25//!   traversals reset it. The literal `id` resolves to the table's id
26//!   field at render time.
27//!
28//! Glued forms are accepted: `users[0]`, `:members[0]`,
29//! `name=foo[0]`, and `=col1,col2[0]` all split into a base token plus
30//! an index selector.
31
32use ciborium::Value as CborValue;
33use indexmap::IndexMap;
34use vantage_core::{Result, error};
35use vantage_dataset::traits::ReadableValueSet;
36use vantage_table::any::AnyTable;
37use vantage_table::traits::table_like::TableLike;
38use vantage_types::Record;
39
40/// Whether the current state is a list of records or a single record.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Mode {
43    List,
44    Single,
45}
46
47/// Resolves model identifiers (singular/plural names, ARNs) to
48/// `AnyTable`s. Implemented per-backend.
49pub trait ModelFactory {
50    /// Resolve a model name (e.g. `iam.user` or `iam.users`).
51    /// Singular names should return [`Mode::Single`], plural
52    /// [`Mode::List`]. Returns `None` for unknown names.
53    fn for_name(&self, name: &str) -> Option<(AnyTable, Mode)>;
54
55    /// Resolve an ARN to a single-record table with any required
56    /// conditions (e.g. resource-name eq) already applied.
57    fn for_arn(&self, arn: &str) -> Option<AnyTable>;
58}
59
60/// Backend hook for printing list and single-record results.
61pub trait Renderer {
62    fn render_list(
63        &self,
64        table: &AnyTable,
65        records: &IndexMap<String, Record<CborValue>>,
66        column_override: Option<&[String]>,
67    );
68    fn render_record(
69        &self,
70        table: &AnyTable,
71        id: &str,
72        record: &Record<CborValue>,
73        relations: &[String],
74    );
75}
76
77#[derive(Debug)]
78enum Token {
79    ModelName(String, Option<usize>),
80    Arn(String),
81    Condition(String, String, Option<usize>),
82    Relation(String, Option<usize>),
83    Index(usize),
84    Columns(Vec<String>, Option<usize>),
85}
86
87fn split_index_suffix(s: &str) -> (&str, Option<usize>) {
88    if let Some(stripped) = s.strip_suffix(']')
89        && let Some(open) = stripped.rfind('[')
90    {
91        let inner = &stripped[open + 1..];
92        if !inner.is_empty()
93            && inner.chars().all(|c| c.is_ascii_digit())
94            && let Ok(n) = inner.parse::<usize>()
95        {
96            return (&stripped[..open], Some(n));
97        }
98    }
99    (s, None)
100}
101
102fn parse_token(arg: &str) -> Result<Token> {
103    if arg.is_empty() {
104        return Err(error!("Empty argument"));
105    }
106    if arg.starts_with("arn:") {
107        return Ok(Token::Arn(arg.to_string()));
108    }
109    if let Some(rest) = arg.strip_prefix(':') {
110        let (rel, idx) = split_index_suffix(rest);
111        if rel.is_empty() {
112            return Err(error!(format!("Empty relation name in token `{arg}`")));
113        }
114        return Ok(Token::Relation(rel.to_string(), idx));
115    }
116    if arg.starts_with('[') {
117        let (_, idx) = split_index_suffix(arg);
118        let idx = idx.ok_or_else(|| error!(format!("Invalid index token `{arg}`")))?;
119        return Ok(Token::Index(idx));
120    }
121    if let Some(rest) = arg.strip_prefix('=') {
122        let (cols_part, idx) = split_index_suffix(rest);
123        if cols_part.is_empty() {
124            return Err(error!(format!(
125                "Empty column list in token `{arg}` — write `=col1,col2`"
126            )));
127        }
128        let cols: Vec<String> = cols_part
129            .split(',')
130            .map(|s| s.trim().to_string())
131            .filter(|s| !s.is_empty())
132            .collect();
133        if cols.is_empty() {
134            return Err(error!(format!("Empty column list in token `{arg}`")));
135        }
136        return Ok(Token::Columns(cols, idx));
137    }
138    if let Some(eq_pos) = arg.find('=') {
139        let field = arg[..eq_pos].to_string();
140        if field.is_empty() {
141            return Err(error!(format!("Empty field name in token `{arg}`")));
142        }
143        let value_part = &arg[eq_pos + 1..];
144        let (value, idx) =
145            if value_part.starts_with('"') && value_part.ends_with('"') && value_part.len() >= 2 {
146                (value_part[1..value_part.len() - 1].to_string(), None)
147            } else {
148                let (v, i) = split_index_suffix(value_part);
149                (v.to_string(), i)
150            };
151        return Ok(Token::Condition(field, value, idx));
152    }
153    let (name, idx) = split_index_suffix(arg);
154    Ok(Token::ModelName(name.to_string(), idx))
155}
156
157/// Run a model-driven CLI.
158///
159/// `args` is the list of positional arguments after any global flags
160/// (region, profile, etc.) have been stripped out.
161pub async fn run<F: ModelFactory, R: Renderer>(
162    factory: &F,
163    renderer: &R,
164    args: &[String],
165) -> Result<()> {
166    if args.is_empty() {
167        return Err(error!(
168            "No model specified — pass a model name (e.g. `iam.users`) or an ARN"
169        ));
170    }
171
172    let mut tokens: Vec<Token> = args.iter().map(|s| parse_token(s)).collect::<Result<_>>()?;
173    let first = tokens.remove(0);
174    let mut column_override: Option<Vec<String>> = None;
175
176    let (mut table, mut mode) = match first {
177        Token::ModelName(name, idx) => {
178            let (t, m) = factory
179                .for_name(&name)
180                .ok_or_else(|| error!(format!("Unknown model `{name}`")))?;
181            if let Some(i) = idx {
182                apply_index(t, i).await?
183            } else {
184                (t, m)
185            }
186        }
187        Token::Arn(arn) => {
188            let t = factory
189                .for_arn(&arn)
190                .ok_or_else(|| error!(format!("Cannot resolve ARN `{arn}`")))?;
191            (t, Mode::Single)
192        }
193        Token::Condition(_, _, _)
194        | Token::Relation(_, _)
195        | Token::Index(_)
196        | Token::Columns(_, _) => {
197            return Err(error!(format!(
198                "First argument must be a model name or ARN, got `{}`",
199                args[0]
200            )));
201        }
202    };
203
204    for token in tokens {
205        match token {
206            Token::Condition(field, value, idx) => {
207                // `id=value` is a sugared "fetch this specific record":
208                // resolve to the actual id field and force single-record
209                // mode. Anything else is just a regular eq filter.
210                let is_id_alias = field == "id";
211                let resolved_field = if is_id_alias {
212                    table.id_field_name().ok_or_else(|| {
213                        error!(format!(
214                            "`id=` used but table `{}` has no id field",
215                            table.table_name()
216                        ))
217                    })?
218                } else {
219                    field.clone()
220                };
221                table.add_condition_eq(&resolved_field, &value)?;
222                if is_id_alias {
223                    mode = Mode::Single;
224                }
225                if let Some(i) = idx {
226                    let (t, m) = apply_index(table, i).await?;
227                    table = t;
228                    mode = m;
229                }
230            }
231            Token::Index(i) => {
232                let (t, m) = apply_index(table, i).await?;
233                table = t;
234                mode = m;
235            }
236            Token::Relation(rel, idx) => {
237                if mode != Mode::Single {
238                    return Err(error!(format!(
239                        "Cannot traverse `:{rel}` from list mode — narrow to a single record first (add a filter or `[N]`)"
240                    )));
241                }
242                table = table.get_ref(&rel)?;
243                mode = Mode::List;
244                // A new table means the column override no longer
245                // applies — drop it so the child renders with its own
246                // default columns until a new override appears.
247                column_override = None;
248                if let Some(i) = idx {
249                    let (t, m) = apply_index(table, i).await?;
250                    table = t;
251                    mode = m;
252                }
253            }
254            Token::Columns(cols, idx) => {
255                column_override = Some(cols);
256                if let Some(i) = idx {
257                    let (t, m) = apply_index(table, i).await?;
258                    table = t;
259                    mode = m;
260                }
261            }
262            Token::ModelName(_, _) | Token::Arn(_) => {
263                return Err(error!(
264                    "Model name or ARN may only appear as the first argument"
265                ));
266            }
267        }
268    }
269
270    match mode {
271        Mode::List => {
272            let records = table.list_values().await?;
273            renderer.render_list(&table, &records, column_override.as_deref());
274        }
275        Mode::Single => {
276            let (id, record) = table
277                .get_some_value()
278                .await?
279                .ok_or_else(|| error!("No record found"))?;
280            let relations = table.get_ref_names();
281            renderer.render_record(&table, &id, &record, &relations);
282        }
283    }
284
285    Ok(())
286}
287
288/// List the table, take the Nth row, narrow the table to that row by
289/// adding `eq(id_field, that_id)`. Returns the narrowed table in
290/// single-record mode so subsequent traversals see one parent.
291async fn apply_index(mut table: AnyTable, index: usize) -> Result<(AnyTable, Mode)> {
292    let records = table.list_values().await?;
293    let total = records.len();
294    let (id, _record) = records.into_iter().nth(index).ok_or_else(|| {
295        error!(format!(
296            "Index [{index}] out of bounds — only {total} record(s) match"
297        ))
298    })?;
299    let id_field = table.id_field_name().ok_or_else(|| {
300        error!(format!(
301            "Cannot apply index — table `{}` has no id field",
302            table.table_name()
303        ))
304    })?;
305    table.add_condition_eq(&id_field, &id)?;
306    Ok((table, Mode::Single))
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[test]
314    fn token_split_index_suffix() {
315        assert_eq!(split_index_suffix("users"), ("users", None));
316        assert_eq!(split_index_suffix("users[0]"), ("users", Some(0)));
317        assert_eq!(split_index_suffix("users[42]"), ("users", Some(42)));
318        assert_eq!(split_index_suffix("[3]"), ("", Some(3)));
319        assert_eq!(split_index_suffix("foo[bar]"), ("foo[bar]", None));
320        assert_eq!(split_index_suffix("foo[]"), ("foo[]", None));
321    }
322
323    #[test]
324    fn token_parse_kinds() {
325        match parse_token("iam.users").unwrap() {
326            Token::ModelName(n, i) => {
327                assert_eq!(n, "iam.users");
328                assert_eq!(i, None);
329            }
330            t => panic!("expected ModelName, got {t:?}"),
331        }
332        match parse_token("iam.users[0]").unwrap() {
333            Token::ModelName(n, i) => {
334                assert_eq!(n, "iam.users");
335                assert_eq!(i, Some(0));
336            }
337            t => panic!("expected ModelName with index, got {t:?}"),
338        }
339        match parse_token(":members[2]").unwrap() {
340            Token::Relation(r, i) => {
341                assert_eq!(r, "members");
342                assert_eq!(i, Some(2));
343            }
344            t => panic!("expected Relation, got {t:?}"),
345        }
346        match parse_token("name=alice").unwrap() {
347            Token::Condition(f, v, i) => {
348                assert_eq!(f, "name");
349                assert_eq!(v, "alice");
350                assert_eq!(i, None);
351            }
352            t => panic!("expected Condition, got {t:?}"),
353        }
354        match parse_token("name=\"john doe\"").unwrap() {
355            Token::Condition(f, v, i) => {
356                assert_eq!(f, "name");
357                assert_eq!(v, "john doe");
358                assert_eq!(i, None);
359            }
360            t => panic!("expected Condition, got {t:?}"),
361        }
362        match parse_token("name=alice[0]").unwrap() {
363            Token::Condition(f, v, i) => {
364                assert_eq!(f, "name");
365                assert_eq!(v, "alice");
366                assert_eq!(i, Some(0));
367            }
368            t => panic!("expected Condition with index, got {t:?}"),
369        }
370        match parse_token("[7]").unwrap() {
371            Token::Index(i) => assert_eq!(i, 7),
372            t => panic!("expected Index, got {t:?}"),
373        }
374        match parse_token("arn:aws:iam::123:user/alice").unwrap() {
375            Token::Arn(s) => assert_eq!(s, "arn:aws:iam::123:user/alice"),
376            t => panic!("expected Arn, got {t:?}"),
377        }
378        match parse_token("=timestamp,message").unwrap() {
379            Token::Columns(cols, idx) => {
380                assert_eq!(cols, vec!["timestamp".to_string(), "message".to_string()]);
381                assert_eq!(idx, None);
382            }
383            t => panic!("expected Columns, got {t:?}"),
384        }
385        match parse_token("=id, name [0]").unwrap_or_else(|_| {
386            // trim handles the spaces, but split on `[0]` requires no
387            // intervening space — skip that variant.
388            parse_token("=id,name[0]").unwrap()
389        }) {
390            Token::Columns(cols, idx) => {
391                assert_eq!(cols, vec!["id".to_string(), "name".to_string()]);
392                assert_eq!(idx, Some(0));
393            }
394            t => panic!("expected Columns with index, got {t:?}"),
395        }
396    }
397}