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 and directory resolution.
105///
106/// - If `file` is `-`, buffer stdin to a tempfile.
107/// - If `file` is a directory, resolve to the model file inside it
108///   (e.g., `model.safetensors`, `*.gguf`, `*.apr`).
109/// - Otherwise pass the path through.
110pub fn with_stdin_support<F>(file: &Path, f: F) -> Result<(), CliError>
111where
112    F: FnOnce(&Path) -> Result<(), CliError>,
113{
114    let file_str = file.to_string_lossy();
115    if is_stdin(&file_str) {
116        let tmp = read_stdin_to_tempfile()?;
117        f(tmp.path())
118    } else {
119        let resolved = crate::error::resolve_model_path(file)?;
120        f(&resolved)
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn test_is_stdin() {
130        assert!(is_stdin("-"));
131        assert!(is_stdin("/dev/stdin"));
132        assert!(is_stdin("/dev/fd/0"));
133        assert!(is_stdin("/proc/self/fd/0"));
134        assert!(!is_stdin("model.gguf"));
135        assert!(!is_stdin(""));
136        assert!(!is_stdin("--"));
137        assert!(!is_stdin("/dev/fd/1"));
138    }
139
140    #[test]
141    fn test_is_stdout() {
142        assert!(is_stdout("-"));
143        assert!(is_stdout("/dev/stdout"));
144        assert!(is_stdout("/dev/fd/1"));
145        assert!(is_stdout("/proc/self/fd/1"));
146        assert!(!is_stdout("output.apr"));
147        assert!(!is_stdout("/dev/stdin"));
148        assert!(!is_stdout("/dev/fd/0"));
149    }
150
151    #[test]
152    fn test_resolve_input_file_path() {
153        let (path, tmp) = resolve_input("/tmp/nonexistent.gguf").expect("should resolve");
154        assert_eq!(path, PathBuf::from("/tmp/nonexistent.gguf"));
155        assert!(tmp.is_none());
156    }
157
158    #[test]
159    fn test_temp_model_file_cleanup() {
160        let tmp_path = std::env::temp_dir().join("apr-test-cleanup.bin");
161        fs::write(&tmp_path, b"test data").expect("write");
162        assert!(tmp_path.exists());
163
164        {
165            let _tmp = TempModelFile {
166                path: tmp_path.clone(),
167            };
168            assert!(tmp_path.exists());
169        }
170        // Dropped — file should be cleaned up
171        assert!(!tmp_path.exists());
172    }
173}