cephas 0.1.1

Privacy-first GTK/WebKit browser with local agent tooling.
Documentation
use crate::browser::{Browser, LoadedPage};
use crate::storage::ProfileStore;
use crate::terminal::terminal_safe_text;
use std::io::{self, Write};
use textwrap::{Options, fill};

pub fn run_interactive(
    mut browser: Browser,
    store: ProfileStore,
    start: Option<String>,
) -> anyhow::Result<()> {
    print_banner(store.path().display().to_string().as_str());

    if let Some(start) = start {
        match browser.open(&start) {
            Ok(page) => print_page(page),
            Err(err) => eprintln!("open failed: {err:#}"),
        }
    }

    let stdin = io::stdin();
    loop {
        print!(
            "{} ",
            style_text("cephas>", "1;36", terminal_colors_enabled())
        );
        io::stdout().flush()?;

        let mut line = String::new();
        if stdin.read_line(&mut line)? == 0 {
            store.save(&browser.profile)?;
            return Ok(());
        }

        let line = line.trim();
        if line.is_empty() {
            continue;
        }

        let mut parts = line.splitn(2, char::is_whitespace);
        let command = parts.next().unwrap_or_default();
        let rest = parts.next().unwrap_or_default().trim();

        let result = match command {
            "open" | "o" => require_arg(rest, "open <url-or-search>").and_then(|target| {
                browser.open(target).map(|page| {
                    print_page(page);
                    true
                })
            }),
            "home" => {
                let home = browser.profile.home.to_string();
                browser.open(&home).map(|page| {
                    print_page(page);
                    true
                })
            }
            "reload" | "r" => browser.reload().map(|page| {
                print_page(page);
                true
            }),
            "back" | "b" => browser.back().map(|page| {
                if let Some(page) = page {
                    print_page(page);
                } else {
                    println!("no back history");
                }
                true
            }),
            "forward" | "f" => browser.forward().map(|page| {
                if let Some(page) = page {
                    print_page(page);
                } else {
                    println!("no forward history");
                }
                true
            }),
            "read" => {
                if let Some(page) = &browser.current {
                    print_reader(page);
                } else {
                    println!("no page is currently open");
                }
                Ok(false)
            }
            "where" | "url" => {
                if let Some(page) = &browser.current {
                    println!("{}", page.url);
                } else {
                    println!("no page is currently open");
                }
                Ok(false)
            }
            "privacy" | "armour" | "armor" | "shields" => {
                if rest.eq_ignore_ascii_case("off") {
                    browser.set_shields_for_current_site(false)?;
                    println!("armour disabled for current site");
                    Ok(true)
                } else if rest.eq_ignore_ascii_case("on") {
                    browser.set_shields_for_current_site(true)?;
                    println!("armour enabled for current site");
                    Ok(true)
                } else {
                    print_privacy(&browser);
                    Ok(false)
                }
            }
            "bookmark" | "mark" => browser.bookmark_current().map(|added| {
                if added {
                    println!("bookmark added");
                } else {
                    println!("bookmark already exists");
                }
                true
            }),
            "bookmarks" | "marks" => {
                for (index, bookmark) in browser.profile.bookmarks.iter().enumerate() {
                    println!(
                        "{}. {} - {}",
                        index + 1,
                        terminal_safe_text(&bookmark.title),
                        bookmark.url
                    );
                }
                Ok(false)
            }
            "history" => {
                for entry in browser.profile.history.iter().rev().take(25) {
                    println!(
                        "{} - {} - {}",
                        entry.visited_at.format("%Y-%m-%d %H:%M"),
                        terminal_safe_text(&entry.title),
                        entry.url
                    );
                }
                Ok(false)
            }
            "diagnostics" | "diag" | "memory" => {
                print_diagnostics(&browser);
                Ok(false)
            }
            "forget" | "shred" => browser.forget_current_site().map(|removed| {
                println!("removed {removed} history entries for current site");
                true
            }),
            "save" => {
                store.save(&browser.profile)?;
                println!("saved profile to {}", store.path().display());
                Ok(false)
            }
            "help" | "?" => {
                print_help();
                Ok(false)
            }
            "quit" | "q" | "exit" => {
                store.save(&browser.profile)?;
                return Ok(());
            }
            _ => browser.open(line).map(|page| {
                print_page(page);
                true
            }),
        };

        match result {
            Ok(should_save) => {
                if should_save {
                    store.save(&browser.profile)?;
                }
            }
            Err(err) => eprintln!("{err:#}"),
        }
    }
}

pub fn print_page(page: &LoadedPage) {
    println!();
    println!("{}", style_text("Page", "1;34", terminal_colors_enabled()));
    println!("  {}", terminal_safe_text(&page.article.title));
    println!("  {} [{}]", page.url, page.status);

    if page.privacy.has_actions() {
        print_report(page);
    }

    println!();
    print_reader(page);
}

pub fn print_reader(page: &LoadedPage) {
    let options = Options::new(100).break_words(false);
    if let Some(excerpt) = &page.article.excerpt {
        println!("{}", fill(&terminal_safe_text(excerpt), &options));
        println!();
    }

    let text = if page.article.text.is_empty() {
        "No readable article text found for this page.".to_string()
    } else {
        terminal_safe_text(&page.article.text)
    };

    for paragraph in text
        .split('\n')
        .filter(|line| !line.trim().is_empty())
        .take(30)
    {
        println!("{}", fill(paragraph, &options));
        println!();
    }
}

fn print_report(page: &LoadedPage) {
    let report = &page.privacy;
    println!(
        "{}",
        style_text("Privacy report", "1;35", terminal_colors_enabled())
    );
    if report.upgraded_to_https {
        println!("  + upgraded to HTTPS");
    }
    if !report.stripped_query_params.is_empty() {
        println!(
            "  + stripped query params: {}",
            terminal_safe_text(&report.stripped_query_params.join(", "))
        );
    }
    if let Some(target) = &report.debounced_redirect {
        println!("  + bypassed redirector to {}", terminal_safe_text(target));
    }
    if let Some(target) = &report.de_amped_to {
        println!("  + redirected AMP page to {}", terminal_safe_text(target));
    }
    if !report.blocked_requests.is_empty() {
        println!(
            "  + blocked {} tracker-like subresources",
            report.blocked_requests.len()
        );
    }
}

fn print_privacy(browser: &Browser) {
    let shields = &browser.profile.shields;
    println!(
        "{}",
        style_text("Armour", "1;35", terminal_colors_enabled())
    );
    println!("  armour: {}", if shields.enabled { "on" } else { "off" });
    println!("  https upgrade: {}", on_off(shields.https_upgrade));
    println!(
        "  global privacy control: {}",
        on_off(shields.global_privacy_control)
    );
    println!("  do not track: {}", on_off(shields.do_not_track));
    println!(
        "  query stripping: {}",
        on_off(shields.strip_tracking_queries)
    );
    println!(
        "  redirect debouncing: {}",
        on_off(shields.debounce_redirects)
    );
    println!("  tracker blocking: {}", on_off(shields.block_trackers));
    println!("  de-amp: {}", on_off(shields.de_amp));

    if let Some(page) = &browser.current {
        println!(
            "  current site protected: {}",
            on_off(shields.is_enabled_for(&page.url))
        );
        print_report(page);
    }
}

fn print_diagnostics(browser: &Browser) {
    let diagnostics = browser.diagnostics();
    println!(
        "{}",
        style_text("Diagnostics", "1;34", terminal_colors_enabled())
    );
    println!(
        "  navigation      back {} / forward {} / max {}",
        diagnostics.back_stack_entries,
        diagnostics.forward_stack_entries,
        diagnostics.max_navigation_stack
    );
    println!(
        "  history         {} / {}",
        diagnostics.history_entries, diagnostics.max_history_entries
    );
    println!(
        "  bookmarks       {} / {}",
        diagnostics.bookmark_entries, diagnostics.max_bookmark_entries
    );
    println!(
        "  page body bytes {} / {}",
        diagnostics.current_body_bytes, diagnostics.max_page_body_bytes
    );
}

fn print_banner(profile_path: &str) {
    let colors = terminal_colors_enabled();
    println!("{}", style_text("Cephas", "1;36", colors));
    println!("  privacy-first Rust browser");
    println!("  profile: {profile_path}");
    println!("  type help for commands; type any URL or search directly to browse");
}

fn print_help() {
    println!(
        "{}",
        style_text("Commands", "1;34", terminal_colors_enabled())
    );
    for (command, description) in [
        ("open <url/search>", "open a URL or search terms"),
        ("back | forward", "navigate history stack"),
        ("reload", "reload current page"),
        ("read", "show reader-mode text again"),
        ("privacy", "show armour report"),
        ("armour on/off", "enable or disable armour for current site"),
        ("bookmark", "bookmark current page"),
        ("bookmarks", "list bookmarks"),
        ("history", "list recent history"),
        ("diagnostics", "show bounded resource counts"),
        ("forget", "delete history for current site"),
        ("save", "save profile"),
        ("quit", "save and exit"),
    ] {
        println!("  {command:<18} {description}");
    }
}

fn require_arg<'a>(value: &'a str, usage: &str) -> anyhow::Result<&'a str> {
    if value.is_empty() {
        anyhow::bail!("usage: {usage}");
    }
    Ok(value)
}

fn on_off(value: bool) -> &'static str {
    if value { "on" } else { "off" }
}

fn terminal_colors_enabled() -> bool {
    std::env::var_os("NO_COLOR").is_none()
        && std::env::var("TERM").map_or(true, |term| term != "dumb")
}

fn style_text(text: &str, code: &str, enabled: bool) -> String {
    if enabled {
        format!("\x1b[{code}m{text}\x1b[0m")
    } else {
        text.to_string()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn style_text_respects_color_flag() {
        assert_eq!(style_text("Cephas", "1;36", false), "Cephas");
        assert_eq!(
            style_text("Cephas", "1;36", true),
            "\x1b[1;36mCephas\x1b[0m"
        );
    }
}