use kernel::release::Version;
use kernel::{AxCode, AxError, Proxying, Reach, Release};
use wire::{InstallChannel, Registry, RegistryNewest, RegistryReading};
use wire::{ReleaseAnswer, ReleaseLine, UpdateHint};
const LATEST_URL: &str = "https://registry.npmjs.org/sprawling/latest";
const CRATES_URL: &str = "https://crates.io/api/v1/crates/sprawling";
const USER_AGENT: &str = concat!(
"sprawling/",
env!("CARGO_PKG_VERSION"),
" (https://github.com/2youg1/sprawling-agents)"
);
const RELEASES: &str = "https://github.com/2youg1/sprawling-agents/releases";
const NPM_UPDATE: &str = "npm install -g sprawling@latest";
const CARGO_UPDATE: &str = "cargo install sprawling --locked";
const ARCHIVE_SIBLING: &str = "skills";
const PATIENCE: std::time::Duration = std::time::Duration::from_secs(10);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Built {
Released(Release),
FromSource,
}
pub fn built() -> Result<Built, AxError> {
match option_env!("SPRAWLING_RELEASE_TAG") {
None => Ok(Built::FromSource),
Some("") => Ok(Built::FromSource),
Some(tag) => Release::from_tag(tag, env!("CARGO_PKG_VERSION")).map(Built::Released),
}
}
pub fn newest() -> Result<Release, AxError> {
npm_newest(LATEST_URL)
}
fn npm_newest(url: &str) -> Result<Release, AxError> {
Release::from_npm_version(&manifest_field(url, &["version"])?)
}
fn crates_newest(url: &str) -> Result<Version, AxError> {
Version::from_crates_version(&manifest_field(url, &["crate", "max_version"])?)
}
fn manifest_field(url: &str, path: &[&str]) -> Result<String, AxError> {
let client = gateway::client_for(Proxying::ExceptLocal, url)
.timeout(PATIENCE)
.user_agent(USER_AGENT)
.build()
.map_err(|err| {
AxError::failure(AxCode::ToolUnavailable, "ask the registry", err.to_string())
.with_recovery("this machine refused to build an HTTP client")
})?;
let response = client
.get(url)
.header("accept", "application/json")
.send()
.map_err(|_| stopped(&client, url))?;
let status = response.status();
if !status.is_success() {
return Err(
AxError::failure(AxCode::ToolUnavailable, "ask the registry", url.to_owned())
.with_recovery(format!(
"the registry answered {status}; the release page carries every \
archive either way: {RELEASES}"
)),
);
}
let body: serde_json::Value = response.json().map_err(|err| {
AxError::failure(
AxCode::ToolUnavailable,
"read the registry's answer",
err.to_string(),
)
.with_recovery(
"the registry answered something other than a version manifest; \
a proxy that returns a login page does this",
)
})?;
path.iter()
.try_fold(&body, |at, key| at.get(key))
.and_then(serde_json::Value::as_str)
.map(str::to_owned)
.ok_or_else(|| {
AxError::failure(
AxCode::ToolUnavailable,
"read the registry's answer",
url.to_owned(),
)
.with_recovery(format!("the manifest carried no `{}`", path.join(".")))
})
}
fn stopped(client: &reqwest::blocking::Client, url: &str) -> AxError {
let Reach {
host,
named,
connected,
answered,
through,
..
} = gateway::reach(client, Proxying::ExceptLocal, url, 0);
AxError::failure(AxCode::ToolUnavailable, "ask the registry", host).with_recovery(format!(
"the call stopped there: name {named:?}, socket {connected:?}, \
request {answered:?}, through {through:?}"
))
}
fn line(release: &Release) -> ReleaseLine {
ReleaseLine {
version: release.npm_version(),
released: release.released(),
}
}
fn crates_line(version: &Version) -> ReleaseLine {
ReleaseLine {
version: version.to_string(),
released: String::new(),
}
}
struct Registries<'a> {
npm: &'a str,
crates: &'a str,
}
#[must_use]
pub fn answer() -> ReleaseAnswer {
match built() {
Ok(mine) => judged(
mine,
this_channel(),
&Registries {
npm: LATEST_URL,
crates: CRATES_URL,
},
),
Err(refusal) => ReleaseAnswer::Refused { refusal },
}
}
fn judged(mine: Built, installed: InstallChannel, at: &Registries<'_>) -> ReleaseAnswer {
let npm = npm_newest(at.npm);
let crates = crates_newest(at.crates);
let registries = vec![
RegistryNewest {
registry: Registry::Npm,
reading: reading(npm.as_ref().map(line)),
},
RegistryNewest {
registry: Registry::CratesIo,
reading: reading(crates.as_ref().map(crates_line)),
},
];
let mine = match mine {
Built::FromSource => {
return ReleaseAnswer::Unreleased {
registries,
update: hint(InstallChannel::Source),
};
}
Built::Released(mine) => mine,
};
let verdict = match installed {
InstallChannel::Cargo => {
crates.map(|newest| kernel::release::stands_on_crates(&mine, &newest))
}
InstallChannel::Npm | InstallChannel::Archive | InstallChannel::Source => {
npm.map(|newest| kernel::release::stands(&mine, &newest))
}
};
match verdict {
Ok(verdict) => ReleaseAnswer::Stands {
verdict,
mine: line(&mine),
registries,
update: hint(installed),
},
Err(refusal) => ReleaseAnswer::Refused { refusal },
}
}
fn reading(read: Result<ReleaseLine, &AxError>) -> RegistryReading {
match read {
Ok(newest) => RegistryReading::Read { newest },
Err(refusal) => RegistryReading::Refused {
refusal: refusal.clone(),
},
}
}
fn this_channel() -> InstallChannel {
let cargo_home = match std::env::var_os("CARGO_HOME") {
Some(set) => Some(std::path::PathBuf::from(set)),
None => match accounting::home::Home::detect() {
Ok(home) => Some(home.path().join(".cargo")),
Err(_no_home) => None,
},
};
let cargo_bin = cargo_home.map(|cargo_home| cargo_home.join("bin"));
match std::env::current_exe() {
Ok(exe) => channel(&exe, cargo_bin.as_deref()),
Err(_unnamed) => InstallChannel::Source,
}
}
pub(crate) fn channel(
exe: &std::path::Path,
cargo_bin: Option<&std::path::Path>,
) -> InstallChannel {
let packaged = exe.components().any(|part| {
matches!(
part.as_os_str().to_str(),
Some("node_modules" | ".bun" | "_npx")
)
});
let dir = exe.parent();
if packaged {
InstallChannel::Npm
} else if cargo_bin.is_some() && dir == cargo_bin {
InstallChannel::Cargo
} else if dir.is_some_and(|dir| dir.join(ARCHIVE_SIBLING).is_dir()) {
InstallChannel::Archive
} else {
InstallChannel::Source
}
}
fn hint(channel: InstallChannel) -> UpdateHint {
let command = match channel {
InstallChannel::Npm => Some(NPM_UPDATE.to_owned()),
InstallChannel::Cargo => Some(CARGO_UPDATE.to_owned()),
InstallChannel::Archive => Some(format!(
"download the newest archive from {RELEASES}, then run `sprawling install` from it"
)),
InstallChannel::Source => None,
};
UpdateHint { channel, command }
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
clippy::wildcard_enum_match_arm,
reason = "test code"
)]
mod tests {
use super::{Built, Registries, built, channel, judged};
use kernel::ReleaseVerdict;
use std::io::{BufRead, BufReader, Write};
use wire::{InstallChannel, ReleaseAnswer};
fn registries(
npm: &'static str,
crates: &'static str,
) -> (String, std::sync::mpsc::Receiver<String>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let (agents, seen) = std::sync::mpsc::channel();
std::thread::spawn(move || {
for stream in listener.incoming() {
let mut stream = stream.unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut head = Vec::new();
loop {
let mut line = String::new();
reader.read_line(&mut line).unwrap();
if line.trim().is_empty() {
break;
}
head.push(line);
}
let body = if head[0].contains("/crates") {
crates
} else {
npm
};
let agent = head
.iter()
.find_map(|line| {
line.to_ascii_lowercase()
.strip_prefix("user-agent:")
.map(|it| it.trim().to_owned())
})
.unwrap_or_default();
agents.send(agent).unwrap();
write!(
stream,
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
)
.unwrap();
}
});
(base, seen)
}
#[test]
fn a_crates_answer_of_the_same_release_reads_as_current() {
let (base, seen) = registries(
r#"{"version":"0.0.8-pre.261005"}"#,
r#"{"crate":{"max_version":"0.0.8"}}"#,
);
let npm = format!("{base}/npm");
let crates = format!("{base}/crates");
let at = Registries {
npm: &npm,
crates: &crates,
};
let mine =
|| Built::Released(kernel::Release::from_npm_version("0.0.8-pre.261002").unwrap());
let verdict = |answer: ReleaseAnswer| match answer {
ReleaseAnswer::Stands { verdict, .. } => verdict,
other => panic!("not judged: {other:?}"),
};
assert_eq!(
[
verdict(judged(mine(), InstallChannel::Cargo, &at)),
verdict(judged(mine(), InstallChannel::Npm, &at)),
],
[ReleaseVerdict::Current, ReleaseVerdict::Behind]
);
let agents: Vec<String> = seen.try_iter().collect();
assert_eq!(agents.len(), 4);
assert!(
agents.iter().all(|agent| agent.starts_with("sprawling/")),
"{agents:?}"
);
}
#[test]
fn the_update_command_follows_the_channel_the_binary_came_through() {
let root = tempfile::tempdir().unwrap();
let cargo_bin = root.path().join(".cargo").join("bin");
let archive = root.path().join("sprawling-0.0.9");
std::fs::create_dir_all(archive.join("skills")).unwrap();
let exe = |dir: &std::path::Path| dir.join("sprawling");
let npm = root
.path()
.join("lib")
.join("node_modules")
.join("sprawling")
.join("bin");
let bunx = root.path().join(".bun").join("install").join("cache");
assert_eq!(
[
channel(&exe(&npm), Some(&cargo_bin)),
channel(&exe(&bunx), Some(&cargo_bin)),
channel(&exe(&cargo_bin), Some(&cargo_bin)),
channel(&exe(&archive), Some(&cargo_bin)),
channel(&exe(&root.path().join("target")), Some(&cargo_bin)),
channel(&exe(&cargo_bin), None),
],
[
InstallChannel::Npm,
InstallChannel::Npm,
InstallChannel::Cargo,
InstallChannel::Archive,
InstallChannel::Source,
InstallChannel::Source,
]
);
}
#[test]
fn a_binary_knows_whether_it_came_out_of_a_release() {
match built().expect("this build's own tag decodes") {
Built::FromSource => {}
Built::Released(mine) => {
assert_eq!(mine.version(), env!("CARGO_PKG_VERSION"));
assert_eq!(
kernel::Release::from_npm_version(&mine.npm_version())
.expect("what this release publishes"),
mine
);
}
}
}
}