Skip to main content

ignition_core/actions/
script.rs

1//! The script action (07-03, SCRPT-01) — `ign script run`, the
2//! smallest verb in the CLI: one action over the already-shipped,
3//! already-secured scriptExec route (05-01's template, 05-03's
4//! deploy/secret lifecycle).
5//!
6//! The opt-in is STRUCTURAL, not a flag: scriptExec deploys ONLY via
7//! `ign webdev deploy --with-script-exec`, whose deploy persists a
8//! 32-byte hex secret in the profile config at 0600 BEFORE upload.
9//! `script_run` resolves that secret FIRST — no stored secret means
10//! the route was never deployed, and the verb refuses with the
11//! additive `script_exec_not_configured` (exit 6) naming the deploy
12//! flag verbatim. There is NO `--yes` guard on script run (the
13//! research-adopted decision): the deploy flag IS the opt-in, the
14//! verb is the route's entire purpose, and agents need it
15//! non-interactive.
16//!
17//! Sequence per invocation (two round trips, the 05-04
18//! precondition's correctness-over-latency precedent):
19//!
20//! 1. **Precondition handshake** — the version action WITH the
21//!    secret header. The 200-BODY envelope is the only success
22//!    oracle (WebDev ignores `status`; denials ride 200); a
23//!    `secret_required`/`secret_mismatch` denial surfaces honestly
24//!    through the existing webdev error family — a mismatch means
25//!    the route was deployed elsewhere/stale, and the hint already
26//!    says redeploy or `--rotate-secret`. No new slug, no
27//!    version-compare magic (the tags precondition owns that
28//!    discrimination for its family; scriptExec's gate IS the
29//!    secret).
30//! 2. **Exec** — `{"action": "exec", "code": <code>}` with the
31//!    secret header. A route error body (`{ok:false,
32//!    error{code,message,traceback?}}` at HTTP 200) maps through
33//!    the same denial seam with the traceback surfaced (the 05-08
34//!    pattern) — a route-side Python exception is a black box no
35//!    more.
36//!
37//! Timeout honesty (planner decision): v1.0.0's route has NO
38//! server-side timeout — the client rides the existing per-request
39//! class, and a long-running script simply holds the HTTP connection
40//! (README documents this).
41//!
42//! [`read_script_input`] is the PURE three-form input reader
43//! (`--code STR`, `--file PATH`, `--file -` stdin — the agent pipe
44//! path), unit-tested separately from the async action: usage-class
45//! errors lead (exit 2, the 03-03 put convention).
46
47use std::io::Read;
48
49use serde::Serialize;
50
51use crate::actions::webdev::{SCRIPT_EXEC_ROUTE, SECRET_HEADER};
52use crate::client::GatewayApi;
53use crate::config::Config;
54use crate::error::CoreError;
55
56/// `ign script run` result — the route's exec answer under
57/// unit-explicit keys, ALL keys always (the family convention:
58/// agents never key-hunt). The secret appears in NONE of them
59/// (redaction — the 05-03 canary extended to this surface).
60#[derive(Debug, Serialize)]
61pub struct ScriptRunResult {
62    /// The script's captured stdout, verbatim (empty string when the
63    /// script printed nothing — the key still rides).
64    pub stdout: String,
65    /// The script's value: a single expression's eval result, or the
66    /// `_result` global statements left (null when neither — the key
67    /// still rides). Raw JSON passthrough, never interpreted.
68    pub result: serde_json::Value,
69    /// The route-measured wall time in milliseconds (route-side
70    /// `time.time()` deltas; 0 when the answer carried none — the
71    /// key still rides).
72    #[serde(rename = "elapsedMs")]
73    pub elapsed_ms: u64,
74}
75
76/// `ign script run` — resolve the profile's stored scriptExec
77/// secret, prove the route answers it, execute the code.
78///
79/// Resolution order is fixed: the secret gate FIRST (None →
80/// `script_exec_not_configured`, zero HTTP), then the version
81/// handshake, then exec. The config is the caller's already-loaded
82/// view (main.rs resolves the profile for the client anyway; the
83/// TUI loads it inside the worker — the fire_webdev_status
84/// precedent).
85pub async fn script_run(
86    api: &dyn GatewayApi,
87    config: &Config,
88    profile_name: &str,
89    project: &str,
90    code: &str,
91) -> Result<ScriptRunResult, CoreError> {
92    // THE structural gate: no persisted secret = the route was never
93    // deployed through the opt-in flag. Refuses before ANY network
94    // I/O (must-have truth #3: zero HTTP requests).
95    let secret = config
96        .profiles
97        .get(profile_name)
98        .and_then(|profile| profile.webdev_secret.clone())
99        .ok_or_else(|| CoreError::ScriptExecNotConfigured {
100            profile: profile_name.to_string(),
101        })?;
102
103    // Precondition handshake: the version action WITH the secret.
104    // Success = the 200-BODY ok envelope (the only oracle); a denial
105    // body surfaces honestly through webdev_route_call's existing
106    // mapping (secret_required/secret_mismatch → webdev_route_error
107    // whose hint names redeploy or --rotate-secret).
108    api.webdev_route_call(
109        project,
110        SCRIPT_EXEC_ROUTE,
111        &serde_json::json!({"action": "version"}),
112        &[(SECRET_HEADER, secret.as_str())],
113    )
114    .await?;
115
116    // Exec: the code rides verbatim; the envelope's data carries
117    // {stdout, result, elapsedMs} (the 05-01 route contract). A
118    // denial (route error with traceback) maps at the same seam.
119    let data = api
120        .webdev_route_call(
121            project,
122            SCRIPT_EXEC_ROUTE,
123            &serde_json::json!({"action": "exec", "code": code}),
124            &[(SECRET_HEADER, secret.as_str())],
125        )
126        .await?;
127
128    Ok(ScriptRunResult {
129        stdout: data
130            .get("stdout")
131            .and_then(serde_json::Value::as_str)
132            .unwrap_or_default()
133            .to_string(),
134        result: data
135            .get("result")
136            .cloned()
137            .unwrap_or(serde_json::Value::Null),
138        elapsed_ms: data
139            .get("elapsedMs")
140            .and_then(serde_json::Value::as_u64)
141            .unwrap_or_default(),
142    })
143}
144
145/// The three-form input reader (PURE — no async, no gateway):
146/// `--code STR` passes through, `--file PATH` reads the file,
147/// `--file -` reads stdin (the agent pipe path). Both `--code` AND
148/// `--file` given → InvalidInput (usage errors lead — the caller
149/// runs this BEFORE profile resolution, exit 2, profile null);
150/// neither given → InvalidInput; unreadable file/stdin →
151/// InvalidInput (the 03-03 put convention: the reason names the
152/// source).
153pub fn read_script_input(code: Option<&str>, file: Option<&str>) -> Result<String, CoreError> {
154    match (code, file) {
155        (Some(_), Some(_)) => Err(CoreError::InvalidInput {
156            reason: "provide exactly one of --code or --file (not both)".to_string(),
157        }),
158        (Some(code), None) => Ok(code.to_string()),
159        (None, Some("-")) => {
160            let mut buffer = String::new();
161            std::io::stdin()
162                .read_to_string(&mut buffer)
163                .map_err(|err| CoreError::InvalidInput {
164                    reason: format!("cannot read stdin: {err}"),
165                })?;
166            Ok(buffer)
167        }
168        (None, Some(file)) => {
169            std::fs::read_to_string(file).map_err(|err| CoreError::InvalidInput {
170                reason: format!("cannot read {file}: {err}"),
171            })
172        }
173        (None, None) => Err(CoreError::InvalidInput {
174            reason: "provide the script via --code PY or --file PATH (--file - reads stdin)"
175                .to_string(),
176        }),
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::{read_script_input, script_run};
183    use crate::client::GatewayApi;
184    use crate::config;
185    use crate::error::CoreError;
186    use std::path::PathBuf;
187
188    /// The recorded (action, body) sequence the rig hands back for
189    /// assertion (an alias keeps the helper's signature legible).
190    type CallLog = std::sync::Arc<std::sync::Mutex<Vec<(String, serde_json::Value)>>>;
191
192    /// A scripted double over the ONE call script_run makes —
193    /// `webdev_route_call` answers from a lookup keyed on the body's
194    /// action token, recorded through a Mutex so the closure stays
195    /// `Fn` (the webdev.rs double shape). Everything else is
196    /// unreachable.
197    struct ScriptRig {
198        calls: CallLog,
199        answers: fn(&str) -> Result<serde_json::Value, CoreError>,
200    }
201
202    #[async_trait::async_trait]
203    impl GatewayApi for ScriptRig {
204        async fn tag_provider_list(
205            &self,
206            _query: &crate::client::query::ListQuery,
207        ) -> Result<
208            crate::client::query::ListEnvelope<crate::client::tags::TagProviderRecord>,
209            CoreError,
210        > {
211            unreachable!("not part of this action")
212        }
213        async fn tag_provider_find(
214            &self,
215            _name: &str,
216        ) -> Result<crate::client::tags::TagProviderRecord, CoreError> {
217            unreachable!("not part of this action")
218        }
219        async fn tag_provider_create(
220            &self,
221            _body: &[crate::client::tags::TagProviderCreate],
222        ) -> Result<(), CoreError> {
223            unreachable!("not part of this action")
224        }
225        async fn tag_provider_delete(
226            &self,
227            _name: &str,
228            _signature: &str,
229        ) -> Result<(), CoreError> {
230            unreachable!("not part of this action")
231        }
232        async fn webdev_route_call(
233            &self,
234            _project: &str,
235            _route: &str,
236            body: &serde_json::Value,
237            _extra_headers: &[(&str, &str)],
238        ) -> Result<serde_json::Value, CoreError> {
239            let action = body["action"].as_str().unwrap_or_default().to_string();
240            self.calls
241                .lock()
242                .expect("calls lock")
243                .push((action.clone(), body.clone()));
244            (self.answers)(&action)
245        }
246        async fn gateway_info(&self) -> Result<crate::client::version::GatewayInfo, CoreError> {
247            unreachable!("not part of this action")
248        }
249        async fn overview(&self) -> Result<crate::client::status::Overview, CoreError> {
250            unreachable!("not part of this action")
251        }
252        async fn status_ping(&self) -> Result<crate::client::status::StatusPing, CoreError> {
253            unreachable!("not part of this action")
254        }
255        async fn modules(
256            &self,
257            _quarantined: bool,
258            _query: &crate::client::query::ListQuery,
259        ) -> Result<crate::client::query::ListEnvelope<crate::client::status::ModuleInfo>, CoreError>
260        {
261            unreachable!("not part of this action")
262        }
263        async fn metrics_current(
264            &self,
265        ) -> Result<crate::client::metrics::CurrentGauges, CoreError> {
266            unreachable!("not part of this action")
267        }
268        async fn metrics_historic(
269            &self,
270        ) -> Result<crate::client::metrics::PerformanceCharts, CoreError> {
271            unreachable!("not part of this action")
272        }
273        async fn metrics_threads(&self) -> Result<crate::client::metrics::ThreadCounts, CoreError> {
274            unreachable!("not part of this action")
275        }
276        async fn designers(
277            &self,
278            _query: &crate::client::query::ListQuery,
279        ) -> Result<
280            crate::client::query::ListEnvelope<crate::client::sessions::DesignerInfo>,
281            CoreError,
282        > {
283            unreachable!("not part of this action")
284        }
285        async fn perspective_sessions(
286            &self,
287            _query: &crate::client::query::ListQuery,
288        ) -> Result<
289            crate::client::query::ListEnvelope<crate::client::sessions::PerspectiveSession>,
290            CoreError,
291        > {
292            unreachable!("not part of this action")
293        }
294        async fn vision_clients(
295            &self,
296            _query: &crate::client::query::ListQuery,
297        ) -> Result<
298            crate::client::query::ListEnvelope<crate::client::sessions::VisionClient>,
299            CoreError,
300        > {
301            unreachable!("not part of this action")
302        }
303        async fn terminate_perspective_session(
304            &self,
305            _id: &str,
306            _message: Option<&str>,
307        ) -> Result<(), CoreError> {
308            unreachable!("not part of this action")
309        }
310        async fn terminate_vision_client(&self, _id: &str) -> Result<(), CoreError> {
311            unreachable!("not part of this action")
312        }
313        async fn prune_designer(&self, _id: &str) -> Result<(), CoreError> {
314            unreachable!("not part of this action")
315        }
316        async fn database_connections(
317            &self,
318        ) -> Result<
319            crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
320            CoreError,
321        > {
322            unreachable!("not part of this action")
323        }
324        async fn opc_connections(
325            &self,
326        ) -> Result<
327            crate::client::query::ListEnvelope<crate::client::connections::GatewayConnection>,
328            CoreError,
329        > {
330            unreachable!("not part of this action")
331        }
332        async fn logs(
333            &self,
334            _filter: &crate::client::logs::LogQuery,
335        ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LogEntry>, CoreError>
336        {
337            unreachable!("not part of this action")
338        }
339        async fn logs_download(&self) -> Result<crate::client::logs::LogDownload, CoreError> {
340            unreachable!("not part of this action")
341        }
342        async fn loggers(
343            &self,
344            _query: &crate::client::query::ListQuery,
345        ) -> Result<crate::client::query::ListEnvelope<crate::client::logs::LoggerInfo>, CoreError>
346        {
347            unreachable!("not part of this action")
348        }
349        async fn set_logger_level(&self, _logger: &str, _level: &str) -> Result<(), CoreError> {
350            unreachable!("not part of this action")
351        }
352        async fn reset_logger_levels(&self) -> Result<(), CoreError> {
353            unreachable!("not part of this action")
354        }
355        async fn restart(&self) -> Result<(), CoreError> {
356            unreachable!("not part of this action")
357        }
358        async fn scan_projects(&self) -> Result<(), CoreError> {
359            unreachable!("not part of this action")
360        }
361        async fn security_properties(
362            &self,
363        ) -> Result<crate::client::restart::SecurityProperties, CoreError> {
364            unreachable!("not part of this action")
365        }
366        async fn webdev_route_status(&self, _route: &str) -> Result<u16, CoreError> {
367            unreachable!("not part of this action")
368        }
369        async fn webdev_route_probe(
370            &self,
371            _project: &str,
372            _route: &str,
373            _extra_headers: &[(&str, &str)],
374        ) -> Result<crate::client::webdev::RouteProbe, CoreError> {
375            unreachable!("not part of this action")
376        }
377        async fn projects(
378            &self,
379            _query: &crate::client::query::ListQuery,
380        ) -> Result<
381            crate::client::query::ListEnvelope<crate::client::projects::ProjectRecord>,
382            CoreError,
383        > {
384            unreachable!("not part of this action")
385        }
386        async fn project_find(
387            &self,
388            _name: &str,
389        ) -> Result<crate::client::projects::ProjectRecord, CoreError> {
390            unreachable!("not part of this action")
391        }
392        async fn project_create(
393            &self,
394            _body: &crate::client::projects::ProjectCreate,
395        ) -> Result<(), CoreError> {
396            unreachable!("not part of this action")
397        }
398        async fn project_copy(&self, _from: &str, _to: &str) -> Result<(), CoreError> {
399            unreachable!("not part of this action")
400        }
401        async fn project_rename(&self, _name: &str, _new_name: &str) -> Result<(), CoreError> {
402            unreachable!("not part of this action")
403        }
404        async fn project_modify(
405            &self,
406            _name: &str,
407            _body: &crate::client::projects::ProjectModify,
408        ) -> Result<(), CoreError> {
409            unreachable!("not part of this action")
410        }
411        async fn project_delete(&self, _name: &str) -> Result<(), CoreError> {
412            unreachable!("not part of this action")
413        }
414        async fn project_export_to_file(
415            &self,
416            _name: &str,
417            _out: &std::path::Path,
418        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
419            unreachable!("not part of this action")
420        }
421        async fn project_import(
422            &self,
423            _name: &str,
424            _zip: Vec<u8>,
425            _overwrite: bool,
426        ) -> Result<crate::client::projects::ImportOutcome, CoreError> {
427            unreachable!("not part of this action")
428        }
429        async fn trial_status_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
430            unreachable!("not part of this action")
431        }
432        async fn banners(&self) -> Result<crate::client::trial::BannerSet, CoreError> {
433            unreachable!("not part of this action")
434        }
435        async fn trial_reset_wire(&self) -> Result<crate::client::trial::TrialWire, CoreError> {
436            unreachable!("not part of this action")
437        }
438        async fn backup_download(
439            &self,
440            _out: &std::path::Path,
441            _backup_type: crate::client::backup::BackupType,
442        ) -> Result<crate::client::projects::ExportMeta, CoreError> {
443            unreachable!("not part of this action")
444        }
445        async fn backup_restore(&self, _gwbk: &std::path::Path) -> Result<(), CoreError> {
446            unreachable!("not part of this action")
447        }
448        async fn eam_task_history(
449            &self,
450            _limit: Option<u32>,
451            _search: Option<&str>,
452        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamHistoryItem>, CoreError>
453        {
454            unreachable!("not part of this action")
455        }
456        async fn eam_task_definitions(
457            &self,
458        ) -> Result<crate::client::query::ListEnvelope<crate::client::eam::EamTaskRecord>, CoreError>
459        {
460            unreachable!("not part of this action")
461        }
462        async fn eam_task_find(
463            &self,
464            _name: &str,
465        ) -> Result<crate::client::eam::EamTaskRecord, CoreError> {
466            unreachable!("not part of this action")
467        }
468        async fn eam_task_create(&self, _definition: &serde_json::Value) -> Result<(), CoreError> {
469            unreachable!("not part of this action")
470        }
471        async fn eam_task_force(&self, _owner: &str, _name: &str) -> Result<(), CoreError> {
472            unreachable!("not part of this action")
473        }
474    }
475
476    /// Temp config with one `dev` profile; the optional secret seeds
477    /// `webdev_secret` (the persisted-secret gate's two states).
478    fn temp_config(secret: Option<&str>) -> (tempfile::TempDir, config::Config, PathBuf) {
479        let dir = tempfile::tempdir().expect("tempdir");
480        let path = dir.path().join("config.toml");
481        let secret_line = secret
482            .map(|secret| format!("webdev_secret = \"{secret}\"\n"))
483            .unwrap_or_default();
484        std::fs::write(
485            &path,
486            format!("active = \"dev\"\n\n[profiles.dev]\nurl = \"http://localhost:9088/\"\n{secret_line}"),
487        )
488        .expect("write config");
489        let config = config::load(&path).expect("config loads");
490        (dir, config, path)
491    }
492
493    /// A rig whose every call answers from a table keyed on the
494    /// action token, recording the bodies it saw.
495    fn rig(answers: fn(&str) -> Result<serde_json::Value, CoreError>) -> (ScriptRig, CallLog) {
496        let calls = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
497        (
498            ScriptRig {
499                calls: std::sync::Arc::clone(&calls),
500                answers,
501            },
502            calls,
503        )
504    }
505
506    /// THE structural gate: no stored secret → the additive slug,
507    /// exit 6, hint naming the deploy flag — and ZERO route calls.
508    #[tokio::test]
509    async fn missing_secret_refuses_before_any_call() {
510        let (_dir, config, _path) = temp_config(None);
511        let (double, calls) = rig(|_| unreachable!("the gate refuses before any call"));
512        let err = script_run(&double, &config, "dev", "ign-cli", "2+2")
513            .await
514            .expect_err("no secret refuses");
515        assert_eq!(err.code(), "script_exec_not_configured");
516        assert_eq!(err.exit_code(), 6);
517        assert!(
518            err.hint()
519                .unwrap()
520                .contains("ign webdev deploy --with-script-exec"),
521            "hint names the deploy flag: {:?}",
522            err.hint()
523        );
524        assert!(
525            calls.lock().expect("calls lock").is_empty(),
526            "zero route calls"
527        );
528    }
529
530    /// The success round: version probe then exec, both seen, the
531    /// exec body carrying the code verbatim, and the answer mapped
532    /// under {stdout, result, elapsedMs} with ALL keys always.
533    #[tokio::test]
534    async fn success_round_probes_then_execs_and_maps_the_envelope() {
535        let (_dir, config, _path) = temp_config(Some("aabbcc"));
536        let (double, calls) = rig(|action| {
537            Ok(match action {
538                "version" => serde_json::json!({"routeVersion": "1.0.0", "minCli": "1.0"}),
539                _ => serde_json::json!({
540                    "stdout": "hello\n",
541                    "result": 4,
542                    "elapsedMs": 12,
543                }),
544            })
545        });
546        let result = script_run(&double, &config, "dev", "ign-cli", "print 'hello'\n2+2")
547            .await
548            .expect("exec succeeds");
549        assert_eq!(result.stdout, "hello\n");
550        assert_eq!(result.result, serde_json::json!(4));
551        assert_eq!(result.elapsed_ms, 12);
552
553        let calls = calls.lock().expect("calls lock");
554        assert_eq!(calls.len(), 2, "exactly probe + exec");
555        assert_eq!(calls[0].0, "version");
556        assert_eq!(calls[1].0, "exec");
557        assert_eq!(
558            calls[1].1["code"], "print 'hello'\n2+2",
559            "code rides verbatim"
560        );
561
562        // Serialized shape: unit-explicit keys, ALL always.
563        let serialized = serde_json::to_value(&result).expect("serializes");
564        assert_eq!(serialized["stdout"], "hello\n");
565        assert_eq!(serialized["result"], 4);
566        assert_eq!(serialized["elapsedMs"], 12);
567        let mut keys: Vec<&str> = serialized
568            .as_object()
569            .expect("object")
570            .keys()
571            .map(String::as_str)
572            .collect();
573        keys.sort_unstable();
574        assert_eq!(keys, vec!["elapsedMs", "result", "stdout"]);
575    }
576
577    /// The missing-answer degrade: absent fields default (empty
578    /// stdout, null result, 0 ms) instead of erroring — ALL keys
579    /// still ride (the family convention).
580    #[tokio::test]
581    async fn absent_answer_fields_default_but_keys_ride() {
582        let (_dir, config, _path) = temp_config(Some("aabbcc"));
583        let (double, _calls) = rig(|_| Ok(serde_json::json!({})));
584        let result = script_run(&double, &config, "dev", "ign-cli", "pass")
585            .await
586            .expect("an empty object still answers");
587        assert_eq!(result.stdout, "");
588        assert_eq!(result.result, serde_json::Value::Null);
589        assert_eq!(result.elapsed_ms, 0);
590    }
591
592    /// A probe denial surfaces HONESTLY through the existing family
593    /// (the rig hands back the error webdev_route_call would have
594    /// mapped) — and exec NEVER fires.
595    #[tokio::test]
596    async fn probe_denial_surfaces_honestly_without_exec() {
597        let (_dir, config, _path) = temp_config(Some("stale"));
598        let (double, calls) = rig(|action| match action {
599            "version" => Err(CoreError::WebdevRouteError {
600                code: "secret_mismatch".to_string(),
601                message: "scriptExec secret mismatch".to_string(),
602                endpoint: Some("/system/webdev/ign-cli/cli/scriptExec".to_string()),
603            }),
604            _ => unreachable!("exec must not fire after a probe denial"),
605        });
606        let err = script_run(&double, &config, "dev", "ign-cli", "2+2")
607            .await
608            .expect_err("mismatch refuses");
609        assert_eq!(err.code(), "webdev_route_error");
610        assert_eq!(err.exit_code(), 6);
611        assert!(
612            err.hint().unwrap().contains("--rotate-secret"),
613            "the existing hint carries the redeploy/rotate advice: {:?}",
614            err.hint()
615        );
616        let calls = calls.lock().expect("calls lock");
617        assert_eq!(calls.len(), 1, "only the probe ran");
618        assert_eq!(calls[0].0, "version");
619    }
620
621    /// The pure three-form reader: --code passes through, --file
622    /// reads disk, both/none/unreadable refuse InvalidInput (usage
623    /// errors lead — the 03-03 put convention).
624    #[test]
625    fn read_script_input_resolves_the_three_forms() {
626        // --code verbatim.
627        assert_eq!(read_script_input(Some("2+2"), None).expect("code"), "2+2");
628        // --file PATH reads the file.
629        let dir = tempfile::tempdir().expect("tempdir");
630        let file = dir.path().join("snippet.py");
631        std::fs::write(&file, "print 'hi'\n").expect("write snippet");
632        assert_eq!(
633            read_script_input(None, file.to_str()).expect("file"),
634            "print 'hi'\n"
635        );
636        // Both → InvalidInput naming the exclusivity.
637        let err = read_script_input(Some("2+2"), file.to_str()).expect_err("both refuse");
638        assert_eq!(err.code(), "invalid_input");
639        assert_eq!(err.exit_code(), 2);
640        assert!(
641            err.to_string().contains("--code") && err.to_string().contains("--file"),
642            "reason names both flags: {err}"
643        );
644        // Neither → InvalidInput naming the forms.
645        let err = read_script_input(None, None).expect_err("neither refuses");
646        assert_eq!(err.code(), "invalid_input");
647        assert!(err.to_string().contains("--file -"), "stdin named: {err}");
648        // Unreadable file → InvalidInput naming the path.
649        let err = read_script_input(None, Some("/nonexistent/snippet.py")).expect_err("miss");
650        assert_eq!(err.code(), "invalid_input");
651        assert!(
652            err.to_string().contains("/nonexistent/snippet.py"),
653            "reason names the file: {err}"
654        );
655    }
656}