use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
use crate::types::ChunkName;
#[derive(Debug, Clone)]
pub struct Script {
source: String,
name: ChunkName,
root: Option<PathBuf>,
args: Vec<String>,
}
impl Script {
pub fn from_source(source: impl Into<String>, name: impl Into<String>) -> Result<Self> {
Ok(Self {
source: source.into(),
name: ChunkName::new(name)?,
root: None,
args: Vec::new(),
})
}
pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let source = std::fs::read_to_string(path).map_err(|source| Error::ScriptRead {
path: path.display().to_string(),
source,
})?;
let root = Some(require_root(path));
Ok(Self {
source,
name: ChunkName::from_path(path),
root,
args: Vec::new(),
})
}
#[must_use]
pub fn with_root(mut self, root: impl Into<PathBuf>) -> Self {
self.root = Some(root.into());
self
}
#[must_use]
pub fn with_args<I, S>(mut self, args: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.args = args.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn source(&self) -> &str {
&self.source
}
#[must_use]
pub const fn name(&self) -> &ChunkName {
&self.name
}
#[must_use]
pub fn root(&self) -> Option<&Path> {
self.root.as_deref()
}
#[must_use]
pub fn args(&self) -> &[String] {
&self.args
}
}
fn require_root(path: &Path) -> PathBuf {
path.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
.to_path_buf()
}
#[cfg(test)]
mod tests {
#![expect(
clippy::unwrap_used,
reason = "tests unwrap known-valid fixtures; a panic is the intended failure signal"
)]
use super::{Script, require_root};
use std::io::Write as _;
use std::path::Path;
#[test]
fn a_source_script_keeps_its_text_and_name() {
let script = Script::from_source("return 1", "inline").unwrap();
assert_eq!(script.source(), "return 1");
assert_eq!(script.name().as_str(), "inline");
}
#[test]
fn a_source_script_has_no_require_root() {
assert!(
Script::from_source("return 1", "inline")
.unwrap()
.root()
.is_none()
);
}
#[test]
fn an_invalid_chunk_name_is_rejected() {
assert!(Script::from_source("return 1", "").is_err());
}
#[test]
fn a_file_script_reads_its_source_and_roots_at_the_parent_directory() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("hook.lua");
let mut file = std::fs::File::create(&path).unwrap();
writeln!(file, "return 42").unwrap();
drop(file);
let script = Script::from_file(&path).unwrap();
assert_eq!(script.source().trim(), "return 42");
assert_eq!(script.name().as_str(), path.display().to_string());
assert_eq!(script.root(), Some(dir.path()));
}
#[test]
fn a_missing_file_reports_the_path_it_tried() {
let err = Script::from_file("/nonexistent/hook.lua").unwrap_err();
assert!(err.to_string().contains("/nonexistent/hook.lua"), "{err}");
}
#[test]
fn a_script_has_no_arguments_unless_given_some() {
assert!(
Script::from_source("return 1", "inline")
.unwrap()
.args()
.is_empty()
);
}
#[test]
fn with_args_records_the_arguments_in_order() {
let script = Script::from_source("return 1", "inline")
.unwrap()
.with_args(["one", "two"]);
assert_eq!(script.args(), ["one", "two"]);
}
#[test]
fn with_args_replaces_rather_than_appends() {
let script = Script::from_source("return 1", "inline")
.unwrap()
.with_args(["one"])
.with_args(["two", "three"]);
assert_eq!(script.args(), ["two", "three"]);
}
#[test]
fn with_root_overrides_the_inferred_directory() {
let script = Script::from_source("return 1", "inline")
.unwrap()
.with_root("/plugins");
assert_eq!(script.root(), Some(Path::new("/plugins")));
}
#[test]
fn a_bare_filename_roots_at_the_current_directory_like_its_dotted_spelling() {
assert_eq!(require_root(Path::new("main.lua")), Path::new("."));
assert_eq!(require_root(Path::new("./main.lua")), Path::new("."));
}
#[test]
fn a_path_with_directories_roots_at_its_parent() {
assert_eq!(
require_root(Path::new("/plugins/hooks/enforce.lua")),
Path::new("/plugins/hooks")
);
assert_eq!(require_root(Path::new("/main.lua")), Path::new("/"));
}
}