Skip to content

Commit 290faae

Browse files
committed
feat: add logging and improve execution UX
1 parent ae85ba4 commit 290faae

10 files changed

Lines changed: 334 additions & 59 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "tide"
3-
version = "1.2.4"
3+
version = "1.3.0"
44
edition = "2024"
55
authors = ["Markus Sommer"]
66
description = "🌊 Tide - Refresh your system with the update wave"

example-full-config.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ skip_optional_on_error = false # Skip optional tasks if a required task fails
1313
keychain_label = "tide-sudo" # For storing sudo password in keychain (macOS Keychain)
1414
use_colors = true # Enable colored output
1515
verbose = false # Show detailed output
16-
log_file = "" # Optional: Path to log file
16+
log_file = "~/.config/tide/tide.log" # Optional: capture command output (relative paths allowed)
1717
desktop_notifications = true # Enable macOS desktop notifications
1818

1919
# ============================================================================

readme.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Tide coordinates macOS software updates, Homebrew cleanups, and any custom shell
3939
- Dry-run mode to preview commands with zero side effects.
4040
- Optional fail-fast behaviour that halts optional work after a required task fails.
4141
- Verbose logging for debugging plus quiet mode for automation owners.
42+
- Structured run logs when `log_file` is set—every task start/stop and trimmed output is written to disk for later review.
4243

4344
### Desktop Notifications 🔔
4445

@@ -109,7 +110,7 @@ parallel_limit = 4
109110
skip_optional_on_error = false
110111
keychain_label = "tide-sudo"
111112
verbose = false
112-
log_file = "" # Optional: capture command output
113+
log_file = "~/.config/tide/tide.log" # Optional: capture command output
113114
desktop_notifications = true # Enable macOS desktop notifications
114115

115116
[[groups]]
@@ -138,6 +139,8 @@ parallel = false
138139
timeout = 600
139140
```
140141

142+
Set `show_progress = false` if you prefer plain log lines instead of spinner-based updates—handy for CI logs or when capturing all details via the log file.
143+
141144
### Task Fields
142145

143146
- `command` – Array form prevents shell quoting issues.
@@ -202,6 +205,10 @@ command = ["./interactive-tool"] # asks questions
202205
# Will hang and timeout after 5 minutes!
203206
```
204207

208+
### Logging
209+
210+
Set `log_file` under `[settings]` to capture a full transcript of the run. Relative paths are resolved relative to the config file, tilde-expansion (`~`) is supported, and directories are created automatically. Each entry records the timestamp, group/task name, status, runtime, and a trimmed copy of any captured output so you can audit what happened without scrolling back through your terminal scrollback.
211+
205212
## Examples
206213

207214
Parallel developer tooling refresh:

src/cli.rs

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,4 @@ pub struct Args {
4646
/// Enable verbose output
4747
#[arg(short, long)]
4848
pub verbose: bool,
49-
50-
/// Show version information
51-
#[arg(short = 'V', long)]
52-
pub version: bool,
5349
}

src/config.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@ impl Default for Settings {
6262
}
6363
}
6464

65+
impl Settings {
66+
/// Return the configured log file path, ignoring empty values.
67+
pub fn log_file_path(&self) -> Option<&str> {
68+
self.log_file
69+
.as_deref()
70+
.map(str::trim)
71+
.filter(|path| !path.is_empty())
72+
}
73+
}
74+
6575
/// Task group configuration
6676
#[derive(Debug, Deserialize, Serialize, Clone)]
6777
pub struct TaskGroup {

src/executor.rs

Lines changed: 162 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use std::time::{Duration, Instant};
99

1010
use crate::config::TaskConfig;
1111
use crate::keychain;
12+
use crate::logger::Logger;
1213
use crate::notifications::NotificationManager;
1314

1415
/// Task execution result
@@ -33,20 +34,97 @@ pub enum TaskStatus {
3334
/// Task executor with progress tracking
3435
#[derive(Clone)]
3536
pub struct TaskExecutor {
36-
pub multi_progress: Arc<MultiProgress>,
37+
pub multi_progress: Option<Arc<MultiProgress>>,
3738
pub dry_run: bool,
3839
pub verbose: bool,
3940
pub notifier: Arc<NotificationManager>,
41+
logger: Option<Arc<Logger>>,
42+
show_progress: bool,
4043
}
4144

4245
impl TaskExecutor {
4346
/// Create a new task executor
44-
pub fn new(dry_run: bool, verbose: bool, notifications_enabled: bool) -> Self {
47+
pub fn new(
48+
dry_run: bool,
49+
verbose: bool,
50+
notifications_enabled: bool,
51+
show_progress: bool,
52+
logger: Option<Arc<Logger>>,
53+
) -> Self {
4554
Self {
46-
multi_progress: Arc::new(MultiProgress::new()),
55+
multi_progress: show_progress.then(|| Arc::new(MultiProgress::new())),
4756
dry_run,
4857
verbose,
4958
notifier: Arc::new(NotificationManager::new(notifications_enabled)),
59+
logger,
60+
show_progress,
61+
}
62+
}
63+
64+
fn update_progress(&self, pb: &ProgressBar, message: &str) {
65+
if self.show_progress {
66+
pb.set_message(message.to_string());
67+
} else {
68+
println!("{}", message);
69+
}
70+
}
71+
72+
fn finish_progress(&self, pb: &ProgressBar, message: &str) {
73+
if self.show_progress {
74+
pb.finish_with_message(message.to_string());
75+
} else {
76+
println!("{}", message);
77+
}
78+
}
79+
80+
fn log_line(&self, message: String) {
81+
if let Some(logger) = &self.logger {
82+
if let Err(err) = logger.log_line(&message) {
83+
if self.verbose {
84+
eprintln!("{}", format!("Failed to write log entry: {}", err).yellow());
85+
}
86+
}
87+
}
88+
}
89+
90+
fn log_task_completion(
91+
&self,
92+
group_label: &str,
93+
task_label: &str,
94+
status: TaskStatus,
95+
duration: Duration,
96+
output: Option<&str>,
97+
) {
98+
if self.logger.is_none() {
99+
return;
100+
}
101+
102+
let status_prefix = match status {
103+
TaskStatus::Success => "✓ SUCCESS",
104+
TaskStatus::Failed => "✗ FAILED",
105+
TaskStatus::Skipped => "○ SKIPPED",
106+
};
107+
self.log_line(format!(
108+
"{} [{}] {} ({})",
109+
status_prefix,
110+
group_label,
111+
task_label,
112+
format_duration(duration)
113+
));
114+
115+
if let Some(output) = output {
116+
let trimmed = output.trim();
117+
if trimmed.is_empty() {
118+
return;
119+
}
120+
if let Some(logger) = &self.logger {
121+
let header = format!("└ output [{}] {}", group_label, task_label);
122+
if let Err(err) = logger.log_block(&header, trimmed) {
123+
if self.verbose {
124+
eprintln!("{}", format!("Failed to write log entry: {}", err).yellow());
125+
}
126+
}
127+
}
50128
}
51129
}
52130

@@ -141,14 +219,18 @@ impl TaskExecutor {
141219

142220
/// Create a configured spinner progress bar
143221
pub fn new_spinner(&self) -> ProgressBar {
144-
let pb = self.multi_progress.add(ProgressBar::new_spinner());
145-
pb.set_style(
146-
ProgressStyle::with_template("{spinner:.cyan} {msg}")
147-
.unwrap()
148-
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
149-
);
150-
pb.enable_steady_tick(Duration::from_millis(120));
151-
pb
222+
if let Some(multi) = &self.multi_progress {
223+
let pb = multi.add(ProgressBar::new_spinner());
224+
pb.set_style(
225+
ProgressStyle::with_template("{spinner:.cyan} {msg}")
226+
.unwrap()
227+
.tick_strings(&["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]),
228+
);
229+
pb.enable_steady_tick(Duration::from_millis(120));
230+
pb
231+
} else {
232+
ProgressBar::hidden()
233+
}
152234
}
153235

154236
/// Execute a single task
@@ -165,76 +247,109 @@ impl TaskExecutor {
165247
let group_label = format_group_label(&group_name, &group_icon);
166248
let task_label = format_task_label(&task_name, &task.icon);
167249
let progress_label = format!("[{}] {}", group_label, task_label);
250+
let running_message = format!("{} {}", progress_label.bold(), "Running…".bright_white());
251+
self.update_progress(&pb, &running_message);
168252

169-
// Update progress bar
170-
pb.set_message(format!(
171-
"{} {}",
172-
progress_label.clone().bold(),
173-
"Running…".bright_white()
253+
let mut cmd = task.command.clone();
254+
if task.sudo && !cmd.is_empty() && cmd[0] != "sudo" {
255+
cmd.insert(0, "sudo".to_string());
256+
}
257+
let command_display = if cmd.is_empty() {
258+
"<empty command>".to_string()
259+
} else {
260+
cmd.join(" ")
261+
};
262+
self.log_line(format!(
263+
"▶ [{}] {} :: {}",
264+
group_label, task_label, command_display
174265
));
175266

176267
if self.dry_run {
177268
tokio::time::sleep(Duration::from_millis(100)).await;
178-
pb.finish_with_message(format!(
269+
let dry_run_msg = format!(
179270
"{} {} {}",
180-
progress_label.clone().bold(),
271+
progress_label.bold(),
181272
"○".yellow(),
182273
"[dry run]".dimmed()
183-
));
274+
);
275+
self.finish_progress(&pb, &dry_run_msg);
276+
let duration = start.elapsed();
277+
let reason = "Dry run - command not executed".to_string();
278+
self.log_task_completion(
279+
&group_label,
280+
&task_label,
281+
TaskStatus::Skipped,
282+
duration,
283+
Some(reason.as_str()),
284+
);
184285
return TaskResult {
185286
name: task_name.clone(),
186287
group: group_name,
187288
group_icon,
188289
status: TaskStatus::Skipped,
189-
duration: start.elapsed(),
190-
output: Some("Dry run - command not executed".to_string()),
290+
duration,
291+
output: Some(reason),
191292
};
192293
}
193294

194295
// Check preconditions
195296
if let Some(check_cmd) = &task.check_command
196297
&& !keychain::command_exists(check_cmd)
197298
{
198-
pb.finish_with_message(format!(
299+
let skip_msg = format!(
199300
"{} {}",
200-
progress_label.clone().bold(),
301+
progress_label.bold(),
201302
"[skipped: command not found]".dimmed()
202-
));
303+
);
304+
self.finish_progress(&pb, &skip_msg);
305+
let duration = start.elapsed();
306+
let reason = format!("Command '{}' not found", check_cmd);
307+
self.log_task_completion(
308+
&group_label,
309+
&task_label,
310+
TaskStatus::Skipped,
311+
duration,
312+
Some(reason.as_str()),
313+
);
203314
return TaskResult {
204315
name: task_name.clone(),
205316
group: group_name,
206317
group_icon,
207318
status: TaskStatus::Skipped,
208-
duration: start.elapsed(),
209-
output: Some(format!("Command '{}' not found", check_cmd)),
319+
duration,
320+
output: Some(reason),
210321
};
211322
}
212323

213324
if let Some(check_path) = &task.check_path {
214325
let expanded = shellexpand::tilde(check_path);
215326
if !Path::new(expanded.as_ref()).exists() {
216-
pb.finish_with_message(format!(
327+
let skip_msg = format!(
217328
"{} {}",
218-
progress_label.clone().bold(),
329+
progress_label.bold(),
219330
"[skipped: path not found]".dimmed()
220-
));
331+
);
332+
self.finish_progress(&pb, &skip_msg);
333+
let duration = start.elapsed();
334+
let reason = format!("Path '{}' not found", check_path);
335+
self.log_task_completion(
336+
&group_label,
337+
&task_label,
338+
TaskStatus::Skipped,
339+
duration,
340+
Some(reason.as_str()),
341+
);
221342
return TaskResult {
222343
name: task_name.clone(),
223344
group: group_name,
224345
group_icon,
225346
status: TaskStatus::Skipped,
226-
duration: start.elapsed(),
227-
output: Some(format!("Path '{}' not found", check_path)),
347+
duration,
348+
output: Some(reason),
228349
};
229350
}
230351
}
231352

232-
// Build command
233-
let mut cmd = task.command.clone();
234-
if task.sudo && !cmd.is_empty() && cmd[0] != "sudo" {
235-
cmd.insert(0, "sudo".to_string());
236-
}
237-
238353
// Warn if command might internally call sudo (heuristic check)
239354
if !task.sudo && self.verbose && !cmd.is_empty() {
240355
let cmd_str = cmd.join(" ").to_lowercase();
@@ -276,12 +391,20 @@ impl TaskExecutor {
276391
TaskStatus::Skipped => "○".yellow(),
277392
};
278393

279-
pb.finish_with_message(format!(
394+
let completion_message = format!(
280395
"{} {} {}",
281396
progress_label.bold(),
282397
status_icon,
283398
format!("({})", format_duration(duration)).dimmed()
284-
));
399+
);
400+
self.finish_progress(&pb, &completion_message);
401+
self.log_task_completion(
402+
&group_label,
403+
&task_label,
404+
status,
405+
duration,
406+
output.as_deref(),
407+
);
285408

286409
TaskResult {
287410
name: task_name,

0 commit comments

Comments
 (0)