use crate::tools;
use std::io::Write;
use std::path::Path;
use std::process::Command;
const PRE_COMMIT: &str = "#!/bin/sh\nexec tw check --no-prose --staged\n";
const PRE_PUSH: &str = "#!/bin/sh\nexec tw check --no-prose\n";
pub fn run(root: &Path, skip_tools: bool) -> Result<(), String> {
let root = root
.canonicalize()
.map_err(|e| format!("{}: {e}", root.display()))?;
heading("git hooks");
hooks(&root)?;
heading("pinned tools");
if skip_tools {
println!(" skipped (--no-tools)");
} else {
prefetch(&root);
}
heading("completions");
println!(" tw completions <bash|zsh|fish|powershell|elvish>");
println!(" see `tw completions --help` for where your shell wants it");
println!(
"\nReady. Day to day:\n\n \
tw check structure and prose\n \
tw check --no-prose structure only, what the hooks run\n \
tw fmt --write format the tags\n \
tw preview build and serve the site locally\n \
tw build pdfs render every manual\n"
);
Ok(())
}
fn hooks(root: &Path) -> Result<(), String> {
let dir = root.join(".githooks");
std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
for (name, body) in [("pre-commit", PRE_COMMIT), ("pre-push", PRE_PUSH)] {
let path = dir.join(name);
std::fs::write(&path, body).map_err(|e| format!("{}: {e}", path.display()))?;
executable(&path)?;
println!(" {name}: {}", body.lines().nth(1).unwrap_or("").trim());
}
let status = Command::new("git")
.arg("-C")
.arg(root)
.args(["config", "core.hooksPath", ".githooks"])
.status()
.map_err(|e| format!("git: {e}"))?;
if !status.success() {
return Err("git config core.hooksPath failed".to_string());
}
println!(" core.hooksPath = .githooks");
Ok(())
}
fn prefetch(root: &Path) {
let config = match crate::external::Config::load(&tools::corpus(root)) {
Ok(config) => config,
Err(e) => {
println!(" tw.toml: {e}");
crate::external::Config::default()
}
};
for tool in config.all_launched() {
match tool.resolve(root) {
Ok(path) => println!(" {}: {}", tool.stem, path.display()),
Err(e) => println!(" {}: not available -- {e}", tool.stem),
}
}
prefetch_extensions(root, &config);
}
fn prefetch_extensions(root: &Path, config: &crate::external::Config) {
if config.extensions.is_empty() {
return;
}
let tooling = match tools::Tooling::find(root) {
Ok(tooling) => tooling,
Err(e) => {
println!(" extensions: {e}");
return;
}
};
let slug = match tools::platform_slug() {
Ok(slug) => slug,
Err(e) => {
println!(" extensions: {e}");
return;
}
};
for name in &config.extensions {
let fetched = tooling.pin(name).and_then(|pin| {
tooling.release(name, &pin, &tools::extension_asset(name, &slug), true)
});
match fetched {
Ok(dir) => {
let bin = dir.join("bin");
let commands = commands_in(&bin);
if commands.is_empty() {
println!(
" {name}: fetched, but {} holds no tw-* commands",
bin.display()
);
} else {
println!(" {name}: {}", commands.join(", "));
}
}
Err(e) => println!(" {name}: not available -- {e}"),
}
}
}
fn commands_in(bin: &Path) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(bin)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let file = e.file_name().to_string_lossy().to_string();
file.strip_prefix("tw-")
.map(|rest| format!("tw {}", rest.strip_suffix(".exe").unwrap_or(rest)))
})
.collect();
names.sort();
names
}
#[cfg(unix)]
fn executable(path: &Path) -> Result<(), String> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))
.map_err(|e| format!("{}: {e}", path.display()))
}
#[cfg(not(unix))]
fn executable(_path: &Path) -> Result<(), String> {
Ok(())
}
pub fn update(root: &Path, repo: &str) -> Result<(), String> {
let current = env!("CARGO_PKG_VERSION");
let url = format!("https://api.github.com/repos/{repo}/releases/latest");
let body: serde_json::Value = ureq::get(&url)
.header("User-Agent", "tw")
.call()
.map_err(|e| match e {
ureq::Error::StatusCode(404) => format!(
"{repo} has published no releases yet, so there is nothing to \
update to. tw is at {current}."
),
other => format!("{url}: {other}"),
})?
.body_mut()
.read_to_string()
.map_err(|e| format!("{url}: {e}"))
.and_then(|text| serde_json::from_str(&text).map_err(|e| format!("{url}: {e}")))?;
let latest = body
.get("tag_name")
.and_then(|t| t.as_str())
.ok_or_else(|| format!("{url}: the release carries no tag_name"))?;
if latest == current {
println!("tw {current} is the newest release.");
} else {
let slug = tools::platform_slug()?;
let asset = format!("tw-{slug}{}", if cfg!(windows) { ".exe" } else { "" });
let assets = body.get("assets").and_then(|a| a.as_array());
let url_of = |name: &str| -> Option<&str> {
assets?
.iter()
.find(|a| a.get("name").and_then(|n| n.as_str()) == Some(name))?
.get("browser_download_url")?
.as_str()
};
let download = url_of(&asset).ok_or_else(|| format!("release {latest} has no {asset}"))?;
let checksum_url = url_of(&format!("{asset}.sha256")).ok_or_else(|| {
format!(
"release {latest} publishes {asset} and no {asset}.sha256, so \
the download that would replace this binary cannot be \
verified. Fix the release rather than trust it."
)
})?;
println!("tw {current} -> {latest}");
let published = ureq::get(checksum_url)
.header("User-Agent", "tw")
.call()
.map_err(|e| format!("{checksum_url}: {e}"))?
.body_mut()
.read_to_string()
.map_err(|e| format!("{checksum_url}: {e}"))?;
let bytes = ureq::get(download)
.header("User-Agent", "tw")
.call()
.map_err(|e| format!("{download}: {e}"))?
.body_mut()
.with_config()
.limit(128 * 1024 * 1024)
.read_to_vec()
.map_err(|e| format!("{download}: {e}"))?;
tools::verify(&bytes, &published, &asset)?;
let staged = std::env::current_exe()
.map_err(|e| format!("current_exe: {e}"))?
.with_extension("new");
let mut file =
std::fs::File::create(&staged).map_err(|e| format!("{}: {e}", staged.display()))?;
file.write_all(&bytes)
.map_err(|e| format!("{}: {e}", staged.display()))?;
drop(file);
executable(&staged)?;
self_replace::self_replace(&staged)
.map_err(|e| format!("could not replace this binary: {e}"))?;
let _ = std::fs::remove_file(&staged);
println!("tw is now {latest}");
}
heading("pinned tools");
prefetch(root);
Ok(())
}
fn heading(text: &str) {
println!("\n{text}");
}