use std::io::{self, IsTerminal, Read, Write};
use std::path::Path;
pub trait Host {
fn env(&self, key: &str) -> Option<String>;
fn read_file(&self, path: &Path) -> io::Result<Vec<u8>>;
fn file_mode(&self, path: &Path) -> io::Result<Option<u32>>;
fn write_new_directory(
&mut self,
_path: &Path,
_files: &[(String, Vec<u8>)],
) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"this host does not support diagnostics bundle output",
))
}
fn write_stdout(&mut self, bytes: &[u8]) -> io::Result<()>;
fn flush_stdout(&mut self) -> io::Result<()>;
fn write_stderr(&mut self, bytes: &[u8]);
fn is_stdin_interactive(&self) -> bool;
fn is_stdout_terminal(&self) -> bool;
fn read_confirmation(&mut self) -> io::Result<Option<String>>;
fn new_operation_id(&mut self) -> String;
}
#[derive(Debug)]
pub struct ProcessHost {
stdout: io::Stdout,
stderr: io::Stderr,
counter: u64,
}
impl ProcessHost {
#[must_use]
pub fn new() -> Self {
Self {
stdout: io::stdout(),
stderr: io::stderr(),
counter: 0,
}
}
}
impl Default for ProcessHost {
fn default() -> Self {
Self::new()
}
}
impl Host for ProcessHost {
fn env(&self, key: &str) -> Option<String> {
std::env::var(key).ok().filter(|value| !value.is_empty())
}
fn read_file(&self, path: &Path) -> io::Result<Vec<u8>> {
std::fs::read(path)
}
fn file_mode(&self, path: &Path) -> io::Result<Option<u32>> {
let metadata = std::fs::metadata(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
Ok(Some(metadata.permissions().mode()))
}
#[cfg(not(unix))]
{
let _ = metadata;
Ok(None)
}
}
fn write_new_directory(&mut self, path: &Path, files: &[(String, Vec<u8>)]) -> io::Result<()> {
if path.try_exists()? {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"the bundle target already exists",
));
}
let mut temporary = path.as_os_str().to_owned();
temporary.push(format!(".tmp-{}", std::process::id()));
let temporary = std::path::PathBuf::from(temporary);
if temporary.try_exists()? {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"the bundle temporary target already exists",
));
}
std::fs::create_dir(&temporary)?;
let written = files.iter().try_for_each(|(name, bytes)| {
if name.is_empty()
|| name.contains('/')
|| name.contains('\\')
|| matches!(name.as_str(), "." | "..")
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"the bundle file name is not accepted",
));
}
let target = temporary.join(name);
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(target)?;
file.write_all(bytes)?;
file.sync_all()
});
if let Err(error) = written {
let _ = std::fs::remove_dir_all(&temporary);
return Err(error);
}
if let Err(error) = std::fs::create_dir(path) {
let _ = std::fs::remove_dir_all(&temporary);
return Err(error);
}
let installed = files
.iter()
.try_for_each(|(name, _)| std::fs::rename(temporary.join(name), path.join(name)));
if let Err(error) = installed {
let _ = std::fs::remove_dir_all(path);
let _ = std::fs::remove_dir_all(&temporary);
return Err(error);
}
std::fs::remove_dir(&temporary)?;
Ok(())
}
fn write_stdout(&mut self, bytes: &[u8]) -> io::Result<()> {
self.stdout.write_all(bytes)
}
fn flush_stdout(&mut self) -> io::Result<()> {
self.stdout.flush()
}
fn write_stderr(&mut self, bytes: &[u8]) {
let _ = self.stderr.write_all(bytes);
let _ = self.stderr.flush();
}
fn is_stdin_interactive(&self) -> bool {
io::stdin().is_terminal()
}
fn is_stdout_terminal(&self) -> bool {
self.stdout.is_terminal()
}
fn read_confirmation(&mut self) -> io::Result<Option<String>> {
let mut buffer = String::new();
let mut handle = io::stdin().lock();
let mut byte = [0_u8; 1];
loop {
match handle.read(&mut byte)? {
0 => break,
_ if byte[0] == b'\n' => break,
_ => buffer.push(char::from(byte[0])),
}
if buffer.len() > MAX_CONFIRMATION_BYTES {
break;
}
}
if buffer.is_empty() {
return Ok(None);
}
Ok(Some(buffer))
}
fn new_operation_id(&mut self) -> String {
self.counter += 1;
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |value| value.as_nanos());
format!("cli-{}-{nanos}-{}", std::process::id(), self.counter)
}
}
const MAX_CONFIRMATION_BYTES: usize = 64;
#[cfg(test)]
pub(crate) mod testing {
use std::collections::BTreeMap;
use std::io;
use std::path::{Path, PathBuf};
use super::Host;
#[derive(Debug, Default)]
pub(crate) struct TestHost {
pub(crate) env: BTreeMap<String, String>,
pub(crate) files: BTreeMap<PathBuf, Vec<u8>>,
pub(crate) modes: BTreeMap<PathBuf, u32>,
pub(crate) directories: BTreeMap<PathBuf, Vec<String>>,
pub(crate) stdout: Vec<u8>,
pub(crate) stderr: Vec<u8>,
pub(crate) stdin_interactive: bool,
pub(crate) stdout_terminal: bool,
pub(crate) confirmation: Option<String>,
pub(crate) stdout_capacity: Option<usize>,
pub(crate) operation_ids: u64,
}
impl TestHost {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn with_env(mut self, key: &str, value: &str) -> Self {
self.env.insert(key.to_owned(), value.to_owned());
self
}
pub(crate) fn with_file(mut self, path: &str, contents: &str) -> Self {
self.files
.insert(PathBuf::from(path), contents.as_bytes().to_vec());
self.modes.insert(PathBuf::from(path), 0o600);
self
}
pub(crate) fn with_mode(mut self, path: &str, mode: u32) -> Self {
self.modes.insert(PathBuf::from(path), mode);
self
}
pub(crate) fn with_stdout_capacity(mut self, bytes: usize) -> Self {
self.stdout_capacity = Some(bytes);
self
}
pub(crate) fn stdout_text(&self) -> String {
String::from_utf8_lossy(&self.stdout).into_owned()
}
}
impl Host for TestHost {
fn env(&self, key: &str) -> Option<String> {
self.env.get(key).cloned().filter(|value| !value.is_empty())
}
fn read_file(&self, path: &Path) -> io::Result<Vec<u8>> {
self.files.get(path).cloned().ok_or_else(|| {
io::Error::new(io::ErrorKind::NotFound, "the test host has no such file")
})
}
fn file_mode(&self, path: &Path) -> io::Result<Option<u32>> {
if !self.files.contains_key(path) {
return Err(io::Error::new(
io::ErrorKind::NotFound,
"the test host has no such file",
));
}
Ok(self.modes.get(path).copied())
}
fn write_new_directory(
&mut self,
path: &Path,
files: &[(String, Vec<u8>)],
) -> io::Result<()> {
if self.directories.contains_key(path) {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
"target exists",
));
}
self.directories.insert(
path.to_path_buf(),
files.iter().map(|(name, _)| name.clone()).collect(),
);
for (name, bytes) in files {
self.files.insert(path.join(name), bytes.clone());
}
Ok(())
}
fn write_stdout(&mut self, bytes: &[u8]) -> io::Result<()> {
if let Some(capacity) = self.stdout_capacity
&& self.stdout.len() + bytes.len() > capacity
{
return Err(io::Error::new(io::ErrorKind::BrokenPipe, "closed pipe"));
}
self.stdout.extend_from_slice(bytes);
Ok(())
}
fn flush_stdout(&mut self) -> io::Result<()> {
Ok(())
}
fn write_stderr(&mut self, bytes: &[u8]) {
self.stderr.extend_from_slice(bytes);
}
fn is_stdin_interactive(&self) -> bool {
self.stdin_interactive
}
fn is_stdout_terminal(&self) -> bool {
self.stdout_terminal
}
fn read_confirmation(&mut self) -> io::Result<Option<String>> {
Ok(self.confirmation.take())
}
fn new_operation_id(&mut self) -> String {
self.operation_ids += 1;
format!("test-operation-{}", self.operation_ids)
}
}
}