Skip to main content

vantage_cmd/
cmd.rs

1//! The [`Cmd`] datasource — a locked command, declared env, and a
2//! registry of per-table Rhai scripts.
3
4use std::collections::HashMap;
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, Mutex};
7
8use indexmap::IndexMap;
9use vantage_core::{Result, error};
10
11use crate::rhai_engine::CompiledScript;
12
13/// Per-table configuration registered on a [`Cmd`]: the Rhai script that
14/// builds the argv and parses the output, plus optional command / env
15/// overrides that win over the datasource-level defaults.
16#[derive(Clone, Debug)]
17pub struct CmdSpec {
18    pub script: Arc<str>,
19    /// Optional per-row detail script. When set, the table loads in two
20    /// passes: `script` lists id stubs, and `detail` hydrates one record at
21    /// a time (with `id` in scope) via `get_value`. Both run the same locked
22    /// command — only the argv the script builds differs (e.g. gh's `runs`
23    /// vs `stats`). When `None`, the table is single-pass as before.
24    pub detail: Option<Arc<str>>,
25    pub command: Option<String>,
26    pub env: IndexMap<String, String>,
27}
28
29impl CmdSpec {
30    pub fn new(script: impl Into<Arc<str>>) -> Self {
31        Self {
32            script: script.into(),
33            detail: None,
34            command: None,
35            env: IndexMap::new(),
36        }
37    }
38
39    /// Register a per-row detail script (opt into two-pass loading).
40    pub fn with_detail(mut self, detail: impl Into<Arc<str>>) -> Self {
41        self.detail = Some(detail.into());
42        self
43    }
44
45    /// Override the locked command for this table only.
46    pub fn with_command(mut self, command: impl Into<String>) -> Self {
47        self.command = Some(command.into());
48        self
49    }
50
51    /// Declare an env var for this table only (merged over, and winning
52    /// against, the datasource-level env).
53    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
54        self.env.insert(key.into(), value.into());
55        self
56    }
57}
58
59/// A command-execution datasource.
60///
61/// Cheap to clone (everything is `Arc`-backed). The `command` and `env`
62/// here are the locked defaults; individual tables can be registered with
63/// their own [`CmdSpec`] overrides via [`Cmd::with_table`].
64#[derive(Clone)]
65pub struct Cmd {
66    command: Arc<str>,
67    env: Arc<IndexMap<String, String>>,
68    pass_path: bool,
69    base_dir: Option<Arc<Path>>,
70    scripts: Arc<IndexMap<String, CmdSpec>>,
71    /// Memoized compiled scripts, keyed by script name. Shared across
72    /// clones so a per-row detail loop reuses one engine + AST. Built
73    /// lazily on first use (see [`Cmd::compiled_script`]).
74    compiled: Arc<Mutex<HashMap<String, Arc<CompiledScript>>>>,
75    /// How many times each named script has actually been compiled —
76    /// diagnostics / test instrumentation for the reuse guarantee.
77    compile_counts: Arc<Mutex<HashMap<String, usize>>>,
78}
79
80impl std::fmt::Debug for Cmd {
81    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82        f.debug_struct("Cmd")
83            .field("command", &self.command)
84            .field("env", &self.env)
85            .field("pass_path", &self.pass_path)
86            .field("base_dir", &self.base_dir)
87            .field("scripts", &self.scripts)
88            .finish()
89    }
90}
91
92impl Cmd {
93    /// Build a datasource locked to `command` (e.g. `"aws"`).
94    pub fn new(command: impl Into<Arc<str>>) -> Self {
95        Self {
96            command: command.into(),
97            env: Arc::new(IndexMap::new()),
98            pass_path: true,
99            base_dir: None,
100            scripts: Arc::new(IndexMap::new()),
101            compiled: Arc::new(Mutex::new(HashMap::new())),
102            compile_counts: Arc::new(Mutex::new(HashMap::new())),
103        }
104    }
105
106    /// Declare a datasource-level env var, passed to every table's child
107    /// process unless a table overrides it.
108    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
109        Arc::make_mut(&mut self.env).insert(key.into(), value.into());
110        self
111    }
112
113    /// Whether to forward `PATH`/`HOME` from the current process so the
114    /// command can be located. Defaults to `true`; set `false` to require
115    /// an absolute command path and a fully-declared environment.
116    pub fn with_pass_path(mut self, pass_path: bool) -> Self {
117        self.pass_path = pass_path;
118        self
119    }
120
121    /// Set the base directory used to resolve a relative `command` *path*
122    /// and as the child process's working directory.
123    ///
124    /// A `command` that contains a path separator but isn't absolute (e.g.
125    /// `./scripts/gh-stats.py`) is resolved against this directory; bare
126    /// names (e.g. `gh`) are left untouched for `PATH` lookup, and absolute
127    /// paths pass through. When set, every table's child process also runs
128    /// with this directory as its working directory, so a script can resolve
129    /// sibling files relative to it.
130    pub fn with_base_dir(mut self, base_dir: impl Into<PathBuf>) -> Self {
131        self.base_dir = Some(Arc::from(base_dir.into()));
132        self
133    }
134
135    /// Register a script under `name` with no overrides.
136    pub fn with_script(self, name: impl Into<String>, script: impl Into<Arc<str>>) -> Self {
137        self.with_table(name, CmdSpec::new(script))
138    }
139
140    /// Register a fully-specified [`CmdSpec`] under `name`.
141    pub fn with_table(mut self, name: impl Into<String>, spec: CmdSpec) -> Self {
142        Arc::make_mut(&mut self.scripts).insert(name.into(), spec);
143        self
144    }
145
146    /// The locked default command.
147    pub fn command(&self) -> &str {
148        &self.command
149    }
150
151    pub(crate) fn pass_path(&self) -> bool {
152        self.pass_path
153    }
154
155    pub(crate) fn base_dir(&self) -> Option<Arc<Path>> {
156        self.base_dir.clone()
157    }
158
159    pub(crate) fn spec_for(&self, name: &str) -> Result<&CmdSpec> {
160        self.scripts.get(name).ok_or_else(|| {
161            error!(
162                "no command script registered for table",
163                table = name.to_string()
164            )
165        })
166    }
167
168    /// Effective command for a table: the spec override, else the locked default.
169    pub(crate) fn effective_command(&self, spec: &CmdSpec) -> String {
170        spec.command
171            .clone()
172            .unwrap_or_else(|| self.command.to_string())
173    }
174
175    /// Effective env for a table: datasource env, with the spec's env
176    /// merged on top (spec wins on key clash).
177    pub(crate) fn effective_env(&self, spec: &CmdSpec) -> IndexMap<String, String> {
178        let mut env = (*self.env).clone();
179        for (k, v) in &spec.env {
180            env.insert(k.clone(), v.clone());
181        }
182        env
183    }
184
185    /// Get (building on first use) the compiled engine + AST for the named
186    /// table's list script. Reused across calls.
187    pub(crate) fn compiled_list_script(&self, name: &str) -> Result<Arc<CompiledScript>> {
188        let spec = self.spec_for(name)?.clone();
189        self.compiled_for(name.to_string(), &spec, &spec.script)
190    }
191
192    /// Get the compiled detail script for the named table, or `None` if the
193    /// table has no detail script (single-pass). Reused across calls so a
194    /// per-row detail loop pays the parse/registration cost once.
195    pub(crate) fn compiled_detail_script(&self, name: &str) -> Result<Option<Arc<CompiledScript>>> {
196        let spec = self.spec_for(name)?.clone();
197        let Some(detail) = spec.detail.clone() else {
198            return Ok(None);
199        };
200        Ok(Some(self.compiled_for(
201            format!("{name}::detail"),
202            &spec,
203            &detail,
204        )?))
205    }
206
207    /// Compile (once) and memoize a script under `cache_key`, using the
208    /// spec's effective command + env. Shared across `Cmd` clones.
209    fn compiled_for(
210        &self,
211        cache_key: String,
212        spec: &CmdSpec,
213        script: &str,
214    ) -> Result<Arc<CompiledScript>> {
215        let mut cache = self.compiled.lock().unwrap();
216        if let Some(existing) = cache.get(&cache_key) {
217            return Ok(existing.clone());
218        }
219        let command = self.effective_command(spec);
220        let env = self.effective_env(spec);
221        let compiled = Arc::new(CompiledScript::compile(
222            command,
223            env,
224            self.pass_path(),
225            self.base_dir(),
226            script,
227        )?);
228        *self
229            .compile_counts
230            .lock()
231            .unwrap()
232            .entry(cache_key.clone())
233            .or_insert(0) += 1;
234        cache.insert(cache_key, compiled.clone());
235        Ok(compiled)
236    }
237
238    /// True if the named table has a detail script (two-pass loading).
239    pub(crate) fn has_detail_script(&self, name: &str) -> bool {
240        self.spec_for(name)
241            .map(|s| s.detail.is_some())
242            .unwrap_or(false)
243    }
244
245    /// How many times the named script has been compiled. Reuse means this
246    /// stays at 1 no matter how many reads run.
247    pub fn compile_count(&self, name: &str) -> usize {
248        self.compile_counts
249            .lock()
250            .unwrap()
251            .get(name)
252            .copied()
253            .unwrap_or(0)
254    }
255
256    /// A Vista factory bound to this datasource.
257    pub fn vista_factory(&self) -> crate::vista::factory::CmdVistaFactory {
258        crate::vista::factory::CmdVistaFactory::new(self.clone())
259    }
260}