diff --git a/crates/puffin-cli/src/commands/mod.rs b/crates/puffin-cli/src/commands/mod.rs index 62dfbd755..8a043ab61 100644 --- a/crates/puffin-cli/src/commands/mod.rs +++ b/crates/puffin-cli/src/commands/mod.rs @@ -5,6 +5,7 @@ pub(crate) use add::add; pub(crate) use clean::clean; pub(crate) use compile::compile; pub(crate) use freeze::freeze; +pub(crate) use remove::remove; pub(crate) use sync::{sync, SyncFlags}; pub(crate) use uninstall::uninstall; pub(crate) use venv::venv; @@ -13,6 +14,7 @@ mod add; mod clean; mod compile; mod freeze; +mod remove; mod reporters; mod sync; mod uninstall; diff --git a/crates/puffin-cli/src/commands/remove.rs b/crates/puffin-cli/src/commands/remove.rs new file mode 100644 index 000000000..965b0b9e9 --- /dev/null +++ b/crates/puffin-cli/src/commands/remove.rs @@ -0,0 +1,73 @@ +use std::path::PathBuf; + +use anyhow::Result; +use miette::{Diagnostic, IntoDiagnostic}; +use thiserror::Error; +use tracing::info; + +use puffin_workspace::WorkspaceError; + +use crate::commands::ExitStatus; +use crate::printer::Printer; + +/// Remove a dependency from the workspace. +#[allow(clippy::unnecessary_wraps)] +pub(crate) fn remove(name: &str, _printer: Printer) -> Result { + match remove_impl(name) { + Ok(status) => Ok(status), + Err(err) => { + #[allow(clippy::print_stderr)] + { + eprint!("{err:?}"); + } + Ok(ExitStatus::Failure) + } + } +} + +#[derive(Error, Debug, Diagnostic)] +enum RemoveError { + #[error( + "Could not find a `pyproject.toml` file in the current directory or any of its parents" + )] + #[diagnostic(code(puffin::remove::workspace_not_found))] + WorkspaceNotFound, + + #[error("Failed to parse `pyproject.toml` at: `{0}`")] + #[diagnostic(code(puffin::remove::parse_error))] + ParseError(PathBuf, #[source] WorkspaceError), + + #[error("Failed to write `pyproject.toml` to: `{0}`")] + #[diagnostic(code(puffin::remove::write_error))] + WriteError(PathBuf, #[source] WorkspaceError), + + #[error("Failed to remove `{0}` from `pyproject.toml`")] + #[diagnostic(code(puffin::remove::parse_error))] + RemovalError(String, #[source] WorkspaceError), +} + +fn remove_impl(name: &str) -> miette::Result { + // Locate the workspace. + let cwd = std::env::current_dir().into_diagnostic()?; + let Some(workspace_root) = puffin_workspace::find_pyproject_toml(cwd) else { + return Err(RemoveError::WorkspaceNotFound.into()); + }; + + info!("Found workspace at: {}", workspace_root.display()); + + // Parse the manifest. + let mut manifest = puffin_workspace::Workspace::try_from(workspace_root.as_path()) + .map_err(|err| RemoveError::ParseError(workspace_root.clone(), err))?; + + // Remove the dependency. + manifest + .remove_dependency(name) + .map_err(|err| RemoveError::RemovalError(name.to_string(), err))?; + + // Write the manifest back to disk. + manifest + .save(&workspace_root) + .map_err(|err| RemoveError::WriteError(workspace_root.clone(), err))?; + + Ok(ExitStatus::Success) +} diff --git a/crates/puffin-cli/src/commands/venv.rs b/crates/puffin-cli/src/commands/venv.rs index b0e6d60b3..6a66c8db7 100644 --- a/crates/puffin-cli/src/commands/venv.rs +++ b/crates/puffin-cli/src/commands/venv.rs @@ -1,7 +1,7 @@ use std::fmt::Write; use std::path::Path; -use anyhow::{Context, Result}; +use anyhow::Result; use colored::Colorize; use fs_err::tokio as fs; @@ -29,8 +29,8 @@ pub(crate) async fn venv( )?; // If the path already exists, remove it. - fs::remove_file(path).await.context("Foo")?; - fs::remove_dir_all(path).await?; + fs::remove_file(path).await.ok(); + fs::remove_dir_all(path).await.ok(); writeln!( printer, diff --git a/crates/puffin-cli/src/main.rs b/crates/puffin-cli/src/main.rs index 43fa091ad..bdae75759 100644 --- a/crates/puffin-cli/src/main.rs +++ b/crates/puffin-cli/src/main.rs @@ -47,6 +47,8 @@ enum Commands { Venv(VenvArgs), /// Add a dependency to the workspace. Add(AddArgs), + /// Remove a dependency from the workspace. + Remove(RemoveArgs), } #[derive(Args)] @@ -87,7 +89,13 @@ struct VenvArgs { #[derive(Args)] struct AddArgs { - /// The name of the package to add. + /// The name of the package to add (e.g., `Django==4.2.6`). + name: String, +} + +#[derive(Args)] +struct RemoveArgs { + /// The name of the package to remove (e.g., `Django`). name: String, } @@ -162,6 +170,7 @@ async fn main() -> ExitCode { } Commands::Venv(args) => commands::venv(&args.name, args.python.as_deref(), printer).await, Commands::Add(args) => commands::add(&args.name, printer), + Commands::Remove(args) => commands::remove(&args.name, printer), }; match result { diff --git a/crates/puffin-workspace/src/error.rs b/crates/puffin-workspace/src/error.rs index a51601380..3e46d6891 100644 --- a/crates/puffin-workspace/src/error.rs +++ b/crates/puffin-workspace/src/error.rs @@ -14,4 +14,13 @@ pub enum WorkspaceError { #[error(transparent)] InvalidRequirement(#[from] pep508_rs::Pep508Error), + + #[error("no `[project]` table found in `pyproject.toml`")] + MissingProjectTable, + + #[error("no `[project.dependencies]` array found in `pyproject.toml`")] + MissingProjectDependenciesArray, + + #[error("unable to find package: `{0}`")] + MissingPackage(String), } diff --git a/crates/puffin-workspace/src/toml.rs b/crates/puffin-workspace/src/toml.rs index ae11fde8d..405c98cf7 100644 --- a/crates/puffin-workspace/src/toml.rs +++ b/crates/puffin-workspace/src/toml.rs @@ -1,5 +1,10 @@ /// Reformat a TOML array to use multiline format. pub(crate) fn format_multiline_array(dependencies: &mut toml_edit::Array) { + if dependencies.is_empty() { + dependencies.set_trailing(""); + return; + } + for item in dependencies.iter_mut() { let decor = item.decor_mut(); let mut prefix = String::new(); diff --git a/crates/puffin-workspace/src/workspace.rs b/crates/puffin-workspace/src/workspace.rs index 7ef37a604..b09b82f02 100644 --- a/crates/puffin-workspace/src/workspace.rs +++ b/crates/puffin-workspace/src/workspace.rs @@ -76,9 +76,7 @@ impl Workspace { return; }; - // TODO(charlie): Awkward `drop` pattern required to work around destructors, apparently. - let mut iter = dependencies.iter(); - let index = iter.position(|item| { + let index = dependencies.iter().position(|item| { let Some(item) = item.as_str() else { return false; }; @@ -90,7 +88,6 @@ impl Workspace { PackageName::normalize(&requirement.requirement.name) == PackageName::normalize(existing.name) }); - drop(iter); if let Some(index) = index { dependencies.replace(index, requirement.given_name); @@ -101,6 +98,45 @@ impl Workspace { format_multiline_array(dependencies); } + /// Remove a dependency from the workspace. + pub fn remove_dependency(&mut self, name: &str) -> Result<(), WorkspaceError> { + let Some(project) = self + .document + .get_mut("project") + .map(|project| project.as_table_mut().unwrap()) + else { + return Err(WorkspaceError::MissingProjectTable); + }; + + let Some(dependencies) = project + .get_mut("dependencies") + .map(|dependencies| dependencies.as_array_mut().unwrap()) + else { + return Err(WorkspaceError::MissingProjectDependenciesArray); + }; + + let index = dependencies.iter().position(|item| { + let Some(item) = item.as_str() else { + return false; + }; + + let Ok(existing) = Requirement::from_str(item) else { + return false; + }; + + PackageName::normalize(name) == PackageName::normalize(existing.name) + }); + + let Some(index) = index else { + return Err(WorkspaceError::MissingPackage(name.to_string())); + }; + + dependencies.remove(index); + format_multiline_array(dependencies); + + Ok(()) + } + /// Save the workspace to disk. pub fn save(&self, path: impl AsRef) -> Result<(), WorkspaceError> { let file = fs::File::create(path.as_ref())?;