Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add interactive CLI #4

Merged
merged 8 commits into from
Aug 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ jobs:
run: git clone https://github.com/coredump-ch/gitcash-demo-repo/
- name: Build
run: cargo build
- name: Create config
run: echo -e "repo_path = 'gitcash-demo-repo'\naccount = 'pos:fridge'\ngit_name = 'CI'\ngit_email = '[email protected]'" > config.toml
- name: Calculate balances
run: target/debug/gitcash --repo-path gitcash-demo-repo balances
run: target/debug/gitcash balances

rustfmt:
name: Check code formatting
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
/target
/Cargo.lock
/config.toml
3 changes: 3 additions & 0 deletions docs/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ There are multiple account types:
- **Source** (prefix `source:`): Special type of account that can be used to
deposit money into the system

Accounts come into existence by usage, but you can also explicitly create a new
account by transferring an amount of 0 to that account.

## Storage format

Transactions are stored as TOML snippets in Git commit messages. Every commit
Expand Down
3 changes: 3 additions & 0 deletions gitcash/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ license = "AGPL-3.0"
[dependencies]
anyhow = "1"
clap = { version = "4", features = ["derive"] }
inquire = "0.6.2"
libgitcash = { path = "../libgitcash/" }
serde = { version = "1", features = ["derive"] }
toml = "0.7"
tracing = "0.1"
tracing-subscriber = "0.3"
38 changes: 38 additions & 0 deletions gitcash/src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
use std::path::{Path, PathBuf};

use anyhow::{bail, Context};
use libgitcash::{Account, AccountType};
use serde::{Deserialize, Serialize};

/// GitCash configuration
#[derive(Debug, Deserialize, Serialize)]
pub struct Config {
/// Path to the GitCash repository to use
pub repo_path: PathBuf,

/// Account corresponding to this PoS
pub account: Account,

/// Name to use for git commits
pub git_name: String,
/// E-mail to use for git commits
pub git_email: String,
}

impl Config {
/// Load config from the specified config path
pub fn load(config_path: &Path) -> anyhow::Result<Self> {
let config_string: String = std::fs::read_to_string(config_path)
.context(format!("Could not read config from {:?}", config_path))?;
let config: Self = toml::from_str(&config_string)
.context(format!("Could not parse config at {:?}", config_path))?;
if config.account.account_type != AccountType::PointOfSale {
bail!(
"Account type must be {:?}, not {:?}",
AccountType::PointOfSale,
config.account.account_type
);
}
Ok(config)
}
}
185 changes: 181 additions & 4 deletions gitcash/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
use std::path::PathBuf;

use anyhow::{anyhow, Context};
use clap::{Parser, Subcommand};
use libgitcash::{AccountType, Repo};
use config::Config;
use inquire::{Autocomplete, InquireError};
use libgitcash::{Account, AccountType, Repo, Transaction};
use tracing::metadata::LevelFilter;

use crate::validators::{NewUsernameValidator, UsernameValidator};

mod config;
mod validators;

#[derive(Parser)]
#[command(author, version, about, long_about = None)]
struct Args {
#[arg(short, long)]
repo_path: std::path::PathBuf,
#[arg(short, long, default_value = "config.toml")]
config: PathBuf,

#[command(subcommand)]
command: Command,
Expand All @@ -20,6 +30,80 @@ enum Command {
Balances,
/// List all user accounts with negative balances
Shame,

/// Interactive CLI
Cli,
}

#[derive(Clone)]
struct CommandSuggester {
commands: Vec<&'static str>,
}

impl CommandSuggester {
pub fn new(commands: &[CliCommand]) -> Self {
Self {
commands: commands
.iter()
.map(|command| command.command())
.collect::<Vec<_>>(),
}
}
}

impl Autocomplete for CommandSuggester {
fn get_suggestions(&mut self, input: &str) -> Result<Vec<String>, inquire::CustomUserError> {
if input.is_empty() {
return Ok(vec![]);
}
Ok(self
.commands
.iter()
.filter(|acc| acc.to_lowercase().contains(&input.to_lowercase()))
.map(|value| value.to_string())
.collect::<Vec<_>>())
}

fn get_completion(
&mut self,
_input: &str,
highlighted_suggestion: Option<String>,
) -> Result<inquire::autocompletion::Replacement, inquire::CustomUserError> {
Ok(highlighted_suggestion)
}
}

#[derive(Debug, Clone, Copy)]
enum CliCommand {
AddUser,
Help,
}

impl CliCommand {
fn command(&self) -> &'static str {
match self {
CliCommand::AddUser => "adduser",
CliCommand::Help => "help",
}
}

fn description(&self) -> &'static str {
match self {
CliCommand::AddUser => "Add a new user",
CliCommand::Help => "Show this help",
}
}
}

impl TryFrom<&str> for CliCommand {
type Error = anyhow::Error;
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value.to_lowercase().as_ref() {
"adduser" => Ok(CliCommand::AddUser),
"help" => Ok(CliCommand::Help),
other => Err(anyhow!("Invalid command: {}", other)),
}
}
}

pub fn main() -> anyhow::Result<()> {
Expand All @@ -32,9 +116,13 @@ pub fn main() -> anyhow::Result<()> {
// Parse args
let args = Args::parse();

// Parse config
let config = Config::load(&args.config)?;

// Open repo
let repo = Repo::open(&args.repo_path)?;
let mut repo = Repo::open(&config.repo_path)?;

// Run command
match args.command {
Command::Accounts => {
println!("Accounts:");
Expand Down Expand Up @@ -74,7 +162,96 @@ pub fn main() -> anyhow::Result<()> {
println!("None at all! 🎉");
}
}
Command::Cli => {
println!("Welcome to the GitCash CLI for {}!", config.git_name);
loop {
if let Err(e) = handle_cli_input(&mut repo, &config) {
match e.downcast::<InquireError>() {
Ok(e) => return Err(e.into()),
Err(e) => println!("Error: {}", e),
}
}
}
}
}

Ok(())
}

// Valid commands
const COMMANDS: [CliCommand; 2] = [CliCommand::AddUser, CliCommand::Help];

fn handle_cli_input(repo: &mut Repo, config: &Config) -> anyhow::Result<()> {
// Get list of valid user account names
let usernames = repo
.accounts()
.into_iter()
.filter(|acc| acc.account_type == AccountType::User)
.map(|acc| acc.name)
.collect::<Vec<_>>();

// Autocompletion: All names that contain the current input as
// substring (case-insensitive)
let name_suggester = {
let usernames = usernames.clone();
move |val: &str| {
Ok(usernames
.iter()
.filter(|acc| acc.to_lowercase().contains(&val.to_lowercase()))
.cloned()
.collect::<Vec<_>>())
}
};

// First, ask for command, product or amount
let target = inquire::Text::new("Amount, EAN or command:")
.with_placeholder("e.g. 2.50 CHF")
.with_autocomplete(CommandSuggester::new(&COMMANDS))
.prompt()?;

// Check whether it's a command
match CliCommand::try_from(&*target) {
Ok(CliCommand::AddUser) => {
println!("Adding user");
let new_name = inquire::Text::new("Name:")
.with_validator(NewUsernameValidator::new(usernames.clone()))
.prompt()?;
repo.create_transaction(Transaction {
from: Account::source("cash")?,
to: Account::user(new_name.clone())?,
amount: 0,
description: Some(format!("Create user {}", new_name)),
meta: None,
})?;
println!("Successfully added user {}", new_name);
return Ok(());
}
Ok(CliCommand::Help) => {
println!("Available commands:");
for command in COMMANDS {
println!("- {}: {}", command.command(), command.description());
}
return Ok(());
}
Err(_) => {}
};

// Not a command, treat it as amount
let amount: f32 = target
.parse()
.context(format!("Invalid amount: {}", target))?;
let name = inquire::Text::new("Name:")
.with_autocomplete(name_suggester.clone())
.with_validator(UsernameValidator::new(usernames))
.prompt()?;
println!("Creating transaction: {} pays {:.2} CHF", name, amount);
repo.create_transaction(Transaction {
from: Account::user(name)?,
to: config.account.clone(),
amount: repo.convert_amount(amount),
description: None,
meta: None,
})?;

Ok(())
}
63 changes: 63 additions & 0 deletions gitcash/src/validators.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
use inquire::{
validator::{ErrorMessage, StringValidator, Validation},
CustomUserError,
};

#[derive(Debug, Clone)]
pub struct UsernameValidator {
usernames: Vec<String>,
}

impl UsernameValidator {
pub fn new(usernames: Vec<String>) -> Self {
Self { usernames }
}
}

impl StringValidator for UsernameValidator {
fn validate(&self, input: &str) -> Result<Validation, CustomUserError> {
Ok(if self.usernames.iter().any(|name| name == input) {
Validation::Valid
} else {
Validation::Invalid(ErrorMessage::Custom(format!(
"Not a known username: {}",
input
)))
})
}
}

#[derive(Debug, Clone)]
pub struct NewUsernameValidator {
usernames: Vec<String>,
}

impl NewUsernameValidator {
pub fn new(usernames: Vec<String>) -> Self {
Self { usernames }
}
}

impl StringValidator for NewUsernameValidator {
fn validate(&self, input: &str) -> Result<Validation, CustomUserError> {
let input = input.trim();
Ok(if input.is_empty() {
Validation::Invalid(ErrorMessage::Custom("Username may not be empty".into()))
} else if input.contains(' ') {
Validation::Invalid(ErrorMessage::Custom(
"Username may not contain a space".into(),
))
} else if input.contains(':') {
Validation::Invalid(ErrorMessage::Custom(
"Username may not contain a colon".into(),
))
} else if self.usernames.iter().any(|name| name == input) {
Validation::Invalid(ErrorMessage::Custom(format!(
"Username already exists: {}",
input
)))
} else {
Validation::Valid
})
}
}
28 changes: 28 additions & 0 deletions libgitcash/src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
use std::path::Path;

use serde::{Deserialize, Serialize};

use crate::error::Error;

#[derive(Debug, Deserialize, Serialize)]
pub struct RepoConfig {
pub name: String,
pub currency: Currency,
}

impl RepoConfig {
/// Load repo config in the specified repo path
pub fn load(repo_path: &Path) -> Result<Self, Error> {
let config_string = std::fs::read_to_string(repo_path.join("gitcash.toml"))
.map_err(|e| Error::RepoError(format!("Could not read gitcash.toml: {}", e)))?;
let config: RepoConfig = toml::from_str(&config_string)
.map_err(|e| Error::RepoError(format!("Could not parse gitcash.toml: {}", e)))?;
Ok(config)
}
}

#[derive(Debug, Deserialize, Serialize)]
pub struct Currency {
pub code: String,
pub divisor: usize,
}
4 changes: 4 additions & 0 deletions libgitcash/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,8 @@ pub enum Error {
LibgitError(#[from] git2::Error),
#[error("Could not parse transaction: {0}")]
TransactionParseError(String),
#[error("Could not serialize transaction: {0}")]
TransactionSerializeError(String),
#[error("Validation error: {0}")]
ValidationError(String),
}
Loading
Loading