Skip to main content

apr_cli/
pipe.rs

1//! Stdin/stdout pipe support (PMAT-261)
2//!
3//! Enables POSIX-standard `-` convention for stdin/stdout in CLI commands.
4//! Model data from stdin is buffered to a temporary file so that mmap-based
5//! operations (GGUF, SafeTensors) work transparently.
6
7use crate::error::CliError;
8use std::fs;
9use std::io::{self, Read, Write};
10use std::path::{Path, PathBuf};
11
12/// Check if a path string indicates stdin.
13///
14/// Recognizes POSIX `-` convention and Linux device paths:
15/// `-`, `/dev/stdin`, `/dev/fd/0`, `/proc/self/fd/0`
16#[must_use]
17pub fn is_stdin(path: &str) -> bool {
18    matches!(path, "-" | "/dev/stdin" | "/dev/fd/0" | "/proc/self/fd/0")
19}
20
21/// Check if a path string indicates stdout.
22///
23/// Recognizes POSIX `-` convention and Linux device paths:
24/// `-`, `/dev/stdout`, `/dev/fd/1`, `/proc/self/fd/1`
25#[must_use]
26pub fn is_stdout(path: &str) -> bool {
27    matches!(path, "-" | "/dev/stdout" | "/dev/fd/1" | "/proc/self/fd/1")
28}
29
30/// Temporary file that holds stdin data for mmap-based operations.
31/// Automatically deleted when dropped.
32pub struct TempModelFile {
33    path: PathBuf,
34}
35
36impl TempModelFile {
37    /// Get the path to the temporary file.
38    #[must_use]
39    pub fn path(&self) -> &Path {
40        &self.path
41    }
42}
43
44impl Drop for TempModelFile {
45    fn drop(&mut self) {
46        let _ = fs::remove_file(&self.path);
47    }
48}
49
50/// Read all of stdin into a temporary file.
51///
52/// Returns a `TempModelFile` whose path can be passed to any function
53/// expecting a file path (mmap, fs::read, etc.).
54pub fn read_stdin_to_tempfile() -> Result<TempModelFile, CliError> {
55    let mut buf = Vec::new();
56    io::stdin()
57        .lock()
58        .read_to_end(&mut buf)
59        .map_err(|e| CliError::ValidationFailed(format!("Failed to read stdin: {e}")))?;
60
61    if buf.is_empty() {
62        return Err(CliError::ValidationFailed(
63            "No data received on stdin. Pipe a model file: cat model.gguf | apr validate -"
64                .to_string(),
65        ));
66    }
67
68    let tmp_dir = std::env::temp_dir();
69    let tmp_path = tmp_dir.join(format!("apr-stdin-{}.bin", std::process::id()));
70
71    fs::write(&tmp_path, &buf)
72        .map_err(|e| CliError::ValidationFailed(format!("Failed to write temp file: {e}")))?;
73
74    Ok(TempModelFile { path: tmp_path })
75}
76
77/// Resolve an input path: if "-", read stdin to tempfile; otherwise return the path as-is.
78///
79/// Returns `(resolved_path, Option<TempModelFile>)`. The caller must hold the
80/// `TempModelFile` in scope to prevent premature cleanup.
81pub fn resolve_input(path_str: &str) -> Result<(PathBuf, Option<TempModelFile>), CliError> {
82    if is_stdin(path_str) {
83        let tmp = read_stdin_to_tempfile()?;
84        let p = tmp.path().to_path_buf();
85        Ok((p, Some(tmp)))
86    } else {
87        Ok((PathBuf::from(path_str), None))
88    }
89}
90
91/// Write bytes to stdout (for `-` output paths).
92pub fn write_stdout(data: &[u8]) -> Result<(), CliError> {
93    io::stdout()
94        .lock()
95        .write_all(data)
96        .map_err(|e| CliError::ValidationFailed(format!("Failed to write to stdout: {e}")))?;
97    io::stdout()
98        .lock()
99        .flush()
100        .map_err(|e| CliError::ValidationFailed(format!("Failed to flush stdout: {e}")))?;
101    Ok(())
102}
103
104/// Run a command with stdin pipe support: if `file` is `-`, buffer stdin
105/// to a tempfile; otherwise pass the path through.
106pub fn with_stdin_support<F>(file: &Path, f: F) -> Result<(), CliError>
107where
108    F: FnOnce(&Path) -> Result<(), CliError>,
109{
110    let file_str = file.to_string_lossy();
111    if is_stdin(&file_str) {
112        let tmp = read_stdin_to_tempfile()?;
113        f(tmp.path())
114    } else {
115        f(file)
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn test_is_stdin() {
125        assert!(is_stdin("-"));
126        assert!(is_stdin("/dev/stdin"));
127        assert!(is_stdin("/dev/fd/0"));
128        assert!(is_stdin("/proc/self/fd/0"));
129        assert!(!is_stdin("model.gguf"));
130        assert!(!is_stdin(""));
131        assert!(!is_stdin("--"));
132        assert!(!is_stdin("/dev/fd/1"));
133    }
134
135    #[test]
136    fn test_is_stdout() {
137        assert!(is_stdout("-"));
138        assert!(is_stdout("/dev/stdout"));
139        assert!(is_stdout("/dev/fd/1"));
140        assert!(is_stdout("/proc/self/fd/1"));
141        assert!(!is_stdout("output.apr"));
142        assert!(!is_stdout("/dev/stdin"));
143        assert!(!is_stdout("/dev/fd/0"));
144    }
145
146    #[test]
147    fn test_resolve_input_file_path() {
148        let (path, tmp) = resolve_input("/tmp/nonexistent.gguf").expect("should resolve");
149        assert_eq!(path, PathBuf::from("/tmp/nonexistent.gguf"));
150        assert!(tmp.is_none());
151    }
152
153    #[test]
154    fn test_temp_model_file_cleanup() {
155        let tmp_path = std::env::temp_dir().join("apr-test-cleanup.bin");
156        fs::write(&tmp_path, b"test data").expect("write");
157        assert!(tmp_path.exists());
158
159        {
160            let _tmp = TempModelFile {
161                path: tmp_path.clone(),
162            };
163            assert!(tmp_path.exists());
164        }
165        // Dropped — file should be cleaned up
166        assert!(!tmp_path.exists());
167    }
168}