use crate::agent::Agent;
use crate::dependency::{self, DependencyTree};
use crate::package::Package;
use crate::package::manifest::cargo_toml::{self, CargoToml, parse_str};
use crate::package::manifest::{DEFAULT_VERSION, Handler, Manifest, ManifestBox};
use anyhow::{Result, bail};
use regex::{Captures, Regex, RegexBuilder};
use semver::Version;
use std::borrow::Cow;
use std::ffi::OsStr;
use std::fs::{self, File};
use std::io::BufRead;
use std::path::Path;
use std::sync::LazyLock;
static FRONTMATTER_RE: LazyLock<Regex> = LazyLock::new(|| {
RegexBuilder::new(r"^---\s+(\S(?:[\s\S])+?)\s+---$")
.multi_line(true)
.build()
.unwrap()
});
#[derive(Debug)]
pub(super) struct CargoScript {
name: String,
toml: CargoToml,
}
impl Manifest for CargoScript {
type Value = toml::Value;
fn read(path: impl AsRef<Path>) -> Result<ManifestBox> {
let path = path.as_ref();
let contents = read(path)?;
let Some(toml) = parse_str(&contents) else {
let path = path.to_string_lossy();
bail!("failed to parse manifest at: {path}");
};
let name = path
.file_name()
.and_then(OsStr::to_str)
.map(ToOwned::to_owned)
.expect("script must have a file name");
Ok(Box::new(Self { name, toml }))
}
fn read_as_value(path: impl AsRef<Path>) -> Result<Self::Value> {
let contents = read(path.as_ref())?;
Ok(toml::from_str::<Self::Value>(&contents)?)
}
}
impl Handler for CargoScript {
fn agent(&self) -> Agent {
self.toml.agent()
}
fn bump(&self, _: &Package, _: Version) -> Result<()> {
bail!("cargo scripts have no version to bump");
}
fn dependency_tree(&self) -> DependencyTree {
self.toml.dependency_tree()
}
fn name(&self) -> &str {
&self.name
}
fn update(&self, package: &Package, targets: &[dependency::Target]) -> Result<()> {
let mut contents = read(&package.path)?;
cargo_toml::update(&self.toml, &mut contents, targets)?;
write(&package.path, &contents)
}
fn version(&self) -> Result<Version> {
Ok(DEFAULT_VERSION)
}
}
macro_rules! bail_not_a_script {
($path:expr) => {
let path = $path.to_string_lossy();
bail!("not a cargo script: {path}");
};
}
fn read(path: &Path) -> Result<String> {
let file = File::open_buffered(path)?;
let mut contents = String::new();
let mut state = FrontmatterState::None;
for (idx, line) in file
.lines()
.map_while(Result::ok)
.enumerate()
{
let line = line.trim();
if idx == 0 {
if line.starts_with("#!") {
continue;
}
bail_not_a_script!(path);
}
contents.push_str(line);
contents.push('\n');
if line == "---" {
match state {
FrontmatterState::None => state = FrontmatterState::Open,
FrontmatterState::Open => state = FrontmatterState::Closed,
FrontmatterState::Closed => break,
}
}
if let FrontmatterState::Closed = state {
break;
}
}
if let Some(captures) = FRONTMATTER_RE.captures(&contents)
&& let Some(frontmatter) = captures.get(1)
{
Ok(frontmatter.as_str().to_owned())
} else {
bail_not_a_script!(path);
}
}
fn write(path: &Path, toml: &str) -> Result<()> {
let contents = fs::read_to_string(path)?;
let contents = FRONTMATTER_RE.replace(&contents, |_: &Captures| {
format!("---\n{}\n---", toml.trim())
});
if Cow::is_owned(&contents) {
fs::write(path, contents.as_str())?;
}
Ok(())
}
#[derive(Clone, Copy, Debug)]
enum FrontmatterState {
None,
Open,
Closed,
}