use std::collections::BTreeSet;
use std::ffi::OsStr;
use std::fmt::Write as _;
use std::path::Path;
use std::time::UNIX_EPOCH;
use super::config::CodeBlockToolsConfig;
use super::lookup;
use super::registry::{ToolRegistry, ToolSlot};
pub fn lint_tools_fingerprint(config: &CodeBlockToolsConfig) -> String {
if !config.enabled {
return String::new();
}
let registry = ToolRegistry::new(config.tools.clone());
let binaries: BTreeSet<&str> = config
.languages
.values()
.filter(|language| language.enabled)
.flat_map(|language| &language.lint)
.filter_map(|tool_id| registry.resolve(tool_id, ToolSlot::Lint))
.filter_map(|tool_def| tool_def.command.first().map(String::as_str))
.collect();
let search_path = std::env::var_os("PATH");
let mut fingerprint = String::new();
for binary in binaries {
let identity = lookup::resolve_program(OsStr::new(binary), search_path.as_deref())
.map_or_else(|| "missing".to_string(), |path| file_identity(&path));
let _ = writeln!(fingerprint, "{binary}\0{identity}");
}
fingerprint
}
fn file_identity(path: &Path) -> String {
let target = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let Ok(metadata) = std::fs::metadata(&target) else {
return format!("{}\0unreadable", path.display());
};
let modified = metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map_or_else(|| "unknown".to_string(), |age| age.as_nanos().to_string());
format!(
"{}\0{}\0{}\0{modified}",
path.display(),
target.display(),
metadata.len()
)
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use crate::code_block_tools::config::{LanguageToolConfig, ToolDefinition};
use std::os::unix::fs::PermissionsExt;
fn config_with_lint_tool(command: &str) -> CodeBlockToolsConfig {
let mut config = CodeBlockToolsConfig {
enabled: true,
..Default::default()
};
config.tools.insert(
"t".to_string(),
ToolDefinition {
command: vec![command.to_string()],
stdin: true,
stdout: true,
lint_args: vec![],
format_args: vec![],
},
);
config.languages.insert(
"json".to_string(),
LanguageToolConfig {
lint: vec!["t".to_string()],
..Default::default()
},
);
config
}
fn write_executable(path: &Path, body: &str) {
std::fs::write(path, body).unwrap();
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
}
#[test]
fn disabled_tools_have_no_fingerprint() {
let mut config = config_with_lint_tool("/bin/sh");
config.enabled = false;
assert_eq!(lint_tools_fingerprint(&config), "");
}
#[test]
fn a_missing_binary_is_recorded_as_missing() {
let config = config_with_lint_tool("/nonexistent/rumdl-fingerprint-tool");
assert_eq!(
lint_tools_fingerprint(&config),
"/nonexistent/rumdl-fingerprint-tool\0missing\n"
);
}
#[test]
fn the_fingerprint_follows_the_binary() {
let dir = tempfile::tempdir().unwrap();
let tool = dir.path().join("tool");
let config = config_with_lint_tool(tool.to_str().unwrap());
let missing = lint_tools_fingerprint(&config);
write_executable(&tool, "#!/bin/sh\n");
let installed = lint_tools_fingerprint(&config);
assert_ne!(missing, installed);
assert_eq!(installed, lint_tools_fingerprint(&config), "not stable");
write_executable(&tool, "#!/bin/sh\nexit 0\n");
assert_ne!(installed, lint_tools_fingerprint(&config), "an upgrade went unnoticed");
}
#[test]
fn a_symlink_is_fingerprinted_by_its_target() {
let dir = tempfile::tempdir().unwrap();
let (a, b, link) = (dir.path().join("a"), dir.path().join("b"), dir.path().join("tool"));
write_executable(&a, "#!/bin/sh\n");
write_executable(&b, "#!/bin/sh\n");
let config = config_with_lint_tool(link.to_str().unwrap());
std::os::unix::fs::symlink(&a, &link).unwrap();
let to_a = lint_tools_fingerprint(&config);
std::fs::remove_file(&link).unwrap();
std::os::unix::fs::symlink(&b, &link).unwrap();
assert_ne!(to_a, lint_tools_fingerprint(&config));
}
#[test]
fn a_user_tool_named_like_the_builtin_is_fingerprinted() {
let mut config = config_with_lint_tool("/nonexistent/custom-rumdl");
let tool = config.tools.remove("t").unwrap();
config.tools.insert("rumdl".to_string(), tool);
config.languages.get_mut("json").unwrap().lint = vec!["rumdl".to_string()];
assert!(
lint_tools_fingerprint(&config).contains("custom-rumdl"),
"{:?}",
lint_tools_fingerprint(&config)
);
}
#[test]
fn format_only_tools_are_not_fingerprinted() {
let mut config = config_with_lint_tool("/nonexistent/lint-tool");
config.languages.get_mut("json").unwrap().format = vec!["other".to_string()];
config.tools.insert(
"other".to_string(),
ToolDefinition {
command: vec!["/nonexistent/format-tool".to_string()],
stdin: true,
stdout: true,
lint_args: vec![],
format_args: vec![],
},
);
assert!(!lint_tools_fingerprint(&config).contains("format-tool"));
}
}