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}
45
46/// Provider selection and common output options.
47#[derive(Clone, Debug, Eq, PartialEq)]
48pub struct Selection {
49    /// Optional provider selector.
50    pub selector: Option<String>,
51    /// Output encoding.
52    pub output: OutputMode,
53    /// Maximum provider rows to inspect.
54    pub max_devices: usize,
55}
56
57/// Profile command options.
58#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct ProfileRequest {
60    /// Requested action.
61    pub action: ProfileAction,
62    /// Optional profile key.
63    pub key: Option<String>,
64    /// Expected adapter identity.
65    pub adapter: String,
66    /// Expected driver identity.
67    pub driver: String,
68    /// Expected backend identity.
69    pub backend: String,
70    /// Logical tick for stale checks.
71    pub now_tick: u64,
72    /// Output encoding.
73    pub output: OutputMode,
74    /// Maximum accepted profile bytes.
75    pub max_profile_bytes: usize,
76}
77
78/// Recipe render options.
79#[derive(Clone, Debug, Eq, PartialEq)]
80pub struct RecipeRequest {
81    /// Recipe id.
82    pub id: String,
83    /// Output encoding.
84    pub output: OutputMode,
85}
86
87/// Parses a `sim compute` payload argument list.
88pub fn parse_compute_args(args: &[String]) -> Result<ComputeCommand, ComputeCliError> {
89    let args = strip_verb(args);
90    if args.is_empty() || args.iter().any(|arg| arg == "--help" || arg == "-h") {
91        return Ok(ComputeCommand::Help);
92    }
93    match args[0].as_str() {
94        "devices" => Ok(ComputeCommand::Devices(parse_selection(
95            "devices",
96            &args[1..],
97        )?)),
98        "probe" => Ok(ComputeCommand::Probe(parse_selection("probe", &args[1..])?)),
99        "profile" => Ok(ComputeCommand::Profile(parse_profile(&args[1..])?)),
100        "explain" => {
101            let mut request = parse_profile(&args[1..])?;
102            if request.action == ProfileAction::List {
103                request.action = ProfileAction::Read;
104            }
105            Ok(ComputeCommand::Explain(request))
106        }
107        "recipe" => Ok(ComputeCommand::Recipe(parse_recipe(&args[1..])?)),
108        other => Err(ComputeCliError::new(format!(
109            "unknown compute verb: {other}"
110        ))),
111    }
112}
113
114fn strip_verb(args: &[String]) -> &[String] {
115    if args.first().is_some_and(|arg| arg == "compute") {
116        &args[1..]
117    } else {
118        args
119    }
120}
121
122fn parse_selection(verb: &str, args: &[String]) -> Result<Selection, ComputeCliError> {
123    let mut output = OutputMode::Text;
124    let mut selector = None;
125    let mut max_devices = 16;
126    let mut i = 0;
127    while i < args.len() {
128        match args[i].as_str() {
129            "--json" => output = OutputMode::Json,
130            "--max-devices" => {
131                max_devices = bounded_usize(take_value(args, &mut i, "--max-devices")?)?;
132                if max_devices == 0 || max_devices > MAX_DEVICES {
133                    return Err(ComputeCliError::new("max-devices is outside policy"));
134                }
135            }
136            value if value.starts_with('-') => {
137                return Err(ComputeCliError::new(format!(
138                    "unknown {verb} option: {value}"
139                )));
140            }
141            value => {
142                checked_selector(value)?;
143                if selector.replace(value.to_owned()).is_some() {
144                    return Err(ComputeCliError::new(format!(
145                        "{verb} accepts at most one selector"
146                    )));
147                }
148            }
149        }
150        i += 1;
151    }
152    Ok(Selection {
153        selector,
154        output,
155        max_devices,
156    })
157}
158
159fn parse_profile(args: &[String]) -> Result<ProfileRequest, ComputeCliError> {
160    let mut request = ProfileRequest {
161        action: ProfileAction::List,
162        key: None,
163        adapter: "modeled-adapter".to_owned(),
164        driver: "modeled-driver".to_owned(),
165        backend: "modeled".to_owned(),
166        now_tick: 0,
167        output: OutputMode::Text,
168        max_profile_bytes: 8192,
169    };
170    let mut i = 0;
171    while i < args.len() {
172        match args[i].as_str() {
173            "list" => request.action = ProfileAction::List,
174            "read" => request.action = ProfileAction::Read,
175            "save" => request.action = ProfileAction::Save,
176            "--json" => request.output = OutputMode::Json,
177            "--key" => request.key = Some(checked_value(take_value(args, &mut i, "--key")?)?),
178            "--adapter" => request.adapter = checked_value(take_value(args, &mut i, "--adapter")?)?,
179            "--driver" => request.driver = checked_value(take_value(args, &mut i, "--driver")?)?,
180            "--backend" => request.backend = checked_value(take_value(args, &mut i, "--backend")?)?,
181            "--now" => request.now_tick = bounded_u64(take_value(args, &mut i, "--now")?)?,
182            "--max-profile-bytes" => {
183                request.max_profile_bytes =
184                    bounded_usize(take_value(args, &mut i, "--max-profile-bytes")?)?;
185                if request.max_profile_bytes == 0 || request.max_profile_bytes > MAX_PROFILE_BYTES {
186                    return Err(ComputeCliError::new("max-profile-bytes is outside policy"));
187                }
188            }
189            value if value.starts_with('-') => {
190                return Err(ComputeCliError::new(format!(
191                    "unknown profile option: {value}"
192                )));
193            }
194            value => {
195                return Err(ComputeCliError::new(format!(
196                    "unknown profile action: {value}"
197                )));
198            }
199        }
200        i += 1;
201    }
202    Ok(request)
203}
204
205fn parse_recipe(args: &[String]) -> Result<RecipeRequest, ComputeCliError> {
206    let mut output = OutputMode::Text;
207    let mut id = "inspect-compute-device".to_owned();
208    for arg in args {
209        match arg.as_str() {
210            "--json" => output = OutputMode::Json,
211            value if value.starts_with('-') => {
212                return Err(ComputeCliError::new(format!(
213                    "unknown recipe option: {value}"
214                )));
215            }
216            value => id = checked_value(value.to_owned())?,
217        }
218    }
219    if id != "inspect-compute-device" {
220        return Err(ComputeCliError::new(format!(
221            "unknown compute recipe: {id}"
222        )));
223    }
224    Ok(RecipeRequest { id, output })
225}
226
227fn take_value(args: &[String], i: &mut usize, flag: &str) -> Result<String, ComputeCliError> {
228    *i += 1;
229    args.get(*i)
230        .filter(|value| !value.starts_with('-'))
231        .cloned()
232        .ok_or_else(|| ComputeCliError::new(format!("{flag} requires a value")))
233}
234
235fn checked_value(value: String) -> Result<String, ComputeCliError> {
236    checked_selector(&value)?;
237    Ok(value)
238}
239
240fn checked_selector(value: &str) -> Result<(), ComputeCliError> {
241    if value.is_empty() || value.len() > MAX_SELECTOR_BYTES || value.contains("..") {
242        return Err(ComputeCliError::new("selector is outside policy"));
243    }
244    if !value
245        .chars()
246        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '/' | '.'))
247    {
248        return Err(ComputeCliError::new(
249            "selector contains unsupported characters",
250        ));
251    }
252    Ok(())
253}
254
255fn bounded_usize(value: String) -> Result<usize, ComputeCliError> {
256    value
257        .parse::<usize>()
258        .map_err(|_| ComputeCliError::new("numeric ceiling must be an unsigned integer"))
259}
260
261fn bounded_u64(value: String) -> Result<u64, ComputeCliError> {
262    value
263        .parse::<u64>()
264        .map_err(|_| ComputeCliError::new("tick must be an unsigned integer"))
265}