miho 8.1.0

Repository management tools
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,
}