Skip to main content

bnto_shell/
args.rs

1// `shell-run` argument preparation — conditional arg groups and the
2// `{{url}}` / `{{input}}` placeholders injected by input preparation.
3
4/// Placeholder in args for URL/text input injected by the CLI's input preparation.
5const URL_PLACEHOLDER: &str = "{{url}}";
6const INPUT_PLACEHOLDER: &str = "{{input}}";
7const OUTPUT_DIR_TOKEN: &str = "{{output_dir}}";
8
9/// Resolve `{{url}}` and `{{input}}` placeholders in args from injected params.
10///
11/// The CLI's input preparation injects `"url"` or `"text"` into the node's
12/// params for URL/Text mode recipes. If no placeholder exists in the args
13/// but a `url` param is present, append the URL as the last argument.
14#[expect(clippy::too_many_lines)]
15pub(crate) fn resolve_input_placeholders(
16    args: &mut Vec<String>,
17    params: &serde_json::Map<String, serde_json::Value>,
18) -> Result<(), String> {
19    let url = params
20        .get("url")
21        .and_then(serde_json::Value::as_str)
22        .unwrap_or("");
23    let text = params
24        .get("text")
25        .and_then(serde_json::Value::as_str)
26        .unwrap_or("");
27
28    // The input value is whichever was injected (url or text).
29    let input_val = if !url.is_empty() { url } else { text };
30    // SEC-27: `{{output_dir}}` is filled after this, so input carrying it
31    // would have it filled too.
32    if input_val.contains(OUTPUT_DIR_TOKEN) {
33        return Err(format!(
34            "the input carries '{OUTPUT_DIR_TOKEN}', which shell-run fills itself"
35        ));
36    }
37
38    let has_url_placeholder = args.iter().any(|a| a.contains(URL_PLACEHOLDER));
39    let has_input_placeholder = args.iter().any(|a| a.contains(INPUT_PLACEHOLDER));
40
41    // Substitute placeholders in existing args.
42    for arg in args.iter_mut() {
43        if arg.contains(URL_PLACEHOLDER) && !url.is_empty() {
44            *arg = arg.replace(URL_PLACEHOLDER, url);
45        }
46        if arg.contains(INPUT_PLACEHOLDER) && !input_val.is_empty() {
47            *arg = arg.replace(INPUT_PLACEHOLDER, input_val);
48        }
49    }
50
51    // If no placeholder was found but a URL was injected, append it.
52    // This is the common case: `yt-dlp <args...> <url>`
53    if !has_url_placeholder && !has_input_placeholder && !url.is_empty() {
54        args.push(url.to_string());
55    }
56    Ok(())
57}
58
59/// Flatten a resolved `args` array that may contain conditional groups.
60///
61/// After template resolution, the args array can contain:
62/// - **Flat string**: included if non-empty, dropped if `""`
63/// - **Nested array**: ALL elements must be non-empty strings → flattened
64///   into parent; if ANY element is `""` → entire group dropped
65/// - **Other types**: dropped (backward compat)
66///
67/// This enables conditional flag groups in recipes:
68/// ```json
69/// "args": ["--always", ["--cookies-from-browser", "{{fields.browser}}"], "{{fields.thumbnail}}"]
70/// ```
71/// When `browser=""`: `["--always"]`
72/// When `browser="chrome"`: `["--always", "--cookies-from-browser", "chrome"]`
73pub fn flatten_conditional_args(args: &[serde_json::Value]) -> Vec<String> {
74    let mut result = Vec::new();
75    for item in args {
76        match item {
77            serde_json::Value::String(s) if !s.is_empty() => {
78                result.push(s.clone());
79            }
80            serde_json::Value::Array(group) => {
81                let strings: Vec<&str> =
82                    group.iter().filter_map(serde_json::Value::as_str).collect();
83                // All elements must be present strings AND non-empty.
84                let all_non_empty =
85                    strings.len() == group.len() && strings.iter().all(|s| !s.is_empty());
86                if all_non_empty {
87                    result.extend(strings.into_iter().map(String::from));
88                }
89            }
90            _ => {}
91        }
92    }
93    result
94}