Skip to content

Commit 51d8f8a

Browse files
authored
fix(edit): add --editor flag and validate editor before launching (#7)
Add a `-e`/`--editor` CLI flag to override the editor used by the edit command. Editor resolution now prefers the flag, falls back to $EDITOR, and fails explicitly when neither is set (instead of silently defaulting to vim). The resolved editor is validated via `which` before launch to give a clear error on missing executables. Add unit tests for resolve_editor, try_generate_edit_diffs, and display helpers, and add tests for PrintableActivityLog row rendering.
1 parent 7c2bdc5 commit 51d8f8a

12 files changed

Lines changed: 465 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ boat-lib = "0.5.0"
3030
tabular = { version = "0.2.0", features = ["ansi-cell"] }
3131
yansi = "1.0.1"
3232
dialoguer = "0.12.0"
33+
which = "8.0.2"
3334

3435
[dev-dependencies]
3536
assert_cmd = "2.0"

src/cli/args.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,10 @@ pub struct EditLogsArgs {
287287
)]
288288
pub period: Option<PeriodInput>,
289289

290+
/// Specify the editor program to use
291+
#[arg(short = 'e', long = "editor")]
292+
pub editor: Option<String>,
293+
290294
/// Include instruction comments in the editable file
291295
#[arg(short = 'i', long = "with-instructions", alias = "with-instr", action = ArgAction::SetTrue, conflicts_with = "hide_instructions")]
292296
pub show_instructions: bool,

src/commands/edit.rs

Lines changed: 139 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,14 @@ pub fn edit(
5656
let default_content = boat_data.to_csv_str(include_instructions, include_activity_definitions);
5757
let edit_file_path = create_tmp_edit_file(&default_content)?;
5858

59-
let editor = env::var("EDITOR").unwrap_or_else(|_| "vim".to_string());
59+
let editor = args
60+
.editor
61+
.clone()
62+
.or_else(|| env::var("EDITOR").ok())
63+
.context("No editor specified and EDITOR is not set")?;
64+
65+
resolve_editor(&editor)?;
66+
info!("launching editor: {editor}");
6067
let status = Command::new(editor).arg(&edit_file_path).status()?;
6168

6269
ensure!(
@@ -137,6 +144,17 @@ struct EditLogDiff {
137144
ends_at_new: Option<Option<DateTime<Utc>>>,
138145
}
139146

147+
fn resolve_editor(editor: &str) -> Result<()> {
148+
if editor.is_empty() {
149+
bail!(
150+
"Default text editor not found. Please refer to your shell documentation to set your EDITOR environment variable."
151+
);
152+
}
153+
154+
which::which(editor).with_context(|| format!("EDITOR command not found: {editor}"))?;
155+
Ok(())
156+
}
157+
140158
fn pretty_print_edit_diffs(diffs: &[EditLogDiff]) {
141159
println!("Detected changes:");
142160
for diff in diffs {
@@ -391,10 +409,13 @@ fn convert_modified_content_to_log_lines(content: &str) -> Result<Vec<DatabaseLo
391409
}
392410

393411
fn create_tmp_edit_file(content: &str) -> Result<PathBuf> {
394-
info!("creating temporary file for editing");
395412
let tmp_dir = env::temp_dir();
396413
let file_path = tmp_dir.join("boat_edit_logs_tmp.csv");
397414
fs::write(&file_path, content)?;
415+
info!(
416+
"created temporary file for editing: {}",
417+
file_path.display()
418+
);
398419
Ok(file_path)
399420
}
400421

@@ -478,4 +499,120 @@ mod tests {
478499
let csv = "1, 10, not-a-date, 2024-06-01 11:00";
479500
assert!(convert_modified_content_to_log_lines(csv).is_err());
480501
}
502+
503+
// --- resolve_editor ---
504+
505+
#[test]
506+
fn resolve_editor_empty_string_fails() {
507+
assert!(resolve_editor("").is_err());
508+
}
509+
510+
#[test]
511+
fn resolve_editor_nonexistent_command_fails() {
512+
assert!(resolve_editor("definitely-not-a-real-editor-xyz").is_err());
513+
}
514+
515+
// --- try_generate_edit_diffs error paths ---
516+
517+
#[test]
518+
fn try_generate_edit_diffs_length_mismatch_fails() {
519+
let orig = vec![make_log(1, 1, "2024-06-01 10:00", Some("2024-06-01 11:00"))];
520+
let edited = vec![
521+
make_log(1, 1, "2024-06-01 10:00", Some("2024-06-01 11:00")),
522+
make_log(2, 1, "2024-06-01 12:00", Some("2024-06-01 13:00")),
523+
];
524+
assert!(try_generate_edit_diffs(&edited, &orig).is_err());
525+
}
526+
527+
#[test]
528+
fn try_generate_edit_diffs_multiple_open_ended_logs_fails() {
529+
let orig = vec![
530+
make_log(1, 1, "2024-06-01 10:00", None),
531+
make_log(2, 1, "2024-06-01 12:00", None),
532+
];
533+
assert!(try_generate_edit_diffs(&orig, &orig).is_err());
534+
}
535+
536+
#[test]
537+
fn try_generate_edit_diffs_unknown_log_id_fails() {
538+
let orig = vec![make_log(1, 1, "2024-06-01 10:00", Some("2024-06-01 11:00"))];
539+
let edited = vec![make_log(
540+
99,
541+
1,
542+
"2024-06-01 10:00",
543+
Some("2024-06-01 11:00"),
544+
)];
545+
assert!(try_generate_edit_diffs(&edited, &orig).is_err());
546+
}
547+
548+
#[test]
549+
fn try_generate_edit_diffs_activity_id_changed_fails() {
550+
let orig = vec![make_log(1, 1, "2024-06-01 10:00", Some("2024-06-01 11:00"))];
551+
let edited = vec![make_log(
552+
1,
553+
999,
554+
"2024-06-01 10:00",
555+
Some("2024-06-01 11:00"),
556+
)];
557+
assert!(try_generate_edit_diffs(&edited, &orig).is_err());
558+
}
559+
560+
#[test]
561+
fn try_generate_edit_diffs_starts_at_after_ends_at_fails() {
562+
let orig = vec![make_log(1, 1, "2024-06-01 10:00", Some("2024-06-01 11:00"))];
563+
let edited = vec![make_log(1, 1, "2024-06-01 12:00", Some("2024-06-01 11:00"))];
564+
assert!(try_generate_edit_diffs(&edited, &orig).is_err());
565+
}
566+
567+
// --- try_generate_edit_diffs happy paths ---
568+
569+
#[test]
570+
fn try_generate_edit_diffs_no_change_returns_empty() {
571+
let logs = vec![make_log(1, 1, "2024-06-01 10:00", Some("2024-06-01 11:00"))];
572+
let diffs = try_generate_edit_diffs(&logs, &logs).unwrap();
573+
assert!(diffs.is_empty());
574+
}
575+
576+
#[test]
577+
fn try_generate_edit_diffs_detects_ends_at_change() {
578+
let orig = vec![make_log(1, 1, "2024-06-01 10:00", Some("2024-06-01 11:00"))];
579+
let edited = vec![make_log(1, 1, "2024-06-01 10:00", Some("2024-06-01 12:00"))];
580+
let diffs = try_generate_edit_diffs(&edited, &orig).unwrap();
581+
assert_eq!(diffs.len(), 1);
582+
assert!(diffs[0].ends_at_new.is_some());
583+
assert!(diffs[0].starts_at_new.is_none());
584+
}
585+
586+
#[test]
587+
fn try_generate_edit_diffs_detects_log_closed_to_open() {
588+
let orig = vec![make_log(1, 1, "2024-06-01 10:00", Some("2024-06-01 11:00"))];
589+
let edited = vec![make_log(1, 1, "2024-06-01 10:00", None)];
590+
let diffs = try_generate_edit_diffs(&edited, &orig).unwrap();
591+
assert_eq!(diffs.len(), 1);
592+
// ends_at_new is Some(None) — the log was re-opened
593+
assert_eq!(diffs[0].ends_at_new, Some(None));
594+
}
595+
596+
// --- date_time_opt_loose_eq ---
597+
598+
#[test]
599+
fn date_time_opt_loose_eq_both_none_are_equal() {
600+
let none: Option<DateTime<Utc>> = None;
601+
assert!(date_time_opt_loose_eq(&none, &none).unwrap());
602+
}
603+
604+
#[test]
605+
fn date_time_opt_loose_eq_some_and_none_are_not_equal() {
606+
let some = Some(Utc.with_ymd_and_hms(2024, 6, 1, 10, 0, 0).unwrap());
607+
let none = None;
608+
assert!(!date_time_opt_loose_eq(&some, &none).unwrap());
609+
assert!(!date_time_opt_loose_eq(&none, &some).unwrap());
610+
}
611+
612+
#[test]
613+
fn date_time_opt_loose_eq_different_minutes_are_not_equal() {
614+
let dt1 = Some(Utc.with_ymd_and_hms(2024, 6, 1, 10, 0, 0).unwrap());
615+
let dt2 = Some(Utc.with_ymd_and_hms(2024, 6, 1, 10, 1, 0).unwrap());
616+
assert!(!date_time_opt_loose_eq(&dt1, &dt2).unwrap());
617+
}
481618
}

src/models/activity_log.rs

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,69 @@ impl RowPrintable for PrintableActivityLog {
6868
}
6969
}
7070
}
71+
72+
#[cfg(test)]
73+
mod tests {
74+
use super::*;
75+
use boat_lib::models::log::Log as DatabaseLog;
76+
use chrono::{TimeZone, Utc};
77+
use std::collections::HashSet;
78+
79+
fn make_activity() -> SimpleActivity {
80+
SimpleActivity {
81+
id: 7,
82+
name: "testing".to_string(),
83+
description: Some("a desc".to_string()),
84+
tags: HashSet::new(),
85+
}
86+
}
87+
88+
fn make_log(ends_after_secs: Option<i64>) -> DatabaseLog {
89+
let start = Utc.with_ymd_and_hms(2024, 4, 15, 10, 0, 0).unwrap();
90+
DatabaseLog {
91+
id: 42,
92+
activity_id: 7,
93+
starts_at: start,
94+
ends_at: ends_after_secs.map(|s| start + chrono::Duration::seconds(s)),
95+
}
96+
}
97+
98+
#[test]
99+
fn row_values_open_log_shows_dash_for_end() {
100+
let pal = PrintableActivityLog::from_activity_and_log(&make_activity(), &make_log(None));
101+
assert_eq!(pal.row_values()[5], "-");
102+
}
103+
104+
#[test]
105+
fn row_values_closed_log_has_non_empty_end() {
106+
let pal =
107+
PrintableActivityLog::from_activity_and_log(&make_activity(), &make_log(Some(3600)));
108+
let end_col = &pal.row_values()[5];
109+
assert_ne!(end_col, "-");
110+
assert!(!end_col.is_empty());
111+
}
112+
113+
#[test]
114+
fn row_values_id_and_name_match_activity() {
115+
let pal =
116+
PrintableActivityLog::from_activity_and_log(&make_activity(), &make_log(Some(3600)));
117+
let values = pal.row_values();
118+
assert_eq!(values[0], "7");
119+
assert_eq!(values[1], "testing");
120+
}
121+
122+
#[test]
123+
fn style_cell_closed_log_returns_value_unchanged() {
124+
let pal =
125+
PrintableActivityLog::from_activity_and_log(&make_activity(), &make_log(Some(3600)));
126+
let val = "unchanged".to_string();
127+
assert_eq!(pal.style_cell(val.clone()), val);
128+
}
129+
130+
#[test]
131+
fn style_cell_open_log_result_contains_original_value() {
132+
let pal = PrintableActivityLog::from_activity_and_log(&make_activity(), &make_log(None));
133+
let val = "styled".to_string();
134+
assert!(pal.style_cell(val.clone()).contains(&val));
135+
}
136+
}

src/utils/display.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,3 +190,49 @@ pub fn current_activity_msg(activity: &DatabaseActivity) -> Result<String> {
190190
pub fn no_current_act_msg() -> String {
191191
"no current activity".to_string()
192192
}
193+
194+
#[cfg(test)]
195+
mod tests {
196+
use super::*;
197+
198+
#[test]
199+
fn get_group_by_display_values_none_returns_all() {
200+
let (label, tooltip) = get_group_by_display_values(GroupBy::None, "anything").unwrap();
201+
assert_eq!(label, "ALL");
202+
assert!(tooltip.is_none());
203+
}
204+
205+
#[test]
206+
fn get_group_by_display_values_year_returns_key() {
207+
let (label, tooltip) = get_group_by_display_values(GroupBy::Year, "2024").unwrap();
208+
assert_eq!(label, "2024");
209+
assert!(tooltip.is_none());
210+
}
211+
212+
#[test]
213+
fn get_group_by_display_values_month_formats_name() {
214+
let (label, tooltip) = get_group_by_display_values(GroupBy::Month, "2024-04").unwrap();
215+
assert_eq!(label, "April 2024");
216+
assert!(tooltip.is_none());
217+
}
218+
219+
#[test]
220+
fn get_group_by_display_values_month_invalid_key_fails() {
221+
assert!(get_group_by_display_values(GroupBy::Month, "not-a-month").is_err());
222+
}
223+
224+
#[test]
225+
fn get_group_by_display_values_week_label_and_date_range() {
226+
let (label, tooltip) = get_group_by_display_values(GroupBy::Week, "2024-W10").unwrap();
227+
assert_eq!(label, "Week 10");
228+
// 2024 starts on a Monday, so week 10 runs Mar 04–Mar 10
229+
let tooltip = tooltip.unwrap();
230+
assert!(tooltip.contains("Mar 04, 2024"), "got: {tooltip}");
231+
assert!(tooltip.contains("Mar 10, 2024"), "got: {tooltip}");
232+
}
233+
234+
#[test]
235+
fn get_group_by_display_values_week_invalid_key_fails() {
236+
assert!(get_group_by_display_values(GroupBy::Week, "not-a-week").is_err());
237+
}
238+
}

tests/cli_delete.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,18 @@ fn delete_with_missing_id_arg_fails() -> Result<()> {
4949

5050
Ok(())
5151
}
52+
53+
#[test]
54+
fn delete_running_activity_succeeds() -> Result<()> {
55+
let (_tmp, config_path) = cli_args_for_temp()?;
56+
57+
run_boat(["new", "ActiveTask", "--start-now"], &config_path).success();
58+
59+
run_boat(["delete", "1", "--no-confirm"], &config_path).success();
60+
61+
run_boat(["list", "--json"], &config_path)
62+
.success()
63+
.stdout(predicates::str::contains("ActiveTask").not());
64+
65+
Ok(())
66+
}

tests/cli_get.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,17 @@ fn get_when_no_current_activity_fails() -> Result<()> {
4141

4242
Ok(())
4343
}
44+
45+
#[test]
46+
fn get_after_pause_fails() -> Result<()> {
47+
let (_tmp, config_path) = cli_args_for_temp()?;
48+
49+
run_boat(["new", "WasRunning", "--start-now"], &config_path).success();
50+
run_boat(["pause"], &config_path).success();
51+
52+
run_boat(["get", "--json"], &config_path)
53+
.failure()
54+
.stderr(predicates::str::contains("no current activity"));
55+
56+
Ok(())
57+
}

0 commit comments

Comments
 (0)