rust-todo-cli 0.2.3

A CLI tool for managing to-do lists
use crate::error::{AppError, io_err};
use crate::task::Task;
use std::fs::File;
use std::io::{ErrorKind, Read, Seek, SeekFrom, Write};
use std::path::PathBuf;

pub enum FilePath {
    Custom(String),
    Default,
}

impl FilePath {
    pub fn new(path: Option<String>) -> Self {
        match path {
            Some(p) => FilePath::Custom(p),
            None => FilePath::Default,
        }
    }

    pub fn path(&self) -> String {
        match self {
            FilePath::Custom(p) => p.clone(),
            FilePath::Default => {
                let mut dir = dirs::data_dir().unwrap_or_else(|| PathBuf::from("."));
                dir.push("rstodo");
                dir.push("task.json");
                dir.to_string_lossy().to_string()
            }
        }
    }
}

pub fn update_tasks(
    path: &FilePath,
    f: impl FnOnce(&mut Vec<Task>) -> Result<(), AppError>,
) -> Result<(), AppError> {
    if let Some(parent) = std::path::Path::new(&path.path()).parent() {
        std::fs::create_dir_all(parent).map_err(|err| io_err("create dir", path.path(), err))?;
    }
    let file = File::options()
        .read(true)
        .write(true)
        .create(true)
        .truncate(false)
        .open(path.path())
        .map_err(|err| io_err("create a read-write handle", path.path(), err))?;
    file.lock()
        .map_err(|err| io_err("lock", path.path(), err))?;
    let mut tasks = load_from(&file, &path.path())?;
    f(&mut tasks)?;
    save_to(&file, &tasks, &path.path())?;
    file.unlock()
        .map_err(|err| io_err("unlock", path.path(), err))?;
    Ok(())
}

fn load_from(file: &File, path: &str) -> Result<Vec<Task>, AppError> {
    let mut f = file
        .try_clone()
        .map_err(|err| io_err("clone file for reading", path.to_string(), err))?;
    f.seek(SeekFrom::Start(0))
        .map_err(|err| io_err("seek to the beginning", path.to_string(), err))?;
    let mut text = String::new();
    f.read_to_string(&mut text)
        .map_err(|err| io_err("read tasks", path.to_string(), err))?;
    if text.trim().is_empty() {
        return Ok(Vec::new());
    }

    serde_json::from_str(&text).map_err(|serde_json_err| AppError::Corrupted {
        path: path.to_string(),
        source: serde_json_err,
    })
}

fn save_to(file: &File, tasks: &[Task], path: &str) -> Result<(), AppError> {
    let mut f = file
        .try_clone()
        .map_err(|err| io_err("clone file for saving", path.to_string(), err))?;
    let data =
        serde_json::to_string_pretty(tasks).map_err(|serde_json_err| AppError::Corrupted {
            path: path.to_string(),
            source: serde_json_err,
        })?;
    f.seek(SeekFrom::Start(0))
        .map_err(|err| io_err("seek to the beginning", path.to_string(), err))?;
    f.set_len(0)
        .map_err(|err| io_err("reset text for saving", path.to_string(), err))?;
    f.write_all(data.as_bytes())
        .map_err(|err| io_err("write to file", path.to_string(), err))?;
    f.flush()
        .map_err(|err| io_err("flush", path.to_string(), err))?;
    Ok(())
}

pub fn load_tasks(path: &FilePath) -> Result<Vec<Task>, AppError> {
    let file = match File::options()
        .read(true)
        .write(false)
        .create(false)
        .truncate(false)
        .open(path.path())
    {
        Ok(f) => f,
        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
        Err(err) => return Err(io_err("create a read-only handle", path.path(), err)),
    };
    file.lock_shared()
        .map_err(|err| io_err("try to get a shared lock", path.path(), err))?;
    load_from(&file, &path.path())
}

#[cfg(test)]
mod tests {
    use chrono::{DateTime, Utc};

    use crate::task::Priority;

    use super::*;
    use std::{fs, os::unix::fs::PermissionsExt, process};

    fn temp_path(name: &str) -> String {
        std::env::temp_dir()
            .join(format!("{}_rstodo_test_{}.json", process::id(), name))
            .to_string_lossy()
            .to_string()
    }

    fn write_file(path: &str, contents: &str) {
        fs::write(path, contents).unwrap();
    }

    fn set_test_task() -> Task {
        let deadline: DateTime<Utc> = "2000-1-1T12:00:00+00:00".parse().unwrap();
        Task::new(
            1,
            "test_task1".to_string(),
            Some("desc".to_string()),
            Some(deadline),
            Priority::High,
        )
    }

    #[test]
    fn file_path() {
        assert_eq!(
            FilePath::new(Some("test1.json".to_string())).path(),
            "test1.json"
        );
        let path_default = FilePath::new(None).path();
        assert!(path_default.contains("task.json"));
        assert!(path_default.contains("rstodo"));
    }

    #[test]
    fn test_load_tasks() {
        let file = temp_path("load");
        let path = FilePath::new(Some(file.clone()));
        let _ = fs::remove_file(&file);

        assert_eq!(load_tasks(&path).unwrap(), Vec::new());

        write_file(&file, "");
        assert_eq!(load_tasks(&path).unwrap(), Vec::new());

        write_file(
            &file,
            &serde_json::to_string_pretty(&vec![set_test_task()]).unwrap(),
        );
        let load = load_tasks(&path).unwrap();
        assert_eq!(load, vec![set_test_task()]);

        write_file(&file, "{Illegal data");
        match load_tasks(&path) {
            Err(AppError::Corrupted { path, source: _ }) => {
                assert_eq!(path, file)
            }
            _ => unreachable!(),
        }

        let _ = fs::remove_file(&file);
    }

    #[test]
    fn test_update() {
        let file = temp_path("update");
        let path = FilePath::new(Some(file.clone()));
        let _ = fs::remove_file(&file);
        for _ in 0..5 {
            update_tasks(&path, |t| {
                t.push(set_test_task());
                Ok(())
            })
            .unwrap();
        }
        let load = load_tasks(&path).unwrap();
        assert_eq!(load.len(), 5);

        for task in load.iter().take(5) {
            assert_eq!(task.id(), 1);
            assert_eq!(task._content(), "test_task1".to_string());
            assert_eq!(task.description(), Some("desc".to_string()));
            assert_eq!(
                task.deadline(),
                Some("2000-1-1T12:00:00+00:00".parse::<DateTime<Utc>>().unwrap())
            );
            assert_eq!(task.priority(), Priority::High);
        }

        update_tasks(&path, |t| {
            t.clear();
            Ok(())
        })
        .unwrap();
        let load = load_tasks(&path).unwrap();
        assert_eq!(load, Vec::new());
        let _ = fs::remove_file(&file);
    }

    #[test]
    fn test_update_err() {
        let file = temp_path("update_err");
        let path = FilePath::new(Some(file.clone()));
        let _ = fs::remove_file(&file);

        update_tasks(&path, |t| {
            t.push(set_test_task());
            Ok(())
        })
        .unwrap();

        let before = fs::read_to_string(&file).unwrap();
        let err = update_tasks(&path, |_| Err(AppError::NothingToChange)).unwrap_err();
        match err {
            AppError::NothingToChange => {}
            _ => unreachable!(),
        }
        assert_eq!(fs::read_to_string(&file).unwrap(), before);
        let _ = fs::remove_file(&file);
    }

    #[test]
    fn test_load_tasks_err() {
        let file = temp_path("load_err_not_utf8");
        let path = FilePath::new(Some(file.clone()));
        let _ = fs::remove_file(&file);

        fs::write(&file, [0xFF, 0xFF]).unwrap();
        let err = load_tasks(&path).unwrap_err();
        let _ = fs::remove_file(&file);
        match err {
            AppError::Io {
                operation,
                path: _,
                source,
            } => {
                assert_eq!(operation, "read tasks");
                assert!(!source.to_string().is_empty());
            }
            _ => unreachable!(),
        }
    }

    #[cfg(unix)]
    #[test]
    fn test_permission_denied() {
        let file = temp_path("permission_denied");
        let path = FilePath::new(Some(file.clone()));

        fs::write(&file, "test_content").unwrap();
        assert_eq!(
            fs::read_to_string(&file).unwrap(),
            "test_content".to_string()
        );

        let no_permission = 0o000;
        let std_permission = 0o644;
        fs::set_permissions(&file, fs::Permissions::from_mode(no_permission)).unwrap();
        let err = load_tasks(&path).unwrap_err();
        match err {
            AppError::Io {
                operation,
                path: _,
                source,
            } => {
                assert_eq!(operation, "create a read-only handle");
                assert!(!source.to_string().is_empty());
            }
            _ => unreachable!(),
        }
        fs::set_permissions(&file, fs::Permissions::from_mode(std_permission)).unwrap();
        let _ = fs::remove_file(&file);
    }

    #[test]
    fn test_update_create_dir() {
        let dir = temp_path("create_file");
        let file = format!("{}/sub/subsub/task.json", dir);
        let path = FilePath::new(Some(file.clone()));
        let _ = fs::remove_dir_all(&dir);

        update_tasks(&path, |t| {
            t.push(set_test_task());
            Ok(())
        })
        .unwrap();
        let load = load_tasks(&path).unwrap();
        let _ = fs::remove_file(&file);
        assert_eq!(load.len(), 1);
        assert_eq!(load[0]._content(), "test_task1".to_string());
    }

    #[test]
    fn test_update_create_dir_err() {
        let path = FilePath::new(Some("".to_string()));
        let err = update_tasks(&path, |_| Ok(())).unwrap_err();
        match err {
            AppError::Io {
                operation,
                path: _,
                source,
            } => {
                assert_eq!(operation, "create a read-write handle");
                assert!(!source.to_string().is_empty());
            }
            _ => unreachable!(),
        }
    }
}