use std::env;
use std::fs;
use std::path::{Component, Path, PathBuf};
use clap::ValueEnum;
use serde_json::{Value, json};
use crate::environment;
use crate::settings::{self, SettingsError};
use crate::version::VERSION;
const FILE: &str = "install.json";
#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
pub enum Way {
Npm,
Cargo,
ReleaseBinary,
#[value(skip)]
Unknown,
}
impl Way {
pub const fn as_str(self) -> &'static str {
match self {
Self::Npm => "npm",
Self::Cargo => "cargo",
Self::ReleaseBinary => "release-binary",
Self::Unknown => "unknown",
}
}
fn parse(name: &str) -> Option<Self> {
<Self as ValueEnum>::from_str(name, false).ok()
}
}
fn executable() -> Option<PathBuf> {
env::current_exe()
.ok()
.and_then(|path| fs::canonicalize(path).ok())
}
pub fn record(way: Way) -> Result<PathBuf, SettingsError> {
let path = executable().unwrap_or_else(|| PathBuf::from(env!("CARGO_BIN_NAME")));
let mut records = settings::read(FILE);
let entry = json!({ "path": path, "way": way.as_str(), "version": VERSION });
let mut installs: Vec<Value> = records["installs"]
.as_array()
.cloned()
.unwrap_or_default()
.into_iter()
.filter(|install| install["path"].as_str() != path.to_str())
.collect();
installs.push(entry);
records["installs"] = Value::Array(installs);
settings::write(FILE, &records)?;
Ok(path)
}
pub fn way() -> Way {
let Some(executable) = executable() else {
return Way::Unknown;
};
recorded(&settings::read(FILE), &executable)
.unwrap_or_else(|| inferred(&executable, cargo_home().as_deref()))
}
fn recorded(records: &Value, executable: &Path) -> Option<Way> {
records["installs"]
.as_array()?
.iter()
.rev()
.find(|install| install["path"].as_str() == executable.to_str())
.and_then(|install| Way::parse(install["way"].as_str()?))
}
fn inferred(executable: &Path, cargo_home: Option<&Path>) -> Way {
let parts: Vec<Component<'_>> = executable.components().collect();
let in_npm_scope = parts
.windows(2)
.any(|pair| pair[0].as_os_str() == "node_modules" && pair[1].as_os_str() == "@ask-loft");
if in_npm_scope {
return Way::Npm;
}
match cargo_home {
Some(home) if installed_by_cargo(executable, home) => Way::Cargo,
_ => Way::Unknown,
}
}
fn installed_by_cargo(executable: &Path, home: &Path) -> bool {
let bin = fs::canonicalize(home.join("bin")).unwrap_or_else(|_| home.join("bin"));
if executable.parent() != Some(bin.as_path()) {
return false;
}
listed_by_cargo(executable, home).unwrap_or(false)
}
fn listed_by_cargo(executable: &Path, home: &Path) -> Option<bool> {
let name = executable.file_name()?.to_str()?;
let listed: Value = serde_json::from_slice(&fs::read(home.join(".crates2.json")).ok()?).ok()?;
let installs = listed["installs"].as_object()?;
Some(installs.iter().any(|(package, install)| {
package.starts_with("loft-cli ")
&& install["bins"]
.as_array()
.is_some_and(|bins| bins.iter().any(|bin| bin.as_str() == Some(name)))
}))
}
fn cargo_home() -> Option<PathBuf> {
environment::value("CARGO_HOME")
.map(PathBuf::from)
.or_else(|| {
environment::value(if cfg!(windows) { "USERPROFILE" } else { "HOME" })
.map(|home| PathBuf::from(home).join(".cargo"))
})
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use serde_json::json;
use super::{Way, inferred, recorded};
#[test]
fn a_record_for_the_executable_gives_its_way_and_one_for_another_path_does_not() {
let records = json!({ "installs": [
{ "path": "/elsewhere/loft", "way": "cargo", "version": "2.0.0" },
{ "path": "/here/loft", "way": "npm", "version": "2.0.0" },
] });
assert_eq!(recorded(&records, Path::new("/here/loft")), Some(Way::Npm));
assert_eq!(recorded(&records, Path::new("/other/loft")), None);
}
#[test]
fn a_damaged_record_gives_no_way() {
for records in [
json!({}),
json!({ "installs": "npm" }),
json!({ "installs": [{ "path": "/here/loft", "way": "brew" }] }),
json!({ "installs": [{ "path": 1, "way": "npm" }] }),
] {
assert_eq!(
recorded(&records, Path::new("/here/loft")),
None,
"{records}"
);
}
}
#[test]
fn an_executable_inside_node_modules_at_ask_loft_was_installed_by_npm() {
let path = Path::new("/p/node_modules/@ask-loft/loft-linux-x64/bin/loft");
assert_eq!(inferred(path, None), Way::Npm);
let unscoped = Path::new("/p/node_modules/loft/bin/loft");
assert_eq!(inferred(unscoped, None), Way::Unknown);
}
#[test]
fn an_executable_in_cargo_s_bin_listed_under_loft_cli_was_installed_by_cargo() {
let home = std::env::temp_dir().join(format!("loft-cargo-home-{}", std::process::id()));
fs::create_dir_all(home.join("bin")).unwrap();
let bin = fs::canonicalize(home.join("bin")).unwrap();
let executable = bin.join("loft");
assert_eq!(inferred(&executable, Some(&home)), Way::Unknown);
let listed = json!({ "installs": {
"loft-cli 2.0.3 (registry+https://github.com/rust-lang/crates.io-index)":
{ "bins": ["loft"] },
} });
fs::write(home.join(".crates2.json"), listed.to_string()).unwrap();
assert_eq!(inferred(&executable, Some(&home)), Way::Cargo);
let elsewhere = Path::new("/usr/local/bin/loft");
assert_eq!(inferred(elsewhere, Some(&home)), Way::Unknown);
fs::remove_dir_all(&home).unwrap();
}
}