diff --git a/ostool/src/bin/cargo-osrun.rs b/ostool/src/bin/cargo-osrun.rs index 5b22310..2ae6ec0 100644 --- a/ostool/src/bin/cargo-osrun.rs +++ b/ostool/src/bin/cargo-osrun.rs @@ -11,7 +11,9 @@ use clap::{Parser, Subcommand}; use colored::Colorize as _; use log::debug; use ostool::{ - Tool, ToolConfig, logger, resolve_manifest_context, + ManifestContext, Tool, + invocation::{Invocation, InvocationOptions}, + logger, run::{qemu::RunQemuOptions, uboot::RunUbootOptions}, }; @@ -108,7 +110,31 @@ async fn try_main() -> anyhow::Result<()> { let manifest_dir: PathBuf = env::var("CARGO_MANIFEST_DIR")?.into(); let manifest = manifest_dir.join("Cargo.toml"); - let manifest = resolve_manifest_context(Some(manifest))?; + let parsed_args = format!("{args:#?}"); + + let RunnerArgs { + elf, + to_bin, + config, + show_output, + no_run, + debug, + command, + dtb_dump, + build_dir, + bin_dir, + .. + } = args; + let bin_dir: Option = bin_dir.map(PathBuf::from); + let build_dir: Option = build_dir.map(PathBuf::from); + + let invocation = Invocation::new(InvocationOptions::new( + Some(manifest), + build_dir, + bin_dir, + debug, + ))?; + let manifest = ManifestContext::from_invocation(&invocation); let log_path = logger::init_file_logger(&manifest.workspace_dir)?; let _ = LOG_PATH.set(log_path.clone()); debug!( @@ -116,44 +142,30 @@ async fn try_main() -> anyhow::Result<()> { log_path.display(), manifest.manifest_path.display() ); - debug!("Parsed arguments: {:#?}", args); + debug!("Parsed arguments: {parsed_args}"); - if args.no_run { + if no_run { exit(0); } - let bin_dir: Option = args.bin_dir.map(PathBuf::from); - let build_dir: Option = args.build_dir.map(PathBuf::from); + let mut tool = Tool::from_invocation(invocation); - let mut tool = Tool::new(ToolConfig { - manifest: Some(manifest.manifest_path), - build_dir, - bin_dir, - debug: args.debug, - disable_someboot_build_config: false, - })?; + tool.prepare_elf_artifact(elf, to_bin).await?; - tool.prepare_elf_artifact(args.elf, args.to_bin).await?; - - match args.command { + match command { Some(SubCommands::Uboot(_)) => { - let config = match args.config.as_deref() { + let config = match config.as_deref() { Some(path) => tool.read_uboot_config_from_path(path).await?, None => { tool.ensure_uboot_config_in_dir(&manifest.workspace_dir) .await? } }; - tool.run_uboot( - &config, - RunUbootOptions { - show_output: args.show_output, - }, - ) - .await?; + tool.run_uboot(&config, RunUbootOptions { show_output }) + .await?; } None => { - let config = match args.config.as_deref() { + let config = match config.as_deref() { Some(path) => tool.read_qemu_config_from_path(path).await?, None => { tool.ensure_qemu_config_in_dir(&manifest.workspace_dir) @@ -163,8 +175,8 @@ async fn try_main() -> anyhow::Result<()> { tool.run_qemu( &config, RunQemuOptions { - dtb_dump: args.dtb_dump, - show_output: args.show_output, + dtb_dump, + show_output, }, ) .await?; @@ -189,3 +201,66 @@ fn report_error(err: &anyhow::Error) { ); } } + +#[cfg(test)] +mod tests { + use std::path::Path; + + use clap::Parser; + + use super::{RunnerArgs, SubCommands}; + + /// Verifies the default runner path parses QEMU-related flags. + #[test] + fn parse_default_qemu_runner_args() { + let args = RunnerArgs::try_parse_from([ + "cargo-osrun", + "qemu-system-aarch64", + "target/kernel.elf", + "--to-bin", + "--config", + "qemu.toml", + "--show-output", + "--debug", + "--dtb-dump", + "--build-dir", + "target/custom", + "--bin-dir", + "dist", + ]) + .unwrap(); + + assert_eq!(args.program, Path::new("qemu-system-aarch64")); + assert_eq!(args.elf, Path::new("target/kernel.elf")); + assert!(args.to_bin); + assert_eq!(args.config.as_deref(), Some(Path::new("qemu.toml"))); + assert!(args.show_output); + assert!(args.debug); + assert!(args.dtb_dump); + assert_eq!(args.build_dir.as_deref(), Some("target/custom")); + assert_eq!(args.bin_dir.as_deref(), Some("dist")); + assert!(args.command.is_none()); + } + + /// Verifies the U-Boot subcommand owns arguments after `--`. + #[test] + fn parse_uboot_runner_subcommand() { + let args = RunnerArgs::try_parse_from([ + "cargo-osrun", + "qemu-system-aarch64", + "target/kernel.elf", + "uboot", + "--", + "bootm", + "${kernel_addr_r}", + ]) + .unwrap(); + + match args.command { + Some(SubCommands::Uboot(uboot)) => { + assert_eq!(uboot.runner_args, ["bootm", "${kernel_addr_r}"]); + } + other => panic!("unexpected command: {other:?}"), + } + } +} diff --git a/ostool/src/board/config.rs b/ostool/src/board/config.rs index f3142dc..a725763 100644 --- a/ostool/src/board/config.rs +++ b/ostool/src/board/config.rs @@ -5,7 +5,9 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use crate::{ - Tool, board::global_config::BoardGlobalConfig, run::shell_init::normalize_shell_init_config, + board::global_config::BoardGlobalConfig, + project::variables::{self, VariableScope}, + run::shell_init::normalize_shell_init_config, }; #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq, Eq)] @@ -37,7 +39,7 @@ impl BoardRunConfig { } pub(crate) async fn load_or_create( - tool: &Tool, + scope: &VariableScope, explicit_path: Option, ) -> anyhow::Result { let config_path = Self::default_path(explicit_path)?; @@ -45,18 +47,18 @@ impl BoardRunConfig { .await .with_context(|| format!("failed to load board config: {}", config_path.display()))? .ok_or_else(|| anyhow!("No board configuration obtained"))?; - config.replace_strings(tool)?; + config.replace_strings(scope)?; config.normalize(&format!("board config {}", config_path.display()))?; Ok(config) } - pub(crate) fn read_from_path(tool: &Tool, path: PathBuf) -> anyhow::Result { + pub(crate) fn read_from_path(scope: &VariableScope, path: PathBuf) -> anyhow::Result { let mut config: Self = toml::from_str( &std::fs::read_to_string(&path) .with_context(|| format!("failed to read board config: {}", path.display()))?, ) .with_context(|| format!("failed to parse board config: {}", path.display()))?; - config.replace_strings(tool)?; + config.replace_strings(scope)?; config.normalize(&format!("board config {}", path.display()))?; Ok(config) } @@ -77,17 +79,17 @@ impl BoardRunConfig { pub(crate) fn apply_overrides( &mut self, - tool: &Tool, + scope: &VariableScope, board_type: Option<&str>, server: Option<&str>, port: Option, ) -> anyhow::Result<()> { if let Some(board_type) = board_type { - self.board_type = tool.replace_string(board_type)?; + self.board_type = variables::expand_variables(board_type, scope)?; } if let Some(server) = server { - let server = tool.replace_string(server)?; + let server = variables::expand_variables(server, scope)?; let server = server.trim().to_string(); if server.is_empty() { anyhow::bail!("board server override must not be empty"); @@ -105,37 +107,37 @@ impl BoardRunConfig { self.normalize("board run arguments") } - fn replace_strings(&mut self, tool: &Tool) -> anyhow::Result<()> { - self.board_type = tool.replace_string(&self.board_type)?; + fn replace_strings(&mut self, scope: &VariableScope) -> anyhow::Result<()> { + self.board_type = variables::expand_variables(&self.board_type, scope)?; self.dtb_file = self .dtb_file .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; self.kernel_load_addr = self .kernel_load_addr .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; self.fit_load_addr = self .fit_load_addr .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; self.bootm_addr = self .bootm_addr .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; self.success_regex = self .success_regex .iter() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .collect::>>()?; self.fail_regex = self .fail_regex .iter() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .collect::>>()?; self.uboot_cmd = self .uboot_cmd @@ -143,24 +145,24 @@ impl BoardRunConfig { .map(|values| { values .iter() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .collect::>>() }) .transpose()?; self.shell_prefix = self .shell_prefix .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; self.shell_init_cmd = self .shell_init_cmd .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; self.server = self .server .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; Ok(()) } @@ -293,7 +295,12 @@ port = 9000 let tool = Tool::new(Default::default()).unwrap(); config - .apply_overrides(&tool, Some(" rk3568 "), Some(" 127.0.0.1 "), Some(7000)) + .apply_overrides( + &tool.variable_scope().unwrap(), + Some(" rk3568 "), + Some(" 127.0.0.1 "), + Some(7000), + ) .unwrap(); assert_eq!(config.board_type, "rk3568"); diff --git a/ostool/src/board/mod.rs b/ostool/src/board/mod.rs index 295f152..450f730 100644 --- a/ostool/src/board/mod.rs +++ b/ostool/src/board/mod.rs @@ -20,6 +20,7 @@ use crate::board::{ use crate::{ Tool, build::config::{BuildConfig, BuildSystem, Cargo}, + project::variables, }; #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -185,8 +186,9 @@ impl Tool { path: &Path, ) -> anyhow::Result { self.sync_cargo_context(cargo); - let path = self.replace_path_variables(path.to_path_buf())?; - BoardRunConfig::read_from_path(self, path) + let scope = self.variable_scope()?; + let path = variables::expand_path_variables(path, &scope)?; + BoardRunConfig::read_from_path(&scope, path) } pub async fn ensure_board_run_config_in_dir_for_cargo( @@ -195,24 +197,27 @@ impl Tool { dir: &Path, ) -> anyhow::Result { self.sync_cargo_context(cargo); - let dir = self.replace_path_variables(dir.to_path_buf())?; - BoardRunConfig::load_or_create(self, Some(dir.join(".board.toml"))).await + let scope = self.variable_scope()?; + let dir = variables::expand_path_variables(dir, &scope)?; + BoardRunConfig::load_or_create(&scope, Some(dir.join(".board.toml"))).await } pub async fn ensure_board_run_config_in_dir( &mut self, dir: &Path, ) -> anyhow::Result { - let dir = self.replace_path_variables(dir.to_path_buf())?; - BoardRunConfig::load_or_create(self, Some(dir.join(".board.toml"))).await + let scope = self.variable_scope()?; + let dir = variables::expand_path_variables(dir, &scope)?; + BoardRunConfig::load_or_create(&scope, Some(dir.join(".board.toml"))).await } pub async fn read_board_run_config_from_path( &mut self, path: &Path, ) -> anyhow::Result { - let path = self.replace_path_variables(path.to_path_buf())?; - BoardRunConfig::read_from_path(self, path) + let scope = self.variable_scope()?; + let path = variables::expand_path_variables(path, &scope)?; + BoardRunConfig::read_from_path(&scope, path) } pub async fn run_board( @@ -259,8 +264,9 @@ impl Tool { ) -> anyhow::Result<()> { let global_config = load_board_global_config_with_notice()?; let mut board_config = board_config.clone(); + let scope = self.variable_scope()?; board_config.apply_overrides( - self, + &scope, options.board_type.as_deref(), options.server.as_deref(), options.port, diff --git a/ostool/src/build/cargo_builder.rs b/ostool/src/build/cargo_builder.rs index 75315da..f79b678 100644 --- a/ostool/src/build/cargo_builder.rs +++ b/ostool/src/build/cargo_builder.rs @@ -25,11 +25,21 @@ use crate::{ }; #[derive(Debug, Clone)] -struct ResolvedCargoArtifact { +pub(crate) struct ResolvedCargoArtifact { elf_path: PathBuf, cargo_artifact_dir: PathBuf, } +impl ResolvedCargoArtifact { + pub(crate) fn elf_path(&self) -> &Path { + &self.elf_path + } + + pub(crate) fn cargo_artifact_dir(&self) -> &Path { + &self.cargo_artifact_dir + } +} + /// A builder for constructing and executing Cargo commands. /// /// `CargoBuilder` provides a fluent API for configuring Cargo build or run @@ -44,7 +54,6 @@ pub struct CargoBuilder<'a> { extra_envs: HashMap, skip_objcopy: bool, resolve_artifact_from_json: bool, - resolved_artifact: Option, config_path: Option, } @@ -65,7 +74,6 @@ impl<'a> CargoBuilder<'a> { extra_envs: HashMap::new(), skip_objcopy: false, resolve_artifact_from_json: true, - resolved_artifact: None, config_path, } } @@ -109,10 +117,10 @@ impl<'a> CargoBuilder<'a> { self.run_pre_build_cmds()?; // 2. Build and run cargo - self.run_cargo().await?; + let outcome = self.run_cargo().await?; // 3. Handle output - self.handle_output().await?; + self.handle_output(&outcome).await?; // 4. Post-build commands self.run_post_build_cmds()?; @@ -121,17 +129,18 @@ impl<'a> CargoBuilder<'a> { } fn run_pre_build_cmds(&mut self) -> anyhow::Result<()> { + let process_context = self.tool.process_context()?; for cmd in &self.config.pre_build_cmds { - self.tool.shell_run_cmd(cmd)?; + crate::process::shell_run_cmd(&process_context, cmd)?; } Ok(()) } - async fn run_cargo(&mut self) -> anyhow::Result<()> { + async fn run_cargo(&mut self) -> anyhow::Result { self.run_cargo_and_resolve_artifact().await } - async fn run_cargo_and_resolve_artifact(&mut self) -> anyhow::Result<()> { + async fn run_cargo_and_resolve_artifact(&mut self) -> anyhow::Result { let (target_pkg_id, default_run) = self.target_package_info()?; let mut cmd = self.build_cargo_command().await?; @@ -203,12 +212,12 @@ impl<'a> CargoBuilder<'a> { &self.config.package, )?; - self.resolved_artifact = Some(resolved); - Ok(()) + Ok(resolved) } async fn build_cargo_command(&mut self) -> anyhow::Result { - let mut cmd = self.tool.command("cargo"); + let process_context = self.tool.process_context()?; + let mut cmd = crate::process::command("cargo", &process_context); cmd.arg(&self.command); @@ -291,18 +300,14 @@ impl<'a> CargoBuilder<'a> { } /// Applies the resolved Cargo artifact to the legacy tool runtime state. - async fn handle_output(&mut self) -> anyhow::Result<()> { - let resolved = self.resolved_artifact.clone().ok_or_else(|| { - anyhow!( - "cargo build finished without a resolved executable artifact for package '{}' and target '{}'", - self.config.package, - self.config.target - ) - })?; - - self.tool.set_elf_artifact_path(resolved.elf_path).await?; - self.tool.ctx.artifacts.cargo_artifact_dir = Some(resolved.cargo_artifact_dir.clone()); - self.tool.ctx.artifacts.runtime_artifact_dir = Some(resolved.cargo_artifact_dir); + async fn handle_output(&mut self, resolved: &ResolvedCargoArtifact) -> anyhow::Result<()> { + self.tool + .set_elf_artifact_path(resolved.elf_path().to_path_buf()) + .await?; + self.tool.ctx.artifacts.cargo_artifact_dir = + Some(resolved.cargo_artifact_dir().to_path_buf()); + self.tool.ctx.artifacts.runtime_artifact_dir = + Some(resolved.cargo_artifact_dir().to_path_buf()); if self.config.to_bin && !self.skip_objcopy { self.tool.objcopy_output_bin()?; @@ -312,8 +317,9 @@ impl<'a> CargoBuilder<'a> { } fn run_post_build_cmds(&mut self) -> anyhow::Result<()> { + let process_context = self.tool.process_context()?; for cmd in &self.config.post_build_cmds { - self.tool.shell_run_cmd(cmd)?; + crate::process::shell_run_cmd(&process_context, cmd)?; } Ok(()) } @@ -749,7 +755,7 @@ mod tests { /// /// This covers post-resolution Tool state, not serde/config loading. #[tokio::test] - async fn handle_output_records_runtime_artifact_state_without_objcopy() { + async fn handle_output_records_runtime_artifact_state_from_resolved_cargo_artifact() { let temp = tempfile::tempdir().unwrap(); fs::write( temp.path().join("Cargo.toml"), @@ -786,12 +792,12 @@ mod tests { }) .unwrap(); - let mut builder = CargoBuilder::build(&mut tool, &config, None).skip_objcopy(true); - builder.resolved_artifact = Some(ResolvedCargoArtifact { + let resolved = ResolvedCargoArtifact { elf_path: elf_path.clone(), cargo_artifact_dir: cargo_artifact_dir.clone(), - }); - builder.handle_output().await.unwrap(); + }; + let mut builder = CargoBuilder::build(&mut tool, &config, None).skip_objcopy(true); + builder.handle_output(&resolved).await.unwrap(); drop(builder); let expected_elf = elf_path.canonicalize().unwrap(); diff --git a/ostool/src/build/mod.rs b/ostool/src/build/mod.rs index 8224878..cb0b511 100644 --- a/ostool/src/build/mod.rs +++ b/ostool/src/build/mod.rs @@ -143,7 +143,8 @@ impl Tool { /// /// Returns an error if the configuration cannot be loaded or the build fails. pub(crate) fn build_custom(&mut self, config: &Custom) -> anyhow::Result<()> { - self.shell_run_cmd(&config.build_cmd)?; + let process_context = self.process_context()?; + crate::process::shell_run_cmd(&process_context, &config.build_cmd)?; Ok(()) } diff --git a/ostool/src/invocation.rs b/ostool/src/invocation.rs new file mode 100644 index 0000000..725fd7d --- /dev/null +++ b/ostool/src/invocation.rs @@ -0,0 +1,93 @@ +//! Invocation options and project layout shared by CLI and library entrypoints. + +use std::path::{Path, PathBuf}; + +use crate::project::{ProjectLayout, resolve_project_layout}; + +/// Static inputs for one CLI or library invocation. +#[derive(Clone, Debug, Default)] +pub struct InvocationOptions { + manifest: Option, + build_dir: Option, + bin_dir: Option, + debug: bool, +} + +impl InvocationOptions { + /// Creates immutable invocation options from CLI or library inputs. + pub fn new( + manifest: Option, + build_dir: Option, + bin_dir: Option, + debug: bool, + ) -> Self { + Self { + manifest, + build_dir, + bin_dir, + debug, + } + } + + /// Returns the optional Cargo manifest path supplied by the caller. + pub fn manifest(&self) -> Option<&Path> { + self.manifest.as_deref() + } + + /// Returns the optional build output directory supplied by the caller. + pub fn build_dir(&self) -> Option<&Path> { + self.build_dir.as_deref() + } + + /// Returns the optional BIN output directory supplied by the caller. + pub fn bin_dir(&self) -> Option<&Path> { + self.bin_dir.as_deref() + } + + /// Returns whether debug-mode runtime artifacts should be preserved. + pub fn debug(&self) -> bool { + self.debug + } +} + +/// Top-level immutable inputs plus resolved project layout. +#[derive(Clone, Debug)] +pub struct Invocation { + options: InvocationOptions, + project_layout: ProjectLayout, +} + +impl Invocation { + /// Resolves the project layout for this invocation. + pub fn new(options: InvocationOptions) -> anyhow::Result { + let project_layout = resolve_project_layout(options.manifest().map(PathBuf::from))?; + Ok(Self { + options, + project_layout, + }) + } + + /// Returns immutable options for this invocation. + pub fn options(&self) -> &InvocationOptions { + &self.options + } + + /// Returns the canonical Cargo manifest path used by this invocation. + pub fn manifest_path(&self) -> &Path { + self.project_layout.manifest_path() + } + + /// Returns the package directory containing the selected manifest. + pub fn manifest_dir(&self) -> &Path { + self.project_layout.manifest_dir() + } + + /// Returns the Cargo workspace root from metadata. + pub fn workspace_dir(&self) -> &Path { + self.project_layout.workspace_dir() + } + + pub(crate) fn into_parts(self) -> (InvocationOptions, ProjectLayout) { + (self.options, self.project_layout) + } +} diff --git a/ostool/src/lib.rs b/ostool/src/lib.rs index 0d10ed9..91e9092 100644 --- a/ostool/src/lib.rs +++ b/ostool/src/lib.rs @@ -44,6 +44,9 @@ pub mod board; /// Application context and state management. pub mod ctx; +/// Invocation inputs and mutable runtime state. +pub mod invocation; + /// Custom file logger for ostool. /// /// Provides a file-based logger that writes all log output to @@ -58,6 +61,10 @@ mod tool; /// build options through an interactive terminal interface. pub mod menuconfig; +mod project; + +mod process; + /// Runtime execution modules for QEMU, TFTP, and U-Boot. /// /// Contains implementations for launching QEMU instances, diff --git a/ostool/src/main.rs b/ostool/src/main.rs index e2bb58f..781cf2f 100644 --- a/ostool/src/main.rs +++ b/ostool/src/main.rs @@ -9,10 +9,10 @@ use env_logger::Env; use log::info; use ostool::{ - ManifestContext, Tool, ToolConfig, board, + ManifestContext, Tool, board, build::{self, CargoQemuRunnerArgs, CargoRunnerKind, CargoUbootRunnerArgs}, + invocation::{Invocation, InvocationOptions}, menuconfig::{MenuConfigHandler, MenuConfigMode}, - resolve_manifest_context, run::{ qemu::{QemuConfig, RunQemuOptions}, uboot::{RunUbootOptions, UbootConfig}, @@ -340,13 +340,16 @@ async fn try_main() -> Result<()> { /// Creates the legacy tool facade from an optional manifest argument. fn init_tool(manifest_arg: Option) -> Result<(Tool, ManifestContext)> { - let manifest = resolve_manifest_context(manifest_arg.clone())?; + let invocation = Invocation::new(InvocationOptions::new( + manifest_arg.clone(), + None, + None, + false, + ))?; + let manifest = ManifestContext::from_invocation(&invocation); info!("Using manifest {}", manifest.manifest_path.display()); - let tool = Tool::new(ToolConfig { - manifest: Some(manifest.manifest_path.clone()), - ..Default::default() - })?; + let tool = Tool::from_invocation(invocation); Ok((tool, manifest)) } @@ -452,10 +455,125 @@ mod tests { use ostool::{Tool, ToolConfig}; use super::{ - BoardArgs, BoardSubCommands, CargoSelectorArgs, Cli, SubCommands, apply_cargo_selector, - build, + BoardArgs, BoardSubCommands, CargoSelectorArgs, Cli, RunSubCommands, SubCommands, + apply_cargo_selector, build, }; + /// Verifies build parsing accepts manifest, config, package, and bin overrides. + #[test] + fn parse_build_with_manifest_config_package_and_bin() { + let cli = Cli::try_parse_from([ + "ostool", + "--manifest", + "examples/kernel/Cargo.toml", + "build", + "--config", + "kernel.build.toml", + "--package", + "kernel", + "--bin", + "kernel-qemu", + ]) + .unwrap(); + + assert_eq!( + cli.manifest.as_deref(), + Some(std::path::Path::new("examples/kernel/Cargo.toml")) + ); + match cli.command { + SubCommands::Build { + config, + cargo_selector, + } => { + assert_eq!( + config.as_deref(), + Some(std::path::Path::new("kernel.build.toml")) + ); + assert_eq!(cargo_selector.package.as_deref(), Some("kernel")); + assert_eq!(cargo_selector.bin.as_deref(), Some("kernel-qemu")); + } + other => panic!("unexpected command: {other:?}"), + } + } + + /// Verifies QEMU run parsing accepts build, QEMU, and Cargo selector args. + #[test] + fn parse_run_qemu_with_build_qemu_and_cargo_selector_args() { + let cli = Cli::try_parse_from([ + "ostool", + "run", + "qemu", + "--config", + "kernel.build.toml", + "--qemu-config", + "kernel.qemu.toml", + "--debug", + "--dtb-dump", + "--package", + "kernel", + "--bin", + "kernel-qemu", + ]) + .unwrap(); + + match cli.command { + SubCommands::Run { + command: RunSubCommands::Qemu(args), + } => { + assert_eq!( + args.config.as_deref(), + Some(std::path::Path::new("kernel.build.toml")) + ); + assert_eq!(args.cargo_selector.package.as_deref(), Some("kernel")); + assert_eq!(args.cargo_selector.bin.as_deref(), Some("kernel-qemu")); + assert_eq!( + args.qemu.qemu_config.as_deref(), + Some(std::path::Path::new("kernel.qemu.toml")) + ); + assert!(args.qemu.debug); + assert!(args.qemu.dtb_dump); + } + other => panic!("unexpected command: {other:?}"), + } + } + + /// Verifies U-Boot run parsing accepts build, U-Boot, and Cargo selector args. + #[test] + fn parse_run_uboot_with_build_uboot_and_cargo_selector_args() { + let cli = Cli::try_parse_from([ + "ostool", + "run", + "uboot", + "--config", + "kernel.build.toml", + "--uboot-config", + "kernel.uboot.toml", + "--package", + "kernel", + "--bin", + "kernel-uboot", + ]) + .unwrap(); + + match cli.command { + SubCommands::Run { + command: RunSubCommands::Uboot(args), + } => { + assert_eq!( + args.config.as_deref(), + Some(std::path::Path::new("kernel.build.toml")) + ); + assert_eq!(args.cargo_selector.package.as_deref(), Some("kernel")); + assert_eq!(args.cargo_selector.bin.as_deref(), Some("kernel-uboot")); + assert_eq!( + args.uboot.uboot_config.as_deref(), + Some(std::path::Path::new("kernel.uboot.toml")) + ); + } + other => panic!("unexpected command: {other:?}"), + } + } + #[test] fn parse_board_ls_with_server_args() { let cli = Cli::try_parse_from([ @@ -542,6 +660,7 @@ mod tests { } } + /// Verifies board run parsing accepts build and board config overrides. #[test] fn parse_board_run_with_build_and_board_config() { let cli = Cli::try_parse_from([ @@ -573,6 +692,7 @@ mod tests { args.board_config.as_deref(), Some(std::path::Path::new("remote.board.toml")) ); + assert!(args.cargo_selector.is_empty()); assert_eq!(args.board_type.as_deref(), Some("rk3568")); assert_eq!(args.server.server.as_deref(), Some("10.0.0.2")); assert_eq!(args.server.port, Some(9000)); @@ -581,6 +701,31 @@ mod tests { } } + /// Verifies board run parsing accepts Cargo package and bin selectors. + #[test] + fn parse_board_run_with_cargo_selector_args() { + let cli = Cli::try_parse_from([ + "ostool", + "board", + "run", + "--package", + "kernel", + "--bin", + "kernel-board", + ]) + .unwrap(); + + match cli.command { + SubCommands::Board(BoardArgs { + command: BoardSubCommands::Run(args), + }) => { + assert_eq!(args.cargo_selector.package.as_deref(), Some("kernel")); + assert_eq!(args.cargo_selector.bin.as_deref(), Some("kernel-board")); + } + other => panic!("unexpected command: {other:?}"), + } + } + #[test] fn apply_cargo_selector_overrides_cargo_build_config() { let (_temp, mut tool) = test_tool(); diff --git a/ostool/src/process/mod.rs b/ostool/src/process/mod.rs new file mode 100644 index 0000000..0439bd8 --- /dev/null +++ b/ostool/src/process/mod.rs @@ -0,0 +1,99 @@ +//! Process command construction and shell hook execution with ostool variables. + +use std::{ + ffi::OsStr, + path::{Path, PathBuf}, +}; + +use anyhow::Context as _; + +use crate::{project::variables::VariableScope, utils::Command}; + +/// Concrete process inputs for command construction and shell hooks. +#[derive(Clone, Debug)] +pub struct ProcessContext { + workdir: PathBuf, + workspace_dir: PathBuf, + variables: VariableScope, + kernel_elf: Option, +} + +impl ProcessContext { + /// Creates a process context from invocation layout, variables, and ELF state. + pub fn new( + workdir: PathBuf, + workspace_dir: PathBuf, + variables: VariableScope, + kernel_elf: Option, + ) -> Self { + Self { + workdir, + workspace_dir, + variables, + kernel_elf, + } + } + + /// Returns the directory commands should run from. + pub fn workdir(&self) -> &Path { + &self.workdir + } + + /// Returns the Cargo workspace root exposed to child processes. + pub fn workspace_dir(&self) -> &Path { + &self.workspace_dir + } + + /// Returns variable-expansion inputs for command arguments and hooks. + pub fn variables(&self) -> &VariableScope { + &self.variables + } + + /// Returns the active kernel ELF path exported to shell hooks. + pub fn kernel_elf(&self) -> Option<&Path> { + self.kernel_elf.as_deref() + } +} + +/// Creates a command that expands ostool variables in its arguments. +pub fn command(program: S, context: &ProcessContext) -> Command +where + S: AsRef, +{ + let variables = context.variables().clone(); + let mut command = Command::new(program, context.workdir(), move |s| { + crate::project::variables::expand_os_value(s, &variables) + }); + command.env( + "WORKSPACE_FOLDER", + context.workspace_dir().display().to_string(), + ); + command +} + +/// Runs a shell command with invocation variables and `KERNEL_ELF` environment. +pub fn shell_run_cmd(context: &ProcessContext, cmd: &str) -> anyhow::Result<()> { + let mut command = match std::env::consts::OS { + "windows" => { + let mut command = command("powershell", context); + command.arg("-Command"); + command + } + _ => { + let mut command = command("sh", context); + command.arg("-c"); + command + } + }; + + command.arg(cmd); + + if let Some(elf) = context.kernel_elf() { + command.env("KERNEL_ELF", elf.display().to_string()); + } + + command + .run() + .with_context(|| format!("failed to run shell command: {cmd}"))?; + Ok(()) +} diff --git a/ostool/src/project/layout.rs b/ostool/src/project/layout.rs new file mode 100644 index 0000000..cfed977 --- /dev/null +++ b/ostool/src/project/layout.rs @@ -0,0 +1,103 @@ +//! Cargo manifest and workspace path resolution for ostool invocations. + +use std::{ + env::current_dir, + path::{Path, PathBuf}, +}; + +use anyhow::{Context, anyhow, bail}; + +use crate::utils::PathResultExt; + +/// Immutable Cargo manifest and workspace paths for one ostool invocation. +#[derive(Clone, Debug)] +pub struct ProjectLayout { + manifest_path: PathBuf, + manifest_dir: PathBuf, + workspace_dir: PathBuf, +} + +impl ProjectLayout { + /// Creates a project layout from already-resolved manifest and workspace paths. + pub(crate) fn from_manifest_parts( + manifest_path: PathBuf, + manifest_dir: PathBuf, + workspace_dir: PathBuf, + ) -> Self { + Self { + manifest_path, + manifest_dir, + workspace_dir, + } + } + + /// Returns the canonical Cargo manifest path used by this invocation. + pub fn manifest_path(&self) -> &Path { + &self.manifest_path + } + + /// Returns the package directory containing the selected manifest. + pub fn manifest_dir(&self) -> &Path { + &self.manifest_dir + } + + /// Returns the Cargo workspace root from metadata. + pub fn workspace_dir(&self) -> &Path { + &self.workspace_dir + } +} + +/// Resolves manifest and workspace paths from an optional manifest or directory. +pub fn resolve_project_layout(input: Option) -> anyhow::Result { + let manifest_path = resolve_manifest_path(input)?; + let manifest_dir = manifest_path + .parent() + .ok_or_else(|| anyhow!("manifest has no parent: {}", manifest_path.display()))? + .to_path_buf(); + + let metadata = cargo_metadata::MetadataCommand::new() + .manifest_path(&manifest_path) + .no_deps() + .exec() + .with_context(|| { + format!( + "failed to load cargo metadata from {}", + manifest_path.display() + ) + })?; + + Ok(ProjectLayout { + manifest_path, + manifest_dir, + workspace_dir: PathBuf::from(metadata.workspace_root.as_std_path()), + }) +} + +/// Resolves a manifest path from a file, directory, or current working directory. +fn resolve_manifest_path(input: Option) -> anyhow::Result { + let path = match input { + Some(path) => path, + None => current_dir().context("failed to get current working directory")?, + }; + + let manifest_path = if path.is_dir() { + path.join("Cargo.toml") + } else { + path + }; + + if manifest_path.file_name().and_then(|name| name.to_str()) != Some("Cargo.toml") { + bail!( + "manifest must be a Cargo.toml file or a directory containing Cargo.toml: {}", + manifest_path.display() + ); + } + + if !manifest_path.exists() { + bail!("Cargo.toml not found: {}", manifest_path.display()); + } + + manifest_path + .canonicalize() + .with_path("failed to canonicalize manifest path", &manifest_path) +} diff --git a/ostool/src/project/metadata.rs b/ostool/src/project/metadata.rs new file mode 100644 index 0000000..880334b --- /dev/null +++ b/ostool/src/project/metadata.rs @@ -0,0 +1,45 @@ +//! Cargo metadata helpers used by invocation and package-scoped expansion. + +use std::path::PathBuf; + +use anyhow::{Context, anyhow, bail}; +use cargo_metadata::Metadata; + +use super::ProjectLayout; + +/// Loads workspace metadata for the resolved project layout. +pub fn cargo_metadata(layout: &ProjectLayout) -> anyhow::Result { + cargo_metadata::MetadataCommand::new() + .manifest_path(layout.manifest_path()) + .no_deps() + .exec() + .with_context(|| { + format!( + "failed to load cargo metadata from {}", + layout.manifest_path().display() + ) + }) +} + +/// Finds the manifest directory for a named Cargo package. +pub fn package_manifest_dir(layout: &ProjectLayout, package: &str) -> anyhow::Result { + let metadata = cargo_metadata(layout)?; + let Some(pkg) = metadata.packages.iter().find(|pkg| pkg.name == package) else { + bail!( + "package '{}' not found in cargo metadata under {}", + package, + layout.manifest_dir().display() + ); + }; + + pkg.manifest_path + .parent() + .map(|path| path.as_std_path().to_path_buf()) + .ok_or_else(|| { + anyhow!( + "package '{}' manifest has no parent: {}", + package, + pkg.manifest_path + ) + }) +} diff --git a/ostool/src/project/mod.rs b/ostool/src/project/mod.rs new file mode 100644 index 0000000..b1964ad --- /dev/null +++ b/ostool/src/project/mod.rs @@ -0,0 +1,7 @@ +//! Project layout, metadata, and variable expansion helpers. + +pub mod layout; +pub mod metadata; +pub mod variables; + +pub use layout::{ProjectLayout, resolve_project_layout}; diff --git a/ostool/src/project/variables.rs b/ostool/src/project/variables.rs new file mode 100644 index 0000000..b907c99 --- /dev/null +++ b/ostool/src/project/variables.rs @@ -0,0 +1,85 @@ +//! Placeholder expansion for workspace, package, temporary, and environment variables. + +use std::{ + ffi::OsStr, + path::{Path, PathBuf}, +}; + +use crate::utils::replace_placeholders; + +use super::ProjectLayout; + +/// Concrete variable inputs for one command or config-expansion context. +#[derive(Clone, Debug)] +pub struct VariableScope { + workspace_dir: PathBuf, + package_dir: PathBuf, + tmp_dir: PathBuf, +} + +impl VariableScope { + /// Creates an explicit variable scope for command and config expansion. + pub fn new(workspace_dir: PathBuf, package_dir: PathBuf, tmp_dir: PathBuf) -> Self { + Self { + workspace_dir, + package_dir, + tmp_dir, + } + } + + /// Builds a variable scope for a specific Cargo package directory. + pub fn for_package(layout: &ProjectLayout, package_dir: PathBuf) -> Self { + Self::new( + layout.workspace_dir().to_path_buf(), + package_dir, + std::env::temp_dir(), + ) + } + + /// Returns the workspace root replacement value. + pub fn workspace_dir(&self) -> &Path { + &self.workspace_dir + } + + /// Returns the package root replacement value. + pub fn package_dir(&self) -> &Path { + &self.package_dir + } + + /// Returns the temporary directory replacement value. + pub fn tmp_dir(&self) -> &Path { + &self.tmp_dir + } +} + +/// Expands ostool placeholders in a UTF-8 string. +pub fn expand_variables(input: &str, scope: &VariableScope) -> anyhow::Result { + let workspace_dir = scope.workspace_dir().display().to_string(); + let package_dir = scope.package_dir().display().to_string(); + let tmp_dir = scope.tmp_dir().display().to_string(); + + replace_placeholders(input, |placeholder| { + let value = match placeholder { + "workspace" | "workspaceFolder" => Some(workspace_dir.clone()), + "package" => Some(package_dir.clone()), + "tmpDir" => Some(tmp_dir.clone()), + p if p.starts_with("env:") => Some(std::env::var(&p[4..]).unwrap_or_default()), + _ => None, + }; + Ok(value) + }) +} + +/// Expands placeholders in an OS string, falling back to the original on errors. +pub fn expand_os_value(value: &OsStr, scope: &VariableScope) -> String { + expand_variables(&value.to_string_lossy(), scope) + .unwrap_or_else(|_| value.to_string_lossy().into_owned()) +} + +/// Expands placeholders in a filesystem path. +pub fn expand_path_variables(path: &Path, scope: &VariableScope) -> anyhow::Result { + Ok(PathBuf::from(expand_variables( + &path.to_string_lossy(), + scope, + )?)) +} diff --git a/ostool/src/run/qemu.rs b/ostool/src/run/qemu.rs index c5d425d..3f6d560 100644 --- a/ostool/src/run/qemu.rs +++ b/ostool/src/run/qemu.rs @@ -46,6 +46,7 @@ use tokio::{ use crate::{ Tool, build::config::Cargo, + project::variables::{self, VariableScope}, run::{ output_matcher::{ByteStreamMatcher, compile_regexes, print_match_event}, ovmf_prebuilt::{Arch, FileType, Prebuilt, Source}, @@ -87,31 +88,31 @@ pub struct QemuConfig { } impl QemuConfig { - fn replace_strings(&mut self, tool: &Tool) -> anyhow::Result<()> { + fn replace_strings(&mut self, scope: &VariableScope) -> anyhow::Result<()> { self.args = self .args .iter() - .map(|arg| tool.replace_string(arg)) + .map(|arg| variables::expand_variables(arg, scope)) .collect::>>()?; self.success_regex = self .success_regex .iter() - .map(|arg| tool.replace_string(arg)) + .map(|arg| variables::expand_variables(arg, scope)) .collect::>>()?; self.fail_regex = self .fail_regex .iter() - .map(|arg| tool.replace_string(arg)) + .map(|arg| variables::expand_variables(arg, scope)) .collect::>>()?; self.shell_prefix = self .shell_prefix .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; self.shell_init_cmd = self .shell_init_cmd .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; Ok(()) } @@ -168,8 +169,9 @@ impl Tool { path: &Path, ) -> anyhow::Result { self.sync_cargo_context(cargo); - let config_path = self.replace_path_variables(path.to_path_buf())?; - read_qemu_config_at_path(self, config_path).await + let scope = self.variable_scope()?; + let config_path = variables::expand_path_variables(path, &scope)?; + read_qemu_config_at_path(&scope, config_path).await } pub async fn ensure_qemu_config_for_cargo( @@ -181,7 +183,8 @@ impl Tool { let arch = infer_target_arch(&cargo.target).or(self.ctx.arch); let config_path = resolve_qemu_config_path_in_dir(&package_dir, arch, None)?; let default_config = self.default_qemu_config_for_cargo(cargo); - ensure_qemu_config_at_path(self, config_path, default_config).await + let scope = self.variable_scope()?; + ensure_qemu_config_at_path(&scope, config_path, default_config).await } pub async fn ensure_qemu_config_in_dir_for_cargo( @@ -190,25 +193,28 @@ impl Tool { dir: &Path, ) -> anyhow::Result { self.sync_cargo_context(cargo); - let dir = self.replace_path_variables(dir.to_path_buf())?; + let scope = self.variable_scope()?; + let dir = variables::expand_path_variables(dir, &scope)?; let arch = infer_target_arch(&cargo.target).or(self.ctx.arch); let config_path = resolve_qemu_config_path_in_dir(&dir, arch, None)?; let default_config = self.default_qemu_config_for_cargo(cargo); - ensure_qemu_config_at_path(self, config_path, default_config).await + ensure_qemu_config_at_path(&scope, config_path, default_config).await } /// Loads a QEMU configuration from a directory using the default filename search. pub async fn ensure_qemu_config_in_dir(&mut self, dir: &Path) -> anyhow::Result { - let dir = self.replace_path_variables(dir.to_path_buf())?; + let scope = self.variable_scope()?; + let dir = variables::expand_path_variables(dir, &scope)?; let config_path = resolve_qemu_config_path_in_dir(&dir, self.ctx.arch, None)?; let default_config = self.default_qemu_config(); - ensure_qemu_config_at_path(self, config_path, default_config).await + ensure_qemu_config_at_path(&scope, config_path, default_config).await } /// Reads a QEMU configuration from an explicit path without creating defaults. pub async fn read_qemu_config_from_path(&mut self, path: &Path) -> anyhow::Result { - let config_path = self.replace_path_variables(path.to_path_buf())?; - read_qemu_config_at_path(self, config_path).await + let scope = self.variable_scope()?; + let config_path = variables::expand_path_variables(path, &scope)?; + read_qemu_config_at_path(&scope, config_path).await } /// Runs an already prepared artifact in QEMU using a fully materialized configuration. @@ -218,13 +224,17 @@ impl Tool { options: RunQemuOptions, ) -> anyhow::Result<()> { let mut config = config.clone(); - config.replace_strings(self)?; + let scope = self.variable_scope()?; + config.replace_strings(&scope)?; config.normalize("QEMU runtime config")?; run_qemu_with_config(self, options, config).await } } -async fn read_qemu_config_at_path(tool: &Tool, config_path: PathBuf) -> anyhow::Result { +async fn read_qemu_config_at_path( + variables: &VariableScope, + config_path: PathBuf, +) -> anyhow::Result { info!("Using QEMU config file: {}", config_path.display()); let content = fs::read_to_string(&config_path) @@ -232,20 +242,20 @@ async fn read_qemu_config_at_path(tool: &Tool, config_path: PathBuf) -> anyhow:: .with_context(|| format!("failed to read QEMU config: {}", config_path.display()))?; let mut config: QemuConfig = toml::from_str(&content) .with_context(|| format!("failed to parse QEMU config: {}", config_path.display()))?; - config.replace_strings(tool)?; + config.replace_strings(variables)?; config.normalize(&format!("QEMU config {}", config_path.display()))?; Ok(config) } async fn ensure_qemu_config_at_path( - tool: &Tool, + variables: &VariableScope, config_path: PathBuf, default_config: QemuConfig, ) -> anyhow::Result { info!("Using QEMU config file: {}", config_path.display()); let config_content = match fs::read_to_string(&config_path).await { - Ok(_) => return read_qemu_config_at_path(tool, config_path).await, + Ok(_) => return read_qemu_config_at_path(variables, config_path).await, Err(e) if e.kind() == io::ErrorKind::NotFound => { let mut config = default_config; config.normalize(&format!("QEMU config {}", config_path.display()))?; @@ -359,7 +369,8 @@ impl QemuRunner<'_> { } } - let mut cmd = self.tool.command(&qemu_executable); + let process_context = self.tool.process_context()?; + let mut cmd = crate::process::command(&qemu_executable, &process_context); for arg in &self.config.args { if arg == "-machine" || arg == "-M" { @@ -751,7 +762,10 @@ mod tests { resolve_qemu_config_path_in_dir, timeout_duration, }; use object::Architecture; - use std::{path::PathBuf, time::Duration}; + use std::{ + path::{Path, PathBuf}, + time::Duration, + }; use tempfile::TempDir; use crate::{ @@ -841,7 +855,8 @@ shell_init_cmd = "root" let mut tool = make_tool(tmp.path()); tool.ctx.arch = Some(Architecture::Aarch64); - let config = read_qemu_config_at_path(&tool, config_path).await.unwrap(); + let scope = tool.variable_scope().unwrap(); + let config = read_qemu_config_at_path(&scope, config_path).await.unwrap(); assert!(!config.to_bin); assert_eq!(config.success_regex, vec!["PASS"]); @@ -861,7 +876,7 @@ shell_init_cmd = "root" tool.ctx.arch = Some(Architecture::Aarch64); let config = ensure_qemu_config_at_path( - &tool, + &tool.variable_scope().unwrap(), config_path.clone(), build_default_qemu_config(Some(Architecture::Aarch64)), ) @@ -1135,7 +1150,9 @@ timeout = 0 ..Default::default() }; - config.replace_strings(&tool).unwrap(); + config + .replace_strings(&tool.variable_scope().unwrap()) + .unwrap(); let expected = tmp.path().display().to_string(); assert_eq!(config.args, vec![expected.clone(), expected.clone()]); @@ -1145,21 +1162,29 @@ timeout = 0 assert_eq!(config.shell_init_cmd.as_deref(), Some(expected.as_str())); } - #[test] - fn qemu_config_explicit_path_supports_variables() { + #[tokio::test] + async fn read_qemu_config_from_variable_path_expands_workspace() { let tmp = TempDir::new().unwrap(); write_single_crate_manifest(tmp.path()); - let tool = make_tool(tmp.path()); - - let result = resolve_qemu_config_path( - &tool, - Some( - tool.replace_path_variables("${workspace}/qemu.toml".into()) - .unwrap(), - ), + std::fs::write( + tmp.path().join("qemu.toml"), + r#" +args = ["-nographic"] +uefi = false +to_bin = false +success_regex = [] +fail_regex = [] +"#, ) .unwrap(); - assert_eq!(result, tmp.path().join("qemu.toml")); + let mut tool = make_tool(tmp.path()); + + let config = tool + .read_qemu_config_from_path(Path::new("${workspace}/qemu.toml")) + .await + .unwrap(); + + assert_eq!(config.args, vec!["-nographic"]); } #[test] diff --git a/ostool/src/run/uboot.rs b/ostool/src/run/uboot.rs index 9a52d45..07d53d5 100644 --- a/ostool/src/run/uboot.rs +++ b/ostool/src/run/uboot.rs @@ -40,6 +40,7 @@ use crate::{ BoxedAsyncRead, BoxedAsyncWrite, SerialStreamTasks, connect_serial_stream, }, }, + project::variables::{self, VariableScope}, run::{ output_matcher::{ ByteStreamMatcher, MATCH_DRAIN_DURATION, compile_regexes, print_match_event, @@ -127,46 +128,46 @@ impl UbootConfig { } } - fn replace_strings(&mut self, tool: &Tool) -> anyhow::Result<()> { + fn replace_strings(&mut self, scope: &VariableScope) -> anyhow::Result<()> { self.dtb_file = self .dtb_file .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; self.kernel_load_addr = self .kernel_load_addr .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; self.fit_load_addr = self .fit_load_addr .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; self.bootm_addr = self .bootm_addr .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; self.board_reset_cmd = self .board_reset_cmd .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; self.board_power_off_cmd = self .board_power_off_cmd .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; self.success_regex = self .success_regex .iter() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .collect::>>()?; self.fail_regex = self .fail_regex .iter() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .collect::>>()?; self.uboot_cmd = self .uboot_cmd @@ -174,21 +175,21 @@ impl UbootConfig { .map(|values| { values .iter() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .collect::>>() }) .transpose()?; self.shell_prefix = self .shell_prefix .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; self.shell_init_cmd = self .shell_init_cmd .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; - self.local.replace_strings(tool)?; + self.local.replace_strings(scope)?; Ok(()) } @@ -228,29 +229,29 @@ impl UbootConfig { } impl LocalUbootConfig { - fn replace_strings(&mut self, tool: &Tool) -> anyhow::Result<()> { + fn replace_strings(&mut self, scope: &VariableScope) -> anyhow::Result<()> { self.serial = self .serial .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; self.baud_rate = self .baud_rate .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; self.board_reset_cmd = self .board_reset_cmd .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; self.board_power_off_cmd = self .board_power_off_cmd .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; if let Some(net) = &mut self.net { - net.replace_strings(tool)?; + net.replace_strings(scope)?; } Ok(()) } @@ -268,27 +269,27 @@ pub struct Net { } impl Net { - fn replace_strings(&mut self, tool: &Tool) -> anyhow::Result<()> { - self.interface = tool.replace_string(&self.interface)?; + fn replace_strings(&mut self, scope: &VariableScope) -> anyhow::Result<()> { + self.interface = variables::expand_variables(&self.interface, scope)?; self.board_ip = self .board_ip .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; self.gatewayip = self .gatewayip .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; self.netmask = self .netmask .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; self.tftp_dir = self .tftp_dir .as_deref() - .map(|value| tool.replace_string(value)) + .map(|value| variables::expand_variables(value, scope)) .transpose()?; Ok(()) } @@ -317,8 +318,9 @@ impl Tool { path: &Path, ) -> anyhow::Result { self.sync_cargo_context(cargo); - let config_path = self.replace_path_variables(path.to_path_buf())?; - read_uboot_config_at_path(self, config_path).await + let scope = self.variable_scope()?; + let config_path = variables::expand_path_variables(path, &scope)?; + read_uboot_config_at_path(&scope, config_path).await } pub async fn ensure_uboot_config_for_cargo( @@ -337,14 +339,16 @@ impl Tool { dir: &Path, ) -> anyhow::Result { self.sync_cargo_context(cargo); - let dir = self.replace_path_variables(dir.to_path_buf())?; - ensure_uboot_config_at_path(self, dir.join(".uboot.toml"), self.default_uboot_config()) + let scope = self.variable_scope()?; + let dir = variables::expand_path_variables(dir, &scope)?; + ensure_uboot_config_at_path(&scope, dir.join(".uboot.toml"), self.default_uboot_config()) .await } pub async fn ensure_uboot_config_in_dir(&mut self, dir: &Path) -> anyhow::Result { - let dir = self.replace_path_variables(dir.to_path_buf())?; - ensure_uboot_config_at_path(self, dir.join(".uboot.toml"), self.default_uboot_config()) + let scope = self.variable_scope()?; + let dir = variables::expand_path_variables(dir, &scope)?; + ensure_uboot_config_at_path(&scope, dir.join(".uboot.toml"), self.default_uboot_config()) .await } @@ -352,8 +356,9 @@ impl Tool { &mut self, path: &Path, ) -> anyhow::Result { - let config_path = self.replace_path_variables(path.to_path_buf())?; - read_uboot_config_at_path(self, config_path).await + let scope = self.variable_scope()?; + let config_path = variables::expand_path_variables(path, &scope)?; + read_uboot_config_at_path(&scope, config_path).await } pub async fn run_uboot( @@ -363,7 +368,8 @@ impl Tool { ) -> anyhow::Result<()> { let _ = options.show_output; let mut config = config.clone(); - config.replace_strings(self)?; + let scope = self.variable_scope()?; + config.replace_strings(&scope)?; config.normalize("U-Boot runtime config")?; let backend = LocalBackend::new(config.local.clone()); let mut runner = Runner::new(self, config, backend); @@ -384,7 +390,7 @@ impl Tool { } async fn read_uboot_config_at_path( - tool: &Tool, + variables: &VariableScope, config_path: PathBuf, ) -> anyhow::Result { let mut config: UbootConfig = fs::read_to_string(&config_path) @@ -395,18 +401,18 @@ async fn read_uboot_config_at_path( format!("failed to parse U-Boot config: {}", config_path.display()) }) })?; - config.replace_strings(tool)?; + config.replace_strings(variables)?; config.normalize(&format!("U-Boot config {}", config_path.display()))?; Ok(config) } async fn ensure_uboot_config_at_path( - tool: &Tool, + variables: &VariableScope, config_path: PathBuf, default_config: UbootConfig, ) -> anyhow::Result { let mut config = match fs::read_to_string(&config_path).await { - Ok(_) => return read_uboot_config_at_path(tool, config_path).await, + Ok(_) => return read_uboot_config_at_path(variables, config_path).await, Err(err) if err.kind() == io::ErrorKind::NotFound => { let config = default_config; fs::write(&config_path, toml::to_string_pretty(&config)?) @@ -417,7 +423,7 @@ async fn ensure_uboot_config_at_path( Err(err) => return Err(err.into()), }; - config.replace_strings(tool)?; + config.replace_strings(variables)?; config.normalize(&format!("U-Boot config {}", config_path.display()))?; Ok(config) } @@ -645,7 +651,8 @@ impl RunnerBackend for LocalBackend { if let Some(cmd) = self.config.board_reset_cmd.as_deref() && !cmd.trim().is_empty() { - tool.shell_run_cmd(cmd)?; + let process_context = tool.process_context()?; + crate::process::shell_run_cmd(&process_context, cmd)?; } Ok(()) } @@ -704,7 +711,9 @@ impl RunnerBackend for LocalBackend { async fn after_run(&mut self, tool: &Tool) -> anyhow::Result<()> { if let Some(cmd) = self.config.board_power_off_cmd.as_deref() && !cmd.trim().is_empty() - && let Err(err) = tool.shell_run_cmd(cmd) + && let Err(err) = tool + .process_context() + .and_then(|context| crate::process::shell_run_cmd(&context, cmd)) { log::warn!("board power-off command failed: {err:#}"); } @@ -1670,7 +1679,9 @@ timeout = 0 ..Default::default() }; - config.replace_strings(&tool).unwrap(); + config + .replace_strings(&tool.variable_scope().unwrap()) + .unwrap(); let expected = tmp.path().display().to_string(); assert_eq!( diff --git a/ostool/src/tool.rs b/ostool/src/tool.rs index b622b96..8bbb68a 100644 --- a/ostool/src/tool.rs +++ b/ostool/src/tool.rs @@ -1,8 +1,6 @@ //! Legacy tool facade for workspace configuration, build, and run workflows. use std::{ - env::current_dir, - ffi::OsStr, path::{Path, PathBuf}, process::Command, sync::Arc, @@ -24,7 +22,10 @@ use crate::{ someboot, }, ctx::AppContext, - utils::{PathResultExt, replace_placeholders}, + invocation::Invocation, + process::ProcessContext, + project::{ProjectLayout, metadata, resolve_project_layout, variables::VariableScope}, + utils::PathResultExt, }; /// Static configuration used to initialize a [`Tool`]. @@ -60,18 +61,57 @@ pub struct ManifestContext { pub workspace_dir: PathBuf, } +impl ManifestContext { + pub fn from_invocation(invocation: &Invocation) -> Self { + Self { + manifest_path: invocation.manifest_path().to_path_buf(), + manifest_dir: invocation.manifest_dir().to_path_buf(), + workspace_dir: invocation.workspace_dir().to_path_buf(), + } + } + + pub(crate) fn from_project_layout(layout: &ProjectLayout) -> Self { + Self { + manifest_path: layout.manifest_path().to_path_buf(), + manifest_dir: layout.manifest_dir().to_path_buf(), + workspace_dir: layout.workspace_dir().to_path_buf(), + } + } +} + impl Tool { /// Creates a new tool from the provided configuration. pub fn new(config: ToolConfig) -> anyhow::Result { - let manifest = resolve_manifest_context(config.manifest.clone())?; + let layout = resolve_project_layout(config.manifest.clone())?; + Ok(Self::from_project_layout(config, layout)) + } + + /// Creates the legacy tool facade from an already-resolved invocation. + /// + /// Invocation options are mapped into `ToolConfig` while the resolved project + /// layout is reused directly, so manifest/workspace discovery is not repeated. + /// Someboot build-config injection uses the same default as `Tool::new` and can + /// be changed afterward with `set_someboot_build_config_enabled`. + pub fn from_invocation(invocation: Invocation) -> Self { + let (options, layout) = invocation.into_parts(); + let config = ToolConfig { + manifest: Some(layout.manifest_path().to_path_buf()), + build_dir: options.build_dir().map(PathBuf::from), + bin_dir: options.bin_dir().map(PathBuf::from), + debug: options.debug(), + ..Default::default() + }; + Self::from_project_layout(config, layout) + } - Ok(Self { + pub(crate) fn from_project_layout(config: ToolConfig, layout: ProjectLayout) -> Self { + Self { config, - manifest_path: manifest.manifest_path, - manifest_dir: manifest.manifest_dir, - workspace_dir: manifest.workspace_dir, + manifest_path: layout.manifest_path().to_path_buf(), + manifest_dir: layout.manifest_dir().to_path_buf(), + workspace_dir: layout.workspace_dir().to_path_buf(), ctx: AppContext::default(), - }) + } } pub fn ctx(&self) -> &AppContext { @@ -140,74 +180,18 @@ impl Tool { } } - /// Executes a shell command in the current context. - pub(crate) fn shell_run_cmd(&self, cmd: &str) -> anyhow::Result<()> { - let mut command = match std::env::consts::OS { - "windows" => { - let mut command = self.command("powershell"); - command.arg("-Command"); - command - } - _ => { - let mut command = self.command("sh"); - command.arg("-c"); - command - } - }; - - command.arg(cmd); - - if let Some(elf) = &self.ctx.artifacts.elf { - command.env("KERNEL_ELF", elf.display().to_string()); - } - - command.run()?; - Ok(()) - } - /// Creates a new command builder for the given program. - pub(crate) fn command(&self, program: &str) -> crate::utils::Command { - let tool = self.clone(); - let mut command = - crate::utils::Command::new(program, &self.manifest_dir, move |s| tool.replace_value(s)); - command.env("WORKSPACE_FOLDER", self.workspace_dir.display().to_string()); - command + pub(crate) fn command(&self, program: &str) -> anyhow::Result { + Ok(crate::process::command(program, &self.process_context()?)) } /// Gets the Cargo metadata for the current manifest. pub fn metadata(&self) -> anyhow::Result { - cargo_metadata::MetadataCommand::new() - .manifest_path(&self.manifest_path) - .no_deps() - .exec() - .with_context(|| { - format!( - "failed to load cargo metadata from {}", - self.manifest_path.display() - ) - }) + metadata::cargo_metadata(&self.project_layout()) } pub(crate) fn resolve_package_manifest_dir(&self, package: &str) -> anyhow::Result { - let metadata = self.metadata()?; - let Some(pkg) = metadata.packages.iter().find(|pkg| pkg.name == package) else { - bail!( - "package '{}' not found in cargo metadata under {}", - package, - self.manifest_dir().display() - ); - }; - - pkg.manifest_path - .parent() - .map(|path| path.as_std_path().to_path_buf()) - .ok_or_else(|| { - anyhow!( - "package '{}' manifest has no parent: {}", - package, - pkg.manifest_path - ) - }) + metadata::package_manifest_dir(&self.project_layout(), package) } /// Sets the ELF artifact path and synchronizes derived runtime metadata. @@ -280,7 +264,7 @@ impl Tool { .purple() ); - let mut objcopy = self.command("rust-objcopy"); + let mut objcopy = self.command("rust-objcopy")?; objcopy.arg(format!( "--binary-architecture={}", format!( @@ -348,7 +332,7 @@ impl Tool { .purple() ); - let mut objcopy = self.command("rust-objcopy"); + let mut objcopy = self.command("rust-objcopy")?; if !self.debug_enabled() { objcopy.arg("--strip-all"); @@ -408,36 +392,6 @@ impl Tool { ) } - pub(crate) fn replace_value(&self, value: S) -> String - where - S: AsRef, - { - self.replace_string(&value.as_ref().to_string_lossy()) - .unwrap_or_else(|_| value.as_ref().to_string_lossy().into_owned()) - } - - pub(crate) fn replace_string(&self, input: &str) -> anyhow::Result { - let package_dir = self.package_root_for_variables()?; - let workspace_dir = self.workspace_dir.display().to_string(); - let package_dir = package_dir.display().to_string(); - let tmp_dir = std::env::temp_dir().display().to_string(); - - replace_placeholders(input, |placeholder| { - let value = match placeholder { - "workspace" | "workspaceFolder" => Some(workspace_dir.clone()), - "package" => Some(package_dir.clone()), - "tmpDir" => Some(tmp_dir.clone()), - p if p.starts_with("env:") => Some(std::env::var(&p[4..]).unwrap_or_default()), - _ => None, - }; - Ok(value) - }) - } - - pub(crate) fn replace_path_variables(&self, path: PathBuf) -> anyhow::Result { - Ok(PathBuf::from(self.replace_string(&path.to_string_lossy())?)) - } - fn package_root_for_variables(&self) -> anyhow::Result { if let Some(BuildConfig { system: BuildSystem::Cargo(cargo), @@ -449,6 +403,31 @@ impl Tool { Ok(self.manifest_dir.clone()) } + fn project_layout(&self) -> ProjectLayout { + ProjectLayout::from_manifest_parts( + self.manifest_path.clone(), + self.manifest_dir.clone(), + self.workspace_dir.clone(), + ) + } + + pub(crate) fn variable_scope(&self) -> anyhow::Result { + let package_dir = self.package_root_for_variables()?; + Ok(VariableScope::for_package( + &self.project_layout(), + package_dir, + )) + } + + pub(crate) fn process_context(&self) -> anyhow::Result { + Ok(ProcessContext::new( + self.manifest_dir.clone(), + self.workspace_dir.clone(), + self.variable_scope()?, + self.ctx.artifacts.elf.clone(), + )) + } + pub(crate) fn ui_hooks(&self) -> Vec { vec![ self.ui_hook_feature_select(), @@ -841,56 +820,7 @@ fn build_target_options(candidates: TargetCandidateSet<'_>) -> Vec { } pub fn resolve_manifest_context(input: Option) -> anyhow::Result { - let manifest_path = resolve_manifest_path(input)?; - let manifest_dir = manifest_path - .parent() - .ok_or_else(|| anyhow!("manifest has no parent: {}", manifest_path.display()))? - .to_path_buf(); - - let metadata = cargo_metadata::MetadataCommand::new() - .manifest_path(&manifest_path) - .no_deps() - .exec() - .with_context(|| { - format!( - "failed to load cargo metadata from {}", - manifest_path.display() - ) - })?; - - Ok(ManifestContext { - manifest_path, - manifest_dir, - workspace_dir: PathBuf::from(metadata.workspace_root.as_std_path()), - }) -} - -fn resolve_manifest_path(input: Option) -> anyhow::Result { - let path = match input { - Some(path) => path, - None => current_dir().context("failed to get current working directory")?, - }; - - let manifest_path = if path.is_dir() { - path.join("Cargo.toml") - } else { - path - }; - - if manifest_path.file_name().and_then(|name| name.to_str()) != Some("Cargo.toml") { - bail!( - "manifest must be a Cargo.toml file or a directory containing Cargo.toml: {}", - manifest_path.display() - ); - } - - if !manifest_path.exists() { - bail!("Cargo.toml not found: {}", manifest_path.display()); - } - - manifest_path - .canonicalize() - .with_path("failed to canonicalize manifest path", &manifest_path) + resolve_project_layout(input).map(|layout| ManifestContext::from_project_layout(&layout)) } #[cfg(test)] @@ -901,6 +831,7 @@ mod tests { }; use crate::build::config::{BuildConfig, BuildSystem, Cargo}; use crate::run::qemu::resolve_qemu_config_path_in_dir; + use crate::{process, project::variables}; use jkconfig::data::ElementHook; use object::Architecture; use std::{ @@ -1121,7 +1052,7 @@ to_bin = false } #[test] - fn replace_string_uses_workspace_and_legacy_workspacefolder() { + fn expand_variables_uses_workspace_and_legacy_workspacefolder() { let temp = tempfile::tempdir().unwrap(); std::fs::write( temp.path().join("Cargo.toml"), @@ -1137,15 +1068,15 @@ to_bin = false }) .unwrap(); - let replaced = tool - .replace_string("${workspace}:${workspaceFolder}") - .unwrap(); + let scope = tool.variable_scope().unwrap(); + let replaced = + variables::expand_variables("${workspace}:${workspaceFolder}", &scope).unwrap(); let expected = temp.path().display().to_string(); assert_eq!(replaced, format!("{expected}:{expected}")); } #[test] - fn replace_string_uses_cross_platform_tmpdir() { + fn expand_variables_uses_cross_platform_tmpdir() { let temp = tempfile::tempdir().unwrap(); std::fs::write( temp.path().join("Cargo.toml"), @@ -1161,13 +1092,14 @@ to_bin = false }) .unwrap(); - let replaced = tool.replace_string("${tmpDir}").unwrap(); + let scope = tool.variable_scope().unwrap(); + let replaced = variables::expand_variables("${tmpDir}", &scope).unwrap(); assert_eq!(replaced, std::env::temp_dir().display().to_string()); } /// Verifies that missing environment placeholders expand to an empty string. #[test] - fn replace_string_uses_empty_string_for_missing_env() { + fn expand_variables_uses_empty_string_for_missing_env() { let temp = tempfile::tempdir().unwrap(); write_single_package(temp.path(), "sample"); @@ -1182,14 +1114,15 @@ to_bin = false std::process::id() ); - let replaced = tool - .replace_string(&format!("before-${{env:{missing}}}-after")) - .unwrap(); + let scope = tool.variable_scope().unwrap(); + let replaced = + variables::expand_variables(&format!("before-${{env:{missing}}}-after"), &scope) + .unwrap(); assert_eq!(replaced, "before--after"); } #[test] - fn replace_string_uses_package_dir_from_build_config() { + fn expand_variables_uses_package_dir_from_build_config() { let temp = tempfile::tempdir().unwrap(); std::fs::write( temp.path().join("Cargo.toml"), @@ -1238,12 +1171,13 @@ to_bin = false }), }); - let replaced = tool.replace_string("${package}").unwrap(); + let scope = tool.variable_scope().unwrap(); + let replaced = variables::expand_variables("${package}", &scope).unwrap(); assert_eq!(replaced, kernel_dir.display().to_string()); } #[test] - fn replace_string_falls_back_to_manifest_dir_for_package() { + fn variable_scope_uses_manifest_dir_for_package_without_build_config() { let temp = tempfile::tempdir().unwrap(); std::fs::write( temp.path().join("Cargo.toml"), @@ -1259,10 +1193,46 @@ to_bin = false }) .unwrap(); - let replaced = tool.replace_string("${package}").unwrap(); + let scope = tool.variable_scope().unwrap(); + let replaced = variables::expand_variables("${package}", &scope).unwrap(); assert_eq!(replaced, temp.path().display().to_string()); } + #[test] + fn variable_scope_errors_when_selected_package_is_missing() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("Cargo.toml"), + "[workspace]\nmembers = [\"app\"]\nresolver = \"3\"\n", + ) + .unwrap(); + + let app_dir = temp.path().join("app"); + std::fs::create_dir_all(app_dir.join("src")).unwrap(); + std::fs::write( + app_dir.join("Cargo.toml"), + "[package]\nname = \"app\"\nversion = \"0.1.0\"\nedition = \"2024\"\n", + ) + .unwrap(); + std::fs::write(app_dir.join("src/main.rs"), "fn main() {}\n").unwrap(); + + let mut tool = Tool::new(ToolConfig { + manifest: Some(app_dir), + ..Default::default() + }) + .unwrap(); + tool.ctx.build_config = Some(BuildConfig { + system: BuildSystem::Cargo(Cargo { + package: "missing".into(), + target: "aarch64-unknown-none".into(), + ..Default::default() + }), + }); + + let err = tool.variable_scope().unwrap_err().to_string(); + assert!(err.contains("package 'missing' not found")); + } + #[test] fn command_replaces_args_and_env() { let temp = tempfile::tempdir().unwrap(); @@ -1280,7 +1250,7 @@ to_bin = false }) .unwrap(); - let mut cmd = tool.command("echo"); + let mut cmd = tool.command("echo").unwrap(); cmd.arg("${workspace}"); cmd.env("PKG_DIR", "${package}"); @@ -1328,10 +1298,11 @@ to_bin = false tool.set_elf_artifact_path(copied.clone()).await.unwrap(); let output = temp.path().join("kernel-env.txt"); - tool.shell_run_cmd(&format!( - "printf '%s' \"$KERNEL_ELF\" > {}", - output.display() - )) + let process_context = tool.process_context().unwrap(); + process::shell_run_cmd( + &process_context, + &format!("printf '%s' \"$KERNEL_ELF\" > {}", output.display()), + ) .unwrap(); assert_eq!(