i5-add-tests (part 3) (#9)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

Reviewed-on: #9
Co-authored-by: Paul Campbell <pcampbell@kemitix.net>
Co-committed-by: Paul Campbell <pcampbell@kemitix.net>
This commit is contained in:
Paul Campbell 2023-07-29 20:36:03 +01:00 committed by Paul Campbell
parent 9057adddc3
commit 83081a2051
20 changed files with 182 additions and 148 deletions

6
.gitignore vendored
View file

@ -1,5 +1,7 @@
target target
*.mp3 *.mp3
*.webm *.webm
/subscriptions.txt subscriptions.txt
/downloaded.txt downloaded.txt
coverage
*.profraw

View file

@ -1,6 +1,7 @@
pipeline: steps:
build: build:
image: rust image: rust:latest
pull: true
commands: commands:
- rustup component add rustfmt clippy - rustup component add rustfmt clippy
- cargo --version - cargo --version

1
Cargo.lock generated
View file

@ -1014,6 +1014,7 @@ name = "podal"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"atom_syndication", "atom_syndication",
"bytes",
"clap", "clap",
"reqwest", "reqwest",
"scraper", "scraper",

View file

@ -10,6 +10,7 @@ atom_syndication = "0.12.1"
reqwest = { version = "0.11.18", features = ["json", "blocking"] } reqwest = { version = "0.11.18", features = ["json", "blocking"] }
scraper = "0.17.1" scraper = "0.17.1"
clap = "4.3.19" clap = "4.3.19"
bytes = "1.4.0"
[dev-dependencies] [dev-dependencies]
tempfile = "*" tempfile = "*"

23
justfile Normal file
View file

@ -0,0 +1,23 @@
coverage-init:
cargo install grcov
rustup component add llvm-tools
coverage:
#!/usr/bin/env bash
set -e
rm -rf ./target
just clean
export RUSTFLAGS="-Zinstrument-coverage" LLVM_PROFILE_FILE="just-%p-%m.profraw"
cargo +nightly build
cargo +nightly test
just generate
just server
generate:
grcov . --binary-path ./target/debug/ -s . -t html --branch --ignore-not-existing --ignore "./target/" -o ./coverage/
server:
cd ./coverage && python3 -m http.server 8001
clean:
rm -rf ./*.prof* ./coverage/

View file

@ -1,12 +1,14 @@
use std::{fmt::Display, str::Utf8Error, string::FromUtf8Error}; use std::{str::Utf8Error, string::FromUtf8Error};
#[derive(Debug)] #[derive(Debug)]
pub struct Error { pub struct Error {
details: String, pub details: String,
} }
impl Display for Error { impl Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { pub fn message(details: &str) -> Self {
f.write_str(self.details.to_string().as_str()) Self {
details: details.to_string(),
}
} }
} }
impl From<Utf8Error> for Error { impl From<Utf8Error> for Error {

View file

@ -1,15 +1,14 @@
use crate::network::NetworkEnv;
use crate::prelude::*; use crate::prelude::*;
use crate::fetch::FetchGet; pub fn find(site: &str, channel_name: &str, e: &NetworkEnv) -> Result<String> {
pub fn find(site: &str, channel_name: &str, e: &FetchGet) -> Result<String> {
if let Some(channel_prefix) = channel_name.chars().next() { if let Some(channel_prefix) = channel_name.chars().next() {
if channel_prefix != '@' { if channel_prefix != '@' {
return Err(format!("Channel Name must begin with an '@': {}", channel_name).into()); return Err(format!("Channel Name must begin with an '@': {}", channel_name).into());
} }
} }
let channel_url = format!("{}{}", site, channel_name); let channel_url = format!("{}{}", site, channel_name);
let response = (e)(&channel_url)?; let response = (e.fetch_as_text)(&channel_url)?;
let rss_url = scraper::Html::parse_document(&response) let rss_url = scraper::Html::parse_document(&response)
.select(&scraper::Selector::parse("link[title='RSS']").unwrap()) .select(&scraper::Selector::parse("link[title='RSS']").unwrap())
.next() .next()
@ -23,15 +22,20 @@ pub fn find(site: &str, channel_name: &str, e: &FetchGet) -> Result<String> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::fetch::Response;
use crate::errors::Error;
use super::*; use super::*;
#[test] #[test]
fn finds_rss_url() -> Result<()> { fn finds_rss_url() -> Result<()> {
//given //given
let fetch_get = &(get as FetchGet); let network_env = NetworkEnv {
fetch_as_text: dummy_fetch_as_text,
fetch_as_bytes: dummy_fetch_as_bytes,
download_as_mp3: dummy_download_as_mp3,
};
//when //when
let result = find("site", "@channel", fetch_get)?; let result = find("site", "@channel", &network_env)?;
//then //then
assert_eq!(result, "the-rss-url"); assert_eq!(result, "the-rss-url");
Ok(()) Ok(())
@ -40,15 +44,19 @@ mod tests {
#[test] #[test]
fn error_if_channel_name_is_invalid() -> Result<()> { fn error_if_channel_name_is_invalid() -> Result<()> {
//given //given
let fetch_get = &(get as FetchGet); let network_env = NetworkEnv {
fetch_as_text: dummy_fetch_as_text,
fetch_as_bytes: dummy_fetch_as_bytes,
download_as_mp3: dummy_download_as_mp3,
};
//when //when
let result = find("site", "invalid-channel-name", fetch_get); let result = find("site", "invalid-channel-name", &network_env);
//then //then
assert!(result.is_err()); assert!(result.is_err());
Ok(()) Ok(())
} }
fn get(_url: &str) -> Result<Response> { fn dummy_fetch_as_text(_url: &str) -> Result<String> {
Ok(r#" Ok(r#"
<html> <html>
<link title="RSS" href="the-rss-url"> <link title="RSS" href="the-rss-url">
@ -56,4 +64,10 @@ mod tests {
"# "#
.to_string()) .to_string())
} }
fn dummy_fetch_as_bytes(_url: &str) -> Result<bytes::Bytes> {
Err(Error::message("Not implemented"))
}
fn dummy_download_as_mp3(_url: &str) -> Result<()> {
Err(Error::message("Not implemented"))
}
} }

View file

@ -1,9 +0,0 @@
use crate::prelude::*;
use atom_syndication::Feed;
pub fn reqwest_blocking_get(url: &str) -> Result<Feed> {
let content = reqwest::blocking::get(url)?.bytes()?;
let channel = Feed::read_from(&content[..])?;
Ok(channel)
}

View file

@ -1,18 +1,14 @@
use crate::prelude::*; use crate::prelude::*;
use crate::fetch::FetchGet; use crate::network::NetworkEnv;
use atom_syndication::Feed;
mod find; mod find;
mod get;
use atom_syndication::Feed;
pub use find::find; pub use find::find;
pub use get::reqwest_blocking_get;
pub struct FeedEnv { pub fn get(url: &str, e: &NetworkEnv) -> Result<Feed> {
pub find: FeedFind, let content = (e.fetch_as_bytes)(url)?;
pub get: FeedGet, let channel = Feed::read_from(&content[..])?;
Ok(channel)
} }
pub type FeedFind = fn(&str, &str, &FetchGet) -> Result<String>;
pub type FeedGet = fn(&str) -> Result<Feed>;

View file

@ -1,34 +0,0 @@
use crate::prelude::*;
use atom_syndication::Link;
use std::process::Command;
pub struct FetchEnv {
pub download: FetchDownload,
pub get: FetchGet,
}
pub type FetchDownload = fn(&Link) -> Result<()>;
pub type FetchGet = fn(&str) -> Result<Response>;
pub fn download(link: &Link) -> Result<()> {
let cmd = "yt-dlp";
// println!("{} --extract-audio --audio-format mp3 {}", cmd, &link.href);
let output = Command::new(cmd)
.arg("--extract-audio")
.arg("--audio-format")
.arg("mp3")
.arg(&link.href)
.output()?;
if !output.stderr.is_empty() {
eprintln!("Error: {}", String::from_utf8(output.stderr)?);
println!("{}", String::from_utf8(output.stdout)?);
}
Ok(())
}
pub type Response = String;
pub fn get(url: &str) -> Result<Response> {
Ok(reqwest::blocking::get(url)?.text()?)
}

26
src/file/env.rs Normal file
View file

@ -0,0 +1,26 @@
use std::fs::{File, OpenOptions};
use std::io::Write;
pub struct FileEnv {
pub open: FileOpen,
pub append_line: FileAppendLine,
}
impl Default for FileEnv {
fn default() -> Self {
Self {
open: |path| File::open(path),
append_line: |file_name, line| {
let mut file = OpenOptions::new()
.write(true)
.append(true)
.create(true)
.open(file_name)
.unwrap();
writeln!(file, "{}", line)?;
Ok(())
},
}
}
}
pub type FileOpen = fn(path: &str) -> std::io::Result<File>;
pub type FileAppendLine = fn(paht: &str, line: &str) -> std::io::Result<()>;

4
src/file/mod.rs Normal file
View file

@ -0,0 +1,4 @@
mod env;
pub mod read;
pub use env::FileEnv;

View file

@ -1,10 +1,10 @@
use crate::prelude::*; use crate::prelude::*;
use std::fs::File; use crate::file::FileEnv;
use std::io::{BufRead, BufReader}; use std::io::{BufRead, BufReader};
pub fn lines_from(file_name: &str) -> Result<Vec<String>> { pub fn lines_from(file_name: &str, e: &FileEnv) -> Result<Vec<String>> {
let file = File::open(file_name)?; let file = (e.open)(file_name)?;
let reader = BufReader::new(file); let reader = BufReader::new(file);
let mut lines = vec![]; let mut lines = vec![];
for line in reader.lines().flatten() { for line in reader.lines().flatten() {
@ -27,11 +27,12 @@ mod tests {
//given //given
let (dir, file_name) = create_text_file( let (dir, file_name) = create_text_file(
"subscriptions.txt", "subscriptions.txt",
include_bytes!("../test/data/subscriptions.txt"), include_bytes!("../../test/data/subscriptions.txt"),
)?; )?;
let file_env = FileEnv::default();
//when //when
let result = lines_from(&file_name)?; let result = lines_from(&file_name, &file_env)?;
//then //then
drop(dir); drop(dir);
@ -44,11 +45,12 @@ mod tests {
//given //given
let (dir, file_name) = create_text_file( let (dir, file_name) = create_text_file(
"subscriptions.txt", "subscriptions.txt",
include_bytes!("../test/data/subscriptions-blank-line.txt"), include_bytes!("../../test/data/subscriptions-blank-line.txt"),
)?; )?;
let file_env = FileEnv::default();
//when //when
let result = lines_from(&file_name)?; let result = lines_from(&file_name, &file_env)?;
//then //then
drop(dir); drop(dir);
@ -61,11 +63,12 @@ mod tests {
//given //given
let (dir, file_name) = create_text_file( let (dir, file_name) = create_text_file(
"subscriptions.txt", "subscriptions.txt",
include_bytes!("../test/data/subscriptions-comment.txt"), include_bytes!("../../test/data/subscriptions-comment.txt"),
)?; )?;
let file_env = FileEnv::default();
//when //when
let result = lines_from(&file_name)?; let result = lines_from(&file_name, &file_env)?;
//then //then
drop(dir); drop(dir);

View file

@ -1,18 +1,10 @@
use crate::file::FileEnv;
use crate::prelude::*; use crate::prelude::*;
use atom_syndication::Link; use super::Link;
use std::fs::OpenOptions;
use std::io::prelude::*;
pub fn add(link: &Link, file_name: &str) -> Result<()> { pub fn add(link: &Link, file_name: &str, e: &FileEnv) -> Result<()> {
let mut file = OpenOptions::new() (e.append_line)(file_name, &link.href)?;
.write(true)
.append(true)
.create(true)
.open(file_name)
.unwrap();
writeln!(file, "{}", link.href)?;
Ok(()) Ok(())
} }
@ -42,7 +34,7 @@ mod tests {
length: None, length: None,
}; };
//when //when
add(&link, &file_name)?; add(&link, &file_name, &FileEnv::default())?;
//then //then
let content: Vec<String> = read_text_file(&file_name)?; let content: Vec<String> = read_text_file(&file_name)?;
@ -71,7 +63,7 @@ mod tests {
length: None, length: None,
}; };
//when //when
add(&link, &file_name)?; add(&link, &file_name, &FileEnv::default())?;
//then //then
let content: Vec<String> = read_text_file(&file_name)?; let content: Vec<String> = read_text_file(&file_name)?;

View file

@ -1,11 +1,11 @@
use crate::prelude::*; use crate::{file::FileEnv, prelude::*};
use atom_syndication::Link;
use std::fs::File;
use std::io::{BufRead, BufReader}; use std::io::{BufRead, BufReader};
pub fn find(link: &Link, file_name: &str) -> Result<bool> { use super::Link;
if let Ok(file) = File::open(file_name) {
pub fn find(link: &Link, file_name: &str, e: &FileEnv) -> Result<bool> {
if let Ok(file) = (e.open)(file_name) {
let reader = BufReader::new(file); let reader = BufReader::new(file);
for line in reader.lines() { for line in reader.lines() {
if line? == link.href { if line? == link.href {
@ -34,14 +34,13 @@ mod test {
title: None, title: None,
length: None, length: None,
}; };
//when //when
let result = find(&link, &file_name)?; let result = find(&link, &file_name, &FileEnv::default())?;
//then //then
drop(dir); drop(dir);
assert_eq!(result, true); assert!(result);
Ok(()) Ok(())
} }
@ -61,12 +60,12 @@ mod test {
}; };
//when //when
let result = find(&link, &file_name)?; let result = find(&link, &file_name, &FileEnv::default())?;
//then //then
drop(dir); drop(dir);
assert_eq!(result, false); assert!(!result);
Ok(()) Ok(())
} }
@ -88,12 +87,12 @@ mod test {
}; };
//when //when
let result = find(&link, &file_name)?; let result = find(&link, &file_name, &FileEnv::default())?;
//then //then
drop(dir); drop(dir);
assert_eq!(result, false); assert!(!result);
Ok(()) Ok(())
} }

View file

@ -1,17 +1,7 @@
use crate::prelude::*;
mod add; mod add;
mod find; mod find;
pub use add::add; pub use add::add;
pub use find::find; pub use find::find;
type Link = atom_syndication::Link; pub type Link = atom_syndication::Link;
pub struct HistoryEnv {
pub find: HistoryFind,
pub add: HistoryAdd,
}
pub type HistoryFind = fn(&Link, &str) -> Result<bool>;
pub type HistoryAdd = fn(&Link, &str) -> Result<()>;

View file

@ -1,34 +1,33 @@
use prelude::*;
mod errors; mod errors;
pub mod feed; pub mod feed;
pub mod fetch; pub mod file;
pub mod history; pub mod history;
pub mod network;
pub mod prelude; pub mod prelude;
mod subscriptions;
#[cfg(test)] #[cfg(test)]
mod test_utils; mod test_utils;
use feed::FeedEnv; use file::FileEnv;
use fetch::FetchEnv; use network::NetworkEnv;
use history::HistoryEnv;
use prelude::*;
pub struct Env { pub struct Env {
pub feed: FeedEnv, pub network: NetworkEnv,
pub history: HistoryEnv, pub file: FileEnv,
pub fetch: FetchEnv,
} }
pub fn run(subscriptions: &str, history: &str, site: &str, e: Env) -> Result<()> { pub fn run(subscriptions: &str, history: &str, site: &str, e: Env) -> Result<()> {
for channel_name in subscriptions::lines_from(subscriptions)? { for channel_name in file::read::lines_from(subscriptions, &e.file)? {
println!("Channel: {}", channel_name); println!("Channel: {}", channel_name);
let feed_url = (e.feed.find)(site, &channel_name, &e.fetch.get)?; let feed_url = feed::find(site, &channel_name, &e.network)?;
for entry in (e.feed.get)(&feed_url)?.entries() { for entry in feed::get(&feed_url, &e.network)?.entries() {
if let Some(link) = entry.links().get(0).cloned() { if let Some(link) = entry.links().get(0).cloned() {
if !(e.history.find)(&link, history)? { if !history::find(&link, history, &e.file)? {
println!("Downloading {}: {}", &channel_name, entry.title().as_str()); println!("Downloading {}: {}", &channel_name, entry.title().as_str());
(e.fetch.download)(&link)?; (e.network.download_as_mp3)(&link.href)?;
(e.history.add)(&link, history)?; history::add(&link, history, &e.file)?;
} }
} }
} }

View file

@ -1,7 +1,7 @@
use podal::file::FileEnv;
use podal::network::NetworkEnv;
use podal::prelude::*; use podal::prelude::*;
use podal::{feed::FeedEnv, fetch::FetchEnv, history::HistoryEnv};
fn main() -> Result<()> { fn main() -> Result<()> {
println!("Podal"); println!("Podal");
let subscriptions = "subscriptions.txt"; let subscriptions = "subscriptions.txt";
@ -13,19 +13,8 @@ fn main() -> Result<()> {
history, history,
site, site,
podal::Env { podal::Env {
feed: FeedEnv { network: NetworkEnv::default(),
find: podal::feed::find, file: FileEnv::default(),
get: podal::feed::reqwest_blocking_get,
},
history: HistoryEnv {
find: podal::history::find,
add: podal::history::add,
},
fetch: FetchEnv {
download: podal::fetch::download,
get: podal::fetch::get,
},
}, },
)?; )?;

32
src/network/env.rs Normal file
View file

@ -0,0 +1,32 @@
use std::process::Command;
use crate::prelude::*;
pub struct NetworkEnv {
pub fetch_as_text: fn(url: &str) -> Result<String>,
pub fetch_as_bytes: fn(url: &str) -> Result<bytes::Bytes>,
pub download_as_mp3: fn(url: &str) -> Result<()>,
}
impl Default for NetworkEnv {
fn default() -> Self {
Self {
fetch_as_text: |url| Ok(reqwest::blocking::get(url)?.text()?),
fetch_as_bytes: |url| Ok(reqwest::blocking::get(url)?.bytes()?),
download_as_mp3: |url| {
let cmd = "yt-dlp";
// println!("{} --extract-audio --audio-format mp3 {}", cmd, &link.href);
let output = Command::new(cmd)
.arg("--extract-audio")
.arg("--audio-format")
.arg("mp3")
.arg(url)
.output()?;
if !output.stderr.is_empty() {
eprintln!("Error: {}", String::from_utf8(output.stderr)?);
println!("{}", String::from_utf8(output.stdout)?);
}
Ok(())
},
}
}
}

3
src/network/mod.rs Normal file
View file

@ -0,0 +1,3 @@
mod env;
pub use env::NetworkEnv;