Skip to content

Commit 18db4f3

Browse files
committed
refactor(cli,models,config): modularize commands, update config to use db, and modernize listing system
- replace monolithic CLI and models with modular structure (new commands/* and models/* modules) - switch config from CSV-based to database-based; add setup of config/database paths and improve config initialization - update list command to use subcommands (logs/activities/tags), wip support for advanced filtering/formatting - refine help/usage output text - clean up main.rs for new flow and generalized configuration/command bootstrapping - add date utilities for period filtering (today, week, month, etc.)
1 parent 80ad188 commit 18db4f3

File tree

13 files changed

+532
-190
lines changed

13 files changed

+532
-190
lines changed

README.md

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@ This is only the code for the command line application. It relies on [`boat-lib`
1515
> This cli is actively being developed. Since it's in its early stages, things will likely break often.
1616
> Don't use it for now.
1717
18-
## Why was this tool created?
18+
## 🤔 Why was this tool created?
1919

2020
The [`bartib`](https://github.com/nikolassv/bartib) cli is what inspired me to create `boat`.
2121
It's a feature-full tool that I used for a while, but I found it quite limiting for my usage due to its [lack of support for machine-readable output](https://github.com/nikolassv/bartib/pull/26).
2222
That's it, I wanted an activity tracker that I could combine easily with [`jq`](https://github.com/jqlang/jq) and so I decided to make my own tool.
2323

24-
## Installation
24+
## 🛠️ Installation
2525

2626
The easiest way to install is through [crates.io](https://crates.io/crates/boat-cli):
2727
```sh
@@ -51,28 +51,45 @@ cd boat-cli
5151
cargo build --release --features bundled-sqlite
5252
```
5353

54-
## Usage
54+
## ⚙️ Configuration
55+
56+
By default, `boat` will create a configuration file in one of the following dirs:
57+
- 🐧 **Linux:** `/home/<user>/.config/boat/config.toml`
58+
- 🪟 **Windows:** `C:\Users\<user>\AppData\Roaming\boat\config.toml`
59+
- 🍎 **macOS:** `/Users/<user>/Library/Application Support/boat/config.toml`
60+
61+
It will also keep the SQLite database file `boat.db` in the same directory (unless specified otherwise in config):
62+
```toml
63+
database_path = "/home/<user>/.config/boat/boat.db"
64+
```
65+
You can override the default configuration file path by setting the `BOAT_CONFIG` environment variable.
66+
67+
## ✨ Usage
5568

5669
To get a feel of how `boat` can be used, you can try `boat help` to get the list of commands:
5770
```help
71+
boat 0.2.1
72+
5873
Basic Opinionated Activity Tracker
5974
60-
Usage: boat <COMMAND>
75+
Usage:
76+
boat <COMMAND>
6177
6278
Commands:
6379
new Create a new activity
6480
start Start/resume an activity
65-
config Manage configuration
6681
pause Pause/stop the current activity
6782
modify Modify an activity
6883
delete Delete an activity
6984
get Get the current activity
70-
list List activities and tags
85+
list List boat objects
7186
help Print this message or the help of the given subcommand(s)
7287
7388
Options:
7489
-h, --help Print help
7590
-V, --version Print version
91+
92+
Made by @coko7 <contact@coko7.fr>
7693
```
7794

7895
If you want to invoke `boat` from your command-line directly, you can make use of a variety of shorter aliases:
@@ -86,6 +103,7 @@ Commands:
86103
delete d, del
87104
get g
88105
list l, ls
106+
help h, -h, --help
89107
```
90108
I really wanted to have each command start with a different character so that I could assign a single-char alias to all of them.
91109
That explains why some of the commands do not use a more fitting keyword.

src/cli.rs

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
1-
use std::ops::RangeInclusive;
2-
31
use boat_lib::repository::Id;
4-
use clap::{ArgAction, Args, Parser, Subcommand, ValueEnum};
2+
use clap::ColorChoice;
3+
use clap::Parser;
4+
use clap::{ArgAction, Args, Subcommand, ValueEnum};
55

66
#[derive(Parser)]
77
#[command(
88
name = "boat",
99
version,
10+
author = "Made by @coko7 <contact@coko7.fr>",
11+
color = ColorChoice::Auto,
1012
about = "Basic Opinionated Activity Tracker",
11-
author = "coko7",
12-
long_about = None
13+
help_template = "{name} {version}\n\n{about}\n\n{usage-heading}\n{usage}\n\n{all-args}\n\n{author}"
1314
)]
1415
pub struct Cli {
1516
#[command(subcommand)]
@@ -31,10 +32,9 @@ pub enum Commands {
3132
#[command(alias = "s", alias = "st", alias = "sail")]
3233
Start(SelectActivityArgs),
3334

34-
/// Manage configuration
35-
#[command(alias = "c", alias = "cfg", alias = "conf")]
36-
Config {},
37-
35+
// /// Manage configuration
36+
// #[command(alias = "c", alias = "cfg", alias = "conf")]
37+
// Config {},
3838
/// Pause/stop the current activity
3939
#[command(alias = "p", alias = "stop")]
4040
Pause,
@@ -51,9 +51,12 @@ pub enum Commands {
5151
#[command(alias = "g")]
5252
Get(PrintActivityArgs),
5353

54-
/// List activities and tags
54+
/// List boat objects
5555
#[command(alias = "l", alias = "ls")]
56-
List(ListActivityArgs),
56+
List {
57+
#[command(subcommand)]
58+
command: ListSubcommand,
59+
},
5760

5861
// This is ONLY way I could find to use the 'h' short alias for help.
5962
#[command(alias = "h", hide = true)]
@@ -76,8 +79,23 @@ pub enum Commands {
7679
// ^^^ or maybe export 'x' ???
7780
}
7881

82+
#[derive(Subcommand)]
83+
#[command(rename_all = "kebab-case")]
84+
pub enum ListSubcommand {
85+
/// List activity logs
86+
#[command(alias = "l", alias = "log")]
87+
Logs(ListActivityArgs),
88+
89+
/// List activities
90+
#[command(alias = "a", alias = "act", alias = "acts", alias = "activity")]
91+
Activities(ListArgs),
92+
93+
/// List tags
94+
#[command(alias = "t", alias = "tag")]
95+
Tags(ListArgs),
96+
}
97+
7998
#[derive(Args, Debug)]
80-
#[group(multiple = false)]
8199
pub struct ListActivityArgs {
82100
/// Only show activities matching a certain period
83101
#[arg(short = 'p', long = "period", value_name = "PERIOD", default_value_t = Period::Today, value_enum)]
@@ -88,18 +106,27 @@ pub struct ListActivityArgs {
88106
pub use_json_format: bool,
89107
}
90108

109+
#[derive(Args, Debug)]
110+
pub struct ListArgs {
111+
/// Output in JSON
112+
#[arg(short = 'j', long = "json")]
113+
pub use_json_format: bool,
114+
}
115+
91116
#[derive(ValueEnum, Clone, Debug)]
92117
pub enum Period {
93118
#[value(name = "today", alias = "tod", alias = "td")]
94119
Today,
95120
#[value(name = "yesterday", alias = "yes", alias = "yd")]
96121
Yesterday,
97-
#[value(name = "week", alias = "wk")]
122+
#[value(name = "this-week", alias = "wk", alias = "week", alias = "toweek")]
98123
ThisWeek,
99-
#[value(name = "last_week")]
124+
#[value(name = "last-week", alias = "last-wk", alias = "yesterweek")]
100125
LastWeek,
101-
#[value(name = "month", alias = "mo")]
126+
#[value(name = "this-month", alias = "mo", alias = "month", alias = "tomonth")]
102127
ThisMonth,
128+
#[value(name = "last-month", alias = "last-mo", alias = "yestermonth")]
129+
LastMonth,
103130
// #[value(name = "range")]
104131
// DateRange {
105132
// #[arg(value_parser = range_parser)]

src/commands/list.rs

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
use anyhow::Result;
2+
use boat_lib::repository::{activities_repository as activities, tags_repository as tags};
3+
use rusqlite::Connection;
4+
use serde::Serialize;
5+
use std::cmp::Reverse;
6+
use tabular::{Row, Table};
7+
use yansi::Paint;
8+
9+
use crate::{
10+
cli,
11+
models::{
12+
RowPrintable, TablePrintable, activity::PrintableActivity,
13+
activity_with_log::PrintableActivityWithLogs, tag::PrintableTag,
14+
},
15+
utils,
16+
};
17+
18+
pub fn list(conn: &mut Connection, command: &cli::ListSubcommand) -> Result<()> {
19+
match command {
20+
cli::ListSubcommand::Logs(args) => list_activity_logs(conn, args),
21+
cli::ListSubcommand::Activities(args) => list_activities(conn, args),
22+
cli::ListSubcommand::Tags(args) => list_tags(conn, args),
23+
}
24+
}
25+
26+
fn list_printable_items<T: RowPrintable + Serialize>(
27+
items: Vec<T>,
28+
args: &cli::ListArgs,
29+
) -> Result<()> {
30+
if args.use_json_format {
31+
let json = serde_json::to_string(&items)?;
32+
println!("{json}");
33+
return Ok(());
34+
}
35+
36+
let table = items.to_printable_table();
37+
println!("{table}");
38+
Ok(())
39+
}
40+
41+
fn list_tags(conn: &mut Connection, args: &cli::ListArgs) -> Result<()> {
42+
let mut all_tags: Vec<_> = tags::get_all(conn)?
43+
.iter()
44+
.map(PrintableTag::from_tag)
45+
.collect();
46+
all_tags.sort_by_key(|t| Reverse(t.id));
47+
48+
list_printable_items(all_tags, args)
49+
}
50+
51+
fn list_activities(conn: &mut Connection, args: &cli::ListArgs) -> Result<()> {
52+
let mut all_acts: Vec<_> = activities::get_all(conn)?
53+
.iter()
54+
.map(PrintableActivity::from_activity)
55+
.collect();
56+
all_acts.sort_by_key(|a| Reverse(a.id));
57+
58+
list_printable_items(all_acts, args)
59+
}
60+
61+
fn list_activity_logs(conn: &mut Connection, args: &cli::ListActivityArgs) -> Result<()> {
62+
let all: Vec<_> = activities::get_all(conn)?
63+
.iter()
64+
.map(PrintableActivityWithLogs::from_activity)
65+
.collect();
66+
67+
if args.use_json_format {
68+
let json = serde_json::to_string(&all)?;
69+
println!("{json}");
70+
} else {
71+
let mut table = Table::new("{:>} {:<} {:<} {:<} {:<} {:<} {:<}");
72+
table.add_row(
73+
Row::new()
74+
.with_ansi_cell("ID".underline())
75+
.with_ansi_cell("Name".underline())
76+
.with_ansi_cell("Description".underline())
77+
.with_ansi_cell("Tags".underline())
78+
.with_ansi_cell("Start".underline())
79+
.with_ansi_cell("End".underline())
80+
.with_ansi_cell("Duration".underline()),
81+
);
82+
83+
let mut log_lines: Vec<_> = all
84+
.iter()
85+
.flat_map(|act| act.logs.iter().zip(std::iter::repeat(act)))
86+
.filter(|(log, _act)| match args.period {
87+
cli::Period::Today => utils::is_today(log.starts_at),
88+
cli::Period::Yesterday => utils::is_yesterday(log.starts_at),
89+
cli::Period::ThisWeek => utils::is_this_week(log.starts_at),
90+
cli::Period::LastWeek => utils::is_last_week(log.starts_at),
91+
cli::Period::ThisMonth => utils::is_this_month(log.starts_at),
92+
cli::Period::LastMonth => utils::is_last_month(log.starts_at),
93+
})
94+
// .filter(|(log, _act)| utils::is_today(log.starts_at))
95+
.collect();
96+
97+
log_lines.sort_by_key(|(log, _)| log.starts_at);
98+
99+
for &(log, act) in log_lines.iter() {
100+
let mut row = Row::new();
101+
let values = utils::convert_to_log_line(log, act);
102+
for val in values.iter() {
103+
if log.ends_at.is_none() {
104+
row = row.with_ansi_cell(val.green());
105+
} else {
106+
row = row.with_cell(val);
107+
}
108+
}
109+
table.add_row(row);
110+
}
111+
println!("{table}");
112+
}
113+
114+
Ok(())
115+
}

src/commands/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pub mod list;

src/config.rs

Lines changed: 21 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -7,31 +7,32 @@ use std::{env, fs, path::PathBuf};
77
pub const APP_NAME: &str = "boat";
88
pub const CONFIG_VAR: &str = "BOAT_CONFIG";
99
pub const DEFAULT_CONFIG_PATH: &str = "config.toml";
10-
11-
pub const DEFAULT_ACT_DEFS_PATH: &str = "act_defs.csv";
12-
pub const DEFAULT_ACT_LOGS_PATH: &str = "act_logs.csv";
10+
pub const DEFAULT_DB_FILE: &str = "boat.db";
1311

1412
#[derive(Debug, Serialize, Deserialize)]
1513
pub struct Configuration {
16-
#[serde(rename = "activity_definitions_path")]
17-
pub activity_definitions_path: PathBuf,
18-
19-
#[serde(rename = "activity_logs_path")]
20-
pub activity_logs_path: PathBuf,
14+
#[serde(rename = "database_path")]
15+
pub database_path: PathBuf,
2116
}
2217

2318
impl Configuration {
24-
pub fn new() -> Result<Self> {
25-
let config_file = get_config_file()?;
26-
let config_dir = config_file.parent().context("wait")?;
19+
pub fn create_default() -> Result<Self> {
20+
let config_file = get_config_file_path()?;
21+
let config_dir = config_file
22+
.parent()
23+
.context("config file should have a parent directory")?;
24+
let database_path = config_dir.join(DEFAULT_DB_FILE);
25+
26+
Ok(Self { database_path })
27+
}
2728

28-
let activity_definitions_path = config_dir.join(DEFAULT_ACT_DEFS_PATH);
29-
let activity_logs_path = config_dir.join(DEFAULT_ACT_LOGS_PATH);
29+
pub fn load_from_fs() -> Result<Configuration> {
30+
let config_file_path = get_config_file_path()?;
31+
let content = fs::read_to_string(config_file_path)?;
3032

31-
Ok(Self {
32-
activity_definitions_path,
33-
activity_logs_path,
34-
})
33+
info!("parsing config toml");
34+
let config: Configuration = toml::from_str(&content)?;
35+
Ok(config)
3536
}
3637

3738
pub fn to_toml_str(&self) -> Result<String> {
@@ -41,7 +42,7 @@ impl Configuration {
4142
}
4243
}
4344

44-
pub fn get_config_file() -> Result<PathBuf> {
45+
pub fn get_config_file_path() -> Result<PathBuf> {
4546
if let Ok(config_var) = env::var(CONFIG_VAR) {
4647
let val = PathBuf::from(config_var);
4748
debug!(
@@ -67,13 +68,13 @@ pub fn get_config_file() -> Result<PathBuf> {
6768
}
6869

6970
pub fn initialize_config() -> Result<()> {
70-
let config_path = get_config_file()?;
71+
let config_path = get_config_file_path()?;
7172
if let Some(parent) = config_path.parent() {
7273
info!("creating config dir at: {}", parent.display());
7374
fs::create_dir_all(parent)?;
7475
}
7576

76-
let config = Configuration::new()?;
77+
let config = Configuration::create_default()?;
7778
let toml = config.to_toml_str()?;
7879
debug!("generating default config: {config:?}");
7980

0 commit comments

Comments
 (0)