use std::io::{BufRead, BufReader, Write};
use std::net::TcpListener;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use super::*;
use crate::runtime::{App, Command, Harness};
use crate::widget::View;
struct Registry {
address: String,
asked: Arc<Mutex<Vec<(String, String)>>>,
}
impl Registry {
fn listing(versions: &[(&str, bool)]) -> Self {
let index: String = versions
.iter()
.map(|(version, yanked)| {
format!("{{\"name\":\"quvyta-code\",\"vers\":\"{version}\",\"deps\":[],\"cksum\":\"00\",\"features\":{{}},\"yanked\":{yanked}}}\n")
})
.collect();
let listener = TcpListener::bind("127.0.0.1:0").expect("a loopback port");
let address = format!("http://{}", listener.local_addr().expect("its address"));
let asked = Arc::new(Mutex::new(Vec::new()));
let record = Arc::clone(&asked);
std::thread::spawn(move || {
for stream in listener.incoming() {
let Ok(mut stream) = stream else { continue };
let mut reader = BufReader::new(stream.try_clone().expect("the stream"));
let mut line = String::new();
let _ = reader.read_line(&mut line);
let path = line.split_whitespace().nth(1).unwrap_or_default().to_owned();
let mut agent = String::new();
loop {
let mut header = String::new();
if reader.read_line(&mut header).unwrap_or(0) == 0 || header.trim().is_empty() {
break;
}
if let Some((name, value)) = header.split_once(':')
&& name.eq_ignore_ascii_case("user-agent")
{
agent = value.trim().to_owned();
}
}
if let Ok(mut asked) = record.lock() {
asked.push((path, agent));
}
let _ = write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{index}",
index.len()
);
}
});
Self { address, asked }
}
fn questions(&self) -> usize {
self.asked.lock().map(|asked| asked.len()).unwrap_or(0)
}
}
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("quvyta-update-check-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
dir
}
fn check(dir: &Path, current: &str, registry: &str) -> UpdateCheck<Update> {
UpdateCheck::new(Ecosystem::QUVYTA, "code", "quvyta-code", current, |update| update)
.in_folders(dir.join("config"), dir.join("state"))
.registry(registry)
}
fn morning() -> SystemTime {
UNIX_EPOCH + Duration::from_secs(1_790_000_000)
}
#[test]
fn a_newer_version_is_announced_and_only_the_package_is_asked_about() {
let dir = scratch("newer");
let registry = Registry::listing(&[("0.1.12", false), ("0.1.13", false), ("0.1.14", false)]);
let update = check(&dir, "0.1.13", ®istry.address).ask(morning()).expect("a newer version");
assert_eq!((update.current(), update.latest()), ("0.1.13", "0.1.14"));
let asked = registry.asked.lock().expect("the questions").clone();
assert_eq!(asked, [("/qu/vy/quvyta-code".to_owned(), "quvyta-code/0.1.13".to_owned())], "the name, and no more");
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn the_running_version_or_an_older_one_is_not_announced() {
let dir = scratch("same");
let registry = Registry::listing(&[("0.1.12", false), ("0.1.13", false)]);
assert_eq!(check(&dir, "0.1.13", ®istry.address).ask(morning()), None);
assert_eq!(registry.questions(), 1, "the registry was asked");
std::fs::remove_dir_all(dir).ok();
let dir = scratch("ahead");
assert_eq!(check(&dir, "0.2.0", ®istry.address).ask(morning()), None, "a build ahead of the registry");
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn yanked_versions_and_pre_releases_are_not_announced() {
let dir = scratch("yanked");
let registry = Registry::listing(&[("0.1.13", false), ("0.1.14", true), ("0.2.0-beta.1", false)]);
assert_eq!(check(&dir, "0.1.13", ®istry.address).ask(morning()), None);
assert_eq!(registry.questions(), 1);
std::fs::remove_dir_all(dir).ok();
}
fn announced(name: &str, current: &str, versions: &[(&str, bool)]) -> Option<String> {
let dir = scratch(name);
let registry = Registry::listing(versions);
let found = check(&dir, current, ®istry.address).ask(morning()).map(|update| update.latest().to_owned());
std::fs::remove_dir_all(dir).ok();
found
}
#[test]
fn a_person_on_a_pre_release_hears_of_the_next_one() {
let listing = [("0.1.0-alpha.1", false), ("0.1.0-alpha.2", false)];
assert_eq!(announced("alpha-next", "0.1.0-alpha.1", &listing).as_deref(), Some("0.1.0-alpha.2"));
assert_eq!(announced("alpha-same", "0.1.0-alpha.2", &listing), None, "the one it runs is not news");
}
#[test]
fn pre_release_numbers_count_as_numbers_and_more_parts_are_newer() {
let tens = [("0.1.0-alpha.9", false), ("0.1.0-alpha.10", false)];
assert_eq!(announced("alpha-ten", "0.1.0-alpha.9", &tens).as_deref(), Some("0.1.0-alpha.10"));
let parts = [("0.1.0-alpha", false), ("0.1.0-alpha.1", false)];
assert_eq!(announced("alpha-parts", "0.1.0-alpha", &parts).as_deref(), Some("0.1.0-alpha.1"));
let words = [("0.1.0-alpha.3", false), ("0.1.0-beta", false)];
assert_eq!(announced("alpha-beta", "0.1.0-alpha.3", &words).as_deref(), Some("0.1.0-beta"));
let numeric_first = [("0.1.0-1", false), ("0.1.0-alpha", false)];
assert_eq!(announced("numeric", "0.1.0-1", &numeric_first).as_deref(), Some("0.1.0-alpha"));
}
#[test]
fn a_release_is_newer_than_its_pre_releases_and_wins_over_them() {
let listing = [("0.1.0-alpha.1", false), ("0.1.0-alpha.2", false), ("0.1.0", false), ("0.0.9", false)];
assert_eq!(announced("alpha-release", "0.1.0-alpha.1", &listing).as_deref(), Some("0.1.0"));
let older = [("0.0.9", false)];
assert_eq!(announced("alpha-older", "0.1.0-alpha.1", &older), None, "an older release is not news");
let next_line = [("0.2.0-alpha.1", false)];
assert_eq!(announced("alpha-line", "0.1.0-alpha.1", &next_line).as_deref(), Some("0.2.0-alpha.1"));
}
#[test]
fn a_person_on_a_release_never_hears_of_a_pre_release() {
let listing = [("0.1.0", false), ("0.2.0-alpha.1", false)];
assert_eq!(announced("stable-pre", "0.1.0", &listing), None);
let both = [("0.1.0", false), ("0.1.1", false), ("0.2.0-alpha.1", false)];
assert_eq!(announced("stable-both", "0.1.0", &both).as_deref(), Some("0.1.1"));
}
#[test]
fn a_yanked_pre_release_is_passed_over_and_build_metadata_is_ignored() {
let listing = [("0.1.0-alpha.2", false), ("0.1.0-alpha.3", true)];
assert_eq!(announced("alpha-yanked", "0.1.0-alpha.1", &listing).as_deref(), Some("0.1.0-alpha.2"));
let build = [("0.1.0-alpha.1+abc", false)];
assert_eq!(announced("alpha-build", "0.1.0-alpha.1+xyz", &build), None, "the same version built twice");
let junk = [("0.1.0-alpha..1", false), ("0.1.0-", false)];
assert_eq!(announced("alpha-junk", "0.1.0-alpha.1", &junk), None, "what cannot be read is silence");
}
#[test]
fn without_a_registry_the_check_is_silent_and_waits_for_the_next_day() {
let listener = TcpListener::bind("127.0.0.1:0").expect("a loopback port");
let address = format!("http://{}", listener.local_addr().expect("its address"));
drop(listener);
let dir = scratch("offline");
assert_eq!(check(&dir, "0.1.13", &address).ask(morning()), None, "silence, not an error");
let registry = Registry::listing(&[("0.1.14", false)]);
let later = morning() + Duration::from_secs(60 * 60);
assert_eq!(check(&dir, "0.1.13", ®istry.address).ask(later), None, "the failed question counted");
assert_eq!(registry.questions(), 0);
let tomorrow = morning() + Duration::from_secs(25 * 60 * 60);
assert!(check(&dir, "0.1.13", ®istry.address).ask(tomorrow).is_some(), "the next day asks again");
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn the_registry_is_asked_at_most_once_a_day() {
let dir = scratch("daily");
let registry = Registry::listing(&[("0.1.14", false)]);
assert!(check(&dir, "0.1.13", ®istry.address).ask(morning()).is_some());
for hours in [1, 6, 23] {
let later = morning() + Duration::from_secs(hours * 60 * 60);
assert_eq!(check(&dir, "0.1.13", ®istry.address).ask(later), None, "{hours} hours later");
}
assert_eq!(registry.questions(), 1, "one question in a day, however many starts");
let tomorrow = morning() + Duration::from_secs(24 * 60 * 60);
assert!(check(&dir, "0.1.13", ®istry.address).ask(tomorrow).is_some());
assert_eq!(registry.questions(), 2);
std::fs::remove_dir_all(dir).ok();
let dir = scratch("clock");
assert!(check(&dir, "0.1.13", ®istry.address).ask(tomorrow).is_some());
assert!(check(&dir, "0.1.13", ®istry.address).ask(morning()).is_some(), "a remembered time ahead of now");
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn with_the_ecosystem_s_notice_off_nothing_is_asked_or_written() {
let dir = scratch("off");
Ecosystem::QUVYTA.set_update_notice_in(&dir.join("config"), false).expect("turned off");
let registry = Registry::listing(&[("0.1.14", false)]);
assert_eq!(check(&dir, "0.1.13", ®istry.address).ask(morning()), None);
assert_eq!(registry.questions(), 0, "not a single request");
assert!(!dir.join("state").exists(), "nothing remembered either");
Ecosystem::QUVYTA.set_update_notice_in(&dir.join("config"), true).expect("turned on");
assert!(check(&dir, "0.1.13", ®istry.address).ask(morning()).is_some());
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn the_index_is_read_where_cargo_reads_it() {
assert_eq!(index_address("https://index.crates.io", "a"), "https://index.crates.io/1/a");
assert_eq!(index_address("https://index.crates.io", "ab"), "https://index.crates.io/2/ab");
assert_eq!(index_address("https://index.crates.io", "abc"), "https://index.crates.io/3/a/abc");
assert_eq!(index_address("https://index.crates.io", "Quvyta-Code"), "https://index.crates.io/qu/vy/quvyta-code");
}
struct Code;
#[derive(Debug)]
enum Msg {
NewVersion(Update),
}
impl App for Code {
type Msg = Msg;
fn init(&mut self) -> Command<Msg> {
Command::check_for_update(UpdateCheck::new(Ecosystem::QUVYTA, "code", "quvyta-code", "0.1.13", Msg::NewVersion))
}
fn update(&mut self, msg: Msg) -> Command<Msg> {
match msg {
Msg::NewVersion(update) => Command::toast(update.toast()),
}
}
fn view(&self, _ui: &mut View<'_, Msg>) {}
}
#[test]
fn a_harness_records_the_question_and_shows_the_notice_for_the_version_it_is_told() {
let mut h = Harness::new(Code, 70, 12);
assert_eq!(h.update_checks().len(), 1, "asked once, at start");
assert_eq!((h.update_checks()[0].package(), h.update_checks()[0].current()), ("quvyta-code", "0.1.13"));
assert!(!h.screen().contains("is out"), "no answer, as without a network: {}", h.screen());
h.set_latest_version(Some("0.1.13")).advance(Duration::from_millis(300));
assert!(!h.screen().contains("is out"), "the version it runs: {}", h.screen());
let mut h = Harness::new(Code, 70, 12);
h.set_latest_version(Some("0.1.14")).advance(Duration::from_millis(300));
let screen = h.screen();
assert!(screen.contains("quvyta-code 0.1.14 is out"), "{screen}");
assert!(screen.contains("You have 0.1.13"), "{screen}");
assert!(screen.contains("in quvyta") && screen.contains("cargo install"), "how to update: {screen}");
}
struct Alpha;
impl App for Alpha {
type Msg = Msg;
fn init(&mut self) -> Command<Msg> {
Command::check_for_update(UpdateCheck::new(
Ecosystem::QUVYTA,
"cli",
"quvyta-cli",
"0.1.0-alpha.1",
Msg::NewVersion,
))
}
fn update(&mut self, msg: Msg) -> Command<Msg> {
match msg {
Msg::NewVersion(update) => Command::toast(update.toast()),
}
}
fn view(&self, _ui: &mut View<'_, Msg>) {}
}
#[test]
fn an_application_in_alpha_shows_the_notice_for_the_next_alpha() {
let mut h = Harness::new(Alpha, 70, 12);
h.set_latest_version(Some("0.1.0-alpha.3")).advance(Duration::from_millis(300));
let screen = h.screen();
assert!(screen.contains("quvyta-cli 0.1.0-alpha.3 is out"), "{screen}");
assert!(screen.contains("You have 0.1.0-alpha.1"), "{screen}");
}
struct Waiting {
dir: PathBuf,
registry: String,
}
impl App for Waiting {
type Msg = Msg;
fn init(&mut self) -> Command<Msg> {
let check = UpdateCheck::new(Ecosystem::QUVYTA, "code", "quvyta-code", "0.1.13", Msg::NewVersion)
.in_folders(self.dir.join("config"), self.dir.join("state"))
.registry(self.registry.clone());
Command::check_for_update(check)
}
fn update(&mut self, _msg: Msg) -> Command<Msg> {
Command::none()
}
fn view(&self, ui: &mut View<'_, Msg>) {
ui.add(crate::widgets::Text::new("qcode"));
}
}
#[test]
fn the_first_frame_never_waits_for_the_registry() {
let listener = TcpListener::bind("127.0.0.1:0").expect("a loopback port");
let registry = format!("http://{}", listener.local_addr().expect("its address"));
std::thread::spawn(move || {
let held: Vec<_> = listener.incoming().take(1).collect();
std::thread::sleep(Duration::from_secs(30));
drop(held);
});
let dir = scratch("waiting");
let app = Waiting { dir: dir.clone(), registry };
let mut engine =
super::super::engine::Engine::new(app, crate::env::Env::builtin(), super::super::engine::TaskMode::Threads);
let mut buffer = ratatui_core::buffer::Buffer::empty(ratatui_core::layout::Rect::new(0, 0, 20, 2));
let started = std::time::Instant::now();
engine.render(&mut buffer, Duration::ZERO);
let spent = started.elapsed();
assert!(spent < Duration::from_secs(5), "the first frame took {spent:?}");
std::fs::remove_dir_all(dir).ok();
}