nextcloud-client-api 0.1.0

implementation of the socket API for the NextCloud client
Documentation
use core::{result::Result as StdResult, time::Duration};
use std::{
    env::{var, VarError},
    io::{prelude::*, BufReader, Error as IoError, ErrorKind},
    os::unix::net::UnixStream,
    path::{Path, PathBuf},
    thread::sleep,
    time::Instant,
};

use log::trace;
use thiserror::Error as ThisError;

use crate::message::{Error as MessageError, Message};

#[derive(Debug, ThisError)]
#[non_exhaustive]
pub enum Error {
    #[error("runtime directory $XDG_RUNTIME_DIR not set correctly: {0}")]
    NoRuntime(#[from] VarError),
    #[error("failed to connect to {0}, {1}")]
    ConnectionFailed(String, IoError),
    #[error("failed to write message: {0}")]
    WriteFailed(IoError),
    #[error("failed to read message: {0}")]
    ReadFailed(IoError),
    #[error("operation failed: {0}")]
    Other(#[from] IoError),
    #[error("parsing failed: {0}")]
    ParsingFailed(String),
    #[error("parsing failed: {0}")]
    ParsingMessageFailed(#[from] MessageError),
}

pub(crate) type Result<T> = StdResult<T, Error>;

pub(crate) struct NextCloudClientSocket {
    stream: UnixStream,
    reader: BufReader<UnixStream>,
}

impl NextCloudClientSocket {
    #[expect(
        clippy::single_call_fn,
        reason = "builders should never be inlined to external code"
    )]
    pub(crate) fn new() -> Result<Self> {
        let socket_file = PathBuf::from(var("XDG_RUNTIME_DIR")?)
            .join("Nextcloud")
            .join("socket");

        NextCloudClientSocket::connect(&socket_file)
    }

    #[cfg(test)]
    #[cfg_attr(
        test,
        expect(clippy::single_call_fn, reason = "builder called for test mock")
    )]
    pub(crate) fn new_test(socket_file: &Path) -> Result<Self> {
        NextCloudClientSocket::connect(socket_file)
    }

    #[cfg_attr(
        not(test),
        expect(clippy::single_call_fn, reason = "abstraction for test mock")
    )]
    fn connect(socket_file: &Path) -> Result<Self> {
        let stream = match UnixStream::connect(socket_file) {
            Ok(stream) => stream,
            Err(error) => {
                return Err(Error::ConnectionFailed(
                    socket_file.to_string_lossy().to_string(),
                    error,
                ))
            }
        };
        stream.set_read_timeout(Some(Duration::from_millis(100)))?;

        let reader = BufReader::new(stream.try_clone()?);

        Ok(Self { stream, reader })
    }

    pub(crate) fn write_message(&mut self, message: &Message) -> Result<()> {
        trace!("write message: {message}");
        self.stream
            .write_all(message.to_string().as_bytes())
            .map_err(Error::WriteFailed)?;
        self.stream
            .write_all("\n".as_bytes())
            .map_err(Error::WriteFailed)
    }

    /// `read_message` receives a message (one line) from the socket.
    /// It can be configured to block until a new line is received.
    /// If there is no data available an empty string is returned.
    pub(crate) fn read_message(&mut self) -> Result<Option<Message>> {
        let mut res = String::new();
        match self.reader.read_line(&mut res) {
            Ok(_) => Ok(Some(res.trim().try_into()?)),
            Err(error) if error.kind() == ErrorKind::TimedOut => Ok(None),
            Err(error) if error.kind() == ErrorKind::WouldBlock => Ok(None),
            Err(error) => Err(Error::ReadFailed(error)),
        }
    }

    pub(crate) fn read_until_settled(&mut self, timeout: Duration) -> Result<Vec<Message>> {
        let mut result = Vec::new();
        let delay = timeout.div_f64(2.0);
        let mut last_activity = Instant::now();

        loop {
            if let Some(message) = self.read_message()? {
                trace!("read line: {message}");
                result.push(message);
                last_activity = Instant::now();

                continue;
            }

            if last_activity.elapsed() > timeout {
                return Ok(result);
            }

            sleep(delay);
        }
    }
}