use crate::error::{PassengerError, Result};
use crate::gate::{indent_at, leading, relative, workspace_root, EditSet, FileEdit};
use std::path::{Path, PathBuf};
#[derive(Debug)]
pub struct Edited {
pub root: PathBuf,
pub edits: EditSet,
pub written: bool,
pub said: String,
}
impl Edited {
pub fn render(&self) -> String {
let mut s = format!("{}\n", self.said);
if !self.edits.is_empty() {
s.push_str(&self.edits.diff_from(&self.root));
}
s.push_str(&format!("written: {}\n", if self.written { "yes" } else { "no" }));
s
}
}
fn finish(root: &Path, path: PathBuf, before: String, after: String, said: String) -> Result<Edited> {
let edit = FileEdit { path, before, after };
let edits = EditSet { files: vec![edit], manifest: None };
let written = !edits.is_empty();
if written {
edits.write()?;
}
Ok(Edited { root: root.to_path_buf(), edits, written, said })
}
pub fn split_spec(spec: &str) -> (String, Option<String>) {
match spec.split_once('@') {
Some((d, v)) if !v.is_empty() => (d.to_string(), Some(v.to_string())),
_ => (spec.trim_end_matches('@').to_string(), None),
}
}
fn manifest_of(root: &Path, krate: &str) -> Result<PathBuf> {
let b = crate::cargo::builder(root)?;
crate::cargo::manifest_of(b.packages(), krate).ok_or_else(|| {
let names: Vec<&String> = b.packages().keys().collect();
PassengerError::UnknownName { detail: format!("no workspace member `{krate}` (members: {names:?})") }
})
}
fn parse(path: &Path) -> Result<(String, toml_edit::DocumentMut)> {
let text = std::fs::read_to_string(path)?;
let doc = text.parse::<toml_edit::DocumentMut>().map_err(|e| PassengerError::toml(format!("{}: {e}", path.display())))?;
Ok((text, doc))
}
fn features_array(t: &mut dyn toml_edit::TableLike, features: &[String]) {
if features.is_empty() {
return;
}
let mut arr = t.get("features").and_then(|f| f.as_array()).cloned().unwrap_or_default();
for f in features {
if !arr.iter().any(|x| x.as_str() == Some(f.as_str())) {
arr.push(f.as_str());
}
}
t.insert("features", toml_edit::value(arr));
}
fn as_table(item: &mut toml_edit::Item) -> &mut dyn toml_edit::TableLike {
if let Some(v) = item.as_str().map(str::to_string) {
let mut t = toml_edit::InlineTable::new();
t.insert("version", toml_edit::Value::from(v.as_str()));
*item = toml_edit::value(t);
}
if item.is_none() {
*item = toml_edit::value(toml_edit::InlineTable::new());
}
item.as_table_like_mut().expect("a dependency entry is a table here")
}
fn set_source(t: &mut dyn toml_edit::TableLike, version: Option<&str>, path: Option<&str>) {
if let Some(v) = version {
t.insert("version", toml_edit::value(v));
}
if let Some(p) = path {
t.insert("path", toml_edit::value(p));
}
}
fn deps_table(doc: &mut toml_edit::DocumentMut) -> &mut dyn toml_edit::TableLike {
if !doc.contains_key("dependencies") {
doc["dependencies"] = toml_edit::table();
}
doc["dependencies"].as_table_like_mut().expect("[dependencies] is a table")
}
fn plain_version(item: &toml_edit::Item) -> bool {
item.as_str().is_some()
}
pub fn dep_add(root: &Path, krate: &str, spec: &str, path_dep: Option<&str>, features: &[String], workspace_table: bool) -> Result<Edited> {
let e = dep_add_plan(root, krate, spec, path_dep, features, workspace_table)?;
if e.written {
e.edits.write()?;
}
Ok(e)
}
pub fn dep_add_plan(root: &Path, krate: &str, spec: &str, path_dep: Option<&str>, features: &[String], workspace_table: bool) -> Result<Edited> {
let root = workspace_root(root)?;
let (name, version) = split_spec(spec);
if name.is_empty() {
return Err(PassengerError::DepVersion { detail: format!("`{spec}` names no dependency") });
}
let manifest = manifest_of(&root, krate)?;
if workspace_table {
let root_manifest = root.join("Cargo.toml");
let (rbefore, mut rdoc) = parse(&root_manifest)?;
let Some(ws) = rdoc.get_mut("workspace").and_then(|w| w.as_table_like_mut()) else {
return Err(PassengerError::NoWorkspaceTable { detail: root_manifest.display().to_string() });
};
if !ws.contains_key("dependencies") {
ws.insert("dependencies", toml_edit::table());
}
let wdeps = ws.get_mut("dependencies").and_then(|d| d.as_table_like_mut()).expect("[workspace.dependencies] is a table");
let had = wdeps.contains_key(&name);
if !had && version.is_none() && path_dep.is_none() {
return Err(PassengerError::DepVersion { detail: format!("`{name}` is not in [workspace.dependencies]; give {name}@<version> or --path") });
}
if version.is_some() || path_dep.is_some() {
let entry = wdeps.entry(&name).or_insert(toml_edit::Item::None);
if path_dep.is_none() && (entry.is_none() || plain_version(entry)) {
*entry = toml_edit::value(version.as_deref().unwrap_or_default());
} else {
set_source(as_table(entry), version.as_deref(), path_dep);
}
}
let rafter = rdoc.to_string();
let (before, mut doc) = parse(&manifest)?;
let deps = deps_table(&mut doc);
let entry = deps.entry(&name).or_insert(toml_edit::Item::None);
let t = as_table(entry);
t.remove("version");
t.remove("path");
t.insert("workspace", toml_edit::value(true));
features_array(t, features);
let after = doc.to_string();
let mut files = Vec::new();
if rafter != rbefore {
files.push(FileEdit { path: root_manifest, before: rbefore, after: rafter });
}
if after != before {
files.push(FileEdit { path: manifest, before, after });
}
let edits = EditSet { files, manifest: None };
let written = !edits.is_empty();
let said = if written { format!("added {name} to {krate} through [workspace.dependencies]") } else { format!("{name} is already in {krate} through [workspace.dependencies]: nothing to do") };
return Ok(Edited { root, edits, written, said });
}
if version.is_none() && path_dep.is_none() {
let (_, doc) = parse(&manifest)?;
let present = doc.get("dependencies").and_then(|d| d.as_table_like()).is_some_and(|d| d.contains_key(&name));
if !present {
return Err(PassengerError::DepVersion { detail: format!("give {name}@<version> or --path") });
}
}
let (before, mut doc) = parse(&manifest)?;
let deps = deps_table(&mut doc);
let existed = deps.contains_key(&name);
let entry = deps.entry(&name).or_insert(toml_edit::Item::None);
if path_dep.is_none() && features.is_empty() && (entry.is_none() || plain_version(entry)) {
if let Some(v) = &version {
*entry = toml_edit::value(v.as_str());
}
} else {
let t = as_table(entry);
set_source(t, version.as_deref(), path_dep);
features_array(t, features);
}
let after = doc.to_string();
let said = match (existed, after != before) {
(_, false) => format!("{name} is already in {krate} as asked: nothing to do"),
(false, true) => format!("added {name} to {krate}"),
(true, true) => format!("updated {name} in {krate}"),
};
let edits = EditSet { files: vec![FileEdit { path: manifest, before, after }], manifest: None };
let written = !edits.is_empty();
Ok(Edited { root, edits, written, said })
}
fn named_by_features(doc: &toml_edit::DocumentMut, dep: &str) -> Vec<String> {
let mut out = Vec::new();
if let Some(feats) = doc.get("features").and_then(|f| f.as_table_like()) {
for (fname, list) in feats.iter() {
let names = list.as_array().map(|a| a.iter().filter_map(|v| v.as_str()).any(|e| {
let e = e.strip_prefix("dep:").unwrap_or(e);
let head = e.split('/').next().unwrap_or(e).trim_end_matches('?');
head == dep
}));
if names == Some(true) {
out.push(fname.to_string());
}
}
}
out
}
pub fn dep_remove(root: &Path, krate: &str, spec: &str, workspace_table: bool) -> Result<Edited> {
let root = workspace_root(root)?;
let (name, _) = split_spec(spec);
let manifest = manifest_of(&root, krate)?;
let (before, mut doc) = parse(&manifest)?;
let naming = named_by_features(&doc, &name);
if !naming.is_empty() {
return Err(PassengerError::DepInUse { detail: format!("{krate}: feature(s) {} name {name}", naming.join(", ")) });
}
let removed = doc.get_mut("dependencies").and_then(|d| d.as_table_like_mut()).and_then(|d| d.remove(&name)).is_some();
let after = doc.to_string();
let mut files = Vec::new();
if after != before {
files.push(FileEdit { path: manifest.clone(), before, after });
}
let mut said = if removed { format!("removed {name} from {krate}") } else { format!("{krate} has no dependency {name}: nothing to do") };
if workspace_table {
let b = crate::cargo::builder(&root)?;
let mut still = Vec::new();
for p in b.packages().values().filter(|p| p.manifest_path != manifest) {
let (_, other) = parse(&p.manifest_path)?;
if other.get("dependencies").and_then(|d| d.as_table_like()).is_some_and(|d| d.contains_key(&name)) {
still.push(p.name.clone());
}
}
let root_manifest = root.join("Cargo.toml");
let (rbefore, mut rdoc) = parse(&root_manifest)?;
if still.is_empty() {
if let Some(w) = rdoc.get_mut("workspace").and_then(|w| w.get_mut("dependencies")).and_then(|d| d.as_table_like_mut()) {
w.remove(&name);
}
let rafter = rdoc.to_string();
if rafter != rbefore {
files.push(FileEdit { path: root_manifest, before: rbefore, after: rafter });
said.push_str(", and from [workspace.dependencies]");
}
} else {
said.push_str(&format!("; [workspace.dependencies] keeps it ({} still name it)", still.join(", ")));
}
}
let edits = EditSet { files, manifest: None };
let written = !edits.is_empty();
if written {
edits.write()?;
}
Ok(Edited { root, edits, written, said })
}
fn check_line(line: &str) -> Result<String> {
let t = line.trim();
let balanced = t.chars().fold(0i32, |d, c| d + match c {
'[' => 1,
']' => -1,
_ => 0,
}) == 0;
if !(t.starts_with("#[") && t.ends_with(']')) || !balanced || t.contains('\n') {
return Err(PassengerError::AttrLine { detail: format!("`{line}`") });
}
Ok(t.to_string())
}
fn cfg_inner(line: &str) -> Option<&str> {
line.trim().strip_prefix("#[cfg(")?.strip_suffix(")]")
}
fn locate(root: &Path, file: &Path, item: &str) -> Result<(PathBuf, usize)> {
let k = crate::commands::klepto_for(root)?;
let abs = if file.is_absolute() { file.to_path_buf() } else { root.join(file) };
let want = abs.canonicalize().map_err(|e| PassengerError::AttrItem { detail: format!("{}: {e}", abs.display()) })?;
let in_file = |p: &Path| p.canonicalize().ok().as_ref() == Some(&want);
let package = k.modules.modules.values().find(|m| !m.inline && in_file(&m.file)).map(|m| m.package.clone());
let id = match (item.strip_prefix("crate::"), &package) {
(Some(rest), Some(p)) => format!("{p}::{rest}"),
_ => item.to_string(),
};
let found = k.items.iter().find(|d| d.id == id && in_file(&d.location.path));
match found.and_then(|d| d.location.line) {
Some(line) => Ok((want, line.saturating_sub(1) as usize)),
None => {
let elsewhere: Vec<String> = k.items.iter().filter(|d| d.id == id).map(|d| relative(root, &d.location.path).map(|r| r.display().to_string()).unwrap_or_default()).collect();
let hint = if elsewhere.is_empty() { "klepto names no such item".to_string() } else { format!("it is in {}", elsewhere.join(", ")) };
Err(PassengerError::AttrItem { detail: format!("`{id}` in {}: {hint}", file.display()) })
}
}
}
fn join(lines: &[String], trailing: bool) -> String {
let mut out = lines.join("\n");
if trailing {
out.push('\n');
}
out
}
pub fn attr_place(root: &Path, file: &Path, item: &str, line: &str) -> Result<Edited> {
let root = workspace_root(root)?;
let attr = check_line(line)?;
let (path, start) = locate(&root, file, item)?;
let before = std::fs::read_to_string(&path)?;
let mut lines: Vec<String> = before.lines().map(str::to_string).collect();
if start >= lines.len() {
return Err(PassengerError::AttrItem { detail: format!("{item}: line {} is past the end of {}", start + 1, path.display()) });
}
let lead = leading(&lines, start);
let already = lead.attrs.iter().any(|(a, b)| b - a == 1 && lines[*a].trim() == attr);
let said;
if already {
said = format!("{item} already carries {attr}: nothing to do");
} else if let Some(new) = cfg_inner(&attr) {
match lead.cfg {
Some(c) if lead.attrs.iter().any(|(a, b)| *a == c && b - a == 1) => {
let old = cfg_inner(&lines[c]).unwrap_or_default().to_string();
let indent = indent_at(&lines, c);
lines[c] = format!("{indent}#[cfg(all({old}, {new}))]");
said = format!("merged {attr} into {item}'s cfg");
}
Some(c) => return Err(PassengerError::AttrLine { detail: format!("{item}'s cfg at line {} spans lines; merge it by hand", c + 1) }),
None => {
let indent = indent_at(&lines, lead.decl);
lines.insert(lead.decl, format!("{indent}{attr}"));
said = format!("placed {attr} on {item}");
}
}
} else {
let at = lead.attrs.first().map(|(a, _)| *a).unwrap_or(lead.decl);
let indent = indent_at(&lines, at);
lines.insert(at, format!("{indent}{attr}"));
said = format!("placed {attr} on {item}");
}
let after = join(&lines, before.ends_with('\n'));
finish(&root, path, before, after, said)
}
pub fn attr_remove(root: &Path, file: &Path, item: &str, line: &str) -> Result<Edited> {
let root = workspace_root(root)?;
let attr = check_line(line)?;
let (path, start) = locate(&root, file, item)?;
let before = std::fs::read_to_string(&path)?;
let mut lines: Vec<String> = before.lines().map(str::to_string).collect();
let lead = leading(&lines, start.min(lines.len()));
let hit = lead.attrs.iter().find(|(a, b)| b - a == 1 && lines[*a].trim() == attr).map(|(a, _)| *a);
let said = match hit {
Some(at) => {
lines.remove(at);
format!("removed {attr} from {item}")
}
None => format!("{item} carries no {attr}: nothing to do"),
};
let after = join(&lines, before.ends_with('\n'));
finish(&root, path, before, after, said)
}