Skip to main content

sim_lib_compute_cli/
args.rs

1//! Argument parsing for the loadable compute command.
2
3use crate::ComputeCliError;
4
5const MAX_SELECTOR_BYTES: usize = 64;
6const MAX_DEVICES: usize = 64;
7const MAX_PROFILE_BYTES: usize = 64 * 1024;
8
9/// Output encoding requested by the command.
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11pub enum OutputMode {
12    /// Stable human-readable text.
13    Text,
14    /// Stable machine-readable JSON.
15    Json,
16}
17
18/// Profile store action.
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub enum ProfileAction {
21    /// List stored profile keys.
22    List,
23    /// Read and check one stored profile.
24    Read,
25    /// Save a bounded synthetic profile.
26    Save,
27}
28
29/// Parsed compute CLI command.
30#[derive(Clone, Debug, Eq, PartialEq)]
31pub enum ComputeCommand {
32    /// Render command help.
33    Help,
34    /// List installed compute providers.
35    Devices(Selection),
36    /// Render bounded probe evidence for selected providers.
37    Probe(Selection),
38    /// Read, write, or list injected profile storage.
39    Profile(ProfileRequest),
40    /// Explain profile routing for one selected device identity.
41    Explain(ProfileRequest),
42    /// Render a checked recipe descriptor.
43    Recipe(RecipeRequest),
44    /// Capture, verify, or import a physical acceptance artifact.
45    Acceptance(AcceptanceRequest),
46}
47
48/// Provider selection and common output options.
49#[derive(Clone, Debug, Eq, PartialEq)]
50pub struct Selection {
51    /// Optional provider selector.
52    pub selector: Option<String>,
53    /// Output encoding.
54    pub output: OutputMode,
55    /// Maximum provider rows to inspect.
56    pub max_devices: usize,
57}
58
59/// Profile command options.
60#[derive(Clone, Debug, Eq, PartialEq)]
61pub struct ProfileRequest {
62    /// Requested action.
63    pub action: ProfileAction,
64    /// Optional profile key.
65    pub key: Option<String>,
66    /// Expected adapter identity.
67    pub adapter: String,
68    /// Expected driver identity.
69    pub driver: String,
70    /// Expected backend identity.
71    pub backend: String,
72    /// Logical tick for stale checks.
73    pub now_tick: u64,
74    /// Output encoding.
75    pub output: OutputMode,
76    /// Maximum accepted profile bytes.
77    pub max_profile_bytes: usize,
78}
79
80/// Recipe render options.
81#[derive(Clone, Debug, Eq, PartialEq)]
82pub struct RecipeRequest {
83    /// Recipe id.
84    pub id: String,
85    /// Output encoding.
86    pub output: OutputMode,
87}
88
89/// Acceptance artifact action.
90#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91pub enum AcceptanceAction {
92    /// Capture one exact physical artifact.
93    Capture,
94    /// Verify an existing artifact.
95    Verify,
96    /// Import an existing artifact after verification.
97    Import,
98}
99
100/// Acceptance command options.
101#[derive(Clone, Debug, Eq, PartialEq)]
102pub struct AcceptanceRequest {
103    /// Requested action.
104    pub action: AcceptanceAction,
105    /// Case manifest path for capture.
106    pub manifest: Option<String>,
107    /// Exact sim-compute source commit expected by the artifact.
108    pub source: String,
109    /// Exact registered target capability captured by the artifact.
110    pub target: Option<String>,
111    /// Output artifact path for capture.
112    pub output: Option<String>,
113    /// Input artifact path for verify/import.
114    pub input: Option<String>,
115}
116
117/// Parses a `sim compute` payload argument list.
118pub fn parse_compute_args(args: &[String]) -> Result<ComputeCommand, ComputeCliError> {
119    let args = strip_verb(args);
120    if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") {
121        return Ok(ComputeCommand::Help);
122    }
123    match args[0].as_str() {
124        "devices" => Ok(ComputeCommand::Devices(parse_selection(
125            "devices",
126            &args[1..],
127        )?)),
128        "probe" => Ok(ComputeCommand::Probe(parse_selection("probe", &args[1..])?)),
129        "profile" => Ok(ComputeCommand::Profile(parse_profile(&args[1..])?)),
130        "explain" => {
131            let mut request = parse_profile(&args[1..])?;
132            if request.action == ProfileAction::List {
133                request.action = ProfileAction::Read;
134            }
135            Ok(ComputeCommand::Explain(request))
136        }
137        "recipe" => Ok(ComputeCommand::Recipe(parse_recipe(&args[1..])?)),
138        "acceptance" => Ok(ComputeCommand::Acceptance(parse_acceptance(&args[1..])?)),
139        other => Err(ComputeCliError::new(format!(
140            "unknown compute verb: {other}"
141        ))),
142    }
143}
144
145fn strip_verb(args: &[String]) -> &[String] {
146    if args.first().is_some_and(|arg| arg == "compute") {
147        &args[1..]
148    } else {
149        args
150    }
151}
152
153fn parse_selection(verb: &str, args: &[String]) -> Result<Selection, ComputeCliError> {
154    let mut output = OutputMode::Text;
155    let mut selector = None;
156    let mut max_devices = 16;
157    let mut i = 0;
158    while i < args.len() {
159        match args[i].as_str() {
160            "--json" => output = OutputMode::Json,
161            "--max-devices" => {
162                max_devices = bounded_usize(take_value(args, &mut i, "--max-devices")?)?;
163                if max_devices == 0 || max_devices > MAX_DEVICES {
164                    return Err(ComputeCliError::new("max-devices is outside policy"));
165                }
166            }
167            value if value.starts_with('-') => {
168                return Err(ComputeCliError::new(format!(
169                    "unknown {verb} option: {value}"
170                )));
171            }
172            value => {
173                checked_selector(value)?;
174                if selector.replace(value.to_owned()).is_some() {
175                    return Err(ComputeCliError::new(format!(
176                        "{verb} accepts at most one selector"
177                    )));
178                }
179            }
180        }
181        i += 1;
182    }
183    Ok(Selection {
184        selector,
185        output,
186        max_devices,
187    })
188}
189
190fn parse_profile(args: &[String]) -> Result<ProfileRequest, ComputeCliError> {
191    let mut request = ProfileRequest {
192        action: ProfileAction::List,
193        key: None,
194        adapter: "modeled-adapter".to_owned(),
195        driver: "modeled-driver".to_owned(),
196        backend: "modeled".to_owned(),
197        now_tick: 0,
198        output: OutputMode::Text,
199        max_profile_bytes: 8192,
200    };
201    let mut i = 0;
202    while i < args.len() {
203        match args[i].as_str() {
204            "list" => request.action = ProfileAction::List,
205            "read" => request.action = ProfileAction::Read,
206            "save" => request.action = ProfileAction::Save,
207            "--json" => request.output = OutputMode::Json,
208            "--key" => request.key = Some(checked_value(take_value(args, &mut i, "--key")?)?),
209            "--adapter" => request.adapter = checked_value(take_value(args, &mut i, "--adapter")?)?,
210            "--driver" => request.driver = checked_value(take_value(args, &mut i, "--driver")?)?,
211            "--backend" => request.backend = checked_value(take_value(args, &mut i, "--backend")?)?,
212            "--now" => request.now_tick = bounded_u64(take_value(args, &mut i, "--now")?)?,
213            "--max-profile-bytes" => {
214                request.max_profile_bytes =
215                    bounded_usize(take_value(args, &mut i, "--max-profile-bytes")?)?;
216                if request.max_profile_bytes == 0 || request.max_profile_bytes > MAX_PROFILE_BYTES {
217                    return Err(ComputeCliError::new("max-profile-bytes is outside policy"));
218                }
219            }
220            value if value.starts_with('-') => {
221                return Err(ComputeCliError::new(format!(
222                    "unknown profile option: {value}"
223                )));
224            }
225            value => {
226                return Err(ComputeCliError::new(format!(
227                    "unknown profile action: {value}"
228                )));
229            }
230        }
231        i += 1;
232    }
233    Ok(request)
234}
235
236fn parse_acceptance(args: &[String]) -> Result<AcceptanceRequest, ComputeCliError> {
237    let Some(action) = args.first() else {
238        return Err(ComputeCliError::new("acceptance requires an action"));
239    };
240    let mut request = AcceptanceRequest {
241        action: match action.as_str() {
242            "capture" => AcceptanceAction::Capture,
243            "verify" => AcceptanceAction::Verify,
244            "import" => AcceptanceAction::Import,
245            other => {
246                return Err(ComputeCliError::new(format!(
247                    "unknown acceptance action: {other}"
248                )));
249            }
250        },
251        manifest: None,
252        source: String::new(),
253        target: None,
254        output: None,
255        input: None,
256    };
257    let mut i = 1;
258    while i < args.len() {
259        match args[i].as_str() {
260            "--manifest" => {
261                request.manifest = Some(checked_path(take_value(args, &mut i, "--manifest")?)?)
262            }
263            "--source" => request.source = checked_hash(take_value(args, &mut i, "--source")?)?,
264            "--target" => {
265                request.target = Some(checked_capability(take_value(args, &mut i, "--target")?)?)
266            }
267            "--output" => {
268                request.output = Some(checked_path(take_value(args, &mut i, "--output")?)?)
269            }
270            value if value.starts_with('-') => {
271                return Err(ComputeCliError::new(format!(
272                    "unknown acceptance option: {value}"
273                )));
274            }
275            value => {
276                let path = checked_path(value.to_owned())?;
277                if request.input.replace(path).is_some() {
278                    return Err(ComputeCliError::new(
279                        "acceptance accepts at most one input artifact",
280                    ));
281                }
282            }
283        }
284        i += 1;
285    }
286    if request.source.is_empty() {
287        return Err(ComputeCliError::new("acceptance requires --source"));
288    }
289    match request.action {
290        AcceptanceAction::Capture => {
291            if request.manifest.is_none()
292                || request.target.is_none()
293                || request.output.is_none()
294                || request.input.is_some()
295            {
296                return Err(ComputeCliError::new(
297                    "acceptance capture requires --manifest, --target, and --output only",
298                ));
299            }
300        }
301        AcceptanceAction::Verify | AcceptanceAction::Import => {
302            if request.input.is_none()
303                || request.manifest.is_some()
304                || request.target.is_some()
305                || request.output.is_some()
306            {
307                return Err(ComputeCliError::new(
308                    "acceptance verify/import requires one input artifact",
309                ));
310            }
311        }
312    }
313    Ok(request)
314}
315
316fn parse_recipe(args: &[String]) -> Result<RecipeRequest, ComputeCliError> {
317    let mut output = OutputMode::Text;
318    let mut id = "inspect-compute-device".to_owned();
319    for arg in args {
320        match arg.as_str() {
321            "--json" => output = OutputMode::Json,
322            value if value.starts_with('-') => {
323                return Err(ComputeCliError::new(format!(
324                    "unknown recipe option: {value}"
325                )));
326            }
327            value => id = checked_value(value.to_owned())?,
328        }
329    }
330    if id != "inspect-compute-device" {
331        return Err(ComputeCliError::new(format!(
332            "unknown compute recipe: {id}"
333        )));
334    }
335    Ok(RecipeRequest { id, output })
336}
337
338fn take_value(args: &[String], i: &mut usize, flag: &str) -> Result<String, ComputeCliError> {
339    *i += 1;
340    args.get(*i)
341        .filter(|value| !value.starts_with('-'))
342        .cloned()
343        .ok_or_else(|| ComputeCliError::new(format!("{flag} requires a value")))
344}
345
346fn checked_value(value: String) -> Result<String, ComputeCliError> {
347    checked_selector(&value)?;
348    Ok(value)
349}
350
351fn checked_selector(value: &str) -> Result<(), ComputeCliError> {
352    if value.is_empty() || value.len() > MAX_SELECTOR_BYTES || value.contains("..") {
353        return Err(ComputeCliError::new("selector is outside policy"));
354    }
355    if !value
356        .chars()
357        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '/' | '.'))
358    {
359        return Err(ComputeCliError::new(
360            "selector contains unsupported characters",
361        ));
362    }
363    Ok(())
364}
365
366fn checked_path(value: String) -> Result<String, ComputeCliError> {
367    if value.is_empty()
368        || value.len() > 240
369        || value.contains('\0')
370        || value.contains('\n')
371        || value.contains('\r')
372    {
373        return Err(ComputeCliError::new("path is outside acceptance policy"));
374    }
375    Ok(value)
376}
377
378fn checked_capability(value: String) -> Result<String, ComputeCliError> {
379    if value.is_empty()
380        || value.len() > MAX_SELECTOR_BYTES
381        || value.contains("..")
382        || !value
383            .chars()
384            .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, ':' | '-' | '_' | '/' | '.'))
385    {
386        return Err(ComputeCliError::new(
387            "target capability is outside acceptance policy",
388        ));
389    }
390    Ok(value)
391}
392
393fn checked_hash(value: String) -> Result<String, ComputeCliError> {
394    if value.len() != 40 || !value.chars().all(|ch| ch.is_ascii_hexdigit()) {
395        return Err(ComputeCliError::new("source must be a 40-hex commit"));
396    }
397    Ok(value.to_ascii_lowercase())
398}
399
400fn bounded_usize(value: String) -> Result<usize, ComputeCliError> {
401    value
402        .parse::<usize>()
403        .map_err(|_| ComputeCliError::new("numeric ceiling must be an unsigned integer"))
404}
405
406fn bounded_u64(value: String) -> Result<u64, ComputeCliError> {
407    value
408        .parse::<u64>()
409        .map_err(|_| ComputeCliError::new("tick must be an unsigned integer"))
410}