Major refactor: modularisation

CandyCorvid
Jul 13, 2023, 10:56 AM
3VVQWLOXWDG4PFA3GCR4QFRKHHNZGK52KWMPAOSDN734DABSJZVQC

Dependencies

  • [2] XGDABCJN Add match_str macro
  • [3] FZIABKEV New tests, refactor to be more modular, improve prompt ui, add test run parameter
  • [4] 3SPNKI46 Improve parsing. Add modules that were missed
  • [5] AMTMTTJT Upgrade status parsing
  • [6] ONQEIR5B WIP mime-type handling
  • [7] AOJXTWBZ Refactor read_until
  • [8] HVFBGX2A Implement basic text/gemini
  • [9] ARXVO5XA Make connection generic on the network
  • [10] WGRFJRTE Add tests, rename Status to Header, implement redirect and proper input handling
  • [11] JWLMAS7U Simplify tests with rstest
  • [12] DBKKKHC2 Initial commit

Change contents

  • file move: io.rs (----------)utils.rs (----------)
    [4.15]
    [4.9554]
  • replacement in src/response.rs at line 159
    [4.6210][4.6210:6277]()
    None => Err(format!("bad status code: {:?}", status)),
    [4.6210]
    [4.6277]
    None => Err(format!("bad status code: [{:?},{:?}]", status, sub)),
  • file move: server.rs (----------)network.rs (----------)
    [4.15]
    [4.34]
  • replacement in src/media.rs at line 1
    [4.459][4.460:492]()
    use std::str::pattern::Pattern;
    [4.459]
    [4.506]
    use std::{borrow::Cow, fmt::Display, str::pattern::Pattern};
  • replacement in src/media.rs at line 11
    [4.875][4.875:901]()
    lines: Vec<Line<'a>>,
    [4.875]
    [4.901]
    pub lines: Vec<Line<'a>>,
  • edit in src/media.rs at line 17
    [4.1000]
    [4.1000]
    }
    #[derive(Clone, Debug)]
    pub enum HeadingLevel {
    L1 = 1,
    L2 = 2,
    L3 = 3,
  • replacement in src/media.rs at line 27
    [4.1060][4.1060:1079]()
    level: u8,
    [4.1060]
    [4.1079]
    level: HeadingLevel,
  • replacement in src/media.rs at line 41
    [4.1366][4.1366:1419]()
    alt: TextLine<'a>,
    lines: Vec<TextLine<'a>>,
    [4.1366]
    [4.1419]
    pub alt: TextLine<'a>,
    pub lines: Vec<TextLine<'a>>,
  • replacement in src/media.rs at line 109
    [2.1479][2.1479:1509]()
    level: 1,
    [2.1479]
    [2.1509]
    level: HeadingLevel::L1,
  • replacement in src/media.rs at line 115
    [2.1644][2.1644:1674]()
    level: 2,
    [2.1644]
    [2.1674]
    level: HeadingLevel::L2,
  • replacement in src/media.rs at line 121
    [2.1810][2.1810:1840]()
    level: 3,
    [2.1810]
    [2.1840]
    level: HeadingLevel::L3,
  • edit in src/media.rs at line 167
    [4.4828]
    [4.4828]
    impl<'a> From<TextLine<'a>> for Cow<'a, str> {
    fn from(value: TextLine<'a>) -> Self {
    value.0.into()
    }
    }
    impl<'a> Display for TextLine<'a> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    write!(f, "{}", self.0)
    }
    }
    impl<'a> AsRef<str> for TextLine<'a> {
    fn as_ref(&self) -> &str {
    self.0
    }
    }
  • edit in src/main.rs at line 2
    [4.21][4.0:19](),[4.940][4.0:19](),[4.3665][4.0:19]()
    use bstr::ByteVec;
  • edit in src/main.rs at line 3
    [4.3684][4.0:31]()
    use io::{BufReadExt, ReadExt};
  • replacement in src/main.rs at line 4
    [4.40][3.144:252](),[3.252][4.3845:3859](),[4.1035][4.3845:3859](),[4.3845][4.3845:3859]()
    use server::Network;
    use std::{
    fmt::Display,
    io::{BufReader, Read, Result as ioResult, Write},
    };
    use url::Url;
    [4.40]
    [4.106]
    use std::fmt::Display;
  • edit in src/main.rs at line 6
    [4.107]
    [4.107]
    mod cli;
    mod core;
  • edit in src/main.rs at line 9
    [4.119][4.119:127]()
    mod io;
  • edit in src/main.rs at line 10
    [4.5051]
    [4.179]
    mod network;
  • replacement in src/main.rs at line 12
    [4.193][4.193:215](),[4.215][3.253:327](),[3.327][4.215:237](),[4.215][4.215:237](),[4.237][3.328:416](),[3.416][4.237:280](),[4.237][4.237:280](),[4.280][3.417:476](),[3.476][4.280:319](),[4.280][4.280:319]()
    mod server;
    mod cl {
    use std::{
    fmt::Display,
    io::{stdout, Write},
    };
    use clap::Parser;
    use url::Url;
    use crate::{danger, media::Media, run, server::TcpNetwork, UI};
    #[derive(Parser)]
    pub struct App {
    #[arg(short, long)]
    pub test_prompt: bool,
    pub url: Option<String>,
    }
    [4.193]
    [3.477]
    mod utils;
  • replacement in src/main.rs at line 14
    [3.478][3.478:500]()
    pub enum Error {}
    [3.478]
    [3.500]
    pub trait Ui {
    fn show(&self, content: media::Media);
    fn warn<D: Display>(&self, warning: D);
    fn read(&self, prompt: impl Display) -> Option<String>;
    fn read_secret(&self, prompt: impl Display) -> Option<String>;
    }
    impl Ui for () {
    fn show(&self, _content: media::Media) {}
  • replacement in src/main.rs at line 23
    [3.501][3.501:761]()
    impl App {
    pub fn run(self) -> Result<(), Error> {
    let mut cli = Cli {};
    if self.test_prompt {
    let overt = cli.read("tell me something");
    cli.warn(format_args!("your response was: {overt}"));
    [3.501]
    [3.761]
    fn warn<D: Display>(&self, _warning: D) {}
  • replacement in src/main.rs at line 25
    [3.762][3.762:1288]()
    let secret = cli.read_secret("tell me your secrets");
    cli.warn(format_args!("your secret was: {secret}"));
    Ok(())
    } else {
    let url = self.url.unwrap_or_else(|| {
    "gemini://gemini.circumlunar.space/docs/specification.gmi".into()
    });
    let url: Url = url.parse().expect("Not a valid URL");
    run::<TcpNetwork<danger::Naive>>(&mut cli, url);
    Ok(())
    }
    }
    [3.762]
    [3.1288]
    fn read(&self, _prompt: impl Display) -> Option<String> {
    None
  • edit in src/main.rs at line 28
    [3.1294][3.1294:2343]()
    pub struct Cli {}
    impl UI for Cli {
    fn show(&self, content: Media) {
    match content {
    Media::Gemini(g) => println!("{g:#?}"), // TODO
    Media::Text(s) => println!("{s}"),
    }
    }
    fn read(&self, prompt: impl Display) -> String {
    print!("{prompt}: ");
    stdout().flush().unwrap();
    std::io::stdin()
    .lines()
    .next()
    .expect("End of input")
    .expect("Failed to read input")
    }
    fn read_secret(&self, prompt: impl Display) -> String {
    let dim = ansi_term::Style::new().dimmed();
    print!("{prompt}: ");
    let hidden = "(input hidden)";
    let len = hidden.len() as u32;
    let back = ansi_control_codes::control_sequences::CUB(Some(len));
    print!("{}{back}", dim.paint(hidden));
    stdout().flush().unwrap();
    rpassword::read_password().expect("Failed to read input")
    }
  • replacement in src/main.rs at line 29
    [3.2344][3.2344:2456]()
    fn warn<D: std::fmt::Display>(&self, warning: D) {
    println!("Warning: {warning}")
    }
    [3.2344]
    [3.2456]
    fn read_secret(&self, _prompt: impl Display) -> Option<String> {
    None
  • edit in src/main.rs at line 32
    [3.2462][3.2462:2678]()
    }
    pub trait UI {
    fn show(&self, content: media::Media);
    fn warn<D: Display>(&self, warning: D);
    fn read(&self, prompt: impl Display) -> String;
    fn read_secret(&self, prompt: impl Display) -> String;
  • replacement in src/main.rs at line 37
    [4.3936][4.3936:3977]()
    let cli: cl::App = cl::App::parse();
    [4.3936]
    [3.2679]
    let cli: cli::App = cli::App::parse();
  • replacement in src/main.rs at line 39
    [3.2701][3.2701:2737]()
    Ok(()) => println!("done"),
    [3.2701]
    [3.2737]
    Ok(()) => (),
  • edit in src/main.rs at line 41
    [3.2767][3.2767:2933](),[4.1110][4.5223:5252](),[3.2933][4.5223:5252](),[4.5223][4.5223:5252](),[4.5252][4.4121:4124](),[4.4121][4.4121:4124](),[4.4124][3.2934:3000](),[3.3000][4.1159:1211](),[4.1159][4.1159:1211](),[4.1211][4.5291:5337](),[4.5291][4.5291:5337](),[4.5337][4.1212:1279](),[4.383][4.4244:4245](),[4.1279][4.4244:4245](),[4.4244][4.4244:4245](),[4.4245][4.7020:7090](),[4.7090][4.4281:4396](),[4.4281][4.4281:4396](),[4.4396][4.5338:5339](),[4.5339][3.3001:3051](),[3.3051][4.5385:5387](),[4.5385][4.5385:5387](),[4.5387][4.4396:4397](),[4.4396][4.4396:4397](),[4.4397][4.5388:5415](),[4.5415][3.3052:3074](),[3.3074][4.5415:5443](),[4.5415][4.5415:5443](),[4.5443][3.3075:3112](),[3.3112][4.5488:5507](),[4.5488][4.5488:5507](),[4.5507][4.7091:7108](),[4.4397][4.7091:7108](),[4.7108][3.3113:3178](),[3.3178][4.5817:5859](),[4.7593][4.5817:5859](),[4.5859][3.3179:3223](),[4.197][4.7888:7905](),[3.3223][4.7888:7905](),[4.7888][4.7888:7905](),[4.8328][4.8328:8338](),[4.8338][3.3224:3296](),[3.3296][4.5949:5992](),[4.8338][4.5949:5992](),[4.5992][3.3297:3335](),[3.3335][4.8543:8570](),[4.8543][4.8543:8570](),[4.8570][4.5993:6036](),[4.6036][3.3336:3374](),[3.3374][4.8757:8784](),[4.8757][4.8757:8784](),[4.8784][4.6037:6084](),[4.6084][3.3375:3417](),[3.3417][4.8982:8999](),[4.8982][4.8982:8999](),[4.8999][3.3418:4748](),[3.4748][4.8999:9009](),[4.8999][4.8999:9009](),[4.9009][3.4749:5527]()
    }
    }
    fn run<N: Network>(ui: &mut impl UI, mut url: Url)
    where
    <N as Network>::Error: std::fmt::Debug,
    {
    while let Some(new_url) = run_url::<N>(ui, url) {
    url = new_url;
    }
    }
    fn run_url<N: Network>(ui: &mut impl UI, url: Url) -> Option<Url>
    where
    <N as Network>::Error: std::fmt::Debug,
    {
    // must make a new connection per request
    let mut stream = N::connect(&url).expect("Failed to connect");
    send_request(&mut stream, &url).expect("request failed to send");
    let head = read_response_header(&mut stream).expect("failed to read header");
    println!("head: {head:?}");
    handle_response_header(ui, head, url, stream)
    }
    fn handle_response_header(
    ui: &mut impl UI,
    head: response::Header,
    url: Url,
    stream: impl Read,
    ) -> Option<Url> {
    match head {
    response::Header::Input(s) => handle::input(s, ui, url),
    response::Header::Success(s) => {
    handle::success(s, stream, ui);
    None
    }
    response::Header::Redirect(s) => Some(handle::redirect(s, ui)),
    response::Header::FailTemp(s) => {
    handle::fail_temp(s, ui);
    None
    }
    response::Header::FailPerm(s) => {
    handle::fail_perm(s, ui);
    None
    }
    response::Header::CertRequired(s) => {
    handle::cert_required(s, ui);
    None
    }
    }
    }
    mod handle {
    use crate::{
    decode_media,
    io::ReadExt,
    response::header::{CertRequired, FailPerm, FailTemp, Input, Redirect, Success},
    UI,
    };
    use std::io::Read;
    use url::Url;
    pub fn cert_required(s: CertRequired, ui: &mut impl UI) {
    let CertRequired { message, typ } = s;
    ui.warn(format_args!(
    "Certificate required: {typ:?}! Details:\n{message}"
    ));
    }
    pub fn fail_perm(s: FailPerm, ui: &mut impl UI) {
    let FailPerm { message, typ } = s;
    ui.warn(format_args!(
    "Permanent failure: {typ:?}! Details:\n{message}"
    ));
    }
    pub fn fail_temp(s: FailTemp, ui: &mut impl UI) {
    let FailTemp { message, typ } = s;
    ui.warn(format_args!(
    "Temporary failure: {typ:?}! Please try again. Details:\n{message}"
    ));
    }
    pub fn redirect(s: Redirect, ui: &mut impl UI) -> Url {
    let Redirect { url, temporary } = s;
    if let Some(false) = temporary {
    ui.warn(format_args!(
    "Server has permanently moved. Redirecting to: {url}\nPlease update your records!"
    ));
    } else {
    ui.warn(format_args!(
    "Server has temporarily moved. Redirecting to: {url}"
    ));
    }
    url
    }
    pub fn success(s: Success, mut stream: impl Read, ui: &mut impl UI) {
    let Success { mime } = s;
    let body = stream.read_string().expect("failed to read body");
    //TODO remove this debug output
    println!("page is encoded as {mime:?}");
    println!("response body: {body}");
    let media = decode_media(mime, &body);
    ui.show(media);
    }
    pub fn input(s: Input, ui: &mut impl UI, mut url: Url) -> Option<Url> {
    let Input { prompt, sensitive } = s;
    let input = if let Some(true) = sensitive {
    ui.read_secret(&prompt)
    } else {
    ui.read(&prompt)
    };
    let input = urlencoding::encode(&input);
    url.set_query(Some(&input));
    Some(url)
  • edit in src/main.rs at line 50
    [4.857][4.857:860](),[4.860][4.1280:1359](),[4.1359][4.157:274](),[4.6162][4.157:274](),[4.525][4.157:274](),[4.274][4.4768:4769](),[4.652][4.4768:4769](),[4.4768][4.4768:4769](),[4.4769][4.275:308](),[4.308][4.4801:4820](),[4.689][4.4801:4820](),[4.4801][4.4801:4820](),[4.4820][4.9016:9096](),[4.9096][4.4871:4929](),[4.4871][4.4871:4929](),[4.4929][4.690:716](),[4.716][4.4929:4960](),[4.4929][4.4929:4960](),[4.4960][4.1360:1420](),[4.375][4.717:782](),[4.1420][4.717:782](),[4.5032][4.717:782](),[4.909][4.5056:5057](),[4.5056][4.5056:5057](),[4.5057][4.9097:9316](),[4.9316][4.969:971](),[4.969][4.969:971](),[4.971][4.5073:5074](),[4.5073][4.5073:5074](),[4.5636][4.9317:9523]()
    }
    fn read_response_header(mut stream: impl Read) -> ioResult<response::Header> {
    let status = stream
    .read_n::<2>()?
    .map(|s| s.checked_sub(b'0').expect("bad status fragment"));
    let c = stream.read_byte()?;
    if c != b' ' {
    println!("Warning: illegal separator byte '0x{c:02X}' was not a space")
    }
    // don't read 1024, but rather read until CRLF
    // TODO limit to 1024
    let mut meta = Vec::new();
    BufReader::new(stream).read_until(b"\r\n", &mut meta)?;
    // drop last 2
    for _ in 0..2 {
    meta.pop();
    }
    let meta = response::Meta(meta.into_string().expect("Meta was not valid UTF8"));
    let status = response::raw::Header { meta, status }
    .try_into()
    .expect("failed to parse header");
    Ok(status)
    }
    fn send_request(stream: &mut impl Write, url: &Url) -> ioResult<usize> {
    let mut sent = 0;
    sent += stream.write(url.as_str().as_bytes())?;
    sent += stream.write("\r\n".as_bytes())?;
    Ok(sent)
  • replacement in src/main.rs at line 54
    [4.6189][4.0:19]()
    use rstest::*;
    [4.6189]
    [4.6189]
    use rstest::rstest;
  • edit in src/main.rs at line 56
    [4.6207][4.6207:6208](),[4.6208][3.5655:5761](),[3.5761][4.6300:6301](),[4.6300][4.6300:6301](),[4.6301][4.20:34](),[4.34][3.5762:5791](),[3.5791][4.58:111](),[4.58][4.58:111](),[4.111][3.5792:5866](),[3.5866][4.180:188](),[4.180][4.180:188](),[4.188][4.6724:6816](),[4.6724][4.6724:6816](),[4.6816][3.5867:5890](),[3.5890][4.6845:6857](),[4.6845][4.6845:6857](),[4.6857][3.5891:6139](),[3.6139][4.7088:7125](),[4.7088][4.7088:7125](),[4.7125][3.6140:6482]()
    use crate::{cl::Cli, read_response_header, send_request};
    use super::response::{header, Header};
    #[rstest]
    fn test_handle_redirect(
    #[values("http://url.com")] expect_url: Url,
    #[values(None, Some(true), Some(false))] temporary: Option<bool>,
    ) {
    let head = Header::Redirect(header::Redirect {
    url: expect_url.clone(),
    temporary,
    });
    let mut app = Cli {};
    let url = {
    let crate::response::Header::Redirect(s) = head
    else {
    panic!("expected redirect")
    };
    crate::handle::redirect(s, &mut app)
    };
    assert_eq!(url, expect_url);
    }
    #[test]
    fn test_request() {
    let url: Url = "gemini://some/url".try_into().unwrap();
    let req = b"gemini://some/url\r\n";
    let mut net_stream = [0; 1024];
    send_request(&mut net_stream.as_mut_slice(), &url).expect("error sending request");
    assert_eq!(req, &net_stream[..req.len()]);
    }
  • replacement in src/main.rs at line 58
    [3.6497][3.6497:7059]()
    #[case('0', Some(false))]
    #[case('1', Some(true))]
    #[case('3', None)]
    fn test_response_input(#[case] sensitive_code: char, #[case] sensitive: Option<bool>) {
    let exp_head = Header::Input(header::Input {
    prompt: "prompt text".to_string(),
    sensitive,
    });
    let resp_text = format!("1{sensitive_code} prompt text\r\n");
    let resp_head: &[u8] = resp_text.as_bytes();
    let head = read_response_header(&mut &*resp_head).expect("failed to read response");
    assert_eq!(exp_head, head);
    [3.6497]
    [4.7125]
    #[case("gemini://gemini.circumlunar.space")]
    #[case("gemini://gemini.circumlunar.space/docs/gemtext.gmi")]
    fn test_connect(#[case] url: Url) {
    crate::core::run_url::<crate::network::TcpNetwork<crate::danger::Naive>>(
    &mut crate::cli::Cli {},
    url,
    );
  • file addition: core.rs (----------)
    [4.15]
    use crate::network::Network;
    use crate::utils::{BufReadExt, ReadExt};
    use crate::{response, Ui};
    use bstr::ByteVec;
    use std::io::{BufReader, Read, Result as ioResult};
    use url::Url;
    pub fn run<N: Network>(ui: &mut impl Ui, mut url: Url)
    where
    <N as Network>::Error: std::fmt::Debug,
    {
    while let Some(new_url) = run_url::<N>(ui, url) {
    url = new_url;
    }
    }
    pub(crate) fn run_url<N: Network>(ui: &mut impl Ui, url: Url) -> Option<Url>
    where
    <N as Network>::Error: std::fmt::Debug,
    {
    // must make a new connection per request
    let mut stream = N::connect(&url).expect("Failed to connect");
    send_request(&mut stream, &url).expect("request failed to send");
    let head = read_response_header(ui, &mut stream).expect("failed to read header");
    // println!("head: {head:?}");
    handle_response_header(ui, head, url, stream)
    }
    fn handle_response_header(
    ui: &mut impl Ui,
    head: response::Header,
    url: Url,
    stream: impl Read,
    ) -> Option<Url> {
    match head {
    response::Header::Input(s) => handle::input(s, ui, url),
    response::Header::Success(s) => {
    handle::success(s, stream, ui);
    None
    }
    response::Header::Redirect(s) => Some(handle::redirect(s, ui)),
    response::Header::FailTemp(s) => {
    handle::fail_temp(s, ui);
    None
    }
    response::Header::FailPerm(s) => {
    handle::fail_perm(s, ui);
    None
    }
    response::Header::CertRequired(s) => {
    handle::cert_required(s, ui);
    None
    }
    }
    }
    pub mod handle {
    use crate::{
    decode_media,
    response::header::{CertRequired, FailPerm, FailTemp, Input, Redirect, Success},
    utils::ReadExt,
    Ui,
    };
    use std::io::Read;
    use url::Url;
    pub fn cert_required(s: CertRequired, ui: &mut impl Ui) {
    let CertRequired { message, typ } = s;
    ui.warn(format_args!(
    "Certificate required: {typ:?}! Details:\n{message}"
    ));
    }
    pub fn fail_perm(s: FailPerm, ui: &mut impl Ui) {
    let FailPerm { message, typ } = s;
    ui.warn(format_args!(
    "Permanent failure: {typ:?}! Details:\n{message}"
    ));
    }
    pub fn fail_temp(s: FailTemp, ui: &mut impl Ui) {
    let FailTemp { message, typ } = s;
    ui.warn(format_args!(
    "Temporary failure: {typ:?}! Please try again. Details:\n{message}"
    ));
    }
    pub fn redirect(s: Redirect, ui: &mut impl Ui) -> Url {
    let Redirect { url, temporary } = s;
    if let Some(false) = temporary {
    ui.warn(format_args!(
    "Server has permanently moved. Redirecting to: {url}\nPlease update your records!"
    ));
    } else {
    ui.warn(format_args!(
    "Server has temporarily moved. Redirecting to: {url}"
    ));
    }
    url
    }
    pub fn success(s: Success, mut stream: impl Read, ui: &mut impl Ui) {
    let Success { mime } = s;
    let body = stream.read_string().expect("failed to read body");
    //TODO remove this debug output
    // println!("page is encoded as {mime:?}");
    // println!("response body: {body}");
    let media = decode_media(mime, &body);
    ui.show(media);
    }
    pub fn input(s: Input, ui: &mut impl Ui, mut url: Url) -> Option<Url> {
    let Input { prompt, sensitive } = s;
    let input = if let Some(true) = sensitive {
    ui.read_secret(&prompt)
    } else {
    ui.read(&prompt)
    }
    .unwrap();
    let input = urlencoding::encode(&input);
    url.set_query(Some(&input));
    Some(url)
    }
    }
    pub fn read_response_header(ui: &impl Ui, mut stream: impl Read) -> ioResult<response::Header> {
    let status = stream.read_n::<2>()?;
    let c = stream.read_byte()?;
    if c != b' ' {
    ui.warn(format_args!(
    "illegal separator byte '0x{c:02X}' was not a space"
    ));
    }
    // don't read 1024, but rather read until CRLF
    // TODO limit to 1024
    let mut meta = Vec::new();
    BufReader::new(stream).read_until(b"\r\n", &mut meta)?;
    // println!(
    // "status line: \"{}{}{}\"",
    // std::str::from_utf8(&status).unwrap(),
    // std::str::from_utf8(&[c]).unwrap(),
    // std::str::from_utf8(&*meta).unwrap()
    // );
    // turn status from ascii to numeric
    let status = status.map(|s| s.checked_sub(b'0').expect("bad status fragment"));
    // drop last 2
    for _ in 0..2 {
    meta.pop();
    }
    let meta = response::Meta(meta.into_string().expect("Meta was not valid UTF8"));
    let status = response::raw::Header { meta, status }
    .try_into()
    .expect("failed to parse header");
    Ok(status)
    }
    pub fn send_request(stream: &mut impl std::io::Write, url: &Url) -> ioResult<usize> {
    let mut sent = 0;
    sent += stream.write(url.as_str().as_bytes())?;
    sent += stream.write("\r\n".as_bytes())?;
    Ok(sent)
    }
    #[cfg(test)]
    mod tests {
    use rstest::*;
    use url::Url;
    use super::{read_response_header, send_request};
    use crate::response::{header, Header};
    #[rstest]
    fn test_handle_redirect(
    #[values("http://url.com")] expect_url: Url,
    #[values(None, Some(true), Some(false))] temporary: Option<bool>,
    ) {
    let head = Header::Redirect(header::Redirect {
    url: expect_url.clone(),
    temporary,
    });
    let mut app = crate::cli::Cli {};
    let url = {
    let Header::Redirect(s) = head
    else {
    panic!("expected redirect")
    };
    crate::core::handle::redirect(s, &mut app)
    };
    assert_eq!(url, expect_url);
    }
    #[rstest]
    #[case('0', Some(false))]
    #[case('1', Some(true))]
    #[case('3', None)]
    fn test_response_input(#[case] sensitive_code: char, #[case] sensitive: Option<bool>) {
    let exp_head = Header::Input(header::Input {
    prompt: "prompt text".to_string(),
    sensitive,
    });
    let resp_text = format!("1{sensitive_code} prompt text\r\n");
    let resp_head: &[u8] = resp_text.as_bytes();
    let head = read_response_header(&(), &mut &*resp_head).expect("failed to read response");
    assert_eq!(exp_head, head);
    }
    #[test]
    fn test_request() {
    let url: Url = "gemini://some/url".try_into().unwrap();
    let req = b"gemini://some/url\r\n";
    let mut net_stream = [0; 1024];
    send_request(&mut net_stream.as_mut_slice(), &url).expect("error sending request");
    assert_eq!(req, &net_stream[..req.len()]);
    }
    }
  • file addition: cli.rs (----------)
    [4.15]
    use std::{
    fmt::Display,
    io::{stdout, Write},
    };
    use ansi_term::{Colour, Style};
    use clap::Parser;
    use url::Url;
    use crate::{
    core::run,
    danger,
    media::{Gemini, Line, Media, Preformat},
    network::TcpNetwork,
    Ui,
    };
    #[derive(Parser)]
    pub struct App {
    #[arg(short, long)]
    pub test_mode: bool,
    pub url: Option<String>,
    }
    // TODO proper error handling
    pub enum Error {}
    impl App {
    pub fn run(self) -> Result<(), Error> {
    let mut cli = Cli {};
    if self.test_mode {
    test_run(&cli);
    Ok(())
    } else {
    let url = self.url.unwrap_or_else(|| {
    "gemini://gemini.circumlunar.space/docs/specification.gmi".into()
    });
    let url: Url = url.parse().expect("Not a valid URL");
    run::<TcpNetwork<danger::Naive>>(&mut cli, url);
    Ok(())
    }
    }
    }
    fn test_run(cli: &Cli) {
    let text: Gemini = "plain line
    # heading1
    ## heading 2
    ### heading 3
    > quote 1
    > quote 2
    > quote 3
    line
    line 2
    line 3
    * list 1
    * list 2
    * list 3
    => gemini://url?query description
    ```rs
    some rust code here
    match true {
    true
    => false,
    false
    => true,
    }
    ```
    # heading
    > quote
    line
    * list
    => gemini://url description
    ordinary line"
    .into();
    let secret = cli.read_secret("tell me your secrets").unwrap();
    cli.warn(format_args!("your secret was: {secret}"));
    let overt = cli
    .read("tell me something that isn't secret, since I can't be trusted with secrets")
    .unwrap();
    cli.warn(format_args!("your response was: {overt}"));
    cli.render_gemini(text);
    }
    pub struct Cli {}
    impl Cli {
    fn render_gemini(&self, g: Gemini) {
    // println!("{:#?}", g)
    let Gemini { lines } = g;
    for line in lines {
    match line {
    Line::Heading { level, title } => {
    let title = title.as_ref();
    let char = match level {
    crate::media::HeadingLevel::L1 => '=',
    crate::media::HeadingLevel::L2 => '-',
    crate::media::HeadingLevel::L3 => ' ',
    };
    let under = core::iter::repeat(char)
    .take(title.len())
    .collect::<String>();
    let style = ansi_term::Style::new().bold();
    println!();
    println!("{}", style.paint(&under));
    println!("{}", style.paint(title));
    println!("{}", style.paint(&under));
    }
    Line::Link { url, description } => {
    let blue = Colour::Cyan;
    print!("{}: {}", '🔗', blue.paint(url));
    if let Some(description) = description {
    let italic = ansi_term::Style::new().italic();
    println!(" | {}", italic.paint(description));
    }
    }
    Line::Text(t) => println!("{}", t),
    Line::Preformatted(p) => {
    let Preformat { alt, lines } = p;
    let dim = Style::new().dimmed().italic();
    let bg = Style::new().on(Colour::Black);
    println!("{}", dim.paint(alt));
    for line in lines {
    println!("{}", bg.paint(line));
    }
    println!();
    }
    Line::ListItem(li) => {
    println!(" • {}", li)
    }
    Line::Quote(q) => {
    let italic = Style::new().italic();
    println!(" | {}", italic.paint(q));
    }
    }
    }
    }
    }
    impl Ui for Cli {
    fn show(&self, content: Media) {
    match content {
    Media::Gemini(g) => self.render_gemini(g),
    Media::Text(s) => println!("{s}"),
    }
    }
    fn read(&self, prompt: impl Display) -> Option<String> {
    print!("{prompt}: ");
    stdout().flush().unwrap();
    Some(
    std::io::stdin()
    .lines()
    .next()?
    .expect("Failed to read input"),
    )
    }
    fn read_secret(&self, prompt: impl Display) -> Option<String> {
    let dim = ansi_term::Style::new().dimmed();
    print!("{prompt}: ");
    let hidden = "(input hidden)";
    let len = hidden.len() as u32;
    let back = ansi_control_codes::control_sequences::CUB(Some(len));
    print!("{}{back}", dim.paint(hidden));
    stdout().flush().unwrap();
    match rpassword::read_password() {
    Ok(v) => Some(v),
    Err(e) => match e.kind() {
    std::io::ErrorKind::UnexpectedEof => None,
    _ => panic!("Failed to read input"),
    },
    }
    }
    fn warn<D: std::fmt::Display>(&self, warning: D) {
    let red = Colour::Red;
    println!("{}: {}", red.paint("Warning"), warning)
    }
    }