usenet_reborn 0.2.2

Terminal-based Usenet NNTP client written in Rust with ratatui/crossterm.
use crate::nntp_client::NntpClient;
use crossterm::event::{self, Event, KeyCode};
use ratatui::{
    backend::CrosstermBackend,
    layout::Rect,
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, Paragraph},
    Terminal,
};
use rek2_nntp::post::Article as PostArticle;
use std::fs;
use std::io::{Write, Stdout};
use std::process::Command;
use tempfile::NamedTempFile;

/// Attempts to load a signature from `$SIGNATURE_FILE` or `~/.signature`.
fn load_signature() -> Option<String> {
    // Determine signature path: env SIGNATURE_FILE or ~/.signature
    let sig_path = std::env::var("SIGNATURE_FILE").unwrap_or_else(|_| {
        std::env::var("HOME")
            .map(|h| format!("{}/.signature", h))
            .unwrap_or_else(|_| String::new())
    });
    if sig_path.is_empty() {
        return None;
    }
    match fs::read_to_string(&sig_path) {
        Ok(s) if !s.trim().is_empty() => Some(format!("\n-- \n{}", s)),
        _ => None,
    }
}

/// Builds the quoted reply template
fn build_reply_template(in_reply_to: &str, original_body: &str) -> String {
    let quoted = original_body
        .lines()
        .map(|line| format!("> {}", line))
        .collect::<Vec<_>>()
        .join("\n");

    format!("> [in reply to {}]\n\n{}\n\n", in_reply_to, quoted)
}

/// Ask user where to send crossposted reply
pub async fn ask_reply_destination(
    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
    original_newsgroups: &str,
    current_newsgroup: &str,
) -> Result<Option<String>, Box<dyn std::error::Error>> {
    // Check if there are multiple newsgroups
    if !original_newsgroups.contains(',') {
        // Single newsgroup, return as-is
        return Ok(Some(original_newsgroups.to_string()));
    }

    let mut choice = 0; // 0 = all newsgroups, 1 = current only
    let groups_list: Vec<&str> = original_newsgroups.split(',').map(|s| s.trim()).collect();

    loop {
        let area = terminal.size()?;

        // Calculate popup size
        let longest_line = groups_list.iter().map(|s| s.len()).max().unwrap_or(0).max(50);
        let popup_width = (longest_line + 10).min(area.width as usize - 4) as u16;
        let popup_height = (groups_list.len() + 10).min(20) as u16;

        let popup = Rect {
            x: (area.width.saturating_sub(popup_width)) / 2,
            y: (area.height.saturating_sub(popup_height)) / 2,
            width: popup_width,
            height: popup_height,
        };

        terminal.draw(|f| {
            let block = Block::default()
                .borders(Borders::ALL)
                .title("Choose Reply Destination");
            f.render_widget(block, popup);

            let inner = Rect {
                x: popup.x + 1,
                y: popup.y + 1,
                width: popup.width - 2,
                height: popup.height - 2,
            };

            let mut lines = vec![
                Line::from("This article was posted to:"),
                Line::from(""),
            ];

            // Add each newsgroup on its own line
            for group in &groups_list {
                lines.push(Line::from(vec![
                    Span::raw(""),
                    Span::styled(*group, Style::default().fg(Color::Cyan)),
                ]));
            }

            lines.push(Line::from(""));
            lines.push(Line::from("Reply to:"));
            lines.push(Line::from(""));

            // Option 1: Reply to all
            lines.push(Line::from(vec![
                Span::raw(if choice == 0 { "[X] " } else { "[ ] " }),
                Span::styled(
                    "All newsgroups above",
                    if choice == 0 {
                        Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
                    } else {
                        Style::default().fg(Color::White)
                    },
                ),
            ]));

            // Option 2: Current only
            lines.push(Line::from(vec![
                Span::raw(if choice == 1 { "[X] " } else { "[ ] " }),
                Span::styled(
                    format!("Only: {} (current)", current_newsgroup),
                    if choice == 1 {
                        Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)
                    } else {
                        Style::default().fg(Color::White)
                    },
                ),
            ]));

            lines.push(Line::from(""));
            lines.push(Line::from(Span::styled(
                "Tab/j/k to switch, Enter to confirm, Esc to cancel",
                Style::default().fg(Color::DarkGray),
            )));

            let paragraph = Paragraph::new(lines).block(Block::default());
            f.render_widget(paragraph, inner);
        })?;

        if let Event::Key(ev) = event::read()? {
            match ev.code {
                KeyCode::Tab | KeyCode::Char('j') | KeyCode::Char('k') => {
                    choice = 1 - choice;
                }
                KeyCode::Enter => {
                    return Ok(Some(if choice == 0 {
                        original_newsgroups.to_string()
                    } else {
                        current_newsgroup.to_string()
                    }));
                }
                KeyCode::Esc => {
                    return Ok(None); // User cancelled
                }
                _ => {}
            }
        }
    }
}

/// High-level helper to compose a reply
pub async fn compose_reply(
    client: &mut NntpClient,
    newsgroup: &str,
    article_id: &str,
    subject: &str,
    from: String,
    original_newsgroups: Option<String>,
) -> Result<Option<PostArticle>, Box<dyn std::error::Error>> {
    // Fetch original article body
    let original_body = client
        .fetch_article_body(article_id)
        .await
        .unwrap_or_default();

    // Create reply template with quoted body
    let prefill = build_reply_template(&from, &original_body);

    // Write to tempfile
    let mut temp = NamedTempFile::new()?;
    write!(temp, "{}", prefill)?;
    let path = temp.path().to_owned();

    // Launch editor
    let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".into());
    Command::new(editor).arg(&path).status()?;

    // Read edited content
    let body = fs::read_to_string(&path)?;

    // If user didn't change or quit
    if body.trim().is_empty() || body.trim() == prefill.trim() {
        return Ok(None);
    }

    // Append signature if available
    let mut full_body = body;
    if let Some(sig) = load_signature() {
        full_body.push_str(&sig);
    }

    // Build and return article - use original newsgroups if available
    let article = PostArticle {
        from,
        newsgroups: original_newsgroups.unwrap_or_else(|| newsgroup.to_string()),
        subject: format!("Re: {}", subject),
        body: full_body,
        message_id: None,
        references: Some(article_id.to_string()),
    };

    Ok(Some(article))
}

/// Composes a brand-new post.
/// `newsgroup` may be a comma‑separated list (e.g. "group1,group2").
/// `subject` is used exactly as given.
pub async fn compose_post(
    _client: &mut NntpClient,
    newsgroup: &str,
    subject: &str,
    from: String,
) -> Result<Option<PostArticle>, Box<dyn std::error::Error>> {
    // Write an empty buffer to tempfile
    let temp = NamedTempFile::new()?;
    let path = temp.path().to_owned();

    // Launch editor for the user to write the body
    let editor = std::env::var("EDITOR").unwrap_or_else(|_| "nano".into());
    Command::new(editor).arg(&path).status()?;

    // Read edited content
    let body = fs::read_to_string(&path)?;

    // If empty, the user cancelled
    if body.trim().is_empty() {
        return Ok(None);
    }

    // Append signature if available
    let mut full_body = body;
    if let Some(sig) = load_signature() {
        full_body.push_str(&sig);
    }

    // Build and return article — use the subject exactly, and allow multiple newsgroups
    let article = PostArticle {
        from,
        newsgroups: newsgroup.to_string(),
        subject: subject.to_string(),
        body: full_body,
        message_id: None,
        references: None,
    };

    Ok(Some(article))
}