wekan-cli 0.10.0

Agent-first, human-friendly command-line client for Wekan
Documentation
use std::path::PathBuf;

use thiserror::Error;
use tokio::io::AsyncRead;

pub(crate) enum PayloadSource {
    Text(String),
    File(PathBuf),
    Stdin,
}

pub(crate) enum PayloadBody {
    Text(String),
    Reader(Box<dyn AsyncRead + Send + Unpin>),
}

pub(crate) struct AcquiredPayload {
    pub(crate) body: PayloadBody,
    pub(crate) content_length: Option<u64>,
}

#[derive(Debug, Error)]
pub(crate) enum PayloadError {
    #[error("could not open raw API body file `{path}`: {source}")]
    OpenFile {
        path: String,
        #[source]
        source: std::io::Error,
    },
    #[error("could not inspect raw API body file `{path}`: {source}")]
    InspectFile {
        path: String,
        #[source]
        source: std::io::Error,
    },
}

impl PayloadSource {
    pub(crate) async fn acquire_bounded(
        self,
        limit: usize,
    ) -> Result<Vec<u8>, super::BinaryInputError> {
        match self {
            Self::Text(body) if body.len() > limit => Err(super::BinaryInputError::TooLarge),
            Self::Text(body) => Ok(body.into_bytes()),
            Self::File(path) => super::binary::acquire_regular_file(&path, limit).await,
            Self::Stdin => super::binary::read_bounded(tokio::io::stdin(), limit).await,
        }
    }

    pub(crate) async fn acquire(self) -> Result<AcquiredPayload, PayloadError> {
        match self {
            Self::Text(body) => Ok(AcquiredPayload {
                body: PayloadBody::Text(body),
                content_length: None,
            }),
            Self::File(path) => {
                let display_path = super::escape_terminal_text(&path.display().to_string());
                let file = tokio::fs::File::open(&path).await.map_err(|source| {
                    PayloadError::OpenFile {
                        path: display_path.clone(),
                        source,
                    }
                })?;
                let content_length = file
                    .metadata()
                    .await
                    .map_err(|source| PayloadError::InspectFile {
                        path: display_path,
                        source,
                    })?
                    .len();
                Ok(AcquiredPayload {
                    body: PayloadBody::Reader(Box::new(file)),
                    content_length: Some(content_length),
                })
            }
            Self::Stdin => Ok(AcquiredPayload {
                body: PayloadBody::Reader(Box::new(tokio::io::stdin())),
                content_length: None,
            }),
        }
    }
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use super::PayloadSource;

    #[tokio::test]
    async fn bounded_payloads_preserve_bytes_and_reject_oversized_sources() {
        let data = "[{}]";
        let directory = tempfile::tempdir().unwrap();
        let path = directory.path().join("cards.json");
        std::fs::write(&path, data).unwrap();
        for source in [
            PayloadSource::Text(data.into()),
            PayloadSource::File(path.clone()),
        ] {
            assert_eq!(
                source.acquire_bounded(data.len()).await.unwrap(),
                data.as_bytes()
            );
        }
        for source in [PayloadSource::Text(data.into()), PayloadSource::File(path)] {
            assert!(matches!(
                source.acquire_bounded(data.len() - 1).await,
                Err(super::super::BinaryInputError::TooLarge)
            ));
        }
        assert!(matches!(
            PayloadSource::File(directory.path().into())
                .acquire_bounded(8)
                .await,
            Err(super::super::BinaryInputError::NotRegularFile)
        ));
    }

    #[tokio::test]
    async fn body_file_errors_escape_terminal_control_characters() {
        let path = PathBuf::from(format!(
            "missing-body-{}\n\u{1b}]52;c;clipboard\u{7}",
            std::process::id()
        ));

        let message = match PayloadSource::File(path).acquire().await {
            Ok(_) => panic!("the terminal-safety fixture must not name an existing file"),
            Err(error) => error.to_string(),
        };

        assert!(!message.contains('\n'));
        assert!(!message.contains('\u{1b}'));
        assert!(!message.contains('\u{7}'));
        assert!(message.contains(r"\n"));
        assert!(message.contains(r"\u{1b}"));
        assert!(message.contains(r"\u{7}"));
    }
}