ripht_php_sapi/adapters/
cli.rs

1//! CLI request builder for command-line style execution.
2//!
3//! Provides minimal `$_SERVER` variables (no HTTP context). Use for
4//! scripts that expect CLI-style invocation.
5
6use std::path::{Path, PathBuf};
7
8use crate::execution::ExecutionContext;
9use crate::sapi::ServerVars;
10
11#[cfg(feature = "tracing")]
12use tracing::debug;
13
14/// Errors from building a CLI request.
15#[derive(Debug, Clone)]
16#[non_exhaustive]
17pub enum CliRequestError {
18    ScriptNotFound(PathBuf),
19}
20
21impl std::error::Error for CliRequestError {}
22
23impl std::fmt::Display for CliRequestError {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        match self {
26            Self::ScriptNotFound(path) => {
27                write!(f, "Script not found: {}", path.display())
28            }
29        }
30    }
31}
32
33/// Builder for CLI-style PHP requests.
34///
35/// Configure arguments, stdin, environment, and INI overrides.
36#[derive(Debug, Clone)]
37pub struct CliRequest {
38    stdin: Vec<u8>,
39    argv: Vec<String>,
40    working_dir: Option<PathBuf>,
41    env_vars: Vec<(String, String)>,
42    ini_overrides: Vec<(String, String)>,
43}
44
45impl Default for CliRequest {
46    fn default() -> Self {
47        Self {
48            stdin: Default::default(),
49            argv: Default::default(),
50            working_dir: Default::default(),
51            env_vars: Default::default(),
52            ini_overrides: vec![
53                ("html_errors".to_string(), "0".to_string()),
54                ("display_errors".to_string(), "1".to_string()),
55                ("implicit_flush".to_string(), "1".to_string()),
56                ("max_input_time".to_string(), "-1".to_string()),
57                ("output_buffering".to_string(), "0".to_string()),
58                ("max_execution_time".to_string(), "0".to_string()),
59            ],
60        }
61    }
62}
63
64impl CliRequest {
65    #[must_use]
66    pub fn new() -> Self {
67        Self::default()
68    }
69
70    #[must_use]
71    pub fn with_arg(mut self, s: impl Into<String>) -> Self {
72        self.argv.push(s.into());
73        self
74    }
75
76    #[must_use]
77    pub fn with_args<I, S>(mut self, iter: I) -> Self
78    where
79        I: IntoIterator<Item = S>,
80        S: Into<String>,
81    {
82        self.argv.extend(
83            iter.into_iter()
84                .map(|s| s.into()),
85        );
86
87        self
88    }
89
90    #[must_use]
91    pub fn with_stdin(mut self, bytes: impl Into<Vec<u8>>) -> Self {
92        self.stdin = bytes.into();
93        self
94    }
95
96    #[must_use]
97    pub fn with_env(
98        mut self,
99        key: impl Into<String>,
100        value: impl Into<String>,
101    ) -> Self {
102        self.env_vars
103            .push((key.into(), value.into()));
104        self
105    }
106
107    #[must_use]
108    pub fn with_envs<I, K, V>(mut self, iter: I) -> Self
109    where
110        I: IntoIterator<Item = (K, V)>,
111        K: Into<String>,
112        V: Into<String>,
113    {
114        self.env_vars.extend(
115            iter.into_iter()
116                .map(|(k, v)| (k.into(), v.into())),
117        );
118
119        self
120    }
121
122    #[must_use]
123    pub fn with_ini(
124        mut self,
125        key: impl Into<String>,
126        value: impl Into<String>,
127    ) -> Self {
128        self.ini_overrides
129            .push((key.into(), value.into()));
130        self
131    }
132
133    #[must_use]
134    pub fn with_ini_overrides<I, K, V>(mut self, iter: I) -> Self
135    where
136        I: IntoIterator<Item = (K, V)>,
137        K: Into<String>,
138        V: Into<String>,
139    {
140        self.ini_overrides.extend(
141            iter.into_iter()
142                .map(|(k, v)| (k.into(), v.into())),
143        );
144        self
145    }
146
147    #[must_use]
148    pub fn with_working_dir(mut self, path: impl Into<PathBuf>) -> Self {
149        self.working_dir = Some(path.into());
150
151        self
152    }
153
154    pub fn build(
155        self,
156        script_path: impl AsRef<Path>,
157    ) -> Result<ExecutionContext, CliRequestError> {
158        let script_path = script_path
159            .as_ref()
160            .to_path_buf();
161
162        #[cfg(feature = "tracing")]
163        debug!(
164            script_path = %script_path.display(),
165            args = ?self.argv,
166            "Building CLI request"
167        );
168
169        if !script_path.exists() {
170            return Err(CliRequestError::ScriptNotFound(script_path));
171        }
172
173        let script_filename = std::fs::canonicalize(&script_path)
174            .unwrap_or_else(|_| script_path.clone());
175
176        let script_name = script_filename
177            .file_name()
178            .map(|s| {
179                s.to_string_lossy()
180                    .into_owned()
181            })
182            .unwrap_or_default();
183
184        let argc = self.argv.len() + 1;
185
186        let argv_str = std::iter::once(script_name.clone())
187            .chain(self.argv.iter().cloned())
188            .collect::<Vec<_>>()
189            .join(" ");
190
191        let mut vars = ServerVars::cli_defaults();
192
193        vars.script_filename(&script_filename)
194            .script_name(&script_name)
195            .path_translated(&script_filename)
196            .argc(argc)
197            .argv(&argv_str);
198
199        if let Some(ref wd) = self.working_dir {
200            vars.pwd(wd);
201        }
202
203        Ok(ExecutionContext {
204            script_path,
205            server_vars: vars,
206            input: self.stdin,
207            env_vars: self.env_vars,
208            ini_overrides: self.ini_overrides,
209            log_to_stderr: true,
210        })
211    }
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use std::path::PathBuf;
218
219    fn php_script_path(name: &str) -> PathBuf {
220        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
221            .join("tests/php_scripts")
222            .join(name)
223    }
224
225    #[test]
226    fn test_cli_request_sets_log_to_stderr() {
227        let script_path = php_script_path("hello.php");
228
229        let ctx = CliRequest::new()
230            .build(&script_path)
231            .expect("failed to build CLI request");
232
233        assert!(
234            ctx.log_to_stderr,
235            "CLI request should set log_to_stderr to true"
236        );
237    }
238
239    #[test]
240    fn test_cli_execution_captures_messages() {
241        use crate::RiphtSapi;
242
243        let sapi = RiphtSapi::instance();
244        let script_path = php_script_path("error_log_test.php");
245
246        let ctx = CliRequest::new()
247            .build(&script_path)
248            .expect("failed to build CLI request");
249
250        let result = sapi
251            .execute(ctx)
252            .expect("execution should succeed");
253
254        assert!(
255            !result.messages.is_empty(),
256            "CLI execution should capture error_log messages"
257        );
258
259        assert!(
260            result
261                .messages
262                .iter()
263                .any(|m| m
264                    .message
265                    .contains("Test error log message")),
266            "Should contain the error_log message"
267        );
268    }
269}