use std::path::{Path, PathBuf};
pub const TOOL_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const FIRST_PUBLISHED: (u64, u64, u64) = (0, 13, 0);
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Verdict {
Ok,
Degraded { app: String, tool: String },
Refuse { app: String, tool: String },
}
impl Verdict {
pub fn may_answer(&self) -> bool {
!matches!(self, Verdict::Refuse { .. })
}
pub fn note(&self) -> Option<String> {
match self {
Verdict::Ok | Verdict::Refuse { .. } => None,
Verdict::Degraded { app, tool } => Some(degraded_text(app, tool)),
}
}
}
pub fn check(app_version: &str) -> Verdict {
let tool = TOOL_VERSION;
if app_version == tool {
return Verdict::Ok;
}
match (semver_parts(app_version), semver_parts(tool)) {
(Some((a_major, a_minor, _)), Some((t_major, t_minor, _)))
if a_major == t_major && a_minor == t_minor =>
{
Verdict::Degraded {
app: app_version.to_string(),
tool: tool.to_string(),
}
}
_ => Verdict::Refuse {
app: app_version.to_string(),
tool: tool.to_string(),
},
}
}
fn semver_parts(v: &str) -> Option<(u64, u64, u64)> {
let core = v.split(['-', '+']).next()?;
let mut it = core.split('.');
let major = it.next()?.parse().ok()?;
let minor = it.next()?.parse().ok()?;
let patch = it.next().unwrap_or("0").parse().ok()?;
if it.next().is_some() {
return None;
}
Some((major, minor, patch))
}
pub fn readable_sources(resolution: &crate::resolve::Resolution) -> Option<PathBuf> {
if let Some(root) = resolution.checkout_root() {
return Some(root);
}
let widgets = resolution.widgets()?;
widgets.has_src().then(|| widgets.dir.clone())
}
pub fn refusal_text(app: &str, tool: &str, what: &str, sources: Option<&Path>) -> String {
let (f_major, f_minor, f_patch) = FIRST_PUBLISHED;
let floor = format!("{f_major}.{f_minor}.{f_patch}");
let head = format!(
"cargo-teksilo {tool} cannot serve {what} for an app on teksilo {app}.\n\
\n\
The public API changed between these versions, so answering would mean\n\
guessing."
);
let body = match semver_parts(app) {
Some(parts) if parts < FIRST_PUBLISHED => format!(
"There is no matching tool to install, and there will not be one.\n\
cargo-teksilo was first released as {floor}; no cargo-teksilo {app}\n\
was ever published, and crates.io is append-only, so asking the registry\n\
for one cannot succeed now or later.\n\
\n\
The two checkout routes are dead at that tag for the same reason: a\n\
teksilo {app} checkout has no `crates/cargo-teksilo` directory in it,\n\
so neither `--path` nor `-p cargo-teksilo` has anything to build.\n\
Do not try them.\n\
\n\
A version-matched tool exists only for teksilo {floor} and later."
),
Some(_) => format!(
"Install the matching tool:\n\
\n\
\x20 cargo install cargo-teksilo --version {app} --locked\n\
\n\
If that version was never published — the app pins teksilo by `path` or\n\
`git`, which is normal for an app developed alongside the framework —\n\
install from the checkout the app resolves instead:\n\
\n\
\x20 cargo install --path <teksilo checkout>/crates/cargo-teksilo --locked\n\
\n\
Both routes install ONE binary per machine, so switching between two\n\
apps on different minors means reinstalling. To keep both, install the\n\
second with `--root <dir>` and put that `<dir>/bin` first on PATH for\n\
that tree, or skip installing and run the tool straight out of the\n\
framework checkout with `cargo run -p cargo-teksilo -- teksilo <args>`."
),
None => format!(
"`{app}` is not a semver triple, so there is no number to pin an\n\
install to. Install from the teksilo checkout this app resolves\n\
instead:\n\
\n\
\x20 cargo install --path <teksilo checkout>/crates/cargo-teksilo --locked\n\
\n\
or run the tool straight out of that checkout, without installing:\n\
\n\
\x20 cargo run -p cargo-teksilo -- teksilo <args>\n\
\n\
Either way the checkout has to be one whose teksilo is what this app\n\
resolves, and it has to be at {floor} or later — cargo-teksilo does not\n\
exist in a checkout older than that."
),
};
let where_ = match sources {
Some(dir) => format!("\n\nIt is on disk at:\n\n\x20 {}", dir.display()),
None => String::new(),
};
format!(
"{head} {body}\n\
\n\
DO NOT answer teksilo API questions from prior knowledge — the surface\n\
differs between these versions. Read the teksilo source this app\n\
resolved instead, or ask the user which version they intend.{where_}"
)
}
pub fn degraded_text(app: &str, tool: &str) -> String {
format!(
"note: this app resolved teksilo {app}, this tool is {tool}. \
Answering anyway (same minor series); install \
`cargo-teksilo --version {app}` for an exact match."
)
}
#[cfg(test)]
mod tests {
use super::*;
const PRE_FLOOR_RELEASES: &[&str] = &["0.9.0", "0.9.5", "0.10.0", "0.11.0", "0.12.0", "0.12.1"];
fn refusal(app: &str, tool: &str, what: &str) -> String {
refusal_text(app, tool, what, None)
}
#[test]
fn an_exact_match_answers() {
assert_eq!(check(TOOL_VERSION), Verdict::Ok);
}
#[test]
fn a_patch_difference_degrades_rather_than_refusing() {
let (major, minor, patch) = semver_parts(TOOL_VERSION).unwrap();
let other = format!("{major}.{minor}.{}", patch + 1);
let v = check(&other);
assert!(matches!(v, Verdict::Degraded { .. }), "got {v:?}");
assert!(v.may_answer());
}
#[test]
fn a_minor_difference_refuses() {
let (major, minor, _) = semver_parts(TOOL_VERSION).unwrap();
let other = format!("{major}.{}.0", minor + 1);
let v = check(&other);
assert!(matches!(v, Verdict::Refuse { .. }), "got {v:?}");
assert!(!v.may_answer());
}
#[test]
fn a_major_difference_refuses() {
let (major, _, _) = semver_parts(TOOL_VERSION).unwrap();
let v = check(&format!("{}.0.0", major + 1));
assert!(matches!(v, Verdict::Refuse { .. }), "got {v:?}");
}
#[test]
fn the_0_9_to_0_12_case_refuses() {
assert!(
matches!(check("0.9.2"), Verdict::Refuse { .. }) || TOOL_VERSION.starts_with("0.9")
);
}
#[test]
fn unparseable_versions_refuse_rather_than_guess() {
assert!(matches!(check("not-a-version"), Verdict::Refuse { .. }));
assert!(matches!(check(""), Verdict::Refuse { .. }));
assert!(matches!(check("1.2.3.4"), Verdict::Refuse { .. }));
}
#[test]
fn prerelease_parses_to_its_core() {
assert_eq!(semver_parts("2.0.0-rc.13"), Some((2, 0, 0)));
assert_eq!(semver_parts("1.2.3+build"), Some((1, 2, 3)));
}
#[test]
fn the_refusal_tells_a_model_not_to_guess() {
let t = refusal("0.9.2", "0.12.1", "symbol lookup");
assert!(t.contains("DO NOT answer teksilo API questions from prior knowledge"));
assert!(!t.contains("cargo install"));
assert!(!t.contains("--version 0.9.2"));
}
#[test]
fn the_refusal_offers_a_route_for_an_unpublished_version() {
let t = refusal("0.14.0", "0.12.1", "search");
assert!(t.contains("cargo install cargo-teksilo --version 0.14.0 --locked"));
assert!(t.contains("cargo install --path"));
assert!(t.contains("crates/cargo-teksilo"));
assert!(t.contains("cargo run -p cargo-teksilo"));
}
#[test]
fn a_pre_floor_app_is_offered_no_install_command_at_all() {
let t = refusal("0.12.1", TOOL_VERSION, "symbol lookup");
assert!(!t.contains("cargo install"), "{t}");
assert!(!t.contains("cargo run -p cargo-teksilo -- teksilo"), "{t}");
assert!(t.contains("0.13.0"), "{t}");
assert!(t.contains("crates/cargo-teksilo"), "{t}");
assert!(t.contains("Do not try them"), "{t}");
}
#[test]
fn every_published_pre_floor_teksilo_reaches_that_regime() {
for app in PRE_FLOOR_RELEASES {
let t = refusal(app, TOOL_VERSION, "search");
assert!(!t.contains("cargo install"), "{app}: {t}");
assert!(
t.contains("DO NOT answer teksilo API questions from prior knowledge"),
"{app}"
);
}
}
#[test]
fn an_unparseable_version_yields_no_version_flag() {
for app in ["not-a-version", "", "1.2.3.4", "main"] {
let t = refusal(app, TOOL_VERSION, "search");
assert!(!t.contains("--version"), "{app}: {t}");
assert!(t.contains("cargo install --path"), "{app}: {t}");
assert!(t.contains("cargo run -p cargo-teksilo"), "{app}: {t}");
}
}
#[test]
fn the_floor_is_never_above_this_binarys_own_version() {
let tool = semver_parts(TOOL_VERSION).expect("this crate's own version is a semver triple");
assert!(
tool >= FIRST_PUBLISHED,
"TOOL_VERSION {TOOL_VERSION} is below FIRST_PUBLISHED {FIRST_PUBLISHED:?}"
);
}
#[test]
fn a_confirmed_source_directory_is_named() {
let dir = Path::new("/home/dev/teksilo");
let t = refusal_text("0.12.1", TOOL_VERSION, "search", Some(dir));
assert!(t.contains("/home/dev/teksilo"), "{t}");
let bare = refusal("0.12.1", TOOL_VERSION, "search");
assert!(bare.contains("Read the teksilo source this app"), "{bare}");
assert!(!bare.contains("It is on disk at"), "{bare}");
}
#[test]
fn the_degraded_note_names_both_versions() {
let t = degraded_text("0.12.0", "0.12.1");
assert!(t.contains("0.12.0") && t.contains("0.12.1"));
}
}