Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
211 changes: 178 additions & 33 deletions rust/lithe-core/src/execution/configuration.rs

Large diffs are not rendered by default.

241 changes: 241 additions & 0 deletions rust/lithe-core/src/tests/run_configuration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,247 @@ fn ordinary_java_main_uses_an_application_launch_plan() {
fs::remove_dir_all(root).unwrap();
}

#[test]
fn plain_java_main_uses_the_jdk_without_maven() {
let root = temporary_root("run-config-plain-java-main");
let source = "src/com/example/WorkerMain.java";
fs::create_dir_all(root.join("src/com/example")).unwrap();
fs::write(
root.join(source),
"package com.example; class WorkerMain { public static void main(String[] args) {} }",
)
.unwrap();

let generated_response: Value = serde_json::from_str(&execute_json(
&serde_json::json!({
"id": "generate-plain-java-main",
"command": "runConfig.generate",
"payload": {"root": root, "paths": [source], "modulePaths": []}
})
.to_string(),
))
.unwrap();
let generated = &generated_response["data"]["generated"];
let java_main = generated["configurations"]
.as_array()
.unwrap()
.iter()
.find(|value| value["provider"] == "java.main")
.unwrap();
assert_eq!(java_main["toolchains"]["java"], "project-jdk");
assert!(java_main["toolchains"]["maven"].is_null());
assert_eq!(java_main["source"], source);

fs::create_dir_all(root.join(".lithe/run")).unwrap();
fs::write(
root.join(".lithe/run/generated.json"),
serde_json::to_string(generated).unwrap(),
)
.unwrap();
let plan: Value = serde_json::from_str(&execute_json(
&serde_json::json!({
"id": "plan-plain-java-main",
"command": "runConfig.createLaunchPlan",
"payload": {
"root": root,
"configurationId": "java-main:com.example.WorkerMain"
}
})
.to_string(),
))
.unwrap();
assert_eq!(plan["ok"], true, "{plan}");
assert_eq!(plan["data"]["executable"]["toolchain"], "project-jdk");
assert_eq!(plan["data"]["arguments"], serde_json::json!([source]));

fs::remove_dir_all(root).unwrap();
}

#[test]
fn resolve_prefers_a_host_provided_local_document() {
let root = temporary_root("run-config-host-local");
fs::create_dir_all(root.join(".lithe/run")).unwrap();
fs::write(
root.join(".lithe/run/generated.json"),
r#"{"version":2,"configurations":[{"id":"current-file","name":"Current File","provider":"java.current-file","execution":"application","toolchains":{"java":"project-jdk"}}]}"#,
)
.unwrap();
fs::write(
root.join(".lithe/run/local.json"),
r#"{"version":2,"configurations":[{"id":"current-file","name":"Project Local","provider":"java.current-file","cwd":"."}]}"#,
)
.unwrap();

let resolve: Value = serde_json::from_str(&execute_json(
&serde_json::json!({
"id": "resolve-host-local",
"command": "runConfig.resolve",
"payload": {
"root": root,
"localDocument": {
"version": 2,
"configurations": [{
"id": "current-file",
"name": "This PC",
"provider": "java.current-file",
"cwd": "."
}]
}
}
})
.to_string(),
))
.unwrap();
assert_eq!(resolve["ok"], true, "{resolve}");
let current = resolve["data"]["configurations"]
.as_array()
.unwrap()
.iter()
.find(|value| value["id"] == "current-file")
.unwrap();
assert_eq!(current["name"], "This PC");

// A legacy v1 local layer supplied by the host migrates like the on-disk
// document, so an old `.lithe/run/local.json` read by the adapter still works.
let legacy: Value = serde_json::from_str(&execute_json(
&serde_json::json!({
"id": "resolve-host-local-v1",
"command": "runConfig.resolve",
"payload": {
"root": root,
"localDocument": {
"version": 1,
"configurations": [{
"id": "current-file",
"name": "Legacy This PC",
"type": "java.current-file",
"programArguments": ["--dev"]
}]
}
}
})
.to_string(),
))
.unwrap();
assert_eq!(legacy["ok"], true, "{legacy}");
let legacy_current = legacy["data"]["configurations"]
.as_array()
.unwrap()
.iter()
.find(|value| value["id"] == "current-file")
.unwrap();
assert_eq!(legacy_current["name"], "Legacy This PC");

fs::remove_dir_all(root).unwrap();
}

#[test]
fn resolve_applies_a_global_toolchain_to_every_configuration() {
let root = temporary_root("run-config-global-toolchain");
fs::create_dir_all(root.join(".lithe/run")).unwrap();
fs::write(
root.join(".lithe/run/generated.json"),
r#"{"version":2,"configurations":[
{"id":"spring","name":"Spring","provider":"spring-boot.maven","execution":"service","toolchains":{"java":"project-jdk","maven":"project-maven"},"extensions":{"maven":{"module":"."}}},
{"id":"plain","name":"Plain","provider":"java.main","execution":"application","toolchains":{"java":"project-jdk"},"extensions":{"java":{"source":"src/App.java"}}}
]}"#,
)
.unwrap();
fs::write(
root.join(".lithe/run/local.json"),
r#"{"version":2,"toolchain":{"java":{"homePath":"C:/custom-jdk"},"maven":{"executablePath":"C:/mvn.cmd","javaHomePath":"C:/maven-jdk"}},"configurations":[]}"#,
)
.unwrap();

let resolved: Value = serde_json::from_str(&execute_json(
&serde_json::json!({
"id": "resolve-global-toolchain",
"command": "runConfig.resolve",
"payload": {"root": root}
})
.to_string(),
))
.unwrap();
assert_eq!(resolved["ok"], true, "{resolved}");
assert_eq!(
resolved["data"]["toolchain"]["java"]["homePath"],
"C:/custom-jdk"
);
let plain = resolved["data"]["configurations"]
.as_array()
.unwrap()
.iter()
.find(|value| value["id"] == "plain")
.unwrap();
assert_eq!(plain["extensions"]["java"]["homePath"], "C:/custom-jdk");
assert_eq!(
plain["extensions"]["java"]["mavenExecutablePath"],
"C:/mvn.cmd"
);
// The global toolchain replaces runtime paths but never the source path.
assert_eq!(plain["extensions"]["java"]["source"], "src/App.java");

fs::remove_dir_all(root).unwrap();
}

#[test]
fn global_toolchain_updates_only_in_the_local_layer() {
let root = temporary_root("run-config-toolchain-update");
fs::create_dir_all(root.join(".lithe/run")).unwrap();
fs::write(
root.join(".lithe/run/local.json"),
r#"{"version":2,"configurations":[]}"#,
)
.unwrap();

let updated: Value = serde_json::from_str(&execute_json(
&serde_json::json!({
"id": "update-global-toolchain",
"command": "runConfig.updateOptions",
"payload": {
"root": root,
"scope": "local",
"configurationId": "unused",
"toolchain": {
"javaHomePath": "C:/jdk-21",
"mavenExecutablePath": "C:/apache-maven/bin/mvn.cmd",
"mavenJavaHomePath": "C:/jdk-17"
}
}
})
.to_string(),
))
.unwrap();
assert_eq!(updated["ok"], true, "{updated}");
let document: Value =
serde_json::from_str(updated["data"]["document"].as_str().unwrap()).unwrap();
assert_eq!(document["toolchain"]["java"]["homePath"], "C:/jdk-21");
assert_eq!(
document["toolchain"]["maven"]["executablePath"],
"C:/apache-maven/bin/mvn.cmd"
);
assert_eq!(document["toolchain"]["maven"]["javaHomePath"], "C:/jdk-17");

// Project scope must never accept toolchain paths.
let rejected: Value = serde_json::from_str(&execute_json(
&serde_json::json!({
"id": "update-global-toolchain-project",
"command": "runConfig.updateOptions",
"payload": {
"root": root,
"scope": "project",
"configurationId": "unused",
"toolchain": {"javaHomePath": "C:/jdk-21"}
}
})
.to_string(),
))
.unwrap();
assert_eq!(rejected["ok"], false, "{rejected}");

fs::remove_dir_all(root).unwrap();
}

#[test]
fn run_configuration_inspect_reports_malformed_and_unsupported_documents() {
let root = temporary_root("run-config-errors");
Expand Down
8 changes: 5 additions & 3 deletions shared/contracts/application-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,8 @@ Platform clients coordinate inspection, generation, resolution, typed document
edits, and launch planning, but must not implement a second JSON merger,
toolchain matcher, ID generator, argument parser, or Java/Maven argument
builder. Opening a project inspects existing files without writing; generation
is an explicit user action. Local absolute paths belong only in
`.lithe/**/local.json` and are excluded from project visibility and Git by
default.
is an explicit user action. Shared project overrides stay in
`.lithe/run/configurations.json`. Machine-local overrides may live in
`.lithe/run/local.json` or in a host-owned document supplied as
`localDocument`; absolute toolchain paths belong only in that local layer and
are excluded from project visibility and Git by default.
22 changes: 22 additions & 0 deletions shared/contracts/run-configuration-v2.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,28 @@
},
"additionalProperties": false
},
"toolchain": {
"type": "object",
"description": "Machine-local global toolchain applied to every configuration. Stored only in the local layer.",
"properties": {
"java": {
"type": "object",
"properties": {
"homePath": { "type": "string" }
},
"additionalProperties": false
},
"maven": {
"type": "object",
"properties": {
"executablePath": { "type": "string" },
"javaHomePath": { "type": "string" }
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"configurations": {
"type": "array",
"items": { "$ref": "#/$defs/configuration" }
Expand Down
28 changes: 21 additions & 7 deletions shared/contracts/rust-core-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -393,13 +393,19 @@ never writes files. `runConfig.generate` accepts `root`, relative Java `paths`,
and relative `modulePaths`; it returns generated configuration and toolchain
requirement documents for the platform adapter to write atomically.

`runConfig.resolve` accepts `root` and optional local `toolchainCandidates`.
It merges configurations by stable ID using this precedence:
`runConfig.resolve` accepts `root`, optional local `toolchainCandidates`, and
optional `localDocument`. When `localDocument` is present, Core uses that JSON
object as the local layer instead of reading `.lithe/run/local.json`. It merges
configurations by stable ID using this precedence:
`local.json > configurations.json > generated.json`. Scalars and arrays are
replaced by the higher layer, while toolchain maps merge by key. It returns
effective configurations, their source, the team default, and structured
effective configurations, their source, the team default, structured
diagnostics for stale, orphaned, missing, disabled, and toolchain mismatch
states.
states, and the effective global `toolchain`. A document-level `toolchain`
object in the local layer (e.g.
`{ "java": { "homePath": ... }, "maven": { "executablePath": ..., "javaHomePath": ... } }`)
is applied to every configuration's `extensions.java.*` and is authoritative
over per-configuration toolchain paths.

`runConfig.updateOptions` and `runConfig.createUserConfiguration` are pure
document transformations. They validate scope, paths, supported types, stable
Expand All @@ -408,14 +414,22 @@ IDs, main classes, modules, and argument parsing, then return UTF-8 JSON in the
file and performs the atomic write. These commands never write files.
For project-scoped option updates, selected toolchain paths must resolve inside
`root` and are persisted with `/`-separated project-relative paths. Local-scoped
updates may carry host absolute paths.
updates may carry host absolute paths. `runConfig.updateOptions` and
`runConfig.inspect` accept the same optional `localDocument` override.
When `updateOptions` carries a `toolchain` object (`javaHomePath`,
`mavenExecutablePath`, `mavenJavaHomePath`), it writes the document-level
global toolchain into the local layer instead of patching a configuration;
project scope rejects this payload because toolchain paths are machine-local.

`runConfig.createLaunchPlan` accepts `root`, `configurationId`, optional
`currentFile` and `classPath`, and optional `debugPort`. It returns a toolchain
`currentFile` and `classPath`, optional `debugPort`, and optional
`localDocument`. It returns a toolchain
reference, argument array, project-relative working directory, and structured
environment references. It does not return a shell command or platform
executable path. All project paths use `/`, reject absolute paths and `..`
traversal, and remain relative to `root`.
traversal, and remain relative to `root`. A `java.main` configuration without a
Maven toolchain launches through `project-jdk` and the configuration's Java
source path.

`java.codeVision` accepts a workspace root, a target Java path, and Java source
paths. It returns declaration locations and usage counts; Git blame attribution
Expand Down
2 changes: 2 additions & 0 deletions windows/tauri/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ fn main() {
));
app.manage(host::FileClipboard::default());
app.manage(run::RunProcessManager::default());
run::cleanup_legacy_appdata(app.handle());
if let Some(window) = app.get_webview_window("main") {
host::apply_window_taskbar_icon(&window);
}
Expand Down Expand Up @@ -97,6 +98,7 @@ fn main() {
run::run_list_java_sources,
run::run_write_generated,
run::run_write_document,
run::run_write_stdin,
run::run_discover_toolchains,
run::run_resolve_launch,
run::run_start_process,
Expand Down
Loading
Loading