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(&notif.repository.full_name, 35)).fg(Color::Cyan),
514            Cell::new(&notif.subject.subject_type).fg(Color::Magenta),
515            Cell::new(&notif.reason).fg(Color::Yellow),
516            Cell::new(truncate(&notif.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}