Files
openidea/crates/ide-run/src/tomcat.rs
l0ng-ai 2e6cda1d86 feat(run): update a Tomcat deployment from compiled classes instead of rebuilding the WAR (#1140)
"Update classes and resources" / "Update resources" on a Tomcat configuration deploying the project's WAR artifact ran a full package build and re-exploded the archive. They now compile the WAR module incrementally through jdtls and copy only the changed classes and resources into WEB-INF/classes and changed web files (src/main/webapp) into the exploded deployment; Tomcat reloads the context, or HotSwap applies the classes when debugging. Falls back to rebuilding the WAR when a dependency module was compiled since the last update (its WEB-INF/lib jar would be stale), the deployment doesn't exist yet, or no Java language server is available. Redeploy still rebuilds the WAR.

Closes #970
2026-11-04 09:43:54 +08:00

668 lines
30 KiB
Rust

//! Isolated local Tomcat installations and deployments. Never writes to CATALINA_HOME.
use crate::RunConfig;
use ide_core::{IdeError, IdeResult};
use quick_xml::{
Reader, Writer,
events::{BytesStart, Event},
};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::{
fs,
io::Read,
net::TcpListener,
path::{Path, PathBuf},
};
fn invalid(e: impl std::fmt::Display) -> IdeError {
IdeError::invalid(e.to_string())
}
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct TomcatHome {
pub version: String,
}
pub fn validate_home(home: &Path) -> IdeResult<TomcatHome> {
if !(home.join("bin/catalina.sh").is_file() || home.join("bin/catalina.bat").is_file())
|| !home.join("lib/catalina.jar").is_file()
{
return Err(invalid("Tomcat home must contain bin/catalina.sh or bin/catalina.bat and lib/catalina.jar"));
}
let mut jar = zip::ZipArchive::new(fs::File::open(home.join("lib/catalina.jar"))?).map_err(invalid)?;
let mut properties = String::new();
jar.by_name("org/apache/catalina/util/ServerInfo.properties").map_err(invalid)?.read_to_string(&mut properties)?;
let version =
properties.lines().find_map(|l| l.strip_prefix("server.info=")).unwrap_or("Apache Tomcat").trim().to_owned();
Ok(TomcatHome { version })
}
pub fn base_dir(cache: &Path, id: &str) -> PathBuf {
// Stable and collision-free for configuration IDs, including Windows path characters.
let key: String = id.as_bytes().iter().map(|b| format!("{b:02x}")).collect();
cache.join("tomcat").join(key)
}
pub fn free_port() -> IdeResult<u16> {
Ok(TcpListener::bind(("127.0.0.1", 0))?.local_addr()?.port())
}
pub fn context_name(context: &str) -> IdeResult<String> {
if context.is_empty() || context == "/" {
return Ok("ROOT".into());
}
if !context.starts_with('/')
|| context.ends_with('/')
|| context[1..].split('/').any(|p| {
p.is_empty() || p == "." || p == ".." || !p.chars().all(|c| c.is_alphanumeric() || "-_".contains(c))
})
{
return Err(invalid("Context path must start with / and contain only letters, digits, /, - or _"));
}
Ok(context[1..].replace('/', "#"))
}
fn escape(text: &str) -> String {
quick_xml::escape::escape(text).into_owned()
}
pub fn context_xml(doc_base: &Path, reload: bool) -> String {
format!("<Context docBase=\"{}\" reloadable=\"{reload}\" />\n", escape(&doc_base.to_string_lossy()))
}
pub fn patch_server_xml(xml: &str, http: u16, shutdown: u16, secret: &str) -> IdeResult<String> {
let mut reader = Reader::from_str(xml);
let mut writer = Writer::new(Vec::new());
let mut http_found = false;
loop {
let event = reader.read_event().map_err(invalid)?;
if matches!(event, Event::Eof) {
break;
}
let event = match event {
Event::Start(ref tag) | Event::Empty(ref tag) => {
let name = tag.name().as_ref().to_owned();
let mut attrs: Vec<(String, String)> = tag
.attributes()
.map(|a| {
let a = a.map_err(invalid)?;
Ok((
a.key.as_ref().to_owned(),
a.normalized_value(quick_xml::XmlVersion::Implicit1_0).map_err(invalid)?.into_owned(),
))
})
.collect::<IdeResult<_>>()?;
let mut set = |key: &str, value: String| {
attrs.retain(|(k, _)| k != key);
attrs.push((key.into(), value));
};
match name.as_str() {
"Server" => {
set("port", shutdown.to_string());
set("address", "127.0.0.1".into());
set("shutdown", secret.into());
}
"Connector" => {
let protocol = tag
.try_get_attribute("protocol")
.map_err(invalid)?
.map(|a| a.value.into_owned())
.unwrap_or_default();
let is_http = !http_found
&& (protocol.is_empty() || protocol.contains("HTTP") || protocol.contains("Http"));
set("port", if is_http { http } else { free_port()? }.to_string());
if is_http {
http_found = true;
}
}
"Engine" => set("name", "Catalina".into()),
"Host" => {
set("name", "localhost".into());
set("appBase", "webapps".into());
set("autoDeploy", "true".into());
set("deployOnStartup", "true".into());
}
_ => {}
}
let mut new = BytesStart::new(name);
for (k, v) in &attrs {
new.push_attribute((k.as_str(), v.as_str()));
}
if matches!(event, Event::Empty(_)) { Event::Empty(new) } else { Event::Start(new) }
}
e => e.into_owned(),
};
writer.write_event(event).map_err(invalid)?;
}
if !http_found {
return Err(invalid("Tomcat server.xml has no HTTP connector"));
}
String::from_utf8(writer.into_inner()).map_err(invalid)
}
fn copy_tree(from: &Path, to: &Path) -> IdeResult<()> {
fs::create_dir_all(to)?;
for entry in fs::read_dir(from)? {
let entry = entry?;
let target = to.join(entry.file_name());
if entry.file_type()?.is_dir() {
copy_tree(&entry.path(), &target)?;
} else {
fs::copy(entry.path(), target)?;
}
}
Ok(())
}
/// Synchronize an archive into a private exploded deployment, preserving unchanged class timestamps.
pub fn explode(war: &Path, destination: &Path, resources_only: bool) -> IdeResult<()> {
let mut zip = zip::ZipArchive::new(fs::File::open(war)?).map_err(invalid)?;
fs::create_dir_all(destination)?;
let mut expected = std::collections::HashSet::new();
for i in 0..zip.len() {
let mut entry = zip.by_index(i).map_err(invalid)?;
let path = entry.enclosed_name().ok_or_else(|| invalid("WAR contains an invalid path"))?;
expected.insert(path.clone());
if entry.is_dir() {
continue;
}
if resources_only && (path.extension().is_some_and(|e| e == "class") || path.starts_with("WEB-INF/lib")) {
continue;
}
let target = destination.join(&path);
let mut bytes = Vec::new();
entry.read_to_end(&mut bytes)?;
if fs::read(&target).ok().as_deref() != Some(&bytes) {
fs::create_dir_all(target.parent().unwrap())?;
fs::write(target, bytes)?;
}
}
fn remove_stale(
root: &Path,
dir: &Path,
expected: &std::collections::HashSet<PathBuf>,
resources: bool,
) -> IdeResult<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if entry.file_type()?.is_dir() {
remove_stale(root, &path, expected, resources)?;
} else {
let rel = path.strip_prefix(root).map_err(invalid)?;
if !expected.contains(rel)
&& !(resources && (rel.extension().is_some_and(|e| e == "class") || rel.starts_with("WEB-INF/lib")))
{
fs::remove_file(path)?;
}
}
}
Ok(())
}
remove_stale(destination, destination, &expected, resources_only)
}
/// Updates the exploded artifact deployment from the module's own class output directories and
/// its web resources (`src/main/webapp`), file by file and only what changed, instead of
/// rebuilding and re-exploding the WAR. `module` and `class_paths` are absolute.
/// `Ok(false)`: not possible this way (not deployed from the artifact yet, the module's output
/// isn't on the classpath, or a module it depends on was compiled since the last update, whose
/// archive in `WEB-INF/lib` would be stale) — rebuild the WAR instead.
pub fn sync_outputs(base: &Path, module: &Path, class_paths: &[PathBuf], resources_only: bool) -> IdeResult<bool> {
let dest = base.join("deployment");
let manifest = base.join("synced-files.json");
let since = [base.join("artifact-path.txt"), manifest.clone()]
.iter()
.filter_map(|p| fs::metadata(p).and_then(|m| m.modified()).ok())
.max();
let Some(since) = since else { return Ok(false) };
if !dest.join("WEB-INF").is_dir() {
return Ok(false);
}
let (own, others): (Vec<&PathBuf>, Vec<&PathBuf>) =
class_paths.iter().filter(|p| p.is_dir()).partition(|p| p.starts_with(module));
if own.is_empty() || others.iter().any(|d| modified_since(d, since)) {
return Ok(false);
}
let previous: Vec<String> =
fs::read(&manifest).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
let mut current = std::collections::BTreeSet::new();
let classes = dest.join("WEB-INF/classes");
for dir in own {
sync_tree(dir, dir, &classes, Path::new("WEB-INF/classes"), resources_only, &mut current)?;
}
let webapp = module.join("src/main/webapp");
if webapp.is_dir() {
sync_tree(&webapp, &webapp, &dest, Path::new(""), resources_only, &mut current)?;
}
for rel in previous.iter().filter(|p| !current.contains(*p)) {
let path = dest.join(rel);
if path.is_file() {
fs::remove_file(path)?;
}
}
fs::write(manifest, serde_json::to_vec(&current).map_err(invalid)?)?;
Ok(true)
}
fn modified_since(dir: &Path, since: std::time::SystemTime) -> bool {
let Ok(entries) = fs::read_dir(dir) else { return false };
entries.flatten().any(|e| match e.file_type() {
Ok(t) if t.is_dir() => modified_since(&e.path(), since),
Ok(t) if t.is_file() => e.metadata().and_then(|m| m.modified()).is_ok_and(|m| m > since),
_ => false,
})
}
/// Copies changed files of `from` (below `root`) to `to`; unchanged files keep their timestamps,
/// so Tomcat only reloads when a class really changed. Records each source file as `prefix/rel`.
fn sync_tree(
root: &Path,
from: &Path,
to: &Path,
prefix: &Path,
resources_only: bool,
seen: &mut std::collections::BTreeSet<String>,
) -> IdeResult<()> {
for entry in fs::read_dir(from)? {
let entry = entry?;
let path = entry.path();
let kind = entry.file_type()?;
if kind.is_dir() {
sync_tree(root, &path, to, prefix, resources_only, seen)?;
continue;
}
if !kind.is_file() {
continue;
}
let rel = path.strip_prefix(root).map_err(invalid)?;
seen.insert(prefix.join(rel).to_string_lossy().replace('\\', "/"));
if resources_only && rel.extension().is_some_and(|e| e == "class") {
continue;
}
let target = to.join(rel);
let bytes = fs::read(&path)?;
if fs::read(&target).ok().as_deref() != Some(&bytes) {
fs::create_dir_all(target.parent().unwrap_or(to))?;
fs::write(target, bytes)?;
}
}
Ok(())
}
pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only: bool) -> IdeResult<PathBuf> {
let p = &config.extras.params;
match p.get("deployment").map(String::as_str).unwrap_or("artifact") {
"artifact" => {
if let Some(id) = p.get("artifact").filter(|s| !s.is_empty())
&& fs::read_to_string(base.join("artifact-id.txt")).ok().as_ref() != Some(id)
{
return Err(invalid("Build the selected WAR artifact before launching Tomcat"));
}
let war = fs::read_to_string(base.join("artifact-path.txt"))
.map_err(|_| invalid("Build the WAR artifact before launching Tomcat"))?;
let war = PathBuf::from(war.trim());
if !war.is_file() || !war.extension().is_some_and(|e| e.eq_ignore_ascii_case("war")) {
return Err(invalid(format!("The built WAR artifact does not exist: {}", war.display())));
}
let dest = base.join("deployment");
explode(&war, &dest, resources_only)?;
Ok(dest)
}
kind @ ("war" | "directory") => {
let value = p
.get("deploymentPath")
.filter(|v| !v.trim().is_empty())
.ok_or_else(|| invalid("Choose a WAR file or exploded directory"))?;
let path = root.join(value);
let path = dunce::canonicalize(&path)
.map_err(|e| invalid(format!("Invalid Tomcat deployment: {} ({e})", path.display())))?;
if (kind == "war" && (!path.is_file() || !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("war"))))
|| (kind == "directory" && !path.is_dir())
{
return Err(invalid(format!("Invalid Tomcat deployment: {}", path.display())));
}
Ok(path)
}
_ => Err(invalid("Unknown Tomcat deployment type")),
}
}
pub fn effective_context(config: &RunConfig, doc: &Path) -> String {
config.extras.params.get("contextPath").filter(|s| !s.is_empty()).cloned().unwrap_or_else(|| {
let name = config
.extras
.params
.get("artifactName")
.cloned()
.unwrap_or_else(|| doc.file_stem().unwrap_or_default().to_string_lossy().into_owned());
if name == "ROOT" { "/".into() } else { format!("/{name}") }
})
}
/// Platform-neutral plan; Windows uses the process manager's raw cmd quoting for batch scripts.
pub fn script_command(home: &Path, windows: bool, verb: &str) -> (String, Vec<String>) {
if windows {
(format!("\"{}\" {verb}", home.join("bin/catalina.bat").display()), vec![])
} else {
("sh".into(), vec![home.join("bin/catalina.sh").to_string_lossy().into_owned(), verb.into()])
}
}
pub fn prepare(root: &Path, cache: &Path, config: &mut RunConfig) -> IdeResult<()> {
// Batch scripts and their environment variables require ordinary Windows paths.
let home = dunce::canonicalize(root.join(
config.extras.params.get("tomcatHome").ok_or_else(|| invalid("Choose a Tomcat installation directory"))?,
))?;
validate_home(&home)?;
let base = base_dir(cache, &config.id);
fs::create_dir_all(&base)?;
let base = dunce::canonicalize(&base)?;
let doc = deployment(root, &base, config, false)?;
let context = effective_context(config, &doc);
let context = context_name(&context)?;
let port: u16 =
config.extras.params.get("httpPort").map(String::as_str).unwrap_or("8080").parse().map_err(invalid)?;
if port == 0 {
return Err(invalid("HTTP port must be between 1 and 65535"));
}
let shutdown = free_port()?;
let secret = format!(
"OPENIDEA_{}_{}",
std::process::id(),
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_err(invalid)?.as_nanos()
);
// Refresh only the IDE-owned configuration, leaving logs and deployed artifacts available.
if base.join("conf").exists() {
fs::remove_dir_all(base.join("conf"))?;
}
copy_tree(&home.join("conf"), &base.join("conf"))?;
let contexts = base.join("conf/Catalina/localhost");
if contexts.exists() {
fs::remove_dir_all(&contexts)?;
}
for dir in ["logs", "temp", "work", "webapps", "conf/Catalina/localhost"] {
fs::create_dir_all(base.join(dir))?;
}
let server = patch_server_xml(&fs::read_to_string(home.join("conf/server.xml"))?, port, shutdown, &secret)?;
fs::write(base.join("conf/server.xml"), server)?;
let debug = config.extras.params.get("tomcatDebug").is_some_and(|v| v == "true");
fs::write(contexts.join(format!("{context}.xml")), context_xml(&doc, !debug))?;
config.env.insert("CATALINA_HOME".into(), home.to_string_lossy().into_owned());
config.env.insert("CATALINA_BASE".into(), base.to_string_lossy().into_owned());
if let Some(jre) = config.extras.params.get("jre").filter(|s| !s.trim().is_empty()) {
let jre = dunce::canonicalize(root.join(jre))?;
config.env.insert("JAVA_HOME".into(), jre.to_string_lossy().into_owned());
config.env.insert("JRE_HOME".into(), jre.to_string_lossy().into_owned());
} else if let Some(java) = config.env.get("JAVA_HOME").cloned() {
config.env.insert("JRE_HOME".into(), java);
}
let vm = config.extras.params.get("vmOptions").cloned().unwrap_or_default();
let jmx = free_port()?;
let mut opts = config.env.get("CATALINA_OPTS").cloned().unwrap_or_default();
opts.push_str(&format!(" {vm} -Dcom.sun.management.jmxremote.port={jmx} -Dcom.sun.management.jmxremote.rmi.port={jmx} -Dcom.sun.management.jmxremote.host=127.0.0.1 -Djava.rmi.server.hostname=127.0.0.1 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false"));
if debug {
opts.push_str(" -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=127.0.0.1:0");
}
config.env.insert("CATALINA_OPTS".into(), opts);
(config.command, config.args) = script_command(&home, cfg!(windows), "run");
config.extras.stop_command = Some(if cfg!(windows) {
script_command(&home, true, "stop").0
} else {
format!("sh '{}' stop", home.join("bin/catalina.sh").to_string_lossy().replace('\'', "'\\''"))
});
config.extras.emulate_terminal = false;
Ok(())
}
pub fn update(root: &Path, cache: &Path, config: &RunConfig, mode: &str) -> IdeResult<()> {
if !matches!(mode, "resources" | "classes" | "redeploy") {
return Err(invalid("Unknown Tomcat update operation"));
}
let base = base_dir(cache, &config.id);
let doc = deployment(root, &base, config, mode == "resources")?;
let context = context_name(&effective_context(config, &doc))?;
let file = base.join("conf/Catalina/localhost").join(format!("{context}.xml"));
if !file.is_file() {
return Err(invalid("Start this Tomcat configuration before updating it"));
}
if mode == "redeploy" {
let content = fs::read(&file)?;
fs::write(file, content)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn archive(path: &Path, entries: &[(&str, &str)]) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut zip = zip::ZipWriter::new(fs::File::create(path).unwrap());
for (name, text) in entries {
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
zip.write_all(text.as_bytes()).unwrap();
}
zip.finish().unwrap();
}
fn config() -> RunConfig {
RunConfig {
id: "local:demo".into(),
name: "Tomcat".into(),
kind: "tomcat".into(),
command: "catalina".into(),
args: vec!["run".into()],
cwd: None,
env: Default::default(),
detected: false,
is_test: false,
debug_adapter: Some("java-debug".into()),
allow_parallel: false,
extras: Default::default(),
}
}
#[test]
fn validates_home_and_reads_version() {
let tmp = tempfile::tempdir().unwrap();
assert!(validate_home(tmp.path()).is_err());
fs::create_dir_all(tmp.path().join("bin")).unwrap();
fs::write(tmp.path().join("bin/catalina.bat"), "").unwrap();
archive(
&tmp.path().join("lib/catalina.jar"),
&[("org/apache/catalina/util/ServerInfo.properties", "server.info=Apache Tomcat/10.1.61\n")],
);
assert_eq!(validate_home(tmp.path()).unwrap().version, "Apache Tomcat/10.1.61");
}
#[test]
fn patches_ports_without_changing_comments_and_escapes_context() {
let xml = "<Server port='8005' shutdown='SHUTDOWN'><!-- <Connector port=\"8080\"/> --><Service><Connector port='8080' protocol='HTTP/1.1'/><Connector port='8009' protocol='AJP/1.3'/><Engine name='Catalina'><Host name='localhost'/></Engine></Service></Server>";
let patched = patch_server_xml(xml, 18080, 18005, "SECRET").unwrap();
assert!(patched.contains("port=\"18080\""));
assert!(patched.contains("port=\"18005\""));
assert!(!patched.contains("port=\"8009\""));
assert!(patched.contains("<!-- <Connector port=\"8080\"/> -->"));
assert_eq!(context_name("/").unwrap(), "ROOT");
assert_eq!(context_name("/a/b").unwrap(), "a#b");
for path in ["../escape", "/../escape", "/x#y", "/a//b", "/a/"] {
assert!(context_name(path).is_err());
}
assert!(context_xml(Path::new("/tmp/a & b/\"web\""), false).contains("a &amp; b/&quot;web&quot;"));
}
#[test]
fn constructs_platform_commands_without_splitting_paths() {
let home = if cfg!(windows) { Path::new("C:/some path/tomcat") } else { Path::new("/some path/tomcat") };
assert_eq!(
script_command(home, false, "run"),
("sh".into(), vec![home.join("bin/catalina.sh").to_string_lossy().into_owned(), "run".into()])
);
let (command, args) = script_command(home, true, "run");
assert_eq!(command, format!("\"{}\" run", home.join("bin/catalina.bat").display()));
assert!(args.is_empty());
}
#[test]
fn resolves_manual_deployments_and_synchronizes_war_resources() {
let tmp = tempfile::tempdir().unwrap();
let war = tmp.path().join("web app.war");
archive(&war, &[("index.html", "old"), ("WEB-INF/classes/App.class", "class1"), ("removed.txt", "delete")]);
let base = tmp.path().join("cache");
fs::create_dir_all(&base).unwrap();
fs::write(base.join("artifact-path.txt"), war.to_string_lossy().as_bytes()).unwrap();
let mut c = config();
let dest = deployment(tmp.path(), &base, &c, false).unwrap();
archive(&war, &[("index.html", "new"), ("WEB-INF/classes/App.class", "class2")]);
deployment(tmp.path(), &base, &c, true).unwrap();
assert_eq!(fs::read_to_string(dest.join("index.html")).unwrap(), "new");
assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/App.class")).unwrap(), "class1");
assert!(!dest.join("removed.txt").exists());
deployment(tmp.path(), &base, &c, false).unwrap();
assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/App.class")).unwrap(), "class2");
c.extras.params.insert("deployment".into(), "war".into());
c.extras.params.insert("deploymentPath".into(), "web app.war".into());
assert_eq!(deployment(tmp.path(), &base, &c, false).unwrap(), dunce::canonicalize(&war).unwrap());
fs::create_dir_all(tmp.path().join("WebContent")).unwrap();
c.extras.params.insert("deployment".into(), "directory".into());
c.extras.params.insert("deploymentPath".into(), "WebContent".into());
assert!(deployment(tmp.path(), &base, &c, false).unwrap().ends_with("WebContent"));
}
#[test]
fn updates_the_exploded_deployment_from_module_outputs() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let (base, module) = (root.join("base"), root.join("web"));
let (dest, out, dep) =
(base.join("deployment"), module.join("target/classes"), root.join("core/target/classes"));
let webapp = module.join("src/main/webapp");
for dir in [dest.join("WEB-INF/classes/a"), out.join("a"), webapp.clone(), dep.clone()] {
fs::create_dir_all(dir).unwrap();
}
for (path, text) in [
(dest.join("WEB-INF/classes/a/Same.class"), "same"),
(dest.join("WEB-INF/classes/a/App.class"), "old"),
(dest.join("index.jsp"), "old"),
(out.join("a/Same.class"), "same"),
(out.join("a/App.class"), "new"),
(out.join("app.properties"), "new"),
(webapp.join("index.jsp"), "new"),
] {
fs::write(path, text).unwrap();
}
let paths = [out.clone(), dep.clone(), root.join("lib.jar")];
// Not deployed from the artifact yet.
assert!(!sync_outputs(&base, &module, &paths, false).unwrap());
fs::write(base.join("artifact-path.txt"), "web.war").unwrap();
let same = || fs::metadata(dest.join("WEB-INF/classes/a/Same.class")).unwrap().modified().unwrap();
let before = same();
assert!(sync_outputs(&base, &module, &paths, true).unwrap());
assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/a/App.class")).unwrap(), "old");
assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/app.properties")).unwrap(), "new");
assert_eq!(fs::read_to_string(dest.join("index.jsp")).unwrap(), "new");
assert!(sync_outputs(&base, &module, &paths, false).unwrap());
assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/a/App.class")).unwrap(), "new");
assert_eq!(same(), before);
fs::remove_file(out.join("app.properties")).unwrap();
assert!(sync_outputs(&base, &module, &paths, false).unwrap());
assert!(!dest.join("WEB-INF/classes/app.properties").exists());
assert!(dest.join("WEB-INF/classes/a/Same.class").exists());
// A dependency module compiled since: its archive in WEB-INF/lib is stale.
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(60);
fs::File::create(dep.join("Core.class")).unwrap().set_modified(later).unwrap();
assert!(!sync_outputs(&base, &module, &paths, false).unwrap());
}
#[test]
fn deployment_errors_identify_the_checked_path() {
let tmp = tempfile::tempdir().unwrap();
let base = tmp.path().join("cache");
fs::create_dir_all(&base).unwrap();
for path in ["missing.war", r"C:\工作\timesheet\target\timesheet.war", r"\\server\share\timesheet.war"] {
fs::write(base.join("artifact-path.txt"), format!("{path}\r\n")).unwrap();
let error = deployment(tmp.path(), &base, &config(), false).unwrap_err().to_string();
assert!(error.contains(path), "{error}");
}
for kind in ["war", "directory"] {
let mut c = config();
c.extras.params.insert("deployment".into(), kind.into());
c.extras.params.insert("deploymentPath".into(), "missing".into());
let error = deployment(tmp.path(), &base, &c, false).unwrap_err().to_string();
assert!(error.contains(&tmp.path().join("missing").display().to_string()), "{error}");
c.extras.params.insert("deploymentPath".into(), "cache".into());
if kind == "war" {
assert!(deployment(tmp.path(), &base, &c, false).unwrap_err().to_string().contains("cache"));
}
}
}
#[test]
fn prepares_isolated_base_and_jdwp_only_for_run() {
let tmp = tempfile::tempdir().unwrap();
check_prepared_paths(tmp.path());
}
#[cfg(windows)]
#[test]
fn prepares_plain_windows_paths_from_verbatim_root() {
let tmp = tempfile::tempdir().unwrap();
let root = PathBuf::from(format!(r"\\?\{}", dunce::canonicalize(tmp.path()).unwrap().display()));
check_prepared_paths(&root);
}
fn check_prepared_paths(root: &Path) {
let home = root.join("Tomcat Home");
fs::create_dir_all(home.join("bin")).unwrap();
fs::create_dir_all(home.join("conf")).unwrap();
fs::create_dir_all(root.join("WebRoot")).unwrap();
fs::write(home.join("bin/catalina.sh"), "").unwrap();
fs::write(home.join("bin/catalina.bat"), "").unwrap();
archive(
&home.join("lib/catalina.jar"),
&[("org/apache/catalina/util/ServerInfo.properties", "server.info=Apache Tomcat/10.1")],
);
let server = "<Server port='8005'><Service><Connector port='8080'/><Engine name='Catalina'><Host name='localhost'/></Engine></Service></Server>";
fs::write(home.join("conf/server.xml"), server).unwrap();
let mut c = config();
for (k, v) in [
("tomcatHome", "Tomcat Home"),
("deployment", "directory"),
("deploymentPath", "WebRoot"),
("contextPath", "/app"),
("tomcatDebug", "true"),
("vmOptions", "-Xmx256m"),
] {
c.extras.params.insert(k.into(), v.into());
}
c.env.insert("JAVA_HOME".into(), "/jdk with spaces".into());
let cache = root.join("cache");
prepare(root, &cache, &mut c).unwrap();
assert_eq!(fs::read_to_string(home.join("conf/server.xml")).unwrap(), server);
let base = PathBuf::from(&c.env["CATALINA_BASE"]);
let context = base.join("conf/Catalina/localhost/app.xml");
let doc = dunce::canonicalize(root.join("WebRoot")).unwrap();
assert_eq!(fs::read_to_string(&context).unwrap(), context_xml(&doc, false));
assert_eq!(c.env["CATALINA_HOME"], dunce::canonicalize(&home).unwrap().to_string_lossy());
assert_eq!(base, dunce::canonicalize(base_dir(&cache, &c.id)).unwrap());
assert!(c.env["CATALINA_OPTS"].contains("suspend=n,address=127.0.0.1:0"));
assert!(c.env["CATALINA_OPTS"].contains("-Xmx256m"));
assert!(!c.env.get("JAVA_OPTS").is_some_and(|s| s.contains("jdwp")));
assert_eq!(c.env["JRE_HOME"], "/jdk with spaces");
let stop = c.extras.stop_command.as_ref().unwrap();
assert!(stop.contains("stop"));
let script = if cfg!(windows) { "catalina.bat" } else { "catalina.sh" };
assert!(stop.contains(script));
fs::create_dir_all(root.join("JDK Home")).unwrap();
c.extras.params.insert("jre".into(), "JDK Home".into());
prepare(root, &cache, &mut c).unwrap();
let jre = dunce::canonicalize(root.join("JDK Home")).unwrap();
for key in ["JAVA_HOME", "JRE_HOME"] {
assert_eq!(c.env[key], jre.to_string_lossy());
}
assert!(!c.command.contains(r"\\?\"));
assert!(c.args.iter().all(|arg| !arg.contains(r"\\?\")));
assert!(!c.extras.stop_command.as_ref().unwrap().contains(r"\\?\"));
assert!(c.env.values().all(|value| !value.contains(r"\\?\")));
assert!(!fs::read_to_string(context).unwrap().contains(r"\\?\"));
}
}