1use std::collections::BTreeMap;
4use std::env;
5use std::ffi::OsString;
6use std::fs;
7use std::path::{Path, PathBuf};
8
9use runx_contracts::JsonValue;
10use runx_runtime::orchestrator::LocalCredentialDescriptor;
11use runx_runtime::{resolve_path_from_user_input, resolve_runx_home_dir};
12use serde::Deserialize;
13
14use super::inputs::{parse_direct_input_arg, parse_input_arg, parse_json_input_arg};
15use super::{SkillAction, SkillPlan};
16
17pub fn parse_skill_plan(args: &[OsString]) -> Result<SkillPlan, String> {
18 let mut state = SkillParseState::default();
19 let mut index = 1;
20
21 while index < args.len() {
22 index = parse_skill_arg(args, index, &mut state)?;
23 index += 1;
24 }
25
26 let env = env::vars().collect();
27 let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
28 let local_credential = finalize_local_credential(&state, &env, &cwd)?;
29
30 let Some(skill_path) = state.skill_path.as_ref() else {
31 return Err("runx skill requires a skill package path".to_owned());
32 };
33 reject_resolver_flags_for_skill_management_action(skill_path, &state)?;
34 let skill_path = skill_path.clone();
35 let action = if state.inspect {
36 SkillAction::Inspect
37 } else {
38 SkillAction::Run
39 };
40
41 Ok(SkillPlan {
42 action,
43 skill_path,
44 runner: state.runner,
45 receipt_dir: state.receipt_dir,
46 run_id: state.run_id,
47 answers: state.answers,
48 registry: state.registry,
49 expected_digest: state.expected_digest,
50 json: state.json,
51 inputs: state.inputs,
52 local_credential,
53 })
54}
55
56#[derive(Default)]
57struct SkillParseState {
58 skill_path: Option<PathBuf>,
59 runner: Option<String>,
60 receipt_dir: Option<PathBuf>,
61 run_id: Option<String>,
62 answers: Option<PathBuf>,
63 registry: Option<String>,
64 expected_digest: Option<String>,
65 json: bool,
66 inspect: bool,
67 force_run: bool,
68 inputs: BTreeMap<String, JsonValue>,
69 credential: Option<CredentialBinding>,
70 credential_profile: Option<String>,
71 secret_env: Option<(String, String)>,
72}
73
74struct CredentialBinding {
75 provider: String,
76 auth_mode: String,
77 material_ref: String,
78 scopes: Vec<String>,
79}
80
81#[derive(Deserialize)]
82#[serde(deny_unknown_fields)]
83struct CredentialProfilesFile {
84 profiles: BTreeMap<String, CredentialProfile>,
85}
86
87#[derive(Deserialize)]
88#[serde(deny_unknown_fields)]
89struct CredentialProfile {
90 credential: String,
91 secret_env: String,
92}
93
94fn parse_credential_binding(value: &str) -> Result<CredentialBinding, String> {
95 let (provider, rest) = value.split_once(':').ok_or_else(credential_usage_error)?;
96 let provider = non_empty_credential_part(provider)?;
97 let (auth_mode, rest) = rest.split_once(':').ok_or_else(credential_usage_error)?;
98 let auth_mode = non_empty_credential_part(auth_mode)?;
99 let (material_ref, scopes) = split_material_ref_and_scopes(rest)?;
100 Ok(CredentialBinding {
101 provider: provider.to_owned(),
102 auth_mode: auth_mode.to_owned(),
103 material_ref,
104 scopes,
105 })
106}
107
108fn split_material_ref_and_scopes(value: &str) -> Result<(String, Vec<String>), String> {
109 let value = non_empty_credential_part(value)?;
110 let Some(index) = value.rfind(':') else {
111 return Ok((value.to_owned(), Vec::new()));
112 };
113 if value[index..].starts_with("://") {
114 return Ok((value.to_owned(), Vec::new()));
115 }
116 let material_ref = non_empty_credential_part(&value[..index])?.to_owned();
117 let scopes = value[index + 1..]
118 .split(',')
119 .map(str::trim)
120 .filter(|scope| !scope.is_empty())
121 .map(ToOwned::to_owned)
122 .collect();
123 Ok((material_ref, scopes))
124}
125
126fn non_empty_credential_part(value: &str) -> Result<&str, String> {
127 let value = value.trim();
128 if value.is_empty() {
129 return Err(credential_usage_error());
130 }
131 Ok(value)
132}
133
134fn credential_usage_error() -> String {
135 "runx skill --credential requires <provider>:<auth_mode>:<material_ref>".to_owned()
136}
137
138fn parse_secret_env(value: &str) -> Result<(String, String), String> {
139 parse_secret_env_from(value, |name| env::var(name).ok())
140}
141
142fn parse_secret_env_from(
143 value: &str,
144 lookup: impl Fn(&str) -> Option<String>,
145) -> Result<(String, String), String> {
146 if value.contains('=') {
147 return Err(
148 "runx skill --secret-env accepts an environment variable name, not an inline value"
149 .to_owned(),
150 );
151 }
152 let name = value.trim();
153 if name.is_empty() {
154 return Err("runx skill --secret-env requires a non-empty env var name".to_owned());
155 }
156 let secret = lookup(name)
157 .ok_or_else(|| format!("runx skill --secret-env env var '{name}' is not set"))?;
158 if secret.trim().is_empty() {
159 return Err("runx skill --secret-env requires a non-empty secret value".to_owned());
160 }
161 Ok((name.to_owned(), secret.to_owned()))
162}
163
164fn finalize_local_credential(
165 state: &SkillParseState,
166 env: &BTreeMap<String, String>,
167 cwd: &Path,
168) -> Result<Option<LocalCredentialDescriptor>, String> {
169 if let Some(profile) = state.credential_profile.as_ref() {
170 if state.credential.is_some() || state.secret_env.is_some() {
171 return Err(
172 "runx skill --credential-profile cannot be combined with --credential or --secret-env"
173 .to_owned(),
174 );
175 }
176 let profile = load_credential_profile(profile, env, cwd)?;
177 let credential = parse_credential_binding(&profile.credential)?;
178 let secret_env = parse_secret_env_from(&profile.secret_env, |name| env.get(name).cloned())?;
179 return Ok(Some(local_credential_descriptor(&credential, &secret_env)));
180 }
181 match (&state.credential, &state.secret_env) {
182 (None, None) => Ok(None),
183 (Some(_), None) => {
184 Err("runx skill --credential requires --secret-env <ENV_VAR>".to_owned())
185 }
186 (binding, Some((env_var, secret))) => {
187 let binding = binding.as_ref().ok_or_else(|| {
188 "runx skill --secret-env requires --credential <provider>:<auth_mode>:<material_ref>"
189 .to_owned()
190 })?;
191 Ok(Some(local_credential_descriptor(
192 binding,
193 &(env_var.clone(), secret.clone()),
194 )))
195 }
196 }
197}
198
199fn local_credential_descriptor(
200 binding: &CredentialBinding,
201 secret_env: &(String, String),
202) -> LocalCredentialDescriptor {
203 LocalCredentialDescriptor {
204 provider: binding.provider.clone(),
205 auth_mode: binding.auth_mode.clone(),
206 env_var: secret_env.0.clone(),
207 material_ref: binding.material_ref.clone(),
208 scopes: binding.scopes.clone(),
209 secret: secret_env.1.clone(),
210 }
211}
212
213fn load_credential_profile(
214 profile_name: &str,
215 env: &BTreeMap<String, String>,
216 cwd: &Path,
217) -> Result<CredentialProfile, String> {
218 let profile_name = profile_name.trim();
219 if profile_name.is_empty() {
220 return Err("runx skill --credential-profile requires a non-empty name".to_owned());
221 }
222 let paths = credential_profile_paths(env, cwd);
223 for path in &paths {
224 let contents = match fs::read_to_string(path) {
225 Ok(contents) => contents,
226 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
227 Err(error) => {
228 return Err(format!(
229 "runx skill could not read credential profile file {}: {error}",
230 path.display()
231 ));
232 }
233 };
234 let parsed: CredentialProfilesFile = serde_json::from_str(&contents).map_err(|error| {
235 format!(
236 "runx skill credential profile file {} is invalid JSON: {error}",
237 path.display()
238 )
239 })?;
240 if let Some(profile) = parsed.profiles.into_iter().find_map(|(name, profile)| {
241 if name == profile_name {
242 Some(profile)
243 } else {
244 None
245 }
246 }) {
247 return Ok(profile);
248 }
249 }
250 let searched = paths
251 .iter()
252 .map(|path| path.display().to_string())
253 .collect::<Vec<_>>()
254 .join(", ");
255 Err(format!(
256 "runx skill credential profile '{profile_name}' was not found; searched {searched}"
257 ))
258}
259
260fn credential_profile_paths(env: &BTreeMap<String, String>, cwd: &Path) -> Vec<PathBuf> {
261 if let Some(path) = env
262 .get("RUNX_CREDENTIAL_PROFILES")
263 .filter(|value| !value.trim().is_empty())
264 {
265 return vec![resolve_path_from_user_input(path, env, cwd, true)];
266 }
267 let mut paths = Vec::new();
268 if let Some(project_dir) = env
269 .get("RUNX_PROJECT_DIR")
270 .filter(|value| !value.trim().is_empty())
271 .map(|value| resolve_path_from_user_input(value, env, cwd, true))
272 .or_else(|| nearest_project_runx_dir(cwd))
273 {
274 paths.push(project_dir.join("credentials.json"));
275 }
276 paths.push(resolve_runx_home_dir(env, cwd).join("credentials.json"));
277 paths.dedup();
278 paths
279}
280
281fn nearest_project_runx_dir(cwd: &Path) -> Option<PathBuf> {
282 cwd.ancestors()
283 .map(|ancestor| ancestor.join(".runx"))
284 .find(|candidate| candidate.is_dir())
285}
286
287fn reject_resolver_flags_for_skill_management_action(
288 skill_path: &Path,
289 state: &SkillParseState,
290) -> Result<(), String> {
291 if state.registry.is_none() && state.expected_digest.is_none() {
292 return Ok(());
293 }
294 if !is_skill_management_action(skill_path) {
295 return Ok(());
296 }
297 Err("runx skill --registry and --digest are only supported when running a skill ref".to_owned())
298}
299
300fn is_skill_management_action(skill_path: &Path) -> bool {
301 if skill_path.components().count() != 1 {
302 return false;
303 }
304 matches!(
305 skill_path.to_str(),
306 Some("add" | "inspect" | "publish" | "search" | "validate")
307 )
308}
309
310fn parse_skill_arg(
314 args: &[OsString],
315 mut index: usize,
316 state: &mut SkillParseState,
317) -> Result<usize, String> {
318 let token = string_arg(args, index)?;
319 if is_retired_skill_option(&token) {
320 return Err(
321 "retired runx skill receipt option is not supported; use --receipt-dir".to_owned(),
322 );
323 }
324 match token.as_str() {
325 value if value.starts_with("--receipt-dir=") => {
326 state.receipt_dir = Some(PathBuf::from(value.trim_start_matches("--receipt-dir=")));
327 }
328 value if value.starts_with("-R=") => {
329 state.receipt_dir = Some(PathBuf::from(value.trim_start_matches("-R=")));
330 }
331 value if value.starts_with("--receipts=") => {
332 state.receipt_dir = Some(PathBuf::from(value.trim_start_matches("--receipts=")));
333 }
334 "--receipt-dir" => {
335 index += 1;
336 state.receipt_dir = Some(PathBuf::from(string_arg(args, index)?));
337 }
338 "--receipts" => {
339 index += 1;
340 state.receipt_dir = Some(PathBuf::from(string_arg(args, index)?));
341 }
342 "-R" => {
343 index += 1;
344 state.receipt_dir = Some(PathBuf::from(string_arg(args, index)?));
345 }
346 value if value.starts_with("--run-id=") || value == "--run-id" => {
347 return Err(skill_resume_flag_error());
348 }
349 value if value.starts_with("--answers=") || value == "--answers" => {
350 return Err(skill_resume_flag_error());
351 }
352 value if value.starts_with("--runner=") || value == "--runner" => {
353 return Err(
354 "runx skill --runner is no longer supported; use `runx skill <skill> <runner>`"
355 .to_owned(),
356 );
357 }
358 value if value.starts_with("--registry=") => {
359 state.registry = Some(non_empty_flag_value(
360 "--registry",
361 value.trim_start_matches("--registry="),
362 )?);
363 }
364 "--registry" => {
365 index += 1;
366 state.registry = Some(non_empty_flag_value(
367 "--registry",
368 &string_arg(args, index)?,
369 )?);
370 }
371 value if value.starts_with("--digest=") => {
372 state.expected_digest = Some(non_empty_flag_value(
373 "--digest",
374 value.trim_start_matches("--digest="),
375 )?);
376 }
377 "--digest" => {
378 index += 1;
379 state.expected_digest =
380 Some(non_empty_flag_value("--digest", &string_arg(args, index)?)?);
381 }
382 value if value.starts_with("--input=") => {
383 index = parse_input_arg(
384 args,
385 index,
386 Some(value.trim_start_matches("--input=")),
387 &mut state.inputs,
388 )?;
389 }
390 value if value.starts_with("--input-json=") => {
391 index = parse_json_input_arg(
392 args,
393 index,
394 Some(value.trim_start_matches("--input-json=")),
395 &mut state.inputs,
396 )?;
397 }
398 value if value.starts_with("-i=") => {
399 index = parse_input_arg(
400 args,
401 index,
402 Some(value.trim_start_matches("-i=")),
403 &mut state.inputs,
404 )?;
405 }
406 "--input" => index = parse_input_arg(args, index, None, &mut state.inputs)?,
407 "--input-json" => index = parse_json_input_arg(args, index, None, &mut state.inputs)?,
408 "-i" => index = parse_input_arg(args, index, None, &mut state.inputs)?,
409 "--run" => state.force_run = true,
410 value if value.starts_with("--credential=") => {
411 state.credential = Some(parse_credential_binding(
412 value.trim_start_matches("--credential="),
413 )?);
414 }
415 "--credential" => {
416 index += 1;
417 state.credential = Some(parse_credential_binding(&string_arg(args, index)?)?);
418 }
419 value if value.starts_with("--credential-profile=") => {
420 state.credential_profile = Some(non_empty_flag_value(
421 "--credential-profile",
422 value.trim_start_matches("--credential-profile="),
423 )?);
424 }
425 value if value.starts_with("--profile=") => {
426 state.credential_profile = Some(non_empty_flag_value(
427 "--profile",
428 value.trim_start_matches("--profile="),
429 )?);
430 }
431 "--credential-profile" => {
432 index += 1;
433 state.credential_profile = Some(non_empty_flag_value(
434 "--credential-profile",
435 &string_arg(args, index)?,
436 )?);
437 }
438 "--profile" | "-p" => {
439 index += 1;
440 state.credential_profile = Some(non_empty_flag_value(
441 "--profile",
442 &string_arg(args, index)?,
443 )?);
444 }
445 value if value.starts_with("--secret-env=") => {
446 state.secret_env = Some(parse_secret_env(value.trim_start_matches("--secret-env="))?);
447 }
448 "--secret-env" => {
449 index += 1;
450 state.secret_env = Some(parse_secret_env(&string_arg(args, index)?)?);
451 }
452 "--json" | "-j" => state.json = true,
453 "--non-interactive" => {}
454 value if value.starts_with("--") => {
455 index = parse_direct_input_arg(args, index, value, &mut state.inputs)?;
456 }
457 value => {
458 if state.skill_path.is_none() && value == "inspect" {
459 state.inspect = true;
460 } else if state.skill_path.is_none() {
461 state.skill_path = Some(PathBuf::from(value));
462 } else if state.runner.is_none() {
463 state.runner = Some(value.to_owned());
464 } else {
465 return Err(format!("unexpected runx skill argument {value}"));
466 }
467 }
468 }
469 Ok(index)
470}
471
472fn non_empty_flag_value(flag: &str, value: &str) -> Result<String, String> {
473 let value = value.trim();
474 if value.is_empty() {
475 return Err(format!("runx skill {flag} requires a non-empty value"));
476 }
477 Ok(value.to_owned())
478}
479
480fn skill_resume_flag_error() -> String {
481 "runx skill continuation flags are no longer supported; use `runx resume <run-id> <answers.json>`".to_owned()
482}
483
484fn is_retired_skill_option(token: &str) -> bool {
485 let Some(flag) = token.strip_prefix("--") else {
486 return false;
487 };
488 let name = flag.split_once('=').map_or(flag, |(name, _value)| name);
489 name == "receipt" || name == retired_receipt_dir_option_name()
490}
491
492fn retired_receipt_dir_option_name() -> String {
493 ["receipt", "Dir"].concat()
494}
495
496fn string_arg(args: &[OsString], index: usize) -> Result<String, String> {
497 let value = args
498 .get(index)
499 .ok_or_else(|| "missing value for runx skill argument".to_owned())?;
500 value
501 .to_str()
502 .map(ToOwned::to_owned)
503 .ok_or_else(|| "runx skill arguments must be UTF-8".to_owned())
504}
505
506#[cfg(test)]
507mod tests {
508 use std::fs;
509 use std::time::{SystemTime, UNIX_EPOCH};
510
511 use super::{SkillAction, SkillParseState, finalize_local_credential};
512
513 #[test]
514 fn credential_profile_resolves_project_descriptor_and_env_secret() -> Result<(), String> {
515 let root = unique_temp_dir("runx-credential-profile")?;
516 let runx_dir = root.join(".runx");
517 fs::create_dir_all(&runx_dir).map_err(|error| error.to_string())?;
518 fs::write(
519 runx_dir.join("credentials.json"),
520 r#"{
521 "profiles": {
522 "example": {
523 "credential": "example:bearer:local://example/internal:example.review",
524 "secret_env": "INTERNAL_SYNC_SECRET"
525 }
526 }
527}
528"#,
529 )
530 .map_err(|error| error.to_string())?;
531
532 let state = SkillParseState {
533 credential_profile: Some("example".to_owned()),
534 ..Default::default()
535 };
536 let env = [
537 (
538 "RUNX_PROJECT_DIR".to_owned(),
539 runx_dir.to_string_lossy().into_owned(),
540 ),
541 ("INTERNAL_SYNC_SECRET".to_owned(), "secret-value".to_owned()),
542 ]
543 .into_iter()
544 .collect();
545 let credential = finalize_local_credential(&state, &env, &root)?
546 .ok_or_else(|| "credential profile did not resolve".to_owned())?;
547
548 assert_eq!(credential.provider, "example");
549 assert_eq!(credential.auth_mode, "bearer");
550 assert_eq!(credential.material_ref, "local://example/internal");
551 assert_eq!(credential.env_var, "INTERNAL_SYNC_SECRET");
552 assert_eq!(credential.secret, "secret-value");
553 assert_eq!(credential.scopes, vec!["example.review"]);
554
555 fs::remove_dir_all(root).map_err(|error| error.to_string())?;
556 Ok(())
557 }
558
559 #[test]
560 fn credential_profile_rejects_manual_credential_flags() -> Result<(), String> {
561 let state = SkillParseState {
562 credential_profile: Some("example".to_owned()),
563 secret_env: Some(("TOKEN".to_owned(), "secret".to_owned())),
564 ..Default::default()
565 };
566 let error = finalize_local_credential(&state, &Default::default(), &std::env::temp_dir())
567 .err()
568 .ok_or_else(|| "profile unexpectedly combined with manual flags".to_owned())?;
569 assert!(error.contains("cannot be combined"));
570 Ok(())
571 }
572
573 #[test]
574 fn short_human_flags_parse_for_skill_runs() -> Result<(), String> {
575 let args = [
576 "skill",
577 "-p",
578 "operator",
579 "-i",
580 "claim=abc",
581 "-R",
582 "receipts",
583 "-j",
584 ]
585 .into_iter()
586 .map(std::ffi::OsString::from)
587 .collect::<Vec<_>>();
588 let mut state = SkillParseState::default();
589 let mut index = 1;
590 while index < args.len() {
591 index = super::parse_skill_arg(&args, index, &mut state)?;
592 index += 1;
593 }
594 assert_eq!(state.credential_profile.as_deref(), Some("operator"));
595 assert_eq!(
596 state.inputs.get("claim"),
597 Some(&runx_contracts::JsonValue::String("abc".to_owned()))
598 );
599 assert_eq!(
600 state.receipt_dir.as_deref(),
601 Some(std::path::Path::new("receipts"))
602 );
603 assert!(state.json);
604 Ok(())
605 }
606
607 #[test]
608 fn input_json_parses_strict_json_values() -> Result<(), String> {
609 let args = [
610 "skill",
611 "skills/data-store",
612 "--input-json",
613 "event",
614 r#"{"type":"posting.claimed","payload":{"actor":"agent-9"}}"#,
615 "--input-json=limits={\"rows\":10}",
616 ]
617 .into_iter()
618 .map(std::ffi::OsString::from)
619 .collect::<Vec<_>>();
620 let plan = super::parse_skill_plan(&args)?;
621 assert_eq!(plan.action, SkillAction::Run);
622
623 assert_eq!(
624 plan.inputs
625 .get("event")
626 .and_then(runx_contracts::JsonValue::as_object)
627 .and_then(|event| event.get("type"))
628 .and_then(runx_contracts::JsonValue::as_str),
629 Some("posting.claimed")
630 );
631 let rows = plan
632 .inputs
633 .get("limits")
634 .and_then(runx_contracts::JsonValue::as_object)
635 .and_then(|limits| limits.get("rows"))
636 .and_then(|rows| match rows {
637 runx_contracts::JsonValue::Number(number) => number.as_f64(),
638 _ => None,
639 });
640 assert_eq!(rows, Some(10.0));
641 Ok(())
642 }
643
644 #[test]
645 fn skill_without_inputs_executes_default_runner() -> Result<(), String> {
646 let args = ["skill", "skills/messageboard"]
647 .into_iter()
648 .map(std::ffi::OsString::from)
649 .collect::<Vec<_>>();
650 let plan = super::parse_skill_plan(&args)?;
651
652 assert_eq!(plan.action, SkillAction::Run);
653 assert_eq!(
654 plan.skill_path,
655 std::path::PathBuf::from("skills/messageboard")
656 );
657 assert_eq!(plan.runner, None);
658 Ok(())
659 }
660
661 #[test]
662 fn positional_runner_without_inputs_executes_runner() -> Result<(), String> {
663 let args = ["skill", "skills/messageboard", "post_and_append"]
664 .into_iter()
665 .map(std::ffi::OsString::from)
666 .collect::<Vec<_>>();
667 let plan = super::parse_skill_plan(&args)?;
668
669 assert_eq!(plan.action, SkillAction::Run);
670 assert_eq!(plan.runner.as_deref(), Some("post_and_append"));
671 Ok(())
672 }
673
674 #[test]
675 fn explicit_inspect_returns_skill_card() -> Result<(), String> {
676 let args = ["skill", "inspect", "skills/messageboard", "post_and_append"]
677 .into_iter()
678 .map(std::ffi::OsString::from)
679 .collect::<Vec<_>>();
680 let plan = super::parse_skill_plan(&args)?;
681
682 assert_eq!(plan.action, SkillAction::Inspect);
683 assert_eq!(
684 plan.skill_path,
685 std::path::PathBuf::from("skills/messageboard")
686 );
687 assert_eq!(plan.runner.as_deref(), Some("post_and_append"));
688 Ok(())
689 }
690
691 #[test]
692 fn positional_runner_with_inputs_executes_runner() -> Result<(), String> {
693 let args = [
694 "skill",
695 "skills/messageboard",
696 "post_and_append",
697 "-i",
698 "title=hello",
699 ]
700 .into_iter()
701 .map(std::ffi::OsString::from)
702 .collect::<Vec<_>>();
703 let plan = super::parse_skill_plan(&args)?;
704
705 assert_eq!(plan.action, SkillAction::Run);
706 assert_eq!(plan.runner.as_deref(), Some("post_and_append"));
707 assert_eq!(
708 plan.inputs.get("title"),
709 Some(&runx_contracts::JsonValue::String("hello".to_owned()))
710 );
711 Ok(())
712 }
713
714 #[test]
715 fn run_flag_executes_zero_input_runner() -> Result<(), String> {
716 let args = ["skill", "skills/ops-desk", "refresh", "--run"]
717 .into_iter()
718 .map(std::ffi::OsString::from)
719 .collect::<Vec<_>>();
720 let plan = super::parse_skill_plan(&args)?;
721
722 assert_eq!(plan.action, SkillAction::Run);
723 assert_eq!(plan.runner.as_deref(), Some("refresh"));
724 Ok(())
725 }
726
727 #[test]
728 fn input_json_rejects_non_json_values() -> Result<(), String> {
729 let args = [
730 "skill",
731 "skills/data-store",
732 "--input-json",
733 "event",
734 "plain text",
735 ]
736 .into_iter()
737 .map(std::ffi::OsString::from)
738 .collect::<Vec<_>>();
739 let Err(error) = super::parse_skill_plan(&args) else {
740 return Err("invalid json input should fail".to_owned());
741 };
742
743 assert!(error.contains("--input-json event is invalid JSON"));
744 Ok(())
745 }
746
747 #[test]
748 fn credential_parser_keeps_uri_material_ref_intact() -> Result<(), String> {
749 let binding = super::parse_credential_binding(
750 "frantic:bearer:local://frantic/internal:frantic.review,frantic.write",
751 )?;
752 assert_eq!(binding.provider, "frantic");
753 assert_eq!(binding.auth_mode, "bearer");
754 assert_eq!(binding.material_ref, "local://frantic/internal");
755 assert_eq!(binding.scopes, vec!["frantic.review", "frantic.write"]);
756 Ok(())
757 }
758
759 fn unique_temp_dir(name: &str) -> Result<std::path::PathBuf, String> {
760 let nanos = SystemTime::now()
761 .duration_since(UNIX_EPOCH)
762 .map_err(|error| error.to_string())?
763 .as_nanos();
764 let path = std::env::temp_dir().join(format!("{name}-{}-{nanos}", std::process::id()));
765 fs::create_dir_all(&path).map_err(|error| error.to_string())?;
766 Ok(path)
767 }
768}