From 0816230e8712948df451fee7aee48537efe043cb Mon Sep 17 00:00:00 2001 From: 魏曹先生 <1992414357@qq.com> Date: Fri, 17 Apr 2026 00:00:04 +0800 Subject: Add edit command with table serialization support --- src/bill.rs | 69 +++++++++++++++++++++++++++++++++- src/cli.rs | 122 ++++++++++++++++++++++++++++++++++++++++++++++-------------- src/edit.rs | 55 +++++++++++++++++++++++++++ src/main.rs | 1 + 4 files changed, 218 insertions(+), 29 deletions(-) create mode 100644 src/edit.rs (limited to 'src') diff --git a/src/bill.rs b/src/bill.rs index e03cd22..4b66554 100644 --- a/src/bill.rs +++ b/src/bill.rs @@ -3,7 +3,7 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::who::Who; +use crate::{display::SimpleTable, string_vec, who::Who}; #[derive(Debug, Default, Serialize, Deserialize)] pub struct Bills { @@ -169,3 +169,70 @@ impl SplitResult { self.final_result.get(&(payer, payee)).copied() } } + +impl Bills { + pub fn table(self) -> String { + let mut table = SimpleTable::new(string_vec![ + "#", "Who", "|", "Paid", "|", "Split", "|", "Reason" + ]); + let mut items: Vec<_> = self.items.into_iter().collect(); + items.sort_by(|a, b| { + b.1.paid + .partial_cmp(&a.1.paid) + .unwrap_or(std::cmp::Ordering::Equal) + }); + for (_, items) in items { + let split = items + .split + .iter() + .map(|i| i.to_string()) + .collect::>() + .join(", "); + table.push_item(string_vec![ + "", + items.who_paid, + "|", + items.paid, + "|", + split, + "|", + items.reason + ]); + } + table.to_string() + } + + pub fn from_table_str(table_str: impl Into) -> Bills { + let mut bills = Bills::default(); + let table_str = table_str.into(); + + for line in table_str.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + + let parts: Vec<&str> = line.split('|').map(|s| s.trim()).collect(); + if parts.len() != 4 { + continue; + } + + let who_paid = parts[0]; + let paid_str = parts[1]; + let split_str = parts[2]; + let reason = parts[3]; + + let paid = paid_str.parse::().unwrap_or(0.0); + + let split: Vec<&str> = split_str + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect(); + + bills.add_bill(who_paid, reason, paid, split); + } + + bills + } +} diff --git a/src/cli.rs b/src/cli.rs index aa0d317..a39624f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,4 +1,4 @@ -use std::{fs::create_dir_all, path::PathBuf}; +use std::{fs::create_dir_all, io::ErrorKind, path::PathBuf}; use mingling::{ AnyOutput, Groupped, @@ -13,6 +13,7 @@ use crate::{ bill::{BillItem, Bills, SplitResult}, calc::calculate_from, display::SimpleTable, + edit::{get_default_editor, input_with_editor_cutsom}, error::BillSplitError, string_vec, }; @@ -30,19 +31,25 @@ pub async fn entry() { program.with_dispatchers(( ClearAllBillCommand, AddBillCommand, - // RenameMemberCommand, - // RenameBillCommand, + EditCommand, ListAllBillCommand, )); + program.with_dispatchers(( + EditWithViCommand, + EditWithVimCommand, + EditWithNvimCommand, + EditWithHelixCommand, + EditWithNanoCommand, + )); + // Execute program.exec().await; } dispatcher!("clear", ClearAllBillCommand => ClearAllBillEntry); dispatcher!("add", AddBillCommand => AddBillEntry); -// dispatcher!("rename.member", RenameMemberCommand => RenameMemberEntry); -// dispatcher!("rename.bill", RenameBillCommand => RenameBillEntry); +dispatcher!("edit", EditCommand => EditEntry); dispatcher!("ls", ListAllBillCommand => ListAllBillEntry); #[chain] @@ -129,36 +136,56 @@ async fn handle_list_bills(prev: StateListBills) -> NextProcess { #[renderer] fn render_bills(prev: ResultBills) { - let mut table = SimpleTable::new(string_vec!["Who", "|", "Paid", "|", "Split", "|", "Reason"]); - for (_, items) in prev.inner.items { - let split = items - .split - .iter() - .map(|i| i.to_string()) - .collect::>() - .join(", "); - table.push_item(string_vec![ - items.who_paid, - "", - items.paid, - "", - split, - "", - items.reason - ]); - } - r_println!("{}", table) + r_println!("{}", prev.inner.table()) } #[renderer] fn render_split_result(prev: ResultSplitResult) { let mut table = SimpleTable::new(string_vec!["Who", "|", "Should Pay", "|", "To"]); for ((who, to), paid) in prev.inner.final_result { - table.push_item(string_vec![who, "", paid, "", to]); + table.push_item(string_vec![who, "|", paid, "|", to]); } r_println!("{}", table) } +pack!(StateEditBills = String); // Editor name +pack!(ErrorEditorNotFound = String); + +#[chain] +async fn parse_edit_cmd(prev: EditEntry) -> NextProcess { + let editor = Picker::<()>::new(prev.inner) + .pick_or::(["--editor", "-e"], get_default_editor()) + .unpack_directly() + .0; + let state = StateEditBills::new(editor); + AnyOutput::new(state).route_chain() +} + +#[chain] +async fn exec_edit_cmd(prev: StateEditBills) -> NextProcess { + let text = match input_with_editor_cutsom( + read_bills().table(), + state_edit_file_path(), + "#", + prev.inner.clone(), + ) { + Ok(v) => v, + Err(e) => match e.kind() { + ErrorKind::NotFound => { + return AnyOutput::new(ErrorEditorNotFound::new(prev.inner)).route_renderer(); + } + _ => panic!("Error editing bills: {}", e), + }, + }; + write_bills(Bills::from_table_str(text)); + Empty::new(()).to_render() +} + +#[renderer] +fn render_error_editor_not_found(prev: ErrorEditorNotFound) { + r_println!("Error: Editor \"{}\" not found", prev.inner); +} + #[renderer] fn render_error_duplicate_split_members(_prev: ErrorDuplicateSplitMembers) { r_println!("Error: Duplicate members found in split list"); @@ -200,6 +227,10 @@ fn state_file_path() -> PathBuf { cobill_dir().join("state.yml") } +fn state_edit_file_path() -> PathBuf { + cobill_dir().join("edit.state.md") +} + fn read_bills() -> Bills { let dir = cobill_dir(); create_dir_all(dir).unwrap(); @@ -218,12 +249,47 @@ fn read_bills() -> Bills { } } -fn op_bills(op: F) { - let mut bills = read_bills(); - op(&mut bills); +fn write_bills(bills: Bills) { let state_file = state_file_path(); let contents = serde_yaml::to_string(&bills).unwrap(); std::fs::write(state_file, contents).unwrap(); } +fn op_bills(op: F) { + let mut bills = read_bills(); + op(&mut bills); + write_bills(bills); +} + +dispatcher!("vi", EditWithViCommand => EditWithViEntry); +dispatcher!("vim", EditWithVimCommand => EditWithVimEntry); +dispatcher!("nvim", EditWithNvimCommand => EditWithNvimEntry); +dispatcher!("helix", EditWithHelixCommand => EditWithHelixEntry); +dispatcher!("nano", EditWithNanoCommand => EditWithNanoEntry); + +#[chain] +async fn edit_with_vi(_prev: EditWithViEntry) -> NextProcess { + EditEntry::new(string_vec!["-e", "vi"]).to_chain() +} + +#[chain] +async fn edit_with_vim(_prev: EditWithVimEntry) -> NextProcess { + EditEntry::new(string_vec!["-e", "vim"]).to_chain() +} + +#[chain] +async fn edit_with_nvim(_prev: EditWithNvimEntry) -> NextProcess { + EditEntry::new(string_vec!["-e", "nvim"]).to_chain() +} + +#[chain] +async fn edit_with_helix(_prev: EditWithHelixEntry) -> NextProcess { + EditEntry::new(string_vec!["-e", "helix"]).to_chain() +} + +#[chain] +async fn edit_with_nano(_prev: EditWithNanoEntry) -> NextProcess { + EditEntry::new(string_vec!["-e", "nano"]).to_chain() +} + gen_program!(); diff --git a/src/edit.rs b/src/edit.rs new file mode 100644 index 0000000..f9c751c --- /dev/null +++ b/src/edit.rs @@ -0,0 +1,55 @@ +use std::process::Command; + +pub fn input_with_editor_cutsom( + default_text: impl AsRef, + cache_file: impl AsRef, + comment_char: impl AsRef, + editor: String, +) -> Result { + let cache_path = cache_file.as_ref(); + let default_content = default_text.as_ref(); + let comment_prefix = comment_char.as_ref(); + + // Write default text to cache file + std::fs::write(cache_path, default_content)?; + + // Open editor with cache file + let status = Command::new(editor).arg(cache_path).status()?; + + if !status.success() { + return Err(std::io::Error::new( + std::io::ErrorKind::Other, + "Editor exited with non-zero status", + )); + } + + // Read the modified content + let content = std::fs::read_to_string(cache_path)?; + + // Remove comment lines and trim + let processed_content: String = content + .lines() + .filter_map(|line| { + let trimmed = line.trim(); + if trimmed.starts_with(comment_prefix) { + None + } else { + Some(line) + } + }) + .collect::>() + .join("\n"); + + // Delete the cache file + let _ = std::fs::remove_file(cache_path); + + Ok(processed_content) +} + +pub fn get_default_editor() -> String { + if let Ok(editor) = std::env::var("EDITOR") { + return editor; + } + + "nano".to_string() +} diff --git a/src/main.rs b/src/main.rs index 614693b..8a31d09 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod bill; mod calc; mod cli; mod display; +mod edit; mod error; mod macros; mod who; -- cgit