use std::path::PathBuf;
use logoxide::reader::DefaultReader;
use logoxide::{
FsStorage, MemoryStorage, Project, Storage,
manifest::{DetailedDependency, MANIFEST},
resolve::{Frozen, Update},
};
use logoxide_app::clean::Cleaning;
use crate::{
app,
cli::{AddArgs, Cli, Format},
cmd::catalog::{load_registry, registry_url, warn_other_registries},
config, print_json, warn,
};
fn version_req(text: &str) -> logoxide::Result<semver::VersionReq> {
text.parse()
.map_err(|e| logoxide::LogoxideError::Manifest(format!("version `{text}`: {e}")))
}
pub(crate) fn add(cli: &Cli, args: &AddArgs) -> logoxide::Result<()> {
let library = cli.library();
let mut project = cli.project(&library)?;
if let Some(rev) = &args.rev {
logoxide::manifest::check_rev(rev)?;
}
let named = args.repository.as_ref().or(args.git.as_ref());
let archive = match (&args.registry, named) {
(Some(_), None) => {
return Err(logoxide::LogoxideError::Manifest(String::from(
"name the package: lx add --registry <catalog> <id or path>",
)));
}
(registry, Some(text)) => archive_entry(&project, text, registry.as_deref())?,
(None, None) => None,
};
let git = match archive {
Some(_) => None,
None => named.map(|url| registry_url(&project, url)).transpose()?,
};
let path = (args.path.as_deref()).map(|path| relative_path(project.dir(), path));
let mut dependency = DetailedDependency {
git: git.clone(),
path: path.clone(),
version: args.version.as_deref().map(version_req).transpose()?,
tag: args.tag.clone(),
branch: args.branch.clone(),
rev: args.rev.clone(),
folder: args.folder.clone(),
features: args.features.clone(),
default_features: !args.no_default_features,
..DetailedDependency::default()
};
let info: Named = match (archive, &git, &path) {
(Some((catalog, entry)), _, _) => {
if dependency.version.is_none()
&& let Some(newest) = entry.release(None)
{
dependency.version = Some(version_req(&newest.version)?);
}
dependency.registry = Some(catalog);
Named {
abbreviation: entry.short_name().to_string(),
id: entry.id,
aliases: entry.aliases,
}
}
(None, Some(url), _) => {
let fetcher = cli.fetcher();
let reference = match (&args.tag, &args.branch, &args.rev) {
(Some(t), _, _) => logoxide::git::GitRef::Tag(t.clone()),
(_, Some(b), _) => logoxide::git::GitRef::Branch(b.clone()),
(_, _, Some(r)) => logoxide::git::GitRef::Rev(r.clone()),
_ => logoxide::git::GitRef::Head,
};
let probe = MemoryStorage::new();
let manifest_path = match &args.folder {
Some(folder) => format!("{}/{MANIFEST}", folder.trim_end_matches('/')),
None => String::from(MANIFEST),
};
logoxide::git::Fetcher::fetch_files(
fetcher,
url,
&reference,
&[manifest_path.as_str()],
&probe,
)?;
let manifest = logoxide::Manifest::parse(&probe.read_string(&manifest_path)?)?;
let info = manifest
.package
.ok_or_else(|| logoxide::LogoxideError::Manifest(format!("{url}: no [package]")))?;
if dependency.version.is_none()
&& dependency.tag.is_none()
&& dependency.branch.is_none()
&& dependency.rev.is_none()
{
let tags = logoxide::git::Fetcher::tags(fetcher, url)?;
let newest = tags
.iter()
.filter_map(|(t, _)| {
logoxide::resolve::folder_tag_version(t, args.folder.as_deref())
})
.filter(|v| v.pre.is_empty())
.max();
let odd = logoxide::resolve::not_semver_note(
tags.iter().map(|(t, _)| t.as_str()),
args.folder.as_deref(),
);
match newest {
Some(newest) => {
dependency.version = Some(version_req(&newest.to_string())?);
if !odd.is_empty() {
errln!("warning: {url}: some tags aren't releases{odd}");
}
}
None => {
return Err(logoxide::LogoxideError::Resolve(format!(
"{url} has no releases (tags like v1.0.0){odd}: give --branch or --rev \
to follow a branch or keep a commit, or --tag"
)));
}
}
}
info.into()
}
(None, None, Some(path)) => {
let storage = FsStorage::new(project.dir().join(path));
let manifest = logoxide::Manifest::parse(&storage.read_string(MANIFEST)?)?;
manifest
.package
.ok_or_else(|| logoxide::LogoxideError::Manifest(format!("{path}: no [package]")))?
.into()
}
(None, None, None) => {
return Err(logoxide::LogoxideError::Manifest(String::from(
"give a repository (github:owner/repo, or --git URL) or --path",
)));
}
};
let name = args.name.clone().unwrap_or_else(|| info.id.clone());
if name != info.id {
dependency.package = Some(info.id.clone());
}
dependency.abbreviation = args.abbreviation.clone();
if !args.aliases.is_empty() {
dependency.aliases = Some(
args.aliases
.iter()
.filter(|a| !a.is_empty())
.cloned()
.collect(),
);
}
if let Some(logoxide::manifest::Dependency::Detailed(existing)) =
project.manifest().dependencies.get(&name)
&& (
existing.git.as_deref(),
existing.registry.as_deref(),
existing.path.as_deref(),
) != (
dependency.git.as_deref(),
dependency.registry.as_deref(),
dependency.path.as_deref(),
)
{
let from = (existing.git.as_deref())
.or(existing.registry.as_deref())
.or(existing.path.as_deref())
.unwrap_or("?");
return Err(logoxide::LogoxideError::Manifest(format!(
"`{name}` is already {from}: give this one another name (--name {name}-2)"
)));
}
if let Some(lock) = project.lock() {
let abbreviation =
(dependency.abbreviation.clone()).unwrap_or_else(|| info.abbreviation.clone());
let aliases = (dependency.aliases.clone()).unwrap_or_else(|| info.aliases.clone());
let ours: Vec<String> = std::iter::once(abbreviation.clone())
.chain(aliases)
.collect();
for other in lock.roots().filter(|p| p.dependency_name() != name) {
let Ok(package) = library.open(other, project.dir()) else {
continue;
};
let (theirs, their_aliases) = project.names(other, package.info());
let clash = (std::iter::once(&theirs).chain(&their_aliases))
.find(|n| ours.iter().any(|o| o.eq_ignore_ascii_case(n)));
if let Some(clash) = clash {
return Err(logoxide::LogoxideError::Manifest(format!(
"`{clash}` already names {} here: give this one another (--abbreviation {abbreviation}2, --alias)",
other.dependency_name()
)));
}
}
}
project.add(&name, &dependency)?;
let only = Update::Only([name.clone()].into());
let lock = app().install(&mut project, only, Frozen::default(), &mut warn())?;
project.save_manifest()?;
let locked = lock.get(&name).map(|p| p.label()).unwrap_or(name);
outln!(
"added {locked} to {}: installed, and locked in {}",
project.dir().join(MANIFEST).display(),
logoxide::lock::LOCKFILE
);
warn_of_clashes(&project, &library);
warn_of_same_works(&project, &library);
warn_of_absolute_paths(&lock);
Ok(())
}
fn relative_path(project: &std::path::Path, path: &str) -> String {
let given = std::path::Path::new(path);
let found = match given.is_absolute() {
true => given.to_path_buf(),
false => match project.join(given).exists() {
true => project.join(given),
false => given.to_path_buf(),
},
};
let (Ok(from), Ok(to)) = (project.canonicalize(), found.canonicalize()) else {
return path.to_string();
};
let (from, to): (Vec<_>, Vec<_>) = (from.components().collect(), to.components().collect());
let shared = (from.iter().zip(&to)).take_while(|(a, b)| a == b).count();
if shared == 0 {
return path.to_string();
}
let up = std::iter::repeat_n(String::from(".."), from.len() - shared);
let down = (to[shared..].iter()).map(|c| c.as_os_str().to_string_lossy().into_owned());
let parts: Vec<String> = up.chain(down).collect();
match parts.is_empty() {
true => String::from("."),
false => parts.join("/"),
}
}
pub(crate) fn warn_of_absolute_paths(lock: &logoxide::Lockfile) {
for locked in &lock.packages {
if let Some(path) = &locked.path
&& std::path::Path::new(path).is_absolute()
{
errln!(
"warning: {}: path {path} is absolute, so {} won't work on another machine \
(give it relative to {MANIFEST})",
locked.label(),
logoxide::lock::LOCKFILE
);
}
}
}
pub(crate) fn warn_of_clashes(project: &Project, library: &logoxide::Library) {
match project.name_clashes(library) {
Ok(clashes) => {
for clash in clashes {
errln!(
"warning: `{}` names {}: references to it can't say which (give one an \
`abbreviation` in {MANIFEST})",
clash.name,
clash.packages.join(" and ")
);
}
}
Err(e) => errln!("warning: {e}"),
}
}
pub(crate) fn warn_of_same_works(project: &Project, library: &logoxide::Library) {
match project.same_works(library) {
Ok(works) => {
for same in works {
let name = (logoxide::works::get(&same.work))
.map_or_else(|| same.work.clone(), |w| w.name.clone());
errln!(
"warning: {} are all {name} (work `{}`)",
same.packages.join(" and "),
same.work
);
}
}
Err(e) => errln!("warning: {e}"),
}
}
struct Named {
id: String,
abbreviation: String,
aliases: Vec<String>,
}
impl From<logoxide::manifest::Package> for Named {
fn from(info: logoxide::manifest::Package) -> Named {
Named {
abbreviation: info.short_name().to_string(),
id: info.id,
aliases: info.aliases,
}
}
}
fn archive_entry(
project: &logoxide::Project,
text: &str,
catalog: Option<&str>,
) -> logoxide::Result<Option<(String, logoxide::catalog::CatalogEntry)>> {
let registries = config().registries(project.manifest());
let (registry, location, name) = match catalog {
Some(catalog) => ("--registry", catalog.to_string(), text),
None => match logoxide::catalog::split_registry_name(text, ®istries) {
Some((registry, name)) => (registry, registries[registry].clone(), name),
None => return Ok(None),
},
};
let freshness = logoxide_app::catalog_cache::Freshness::Refresh;
let loaded = load_registry(registry, &location, project.dir(), true, freshness)?;
let entry = loaded.find(name)?.ok_or_else(|| {
logoxide::LogoxideError::Catalog(format!("{location} has no package `{name}`"))
})?;
match &entry.source {
logoxide::catalog::CatalogSource::Registry { catalog } => {
warn_other_registries(project, &loaded, &location, &entry.id);
Ok(Some((catalog.clone(), entry.clone())))
}
_ if catalog.is_some() => Err(logoxide::LogoxideError::Catalog(format!(
"`{name}` in {location} isn't published as archives"
))),
_ => Ok(None),
}
}
pub(crate) fn remove(cli: &Cli, id: &str) -> logoxide::Result<()> {
let library = cli.library();
let mut project = cli.project(&library)?;
if !project.remove(id)? {
return Err(logoxide::LogoxideError::Manifest(format!(
"no dependency `{id}`"
)));
}
app().install(
&mut project,
Update::Nothing,
Frozen::default(),
&mut warn(),
)?;
project.save_manifest()?;
outln!("removed {id}");
Ok(())
}
pub(crate) fn set_enabled(cli: &Cli, name: &str, enabled: bool) -> logoxide::Result<()> {
let library = cli.library();
let mut project = cli.project(&library)?;
let was = project.is_enabled(name);
if !project.set_enabled(name, enabled)? {
return Err(logoxide::LogoxideError::Manifest(format!(
"no dependency `{name}`"
)));
}
project.save_manifest()?;
match (was, enabled) {
(true, true) => outln!("{name} is already on"),
(false, false) => outln!("{name} is already off"),
(_, true) => outln!("turned {name} on"),
(_, false) => {
outln!("turned {name} off (still installed: `lx enable {name}` turns it on)")
}
}
Ok(())
}
pub(crate) fn repair(cli: &Cli) -> logoxide::Result<()> {
let library = cli.library();
let mut project = cli.project(&library)?;
let changed: Vec<String> = (project.lock().into_iter())
.flat_map(|lock| lock.packages.iter())
.filter(|p| p.checksum.is_some() && library.is_installed(p, project.dir()))
.filter(|p| {
let dir = FsStorage::new(library.package_dir(p, project.dir()));
logoxide::package::checksum(&dir).ok() != p.checksum
})
.map(|p| p.label())
.collect();
let lock = project.repair(&library, cli.fetcher())?;
for label in &changed {
outln!("repaired {label}");
}
outln!(
"{} packages, {} repaired, locked in {}",
lock.packages.len(),
changed.len(),
project.dir().join(logoxide::lock::LOCKFILE).display()
);
Ok(())
}
pub(crate) fn install(cli: &Cli, update: Update, frozen: Frozen) -> logoxide::Result<()> {
let library = cli.library();
let mut project = cli.project(&library)?;
let previous = project.lock().cloned();
let before: Vec<String> = (previous.iter())
.flat_map(|l| l.packages.iter().map(|p| p.name()))
.collect();
let lock = app().install(&mut project, update, frozen, &mut warn())?;
for moved in previous.map(|p| lock.moved_since(&p)).unwrap_or_default() {
errln!(
"warning: {}: {} was moved ({} before, {} now): its text may not be what was locked",
moved.label,
moved.release,
moved.before,
moved.after
);
}
warn_of_absolute_paths(&lock);
for package in &lock.packages {
let note = if before.contains(&package.name()) {
""
} else {
" (new)"
};
outln!("{}{note}", package.label());
}
outln!(
"{} packages, locked in {}",
lock.packages.len(),
project.dir().join(logoxide::lock::LOCKFILE).display()
);
warn_of_clashes(&project, &library);
warn_of_same_works(&project, &library);
Ok(())
}
pub(crate) fn is_enabled(cli: &Cli, name: &str) -> logoxide::Result<bool> {
let library = cli.library();
let project = cli.project(&library)?;
if !project.manifest().dependencies.contains_key(name) {
return Err(logoxide::LogoxideError::Manifest(format!(
"no dependency `{name}`"
)));
}
let enabled = project.is_enabled(name);
outln!("{}", if enabled { "on" } else { "off" });
Ok(enabled)
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct Listed {
name: String,
id: String,
version: String,
label: String,
dependency: bool,
enabled: bool,
installed: bool,
abbreviation: Option<String>,
title: Option<String>,
license: Option<String>,
git: Option<String>,
tag: Option<String>,
branch: Option<String>,
commit: Option<String>,
path: Option<String>,
registry: Option<String>,
import: Option<String>,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct InstalledVersion {
id: String,
version: String,
key: String,
dir: String,
abbreviation: Option<String>,
title: Option<String>,
license: Option<String>,
used_by: Vec<UsedBy>,
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct UsedBy {
path: String,
host: String,
library: bool,
gone: bool,
}
pub(crate) fn list_all(cli: &Cli) -> logoxide::Result<()> {
let library = cli.library();
let projects = [Project::open(library.root())?, cli.project(&library)?];
let installed = app().installed(&projects)?;
let canonical = |p: &std::path::Path| p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
let own = canonical(library.root()).display().to_string();
let here = canonical(projects[1].dir()).display().to_string();
let host = logoxide::roots::host_name();
if cli.format == Format::Json {
let listed: Vec<InstalledVersion> = (installed.into_iter())
.map(|v| InstalledVersion {
id: v.id.clone(),
version: v.version().to_string(),
key: v.key.clone(),
dir: v.dir.display().to_string(),
abbreviation: v.package.as_ref().and_then(|p| p.abbreviation.clone()),
title: v.package.as_ref().map(|p| p.name.clone()),
license: v.package.as_ref().and_then(|p| p.license.clone()),
used_by: (v.used_by.into_iter())
.map(|u| UsedBy {
library: u.host == host && u.path == own,
path: u.path,
host: u.host,
gone: u.gone,
})
.collect(),
})
.collect();
return print_json(&listed);
}
if installed.is_empty() {
outln!(
"nothing in the library ({}) yet: `lx add --global <package>` adds one to it",
library.root().display()
);
return Ok(());
}
for v in &installed {
let about = match &v.package {
Some(p) => match &p.license {
Some(license) => format!("{} ({}), {license}", p.short_name(), p.name),
None => format!("{} ({})", p.short_name(), p.name),
},
None => String::from("(its logoxide.toml doesn't read)"),
};
let users: Vec<String> = (v.used_by.iter())
.map(|u| {
let mut said = u.path.clone();
if u.host != host {
said = format!("{said} on {}", u.host);
} else if u.path == own {
said = String::from("the library's own (--global)");
} else if u.path == here {
said = format!("{said} (here)");
}
if u.gone {
said.push_str(" (gone)");
}
said
})
.collect();
let used = match (users.is_empty(), &v.package) {
(true, Some(_)) => String::from("unused (lx clean --remove deletes it)"),
(true, None) => format!("unused (delete {} by hand)", v.dir.display()),
(false, _) => format!("used by {}", users.join(", ")),
};
outln!("{} {} {about} {used}", v.id, v.key);
}
Ok(())
}
pub(crate) fn list(cli: &Cli, only: Option<bool>) -> logoxide::Result<()> {
let library = cli.library();
let project = cli.project(&library)?;
let json = cli.format == Format::Json;
let Some(lock) = project.lock() else {
match json {
true => outln!("[]"),
false => outln!("nothing installed yet: `lx add --git URL`, then `lx install`"),
}
return Ok(());
};
let in_use: std::collections::BTreeSet<String> =
project.in_use().iter().map(|p| p.name()).collect();
if json {
let mut listed = vec![];
for locked in &lock.packages {
let used = in_use.contains(&locked.name());
if only.is_some_and(|wanted| wanted != used) {
continue;
}
let info = library.open(locked, project.dir()).ok();
let info = info.as_ref().map(|p| p.info());
listed.push(Listed {
name: locked.dependency_name().to_string(),
id: locked.id.clone(),
version: locked.version.to_string(),
label: locked.label(),
dependency: !lock.roots().any(|r| std::ptr::eq(r, locked)),
enabled: used,
installed: library.is_installed(locked, project.dir()),
abbreviation: info.map(|i| project.names(locked, i).0),
title: info.map(|i| i.name.clone()),
license: info.and_then(|i| i.license.clone()),
git: locked.git.clone(),
tag: locked.tag.clone(),
branch: locked.branch.clone(),
commit: locked.commit.clone(),
path: locked.path.clone(),
registry: locked.registry.clone(),
import: locked.import.clone(),
});
}
return print_json(&listed);
}
for locked in &lock.packages {
let root = if lock.roots().any(|r| std::ptr::eq(r, locked)) {
""
} else {
" (a dependency)"
};
let used = in_use.contains(&locked.name());
if only.is_some_and(|wanted| wanted != used) {
continue;
}
let off = match used {
true => "",
false => " (off)",
};
let source = match (&locked.git, &locked.path) {
(Some(git), _) => format!(
"{git} {}",
locked
.tag
.as_deref()
.or(locked.branch.as_deref())
.unwrap_or("")
),
(_, Some(path)) => format!("path {path}"),
_ => match (&locked.registry, &locked.import) {
(Some(registry), _) => format!("registry {registry}"),
(_, Some(import)) => match &locked.upstream_version {
Some(upstream) => format!("import {import} ({upstream})"),
None => format!("import {import}"),
},
_ => String::new(),
},
};
let installed = if library.is_installed(locked, project.dir()) {
""
} else {
" NOT INSTALLED"
};
let about = library.open(locked, project.dir()).ok().map(|p| {
let info = p.info();
let (abbreviation, _) = project.names(locked, info);
format!(
"{} ({}), {}",
abbreviation,
info.name,
info.license.as_deref().unwrap_or("no license given")
)
});
outln!(
"{} {} {}{root}{off}{installed}",
locked.label(),
about.unwrap_or_default(),
source.trim()
);
}
Ok(())
}
#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct InfoReport {
name: String,
id: String,
version: String,
label: String,
abbreviation: String,
aliases: Vec<String>,
title: String,
kind: logoxide::manifest::PackageKind,
language: String,
license: Option<String>,
rights: logoxide::license::Rights,
license_file: Option<String>,
work: Option<String>,
edition: Option<String>,
edition_year: Option<i32>,
git: Option<String>,
commit: Option<String>,
path: Option<String>,
registry: Option<String>,
import: Option<String>,
upstream_version: Option<String>,
dir: String,
files: usize,
size: u64,
checksum: String,
locked_checksum: Option<String>,
unchanged: bool,
other_files: Vec<String>,
books: usize,
verses: usize,
format: String,
}
pub(crate) fn info(cli: &Cli, id: &str) -> logoxide::Result<()> {
let library = cli.library();
let project = cli.project(&library)?;
let locked = (project.lock())
.and_then(|lock| {
lock.get(id)
.or_else(|| lock.packages.iter().find(|p| p.id == id))
})
.cloned()
.ok_or_else(|| {
logoxide::LogoxideError::NotFound(format!("{id} isn't in the lock file (lx install)"))
})?;
let dir = library.package_dir(&locked, project.dir());
let package = library.open(&locked, project.dir())?;
let i = package.info().clone();
let (abbreviation, aliases) = project.names(&locked, &i);
let storage = FsStorage::new(&dir);
let files = storage.walk("")?;
let size: u64 = (files.iter())
.filter_map(|f| std::fs::metadata(dir.join(f)).ok())
.map(|m| m.len())
.sum();
let checksum = package.checksum()?;
let others = logoxide::package::other_files(&storage)?;
let cached = library.book_cache().open(package, &DefaultReader)?;
let books = &cached.manifest().books;
let verses: usize = (books.iter())
.flat_map(|(_, counts)| counts.iter().map(|&c| c as usize))
.sum();
if cli.format == Format::Json {
return print_json(&InfoReport {
name: locked.dependency_name().to_string(),
id: locked.id.clone(),
version: locked.version.to_string(),
label: locked.label(),
abbreviation,
aliases,
title: i.name.clone(),
kind: i.kind,
language: i.language.clone(),
license: i.license.clone(),
rights: i.text_rights(),
license_file: i.license_file.clone(),
work: i.work.clone(),
edition: i.edition.clone(),
edition_year: i.edition_year,
git: locked.git.clone(),
commit: locked.commit.clone(),
path: locked.path.clone(),
registry: locked.registry.clone(),
import: locked.import.clone(),
upstream_version: locked.upstream_version.clone(),
dir: dir.display().to_string(),
files: files.len(),
size,
unchanged: locked.checksum.as_ref().is_none_or(|c| *c == checksum),
checksum,
locked_checksum: locked.checksum.clone(),
other_files: others,
books: books.len(),
verses,
format: cached.manifest().source_format.to_string(),
});
}
outln!("{} ({}), {}", locked.label(), abbreviation, i.name);
if !aliases.is_empty() {
outln!("aliases: {}", aliases.join(", "));
}
outln!(
"kind: {:?}, language: {}, license: {}",
i.kind,
i.language,
(i.license.as_deref())
.or(i.license_file.as_deref())
.unwrap_or("none given")
);
let rights = i.text_rights();
if !rights.is_empty() {
outln!("rights: {}", rights.describe());
}
if let Some(work) = logoxide::works::describe(&i) {
outln!("work: {work}");
}
let from = match (&locked.git, &locked.path) {
(Some(git), _) => format!("{git} at {}", locked.commit.as_deref().unwrap_or("?")),
(_, Some(path)) => format!("path {path}"),
_ => match (&locked.registry, &locked.import) {
(Some(registry), _) => format!("registry {registry}"),
(_, Some(import)) => {
let made = (locked.downloads.iter())
.map(|d| d.url.as_str())
.collect::<Vec<_>>()
.join(", ");
match &locked.upstream_version {
Some(upstream) => {
format!("import {import}, the publisher's {upstream} ({made})")
}
None => format!("import {import} ({made})"),
}
}
_ => String::from("?"),
},
};
outln!("from: {from}");
outln!(
"in: {} ({} files, {:.1} MB)",
dir.display(),
files.len(),
size as f64 / 1e6
);
match &locked.checksum {
Some(expected) if *expected == checksum => outln!("checksum: {checksum} (as locked)"),
Some(expected) => outln!(
"checksum: {checksum} (CHANGED: locked as {expected}; lx install --repair puts its files back)"
),
None => outln!("checksum: {checksum}"),
}
if !others.is_empty() {
outln!("not the package's (kept): {}", others.join(", "));
}
outln!(
"{} books, {verses} verses ({})",
books.len(),
cached.manifest().source_format
);
Ok(())
}
pub(crate) fn clean(
cli: &Cli,
remove: bool,
keep: &[PathBuf],
prune_roots: bool,
) -> logoxide::Result<()> {
let library = cli.library();
let mut projects = vec![Project::open(library.root())?, cli.project(&library)?];
for dir in keep {
projects.push(Project::open(dir)?);
}
let cleaned = app().clean(&projects, remove, prune_roots, &mut |step| match step {
Cleaning::Staging {
path,
removed: true,
} => outln!(
"removed {}: left by an install that didn't finish",
path.display()
),
Cleaning::Staging { path, .. } => outln!(
"stale: {} (left by an install that didn't finish; lx clean --remove deletes it)",
path.display()
),
Cleaning::RootGone {
path,
forgotten: true,
..
} => outln!("forgot {path}: its project is gone"),
Cleaning::RootGone { path, versions, .. } => outln!(
"{path}: its project is gone, but its {versions} version{} stay (lx clean --prune-roots forgets it)",
if versions == 1 { "" } else { "s" }
),
Cleaning::Kept { id, key, others } => outln!(
"kept {id} {key}: {} file{} not the package's ({})",
others.len(),
if others.len() == 1 { " is" } else { "s are" },
others.join(", ")
),
Cleaning::Unreadable { id, key, dir, error } => outln!(
"kept {id} {key}: its logoxide.toml doesn't read, so its files can't be told from \
yours (delete {} by hand if nothing in it is yours): {}",
dir.display(),
error.lines().last().unwrap_or_default()
),
Cleaning::Removed { id, key } => outln!("removed {id} {key}"),
Cleaning::Unused { id, key, dir } => outln!("unused: {id} {key} ({})", dir.display()),
_ => {}
})?;
if cleaned.unused == 0 {
outln!("every installed version is used");
return Ok(());
}
if !remove {
outln!(
"{} unused (other vaults may use them: --keep <their folder>); lx clean --remove deletes them",
cleaned.unused
);
}
Ok(())
}