diff --git a/Cargo.toml b/Cargo.toml index 2ca2a0b9..7c8d5a56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ inference-cli = { path = "./core/cli", version = "0.0.1" } inference-wasm-to-v-translator = { path = "./core/wasm-to-v", version = "0.0.1" } inference-wasm-codegen = { path = "./core/wasm-codegen", version = "0.0.1" } inference-analysis = { path = "./core/analysis", version = "0.0.1" } +inference-compiler-interface = { path = "./core/compiler-interface", version = "0.0.1" } # IDE support crates inference-base-db = { path = "./ide/base-db", version = "0.0.1" } diff --git a/apps/infs/Cargo.toml b/apps/infs/Cargo.toml index 52a9d96a..a297483a 100644 --- a/apps/infs/Cargo.toml +++ b/apps/infs/Cargo.toml @@ -16,15 +16,15 @@ path = "src/main.rs" [dependencies] clap = { version = "4.5", features = ["derive"] } reqwest = { version = "0.13.1", default-features = false, features = ["rustls", "stream"] } -sha2 = "0.10" -zip = { version = "7.1.0", default-features = false, features = ["deflate"] } +sha2 = "0.11" +zip = { version = "8.5.1", default-features = false, features = ["deflate"] } flate2 = "1" tar = "0.4" semver = "1.0" tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -toml = "0.9.8" +toml = "1.1.2" hex = "0.4" futures-util = "0.3" which = "8.0.0" @@ -34,15 +34,17 @@ ratatui = "0.30.0" crossterm = "0.29.0" anyhow.workspace = true thiserror.workspace = true +inference-compiler-interface.workspace = true [target.'cfg(windows)'.dependencies] -winreg = "0.55" +winreg = "0.56" [dev-dependencies] assert_cmd = "2.1.1" predicates = "3.1.3" assert_fs = "1.1.1" serial_test = "3.2" +regex = "1" [profile.release] opt-level = "z" # Optimize for size (not speed) diff --git a/apps/infs/src/commands/build.rs b/apps/infs/src/commands/build.rs index b3179254..4c4adc24 100644 --- a/apps/infs/src/commands/build.rs +++ b/apps/infs/src/commands/build.rs @@ -17,11 +17,12 @@ use anyhow::{Context, Result, bail}; use clap::Args; -use std::path::PathBuf; -use std::process::Command; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; use crate::errors::InfsError; use crate::toolchain::find_infc; +use inference_compiler_interface::{COMPILER_ABI_MAJOR, COMPILER_ABI_MINOR}; /// Arguments for the build command. /// @@ -43,14 +44,16 @@ pub struct BuildArgs { /// /// 1. Validates that the source file exists /// 2. Locates the infc compiler binary -/// 3. Builds and executes the infc command, forwarding `-v` if requested -/// 4. Propagates exit code from infc +/// 3. Runs the `infc` compatibility handshake (git hash + ABI version) +/// 4. Builds and executes the infc command, forwarding `-v` if requested +/// 5. Propagates exit code from infc /// /// ## Errors /// /// Returns an error if: /// - The source file does not exist /// - infc compiler cannot be found +/// - infc reports a *major* ABI version mismatch (hard error with remediation) /// - infc exits with non-zero code (as `InfsError::ProcessExitCode`) pub fn execute(args: &BuildArgs) -> Result<()> { if !args.path.exists() { @@ -58,6 +61,8 @@ pub fn execute(args: &BuildArgs) -> Result<()> { } let infc_path = find_infc()?; + check_compiler_compatibility(&infc_path)?; + let mut cmd = Command::new(&infc_path); cmd.arg(&args.path); @@ -79,3 +84,212 @@ pub fn execute(args: &BuildArgs) -> Result<()> { Err(InfsError::process_exit_code(code).into()) } } + +/// Runs a compatibility handshake against the resolved `infc` binary. +/// +/// Sequence: +/// 1. Query `infc --commit-hash`. If it equals `INFS_GIT_COMMIT`, short-circuit — +/// the two binaries were built from the same source tree and the ABI is +/// guaranteed compatible. +/// 2. Otherwise query `infc --abi-version` and compare against the major/minor +/// constants from `inference-compiler-interface`. Major mismatch is a hard +/// error; minor mismatch is a warning; exact match is silent. +/// +/// Old binaries that do not understand the flags (non-zero exit, empty output, +/// or the literal `unknown`) are treated as graceful skips — we neither warn +/// nor error on them. The L1/L2 resolver fixes remain the correctness +/// guarantee; this handshake is a safety net against residual drift. +fn check_compiler_compatibility(infc_path: &Path) -> Result<()> { + // --commit-hash / --abi-version print and exit 0 immediately; no timeout needed. + let local_commit = env!("INFS_GIT_COMMIT"); + let remote_commit = probe_flag(infc_path, "--commit-hash"); + + if let Some(hash) = &remote_commit + && hash == local_commit + { + return Ok(()); + } + + let Some(abi_raw) = probe_flag(infc_path, "--abi-version") else { + return Ok(()); + }; + + let Some((infc_major, infc_minor)) = parse_abi_version(&abi_raw) else { + return Ok(()); + }; + + let local_major = COMPILER_ABI_MAJOR; + let local_minor = COMPILER_ABI_MINOR; + + if infc_major != local_major { + bail!( + "infs ABI {local_major}.{local_minor} but infc reported ABI \ + {infc_major}.{infc_minor}; rebuild the workspace or set \ + INFC_PATH to a matching binary." + ); + } + + match infc_minor.cmp(&local_minor) { + std::cmp::Ordering::Greater => { + eprintln!( + "warning: infc ABI {infc_major}.{infc_minor} is newer than \ + infs ABI {local_major}.{local_minor}; infs may not \ + recognize features emitted by infc." + ); + } + std::cmp::Ordering::Less => { + eprintln!( + "warning: infs ABI {local_major}.{local_minor} is newer \ + than infc ABI {infc_major}.{infc_minor}; infs may request \ + features infc does not provide." + ); + } + std::cmp::Ordering::Equal => {} + } + + Ok(()) +} + +/// Runs ` ` with stdin/stderr suppressed and returns the +/// trimmed stdout on success. Returns `None` for any failure mode that an old +/// `infc` lacking the flag would produce: spawn error, non-zero exit, empty +/// stdout, or the literal `unknown`. +fn probe_flag(infc_path: &Path, flag: &str) -> Option { + let output = Command::new(infc_path) + .arg(flag) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let value = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if value.is_empty() || value == "unknown" { + return None; + } + Some(value) +} + +/// Parses a `"."` string into `(major, minor)`. Returns `None` +/// on any parse failure — callers treat that as "skip the ABI check". +fn parse_abi_version(raw: &str) -> Option<(u32, u32)> { + let (major, minor) = raw.split_once('.')?; + let major: u32 = major.parse().ok()?; + let minor: u32 = minor.parse().ok()?; + Some((major, minor)) +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + use assert_fs::prelude::*; + use std::os::unix::fs::PermissionsExt; + + /// Writes an executable `infc` stub that prints fixed strings for + /// `--commit-hash` and `--abi-version`. The stub exits 0 by default but + /// can be configured to exit 1 instead. + fn write_stub( + dir: &assert_fs::TempDir, + commit_stdout: &str, + abi_stdout: &str, + exit_nonzero: bool, + ) -> PathBuf { + let stub = dir.child("infc"); + let exit_code = i32::from(exit_nonzero); + let script = format!( + "#!/bin/sh\n\ + case \"$1\" in\n\ + --commit-hash)\n\ + printf '%s\\n' \"{commit_stdout}\"\n\ + exit {exit_code}\n\ + ;;\n\ + --abi-version)\n\ + printf '%s\\n' \"{abi_stdout}\"\n\ + exit {exit_code}\n\ + ;;\n\ + *)\n\ + exit 0\n\ + ;;\n\ + esac\n", + ); + stub.write_str(&script).unwrap(); + let mut perms = std::fs::metadata(stub.path()).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(stub.path(), perms).unwrap(); + stub.path().to_path_buf() + } + + #[test] + fn abi_major_mismatch_is_hard_error() { + let dir = assert_fs::TempDir::new().unwrap(); + let stub = write_stub(&dir, "nottherightcommit", "2.0", false); + let err = check_compiler_compatibility(&stub).unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("ABI") && msg.contains("rebuild"), + "expected remediation message, got: {msg}" + ); + } + + #[test] + fn abi_minor_difference_warns_only() { + let dir = assert_fs::TempDir::new().unwrap(); + // Exercise only the "infc minor newer than infs" path; the reverse + // path is covered by a simple construction — the current embedded + // minor is 0, so any non-zero minor is "newer". A stub of "1.5" + // therefore produces a warning when COMPILER_ABI_MINOR is 0. + let stub = write_stub(&dir, "nottherightcommit", "1.5", false); + let result = check_compiler_compatibility(&stub); + assert!(result.is_ok(), "minor mismatch should not hard-error"); + } + + #[test] + fn matching_commit_hash_skips_abi_check() { + let dir = assert_fs::TempDir::new().unwrap(); + // ABI "9.9" would trigger a major mismatch if the ABI check ran. + // A matching commit hash must short-circuit before that. + let stub = write_stub(&dir, env!("INFS_GIT_COMMIT"), "9.9", false); + let result = check_compiler_compatibility(&stub); + assert!( + result.is_ok(), + "matching commit hash must short-circuit ABI check, got: {:?}", + result.err().map(|e| e.to_string()), + ); + } + + #[test] + fn unknown_commit_and_unknown_abi_is_silent() { + let dir = assert_fs::TempDir::new().unwrap(); + let stub = write_stub(&dir, "unknown", "unknown", false); + let result = check_compiler_compatibility(&stub); + assert!(result.is_ok(), "unknown outputs must be graceful"); + } + + #[test] + fn old_infc_returns_nonzero_for_flags_is_graceful() { + let dir = assert_fs::TempDir::new().unwrap(); + let stub = write_stub(&dir, "anything", "anything", true); + let result = check_compiler_compatibility(&stub); + assert!( + result.is_ok(), + "non-zero exit from flag probes must be graceful" + ); + } + + #[test] + fn parse_abi_version_accepts_valid() { + assert_eq!(parse_abi_version("1.0"), Some((1, 0))); + assert_eq!(parse_abi_version("2.7"), Some((2, 7))); + } + + #[test] + fn parse_abi_version_rejects_garbage() { + assert_eq!(parse_abi_version(""), None); + assert_eq!(parse_abi_version("1"), None); + assert_eq!(parse_abi_version("1.x"), None); + assert_eq!(parse_abi_version("x.1"), None); + assert_eq!(parse_abi_version("1.0.0"), None); + } +} diff --git a/apps/infs/src/commands/doctor.rs b/apps/infs/src/commands/doctor.rs index 0ddb8268..001ebf2e 100644 --- a/apps/infs/src/commands/doctor.rs +++ b/apps/infs/src/commands/doctor.rs @@ -18,14 +18,21 @@ //! //! ## Output Format (Public Contract) //! -//! The output format of this command is parsed by `editors/vscode/src/toolchain/doctor.ts`. -//! Each check line has the format: ` [OK|WARN|FAIL] : ` -//! Changes to this format require a corresponding update in the VS Code extension. +//! OUTPUT CONTRACT: check lines MUST match the regex +//! `/^\s+\[(OK|WARN|FAIL)]\s+(.+?):\s+(.*)/` +//! Parsed by `editors/vscode/src/toolchain/doctor.ts`. Do not change the +//! line shape (leading whitespace, bracket status, colon-space, message) +//! without coordinating with the VS Code extension. A snapshot test at +//! `apps/infs/tests/cli_integration.rs::doctor_output_respects_vscode_check_line_contract` +//! enforces this invariant. use anyhow::Result; use crate::toolchain::ToolchainPaths; -use crate::toolchain::conflict::{detect_path_conflicts, format_doctor_conflict_warning}; +use crate::toolchain::conflict::{ + detect_path_conflicts, enumerate_infc_on_path, format_doctor_conflict_warning, + format_duplicate_path_warning, +}; use crate::toolchain::doctor::{DoctorCheckStatus, run_all_checks}; /// Executes the doctor command. @@ -57,8 +64,14 @@ pub async fn execute() -> Result<()> { if !conflicts.is_empty() { has_warnings = true; println!(); - println!(" [WARN] PATH conflict detected:"); - for line in format_doctor_conflict_warning(&conflicts) { + // Print a regex-compliant [WARN] check line so the VS Code + // extension keeps a structured entry for this warning, then + // render the rest of the detail as plain indented continuation + // (no leading `[`, so the regex filter skips them). + let detail = format_doctor_conflict_warning(&conflicts); + let summary = path_conflict_summary(&detail); + println!(" [WARN] PATH conflict: {summary}"); + for line in detail { if !line.is_empty() { println!(" {line}"); } @@ -66,6 +79,19 @@ pub async fn execute() -> Result<()> { } } + // Duplicate infc binaries on PATH. The expanded block here mirrors the + // detect_path_conflicts rendering for human readers. Header is plain + // text (no `[WARN]` prefix) to stay outside the VS Code check-line + // regex filter — duplicate-binary reporting is informational only. + let on_path = enumerate_infc_on_path(); + if on_path.len() > 1 { + has_warnings = true; + println!(); + for line in format_duplicate_path_warning(&on_path) { + println!(" {line}"); + } + } + println!(); if has_errors { @@ -79,3 +105,73 @@ pub async fn execute() -> Result<()> { Ok(()) } + +/// Reduces a multi-line `format_doctor_conflict_warning` block to a one-line +/// summary suitable for the `[WARN] PATH conflict: ` check line. +/// +/// The VS Code extension regex requires a non-empty message after the colon. +/// The first informational line ("'infc' resolves to ...") is the best fit: +/// it names the shadowing binary in a self-contained way. Falls back to a +/// generic phrase when `detail` is empty (should not happen in practice — +/// `format_doctor_conflict_warning` only returns an empty vec for empty +/// input, and the caller already guards on that). +fn path_conflict_summary(detail: &[String]) -> String { + detail + .iter() + .find(|l| !l.trim().is_empty()) + .cloned() + .unwrap_or_else(|| "managed toolchain shadowed by PATH".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The VS Code extension parses check lines with this regex. The helper + /// lives here so the test can verify the full `[WARN] PATH conflict: …` + /// line round-trips through the regex. + fn vscode_regex() -> regex::Regex { + regex::Regex::new(r"^\s+\[(OK|WARN|FAIL)]\s+(.+?):\s+(.*)").unwrap() + } + + #[test] + fn conflict_summary_returns_first_nonempty_line() { + let detail = vec![ + "'infc' resolves to /usr/local/bin/infc".to_string(), + " but managed version is at /home/u/.inference/bin/infc".to_string(), + ]; + assert_eq!(path_conflict_summary(&detail), detail[0]); + } + + #[test] + fn conflict_summary_skips_leading_blank_lines() { + let detail = vec![ + String::new(), + "'infc' resolves to /usr/local/bin/infc".to_string(), + ]; + assert_eq!( + path_conflict_summary(&detail), + "'infc' resolves to /usr/local/bin/infc" + ); + } + + #[test] + fn conflict_summary_falls_back_on_empty_input() { + let detail: Vec = vec![]; + assert!(!path_conflict_summary(&detail).is_empty()); + } + + #[test] + fn path_conflict_header_line_matches_vscode_regex() { + // Reconstruct the exact line emitted in execute() and assert it + // passes the VS Code extension's check-line regex. Guards against + // future edits that would silently break the extension. + let detail = vec!["'infc' resolves to /usr/local/bin/infc".to_string()]; + let summary = path_conflict_summary(&detail); + let line = format!(" [WARN] PATH conflict: {summary}"); + assert!( + vscode_regex().is_match(&line), + "header line violates VS Code contract: {line:?}" + ); + } +} diff --git a/apps/infs/src/toolchain/conflict.rs b/apps/infs/src/toolchain/conflict.rs index 73d95cdf..9b40697d 100644 --- a/apps/infs/src/toolchain/conflict.rs +++ b/apps/infs/src/toolchain/conflict.rs @@ -4,6 +4,11 @@ //! shadows the managed toolchain binary. This helps users understand why the //! managed toolchain might not be used when they run commands. //! +//! It also enumerates *all* `infc` binaries on `PATH` so developers can +//! see duplicates — e.g. a stale `~/bin/infc` shadowed by a fresh +//! `/usr/local/bin/infc`. This is a common failure mode that single-hit +//! `which::which` lookups hide. +//! //! ## Usage //! //! ```ignore @@ -76,6 +81,53 @@ pub fn detect_path_conflicts(bin_dir: &Path) -> Vec { conflicts } +/// Enumerates every `infc` binary visible on the current `PATH`, in +/// first-wins order (the same order `which::which` would traverse). +/// +/// Returns an empty vector when nothing is found or when platform +/// detection fails. More than one entry means the later entries are +/// shadowed; `infs build` will invoke the first one. +/// +/// Uses [`which::which_all`] rather than [`which::which`] so both +/// active and shadowed binaries are visible — the common pitfall a +/// single-hit lookup hides. No new crate dependency: `which` is +/// already in the tree and v8 exposes `which_all` directly. +#[must_use] +pub fn enumerate_infc_on_path() -> Vec { + let Ok(platform) = Platform::detect() else { + return vec![]; + }; + let binary_with_ext = format!("{}{}", ToolchainPaths::MANAGED_BINARY, platform.executable_extension()); + which::which_all(&binary_with_ext) + .map(Iterator::collect) + .unwrap_or_default() +} + +/// Formats a multi-line warning describing duplicate `infc` binaries +/// on `PATH`. Returns an empty vector when `paths.len() <= 1`; the +/// caller is expected to check for the duplicate case before +/// rendering. +/// +/// The first entry is labelled `(active)` and the rest `(shadowed)` +/// because `which::which_all` iterates in the order `PATH` would +/// resolve — which is also the order `infs build` effectively uses +/// when the `PATH` priority fires. +#[must_use] +pub fn format_duplicate_path_warning(paths: &[PathBuf]) -> Vec { + if paths.len() <= 1 { + return Vec::new(); + } + + let mut lines = Vec::new(); + lines.push("Multiple infc binaries found on PATH (first wins):".to_string()); + for (idx, path) in paths.iter().enumerate() { + let tag = if idx == 0 { "active" } else { "shadowed" }; + lines.push(format!(" {}. {} ({})", idx + 1, path.display(), tag)); + } + lines.push("Use INFC_PATH to pin a specific binary.".to_string()); + lines +} + /// Formats a user-friendly warning message for PATH conflicts. /// /// The message includes: @@ -352,4 +404,128 @@ mod tests { assert!(debug_str.contains("PathConflict")); assert!(debug_str.contains("test")); } + + /// Creates an executable `infc[.exe]` stub in `dir` so `which::which_all` + /// will count it as a match. + fn write_executable_infc_stub(dir: &Path) -> PathBuf { + let platform = Platform::detect().unwrap(); + let name = format!("{}{}", ToolchainPaths::MANAGED_BINARY, platform.executable_extension()); + let stub = dir.join(&name); + std::fs::write(&stub, b"#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&stub).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&stub, perms).unwrap(); + } + stub + } + + #[test] + #[serial_test::serial] + fn multiple_infc_on_path_all_reported() { + let dir_a = assert_fs::TempDir::new().unwrap(); + let dir_b = assert_fs::TempDir::new().unwrap(); + let stub_a = write_executable_infc_stub(dir_a.path()); + let stub_b = write_executable_infc_stub(dir_b.path()); + + let original_path = env::var("PATH").unwrap_or_default(); + let joined = env::join_paths([dir_a.path(), dir_b.path()]).unwrap(); + + // SAFETY: serialized; cleaned up below regardless of outcome. + unsafe { + env::set_var("PATH", &joined); + } + + let found = enumerate_infc_on_path(); + + // SAFETY: restore PATH before assertions so a panic doesn't leak state. + unsafe { + env::set_var("PATH", original_path); + } + + // Some CI sandboxes resolve symlinks asymmetrically; accept either + // the raw tempdir path or its canonical form, but require PATH order. + let canon = |p: &Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf()); + assert_eq!(found.len(), 2, "expected both infc stubs to be enumerated: {found:?}"); + assert_eq!(canon(&found[0]), canon(&stub_a), "first hit must be the first PATH entry"); + assert_eq!(canon(&found[1]), canon(&stub_b), "second hit must be the second PATH entry"); + } + + #[test] + #[serial_test::serial] + fn single_infc_on_path_no_duplicate_warning() { + let dir = assert_fs::TempDir::new().unwrap(); + write_executable_infc_stub(dir.path()); + + let original_path = env::var("PATH").unwrap_or_default(); + + // SAFETY: serialized; cleaned up below regardless of outcome. + unsafe { + env::set_var("PATH", dir.path()); + } + + let found = enumerate_infc_on_path(); + + // SAFETY: restore PATH before assertions. + unsafe { + env::set_var("PATH", original_path); + } + + assert_eq!(found.len(), 1, "exactly one infc should be visible: {found:?}"); + // A single entry must not trigger the duplicate-warning block. + let warning = format_duplicate_path_warning(&found); + assert!( + warning.is_empty(), + "single PATH entry must not produce a duplicate warning: {warning:?}" + ); + } + + #[test] + #[serial_test::serial] + fn no_infc_on_path_no_warning() { + let empty_dir = assert_fs::TempDir::new().unwrap(); + let original_path = env::var("PATH").unwrap_or_default(); + + // SAFETY: serialized; cleaned up below regardless of outcome. + unsafe { + env::set_var("PATH", empty_dir.path()); + } + + let found = enumerate_infc_on_path(); + + // SAFETY: restore PATH before assertions. + unsafe { + env::set_var("PATH", original_path); + } + + assert!( + found.is_empty(), + "empty PATH directory must yield no infc matches: {found:?}" + ); + let warning = format_duplicate_path_warning(&found); + assert!(warning.is_empty(), "empty enumeration must not warn"); + } + + #[test] + fn format_duplicate_path_warning_lists_active_and_shadowed() { + let paths = vec![ + PathBuf::from("/usr/local/bin/infc"), + PathBuf::from("/home/user/bin/infc"), + ]; + let lines = format_duplicate_path_warning(&paths); + + assert!(!lines.is_empty()); + assert!(lines.iter().any(|l| l.contains("Multiple infc binaries"))); + assert!(lines.iter().any(|l| l.contains("1. /usr/local/bin/infc (active)"))); + assert!(lines.iter().any(|l| l.contains("2. /home/user/bin/infc (shadowed)"))); + assert!(lines.iter().any(|l| l.contains("INFC_PATH"))); + } + + #[test] + fn format_duplicate_path_warning_empty_for_zero_paths() { + let lines = format_duplicate_path_warning(&[]); + assert!(lines.is_empty()); + } } diff --git a/apps/infs/src/toolchain/doctor.rs b/apps/infs/src/toolchain/doctor.rs index 7e8736c1..a844e4cd 100644 --- a/apps/infs/src/toolchain/doctor.rs +++ b/apps/infs/src/toolchain/doctor.rs @@ -11,6 +11,8 @@ //! - Default toolchain configuration //! - `infc` compiler binary presence +use super::conflict::enumerate_infc_on_path; +use super::resolver::{self, find_infc_with_source}; use super::{Platform, ToolchainPaths}; /// Generates a message for when no default toolchain is set. @@ -98,14 +100,23 @@ impl DoctorCheck { /// Runs all doctor checks and returns the results. /// /// This function aggregates all health checks into a single vector. +/// +/// Order matters: the VS Code extension parses the printed check lines in +/// order. New checks must be appended, never inserted mid-list, so the +/// extension's append-only parsing contract remains stable. pub fn run_all_checks() -> Vec { - vec![ + let mut checks = vec![ check_infs_binary(), check_platform(), check_toolchain_directory(), check_default_toolchain(), check_infc(), - ] + check_resolved_infc(), + ]; + if let Some(ambiguity) = check_resolution_ambiguity() { + checks.push(ambiguity); + } + checks } /// Checks if the infs binary is accessible in PATH. @@ -191,6 +202,11 @@ pub fn check_default_toolchain() -> DoctorCheck { } /// Checks if the infc compiler binary is available. +/// +/// Enumerates *every* `infc` on `PATH` (not just the first hit) so +/// developers can see shadowed copies at a glance. The output stays +/// on one line per the VS Code `[OK|WARN|FAIL] : ` +/// contract; duplicates are inlined with `; ` separators. #[must_use] pub fn check_infc() -> DoctorCheck { let Ok(platform) = Platform::detect() else { @@ -199,8 +215,27 @@ pub fn check_infc() -> DoctorCheck { let binary_with_ext = format!("infc{}", platform.executable_extension()); - if which::which(&binary_with_ext).is_ok() { - return DoctorCheck::ok("infc", format!("Found {binary_with_ext} in PATH")); + let on_path = enumerate_infc_on_path(); + match on_path.len() { + 0 => {} // Fall through to managed-toolchain check below. + 1 => { + return DoctorCheck::ok("infc", format!("Found {binary_with_ext} in PATH")); + } + _ => { + let enumerated = on_path + .iter() + .enumerate() + .map(|(idx, p)| format!("{}. {}", idx + 1, p.display())) + .collect::>() + .join("; "); + return DoctorCheck::warning( + "infc", + format!( + "{} {binary_with_ext} binaries on PATH (first wins): {enumerated}", + on_path.len() + ), + ); + } } let Ok(paths) = ToolchainPaths::new() else { @@ -231,6 +266,48 @@ pub fn check_infc() -> DoctorCheck { } } +/// Reports which `infc` binary `infs build` will actually invoke, and +/// which priority in [`find_infc_with_source`] fired. +/// +/// Complements [`check_infc`], which only confirms *availability*. This +/// check tells the user *why* one binary was selected over another — +/// critical for developers whose machine has both a workspace sibling and +/// a managed toolchain installed. +#[must_use] +pub fn check_resolved_infc() -> DoctorCheck { + match find_infc_with_source() { + Ok((path, source)) => DoctorCheck::ok( + "Resolved infc", + format!("{} (source: {})", path.display(), source.label()), + ), + Err(err) => DoctorCheck::warning("Resolved infc", err.to_string()), + } +} + +/// Warns when both a workspace sibling and a managed toolchain `infc` +/// exist — the exact ambiguity the priority-2 workspace-sibling rule was +/// introduced to handle silently. Surfacing it here gives developers a +/// clear knob: either intentionally run from the workspace, or remove the +/// stale managed install. +/// +/// Returns `None` when no ambiguity exists, so the check is elided from +/// output in the common single-source case. +#[must_use] +pub fn check_resolution_ambiguity() -> Option { + let sibling = resolver::workspace_sibling_infc()?; + let managed = resolver::managed_toolchain_infc()?; + Some(DoctorCheck::warning( + "Resolution ambiguity", + format!( + "both workspace sibling ({}) and managed toolchain ({}) exist; \ + workspace sibling wins. Remove the stale managed install or \ + set INFC_PATH to pin your choice.", + sibling.display(), + managed.display() + ), + )) +} + #[cfg(test)] mod tests { use super::*; @@ -262,8 +339,13 @@ mod tests { #[test] fn run_all_checks_returns_expected_count() { let checks = run_all_checks(); - // Checks: infs, platform, toolchain dir, default toolchain, infc - assert_eq!(checks.len(), 5); + // Base checks: infs, platform, toolchain dir, default toolchain, + // infc, resolved infc. Ambiguity check is conditional (0 or 1). + assert!( + checks.len() == 6 || checks.len() == 7, + "unexpected check count: {}", + checks.len() + ); } #[test] @@ -321,6 +403,32 @@ mod tests { std::fs::remove_dir_all(&temp_dir).ok(); } + #[test] + #[serial_test::serial] + fn check_resolved_infc_returns_valid_doctor_check() { + // find_infc_with_source may succeed (Ok) or fail (Warning) depending + // on the test environment — both are valid outcomes. We verify the + // returned DoctorCheck is well-formed and uses the expected name. + let check = check_resolved_infc(); + assert_eq!(check.name, "Resolved infc"); + assert!(!check.message.is_empty()); + assert!( + check.status == DoctorCheckStatus::Ok + || check.status == DoctorCheckStatus::Warning, + "unexpected status: {:?}", + check.status + ); + // When resolution succeeds, the message must contain the "source:" + // tag so users can see which priority fired. + if check.status == DoctorCheckStatus::Ok { + assert!( + check.message.contains("(source: "), + "ok message missing source tag: {}", + check.message + ); + } + } + #[test] fn no_default_toolchain_message_with_installed_versions() { let temp_dir = std::env::temp_dir().join("infs_test_doctor_no_default_installed"); diff --git a/apps/infs/src/toolchain/paths.rs b/apps/infs/src/toolchain/paths.rs index 0dc01e1e..4cf15912 100644 --- a/apps/infs/src/toolchain/paths.rs +++ b/apps/infs/src/toolchain/paths.rs @@ -38,6 +38,13 @@ const INFS_METADATA_FILE: &str = "infs.json"; /// Current schema version for infs metadata. const INFS_METADATA_SCHEMA_VERSION: u32 = 1; +/// Mirror of `resolver::verbose` — kept local so `paths.rs` does not depend on +/// resolver internals. Behavior is identical on purpose: both surfaces treat +/// `INFS_VERBOSE=1`/`INFS_VERBOSE=yes` as enabled, and empty/`"0"` as disabled. +fn infs_verbose() -> bool { + std::env::var_os("INFS_VERBOSE").is_some_and(|v| !v.is_empty() && v != "0") +} + /// Metadata about a toolchain installation. /// /// This is stored in each toolchain version directory as `.metadata.json`. @@ -355,12 +362,42 @@ impl ToolchainPaths { /// /// Returns an error if the metadata file cannot be written. pub fn ensure_infs_metadata(&self) -> Result<()> { - if !self.infs_metadata_path().exists() { + if self.infs_metadata_path().exists() { + // Best-effort: parse existing metadata so any schema_version drift + // surfaces under INFS_VERBOSE on first-launch initialization. The + // return value is intentionally discarded — we only want the + // validation side-effect; callers that need the data use + // [`Self::read_infs_metadata`] directly. + let _ = self.read_infs_metadata(); + } else { self.write_infs_metadata(&InfsMetadata::new())?; } Ok(()) } + /// Reads infs metadata from the root `infs.json` file. + /// + /// Returns `None` if the file does not exist or cannot be parsed. When the + /// stored `schema_version` does not match the compiled-in constant and + /// `INFS_VERBOSE` is set to a non-empty, non-"0" value, a non-fatal + /// warning is emitted to stderr. The metadata is still returned — this + /// is observability, not gating, so older installs keep working + /// on a best-effort basis. + #[must_use = "returns metadata without side effects (besides optional verbose logging)"] + pub fn read_infs_metadata(&self) -> Option { + let path = self.infs_metadata_path(); + let content = std::fs::read_to_string(&path).ok()?; + let metadata: InfsMetadata = serde_json::from_str(&content).ok()?; + if metadata.schema_version != INFS_METADATA_SCHEMA_VERSION && infs_verbose() { + eprintln!( + "infs: infs.json has schema_version {}, expected {}; \ + running with best-effort compatibility", + metadata.schema_version, INFS_METADATA_SCHEMA_VERSION + ); + } + Some(metadata) + } + /// Returns the path for a downloaded archive file. #[must_use = "returns the path without side effects"] pub fn download_path(&self, filename: &str) -> PathBuf { @@ -960,6 +997,87 @@ mod tests { std::fs::remove_dir_all(&temp_dir).ok(); } + #[test] + #[serial_test::serial] + fn read_infs_metadata_warns_on_schema_version_mismatch() { + // The warning is emitted to stderr as a side-effect under INFS_VERBOSE. + // Capturing stderr in-process requires a test-only dependency that + // isn't wired in (e.g. `gag`). Pragmatic fallback per the L7 plan: + // assert the Ok path — metadata still returned — and leave stderr + // inspection to manual verification. The mismatch branch executes + // under this test, so any panic/unwrap inside it would surface. + let temp_dir = env::temp_dir().join("infs_test_read_meta_mismatch"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(&temp_dir).unwrap(); + let paths = ToolchainPaths::with_root(temp_dir.clone()); + + let bogus = InfsMetadata { + version: "0.0.1-test".to_string(), + created_at: "2020-01-01T00:00:00Z".to_string(), + // Deliberately drifted from INFS_METADATA_SCHEMA_VERSION. + schema_version: 999, + }; + paths.write_infs_metadata(&bogus).unwrap(); + + // SAFETY: serialized test; restored in the cleanup block. + unsafe { + env::set_var("INFS_VERBOSE", "1"); + } + + let result = paths.read_infs_metadata(); + + // SAFETY: restore regardless of assertion outcome. + unsafe { + env::remove_var("INFS_VERBOSE"); + } + + let meta = result.expect("metadata should still be returned on mismatch"); + assert_eq!(meta.schema_version, 999); + assert_eq!(meta.version, "0.0.1-test"); + + std::fs::remove_dir_all(&temp_dir).ok(); + } + + #[test] + #[serial_test::serial] + fn read_infs_metadata_no_warning_when_version_matches() { + let temp_dir = env::temp_dir().join("infs_test_read_meta_match"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(&temp_dir).unwrap(); + let paths = ToolchainPaths::with_root(temp_dir.clone()); + + paths.ensure_infs_metadata().unwrap(); + + // SAFETY: serialized test; restored below. + unsafe { + env::set_var("INFS_VERBOSE", "1"); + } + + let result = paths.read_infs_metadata(); + + // SAFETY: restore regardless of outcome. + unsafe { + env::remove_var("INFS_VERBOSE"); + } + + let meta = result.expect("metadata should be returned for a fresh install"); + assert_eq!(meta.schema_version, INFS_METADATA_SCHEMA_VERSION); + + std::fs::remove_dir_all(&temp_dir).ok(); + } + + #[test] + fn read_infs_metadata_returns_none_when_file_missing() { + let temp_dir = env::temp_dir().join("infs_test_read_meta_missing"); + let _ = std::fs::remove_dir_all(&temp_dir); + std::fs::create_dir_all(&temp_dir).unwrap(); + let paths = ToolchainPaths::with_root(temp_dir.clone()); + + assert!(paths.read_infs_metadata().is_none()); + + std::fs::remove_dir_all(&temp_dir).ok(); + } + #[cfg(unix)] #[test] fn validate_symlinks_detects_broken_symlink() { diff --git a/apps/infs/src/toolchain/resolver.rs b/apps/infs/src/toolchain/resolver.rs index 8c3db724..6c6bbb49 100644 --- a/apps/infs/src/toolchain/resolver.rs +++ b/apps/infs/src/toolchain/resolver.rs @@ -4,12 +4,15 @@ //! across different installation contexts. The search order prioritizes: //! //! 1. Explicit override via `INFC_PATH` environment variable -//! 2. System PATH via `which::which("infc")` -//! 3. Managed toolchain at `~/.inference/toolchains/VERSION/infc` +//! 2. Cargo-workspace sibling at `target//infc[.exe]` +//! 3. System PATH via `which::which("infc")` +//! 4. Managed toolchain at `~/.inference/toolchains/VERSION/infc` //! //! ## Environment Variables //! //! - `INFC_PATH`: Explicit path to the infc binary (highest priority) +//! - `INFS_VERBOSE`: When set to a non-empty, non-"0" value, emits resolution +//! trace lines to stderr describing which priority resolved `infc` //! //! ## Example //! @@ -21,7 +24,7 @@ //! ``` use anyhow::{Context, Result, bail}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use crate::toolchain::paths::ToolchainPaths; use crate::toolchain::platform::Platform; @@ -29,38 +32,160 @@ use crate::toolchain::platform::Platform; /// Environment variable for explicit infc binary path override. const INFC_PATH_ENV: &str = "INFC_PATH"; -/// Locates the `infc` compiler binary. -/// -/// Searches for the infc binary in the following priority order: +/// Identifies which priority in [`find_infc_with_source`] resolved the binary. /// -/// 1. **`INFC_PATH` environment variable** - Explicit override for testing -/// or custom installations -/// 2. **System PATH** - Uses `which::which("infc")` to find infc in PATH -/// 3. **Managed toolchain** - Looks in `~/.inference/toolchains/VERSION/infc` -/// using the default toolchain version if set +/// The [`ResolutionSource::label`] method emits the exact strings used in +/// [`trace_resolved`], so trace output and doctor output stay in sync. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResolutionSource { + /// Resolved via the `INFC_PATH` environment variable (priority 1). + InfcPathEnv, + /// Resolved via the workspace sibling `target//infc` (priority 2). + WorkspaceSibling, + /// Resolved via `which::which("infc")` against the system `PATH` (priority 3). + SystemPath, + /// Resolved via the managed toolchain under `~/.inference/toolchains/` (priority 4). + ManagedToolchain, +} + +impl ResolutionSource { + /// Returns the human-readable label for this resolution source. + /// + /// The same string is emitted in `INFS_VERBOSE=1` trace lines, so `infs + /// doctor` and verbose build output agree. + #[must_use] + pub fn label(self) -> &'static str { + match self { + Self::InfcPathEnv => "INFC_PATH env", + Self::WorkspaceSibling => "workspace sibling", + Self::SystemPath => "PATH", + Self::ManagedToolchain => "managed toolchain", + } + } +} + +/// Returns true if `INFS_VERBOSE` is set to a non-empty non-"0" value. +fn verbose() -> bool { + std::env::var_os("INFS_VERBOSE").is_some_and(|v| !v.is_empty() && v != "0") +} + +/// Emits a resolution trace line to stderr under `INFS_VERBOSE`. +fn trace_resolved(source: ResolutionSource, path: &Path) { + if verbose() { + eprintln!( + "infs: resolved infc via {}: {}", + source.label(), + path.display() + ); + } +} + +// Testable seam for `std::env::current_exe()`. Under `#[cfg(test)]`, a +// thread-local override allows unit tests to simulate arbitrary executable +// locations without touching the real process state. Production builds +// always delegate to `std::env`. +#[cfg(test)] +thread_local! { + static CURRENT_EXE_OVERRIDE: std::cell::RefCell> + = const { std::cell::RefCell::new(None) }; +} + +fn current_exe_for_resolver() -> std::io::Result { + #[cfg(test)] + if let Some(p) = CURRENT_EXE_OVERRIDE.with(|c| c.borrow().clone()) { + return Ok(p); + } + std::env::current_exe() +} + +/// Priority-2 (relative to PATH): when `infs` was itself cargo-built into +/// `target//infs[.exe]`, a sibling `infc[.exe]` in the same dir is +/// assumed to be the paired build. Falls through silently on any error. /// -/// # Errors +/// Canonicalizes the current exe first for the common case where cargo +/// invokes via a symlink, but falls back to the raw path when +/// `canonicalize()` fails (broken symlinks, restricted ACLs, some +/// container `/proc/self/exe` setups). +pub(crate) fn workspace_sibling_infc() -> Option { + let raw = current_exe_for_resolver().ok()?; + let canonical = raw.canonicalize().ok(); + if canonical.is_none() && verbose() { + eprintln!( + "infs: canonicalize failed for {}; trying raw path", + raw.display() + ); + } + workspace_sibling_infc_from(canonical.as_deref().unwrap_or(&raw)) +} + +fn workspace_sibling_infc_from(exe: &Path) -> Option { + let platform = Platform::detect().ok()?; + let ext = platform.executable_extension(); + let dir = exe.parent()?; + let profile = dir.file_name()?.to_str()?; + if profile != "debug" && profile != "release" { + return None; + } + // Accept both the standard `target//` layout and the + // `target///` layout produced by `cargo build + // --target `. Nightly CI and cross-compilation builds + // routinely put the triple between `target` and the profile dir. + let grandparent = dir.parent()?; + let is_target = grandparent.file_name().and_then(|n| n.to_str()) == Some("target") + || grandparent + .parent() + .and_then(|gg| gg.file_name()) + .and_then(|n| n.to_str()) + == Some("target"); + if !is_target { + return None; + } + let expected_infs = format!("infs{ext}"); + let actual = exe.file_name()?.to_str()?; + let matches = if platform.is_windows() { + actual.eq_ignore_ascii_case(&expected_infs) + } else { + actual == expected_infs + }; + if !matches { + return None; + } + let candidate = dir.join(format!("infc{ext}")); + candidate.is_file().then_some(candidate) +} + +/// Returns the managed-toolchain `infc` path when the default toolchain is +/// installed and the binary exists. Returns `None` otherwise — the caller +/// decides whether to emit a diagnostic. /// -/// Returns an error if: -/// - `INFC_PATH` is set but the path does not exist -/// - No infc binary could be found in any location +/// Extracted so [`find_infc_with_source`] and doctor's ambiguity check can +/// both ask the same question without duplicating path construction. +pub(crate) fn managed_toolchain_infc() -> Option { + let paths = ToolchainPaths::new().ok()?; + let version = paths.get_default_version().ok().flatten()?; + let platform = Platform::detect().ok()?; + let ext = platform.executable_extension(); + let infc_name = format!("infc{ext}"); + let infc_path = paths.binary_path(&version, &infc_name); + infc_path.is_file().then_some(infc_path) +} + +/// Locates the `infc` compiler binary and reports which priority fired. /// -/// The error message provides helpful guidance on how to install infc. +/// Priorities match [`find_infc`]; callers that only need the path should +/// use that wrapper. Doctor and other diagnostic surfaces use this richer +/// form to tell the user *why* a particular binary was selected. /// -/// # Example +/// # Errors /// -/// ```rust,ignore -/// let infc_path = find_infc()?; -/// std::process::Command::new(&infc_path) -/// .arg("--help") -/// .status()?; -/// ``` -pub fn find_infc() -> Result { +/// Same as [`find_infc`]. +pub fn find_infc_with_source() -> Result<(PathBuf, ResolutionSource)> { // Priority 1: INFC_PATH environment variable if let Ok(path) = std::env::var(INFC_PATH_ENV) { let path = PathBuf::from(path); if path.exists() { - return Ok(path); + trace_resolved(ResolutionSource::InfcPathEnv, &path); + return Ok((path, ResolutionSource::InfcPathEnv)); } bail!( "INFC_PATH environment variable set to '{}', but file does not exist", @@ -68,24 +193,29 @@ pub fn find_infc() -> Result { ); } - // Priority 2: System PATH + // Priority 2: cargo-workspace sibling infc + if let Some(path) = workspace_sibling_infc() { + trace_resolved(ResolutionSource::WorkspaceSibling, &path); + return Ok((path, ResolutionSource::WorkspaceSibling)); + } + + // Priority 3: System PATH if let Ok(path) = which::which("infc") { - return Ok(path); + trace_resolved(ResolutionSource::SystemPath, &path); + return Ok((path, ResolutionSource::SystemPath)); } - // Priority 3: Managed toolchain + // Priority 4: Managed toolchain + if let Some(path) = managed_toolchain_infc() { + trace_resolved(ResolutionSource::ManagedToolchain, &path); + return Ok((path, ResolutionSource::ManagedToolchain)); + } + // If a default toolchain is configured but the binary is missing, surface + // the detection attempt so platform-detection errors still bubble up. if let Ok(paths) = ToolchainPaths::new() - && let Ok(Some(version)) = paths.get_default_version() + && let Ok(Some(_)) = paths.get_default_version() { - let platform = - Platform::detect().context("Failed to detect platform while searching for infc")?; - let ext = platform.executable_extension(); - let infc_name = format!("infc{ext}"); - let infc_path = paths.binary_path(&version, &infc_name); - - if infc_path.exists() { - return Ok(infc_path); - } + Platform::detect().context("Failed to detect platform while searching for infc")?; } bail!( @@ -98,15 +228,72 @@ pub fn find_infc() -> Result { ); } +/// Locates the `infc` compiler binary. +/// +/// Searches for the infc binary in the following priority order: +/// +/// 1. **`INFC_PATH` environment variable** - Explicit override for testing +/// or custom installations (hardest override) +/// 2. **Workspace sibling** - When `infs` is running from +/// `target//infs[.exe]`, prefer the paired +/// `target//infc[.exe]` if present +/// 3. **System PATH** - Uses `which::which("infc")` to find infc in PATH +/// 4. **Managed toolchain** - Looks in `~/.inference/toolchains/VERSION/infc` +/// using the default toolchain version if set +/// +/// Fallthrough on any priority's failure is intentional; each priority is +/// best-effort. Set `INFS_VERBOSE=1` to trace which priority resolved +/// `infc` on stderr. +/// +/// # Errors +/// +/// Returns an error if: +/// - `INFC_PATH` is set but the path does not exist +/// - No infc binary could be found in any location +/// +/// The error message provides helpful guidance on how to install infc. +/// +/// # Example +/// +/// ```rust,ignore +/// let infc_path = find_infc()?; +/// std::process::Command::new(&infc_path) +/// .arg("--help") +/// .status()?; +/// ``` +pub fn find_infc() -> Result { + find_infc_with_source().map(|(path, _)| path) +} + #[cfg(test)] mod tests { use super::*; use std::env; + /// RAII guard that restores `CURRENT_EXE_OVERRIDE` on drop. + struct ExeOverrideGuard; + + impl ExeOverrideGuard { + fn set(path: PathBuf) -> Self { + CURRENT_EXE_OVERRIDE.with(|c| *c.borrow_mut() = Some(path)); + Self + } + } + + impl Drop for ExeOverrideGuard { + fn drop(&mut self) { + CURRENT_EXE_OVERRIDE.with(|c| *c.borrow_mut() = None); + } + } + + fn exe_name(name: &str) -> String { + let ext = Platform::detect().unwrap().executable_extension(); + format!("{name}{ext}") + } + #[test] #[serial_test::serial] fn infc_path_env_nonexistent_returns_error() { - // Use a path that definitely doesn't exist let path = "/nonexistent/path/to/infc"; // SAFETY: This test runs in isolation and we restore the env var at the end. @@ -129,7 +316,6 @@ mod tests { #[test] #[serial_test::serial] fn error_message_contains_installation_instructions() { - // Temporarily override PATH to ensure infc is not found let original_path = env::var("PATH").unwrap_or_default(); // SAFETY: This test runs in isolation and we restore the env vars at the end. @@ -137,11 +323,14 @@ mod tests { env::set_var("PATH", ""); env::remove_var(INFC_PATH_ENV); - // Use isolated INFERENCE_HOME to ensure no managed toolchain let temp_dir = env::temp_dir().join("infs_test_resolver"); env::set_var("INFERENCE_HOME", &temp_dir); } + // Also suppress the workspace-sibling priority so the error path fires + // regardless of how the test binary itself is laid out. + let _guard = ExeOverrideGuard::set(PathBuf::from("/nonexistent/elsewhere/infs")); + let result = find_infc(); // SAFETY: Cleanup - restoring previous state @@ -157,4 +346,363 @@ mod tests { "Error should contain installation instructions: {err}" ); } + + #[test] + fn sibling_infc_found_when_exe_in_target_debug() { + let temp = assert_fs::TempDir::new().unwrap(); + let debug = temp.path().join("target").join("debug"); + std::fs::create_dir_all(&debug).unwrap(); + let infs_path = debug.join(exe_name("infs")); + let infc_path = debug.join(exe_name("infc")); + std::fs::write(&infs_path, b"").unwrap(); + std::fs::write(&infc_path, b"").unwrap(); + + let result = workspace_sibling_infc_from(&infs_path); + assert_eq!(result.as_deref(), Some(infc_path.as_path())); + } + + #[test] + fn sibling_infc_found_when_exe_in_target_release() { + let temp = assert_fs::TempDir::new().unwrap(); + let release = temp.path().join("target").join("release"); + std::fs::create_dir_all(&release).unwrap(); + let infs_path = release.join(exe_name("infs")); + let infc_path = release.join(exe_name("infc")); + std::fs::write(&infs_path, b"").unwrap(); + std::fs::write(&infc_path, b"").unwrap(); + + let result = workspace_sibling_infc_from(&infs_path); + assert_eq!(result.as_deref(), Some(infc_path.as_path())); + } + + #[test] + fn sibling_returns_none_when_exe_not_in_target() { + // No target// ancestor — should reject regardless of file presence. + let fabricated = PathBuf::from("/usr/local/bin").join(exe_name("infs")); + let result = workspace_sibling_infc_from(&fabricated); + assert_eq!(result, None); + } + + #[test] + fn sibling_returns_none_when_only_infs_present() { + let temp = assert_fs::TempDir::new().unwrap(); + let debug = temp.path().join("target").join("debug"); + std::fs::create_dir_all(&debug).unwrap(); + let infs_path = debug.join(exe_name("infs")); + std::fs::write(&infs_path, b"").unwrap(); + // Deliberately do not create infc. + + let result = workspace_sibling_infc_from(&infs_path); + assert_eq!(result, None); + } + + #[test] + fn sibling_returns_none_when_parent_not_target() { + let temp = assert_fs::TempDir::new().unwrap(); + // build/debug/ instead of target/debug/ — shape must reject. + let build = temp.path().join("build").join("debug"); + std::fs::create_dir_all(&build).unwrap(); + let infs_path = build.join(exe_name("infs")); + let infc_path = build.join(exe_name("infc")); + std::fs::write(&infs_path, b"").unwrap(); + std::fs::write(&infc_path, b"").unwrap(); + + let result = workspace_sibling_infc_from(&infs_path); + assert_eq!(result, None); + } + + #[test] + fn sibling_infc_found_when_exe_in_target_triple_debug() { + // `cargo build --target x86_64-unknown-linux-gnu` produces + // `target//debug/` — the sibling heuristic must accept it. + let temp = assert_fs::TempDir::new().unwrap(); + let debug = temp + .path() + .join("target") + .join("x86_64-unknown-linux-gnu") + .join("debug"); + std::fs::create_dir_all(&debug).unwrap(); + let infs_path = debug.join(exe_name("infs")); + let infc_path = debug.join(exe_name("infc")); + std::fs::write(&infs_path, b"").unwrap(); + std::fs::write(&infc_path, b"").unwrap(); + + let result = workspace_sibling_infc_from(&infs_path); + assert_eq!(result.as_deref(), Some(infc_path.as_path())); + } + + #[test] + fn sibling_infc_found_when_exe_in_target_triple_release() { + let temp = assert_fs::TempDir::new().unwrap(); + let release = temp + .path() + .join("target") + .join("aarch64-apple-darwin") + .join("release"); + std::fs::create_dir_all(&release).unwrap(); + let infs_path = release.join(exe_name("infs")); + let infc_path = release.join(exe_name("infc")); + std::fs::write(&infs_path, b"").unwrap(); + std::fs::write(&infc_path, b"").unwrap(); + + let result = workspace_sibling_infc_from(&infs_path); + assert_eq!(result.as_deref(), Some(infc_path.as_path())); + } + + #[test] + fn sibling_returns_none_when_no_target_ancestor_at_either_depth() { + // foo/bar/debug/infs — neither parent nor grandparent is named + // "target"; must reject even though the profile dir is valid and + // a sibling infc exists. + let temp = assert_fs::TempDir::new().unwrap(); + let debug = temp.path().join("foo").join("bar").join("debug"); + std::fs::create_dir_all(&debug).unwrap(); + let infs_path = debug.join(exe_name("infs")); + let infc_path = debug.join(exe_name("infc")); + std::fs::write(&infs_path, b"").unwrap(); + std::fs::write(&infc_path, b"").unwrap(); + + let result = workspace_sibling_infc_from(&infs_path); + assert_eq!(result, None); + } + + #[test] + #[serial_test::serial] + fn infc_path_env_overrides_sibling() { + // Build a plausible workspace-sibling layout that would satisfy L1... + let workspace = assert_fs::TempDir::new().unwrap(); + let debug = workspace.path().join("target").join("debug"); + std::fs::create_dir_all(&debug).unwrap(); + let infs_path = debug.join(exe_name("infs")); + let sibling_infc = debug.join(exe_name("infc")); + std::fs::write(&infs_path, b"").unwrap(); + std::fs::write(&sibling_infc, b"").unwrap(); + + // ...then also set INFC_PATH to a distinct file. + let env_dir = assert_fs::TempDir::new().unwrap(); + let env_infc = env_dir.path().join(exe_name("infc")); + std::fs::write(&env_infc, b"").unwrap(); + + let _guard = ExeOverrideGuard::set(infs_path); + + // SAFETY: serialized test; we restore immediately after. + unsafe { + env::set_var(INFC_PATH_ENV, &env_infc); + } + + let result = find_infc(); + + // SAFETY: cleanup regardless of assertion outcome. + unsafe { + env::remove_var(INFC_PATH_ENV); + } + + let resolved = result.unwrap(); + assert_eq!( + resolved, env_infc, + "INFC_PATH must outrank the workspace sibling" + ); + assert_ne!( + resolved, sibling_infc, + "workspace sibling must not win when INFC_PATH is set" + ); + } + + #[test] + #[serial_test::serial] + fn verbose_false_when_unset() { + // SAFETY: serialized; state restored. + unsafe { + env::remove_var("INFS_VERBOSE"); + } + assert!(!verbose()); + } + + #[test] + #[serial_test::serial] + fn verbose_true_when_set() { + // SAFETY: serialized; state restored. + unsafe { + env::set_var("INFS_VERBOSE", "1"); + } + let got = verbose(); + unsafe { + env::remove_var("INFS_VERBOSE"); + } + assert!(got); + } + + #[test] + #[serial_test::serial] + fn verbose_false_when_set_to_zero() { + // SAFETY: serialized; state restored. + unsafe { + env::set_var("INFS_VERBOSE", "0"); + } + let got = verbose(); + unsafe { + env::remove_var("INFS_VERBOSE"); + } + assert!(!got); + } + + #[test] + #[serial_test::serial] + fn verbose_false_when_empty_string() { + // SAFETY: serialized; state restored. + unsafe { + env::set_var("INFS_VERBOSE", ""); + } + let got = verbose(); + unsafe { + env::remove_var("INFS_VERBOSE"); + } + assert!(!got); + } + + #[test] + fn broken_exe_override_falls_through_to_none() { + // Inject a path that doesn't exist on disk. canonicalize() will fail; + // the raw path shape (/nonexistent/elsewhere/infs) doesn't match + // target//infs either, so the expected outcome is None. + let _guard = ExeOverrideGuard::set(PathBuf::from("/nonexistent/elsewhere/infs")); + assert_eq!(workspace_sibling_infc(), None); + } + + #[test] + fn canonicalize_fails_but_raw_matches_shape_without_sibling() { + // Fabricate a raw path that MATCHES the target//infs shape + // but points to a nonexistent location. canonicalize() fails; raw + // path passes shape check; but the sibling infc doesn't exist, so + // the function still returns None. Exercises the raw-path fallback + // branch end-to-end. + let fabricated = PathBuf::from("/nonexistent") + .join("target") + .join("debug") + .join(exe_name("infs")); + let _guard = ExeOverrideGuard::set(fabricated); + assert_eq!(workspace_sibling_infc(), None); + } + + #[test] + fn resolution_source_labels_match_trace_strings() { + // Label strings are a public contract: doctor output and verbose + // trace lines both use them, so they must not drift. + assert_eq!(ResolutionSource::InfcPathEnv.label(), "INFC_PATH env"); + assert_eq!( + ResolutionSource::WorkspaceSibling.label(), + "workspace sibling" + ); + assert_eq!(ResolutionSource::SystemPath.label(), "PATH"); + assert_eq!( + ResolutionSource::ManagedToolchain.label(), + "managed toolchain" + ); + } + + #[test] + #[serial_test::serial] + fn find_infc_with_source_reports_workspace_sibling() { + // Fabricate a target/debug/{infs,infc} layout via the CURRENT_EXE_OVERRIDE + // seam so the workspace-sibling priority wins deterministically. + let temp = assert_fs::TempDir::new().unwrap(); + let debug = temp.path().join("target").join("debug"); + std::fs::create_dir_all(&debug).unwrap(); + let infs_path = debug.join(exe_name("infs")); + let infc_path = debug.join(exe_name("infc")); + std::fs::write(&infs_path, b"").unwrap(); + std::fs::write(&infc_path, b"").unwrap(); + + // SAFETY: serialized test; cleanup happens regardless of outcome. + unsafe { + env::remove_var(INFC_PATH_ENV); + } + let _guard = ExeOverrideGuard::set(infs_path); + + let result = find_infc_with_source(); + + let (path, source) = result.unwrap(); + assert_eq!(source, ResolutionSource::WorkspaceSibling); + assert_eq!( + path.canonicalize().unwrap(), + infc_path.canonicalize().unwrap() + ); + } + + #[test] + #[serial_test::serial] + fn find_infc_with_source_reports_infc_path_env() { + let env_dir = assert_fs::TempDir::new().unwrap(); + let env_infc = env_dir.path().join(exe_name("infc")); + std::fs::write(&env_infc, b"").unwrap(); + + // Neutralize the workspace-sibling priority so it cannot accidentally + // fire before INFC_PATH — the test is about priority-1 winning. + let _guard = ExeOverrideGuard::set(PathBuf::from("/nonexistent/elsewhere/infs")); + + // SAFETY: serialized test; env restored below. + unsafe { + env::set_var(INFC_PATH_ENV, &env_infc); + } + + let result = find_infc_with_source(); + + // SAFETY: cleanup regardless of outcome. + unsafe { + env::remove_var(INFC_PATH_ENV); + } + + let (path, source) = result.unwrap(); + assert_eq!(source, ResolutionSource::InfcPathEnv); + assert_eq!(path, env_infc); + } + + #[test] + #[serial_test::serial] + fn find_infc_with_source_reports_path() { + // Fabricate an infc on a dedicated PATH dir, then point PATH at it. + // The test skips gracefully when tempfile symlinks prevent `which` + // from finding the stub (e.g. restricted CI sandboxes). + let path_dir = assert_fs::TempDir::new().unwrap(); + let stub = path_dir.path().join(exe_name("infc")); + std::fs::write(&stub, b"#!/bin/sh\nexit 0\n").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&stub).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&stub, perms).unwrap(); + } + + let original_path = env::var("PATH").unwrap_or_default(); + // Neutralize workspace-sibling priority so PATH lookup is reached. + let _guard = ExeOverrideGuard::set(PathBuf::from("/nonexistent/elsewhere/infs")); + + // SAFETY: serialized test; env restored below. + unsafe { + env::remove_var(INFC_PATH_ENV); + env::set_var("PATH", path_dir.path()); + } + + let result = find_infc_with_source(); + + // SAFETY: restore PATH regardless of outcome. + unsafe { + env::set_var("PATH", original_path); + } + + // If `which` couldn't locate our stub in this environment, fall back + // to the managed-toolchain branch or a not-found error. Both are + // acceptable here — what we're asserting is that when PATH *does* + // win, the reported source is SystemPath. + if let Ok((path, source)) = result + && source == ResolutionSource::SystemPath + { + assert_eq!( + path.canonicalize().unwrap(), + stub.canonicalize().unwrap(), + "PATH resolution must return the fabricated stub" + ); + } + } } diff --git a/apps/infs/tests/cli_integration.rs b/apps/infs/tests/cli_integration.rs index 1d51852f..a29ed5af 100644 --- a/apps/infs/tests/cli_integration.rs +++ b/apps/infs/tests/cli_integration.rs @@ -741,6 +741,120 @@ fn doctor_shows_all_checks() { .stdout(predicate::str::contains("infc")); } +/// Verifies that `infs doctor` output respects the VS Code extension's line contract. +/// +/// The VS Code extension at `editors/vscode/src/toolchain/doctor.ts:32` parses check +/// lines with the regex `/^\s+\[(OK|WARN|FAIL)]\s+(.+?):\s+(.*)/`. Any change to +/// the line shape breaks the extension's doctor rendering. This test locks the +/// format in place so drift is caught in CI before it reaches editors/vscode. +/// +/// **Test setup**: Runs `infs doctor` with an isolated `INFERENCE_HOME` so the +/// user's real toolchain state does not influence the output, and with +/// `INFC_PATH` removed so resolver priorities behave deterministically. +#[test] +fn doctor_output_respects_vscode_check_line_contract() { + let check_pattern = + regex::Regex::new(r"^\s+\[(OK|WARN|FAIL)]\s+(.+?):\s+(.*)").unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let output = Command::new(assert_cmd::cargo::cargo_bin!("infs")) + .arg("doctor") + .env("INFERENCE_HOME", temp.path()) + .env_remove("INFC_PATH") + .output() + .expect("doctor should run"); + + let stdout = String::from_utf8_lossy(&output.stdout); + + let check_lines: Vec<_> = stdout + .lines() + .filter(|l| l.trim_start().starts_with('[')) + .collect(); + + assert!( + !check_lines.is_empty(), + "doctor produced zero check lines. Output was:\n{stdout}" + ); + + for line in &check_lines { + assert!( + check_pattern.is_match(line), + "line violates VS Code contract (editors/vscode/src/toolchain/doctor.ts): {line:?}" + ); + } +} + +/// Regression test for the PATH-conflict block in `infs doctor`. +/// +/// The baseline `doctor_output_respects_vscode_check_line_contract` test +/// does not trigger the conflict branch — with a pristine `INFERENCE_HOME`, +/// `detect_path_conflicts` returns empty. This test builds a layout where +/// `INFERENCE_HOME/bin/infc` exists *and* a differently-located `infc` is +/// visible on `PATH`, so `detect_path_conflicts` reports a mismatch and +/// the `[WARN] PATH conflict: …` header is actually emitted. Then the same +/// VS Code regex must still match every bracketed line. +/// +/// Gated on `unix` because the stub invocation relies on a `#!/bin/sh` +/// shebang with chmod +x — Windows would need a distinct stub builder. +#[cfg(unix)] +#[test] +#[serial_test::serial] +fn doctor_output_respects_vscode_contract_on_path_conflict() { + use std::os::unix::fs::PermissionsExt; + + let check_pattern = + regex::Regex::new(r"^\s+\[(OK|WARN|FAIL)]\s+(.+?):\s+(.*)").unwrap(); + + // Build a managed toolchain layout: INFERENCE_HOME/bin/infc must exist + // so `detect_path_conflicts` considers the expected location "real". + let home = assert_fs::TempDir::new().unwrap(); + let bin_dir = home.path().join("bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + let managed_infc = bin_dir.join("infc"); + std::fs::write(&managed_infc, b"#!/bin/sh\nexit 0\n").unwrap(); + let mut perms = std::fs::metadata(&managed_infc).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&managed_infc, perms).unwrap(); + + // Build a separate PATH dir with its own infc stub; pointing PATH here + // makes `which::which` resolve to a path that differs from `managed_infc`. + let stub_dir = assert_fs::TempDir::new().unwrap(); + let stub_infc = stub_dir.path().join("infc"); + std::fs::write(&stub_infc, b"#!/bin/sh\nexit 0\n").unwrap(); + let mut perms = std::fs::metadata(&stub_infc).unwrap().permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&stub_infc, perms).unwrap(); + + let output = Command::new(assert_cmd::cargo::cargo_bin!("infs")) + .arg("doctor") + .env("INFERENCE_HOME", home.path()) + .env("PATH", stub_dir.path()) + .env_remove("INFC_PATH") + .output() + .expect("doctor should run"); + + let stdout = String::from_utf8_lossy(&output.stdout); + + let check_lines: Vec<_> = stdout + .lines() + .filter(|l| l.trim_start().starts_with('[')) + .collect(); + + assert!( + check_lines + .iter() + .any(|l| l.contains("PATH conflict:")), + "expected a `[WARN] PATH conflict: …` line. Output was:\n{stdout}" + ); + + for line in &check_lines { + assert!( + check_pattern.is_match(line), + "line violates VS Code contract (editors/vscode/src/toolchain/doctor.ts): {line:?}" + ); + } +} + /// Verifies that `infs doctor` shows the checking message. /// /// **Expected behavior**: Output contains the initial "Checking" message. diff --git a/core/cli/Cargo.toml b/core/cli/Cargo.toml index e5a22b37..eb6f99c8 100644 --- a/core/cli/Cargo.toml +++ b/core/cli/Cargo.toml @@ -5,6 +5,7 @@ edition = { workspace = true } license = { workspace = true } homepage = { workspace = true } repository = { workspace = true } +build = "build.rs" [dependencies] clap = { version = "4.5.54", features = ["derive"] } @@ -12,6 +13,7 @@ walkdir = "2.5.0" anyhow.workspace = true inference.workspace = true inference-wasm-codegen.workspace = true +inference-compiler-interface.workspace = true [dev-dependencies] assert_cmd = "2.1.1" diff --git a/core/cli/build.rs b/core/cli/build.rs new file mode 100644 index 00000000..077b78a5 --- /dev/null +++ b/core/cli/build.rs @@ -0,0 +1,75 @@ +//! Build script for infc CLI. +//! +//! Sets compile-time environment variables for version information. + +use std::process::Command; + +fn main() { + // Set git commit hash + let commit = get_git_commit(); + println!("cargo:rustc-env=INFC_GIT_COMMIT={commit}"); + + // Emit rerun-if-changed paths so cargo re-runs this script whenever + // HEAD moves. `.git` can be a file (worktrees, submodules) pointing at + // a separate gitdir, so the literal `/.git/HEAD` path is + // unreliable. Ask git where HEAD actually lives, then also watch the + // ref HEAD points at and packed-refs for branches that only exist + // there. Any failure is silent — a missing .git must not break the + // build. + emit_git_rerun_paths(); +} + +/// Emits `cargo:rerun-if-changed` lines for the git HEAD file, the ref it +/// points at (when HEAD is a symbolic ref), and packed-refs. Uses `git +/// rev-parse --git-path` so worktrees and submodules resolve to the real +/// on-disk location rather than the `.git` pointer file. +fn emit_git_rerun_paths() { + let Some(head_path) = git_path("HEAD") else { + return; + }; + println!("cargo:rerun-if-changed={head_path}"); + + if let Ok(head_contents) = std::fs::read_to_string(&head_path) + && let Some(ref_name) = head_contents.strip_prefix("ref: ").map(str::trim) + && !ref_name.is_empty() + && let Some(ref_path) = git_path(ref_name) + { + println!("cargo:rerun-if-changed={ref_path}"); + } + + if let Some(packed_refs) = git_path("packed-refs") { + println!("cargo:rerun-if-changed={packed_refs}"); + } +} + +/// Returns `git rev-parse --git-path ` as a trimmed string, or +/// `None` if git is unavailable, the command fails, or stdout is empty. +fn git_path(name: &str) -> Option { + let output = Command::new("git") + .args(["rev-parse", "--git-path", name]) + .output() + .ok()?; + if !output.status.success() { + return None; + } + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if path.is_empty() { None } else { Some(path) } +} + +/// Gets the short git commit hash. +fn get_git_commit() -> String { + let output = Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .output(); + + if let Ok(output) = output + && output.status.success() + { + let hash = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !hash.is_empty() { + return hash; + } + } + + "unknown".to_string() +} diff --git a/core/cli/src/main.rs b/core/cli/src/main.rs index b06042a4..55a3fb55 100644 --- a/core/cli/src/main.rs +++ b/core/cli/src/main.rs @@ -216,7 +216,26 @@ pub(crate) fn normalize_args(args: &mut Cli) { #[allow(clippy::too_many_lines)] fn main() { let mut args = Cli::parse(); - if !args.path.exists() { + + if args.commit_hash { + println!("{}", env!("INFC_GIT_COMMIT")); + process::exit(0); + } + + if args.abi_version { + println!( + "{}.{}", + inference_compiler_interface::COMPILER_ABI_MAJOR, + inference_compiler_interface::COMPILER_ABI_MINOR, + ); + process::exit(0); + } + + let Some(path) = args.path.clone() else { + eprintln!("Error: source file argument required"); + process::exit(1); + }; + if !path.exists() { eprintln!("Error: path not found"); process::exit(1); } @@ -228,7 +247,7 @@ fn main() { let need_analyze = args.analyze; let need_codegen = args.codegen; - let source_code = match fs::read_to_string(&args.path) { + let source_code = match fs::read_to_string(&path) { Ok(content) => content, Err(e) => { eprintln!("Error reading source file: {e}"); @@ -239,7 +258,7 @@ fn main() { if need_codegen || need_analyze || need_parse { match parse(source_code.as_str()) { Ok(ast) => { - println!("Parsed: {}", args.path.display()); + println!("Parsed: {}", path.display()); t_ast = Some(ast); } Err(e) => { @@ -275,7 +294,7 @@ fn main() { } } typed_context = Some(tctx); - println!("Analyzed: {}", args.path.display()); + println!("Analyzed: {}", path.display()); } } } @@ -296,8 +315,7 @@ fn main() { } }; println!("Codegen complete"); - let source_fname = args - .path + let source_fname = path .file_stem() .unwrap_or_else(|| std::ffi::OsStr::new("module")) .to_str() @@ -348,12 +366,14 @@ mod tests { fn make_args(parse: bool, analyze: bool, codegen: bool) -> Cli { Cli { - path: PathBuf::from("test.inf"), + path: Some(PathBuf::from("test.inf")), parse, analyze, codegen, generate_wasm_output: false, generate_v_output: false, + commit_hash: false, + abi_version: false, } } diff --git a/core/cli/src/parser.rs b/core/cli/src/parser.rs index f0a012dd..ab0699fc 100644 --- a/core/cli/src/parser.rs +++ b/core/cli/src/parser.rs @@ -68,7 +68,11 @@ pub(crate) struct Cli { /// /// Currently only single-file compilation is supported. Multi-file projects /// and project file (`.infp`) support is planned for future releases. - pub(crate) path: std::path::PathBuf, + /// + /// Optional so informational flags (e.g. `--commit-hash`) can run without + /// a source file argument. Regular compilation still requires a path and + /// exits with an error if one is not supplied. + pub(crate) path: Option, /// Run the parse phase to build the typed AST. /// @@ -130,4 +134,18 @@ pub(crate) struct Cli { /// Rocq proof assistant. #[clap(short = 'v', action = clap::ArgAction::SetTrue)] pub(crate) generate_v_output: bool, + + /// Print the git commit hash embedded at build time and exit 0. + /// + /// Used by `infs build` to detect version drift between paired `infs` and + /// `infc` binaries. Does not require a source file argument. + #[clap(long = "commit-hash", action = clap::ArgAction::SetTrue)] + pub(crate) commit_hash: bool, + + /// Print the compiler ABI version (`.`) and exit 0. + /// + /// Used by `infs build` to verify that the invoked `infc` speaks a CLI/IO + /// contract it understands. Does not require a source file argument. + #[clap(long = "abi-version", action = clap::ArgAction::SetTrue)] + pub(crate) abi_version: bool, } diff --git a/core/cli/tests/cli_integration.rs b/core/cli/tests/cli_integration.rs index 43aa5668..73de00e0 100644 --- a/core/cli/tests/cli_integration.rs +++ b/core/cli/tests/cli_integration.rs @@ -196,3 +196,44 @@ fn fails_with_parse_error() { .failure() .stderr(predicate::str::contains("Parse error")); } + +/// Verifies that `--commit-hash` prints the embedded git commit and exits 0 +/// without requiring a source file argument. +/// +/// **Expected behavior**: Exit with code 0, print a non-empty commit string +/// to stdout (either the short git hash or the `unknown` fallback). +#[test] +fn commit_hash_flag_prints_and_exits() { + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("infc")); + cmd.arg("--commit-hash"); + let assert = cmd.assert().success(); + let output = assert.get_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + let hash = stdout.trim(); + assert!(!hash.is_empty(), "commit-hash stdout was empty"); + assert!( + hash == "unknown" || hash.chars().all(|c| c.is_ascii_hexdigit()), + "commit-hash stdout was not hex or 'unknown': {hash:?}" + ); +} + +/// Verifies that `--abi-version` prints the compiler ABI version and exits 0 +/// without requiring a source file argument. +/// +/// **Expected behavior**: Exit with code 0, print `.` to stdout +/// matching the constants exported by `inference-compiler-interface`. +#[test] +fn abi_version_flag_prints_and_exits() { + let mut cmd = Command::new(assert_cmd::cargo::cargo_bin!("infc")); + cmd.arg("--abi-version"); + let assert = cmd.assert().success(); + let output = assert.get_output(); + let stdout = String::from_utf8_lossy(&output.stdout); + let version = stdout.trim(); + let expected = format!( + "{}.{}", + inference_compiler_interface::COMPILER_ABI_MAJOR, + inference_compiler_interface::COMPILER_ABI_MINOR, + ); + assert_eq!(version, expected); +} diff --git a/core/compiler-interface/Cargo.toml b/core/compiler-interface/Cargo.toml new file mode 100644 index 00000000..5fb9b178 --- /dev/null +++ b/core/compiler-interface/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "inference-compiler-interface" +version = { workspace = true } +edition = { workspace = true } +license = { workspace = true } +homepage = { workspace = true } +repository = { workspace = true } +description = "Shared compiler ABI version constants for infs/infc handshake" + +[dependencies] diff --git a/core/compiler-interface/src/lib.rs b/core/compiler-interface/src/lib.rs new file mode 100644 index 00000000..f0f8b066 --- /dev/null +++ b/core/compiler-interface/src/lib.rs @@ -0,0 +1,17 @@ +//! Compiler interface version constants shared by `infs` and `infc`. +//! +//! The ABI (application binary interface) here means the set of CLI flags, +//! stdin/stdout contract, and exit codes that `infs` relies on when +//! invoking `infc` as a subprocess. Bump the major on any breaking change; +//! bump the minor on additive, backward-compatible changes. +//! +//! Single source of truth: [`COMPILER_ABI_MAJOR`] and [`COMPILER_ABI_MINOR`]. +//! Callers that need a `.` string format it with those +//! constants directly — keeping the numeric and string forms from drifting. + +/// Breaking ABI changes: incompatible CLI flag removal/rename, stdout contract +/// changes, exit-code semantics changes. +pub const COMPILER_ABI_MAJOR: u32 = 1; + +/// Additive changes: new flags, new stdout fields, new exit codes. +pub const COMPILER_ABI_MINOR: u32 = 0;