Compare commits
10 commits
49f754465e
...
4bcd25cc09
Author | SHA1 | Date | |
---|---|---|---|
4bcd25cc09 | |||
05569817aa | |||
34ff456eeb | |||
20c41d05de | |||
0f4d21b974 | |||
3a7c37f7a5 | |||
5ac0fa668a | |||
7e7fd1fd35 | |||
1ed172937a | |||
c75e518658 |
9 changed files with 313 additions and 177 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1 +1,2 @@
|
|||
/target
|
||||
*.json
|
2
Cargo.lock
generated
2
Cargo.lock
generated
|
@ -938,7 +938,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "worthit"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
[package]
|
||||
name = "worthit"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
@ -15,4 +15,6 @@ tabled = "0.20.0"
|
|||
|
||||
[profile.release]
|
||||
lto = true
|
||||
opt-level = "z"
|
||||
opt-level = "z"
|
||||
codegen-units = 1
|
||||
strip = true
|
46
README.md
Normal file
46
README.md
Normal file
|
@ -0,0 +1,46 @@
|
|||
記錄自己買過咩東西
|
||||
|
||||
```bash
|
||||
> worthit
|
||||
記錄買過咩東西
|
||||
|
||||
Usage: worthit <COMMAND>
|
||||
|
||||
Commands:
|
||||
add
|
||||
set
|
||||
show
|
||||
delete
|
||||
|
||||
Options:
|
||||
-h, --help Print help (see more with '--help')
|
||||
-V, --version Print version
|
||||
```
|
||||
|
||||
```bash
|
||||
> worthit add --help
|
||||
Usage: worthit add --name <NAME> --price <PRICE> --pd <PURCHASE_DATE>
|
||||
|
||||
Options:
|
||||
-n, --name <NAME> 佢個名
|
||||
-p, --price <PRICE> 幾钱
|
||||
--pd <PURCHASE_DATE> 幾時買,例如2024-1-6
|
||||
-h, --help Print help
|
||||
```
|
||||
|
||||
```bash
|
||||
> worthit set --help
|
||||
Usage: worthit set [OPTIONS]
|
||||
|
||||
Options:
|
||||
-n, --current-name <NAME> 佢個名
|
||||
--new-name <NEW_NAME> 起個新名
|
||||
-p, --price <PRICE> 幾钱
|
||||
--purchase-date <PURCHASE_DATE> 幾時買,例如2024-1-6
|
||||
-s, --status <STATUS> 0: 用緊, 1: 食塵, 2: 壞咗, 3: 賣咗 [default: 0]
|
||||
--repair-count <REPAIR_COUNT> 整咗幾次
|
||||
--repair-cost <REPAIR_COST> 維修費
|
||||
--sold-price <SOLD_PRICE> 轉手價
|
||||
--sold-date <SOLD_DATE> 幾時賣,例如2025-1-6
|
||||
-h, --help Print help
|
||||
```
|
64
src/cli.rs
64
src/cli.rs
|
@ -1,7 +1,18 @@
|
|||
use anyhow::Result;
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use crate::{
|
||||
model::Records,
|
||||
utils::{add_handler, delete_handler, set_handler, show_handler},
|
||||
};
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version, about="敗家記錄", long_about = None, disable_help_subcommand = true)]
|
||||
#[command(
|
||||
version,
|
||||
about = "記錄買過咩東西",
|
||||
long_about = "大額商品總是希望可以耐用不易壞,記錄買過啲貴嘢睇下可以用幾耐",
|
||||
disable_help_subcommand = true
|
||||
)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
|
@ -20,9 +31,12 @@ pub enum Commands {
|
|||
purchase_date: String,
|
||||
},
|
||||
Set {
|
||||
#[arg(short = 'n', long, help = "佢個名")]
|
||||
#[arg(short = 'n', long = "current-name", help = "佢個名")]
|
||||
name: Option<String>,
|
||||
|
||||
#[arg(long = "new-name", help = "起個新名")]
|
||||
new_name: Option<String>,
|
||||
|
||||
#[arg(short = 'p', long, help = "幾钱")]
|
||||
price: Option<f64>,
|
||||
|
||||
|
@ -37,8 +51,6 @@ pub enum Commands {
|
|||
)]
|
||||
status: Option<u32>,
|
||||
|
||||
// #[arg(long, help = "用咗幾次")]
|
||||
// usage_count: Option<u32>,
|
||||
#[arg(long, help = "整咗幾次")]
|
||||
repair_count: Option<u32>,
|
||||
|
||||
|
@ -57,3 +69,47 @@ pub enum Commands {
|
|||
name: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
pub trait CommandHandler {
|
||||
fn execute(self, records: Records, record_path: String) -> Result<()>;
|
||||
}
|
||||
|
||||
impl CommandHandler for Commands {
|
||||
fn execute(self, records: Records, record_path: String) -> Result<()> {
|
||||
match self {
|
||||
Commands::Add {
|
||||
name,
|
||||
price,
|
||||
purchase_date,
|
||||
} => add_handler(name, price, purchase_date, records, record_path),
|
||||
Commands::Set {
|
||||
name,
|
||||
new_name,
|
||||
price,
|
||||
purchase_date,
|
||||
status,
|
||||
repair_count,
|
||||
repair_cost,
|
||||
sold_price,
|
||||
sold_date,
|
||||
} => set_handler(
|
||||
name,
|
||||
new_name,
|
||||
price,
|
||||
purchase_date,
|
||||
status,
|
||||
repair_count,
|
||||
repair_cost,
|
||||
sold_price,
|
||||
sold_date,
|
||||
records,
|
||||
record_path,
|
||||
),
|
||||
Commands::Show => {
|
||||
show_handler(records);
|
||||
Ok(())
|
||||
}
|
||||
Commands::Delete { name } => delete_handler(name, records, record_path),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,8 +16,6 @@ pub struct ProductTable {
|
|||
#[tabled(rename = "狀態")]
|
||||
status: Status,
|
||||
|
||||
// #[tabled(rename = "使用次數")]
|
||||
// usage_count: u32,
|
||||
#[tabled(rename = "維修次數")]
|
||||
repair_count: String,
|
||||
|
||||
|
|
166
src/main.rs
166
src/main.rs
|
@ -1,14 +1,8 @@
|
|||
use anyhow::{Context, Result, anyhow};
|
||||
use chrono::NaiveDate;
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use cli::{Cli, Commands};
|
||||
use tabled::Table;
|
||||
use cli::Cli;
|
||||
|
||||
use crate::{
|
||||
display::ProductTable,
|
||||
model::{Product, Records, Status},
|
||||
utils::setup_records_file,
|
||||
};
|
||||
use crate::{cli::CommandHandler, model::Records, utils::setup_records_file};
|
||||
|
||||
mod cli;
|
||||
mod display;
|
||||
|
@ -16,158 +10,12 @@ mod model;
|
|||
mod utils;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
// let record_path = "records.json";
|
||||
let binding = setup_records_file()?;
|
||||
let record_path = binding
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow!("Invalid UTF-8 in record file path"))?;
|
||||
// let record_path = "records.json".to_string();
|
||||
let record_path = setup_records_file()?;
|
||||
|
||||
let cli = Cli::parse();
|
||||
let mut records = Records::load(record_path)?;
|
||||
|
||||
match cli.command {
|
||||
Commands::Add {
|
||||
name,
|
||||
price,
|
||||
purchase_date,
|
||||
} => {
|
||||
if name.is_empty() {
|
||||
anyhow::bail!("product name can't not be empty.")
|
||||
}
|
||||
if price <= 0.0 {
|
||||
anyhow::bail!("price must be greater than 0.")
|
||||
}
|
||||
let purchase_date = NaiveDate::parse_from_str(&purchase_date, "%Y-%m-%d")
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Invalid date format. Please use YYYY-M-D, e.g. 2023-1-9. You entered: {}",
|
||||
purchase_date
|
||||
)
|
||||
})?;
|
||||
let product = Product::new(name, price, purchase_date);
|
||||
|
||||
records.add_product(product)?;
|
||||
records.save(record_path)?;
|
||||
}
|
||||
Commands::Set {
|
||||
name,
|
||||
price,
|
||||
purchase_date,
|
||||
// usage_count,
|
||||
status,
|
||||
repair_count,
|
||||
repair_cost,
|
||||
sold_price,
|
||||
sold_date,
|
||||
} => {
|
||||
let product_name = name.as_ref().ok_or_else(|| anyhow!("Name is required"))?;
|
||||
let product = records.get_product_mut(product_name)?;
|
||||
|
||||
let validated_price = price
|
||||
.map(|price| {
|
||||
(price > 0.0)
|
||||
.then_some(price)
|
||||
.ok_or_else(|| anyhow!("price must be greater than 0"))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let parsed_purchase_date = purchase_date
|
||||
.map(|date_str| {
|
||||
NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").with_context(|| anyhow!(
|
||||
"Invalid date format. Please use YYYY-M-D, e.g. 2023-1-9. You entered: {}",
|
||||
date_str
|
||||
))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let parsed_status = status.map(Status::from_u32).transpose()?;
|
||||
|
||||
let validated_repair_cost = repair_cost
|
||||
.map(|cost| {
|
||||
(cost > 0.0)
|
||||
.then_some(cost)
|
||||
.ok_or_else(|| anyhow!("cost price must be greater than 0"))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
// Begin ===============================================
|
||||
let sold_price_result = sold_price.map(|price| {
|
||||
(price > 0.0)
|
||||
.then_some(price)
|
||||
.ok_or_else(|| anyhow!("sold price can't be negative"))
|
||||
});
|
||||
let sold_date_result = sold_date.map(|date_str| {
|
||||
NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").with_context(|| {
|
||||
anyhow!(
|
||||
"Invalid date format. Please use YYYY-M-D, e.g. 2023-1-9. You entered: {}",
|
||||
date_str
|
||||
)
|
||||
})
|
||||
});
|
||||
match (&sold_date_result, &sold_price_result) {
|
||||
// sold_date_result 有值,但 sold_price_result 是 None
|
||||
// 或是 sold_price_result 有值,但 sold_date_result 是 None
|
||||
(Some(_), None) | (None, Some(_)) => {
|
||||
if product.sold_price.is_none() && product.sold_date.is_none() {
|
||||
anyhow::bail!(
|
||||
"Both `sold-price` and `sold-date` must be provided together when setting for the first time"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let validated_sold_price = sold_price_result.transpose()?;
|
||||
let parsed_sold_date = sold_date_result.transpose()?;
|
||||
// =============================================== End
|
||||
|
||||
product.update(
|
||||
validated_price,
|
||||
parsed_purchase_date,
|
||||
parsed_status,
|
||||
// usage_count,
|
||||
repair_count,
|
||||
validated_repair_cost,
|
||||
validated_sold_price,
|
||||
parsed_sold_date,
|
||||
);
|
||||
records.save(record_path)?;
|
||||
}
|
||||
Commands::Show => {
|
||||
let products: Vec<ProductTable> = records
|
||||
.list_products()
|
||||
.iter()
|
||||
.map(|p| ProductTable::from_product(p))
|
||||
.collect();
|
||||
|
||||
if products.is_empty() {
|
||||
println!("No products found");
|
||||
} else {
|
||||
println!("{}", Table::new(products))
|
||||
}
|
||||
}
|
||||
Commands::Delete { name } => {
|
||||
if let Some(name) = name {
|
||||
records.remove_product(&name)?;
|
||||
records.save(record_path)?;
|
||||
} else {
|
||||
let product_names: Vec<String> = records.products.keys().cloned().collect();
|
||||
if product_names.is_empty() {
|
||||
println!("No product to delete.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let target = dialoguer::Select::new()
|
||||
.with_prompt("Choose product to delete")
|
||||
.items(&product_names)
|
||||
.interact()?;
|
||||
|
||||
let selected_name = &product_names[target];
|
||||
records.remove_product(selected_name)?;
|
||||
let _ = records.save(record_path);
|
||||
println!("Deleted product: {}", selected_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
let records = Records::load(&record_path)?;
|
||||
cli.command.execute(records, record_path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
18
src/model.rs
18
src/model.rs
|
@ -45,7 +45,6 @@ pub struct Product {
|
|||
pub price: f64,
|
||||
pub purchase_date: NaiveDate,
|
||||
pub status: Option<Status>,
|
||||
// pub usage_count: Option<u32>,
|
||||
pub repair_count: Option<u32>,
|
||||
pub repair_cost: Option<f64>,
|
||||
pub sold_price: Option<f64>,
|
||||
|
@ -59,7 +58,6 @@ impl Product {
|
|||
price,
|
||||
purchase_date,
|
||||
status: Some(Status::Active),
|
||||
// usage_count: None,
|
||||
repair_count: None,
|
||||
repair_cost: None,
|
||||
sold_price: None,
|
||||
|
@ -73,7 +71,6 @@ impl Product {
|
|||
price: Option<f64>,
|
||||
purchase_date: Option<NaiveDate>,
|
||||
status: Option<Status>,
|
||||
// usage_count: Option<u32>,
|
||||
repair_count: Option<u32>,
|
||||
repair_cost: Option<f64>,
|
||||
sold_price: Option<f64>,
|
||||
|
@ -89,7 +86,6 @@ impl Product {
|
|||
self.status = Some(s)
|
||||
}
|
||||
|
||||
// self.usage_count = usage_count.or(self.usage_count);
|
||||
self.repair_count = repair_count.or(self.repair_count);
|
||||
self.repair_cost = repair_cost.or(self.repair_cost);
|
||||
self.sold_price = sold_price.or(self.sold_price);
|
||||
|
@ -108,7 +104,7 @@ impl Records {
|
|||
return Ok(Self::new());
|
||||
}
|
||||
let content = fs::read_to_string(file_path)
|
||||
.with_context(|| format!("Failed to read `record.json` file: {}", file_path))?;
|
||||
.with_context(|| format!("Failed to read `records.json` file: {}", file_path))?;
|
||||
let records: Records = serde_json::from_str(&content)?;
|
||||
|
||||
Ok(records)
|
||||
|
@ -123,7 +119,7 @@ impl Records {
|
|||
pub fn add_product(&mut self, product: Product) -> Result<()> {
|
||||
if self.products.contains_key(&product.name) {
|
||||
return Err(anyhow!(
|
||||
"product name `{}` already exists. choose another one",
|
||||
"Product name `{}` already exists. choose another one",
|
||||
product.name
|
||||
));
|
||||
}
|
||||
|
@ -153,4 +149,14 @@ impl Records {
|
|||
pub fn list_products(&self) -> Vec<&Product> {
|
||||
self.products.values().collect()
|
||||
}
|
||||
|
||||
pub fn rename_product(&mut self, current_name: &str, new_name: String) -> Result<()> {
|
||||
if self.products.contains_key(&new_name) {
|
||||
anyhow::bail!("Product name `{}` already exists", new_name);
|
||||
}
|
||||
let mut product = self.remove_product(current_name)?;
|
||||
product.name = new_name.clone();
|
||||
self.products.insert(new_name, product);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
185
src/utils.rs
185
src/utils.rs
|
@ -1,10 +1,189 @@
|
|||
use anyhow::{Context, Result};
|
||||
use chrono::NaiveDate;
|
||||
use dirs::data_local_dir;
|
||||
use std::path::PathBuf;
|
||||
use tabled::Table;
|
||||
|
||||
pub fn setup_records_file() -> Result<PathBuf> {
|
||||
use crate::{
|
||||
display::ProductTable,
|
||||
model::{Product, Records, Status},
|
||||
};
|
||||
|
||||
pub fn setup_records_file() -> Result<String> {
|
||||
let local_path = data_local_dir().context("Unable to determine the local directory")?;
|
||||
let worthit_dir = local_path.join("worthit");
|
||||
let records_file_path = worthit_dir.join("records.json");
|
||||
Ok(records_file_path)
|
||||
let record_path = records_file_path
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("Invalid UTF-8 in record file path"))?;
|
||||
Ok(record_path.into())
|
||||
}
|
||||
|
||||
pub fn add_handler(
|
||||
name: String,
|
||||
price: f64,
|
||||
purchase_date: String,
|
||||
mut records: Records,
|
||||
record_path: String,
|
||||
) -> Result<()> {
|
||||
if name.is_empty() {
|
||||
anyhow::bail!("Product name can't not be empty.")
|
||||
}
|
||||
if price <= 0.0 {
|
||||
anyhow::bail!("Price must be greater than 0.")
|
||||
}
|
||||
let purchase_date =
|
||||
NaiveDate::parse_from_str(&purchase_date, "%Y-%m-%d").with_context(|| {
|
||||
format!(
|
||||
"Invalid date format. Please use YYYY-M-D, e.g. 2023-1-9. You entered: {}",
|
||||
purchase_date
|
||||
)
|
||||
})?;
|
||||
let product = Product::new(name, price, purchase_date);
|
||||
|
||||
records.add_product(product)?;
|
||||
records.save(&record_path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn set_handler(
|
||||
current_name: Option<String>,
|
||||
new_name: Option<String>,
|
||||
price: Option<f64>,
|
||||
purchase_date: Option<String>,
|
||||
status: Option<u32>,
|
||||
repair_count: Option<u32>,
|
||||
repair_cost: Option<f64>,
|
||||
sold_price: Option<f64>,
|
||||
sold_date: Option<String>,
|
||||
mut records: Records,
|
||||
record_path: String,
|
||||
) -> Result<()> {
|
||||
let current_product_name = current_name
|
||||
.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Name is required"))?;
|
||||
|
||||
let lookup_product = match &new_name {
|
||||
Some(new_name) => {
|
||||
records.rename_product(current_product_name, new_name.into())?;
|
||||
new_name
|
||||
}
|
||||
None => current_product_name,
|
||||
};
|
||||
let product = records.get_product_mut(lookup_product)?;
|
||||
|
||||
let validated_price = price
|
||||
.map(|price| {
|
||||
(price > 0.0)
|
||||
.then_some(price)
|
||||
.ok_or_else(|| anyhow::anyhow!("Price must be greater than 0"))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let parsed_purchase_date = purchase_date
|
||||
.map(|date_str| {
|
||||
NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").with_context(|| {
|
||||
anyhow::anyhow!(
|
||||
"Invalid date format. Please use YYYY-M-D, e.g. 2023-1-9. You entered: {}",
|
||||
date_str
|
||||
)
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
let parsed_status = status.map(Status::from_u32).transpose()?;
|
||||
|
||||
let validated_repair_cost = repair_cost
|
||||
.map(|cost| {
|
||||
(cost > 0.0)
|
||||
.then_some(cost)
|
||||
.ok_or_else(|| anyhow::anyhow!("Cost price must be greater than 0"))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
// Begin ===============================================
|
||||
let sold_price_result = sold_price.map(|price| {
|
||||
(price > 0.0)
|
||||
.then_some(price)
|
||||
.ok_or_else(|| anyhow::anyhow!("Sold price can't be negative"))
|
||||
});
|
||||
let sold_date_result = sold_date.map(|date_str| {
|
||||
NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").with_context(|| {
|
||||
anyhow::anyhow!(
|
||||
"Invalid date format. Please use YYYY-M-D, e.g. 2023-1-9. You entered: {}",
|
||||
date_str
|
||||
)
|
||||
})
|
||||
});
|
||||
match (&sold_date_result, &sold_price_result) {
|
||||
// sold_date_result 有值,但 sold_price_result 是 None
|
||||
// 或是 sold_price_result 有值,但 sold_date_result 是 None
|
||||
(Some(_), None) | (None, Some(_)) => {
|
||||
if product.sold_price.is_none() && product.sold_date.is_none() {
|
||||
anyhow::bail!(
|
||||
"Both `sold-price` and `sold-date` must be provided together when setting for the first time"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let validated_sold_price = sold_price_result.transpose()?;
|
||||
let parsed_sold_date = sold_date_result.transpose()?;
|
||||
// =============================================== End
|
||||
|
||||
product.update(
|
||||
validated_price,
|
||||
parsed_purchase_date,
|
||||
parsed_status,
|
||||
repair_count,
|
||||
validated_repair_cost,
|
||||
validated_sold_price,
|
||||
parsed_sold_date,
|
||||
);
|
||||
records.save(&record_path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn show_handler(records: Records) {
|
||||
let products: Vec<ProductTable> = records
|
||||
.list_products()
|
||||
.iter()
|
||||
.map(|p| ProductTable::from_product(p))
|
||||
.collect();
|
||||
|
||||
if products.is_empty() {
|
||||
println!("No products found");
|
||||
} else {
|
||||
println!("{}", Table::new(products))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_handler(
|
||||
name: Option<String>,
|
||||
mut records: Records,
|
||||
record_path: String,
|
||||
) -> Result<()> {
|
||||
if let Some(name) = name {
|
||||
records.remove_product(&name)?;
|
||||
records.save(&record_path)?;
|
||||
} else {
|
||||
let product_names: Vec<String> = records.products.keys().cloned().collect();
|
||||
if product_names.is_empty() {
|
||||
println!("No product to delete.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let target = dialoguer::Select::new()
|
||||
.with_prompt("Choose product to delete")
|
||||
.items(&product_names)
|
||||
.interact()?;
|
||||
|
||||
let selected_name = &product_names[target];
|
||||
records.remove_product(selected_name)?;
|
||||
let _ = records.save(&record_path);
|
||||
println!("Deleted product: {}", selected_name);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue