use crate::error::{PassengerError, Result};
use klepto::{GateEdit, GatePlan, ManifestEdit};
use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileEdit {
pub path: PathBuf,
pub before: String,
pub after: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct EditSet {
pub files: Vec<FileEdit>,
pub manifest: Option<FileEdit>,
}
impl EditSet {
pub fn is_empty(&self) -> bool {
self.files.iter().all(|f| f.before == f.after) && self.manifest.as_ref().is_none_or(|m| m.before == m.after)
}
fn all(&self) -> impl Iterator<Item = &FileEdit> {
self.files.iter().chain(self.manifest.iter())
}
pub fn diff(&self) -> String {
self.diff_named(|p| p.to_string_lossy().into_owned())
}
pub fn diff_from(&self, root: &Path) -> String {
self.diff_named(|p| {
relative(root, p)
.map(|r| format!("a/{}", r.to_string_lossy()))
.unwrap_or_else(|_| p.to_string_lossy().into_owned())
})
}
fn diff_named(&self, name: impl Fn(&Path) -> String) -> String {
let mut edits: Vec<&FileEdit> = self.all().filter(|f| f.before != f.after).collect();
edits.sort_by(|a, b| a.path.cmp(&b.path));
let mut out = String::new();
for e in edits {
let n = name(&e.path);
let d = similar::TextDiff::from_lines(&e.before, &e.after);
out.push_str(&d.unified_diff().header(&n, &n.replacen("a/", "b/", 1)).to_string());
}
out
}
pub fn write(&self) -> Result<()> {
for e in self.all() {
if e.before != e.after {
std::fs::write(&e.path, &e.after)?;
}
}
Ok(())
}
pub fn write_to(&self, from_root: &Path, to_root: &Path) -> Result<Vec<PathBuf>> {
let mut written = Vec::new();
for e in self.all() {
let rel = relative(from_root, &e.path)?;
let target = to_root.join(&rel);
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&target, &e.after)?;
written.push(rel);
}
Ok(written)
}
}
pub(crate) fn relative(root: &Path, path: &Path) -> Result<PathBuf> {
if let Ok(r) = path.strip_prefix(root) {
return Ok(r.to_path_buf());
}
std::fs::canonicalize(root)
.ok()
.and_then(|c| path.strip_prefix(&c).ok().map(Path::to_path_buf))
.ok_or_else(|| PassengerError::Path(format!("{} is not under {}", path.display(), root.display())))
}
fn skip_dir(name: &OsStr) -> bool {
name == "target" || name == ".git" || name == ".passenger"
}
pub fn find_manifest(root: &Path, package: &str) -> Result<PathBuf> {
let want = package.replace('-', "_");
let mut found: Vec<PathBuf> = Vec::new();
let walk = WalkDir::new(root).into_iter().filter_entry(|e| !(e.depth() > 0 && e.file_type().is_dir() && skip_dir(e.file_name())));
for entry in walk.filter_map(|e| e.ok()) {
if !entry.file_type().is_file() || entry.file_name() != "Cargo.toml" {
continue;
}
let Ok(text) = std::fs::read_to_string(entry.path()) else { continue };
let Ok(doc) = text.parse::<toml_edit::DocumentMut>() else { continue };
let name = doc.get("package").and_then(|p| p.get("name")).and_then(|n| n.as_str()).map(|s| s.replace('-', "_"));
if name.as_deref() == Some(want.as_str()) {
found.push(entry.path().to_path_buf());
}
}
found.sort();
found
.into_iter()
.next()
.ok_or_else(|| PassengerError::Unsupported(format!("no Cargo.toml with [package].name = \"{package}\" under {}", root.display())))
}
pub fn apply(root: &Path, plan: &GatePlan) -> Result<EditSet> {
let mut by_file: BTreeMap<PathBuf, Vec<&GateEdit>> = BTreeMap::new();
for g in &plan.gates {
by_file.entry(g.location.path.clone()).or_default().push(g);
}
let mut files = Vec::new();
for (path, mut gates) in by_file {
let before = std::fs::read_to_string(&path)?;
let after = gate_file(&before, &mut gates);
files.push(FileEdit { path, before, after });
}
let manifest = if plan.manifest.is_empty() {
None
} else {
let path = find_manifest(root, &plan.package)?;
let before = std::fs::read_to_string(&path)?;
let after = edit_manifest(&before, &plan.manifest)?;
Some(FileEdit { path, before, after })
};
Ok(EditSet { files, manifest })
}
fn is_doc_line(line: &str) -> bool {
let t = line.trim_start();
t.starts_with("///") || t.starts_with("//!")
}
pub fn merged_attribute(existing: &klepto::CfgPredicate, predicate: &klepto::CfgPredicate) -> String {
let merged = existing.clone().and(predicate.clone()).simplify();
format!("#[cfg({})]", merged.render())
}
fn gate_file(before: &str, gates: &mut [&GateEdit]) -> String {
let had_trailing_newline = before.ends_with('\n');
let mut lines: Vec<String> = before.lines().map(str::to_string).collect();
gates.sort_by(|a, b| b.location.line.cmp(&a.location.line).then_with(|| a.item.cmp(&b.item)));
let mut done: BTreeSet<u32> = BTreeSet::new();
for g in gates.iter() {
let Some(line) = g.location.line else { continue };
if !done.insert(line) {
continue;
}
let start = (line as usize).saturating_sub(1);
if start >= lines.len() {
continue;
}
let mut j = start;
let mut replaced = false;
while j < lines.len() {
let t = lines[j].trim_start().to_string();
if is_doc_line(&t) {
j += 1;
continue;
}
if !t.starts_with("#[") {
break;
}
if t.starts_with("#[cfg(") {
let indent: String = lines[j].chars().take_while(|c| c.is_whitespace()).collect();
lines[j] = format!("{indent}{}", merged_attribute(&g.existing_cfg, &g.predicate));
replaced = true;
break;
}
let mut depth: i32 = 0;
loop {
for ch in lines[j].chars() {
match ch {
'[' => depth += 1,
']' => depth -= 1,
_ => {}
}
}
j += 1;
if depth <= 0 || j >= lines.len() {
break;
}
}
}
if !replaced {
let at = j.min(lines.len());
let indent: String = lines.get(at).map(|l| l.chars().take_while(|c| c.is_whitespace()).collect()).unwrap_or_default();
lines.insert(at, format!("{indent}{}", g.attribute_text()));
}
}
let mut out = lines.join("\n");
if had_trailing_newline {
out.push('\n');
}
out
}
fn string_array(items: &[String]) -> toml_edit::Array {
let mut a = toml_edit::Array::new();
for s in items {
a.push(s.as_str());
}
a
}
fn set_optional(item: &mut toml_edit::Item) {
if let Some(s) = item.as_str().map(str::to_string) {
let mut t = toml_edit::InlineTable::new();
t.insert("version", toml_edit::Value::from(s.as_str()));
t.insert("optional", toml_edit::Value::from(true));
*item = toml_edit::value(t);
} else if let Some(t) = item.as_table_like_mut() {
t.insert("optional", toml_edit::value(true));
}
}
const DEP_SECTIONS: [&str; 3] = ["dependencies", "dev-dependencies", "build-dependencies"];
fn make_optional(doc: &mut toml_edit::DocumentMut, dep: &str) -> Result<()> {
let mut hit = false;
let mut visit = |tbl: &mut dyn toml_edit::TableLike| {
let keys: Vec<String> = tbl.iter().map(|(k, _)| k.to_string()).collect();
for key in keys {
if key.replace('-', "_") != dep {
continue;
}
if let Some(item) = tbl.get_mut(&key) {
set_optional(item);
hit = true;
}
}
};
for section in DEP_SECTIONS {
if let Some(t) = doc.get_mut(section).and_then(|i| i.as_table_like_mut()) {
visit(t);
}
}
if let Some(targets) = doc.get_mut("target").and_then(|i| i.as_table_like_mut()) {
let names: Vec<String> = targets.iter().map(|(k, _)| k.to_string()).collect();
for name in names {
if let Some(tt) = targets.get_mut(&name).and_then(|i| i.as_table_like_mut()) {
for section in DEP_SECTIONS {
if let Some(t) = tt.get_mut(section).and_then(|i| i.as_table_like_mut()) {
visit(t);
}
}
}
}
}
if hit {
Ok(())
} else {
Err(PassengerError::Unsupported(format!("dependency `{dep}` is not declared in the manifest")))
}
}
fn edit_manifest(before: &str, edits: &[ManifestEdit]) -> Result<String> {
let mut doc: toml_edit::DocumentMut = before.parse().map_err(|e: toml_edit::TomlError| PassengerError::Toml(e.to_string()))?;
for e in edits {
match e {
ManifestEdit::MakeOptional { dep } => make_optional(&mut doc, dep)?,
ManifestEdit::SetFeature { name, enables } => {
if !doc.contains_key("features") {
doc["features"] = toml_edit::table();
}
let feats = doc["features"]
.as_table_like_mut()
.ok_or_else(|| PassengerError::Toml("[features] is not a table".into()))?;
feats.insert(name, toml_edit::value(string_array(enables)));
}
ManifestEdit::SetDefault { features } => {
if !doc.contains_key("features") {
doc["features"] = toml_edit::table();
}
let feats = doc["features"]
.as_table_like_mut()
.ok_or_else(|| PassengerError::Toml("[features] is not a table".into()))?;
feats.insert("default", toml_edit::value(string_array(features)));
}
}
}
Ok(doc.to_string())
}
pub fn workspace_root(root: &Path) -> Result<PathBuf> {
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
let out = std::process::Command::new(cargo)
.args(["locate-project", "--workspace", "--message-format", "plain"])
.current_dir(root)
.output()?;
if !out.status.success() {
return Ok(root.to_path_buf());
}
let manifest = String::from_utf8_lossy(&out.stdout).trim().to_string();
let ws = Path::new(&manifest).parent().map(Path::to_path_buf).unwrap_or_else(|| root.to_path_buf());
let same = std::fs::canonicalize(&ws).ok() == std::fs::canonicalize(root).ok();
Ok(if same { root.to_path_buf() } else { ws })
}
pub fn scratch_copy(root: &Path) -> Result<tempfile::TempDir> {
let dir = tempfile::tempdir()?;
let walk = WalkDir::new(root).into_iter().filter_entry(|e| !(e.depth() > 0 && e.file_type().is_dir() && skip_dir(e.file_name())));
for entry in walk.filter_map(|e| e.ok()) {
if entry.depth() == 0 {
continue;
}
let ft = entry.file_type();
if ft.is_symlink() {
continue;
}
let rel = entry.path().strip_prefix(root).map_err(|e| PassengerError::Path(e.to_string()))?;
let target = dir.path().join(rel);
if ft.is_dir() {
std::fs::create_dir_all(&target)?;
} else if ft.is_file() {
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(entry.path(), &target)?;
}
}
Ok(dir)
}