diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7afdb26..ac18cf0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
+#### Features
+
+* Add support for `[workspace.dependencies]`, (closes [#360](https://github.com/kbknapp/cargo-outdated/issues/360))
+
### v0.18.0 (2025-10-27)
#### Changes
diff --git a/src/cargo_ops/elaborate_workspace.rs b/src/cargo_ops/elaborate_workspace.rs
index 4a935b2..261d645 100644
--- a/src/cargo_ops/elaborate_workspace.rs
+++ b/src/cargo_ops/elaborate_workspace.rs
@@ -2,7 +2,9 @@ use std::{
cell::RefCell,
cmp::Ordering,
collections::{BTreeSet, HashMap, HashSet, VecDeque},
- io::{self, Write},
+ fs::File,
+ io::{self, Read, Write},
+ path::Path,
rc::Rc,
};
@@ -23,6 +25,7 @@ use cargo::{
use rustc_hash::FxHashMap;
use serde::{Deserialize, Serialize};
use tabwriter::TabWriter;
+use toml::Value;
use crate::error::OutdatedError;
@@ -38,12 +41,16 @@ pub struct ElaborateWorkspace<'ela> {
pub pkg_status: RefCell, PkgStatus>>,
/// Whether using workspace mode
pub workspace_mode: bool,
+ /// Names of workspace-level dependencies
+ pub workspace_deps: HashSet,
}
/// A struct to serialize to json with serde
#[derive(Serialize, Deserialize)]
pub struct CrateMetadata {
pub crate_name: String,
+ #[serde(skip_serializing_if = "BTreeSet::is_empty")]
+ pub workspace_dependencies: BTreeSet,
pub dependencies: BTreeSet,
}
@@ -65,11 +72,34 @@ impl PartialOrd for Metadata {
fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) }
}
+/// Load workspace dependency names from the workspace root Cargo.toml
+pub fn load_workspace_dep_names(workspace_root: &Path) -> CargoResult> {
+ let cargo_toml = workspace_root.join("Cargo.toml");
+
+ if !cargo_toml.is_file() {
+ return Ok(HashSet::new());
+ }
+
+ let mut buf = String::new();
+ File::open(&cargo_toml)?.read_to_string(&mut buf)?;
+ let root: Value = ::toml::from_str(&buf)?;
+
+ let ws_deps = root
+ .get("workspace")
+ .and_then(|ws| ws.get("dependencies"))
+ .and_then(|deps| deps.as_table())
+ .map(|table| table.keys().cloned().collect())
+ .unwrap_or_default();
+
+ Ok(ws_deps)
+}
+
impl<'ela> ElaborateWorkspace<'ela> {
/// Elaborate a `Workspace`
pub fn from_workspace(
workspace: &'ela Workspace<'_>,
options: &Options,
+ workspace_deps: HashSet,
) -> CargoResult> {
// new in cargo 0.54.0
let flag_features: BTreeSet = options
@@ -128,6 +158,7 @@ impl<'ela> ElaborateWorkspace<'ela> {
pkg_deps,
pkg_status: RefCell::new(FxHashMap::default()),
workspace_mode: options.workspace || workspace.current().is_err(),
+ workspace_deps,
})
}
@@ -278,17 +309,21 @@ impl<'ela> ElaborateWorkspace<'ela> {
Ok(())
}
- /// Print package status to `TabWriter`
- pub fn print_list(
+ /// Collect dependency lines for a given root, split into workspace and
+ /// package deps.
+ ///
+ /// Returns `(workspace_lines, package_lines)`.
+ fn collect_dependency_lines(
&'ela self,
options: &Options,
root: PackageId,
- preceding_line: bool,
skip: &HashSet,
- ) -> CargoResult {
- let mut lines = BTreeSet::new();
+ ) -> CargoResult<(BTreeSet, BTreeSet)> {
+ let mut workspace_lines = BTreeSet::new();
+ let mut package_lines = BTreeSet::new();
let mut queue = VecDeque::new();
queue.push_back(vec![root]);
+
while let Some(path) = queue.pop_front() {
let pkg = path.last().ok_or(OutdatedError::EmptyPath)?;
let name = pkg.name().to_string();
@@ -298,19 +333,19 @@ impl<'ela> ElaborateWorkspace<'ela> {
}
let depth = path.len() as i32 - 1;
- // generate lines
let status = &self.pkg_status.borrow_mut()[&path];
+
if (status.compat.is_changed() || status.latest.is_changed())
&& (options.packages.is_empty() || options.packages.contains(&name))
{
- // name version compatible latest kind platform
let parent = path.get(path.len() - 2);
+
if let Some(parent) = parent {
let dependency = &self.pkg_deps[parent][pkg];
let label = if self.workspace_mode
|| parent == &self.workspace.current()?.package_id()
{
- name
+ name.clone()
} else {
format!("{}->{}", self.pkgs[parent].name(), name)
};
@@ -326,7 +361,14 @@ impl<'ela> ElaborateWorkspace<'ela> {
.map(ToString::to_string)
.unwrap_or_else(|| "---".to_owned())
);
- lines.insert(line);
+
+ let is_direct_workspace_dep = depth == 1 && self.workspace_deps.contains(&name);
+
+ if is_direct_workspace_dep {
+ workspace_lines.insert(line);
+ } else {
+ package_lines.insert(line);
+ }
} else {
let line = format!(
"{}\t{}\t{}\t{}\t---\t---\n",
@@ -335,9 +377,10 @@ impl<'ela> ElaborateWorkspace<'ela> {
status.compat,
status.latest
);
- lines.insert(line);
+ package_lines.insert(line);
}
}
+
// next layer
// this unwrap is safe since we first check if it is None :)
if options.depth.is_none() || depth < options.depth.unwrap() {
@@ -357,6 +400,29 @@ impl<'ela> ElaborateWorkspace<'ela> {
}
}
+ Ok((workspace_lines, package_lines))
+ }
+
+ /// Print package status to `TabWriter`
+ pub fn print_list(
+ &'ela self,
+ options: &Options,
+ root: PackageId,
+ preceding_line: bool,
+ skip: &HashSet,
+ ) -> CargoResult {
+ let (workspace_lines, package_lines) =
+ self.collect_dependency_lines(options, root, skip)?;
+
+ // When workspace deps exist, they are printed separately via
+ // `print_workspace_deps_list`, so only include package_lines here.
+ let lines = if self.workspace_deps.is_empty() {
+ // No workspace deps section — everything goes in one table
+ &package_lines | &workspace_lines
+ } else {
+ package_lines
+ };
+
if lines.is_empty() {
if !self.workspace_mode {
println!("All dependencies are up to date, yay!");
@@ -365,15 +431,19 @@ impl<'ela> ElaborateWorkspace<'ela> {
if preceding_line {
println!();
}
+
if self.workspace_mode {
println!("{}\n================", root.name());
}
+
let mut tw = TabWriter::new(vec![]);
writeln!(&mut tw, "Name\tProject\tCompat\tLatest\tKind\tPlatform")?;
writeln!(&mut tw, "----\t-------\t------\t------\t----\t--------")?;
+
for line in &lines {
write!(&mut tw, "{line}")?;
}
+
tw.flush()?;
write!(io::stdout(), "{}", String::from_utf8(tw.into_inner()?)?)?;
io::stdout().flush()?;
@@ -382,6 +452,56 @@ impl<'ela> ElaborateWorkspace<'ela> {
Ok(lines.len() as i32)
}
+ /// Collect workspace dependency lines across all members and print them
+ /// as a single table at the top of the output.
+ ///
+ /// Resolves status per member internally (since `resolve_status` clears
+ /// state each time).
+ ///
+ /// Returns the number of lines printed.
+ pub fn print_workspace_deps_list(
+ &'ela self,
+ compat: &ElaborateWorkspace<'_>,
+ latest: &ElaborateWorkspace<'_>,
+ options: &Options,
+ context: &GlobalContext,
+ skip: &HashSet,
+ ) -> CargoResult {
+ if self.workspace_deps.is_empty() {
+ return Ok(0);
+ }
+
+ let mut all_ws_lines = BTreeSet::new();
+
+ for member in self.workspace.members() {
+ self.resolve_status(compat, latest, options, context, member.package_id(), skip)?;
+
+ let (ws_lines, _) =
+ self.collect_dependency_lines(options, member.package_id(), skip)?;
+ all_ws_lines.extend(ws_lines);
+ }
+
+ if all_ws_lines.is_empty() {
+ return Ok(0);
+ }
+
+ println!("Workspace\n================");
+
+ let mut tw = TabWriter::new(vec![]);
+ writeln!(&mut tw, "Name\tProject\tCompat\tLatest\tKind\tPlatform")?;
+ writeln!(&mut tw, "----\t-------\t------\t------\t----\t--------")?;
+
+ for line in &all_ws_lines {
+ write!(&mut tw, "{line}")?;
+ }
+
+ tw.flush()?;
+ write!(io::stdout(), "{}", String::from_utf8(tw.into_inner()?)?)?;
+ io::stdout().flush()?;
+
+ Ok(all_ws_lines.len() as i32)
+ }
+
pub fn print_json(
&'ela self,
options: &Options,
@@ -390,6 +510,7 @@ impl<'ela> ElaborateWorkspace<'ela> {
) -> CargoResult {
let mut crate_graph = CrateMetadata {
crate_name: root.name().to_string(),
+ workspace_dependencies: BTreeSet::new(),
dependencies: BTreeSet::new(),
};
let mut queue = VecDeque::new();
@@ -422,9 +543,9 @@ impl<'ela> ElaborateWorkspace<'ela> {
let label = if self.workspace_mode
|| parent == &self.workspace.current()?.package_id()
{
- name
+ name.clone()
} else {
- format!("{}->{}", self.pkgs[parent].name(), name)
+ format!("{}->{}", self.pkgs[parent].name(), name.clone())
};
let dependency_type = match dependency.kind() {
@@ -443,7 +564,7 @@ impl<'ela> ElaborateWorkspace<'ela> {
}
} else {
Metadata {
- name,
+ name: name.clone(),
project: pkg.version().to_string(),
compat: status.compat.to_string(),
latest: status.latest.to_string(),
@@ -452,7 +573,14 @@ impl<'ela> ElaborateWorkspace<'ela> {
}
};
- crate_graph.dependencies.insert(line);
+ // Check if this is a workspace dependency (direct dependency at depth 1)
+ let is_workspace_dep = depth == 1 && self.workspace_deps.contains(&name);
+
+ if is_workspace_dep {
+ crate_graph.workspace_dependencies.insert(line);
+ } else {
+ crate_graph.dependencies.insert(line);
+ }
}
// next layer
// this unwrap is safe since we first check if it is None :)
@@ -478,6 +606,6 @@ impl<'ela> ElaborateWorkspace<'ela> {
println!("{}", serde_json::to_string(&crate_graph)?);
- Ok(crate_graph.dependencies.len() as i32)
+ Ok((crate_graph.workspace_dependencies.len() + crate_graph.dependencies.len()) as i32)
}
}
diff --git a/src/cargo_ops/mod.rs b/src/cargo_ops/mod.rs
index 0f84445..b7e30d1 100644
--- a/src/cargo_ops/mod.rs
+++ b/src/cargo_ops/mod.rs
@@ -4,7 +4,10 @@ use toml::value::{Table, Value};
mod elaborate_workspace;
mod pkg_status;
mod temp_project;
-pub use self::{elaborate_workspace::ElaborateWorkspace, temp_project::TempProject};
+pub use self::{
+ elaborate_workspace::{load_workspace_dep_names, ElaborateWorkspace},
+ temp_project::TempProject,
+};
/// A continent struct for quick parsing and manipulating manifest
#[derive(Debug, serde::Serialize, serde::Deserialize)]
diff --git a/src/cargo_ops/temp_project.rs b/src/cargo_ops/temp_project.rs
index 6bcd307..65bd4f8 100644
--- a/src/cargo_ops/temp_project.rs
+++ b/src/cargo_ops/temp_project.rs
@@ -49,6 +49,7 @@ impl<'tmp> TempProject<'tmp> {
let workspace_root_str = workspace_root.to_string_lossy();
let temp_dir = Builder::new().prefix("cargo-outdated").tempdir()?;
let manifest_paths = manifest_paths(orig_workspace)?;
+ let ws_deps = load_workspace_deps(workspace_root)?;
let mut tmp_manifest_paths = vec![];
for from in &manifest_paths {
@@ -73,7 +74,7 @@ impl<'tmp> TempProject<'tmp> {
tmp_manifest_paths.push(dest.clone());
fs::copy(from, &dest)?;
- // removing default-run key if it exists to check dependencies
+ // Parse manifest, clean up keys, resolve workspace deps, and re-serialize
let mut om: Manifest = {
let mut buf = String::new();
let mut file = File::open(&dest)?;
@@ -81,32 +82,33 @@ impl<'tmp> TempProject<'tmp> {
::toml::from_str(&buf)?
};
- if om.package.contains_key("default-run") {
- om.package.remove("default-run");
- let om_serialized = ::toml::to_string(&om).expect("Cannot format as toml file");
- let mut cargo_toml = OpenOptions::new()
- .read(true)
- .write(true)
- .truncate(true)
- .open(&dest)?;
- write!(cargo_toml, "{om_serialized}")?;
+ let mut needs_rewrite = false;
+
+ // Remove keys not needed for dependency checking
+ for key in &["default-run", "links", "build"] {
+ if om.package.remove(*key).is_some() {
+ needs_rewrite = true;
+ }
}
- // if build script is specified in the original Cargo.toml (from links or build)
- // remove it as we do not need it for checking dependencies
- if om.package.contains_key("links") {
- om.package.remove("links");
- let om_serialized = ::toml::to_string(&om).expect("Cannot format as toml file");
- let mut cargo_toml = OpenOptions::new()
- .read(true)
- .write(true)
- .truncate(true)
- .open(&dest)?;
- write!(cargo_toml, "{om_serialized}")?;
+ // Resolve workspace = true references and strip [workspace.dependencies]
+ if let Some(ref ws_deps) = ws_deps {
+ // Member directory relative to workspace root (e.g. "tauri-app")
+ let member_rel_dir = if workspace_root_str.len() < from_dir_str.len() {
+ PathBuf::from(&from_dir_str[workspace_root_str.len() + 1..])
+ } else {
+ PathBuf::new()
+ };
+ resolve_all_workspace_deps(&mut om, ws_deps, &member_rel_dir)?;
+
+ if let Some(ref mut ws) = om.workspace {
+ ws.remove("dependencies");
+ }
+
+ needs_rewrite = true;
}
- if om.package.contains_key("build") {
- om.package.remove("build");
+ if needs_rewrite {
let om_serialized = ::toml::to_string(&om).expect("Cannot format as toml file");
let mut cargo_toml = OpenOptions::new()
.read(true)
@@ -127,9 +129,19 @@ impl<'tmp> TempProject<'tmp> {
// virtual root
let mut virtual_root = workspace_root.join("Cargo.toml");
if !manifest_paths.contains(&virtual_root) && virtual_root.is_file() {
- fs::copy(&virtual_root, temp_dir.path().join("Cargo.toml"))?;
+ let dest_root = temp_dir.path().join("Cargo.toml");
+ fs::copy(&virtual_root, &dest_root)?;
+
+ // Remove [workspace.dependencies] from the copy since member deps
+ // have been flattened and Cargo would complain about unreferenced
+ // workspace dependencies.
+ if ws_deps.is_some() {
+ strip_workspace_deps(&dest_root)?;
+ }
+
virtual_root.pop();
virtual_root.push("Cargo.lock");
+
if virtual_root.is_file() {
fs::copy(&virtual_root, temp_dir.path().join("Cargo.lock"))?;
}
@@ -733,6 +745,212 @@ impl<'tmp> TempProject<'tmp> {
}
}
+/// Rebase a path that is relative to the workspace root so that it becomes
+/// relative to `from_dir` (also relative to workspace root).
+///
+/// Example: `rebase_relative_path("tauri-app", "crates/api-service")`
+/// returns `"../crates/api-service"`.
+fn rebase_relative_path(from_dir: &Path, target: &Path) -> PathBuf {
+ let depth = from_dir.components().count();
+ let mut result = PathBuf::new();
+
+ for _ in 0..depth {
+ result.push("..");
+ }
+
+ result.push(target);
+
+ result
+}
+
+/// Load `[workspace.dependencies]` from a workspace root Cargo.toml.
+///
+/// Parses the file as raw `toml::Value` (not `Manifest`) to avoid
+/// requiring `[package]`, which virtual workspace roots lack.
+fn load_workspace_deps(workspace_root: &Path) -> CargoResult