From 9050c1d0eca5469de704e58a6fd61e0c9d348ef2 Mon Sep 17 00:00:00 2001 From: Vincent Esche Date: Sun, 8 Feb 2026 23:16:27 +0100 Subject: [PATCH 1/5] Resolve `workspace = true` dependencies before temp project manipulation --- src/cargo_ops/temp_project.rs | 214 ++++++++++++++++++++++++++++++---- 1 file changed, 190 insertions(+), 24 deletions(-) diff --git a/src/cargo_ops/temp_project.rs b/src/cargo_ops/temp_project.rs index 6bcd307..1be5810 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,27 @@ 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 { + resolve_all_workspace_deps(&mut om, ws_deps)?; + + 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 +123,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 +739,166 @@ impl<'tmp> TempProject<'tmp> { } } +/// 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> { + let cargo_toml = workspace_root.join("Cargo.toml"); + + if !cargo_toml.is_file() { + return Ok(None); + } + + 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()) + .cloned(); + + Ok(ws_deps) +} + +/// Resolve `workspace = true` references in a single dependency table +/// by merging actual version/source info from workspace-level deps. +fn resolve_workspace_deps(deps: &mut Table, ws_deps: &Table) -> CargoResult<()> { + let dep_keys: Vec<_> = deps.keys().cloned().collect(); + + for key in dep_keys { + let needs_resolve = deps + .get(&key) + .and_then(|v| v.as_table()) + .and_then(|t| t.get("workspace")) + .and_then(|w| w.as_bool()) + .unwrap_or(false); + + if !needs_resolve { + continue; + } + + let ws_dep = ws_deps.get(&key).ok_or_else(|| { + anyhow!( + "dependency `{}` has `workspace = true` but is not defined in [workspace.dependencies]", + key + ) + })?; + + let member_table = deps.get(&key).unwrap().as_table().unwrap().clone(); + + // Build the resolved table starting from workspace definition + let mut resolved = match ws_dep { + Value::String(version) => { + let mut t = Table::new(); + t.insert("version".to_owned(), Value::String(version.clone())); + t + } + Value::Table(ws_table) => ws_table.clone(), + _ => { + return Err(anyhow!( + "workspace dependency `{}` has unexpected type", + key + )); + } + }; + + // Merge member-level overrides on top of workspace values. + // Member values take precedence, except for `features` which are additive. + for (k, v) in &member_table { + if k == "workspace" { + continue; + } + + if k == "features" { + // Cargo merges features additively + let mut combined: Vec = resolved + .get("features") + .and_then(|f| f.as_array()) + .cloned() + .unwrap_or_default(); + + if let Value::Array(ref member_features) = *v { + for feat in member_features { + if !combined.contains(feat) { + combined.push(feat.clone()); + } + } + } + + resolved.insert("features".to_owned(), Value::Array(combined)); + } else { + resolved.insert(k.clone(), v.clone()); + } + } + + deps.insert(key, Value::Table(resolved)); + } + + Ok(()) +} + +/// Resolve all `workspace = true` dep references across every dependency +/// section in a manifest. +fn resolve_all_workspace_deps(manifest: &mut Manifest, ws_deps: &Table) -> CargoResult<()> { + if let Some(ref mut deps) = manifest.dependencies { + resolve_workspace_deps(deps, ws_deps)?; + } + + if let Some(ref mut deps) = manifest.dev_dependencies { + resolve_workspace_deps(deps, ws_deps)?; + } + + if let Some(ref mut deps) = manifest.build_dependencies { + resolve_workspace_deps(deps, ws_deps)?; + } + + if let Some(ref mut targets) = manifest.target { + for (_key, target) in targets.iter_mut() { + if let Value::Table(ref mut target) = *target { + for dep_section in &["dependencies", "dev-dependencies", "build-dependencies"] { + if let Some(&mut Value::Table(ref mut dep_table)) = target.get_mut(*dep_section) + { + resolve_workspace_deps(dep_table, ws_deps)?; + } + } + } + } + } + + Ok(()) +} + +/// Strip `[workspace.dependencies]` from a raw TOML value. +/// +/// After flattening workspace deps into member manifests, the virtual root's +/// `[workspace.dependencies]` section must be removed so Cargo doesn't +/// complain about unreferenced workspace dependencies. +fn strip_workspace_deps(cargo_toml_path: &Path) -> CargoResult<()> { + let mut buf = String::new(); + File::open(cargo_toml_path)?.read_to_string(&mut buf)?; + let mut root: Value = ::toml::from_str(&buf)?; + + let changed = if let Some(ws) = root.get_mut("workspace").and_then(|ws| ws.as_table_mut()) { + ws.remove("dependencies").is_some() + } else { + false + }; + + if changed { + let serialized = ::toml::to_string(&root).expect("Cannot format as toml file"); + let mut file = OpenOptions::new() + .write(true) + .truncate(true) + .open(cargo_toml_path)?; + write!(file, "{serialized}")?; + } + + Ok(()) +} + /// Features and optional dependencies of a Summary fn features_and_options(summary: &Summary) -> HashSet<&str> { let mut result: HashSet<&str> = summary.features().keys().map(|s| s.as_str()).collect(); From a09d801b2bd3b60ac193947e9c8e8a950bfe3ede Mon Sep 17 00:00:00 2001 From: Vincent Esche Date: Tue, 10 Feb 2026 10:14:29 +0100 Subject: [PATCH 2/5] Make workspace dep paths absolute to prevent misresolution in member manifests --- src/cargo_ops/temp_project.rs | 40 +++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/src/cargo_ops/temp_project.rs b/src/cargo_ops/temp_project.rs index 1be5810..8755ed9 100644 --- a/src/cargo_ops/temp_project.rs +++ b/src/cargo_ops/temp_project.rs @@ -93,7 +93,7 @@ impl<'tmp> TempProject<'tmp> { // Resolve workspace = true references and strip [workspace.dependencies] if let Some(ref ws_deps) = ws_deps { - resolve_all_workspace_deps(&mut om, ws_deps)?; + resolve_all_workspace_deps(&mut om, ws_deps, workspace_root)?; if let Some(ref mut ws) = om.workspace { ws.remove("dependencies"); @@ -765,7 +765,15 @@ fn load_workspace_deps(workspace_root: &Path) -> CargoResult> { /// Resolve `workspace = true` references in a single dependency table /// by merging actual version/source info from workspace-level deps. -fn resolve_workspace_deps(deps: &mut Table, ws_deps: &Table) -> CargoResult<()> { +/// +/// Relative `path` values are made absolute (relative to `workspace_root`) +/// so that downstream path handling doesn't misinterpret them as relative +/// to the member directory. +fn resolve_workspace_deps( + deps: &mut Table, + ws_deps: &Table, + workspace_root: &Path, +) -> CargoResult<()> { let dep_keys: Vec<_> = deps.keys().cloned().collect(); for key in dep_keys { @@ -834,6 +842,20 @@ fn resolve_workspace_deps(deps: &mut Table, ws_deps: &Table) -> CargoResult<()> } } + // Make relative paths absolute so they are not misinterpreted as + // relative to the member directory by `replace_path_with_absolute`. + if let Some(Value::String(ref p)) = resolved.get("path") { + let dep_path = Path::new(p); + + if dep_path.is_relative() { + let abs_path = workspace_root.join(dep_path); + resolved.insert( + "path".to_owned(), + Value::String(abs_path.to_string_lossy().to_string()), + ); + } + } + deps.insert(key, Value::Table(resolved)); } @@ -842,17 +864,21 @@ fn resolve_workspace_deps(deps: &mut Table, ws_deps: &Table) -> CargoResult<()> /// Resolve all `workspace = true` dep references across every dependency /// section in a manifest. -fn resolve_all_workspace_deps(manifest: &mut Manifest, ws_deps: &Table) -> CargoResult<()> { +fn resolve_all_workspace_deps( + manifest: &mut Manifest, + ws_deps: &Table, + workspace_root: &Path, +) -> CargoResult<()> { if let Some(ref mut deps) = manifest.dependencies { - resolve_workspace_deps(deps, ws_deps)?; + resolve_workspace_deps(deps, ws_deps, workspace_root)?; } if let Some(ref mut deps) = manifest.dev_dependencies { - resolve_workspace_deps(deps, ws_deps)?; + resolve_workspace_deps(deps, ws_deps, workspace_root)?; } if let Some(ref mut deps) = manifest.build_dependencies { - resolve_workspace_deps(deps, ws_deps)?; + resolve_workspace_deps(deps, ws_deps, workspace_root)?; } if let Some(ref mut targets) = manifest.target { @@ -861,7 +887,7 @@ fn resolve_all_workspace_deps(manifest: &mut Manifest, ws_deps: &Table) -> Cargo for dep_section in &["dependencies", "dev-dependencies", "build-dependencies"] { if let Some(&mut Value::Table(ref mut dep_table)) = target.get_mut(*dep_section) { - resolve_workspace_deps(dep_table, ws_deps)?; + resolve_workspace_deps(dep_table, ws_deps, workspace_root)?; } } } From e7df01cf2374a156eb999a4bf9c49f6509a3efa8 Mon Sep 17 00:00:00 2001 From: Vincent Esche Date: Tue, 10 Feb 2026 10:22:53 +0100 Subject: [PATCH 3/5] Rebase workspace dep paths relative to member directory instead of making them absolute --- src/cargo_ops/temp_project.rs | 54 ++++++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/src/cargo_ops/temp_project.rs b/src/cargo_ops/temp_project.rs index 8755ed9..65bd4f8 100644 --- a/src/cargo_ops/temp_project.rs +++ b/src/cargo_ops/temp_project.rs @@ -93,7 +93,13 @@ impl<'tmp> TempProject<'tmp> { // Resolve workspace = true references and strip [workspace.dependencies] if let Some(ref ws_deps) = ws_deps { - resolve_all_workspace_deps(&mut om, ws_deps, workspace_root)?; + // 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"); @@ -739,6 +745,24 @@ 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 @@ -766,13 +790,14 @@ fn load_workspace_deps(workspace_root: &Path) -> CargoResult> { /// Resolve `workspace = true` references in a single dependency table /// by merging actual version/source info from workspace-level deps. /// -/// Relative `path` values are made absolute (relative to `workspace_root`) -/// so that downstream path handling doesn't misinterpret them as relative -/// to the member directory. +/// Workspace dep `path` values are relative to the workspace root. After +/// flattening into a member manifest they must be rebased to be relative +/// to the member's directory, so that the temp project structure resolves +/// them correctly. fn resolve_workspace_deps( deps: &mut Table, ws_deps: &Table, - workspace_root: &Path, + member_rel_dir: &Path, ) -> CargoResult<()> { let dep_keys: Vec<_> = deps.keys().cloned().collect(); @@ -842,16 +867,17 @@ fn resolve_workspace_deps( } } - // Make relative paths absolute so they are not misinterpreted as - // relative to the member directory by `replace_path_with_absolute`. + // Rebase relative paths from workspace-root-relative to + // member-directory-relative so the temp project resolves them + // to the correct copy rather than the original source. if let Some(Value::String(ref p)) = resolved.get("path") { let dep_path = Path::new(p); if dep_path.is_relative() { - let abs_path = workspace_root.join(dep_path); + let rebased = rebase_relative_path(member_rel_dir, dep_path); resolved.insert( "path".to_owned(), - Value::String(abs_path.to_string_lossy().to_string()), + Value::String(rebased.to_string_lossy().to_string()), ); } } @@ -867,18 +893,18 @@ fn resolve_workspace_deps( fn resolve_all_workspace_deps( manifest: &mut Manifest, ws_deps: &Table, - workspace_root: &Path, + member_rel_dir: &Path, ) -> CargoResult<()> { if let Some(ref mut deps) = manifest.dependencies { - resolve_workspace_deps(deps, ws_deps, workspace_root)?; + resolve_workspace_deps(deps, ws_deps, member_rel_dir)?; } if let Some(ref mut deps) = manifest.dev_dependencies { - resolve_workspace_deps(deps, ws_deps, workspace_root)?; + resolve_workspace_deps(deps, ws_deps, member_rel_dir)?; } if let Some(ref mut deps) = manifest.build_dependencies { - resolve_workspace_deps(deps, ws_deps, workspace_root)?; + resolve_workspace_deps(deps, ws_deps, member_rel_dir)?; } if let Some(ref mut targets) = manifest.target { @@ -887,7 +913,7 @@ fn resolve_all_workspace_deps( for dep_section in &["dependencies", "dev-dependencies", "build-dependencies"] { if let Some(&mut Value::Table(ref mut dep_table)) = target.get_mut(*dep_section) { - resolve_workspace_deps(dep_table, ws_deps, workspace_root)?; + resolve_workspace_deps(dep_table, ws_deps, member_rel_dir)?; } } } From f56f26ef66a38dda97d1f98297d5a60ddb1d7125 Mon Sep 17 00:00:00 2001 From: Vincent Esche Date: Tue, 10 Feb 2026 13:34:06 +0100 Subject: [PATCH 4/5] Print workspace dependencies in their own table at the top of output --- src/cargo_ops/elaborate_workspace.rs | 160 ++++++++++++++++++++++++--- src/cargo_ops/mod.rs | 5 +- src/main.rs | 23 +++- 3 files changed, 170 insertions(+), 18 deletions(-) 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/main.rs b/src/main.rs index c15b23a..dc9760a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -111,7 +111,12 @@ pub fn execute(options: Options, context: &mut GlobalContext) -> CargoResult 0 { context.shell().set_verbosity(Verbosity::Verbose); } else { @@ -137,6 +142,7 @@ pub fn execute(options: Options, context: &mut GlobalContext) -> CargoResult CargoResult CargoResult verbose!(context, "Printing...", "Package status in json format"), } + // Print workspace dependencies table first (across all members) + if matches!(options.format, Format::List) { + sum += ela_curr.print_workspace_deps_list( + &ela_compat, + &ela_latest, + &options, + context, + &skipped, + )?; + } + + // Then print per-member tables for member in ela_curr.workspace.members() { ela_curr.resolve_status( &ela_compat, @@ -184,9 +203,11 @@ pub fn execute(options: Options, context: &mut GlobalContext) -> CargoResult Date: Tue, 10 Feb 2026 13:39:53 +0100 Subject: [PATCH 5/5] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) 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