Compare commits

...

10 commits

Author SHA1 Message Date
4bcd25cc09 rename trait method 2025-08-10 20:19:49 +08:00
05569817aa chore: build 2025-08-10 20:04:46 +08:00
34ff456eeb refactor: impl trait for handler 2025-08-10 20:04:35 +08:00
20c41d05de add README 2025-08-09 11:29:57 +08:00
0f4d21b974 chore: update version 2025-08-09 11:11:26 +08:00
3a7c37f7a5 update gitignore 2025-08-09 11:10:57 +08:00
5ac0fa668a fix: impl rename method 2025-08-09 11:10:33 +08:00
7e7fd1fd35 refactor handlers 2025-08-09 10:20:45 +08:00
1ed172937a update setup_record_file function 2025-08-09 10:00:54 +08:00
c75e518658 remove usage_count and add cli description 2025-08-09 10:00:04 +08:00
9 changed files with 313 additions and 177 deletions

1
.gitignore vendored
View file

@ -1 +1,2 @@
/target
*.json

2
Cargo.lock generated
View file

@ -938,7 +938,7 @@ dependencies = [
[[package]]
name = "worthit"
version = "0.1.0"
version = "0.1.1"
dependencies = [
"anyhow",
"chrono",

View file

@ -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
View 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
```

View file

@ -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),
}
}
}

View file

@ -16,8 +16,6 @@ pub struct ProductTable {
#[tabled(rename = "狀態")]
status: Status,
// #[tabled(rename = "使用次數")]
// usage_count: u32,
#[tabled(rename = "維修次數")]
repair_count: String,

View file

@ -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(())
}

View file

@ -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(())
}
}

View file

@ -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(())
}