commit f60d3b50fc0f07a41383bab345dd6b43ce0aa048 Author: wieerwill Date: Sun Oct 8 21:43:29 2023 +0200 working minimal cli diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ada8be9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb \ No newline at end of file diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..887b6ef --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "rusty-journal" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +anyhow = "1.0" +home = "0.5" +serde_json = "1.0" +structopt = "0.3" + +[dependencies.chrono] +features = ["serde"] # We're also going to need the serde feature for the chrono crate, so we can serialize the DateTime field. +version = "0.4" + +[dependencies.serde] # Add serde in its own section. +features = ["derive"] # We'll need the derive feature. +version = "1.0" diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a11c3f --- /dev/null +++ b/README.md @@ -0,0 +1,21 @@ +# Rusty Journal +The application is a command line task tracker. It records our tasks in a text file, displays them as a list in the terminal, and lets us mark them as done. + +This programme follows the Microsoft Learning Path for [Rust](https://learn.microsoft.com/de-de/training/modules/rust-create-command-line-program/) + +1. install rust toolchain with [rustup](https://rustup.rs) +2. `cargo run` to start the app + +Try it out: +```sh +cargo run -- -j test-journal.json add "buy milk" +cargo run -- -j test-journal.json add "take the dog for a walk" +cargo run -- -j test-journal.json add "water the plants" +cargo run -- -j test-journal.json list +cargo run -- -j test-journal.json done 2 +cargo run -- -j test-journal.json list +``` + +To compile the programme, switch to the terminal and execute the command `cargo run --release`. + +The compiled binary (the executable) is located in the `target/release/` directory and is named after the project name. If you are using macOS or Linux, it will be called `rusty-journal`. If you use Windows, it is called `rusty-journal.exe`. \ No newline at end of file diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..35ffa0f --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,33 @@ +use std::path::PathBuf; +use structopt::StructOpt; + +#[derive(Debug, StructOpt)] +pub enum Action { + /// Write tasks to the journal file. + Add { + /// The task description text. + #[structopt()] + text: String, + }, + /// Remove an entry from the journal file by position. + Done { + #[structopt()] + position: usize, + }, + /// List all tasks in the journal file. + List, +} + +#[derive(Debug, StructOpt)] +#[structopt( + name = "Rusty Journal", + about = "A command line to-do app written in Rust" +)] +pub struct CommandLineArgs { + #[structopt(subcommand)] + pub action: Action, + + /// Use a different journal file. + #[structopt(parse(from_os_str), short, long)] + pub journal_file: Option, +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..9dd6382 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,33 @@ +use anyhow::anyhow; +use std::path::PathBuf; +use structopt::StructOpt; +mod cli; +mod tasks; + +use cli::{Action::*, CommandLineArgs}; +use tasks::Task; + +fn find_default_journal_file() -> Option { + home::home_dir().map(|mut path| { + path.push(".rust-journal.json"); + path + }) +} + +fn main() -> anyhow::Result<()> { + let CommandLineArgs { + action, + journal_file, + } = CommandLineArgs::from_args(); + + let journal_file = journal_file + .or_else(find_default_journal_file) + .ok_or(anyhow!("Failed to find journal file."))?; + + match action { + Add { text } => tasks::add_task(journal_file, Task::new(text)), + List => tasks::list_tasks(journal_file), + Done { position } => tasks::complete_task(journal_file, position), + }?; + Ok(()) +} \ No newline at end of file diff --git a/src/tasks.rs b/src/tasks.rs new file mode 100644 index 0000000..e92e3f9 --- /dev/null +++ b/src/tasks.rs @@ -0,0 +1,94 @@ +use chrono::{serde::ts_seconds, DateTime, Local, Utc}; +use serde::Deserialize; +use serde::Serialize; +use std::fmt; +use std::fs::{File, OpenOptions}; +use std::io::{Error, ErrorKind, Result, Seek, SeekFrom}; +use std::path::PathBuf; + +#[derive(Debug, Deserialize, Serialize)] +pub struct Task { + pub text: String, + + #[serde(with = "ts_seconds")] + pub created_at: DateTime, +} + +impl Task { + pub fn new(text: String) -> Task { + let created_at: DateTime = Utc::now(); + Task { text, created_at } + } +} + +fn collect_tasks(mut file: &File) -> Result> { + file.seek(SeekFrom::Start(0))?; // Rewind the file before. + let tasks = match serde_json::from_reader(file) { + Ok(tasks) => tasks, + Err(e) if e.is_eof() => Vec::new(), + Err(e) => Err(e)?, + }; + file.seek(SeekFrom::Start(0))?; // Rewind the file after. + Ok(tasks) +} + +pub fn add_task(journal_path: PathBuf, task: Task) -> Result<()> { + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(journal_path)?; + let mut tasks = collect_tasks(&file)?; + tasks.push(task); + serde_json::to_writer(file, &tasks)?; + Ok(()) +} + +pub fn complete_task(journal_path: PathBuf, task_position: usize) -> Result<()> { + // Open the file. + let file = OpenOptions::new() + .read(true) + .write(true) + .open(journal_path)?; + + // Consume file's contents as a vector of tasks. + let mut tasks = collect_tasks(&file)?; + + // Try to remove the task. + if task_position == 0 || task_position > tasks.len() { + return Err(Error::new(ErrorKind::InvalidInput, "Invalid Task ID")); + } + tasks.remove(task_position - 1); + + // Write the modified task list back into the file. + file.set_len(0)?; + serde_json::to_writer(file, &tasks)?; + Ok(()) +} + +pub fn list_tasks(journal_path: PathBuf) -> Result<()> { + // Open the file. + let file = OpenOptions::new().read(true).open(journal_path)?; + // Parse the file and collect the tasks. + let tasks = collect_tasks(&file)?; + + // Enumerate and display tasks, if any. + if tasks.is_empty() { + println!("Task list is empty!"); + } else { + let mut order: u32 = 1; + for task in tasks { + println!("{}: {}", order, task); + order += 1; + } + } + + Ok(()) +} + +impl fmt::Display for Task { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let created_at = self.created_at.with_timezone(&Local).format("%F %H:%M"); + write!(f, "{:<50} [{}]", self.text, created_at) + } +}