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    contract_pre_pipe_stdin_support!();
115    let file_str = file.to_string_lossy();
116    if is_stdin(&file_str) {
117        let tmp = read_stdin_to_tempfile()?;
118        f(tmp.path())
119    } else {
120        let resolved = crate::error::resolve_model_path(file)?;
121        f(&resolved)
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn test_is_stdin() {
131        assert!(is_stdin("-"));
132        assert!(is_stdin("/dev/stdin"));
133        assert!(is_stdin("/dev/fd/0"));
134        assert!(is_stdin("/proc/self/fd/0"));
135        assert!(!is_stdin("model.gguf"));
136        assert!(!is_stdin(""));
137        assert!(!is_stdin("--"));
138        assert!(!is_stdin("/dev/fd/1"));
139    }
140
141    #[test]
142    fn test_is_stdout() {
143        assert!(is_stdout("-"));
144        assert!(is_stdout("/dev/stdout"));
145        assert!(is_stdout("/dev/fd/1"));
146        assert!(is_stdout("/proc/self/fd/1"));
147        assert!(!is_stdout("output.apr"));
148        assert!(!is_stdout("/dev/stdin"));
149        assert!(!is_stdout("/dev/fd/0"));
150    }
151
152    #[test]
153    fn test_resolve_input_file_path() {
154        let (path, tmp) = resolve_input("/tmp/nonexistent.gguf").expect("should resolve");
155        assert_eq!(path, PathBuf::from("/tmp/nonexistent.gguf"));
156        assert!(tmp.is_none());
157    }
158
159    #[test]
160    fn test_temp_model_file_cleanup() {
161        let tmp_path = std::env::temp_dir().join("apr-test-cleanup.bin");
162        fs::write(&tmp_path, b"test data").expect("write");
163        assert!(tmp_path.exists());
164
165        {
166            let _tmp = TempModelFile {
167                path: tmp_path.clone(),
168            };
169            assert!(tmp_path.exists());
170        }
171        // Dropped — file should be cleaned up
172        assert!(!tmp_path.exists());
173    }
174}