aboutsummaryrefslogtreecommitdiff
path: root/mling/src
diff options
context:
space:
mode:
Diffstat (limited to 'mling/src')
-rw-r--r--mling/src/cli.rs45
-rw-r--r--mling/src/cli/list.rs117
-rw-r--r--mling/src/cli/namespace_mgr.rs128
-rw-r--r--mling/src/cli/read.rs77
-rw-r--r--mling/src/cli/refresh.rs32
-rw-r--r--mling/src/main.rs14
-rw-r--r--mling/src/namespace_manager.rs113
-rw-r--r--mling/src/project_installer.rs153
-rw-r--r--mling/src/project_solver.rs105
9 files changed, 784 insertions, 0 deletions
diff --git a/mling/src/cli.rs b/mling/src/cli.rs
new file mode 100644
index 0000000..e0dfbe6
--- /dev/null
+++ b/mling/src/cli.rs
@@ -0,0 +1,45 @@
+use mingling::{
+ macros::renderer,
+ setup::{BasicProgramSetup, GeneralRendererSetup},
+};
+
+use crate::{__completion_gen::CompletionDispatcher, DispatcherNotFound, ThisProgram};
+
+pub mod list;
+pub use list::*;
+
+pub mod namespace_mgr;
+pub use namespace_mgr::*;
+
+pub mod read;
+pub use read::*;
+
+pub mod refresh;
+pub use refresh::*;
+
+pub fn cli_entry() {
+ let mut program = ThisProgram::new();
+
+ program.with_setup(BasicProgramSetup);
+ program.with_setup(GeneralRendererSetup);
+ program.with_dispatcher(CompletionDispatcher);
+
+ program.with_dispatcher(ListInstalledCommand);
+ program.with_dispatchers((
+ TrustNamespaceCommand,
+ UntrustNamespaceCommand,
+ SetTrustNamespaceCommand,
+ RemoveNamespaceCommand,
+ ));
+ program.with_dispatcher(RefreshCommand);
+ program.with_dispatchers((
+ ReadTargetDirCommand,
+ ReadWorkspaceRootCommand,
+ ReadBinariesCommand,
+ ));
+
+ program.exec();
+}
+
+#[renderer]
+pub(crate) fn render_help(_prev: DispatcherNotFound) {}
diff --git a/mling/src/cli/list.rs b/mling/src/cli/list.rs
new file mode 100644
index 0000000..9aff22b
--- /dev/null
+++ b/mling/src/cli/list.rs
@@ -0,0 +1,117 @@
+use colored::Colorize;
+use mingling::{
+ Groupped, RenderResult, ShellContext, Suggest,
+ macros::{chain, completion, dispatcher, pack, r_println, renderer, suggest},
+ parser::Picker,
+};
+use serde::Serialize;
+
+use crate::{ThisProgram, namespace_manager::list_namespaces};
+
+dispatcher!("ls-namespace", ListInstalledCommand => ListInstalledEntry);
+
+#[completion(ListInstalledEntry)]
+pub(crate) fn comp_list_installed(ctx: &ShellContext) -> Suggest {
+ if ctx.typing_argument() {
+ return suggest! {
+ "--trusted": "Show only trusted namespaces",
+ "--untrusted": "Show only untrusted namespaces",
+ };
+ }
+ return suggest!();
+}
+
+#[derive(Debug, Serialize, Default, Groupped)]
+pub(crate) enum StateListInstalledOptions {
+ #[default]
+ All,
+ OnlyTrusted,
+ OnlyUntrusted,
+}
+
+pack!(MutexErrorListInstalled = ());
+
+#[chain]
+pub(crate) fn handle_list_installed_entry(prev: ListInstalledEntry) -> NextProcess {
+ let picker = Picker::new(prev.inner);
+ let r = picker
+ .pick::<bool>("--trusted")
+ .pick::<bool>("--untrusted")
+ .unpack();
+
+ let option: StateListInstalledOptions = match r {
+ // (show_trusted, show_untrusted)
+ (true, false) => StateListInstalledOptions::OnlyTrusted,
+ (false, true) => StateListInstalledOptions::OnlyUntrusted,
+ (false, false) => StateListInstalledOptions::All,
+ (true, true) => return MutexErrorListInstalled::default().to_render(),
+ };
+
+ option.to_chain()
+}
+
+#[renderer]
+pub(crate) fn render_list_installed_mutex_error(_prev: MutexErrorListInstalled) {
+ r_println!("Error: cannot use both --trusted and --untrusted options at the same time")
+}
+
+#[derive(Debug, Groupped, Serialize)]
+pub(crate) struct ResultInstalledNamespaces {
+ trusted: Vec<String>,
+ untrusted: Vec<String>,
+ untagged: Vec<String>,
+ option: StateListInstalledOptions,
+}
+
+#[chain]
+pub(crate) fn handle_state_list_installed_option(prev: StateListInstalledOptions) -> NextProcess {
+ ResultInstalledNamespaces {
+ trusted: list_namespaces(true, false, false),
+ untrusted: list_namespaces(false, true, false),
+ untagged: list_namespaces(false, false, true),
+ option: prev,
+ }
+}
+
+#[renderer]
+pub(crate) fn render_installed(prev: ResultInstalledNamespaces) {
+ match prev.option {
+ StateListInstalledOptions::All => {
+ print_list("Trusted".bright_green().bold().to_string(), prev.trusted, r);
+ print_list(
+ "Unrusted".bright_red().bold().to_string(),
+ prev.untrusted,
+ r,
+ );
+ print_list(
+ "Untagged".bright_black().bold().to_string(),
+ prev.untagged,
+ r,
+ );
+ }
+ StateListInstalledOptions::OnlyTrusted => {
+ print_list("Trusted".bright_green().bold().to_string(), prev.trusted, r);
+ }
+ StateListInstalledOptions::OnlyUntrusted => {
+ print_list(
+ "Unrusted".bright_red().bold().to_string(),
+ prev.untrusted,
+ r,
+ );
+ }
+ }
+}
+
+fn print_list(title: String, list: Vec<String>, r: &mut RenderResult) {
+ if list.is_empty() {
+ return;
+ }
+
+ r_println!("{}", title);
+
+ let mut i = 1;
+ for namespace in list.iter() {
+ r_println!(" {}. {}\n", i.to_string(), namespace.bold());
+ i += 1;
+ }
+}
diff --git a/mling/src/cli/namespace_mgr.rs b/mling/src/cli/namespace_mgr.rs
new file mode 100644
index 0000000..9781040
--- /dev/null
+++ b/mling/src/cli/namespace_mgr.rs
@@ -0,0 +1,128 @@
+use mingling::{
+ ShellContext, Suggest, SuggestItem,
+ macros::{chain, completion, dispatcher, pack, r_println, renderer, route, suggest},
+ parser::{Picker, Yes},
+};
+
+use crate::{
+ ThisProgram,
+ namespace_manager::{list_namespaces, remove_namespace, set_namespace_trusted},
+};
+
+dispatcher!("trust", TrustNamespaceCommand => TrustNamespaceEntry);
+dispatcher!("untrust", UntrustNamespaceCommand => UntrustNamespaceEntry);
+
+dispatcher!("set-trust", SetTrustNamespaceCommand => SetTrustNamespaceEntry);
+
+dispatcher!("rm-namespace", RemoveNamespaceCommand => RemoveNamespaceEntry);
+
+pack!(ErrorNamespaceNotProvided = ());
+pack!(ResultNamespaceTrustChanged = ());
+pack!(ResultNamespaceRemoved = ());
+
+#[completion(TrustNamespaceEntry)]
+pub(crate) fn comp_trust(ctx: &ShellContext) -> Suggest {
+ if ctx.previous_word == "trust" {
+ return Suggest::Suggest(
+ list_namespaces(false, true, true)
+ .into_iter()
+ .map(|i| SuggestItem::new(i))
+ .collect::<std::collections::BTreeSet<_>>(),
+ );
+ }
+ return suggest!();
+}
+
+#[completion(UntrustNamespaceEntry)]
+pub(crate) fn comp_untrust(ctx: &ShellContext) -> Suggest {
+ if ctx.previous_word == "untrust" {
+ return Suggest::Suggest(
+ list_namespaces(true, false, true)
+ .into_iter()
+ .map(|i| SuggestItem::new(i))
+ .collect::<std::collections::BTreeSet<_>>(),
+ );
+ }
+ return suggest!();
+}
+
+#[completion(SetTrustNamespaceEntry)]
+pub(crate) fn comp_set_trust(ctx: &ShellContext) -> Suggest {
+ if ctx.typing_argument() {
+ return suggest!(
+ "-t": "Whether to trust this namespace",
+ "--trusted": "Whether to trust this namespace",
+ );
+ }
+ if ctx.filling_argument_first(["-t", "--trusted"]) {
+ return suggest!("yes", "no");
+ }
+ if ctx.previous_word == "set-trust" {
+ return Suggest::Suggest(
+ list_namespaces(true, true, true)
+ .into_iter()
+ .map(|i| SuggestItem::new(i))
+ .collect::<std::collections::BTreeSet<_>>(),
+ );
+ }
+ return suggest!();
+}
+
+#[completion(RemoveNamespaceEntry)]
+pub(crate) fn comp_remove_namespace(ctx: &ShellContext) -> Suggest {
+ if ctx.previous_word == "rm-namespace" {
+ return Suggest::Suggest(
+ list_namespaces(true, true, true)
+ .into_iter()
+ .map(|i| SuggestItem::new(i))
+ .collect::<std::collections::BTreeSet<_>>(),
+ );
+ }
+ return suggest!();
+}
+
+#[chain]
+pub(crate) fn handle_set_trust(p: SetTrustNamespaceEntry) -> NextProcess {
+ let (trusted, namespace) = route!(
+ Picker::new(p.inner)
+ .pick::<Yes>(["-t", "--trusted"])
+ .pick_or_route((), ErrorNamespaceNotProvided::default().to_render())
+ .unpack()
+ );
+ set_namespace_trusted(namespace, trusted.is_yes());
+ ResultNamespaceTrustChanged::default().to_render()
+}
+
+#[chain]
+pub(crate) fn handle_trust(p: TrustNamespaceEntry) -> NextProcess {
+ SetTrustNamespaceEntry::new({
+ let mut args = p.inner.clone();
+ args.extend(vec!["-t".to_string(), "yes".to_string()]);
+ args
+ })
+}
+
+#[chain]
+pub(crate) fn handle_untrust(p: UntrustNamespaceEntry) -> NextProcess {
+ SetTrustNamespaceEntry::new({
+ let mut args = p.inner.clone();
+ args.extend(vec!["-t".to_string(), "no".to_string()]);
+ args
+ })
+}
+
+#[chain]
+pub(crate) fn handle_remove_namespace(p: RemoveNamespaceEntry) -> NextProcess {
+ let namespace = route!(
+ Picker::new(p.inner)
+ .pick_or_route((), ErrorNamespaceNotProvided::default().to_render())
+ .unpack()
+ );
+ remove_namespace(namespace);
+ ResultNamespaceRemoved::default().to_render()
+}
+
+#[renderer]
+pub(crate) fn render_error_namespace_not_provided(_prev: ErrorNamespaceNotProvided) {
+ r_println!("Error: no namespace was provided!")
+}
diff --git a/mling/src/cli/read.rs b/mling/src/cli/read.rs
new file mode 100644
index 0000000..8717932
--- /dev/null
+++ b/mling/src/cli/read.rs
@@ -0,0 +1,77 @@
+use colored::Colorize;
+use std::path::PathBuf;
+
+use mingling::{
+ Groupped,
+ macros::{chain, dispatcher, pack, r_println, renderer},
+};
+use serde::Serialize;
+
+use crate::{
+ ThisProgram,
+ project_solver::{BinaryItem, solve_current_dir},
+};
+
+dispatcher!("show-target-dir", ReadTargetDirCommand => ReadTargetDirEntry);
+dispatcher!("show-workspace-root", ReadWorkspaceRootCommand => ReadWorkspaceRootEntry);
+dispatcher!("show-binaries", ReadBinariesCommand => ReadBinariesEntry);
+
+pack!(ResultDir = PathBuf);
+pack!(ResultTargetDirNotFound = ());
+
+#[derive(Debug, Serialize, Default, Groupped)]
+pub(crate) struct ResultBinaries {
+ bin: Vec<BinaryItem>,
+}
+
+#[chain]
+pub(crate) fn handle_target_dir_entry(_prev: ReadTargetDirEntry) -> NextProcess {
+ match solve_current_dir() {
+ Ok(solved) => {
+ let dir = solved.target_dir;
+ ResultDir::new(dir).to_render()
+ }
+ Err(_) => ResultTargetDirNotFound::new(()).to_render(),
+ }
+}
+
+#[chain]
+pub(crate) fn handle_workspace_root_entry(_prev: ReadWorkspaceRootEntry) -> NextProcess {
+ match solve_current_dir() {
+ Ok(solved) => {
+ let dir = solved.workspace_root;
+ ResultDir::new(dir).to_render()
+ }
+ Err(_) => ResultTargetDirNotFound::new(()).to_render(),
+ }
+}
+
+#[chain]
+pub(crate) fn handle_binaries_entry(_prev: ReadBinariesEntry) -> NextProcess {
+ match solve_current_dir() {
+ Ok(solved) => {
+ let binaries = solved.binaries;
+ ResultBinaries { bin: binaries }.to_render()
+ }
+ Err(_) => ResultTargetDirNotFound::new(()).to_render(),
+ }
+}
+
+#[renderer]
+pub(crate) fn render_dir(prev: ResultDir) {
+ r_println!("{}", prev.inner.display())
+}
+
+#[renderer]
+pub(crate) fn render_binaries(prev: ResultBinaries) {
+ let mut i = 1;
+ for item in prev.bin.iter() {
+ r_println!(
+ "{}. {} ({})",
+ i.to_string(),
+ item.name.bold(),
+ item.path.to_string_lossy().underline().bright_cyan()
+ );
+ i += 1;
+ }
+}
diff --git a/mling/src/cli/refresh.rs b/mling/src/cli/refresh.rs
new file mode 100644
index 0000000..368670e
--- /dev/null
+++ b/mling/src/cli/refresh.rs
@@ -0,0 +1,32 @@
+use mingling::{
+ ShellContext, Suggest,
+ macros::{chain, completion, dispatcher, pack, suggest},
+ parser::Picker,
+};
+
+use crate::{ThisProgram, project_installer::install_all};
+
+dispatcher!("refresh", RefreshCommand => RefreshEntry);
+
+pack!(ResultRefreshCompleted = ());
+
+#[completion(RefreshEntry)]
+pub(crate) fn comp_refresh(ctx: &ShellContext) -> Suggest {
+ if ctx.typing_argument() {
+ return suggest! {
+ "--clean": "Clean build artifacts before installation",
+ "-c": "Clean build artifacts before installation",
+ };
+ }
+ return suggest!();
+}
+
+#[chain]
+pub(crate) fn handle_refresh_entry(prev: RefreshEntry) -> NextProcess {
+ let is_clean_before_build = Picker::new(prev.inner)
+ .pick::<bool>(["--clean", "-c"])
+ .unpack();
+ let _ = install_all(is_clean_before_build);
+
+ ResultRefreshCompleted::new(())
+}
diff --git a/mling/src/main.rs b/mling/src/main.rs
new file mode 100644
index 0000000..7b72dc1
--- /dev/null
+++ b/mling/src/main.rs
@@ -0,0 +1,14 @@
+use mingling::macros::gen_program;
+
+pub mod cli;
+pub mod namespace_manager;
+pub mod project_installer;
+pub mod project_solver;
+
+use crate::cli::*;
+
+fn main() {
+ cli_entry();
+}
+
+gen_program!();
diff --git a/mling/src/namespace_manager.rs b/mling/src/namespace_manager.rs
new file mode 100644
index 0000000..4d36136
--- /dev/null
+++ b/mling/src/namespace_manager.rs
@@ -0,0 +1,113 @@
+use std::path::PathBuf;
+
+use just_fmt::kebab_case;
+
+pub fn list_namespaces(
+ show_trusted: bool,
+ show_untrusted: bool,
+ show_untagged: bool,
+) -> Vec<String> {
+ let wdir = working_dir();
+ if !wdir.exists() {
+ return Vec::new();
+ }
+
+ let mut namespaces = Vec::new();
+ let entries = match std::fs::read_dir(&wdir) {
+ Ok(entries) => entries,
+ Err(_) => return Vec::new(),
+ };
+ for entry in entries {
+ let entry = match entry {
+ Ok(e) => e,
+ Err(_) => continue,
+ };
+ let path = entry.path();
+ if path.is_dir() {
+ if let Some(name) = path.file_name() {
+ if let Some(name_str) = name.to_str() {
+ // Skip directories starting with a dot
+ if name_str.starts_with('.') {
+ continue;
+ }
+ let namespace = name_str.to_string();
+ let is_trusted = is_trusted_namespace(namespace.clone());
+ let is_untrusted = is_untrusted_namespace(namespace.clone());
+ let is_untagged = is_untagged_namespace(namespace.clone());
+
+ if (show_trusted && is_trusted)
+ || (show_untrusted && is_untrusted)
+ || (show_untagged && is_untagged)
+ {
+ namespaces.push(namespace);
+ }
+ }
+ }
+ }
+ }
+
+ namespaces
+}
+
+pub fn set_namespace_trusted(namespace: String, trusted: bool) {
+ let ndir = namespace_dir(namespace);
+ let trusted_file = ndir.join("TRUSTED");
+ let untrusted_file = ndir.join("UNTRUSTED");
+
+ if trusted {
+ // Create TRUSTED file and remove UNTRUSTED if it exists
+ let _ = std::fs::write(&trusted_file, "");
+ let _ = std::fs::remove_file(&untrusted_file);
+ } else {
+ // Remove TRUSTED file
+ let _ = std::fs::remove_file(&trusted_file);
+ }
+}
+
+pub fn remove_namespace(namespace: String) {
+ let ndir = namespace_dir(namespace);
+ if ndir.exists() {
+ let _ = std::fs::remove_dir_all(&ndir);
+ }
+}
+
+pub fn working_dir() -> PathBuf {
+ dirs::data_dir().unwrap().join("mingling")
+}
+
+pub fn namespace_dir(namespace: String) -> PathBuf {
+ working_dir().join(kebab_case!(namespace))
+}
+
+pub fn is_untrusted_namespace(namespace: String) -> bool {
+ let untrusted_file = namespace_dir(namespace).join("UNTRUSTED");
+ untrusted_file.exists()
+}
+
+pub fn is_trusted_namespace(namespace: String) -> bool {
+ let trusted = namespace_dir(namespace).join("TRUSTED");
+ trusted.exists()
+}
+
+pub fn is_untagged_namespace(namespace: String) -> bool {
+ let ndir = namespace_dir(namespace);
+ let trusted = ndir.join("TRUSTED");
+ let untrusted = ndir.join("UNTRUSTED");
+ !trusted.exists() && !untrusted.exists()
+}
+
+pub fn bin_dir(namespace: String) -> PathBuf {
+ namespace_dir(namespace).join("bin")
+}
+
+pub fn comp_dir(namespace: String) -> PathBuf {
+ namespace_dir(namespace).join("comp")
+}
+
+pub fn exe_path(namespace: String, bin_name_without_ext: String) -> PathBuf {
+ if cfg!(target_os = "windows") {
+ bin_dir(namespace).join(bin_name_without_ext + ".exe")
+ } else {
+ bin_dir(namespace).join(bin_name_without_ext)
+ }
+}
diff --git a/mling/src/project_installer.rs b/mling/src/project_installer.rs
new file mode 100644
index 0000000..d004e40
--- /dev/null
+++ b/mling/src/project_installer.rs
@@ -0,0 +1,153 @@
+use std::path::PathBuf;
+
+use mingling::{ShellFlag, build::build_comp_script_to};
+
+use crate::{
+ namespace_manager::{bin_dir, comp_dir, exe_path, working_dir},
+ project_solver::solve,
+};
+
+const SCRIPT_LOAD_BASH: &str = include_str!("../tmpl/load.sh");
+const SCRIPT_LOAD_FISH: &str = include_str!("../tmpl/load.fish");
+const SCRIPT_LOAD_PWSH: &str = include_str!("../tmpl/load.ps1");
+
+#[derive(serde::Deserialize)]
+struct CargoToml {
+ package: Package,
+}
+
+#[derive(serde::Deserialize)]
+struct Package {
+ name: String,
+}
+
+pub fn install_all(clean_before_build: bool) -> Result<(), std::io::Error> {
+ let current = std::env::current_dir()?;
+ install_this_project(current, clean_before_build)?;
+ install_shell_scripts()?;
+ Ok(())
+}
+
+pub fn install_this_project(
+ current: PathBuf,
+ clean_before_build: bool,
+) -> Result<(), std::io::Error> {
+ // Obtain context data
+ let solved = solve(current)?;
+
+ let workspace_root = &solved.workspace_root;
+
+ // If clean_before_build, execute cargo clean in workspace_root first
+ if clean_before_build {
+ let status = std::process::Command::new("cargo")
+ .arg("clean")
+ .current_dir(workspace_root)
+ .status()?;
+ if !status.success() {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::Other,
+ "exec `cargo clean` failed",
+ ));
+ }
+ }
+
+ // Execute cargo build --release in workspace_root
+ let status = std::process::Command::new("cargo")
+ .args(["build", "--release"])
+ .current_dir(workspace_root)
+ .status()?;
+ if !status.success() {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::Other,
+ "cargo build --release failed",
+ ));
+ }
+
+ // Parse package.name from workspace_root's Cargo.toml as namespace
+ let cargo_toml_content = std::fs::read_to_string(workspace_root.join("Cargo.toml"))?;
+ let cargo_toml: CargoToml = toml::from_str(&cargo_toml_content).map_err(|e| {
+ std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ format!("failed to parse Cargo.toml: {e}"),
+ )
+ })?;
+ let namespace = cargo_toml.package.name;
+
+ // Ensure destination directories exist
+ std::fs::create_dir_all(bin_dir(namespace.clone()))?;
+ std::fs::create_dir_all(comp_dir(namespace.clone()))?;
+
+ // Copy binaries to corresponding exe_path
+ for bin in &solved.binaries {
+ let dst = exe_path(namespace.clone(), bin.name.clone());
+ std::fs::copy(&bin.path, &dst)?;
+ }
+
+ // Copy all completion scripts containing _comp from target/release to comp_dir
+ let target_dir = &solved.target_dir;
+ let release_dir = target_dir.join("release");
+ if release_dir.exists() {
+ for entry in std::fs::read_dir(&release_dir)? {
+ let entry = entry?;
+ let file_name = entry.file_name();
+ let file_name_str = file_name.to_string_lossy();
+ if file_name_str.contains("_comp") {
+ let dest = comp_dir(namespace.clone()).join(file_name.as_os_str());
+ std::fs::copy(entry.path(), &dest)?;
+ }
+ }
+ }
+
+ Ok(())
+}
+
+pub fn install_shell_scripts() -> Result<(), std::io::Error> {
+ // Get the working directory (mingling data dir)
+ let wdir = working_dir();
+ std::fs::create_dir_all(&wdir)?;
+
+ // Build shell completion scripts for the "mling" command based on the current OS
+ let mling_comp = if cfg!(target_os = "windows") {
+ vec![ShellFlag::Powershell]
+ } else if cfg!(target_os = "macos") || cfg!(target_os = "linux") {
+ vec![ShellFlag::Bash, ShellFlag::Zsh, ShellFlag::Fish]
+ } else {
+ vec![ShellFlag::Bash]
+ };
+
+ for flag in mling_comp {
+ build_comp_script_to(
+ &flag,
+ "mling",
+ wdir.join(".comp").display().to_string().as_str(),
+ )?;
+ }
+
+ // Determine which scripts to write based on platform
+ let scripts: Vec<(&str, &str)> = if cfg!(target_os = "windows") {
+ vec![("load.ps1", SCRIPT_LOAD_PWSH)]
+ } else if cfg!(target_os = "macos") || cfg!(target_os = "linux") {
+ vec![
+ ("load.sh", SCRIPT_LOAD_BASH),
+ ("load.fish", SCRIPT_LOAD_FISH),
+ ]
+ } else {
+ // Fallback: write bash script
+ vec![("load.sh", SCRIPT_LOAD_BASH)]
+ };
+
+ for (filename, content) in scripts {
+ let dest = wdir.join(filename);
+ std::fs::write(&dest, content)?;
+ if cfg!(target_os = "linux") {
+ let status = std::process::Command::new("chmod")
+ .args(["+x", &dest.to_string_lossy()])
+ .status()?;
+ if !status.success() {
+ eprintln!("Failed to chmod {}", filename);
+ }
+ }
+ }
+
+ Ok(())
+}
diff --git a/mling/src/project_solver.rs b/mling/src/project_solver.rs
new file mode 100644
index 0000000..381bba2
--- /dev/null
+++ b/mling/src/project_solver.rs
@@ -0,0 +1,105 @@
+use std::path::PathBuf;
+
+use serde::Serialize;
+
+pub type BinaryName = String;
+pub type BinaryTargetPath = PathBuf;
+
+pub struct ProjectSolveResult {
+ pub target_dir: PathBuf,
+ pub workspace_root: PathBuf,
+ pub binaries: Vec<BinaryItem>,
+}
+
+#[derive(Debug, Serialize)]
+pub struct BinaryItem {
+ pub name: String,
+ pub path: PathBuf,
+}
+
+pub fn solve_current_dir() -> Result<ProjectSolveResult, std::io::Error> {
+ let current = std::env::current_dir()?;
+ solve(current)
+}
+
+pub fn solve(current: PathBuf) -> Result<ProjectSolveResult, std::io::Error> {
+ let (target_dir, workspace_root, binaries) = solve_inner(&current)?;
+ Ok(ProjectSolveResult {
+ target_dir,
+ workspace_root,
+ binaries,
+ })
+}
+
+fn solve_inner(current: &PathBuf) -> Result<(PathBuf, PathBuf, Vec<BinaryItem>), std::io::Error> {
+ let output = std::process::Command::new("cargo")
+ .arg("metadata")
+ .arg("--format-version")
+ .arg("1")
+ .current_dir(current)
+ .output()?;
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::Other,
+ format!("cargo metadata failed: {}", stderr),
+ ));
+ }
+ let metadata: serde_json::Value = serde_json::from_slice(&output.stdout)
+ .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
+
+ let workspace_root_str = metadata["workspace_root"].as_str().ok_or_else(|| {
+ std::io::Error::new(std::io::ErrorKind::InvalidData, "missing workspace_root")
+ })?;
+ let workspace_root = PathBuf::from(workspace_root_str);
+
+ let target_dir_str = metadata["target_directory"].as_str().ok_or_else(|| {
+ std::io::Error::new(std::io::ErrorKind::InvalidData, "missing target_directory")
+ })?;
+ let target_dir = PathBuf::from(target_dir_str);
+
+ let packages = metadata["packages"].as_array().ok_or_else(|| {
+ std::io::Error::new(std::io::ErrorKind::InvalidData, "missing packages array")
+ })?;
+
+ let mut binaries = Vec::new();
+ let cargo_toml_path = workspace_root.join("Cargo.toml");
+
+ // Find the package whose manifest_path matches workspace_root/Cargo.toml
+ for pkg in packages {
+ let manifest_path = pkg["manifest_path"].as_str().ok_or_else(|| {
+ std::io::Error::new(std::io::ErrorKind::InvalidData, "missing manifest_path")
+ })?;
+ let manifest_path_buf = PathBuf::from(manifest_path);
+ if manifest_path_buf == cargo_toml_path {
+ // Found the workspace root package
+ if let Some(targets) = pkg["targets"].as_array() {
+ for target in targets {
+ let kind = target["kind"].as_array();
+ let is_bin = kind
+ .map(|k| k.iter().any(|v| v.as_str() == Some("bin")))
+ .unwrap_or(false);
+ if is_bin {
+ let name = target["name"].as_str().ok_or_else(|| {
+ std::io::Error::new(
+ std::io::ErrorKind::InvalidData,
+ "missing target name",
+ )
+ })?;
+ let mut binary_path = target_dir.join("release").join(name);
+ if cfg!(target_os = "windows") {
+ binary_path.set_extension("exe");
+ }
+ binaries.push(BinaryItem {
+ name: name.to_string(),
+ path: binary_path,
+ });
+ }
+ }
+ }
+ break;
+ }
+ }
+
+ Ok((target_dir, workspace_root, binaries))
+}