Skip to main content

java_manager/
execute.rs

1//! Running Java programs with controlled output and redirection.
2
3use crate::{JavaError, JavaInfo};
4use std::fs::{File, OpenOptions};
5use std::io::{BufRead, BufReader};
6use std::path::{Path, PathBuf};
7use std::process::{Command, Stdio};
8use std::thread;
9use std::time::Duration;
10
11/// Controls which output streams are printed to the console.
12///
13/// Used internally by [`JavaInfo::execute`] and its variants.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum OutputMode {
16    /// Print both stdout and stderr.
17    Both,
18    /// Print only stdout; stderr is discarded.
19    OutputOnly,
20    /// Print only stderr; stdout is discarded.
21    ErrorOnly,
22}
23
24impl JavaInfo {
25    /// Executes the Java executable with the given arguments, printing both
26    /// stdout and stderr to the console.
27    ///
28    /// The argument string is split using shell‑like rules (via `shell_words`).
29    /// The child process's stdout and stderr are captured and printed line by line
30    /// while the process runs.
31    ///
32    /// # Errors
33    ///
34    /// Returns `JavaError::IoError` if spawning or waiting fails.
35    /// Returns `JavaError::Other` if the argument string cannot be parsed.
36    /// Returns `JavaError::ExecutionFailed` if the Java process exits with a non‑zero status.
37    ///
38    /// # Examples
39    ///
40    /// ```no_run
41    /// # use java_manager::JavaInfo;
42    /// # let java = JavaInfo::new("/path/to/java".into())?;
43    /// java.execute("-version")?;
44    /// # Ok::<_, java_manager::JavaError>(())
45    /// ```
46    pub fn execute(&self, args: &str) -> Result<(), JavaError> {
47        self.run_java(args, OutputMode::Both)
48    }
49
50    /// Executes the Java executable, printing only stderr to the console.
51    /// Stdout is captured and discarded.
52    ///
53    /// See [`execute`](JavaInfo::execute) for details.
54    ///
55    /// # Errors
56    ///
57    /// Same as [`execute`](JavaInfo::execute).
58    pub fn execute_with_error(&self, args: &str) -> Result<(), JavaError> {
59        self.run_java(args, OutputMode::ErrorOnly)
60    }
61
62    /// Executes the Java executable, printing only stdout to the console.
63    /// Stderr is captured and discarded.
64    ///
65    /// See [`execute`](JavaInfo::execute) for details.
66    ///
67    /// # Errors
68    ///
69    /// Same as [`execute`](JavaInfo::execute).
70    pub fn execute_with_output(&self, args: &str) -> Result<(), JavaError> {
71        self.run_java(args, OutputMode::OutputOnly)
72    }
73
74    /// Internal implementation of Java execution with configurable output.
75    fn run_java(&self, args: &str, mode: OutputMode) -> Result<(), JavaError> {
76        let java_exe = self.java_executable()?;
77
78        let arg_vec = shell_words::split(args)
79            .map_err(|e| JavaError::Other(format!("Failed to parse arguments: {}", e)))?;
80
81        let mut cmd = Command::new(java_exe);
82        cmd.args(&arg_vec);
83
84        cmd.stdout(Stdio::piped());
85        cmd.stderr(Stdio::piped());
86
87        let mut child = cmd.spawn().map_err(JavaError::IoError)?;
88
89        let stdout = child.stdout.take().expect("Failed to get stdout pipe");
90        let stderr = child.stderr.take().expect("Failed to get stderr pipe");
91
92        let stdout_handle = if matches!(mode, OutputMode::Both | OutputMode::OutputOnly) {
93            Some(thread::spawn(move || {
94                let reader = BufReader::new(stdout);
95                for line in reader.lines().map_while(Result::ok) {
96                    println!("{}", line);
97                }
98            }))
99        } else {
100            None
101        };
102
103        let stderr_handle = if matches!(mode, OutputMode::Both | OutputMode::ErrorOnly) {
104            Some(thread::spawn(move || {
105                let reader = BufReader::new(stderr);
106                for line in reader.lines().map_while(Result::ok) {
107                    eprintln!("{}", line);
108                }
109            }))
110        } else {
111            None
112        };
113
114        let status = child.wait().map_err(JavaError::IoError)?;
115
116        if let Some(handle) = stdout_handle {
117            handle.join().unwrap();
118        }
119        if let Some(handle) = stderr_handle {
120            handle.join().unwrap();
121        }
122
123        if status.success() {
124            Ok(())
125        } else {
126            Err(JavaError::ExecutionFailed(format!(
127                "Execution failed: {}",
128                status.code().unwrap()
129            )))
130        }
131    }
132
133    /// Returns the path to the `java` executable inside this installation's `JAVA_HOME/bin`.
134    ///
135    /// # Errors
136    ///
137    /// Returns `JavaError::NotFound` if the executable does not exist.
138    fn java_executable(&self) -> Result<PathBuf, JavaError> {
139        let java_home = &self.java_home;
140        let exe_name = if cfg!(windows) { "java.exe" } else { "java" };
141        let java_exe = java_home.join("bin").join(exe_name);
142        if java_exe.exists() {
143            Ok(java_exe)
144        } else {
145            Err(JavaError::NotFound(format!(
146                "Java executable not found: {:?}",
147                java_exe
148            )))
149        }
150    }
151}
152
153/// A builder for configuring and executing a Java program (JAR or main class).
154///
155/// This struct allows you to set the Java runtime, JAR file or main class,
156/// arbitrary tool arguments, memory limits, program arguments, and I/O
157/// redirection before spawning the process.
158///
159/// # Examples
160///
161/// Run a JAR:
162///
163/// ```no_run
164/// use java_manager::{JavaRunner, JavaRedirect};
165///
166/// # let java = java_manager::java_home().unwrap();
167/// JavaRunner::new()
168///     .java(java)
169///     .jar("myapp.jar")
170///     .min_memory(256 * 1024 * 1024)   // 256 MB
171///     .max_memory(1024 * 1024 * 1024)  // 1 GB
172///     .arg("--server")
173///     .redirect(JavaRedirect::new().output("out.log").error("err.log"))
174///     .execute()?;
175/// # Ok::<_, java_manager::JavaError>(())
176/// ```
177///
178/// Run a tool command (no JAR or main class needed):
179///
180/// ```no_run
181/// # use java_manager::JavaRunner;
182/// # let java = java_manager::java_home().unwrap();
183/// JavaRunner::new()
184///     .java(java)
185///     .cmd(&["-version"])
186///     .execute()?;
187/// # Ok::<_, java_manager::JavaError>(())
188/// ```
189#[derive(Debug, Default)]
190pub struct JavaRunner {
191    java: Option<JavaInfo>,
192    jar: Option<PathBuf>,
193    min_memory: Option<String>,
194    max_memory: Option<String>,
195    main_class: Option<String>,
196    cmd_args: Option<Vec<String>>,
197    args: Vec<String>,
198    redirect: JavaRedirect,
199    classpath: Option<String>,
200    module_path: Option<PathBuf>,
201    add_opens: Vec<String>,
202    add_exports: Vec<String>,
203    system_properties: Vec<String>,
204    env_vars: Vec<(String, String)>,
205    working_dir: Option<PathBuf>,
206    timeout: Option<Duration>,
207}
208
209/// I/O redirection options for a Java process.
210///
211/// Use the builder methods to specify files for stdout, stderr, and stdin.
212/// If a stream is not redirected, it will inherit the parent's corresponding
213/// stream (i.e., print to console or read from keyboard).
214#[derive(Debug, Default)]
215pub struct JavaRedirect {
216    output: Option<PathBuf>,
217    error: Option<PathBuf>,
218    input: Option<PathBuf>,
219    append_output: bool,
220    append_error: bool,
221}
222
223impl JavaRedirect {
224    /// Creates a new empty redirection configuration.
225    pub fn new() -> Self {
226        Self::default()
227    }
228
229    /// Redirects the Java process's standard output to the given file.
230    /// The file will be created (or truncated) before execution.
231    pub fn output(mut self, path: impl AsRef<Path>) -> Self {
232        self.output = Some(path.as_ref().to_path_buf());
233        self
234    }
235
236    /// Redirects the Java process's standard error to the given file.
237    /// The file will be created (or truncated) before execution.
238    pub fn error(mut self, path: impl AsRef<Path>) -> Self {
239        self.error = Some(path.as_ref().to_path_buf());
240        self
241    }
242
243    /// Redirects the Java process's standard input from the given file.
244    /// The file must exist and be readable.
245    pub fn input(mut self, path: impl AsRef<Path>) -> Self {
246        self.input = Some(path.as_ref().to_path_buf());
247        self
248    }
249
250    /// Append to the output file instead of truncating.
251    /// Only has an effect when [`output`](Self::output) is also set.
252    pub fn append_output(mut self) -> Self {
253        self.append_output = true;
254        self
255    }
256
257    /// Append to the error file instead of truncating.
258    /// Only has an effect when [`error`](Self::error) is also set.
259    pub fn append_error(mut self) -> Self {
260        self.append_error = true;
261        self
262    }
263}
264
265impl JavaRunner {
266    /// Creates a new builder with default settings.
267    pub fn new() -> Self {
268        Self::default()
269    }
270
271    /// Sets the Java installation to use.
272    ///
273    /// This is mandatory before calling `execute`.
274    pub fn java(mut self, java: JavaInfo) -> Self {
275        self.java = Some(java);
276        self
277    }
278
279    /// Sets the JAR file to execute (implies the `-jar` flag).
280    ///
281    /// Either `jar` or `main_class` must be set.
282    pub fn jar(mut self, jar: impl AsRef<Path>) -> Self {
283        self.jar = Some(jar.as_ref().to_path_buf());
284        self
285    }
286
287    /// Sets the initial heap size (`-Xms`).
288    ///
289    /// The value is given in bytes and will be formatted as a memory string
290    /// (e.g., `256m`, `1g`). If the size is not a multiple of a megabyte or gigabyte,
291    /// it will be rounded to the nearest megabyte.
292    pub fn min_memory(mut self, bytes: usize) -> Self {
293        self.min_memory = Some(format_memory(bytes));
294        self
295    }
296
297    /// Sets the maximum heap size (`-Xmx`).
298    ///
299    /// See [`min_memory`](JavaRunner::min_memory) for formatting details.
300    pub fn max_memory(mut self, bytes: usize) -> Self {
301        self.max_memory = Some(format_memory(bytes));
302        self
303    }
304
305    /// Sets the main class to execute (instead of a JAR file).
306    ///
307    /// Either `jar` or `main_class` must be set.
308    pub fn main_class(mut self, class: impl Into<String>) -> Self {
309        self.main_class = Some(class.into());
310        self
311    }
312
313    /// Sets arbitrary tool arguments for the Java command (no JAR or main class).
314    ///
315    /// Use this to run commands like `java -version`, `java --list-modules`,
316    /// or `java -XshowSettings`. When `cmd_args` is set, the `jar` and
317    /// `main_class` requirements are bypassed.
318    ///
319    /// # Examples
320    ///
321    /// ```no_run
322    /// # use java_manager::JavaRunner;
323    /// # let java = java_manager::java_home().unwrap();
324    /// JavaRunner::new()
325    ///     .java(java)
326    ///     .cmd(&["-version"])
327    ///     .execute()?;
328    /// # Ok::<_, java_manager::JavaError>(())
329    /// ```
330    pub fn cmd(mut self, args: &[&str]) -> Self {
331        self.cmd_args = Some(args.iter().map(|s| s.to_string()).collect());
332        self
333    }
334
335    /// Adds a single argument to be passed to the Java program.
336    ///
337    /// Arguments are appended in the order they are added.
338    pub fn arg(mut self, arg: impl Into<String>) -> Self {
339        self.args.push(arg.into());
340        self
341    }
342
343    /// Sets I/O redirection options.
344    pub fn redirect(mut self, redirect: JavaRedirect) -> Self {
345        self.redirect = redirect;
346        self
347    }
348
349    /// Sets the classpath (`-cp` / `-classpath`).
350    ///
351    /// Paths are joined with the platform-specific separator (`;` on Windows, `:` otherwise).
352    pub fn classpath(mut self, paths: &[impl AsRef<Path>]) -> Self {
353        let separator = if cfg!(windows) { ";" } else { ":" };
354        let joined: Vec<String> = paths
355            .iter()
356            .map(|p| p.as_ref().to_string_lossy().to_string())
357            .collect();
358        self.classpath = Some(joined.join(separator));
359        self
360    }
361
362    /// Sets the module path (`--module-path`).
363    pub fn module_path(mut self, path: impl AsRef<Path>) -> Self {
364        self.module_path = Some(path.as_ref().to_path_buf());
365        self
366    }
367
368    /// Adds a `--add-opens` flag (e.g., `java.base/java.lang=ALL-UNNAMED`).
369    pub fn add_opens(
370        mut self,
371        module: impl Into<String>,
372        package: impl Into<String>,
373        target: impl Into<String>,
374    ) -> Self {
375        self.add_opens.push(format!(
376            "{}/{}.{}",
377            module.into(),
378            package.into(),
379            target.into()
380        ));
381        self
382    }
383
384    /// Adds a `--add-exports` flag (e.g., `java.base/com.sun.internal=ALL-UNNAMED`).
385    pub fn add_exports(
386        mut self,
387        module: impl Into<String>,
388        package: impl Into<String>,
389        target: impl Into<String>,
390    ) -> Self {
391        self.add_exports.push(format!(
392            "{}/{}.{}",
393            module.into(),
394            package.into(),
395            target.into()
396        ));
397        self
398    }
399
400    /// Sets a system property (`-Dkey=value`).
401    pub fn system_property(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
402        self.system_properties
403            .push(format!("-D{}={}", key.into(), value.into()));
404        self
405    }
406
407    /// Sets an environment variable for the child Java process.
408    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
409        self.env_vars.push((key.into(), value.into()));
410        self
411    }
412
413    /// Sets the working directory for the child Java process.
414    pub fn working_dir(mut self, path: impl AsRef<Path>) -> Self {
415        self.working_dir = Some(path.as_ref().to_path_buf());
416        self
417    }
418
419    /// Sets a timeout for the Java process.
420    ///
421    /// If the process runs longer than the specified duration, it will be killed.
422    /// A `Duration::ZERO` or `None` means no timeout.
423    pub fn timeout(mut self, duration: Duration) -> Self {
424        self.timeout = Some(duration);
425        self
426    }
427
428    /// Executes the configured Java program.
429    ///
430    /// When [`cmd_args`](Self::cmd) is set, the JAR / main-class requirement is
431    /// bypassed and the provided arguments are passed directly to `java`.
432    ///
433    /// # Errors
434    ///
435    /// Returns `JavaError::Other` if no Java installation has been set, or if
436    /// neither a JAR file nor a main class (nor `cmd` arguments) has been specified.
437    /// Returns `JavaError::NotFound` if the Java executable does not exist.
438    /// Returns `JavaError::IoError` if file operations or process spawning fail.
439    /// Returns `JavaError::ExecutionFailed` if the Java process exits with a non‑zero status.
440    pub fn execute(&self) -> Result<(), JavaError> {
441        let java = self.java.as_ref().ok_or_else(|| {
442            JavaError::Other("Must set Java environment via `.java(...)`".to_string())
443        })?;
444        let java_exe = java.java_executable()?;
445
446        let mut cmd = Command::new(java_exe);
447
448        // Classpath
449        if let Some(cp) = &self.classpath {
450            cmd.arg("-cp");
451            cmd.arg(cp);
452        }
453
454        // Module path
455        if let Some(mp) = &self.module_path {
456            cmd.arg("--module-path");
457            cmd.arg(mp);
458        }
459
460        // --add-opens
461        for open in &self.add_opens {
462            cmd.arg("--add-opens");
463            cmd.arg(open);
464        }
465
466        // --add-exports
467        for export in &self.add_exports {
468            cmd.arg("--add-exports");
469            cmd.arg(export);
470        }
471
472        // System properties
473        for prop in &self.system_properties {
474            cmd.arg(prop);
475        }
476
477        if let Some(min) = &self.min_memory {
478            cmd.arg(format!("-Xms{}", min));
479        }
480        if let Some(max) = &self.max_memory {
481            cmd.arg(format!("-Xmx{}", max));
482        }
483
484        if let Some(cmd_args) = &self.cmd_args {
485            cmd.args(cmd_args);
486        } else if let Some(jar) = &self.jar {
487            cmd.arg("-jar");
488            cmd.arg(jar);
489        } else if let Some(main) = &self.main_class {
490            cmd.arg(main);
491        } else {
492            return Err(JavaError::Other(
493                "Must specify JAR file, main class, or cmd args".into(),
494            ));
495        }
496
497        cmd.args(&self.args);
498
499        // Environment variables
500        for (key, value) in &self.env_vars {
501            cmd.env(key, value);
502        }
503
504        // Working directory
505        if let Some(dir) = &self.working_dir {
506            cmd.current_dir(dir);
507        }
508
509        // Configure redirection
510        if let Some(output) = &self.redirect.output {
511            let file = if self.redirect.append_output {
512                OpenOptions::new().append(true).create(true).open(output)
513            } else {
514                File::create(output)
515            }
516            .map_err(JavaError::IoError)?;
517            cmd.stdout(Stdio::from(file));
518        } else {
519            cmd.stdout(Stdio::inherit());
520        }
521
522        if let Some(error) = &self.redirect.error {
523            let file = if self.redirect.append_error {
524                OpenOptions::new().append(true).create(true).open(error)
525            } else {
526                File::create(error)
527            }
528            .map_err(JavaError::IoError)?;
529            cmd.stderr(Stdio::from(file));
530        } else {
531            cmd.stderr(Stdio::inherit());
532        }
533
534        if let Some(input) = &self.redirect.input {
535            let file = File::open(input).map_err(JavaError::IoError)?;
536            cmd.stdin(Stdio::from(file));
537        } else {
538            cmd.stdin(Stdio::inherit());
539        }
540
541        // Timeout handling
542        if let Some(timeout) = self.timeout {
543            let mut child = cmd.spawn().map_err(JavaError::IoError)?;
544            let start = std::time::Instant::now();
545            loop {
546                if child.try_wait().map_err(JavaError::IoError)?.is_some() {
547                    // Process completed within the timeout
548                    let status = child.wait().map_err(JavaError::IoError)?;
549                    return if status.success() {
550                        Ok(())
551                    } else {
552                        Err(JavaError::ExecutionFailed(format!(
553                            "Execution failed: {}",
554                            status.code().unwrap()
555                        )))
556                    };
557                }
558                if start.elapsed() >= timeout {
559                    // Timeout reached, kill the process
560                    kill_process(&child)?;
561                    return Err(JavaError::ExecutionFailed(format!(
562                        "Process timed out after {}ms",
563                        timeout.as_millis()
564                    )));
565                }
566                std::thread::sleep(Duration::from_millis(50));
567            }
568        }
569
570        let status = cmd.status().map_err(JavaError::IoError)?;
571
572        if status.success() {
573            Ok(())
574        } else {
575            Err(JavaError::ExecutionFailed(format!(
576                "Execution failed: {}",
577                status.code().unwrap()
578            )))
579        }
580    }
581}
582
583/// Formats a memory size in bytes into a Java‑compatible string (`<n>m` or `<n>g`).
584///
585/// If the size is an exact multiple of 1 GiB, it is formatted as `<n>g`.
586/// Otherwise, if it is an exact multiple of 1 MiB, it is formatted as `<n>m`.
587/// If neither, it is rounded to the nearest mebibyte and formatted as `<n>m`.
588fn format_memory(bytes: usize) -> String {
589    const MB: usize = 1024 * 1024;
590    const GB: usize = MB * 1024;
591
592    if bytes.is_multiple_of(GB) {
593        format!("{}g", bytes / GB)
594    } else if bytes.is_multiple_of(MB) {
595        format!("{}m", bytes / MB)
596    } else {
597        let mb = (bytes + MB / 2) / MB;
598        format!("{}m", mb)
599    }
600}
601
602fn kill_process(child: &std::process::Child) -> Result<(), JavaError> {
603    // The Child struct doesn't have a kill method on & reference,
604    // but we can use taskkill (Windows) or kill command (Unix)
605    let pid = child.id();
606    #[cfg(windows)]
607    let status = std::process::Command::new("taskkill")
608        .args(["/PID", &pid.to_string(), "/F"])
609        .status()
610        .map_err(JavaError::IoError)?;
611    #[cfg(not(windows))]
612    let status = std::process::Command::new("kill")
613        .args(["-9", &pid.to_string()])
614        .status()
615        .map_err(JavaError::IoError)?;
616
617    if status.success() {
618        Ok(())
619    } else {
620        Err(JavaError::Other(format!("Failed to kill process {}", pid)))
621    }
622}
623
624#[cfg(test)]
625mod tests {
626    use super::*;
627    use crate::JavaInfo;
628
629    #[test]
630    fn test_format_memory_exact_mb() {
631        assert_eq!(format_memory(256 * 1024 * 1024), "256m");
632    }
633
634    #[test]
635    fn test_format_memory_exact_gb() {
636        assert_eq!(format_memory(2 * 1024 * 1024 * 1024), "2g");
637    }
638
639    #[test]
640    fn test_format_memory_rounded() {
641        let result = format_memory(100 * 1024 * 1024 + 512 * 1024);
642        assert!(result.ends_with('m'));
643        let num: usize = result[..result.len() - 1].parse().unwrap();
644        assert!(num >= 100);
645    }
646
647    #[test]
648    fn test_format_memory_zero() {
649        assert_eq!(format_memory(0), "0g");
650    }
651
652    #[test]
653    fn test_format_memory_small() {
654        let result = format_memory(1);
655        assert_eq!(result, "0m");
656    }
657
658    #[test]
659    fn test_runner_missing_java() {
660        let result = JavaRunner::new().jar("test.jar").execute();
661        assert!(result.is_err());
662        let err = result.unwrap_err().to_string();
663        assert!(err.contains("Must set Java environment"));
664    }
665
666    #[test]
667    fn test_runner_missing_jar_or_main() {
668        // JavaInfo::default() has empty java_home, so java_executable() fails first
669        let info = JavaInfo::default();
670        let result = JavaRunner::new().java(info).execute();
671        assert!(result.is_err());
672    }
673
674    #[test]
675    fn test_java_redirect_default() {
676        let r = JavaRedirect::new();
677        assert!(r.output.is_none());
678        assert!(r.error.is_none());
679        assert!(r.input.is_none());
680        assert!(!r.append_output);
681        assert!(!r.append_error);
682    }
683
684    #[test]
685    fn test_java_redirect_append() {
686        let r = JavaRedirect::new()
687            .output("out.log")
688            .append_output()
689            .error("err.log")
690            .append_error();
691        assert!(r.append_output);
692        assert!(r.append_error);
693    }
694
695    #[test]
696    fn test_runner_builder_methods() {
697        let runner = JavaRunner::new()
698            .system_property("foo", "bar")
699            .add_opens("java.base", "java.lang", "ALL-UNNAMED")
700            .add_exports("java.base", "com.sun.internal", "ALL-UNNAMED");
701        assert_eq!(runner.system_properties, vec!["-Dfoo=bar"]);
702        assert_eq!(runner.add_opens, vec!["java.base/java.lang.ALL-UNNAMED"]);
703        assert_eq!(
704            runner.add_exports,
705            vec!["java.base/com.sun.internal.ALL-UNNAMED"]
706        );
707    }
708
709    #[test]
710    fn test_output_mode_debug_clone() {
711        let mode = OutputMode::Both;
712        let cloned = mode;
713        assert_eq!(mode, cloned);
714        let _ = format!("{:?}", mode);
715    }
716
717    #[test]
718    fn test_format_memory_1gb_plus_1b() {
719        let result = format_memory(1024 * 1024 * 1024 + 1);
720        assert_eq!(result, "1024m");
721    }
722
723    #[test]
724    fn test_format_memory_1_mib() {
725        assert_eq!(format_memory(1024 * 1024), "1m");
726    }
727
728    #[test]
729    fn test_format_memory_1024_mib_is_1g() {
730        assert_eq!(format_memory(1024 * 1024 * 1024), "1g");
731    }
732
733    #[test]
734    fn test_runner_env_var() {
735        let runner = JavaRunner::new().env("MY_VAR", "my_value");
736        assert_eq!(runner.env_vars, vec![("MY_VAR".into(), "my_value".into())]);
737    }
738
739    #[test]
740    fn test_runner_working_dir() {
741        let runner = JavaRunner::new().working_dir("/tmp");
742        assert_eq!(runner.working_dir, Some(PathBuf::from("/tmp")));
743    }
744
745    #[test]
746    fn test_runner_timeout() {
747        let runner = JavaRunner::new().timeout(Duration::from_secs(30));
748        assert_eq!(runner.timeout, Some(Duration::from_secs(30)));
749    }
750
751    #[test]
752    fn test_runner_classpath() {
753        let separator = if cfg!(windows) { ";" } else { ":" };
754        let runner = JavaRunner::new().classpath(&["lib/a.jar", "config"]);
755        assert_eq!(
756            runner.classpath,
757            Some(format!("lib/a.jar{separator}config"))
758        );
759    }
760
761    #[test]
762    fn test_runner_module_path() {
763        let runner = JavaRunner::new().module_path("./modules");
764        assert_eq!(runner.module_path, Some(PathBuf::from("./modules")));
765    }
766
767    #[test]
768    fn test_runner_multiple_args() {
769        let runner = JavaRunner::new().arg("--verbose").arg("--debug");
770        assert_eq!(runner.args, vec!["--verbose", "--debug"]);
771    }
772
773    #[test]
774    fn test_runner_all_builder_methods() {
775        let runner = JavaRunner::new()
776            .classpath(&["lib/*"])
777            .module_path("mods")
778            .add_opens("java.base", "java.lang", "ALL-UNNAMED")
779            .add_exports("java.base", "sun.security", "ALL-UNNAMED")
780            .system_property("key", "val")
781            .env("HOME", "/root")
782            .working_dir("/app")
783            .timeout(Duration::from_secs(10))
784            .min_memory(256 * 1024 * 1024)
785            .max_memory(1024 * 1024 * 1024);
786
787        assert!(runner.classpath.is_some());
788        assert!(runner.module_path.is_some());
789        assert_eq!(runner.add_opens.len(), 1);
790        assert_eq!(runner.add_exports.len(), 1);
791        assert_eq!(runner.system_properties.len(), 1);
792        assert_eq!(runner.env_vars.len(), 1);
793        assert!(runner.working_dir.is_some());
794        assert_eq!(runner.timeout, Some(Duration::from_secs(10)));
795        assert!(runner.min_memory.is_some());
796        assert!(runner.max_memory.is_some());
797    }
798
799    // -- Bug 5: cmd_args --
800
801    #[test]
802    fn test_runner_cmd_sets_field() {
803        let runner = JavaRunner::new().cmd(&["-version"]);
804        assert_eq!(runner.cmd_args, Some(vec!["-version".to_string()]));
805    }
806
807    #[test]
808    fn test_runner_cmd_multiple_args() {
809        let runner = JavaRunner::new().cmd(&["--list-modules", "--add-modules", "java.se"]);
810        assert_eq!(
811            runner.cmd_args,
812            Some(vec![
813                "--list-modules".to_string(),
814                "--add-modules".to_string(),
815                "java.se".to_string(),
816            ])
817        );
818    }
819
820    #[test]
821    fn test_runner_cmd_bypasses_jar_main_check() {
822        // When cmd_args is set, the builder should NOT error about missing jar/main_class.
823        // java_executable() will fail first since JavaInfo::default() has empty java_home,
824        // but that's a different error — the important thing is we don't get the
825        // "Must specify JAR file, main class, or cmd args" error.
826        let info = JavaInfo::default();
827        let result = JavaRunner::new().java(info).cmd(&["-version"]).execute();
828        let err = result.unwrap_err().to_string();
829        assert!(!err.contains("Must specify JAR file"));
830        assert!(err.contains("Not found") || err.contains("Java executable not found"));
831    }
832}