main
1mod actions;
2mod cache;
3mod config;
4mod github;
5mod rules;
6
7use anyhow::{Context, Result};
8use clap::{Parser, Subcommand};
9use colored::*;
10use comfy_table::modifiers::UTF8_ROUND_CORNERS;
11use comfy_table::presets::UTF8_FULL;
12use comfy_table::{Cell, CellAlignment, Color, ContentArrangement, Table};
13use indicatif::{ProgressBar, ProgressStyle};
14use std::io::IsTerminal;
15use std::path::PathBuf;
16
17use crate::actions::ActionExecutor;
18use crate::cache::Cache;
19use crate::config::{Action, Config};
20use crate::github::GitHubClient;
21
22#[derive(Parser)]
23#[command(name = "github-notif-manager")]
24#[command(about = "Rule-based GitHub notification automation", version, long_about = None)]
25struct Cli {
26 #[command(subcommand)]
27 command: Commands,
28}
29
30#[derive(Subcommand)]
31enum Commands {
32 /// Process notifications according to rules
33 Process {
34 /// Path to configuration file
35 #[arg(short, long)]
36 config: Option<PathBuf>,
37
38 /// Show what would be done without making changes
39 #[arg(short = 'n', long)]
40 dry_run: bool,
41
42 /// Show detailed output including skipped notifications
43 #[arg(short, long)]
44 verbose: bool,
45
46 /// Fetch all notifications (ignore cache, do a full refresh)
47 #[arg(short, long)]
48 all: bool,
49
50 /// Custom cache file path (default: $XDG_CACHE_HOME/github-notif-manager/cache.json)
51 #[arg(long)]
52 cache_dir: Option<PathBuf>,
53 },
54
55 /// Validate configuration file
56 Validate {
57 /// Path to configuration file
58 #[arg(short, long)]
59 config: Option<PathBuf>,
60 },
61
62 /// List recent notifications (for testing/debugging)
63 List {
64 /// Number of notifications to show
65 #[arg(short, long, default_value = "10")]
66 limit: usize,
67
68 /// Fetch all notifications (ignore cache, do a full refresh)
69 #[arg(short, long)]
70 all: bool,
71
72 /// Custom cache file path
73 #[arg(long)]
74 cache_dir: Option<PathBuf>,
75 },
76
77 /// Show cache status
78 Cache {
79 /// Custom cache file path
80 #[arg(long)]
81 cache_dir: Option<PathBuf>,
82
83 /// Clear the entire cache
84 #[arg(long)]
85 clear: bool,
86
87 /// Clear only the done IDs (keep notifications)
88 #[arg(long)]
89 clear_done: bool,
90 },
91}
92
93fn main() -> Result<()> {
94 let cli = Cli::parse();
95
96 match cli.command {
97 Commands::Process {
98 config,
99 dry_run,
100 verbose,
101 all,
102 cache_dir,
103 } => cmd_process(config.unwrap_or_else(default_config_path), dry_run, verbose, all, cache_dir),
104 Commands::Validate { config } => cmd_validate(config.unwrap_or_else(default_config_path)),
105 Commands::List {
106 limit,
107 all,
108 cache_dir,
109 } => cmd_list(limit, all, cache_dir),
110 Commands::Cache { cache_dir, clear, clear_done } => cmd_cache(cache_dir, clear, clear_done),
111 }
112}
113
114fn default_config_path() -> PathBuf {
115 dirs::config_dir()
116 .unwrap_or_else(|| dirs::home_dir().unwrap().join(".config"))
117 .join("github-notif-manager")
118 .join("config.yaml")
119}
120
121fn cache_path(cache_dir: Option<PathBuf>) -> PathBuf {
122 cache_dir.unwrap_or_else(Cache::default_path)
123}
124
125fn is_tty() -> bool {
126 std::io::stderr().is_terminal()
127}
128
129fn truncate(s: &str, max: usize) -> String {
130 if s.len() > max {
131 format!("{}…", &s[..max - 1])
132 } else {
133 s.to_string()
134 }
135}
136
137/// Fetch notifications, using cache for incremental updates
138fn fetch_notifications(
139 client: &GitHubClient,
140 cache: &mut Cache,
141 force_all: bool,
142) -> Result<()> {
143 let (since, full_fetch) = if force_all || cache.last_fetched.is_none() {
144 (None, true)
145 } else {
146 (cache.since_param(), false)
147 };
148
149 let tty = is_tty();
150 let pb = if tty {
151 let pb = ProgressBar::new_spinner();
152 pb.set_style(
153 ProgressStyle::with_template("{spinner:.cyan} {msg}")
154 .unwrap()
155 .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"),
156 );
157 if full_fetch {
158 pb.set_message("Fetching all notifications…");
159 } else {
160 pb.set_message(format!(
161 "Fetching notifications since {} ({} cached)…",
162 cache
163 .last_fetched
164 .map(|d| d.format("%Y-%m-%d %H:%M UTC").to_string())
165 .unwrap_or_default(),
166 cache.notifications.len(),
167 ));
168 }
169 Some(pb)
170 } else {
171 None
172 };
173
174 let fresh = client
175 .get_notifications(force_all, since.as_deref(), pb.as_ref())
176 .context("Failed to fetch notifications")?;
177
178 let (fresh_count, filtered_count) = cache.merge(fresh, full_fetch);
179
180 if let Some(pb) = pb {
181 pb.finish_and_clear();
182 }
183
184 if full_fetch {
185 let msg = if filtered_count > 0 {
186 format!(
187 " {} Fetched {} notifications (filtered {} previously done)",
188 if tty { "✓".green().to_string() } else { "OK".to_string() },
189 cache.notifications.len(),
190 filtered_count
191 )
192 } else {
193 format!(
194 " {} Fetched {} notifications",
195 if tty { "✓".green().to_string() } else { "OK".to_string() },
196 cache.notifications.len()
197 )
198 };
199 eprintln!("{}", msg);
200 } else {
201 let msg = if filtered_count > 0 {
202 format!(
203 " {} Fetched {} new/updated (filtered {} done), {} total cached",
204 if tty { "✓".green().to_string() } else { "OK".to_string() },
205 fresh_count - filtered_count,
206 filtered_count,
207 cache.notifications.len()
208 )
209 } else {
210 format!(
211 " {} Fetched {} new/updated, {} total cached",
212 if tty { "✓".green().to_string() } else { "OK".to_string() },
213 fresh_count,
214 cache.notifications.len()
215 )
216 };
217 eprintln!("{}", msg);
218 }
219
220 Ok(())
221}
222
223fn cmd_process(
224 config_path: PathBuf,
225 dry_run: bool,
226 verbose: bool,
227 force_all: bool,
228 cache_dir: Option<PathBuf>,
229) -> Result<()> {
230 let cp = cache_path(cache_dir);
231 let tty = is_tty();
232
233 // Load configuration
234 let config = Config::from_file(&config_path)?;
235 let enabled_rules = config.enabled_rules();
236
237 // Print header
238 if tty {
239 println!("\n{}", "═".repeat(60).bright_blue());
240 println!("{}", " GitHub Notification Manager".bright_blue().bold());
241 println!("{}", "═".repeat(60).bright_blue());
242 println!("Config: {}", config_path.display());
243 println!("Cache: {}", cp.display());
244 println!(
245 "Rules: {} enabled, {} disabled",
246 config.enabled_count(),
247 config.disabled_count()
248 );
249 println!(
250 "Mode: {}",
251 if dry_run { "DRY RUN".yellow() } else { "LIVE".green() }
252 );
253 println!("{}", "═".repeat(60).bright_blue());
254 println!();
255 } else {
256 eprintln!(
257 "github-notif-manager: config={} rules={} mode={}",
258 config_path.display(),
259 config.enabled_count(),
260 if dry_run { "dry-run" } else { "live" }
261 );
262 }
263
264 // Initialize GitHub client
265 let client = GitHubClient::new()?;
266
267 // Load cache and fetch notifications
268 let mut cache = Cache::load(&cp)?;
269 fetch_notifications(&client, &mut cache, force_all)?;
270
271 if cache.notifications.is_empty() {
272 eprintln!(" No notifications to process");
273 cache.save(&cp)?;
274 return Ok(());
275 }
276
277 // Process notifications
278 let total_notifications = cache.notifications.len();
279
280 let pb = if tty {
281 let pb = ProgressBar::new(total_notifications as u64);
282 pb.set_style(
283 ProgressStyle::with_template(
284 " {spinner:.cyan} Processing [{bar:30.cyan/dim}] {pos}/{len} {msg}",
285 )
286 .unwrap()
287 .tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
288 .progress_chars("━╸─"),
289 );
290 Some(pb)
291 } else {
292 None
293 };
294
295 let executor = ActionExecutor::new(&client, dry_run);
296 let results = executor.execute_batch(&cache.notifications, &enabled_rules, pb.as_ref());
297
298 // Track done notifications in cache (unless dry-run)
299 if !dry_run {
300 let done_ids: Vec<String> = results
301 .iter()
302 .filter(|r| matches!(r.action, Action::Done) && r.success)
303 .map(|r| r.notification_id.clone())
304 .collect();
305 for id in &done_ids {
306 cache.mark_done(id);
307 }
308 }
309
310 // Save cache
311 cache.save(&cp)?;
312
313 // Calculate statistics
314 let done_ok = results.iter().filter(|r| r.action == Action::Done && r.success).count();
315 let done_fail = results.iter().filter(|r| r.action == Action::Done && !r.success).count();
316 let skip_ok = results.iter().filter(|r| r.action == Action::Skip).count();
317 let no_match = total_notifications - results.len();
318
319 if tty {
320 // Display results table
321 let visible_results: Vec<_> = results
322 .iter()
323 .filter(|r| verbose || !matches!(r.action, Action::Skip))
324 .collect();
325
326 if !visible_results.is_empty() {
327 let mut table = Table::new();
328 table
329 .load_preset(UTF8_FULL)
330 .apply_modifier(UTF8_ROUND_CORNERS)
331 .set_content_arrangement(ContentArrangement::Dynamic)
332 .set_header(vec![
333 Cell::new("Action"),
334 Cell::new("Repository"),
335 Cell::new("Type"),
336 Cell::new("Subject"),
337 Cell::new("Rule"),
338 ]);
339
340 for result in &visible_results {
341 let (action_str, action_color) = match result.action {
342 Action::Done => ("done", Color::Yellow),
343 Action::Skip => ("skip", Color::DarkGrey),
344 };
345
346 let status = if result.success { "" } else { " ✗" };
347
348 table.add_row(vec![
349 Cell::new(format!("{}{}", action_str, status)).fg(action_color),
350 Cell::new(truncate(&result.repository, 30)).fg(Color::Cyan),
351 Cell::new(&result.subject_type).fg(Color::Magenta),
352 Cell::new(truncate(&result.subject_title, 40)),
353 Cell::new(truncate(&result.rule_name, 28)).fg(Color::Green),
354 ]);
355 }
356
357 println!();
358 println!("{table}");
359 }
360
361 // Display summary table
362 println!("\n{}", "Summary".bright_cyan().bold());
363
364 let mut summary = Table::new();
365 summary
366 .load_preset(UTF8_FULL)
367 .apply_modifier(UTF8_ROUND_CORNERS)
368 .set_content_arrangement(ContentArrangement::Dynamic)
369 .set_header(vec![
370 Cell::new("Action"),
371 Cell::new("Count").set_alignment(CellAlignment::Right),
372 Cell::new("Failed").set_alignment(CellAlignment::Right),
373 ]);
374
375 summary.add_row(vec![
376 Cell::new("Done"),
377 Cell::new(done_ok).set_alignment(CellAlignment::Right),
378 Cell::new(done_fail).set_alignment(CellAlignment::Right),
379 ]);
380 summary.add_row(vec![
381 Cell::new("Skipped"),
382 Cell::new(skip_ok).set_alignment(CellAlignment::Right),
383 Cell::new("-").set_alignment(CellAlignment::Right),
384 ]);
385 summary.add_row(vec![
386 Cell::new("No match"),
387 Cell::new(no_match).set_alignment(CellAlignment::Right),
388 Cell::new("-").set_alignment(CellAlignment::Right),
389 ]);
390
391 println!("{summary}");
392
393 if dry_run {
394 println!("\n{}", "DRY RUN: No changes were made".yellow());
395 }
396 } else {
397 // Non-TTY: simple log-style output
398 println!(
399 "done={} skipped={} no_match={} failed={}",
400 done_ok, skip_ok, no_match, done_fail
401 );
402 if dry_run {
403 println!("mode=dry-run");
404 }
405 }
406
407 Ok(())
408}
409
410fn cmd_validate(config_path: PathBuf) -> Result<()> {
411 let config = Config::from_file(&config_path)?;
412
413 println!("\n{}", "✓ Configuration is valid".green().bold());
414 println!();
415 println!("Total rules: {}", config.rules.len());
416 println!("Enabled rules: {}", config.enabled_count());
417 println!("Disabled rules: {}", config.disabled_count());
418
419 let mut table = Table::new();
420 table
421 .load_preset(UTF8_FULL)
422 .apply_modifier(UTF8_ROUND_CORNERS)
423 .set_content_arrangement(ContentArrangement::Dynamic)
424 .set_header(vec![
425 Cell::new("#").set_alignment(CellAlignment::Right),
426 Cell::new("On"),
427 Cell::new("Name"),
428 Cell::new("Action"),
429 Cell::new("Filters"),
430 ]);
431
432 for (i, rule) in config.rules.iter().enumerate() {
433 let (enabled, enabled_color) = if rule.enabled {
434 ("✓", Color::Green)
435 } else {
436 ("✗", Color::Red)
437 };
438 let default_name = format!("Rule {}", i + 1);
439 let name = rule
440 .name
441 .as_ref()
442 .map(|s| s.as_str())
443 .unwrap_or(&default_name);
444 let filters: Vec<String> = rule
445 .filters
446 .iter()
447 .map(|(k, v)| match v {
448 crate::config::FilterValue::String(s) => format!("{}={}", k, s),
449 crate::config::FilterValue::Bool(b) => format!("{}={}", k, b),
450 crate::config::FilterValue::Number(n) => format!("{}={}", k, n),
451 })
452 .collect();
453
454 table.add_row(vec![
455 Cell::new(i + 1).set_alignment(CellAlignment::Right),
456 Cell::new(enabled).fg(enabled_color),
457 Cell::new(name).fg(Color::Cyan),
458 Cell::new(rule.action.to_string()).fg(Color::Yellow),
459 Cell::new(filters.join(", ")),
460 ]);
461 }
462
463 println!();
464 println!("{table}");
465
466 Ok(())
467}
468
469fn cmd_list(limit: usize, force_all: bool, cache_dir: Option<PathBuf>) -> Result<()> {
470 let cp = cache_path(cache_dir);
471 let client = GitHubClient::new()?;
472
473 // Load cache and fetch
474 let mut cache = Cache::load(&cp)?;
475 fetch_notifications(&client, &mut cache, force_all)?;
476 cache.save(&cp)?;
477
478 let notifications = &cache.notifications;
479
480 if notifications.is_empty() {
481 println!("{}", "No notifications found".yellow());
482 return Ok(());
483 }
484
485 println!(
486 "\nShowing first {} of {}:\n",
487 std::cmp::min(limit, notifications.len()),
488 notifications.len().to_string().bold()
489 );
490
491 let mut table = Table::new();
492 table
493 .load_preset(UTF8_FULL)
494 .apply_modifier(UTF8_ROUND_CORNERS)
495 .set_content_arrangement(ContentArrangement::Dynamic)
496 .set_header(vec![
497 Cell::new(""),
498 Cell::new("Repository"),
499 Cell::new("Type"),
500 Cell::new("Reason"),
501 Cell::new("Subject"),
502 ]);
503
504 for notif in notifications.iter().take(limit) {
505 let (unread, unread_color) = if notif.unread {
506 ("●", Color::Green)
507 } else {
508 ("○", Color::DarkGrey)
509 };
510
511 table.add_row(vec![
512 Cell::new(unread).fg(unread_color),
513 Cell::new(truncate(¬if.repository.full_name, 35)).fg(Color::Cyan),
514 Cell::new(¬if.subject.subject_type).fg(Color::Magenta),
515 Cell::new(¬if.reason).fg(Color::Yellow),
516 Cell::new(truncate(¬if.subject.title, 45)),
517 ]);
518 }
519
520 println!("{table}");
521
522 Ok(())
523}
524
525fn cmd_cache(cache_dir: Option<PathBuf>, clear: bool, clear_done: bool) -> Result<()> {
526 let cp = cache_path(cache_dir);
527
528 if clear {
529 if cp.exists() {
530 std::fs::remove_file(&cp)?;
531 println!("{} Cache cleared: {}", "✓".green(), cp.display());
532 } else {
533 println!("{}", "No cache file found".yellow());
534 }
535 return Ok(());
536 }
537
538 if clear_done {
539 if !cp.exists() {
540 println!("{}", "No cache file found".yellow());
541 return Ok(());
542 }
543 let mut cache = Cache::load(&cp)?;
544 let count = cache.done_count();
545 cache.clear_done_ids();
546 cache.save(&cp)?;
547 println!(
548 "{} Cleared {} done notification IDs from cache",
549 "✓".green(),
550 count
551 );
552 return Ok(());
553 }
554
555 if !cp.exists() {
556 println!("{}", "No cache file found".yellow());
557 println!("Path: {}", cp.display());
558 println!("Run 'list' or 'process' to populate the cache.");
559 return Ok(());
560 }
561
562 let cache = Cache::load(&cp)?;
563 let file_size = std::fs::metadata(&cp)?.len();
564
565 println!("\n{}", "Cache Status".bright_cyan().bold());
566 println!("Path: {}", cp.display());
567 println!("Size: {:.1} KB", file_size as f64 / 1024.0);
568 println!(
569 "Last fetched: {}",
570 cache
571 .last_fetched
572 .map(|d| d.format("%Y-%m-%d %H:%M:%S UTC").to_string())
573 .unwrap_or_else(|| "never".to_string())
574 .cyan()
575 );
576 println!(
577 "Notifications: {}",
578 cache.notifications.len().to_string().bold()
579 );
580
581 if !cache.notifications.is_empty() {
582 let unread = cache.notifications.iter().filter(|n| n.unread).count();
583 let read = cache.notifications.len() - unread;
584 println!(" Unread: {}", unread.to_string().green());
585 println!(" Read: {}", read.to_string().bright_black());
586 }
587
588 println!(
589 "Done tracked: {} {}",
590 cache.done_count().to_string().yellow(),
591 "(use --clear-done to reset)".bright_black()
592 );
593
594 Ok(())
595}