use std::path::{Path, PathBuf};
pub(crate) const SCRATCH_DIR: &str = ".arcature";
pub(crate) const RESTART_SENTINEL: &str = "restart";
pub(crate) const DEV_ADDRESS_FILE: &str = "dev.addr";
#[derive(Debug, Clone)]
pub(crate) struct Project {
root: PathBuf,
}
impl Project {
pub(crate) fn discover(start: &Path) -> Result<Self, ProjectError> {
let root = start
.ancestors()
.find(|dir| dir.join("Cargo.toml").is_file())
.ok_or_else(|| ProjectError::NoCargoToml {
from: start.to_path_buf(),
})?
.to_path_buf();
if !root.join("package.json").is_file() {
return Err(ProjectError::NoPackageJson { root });
}
Ok(Self { root })
}
pub(crate) fn root(&self) -> &Path {
&self.root
}
pub(crate) fn scratch(&self) -> Result<PathBuf, ProjectError> {
let dir = self.root.join(SCRATCH_DIR);
std::fs::create_dir_all(&dir).map_err(|source| ProjectError::Scratch {
path: dir.clone(),
source,
})?;
Ok(dir)
}
pub(crate) fn sentinel(&self) -> PathBuf {
self.root.join(SCRATCH_DIR).join(RESTART_SENTINEL)
}
}
pub(crate) fn touch_sentinel(path: &Path) -> std::io::Result<()> {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default();
std::fs::write(path, format!("{stamp}\n"))
}
#[derive(Debug)]
pub(crate) struct PublishedAddress {
path: PathBuf,
}
impl PublishedAddress {
pub(crate) fn publish(root: &Path, address: &str) -> std::io::Result<Self> {
let dir = root.join(SCRATCH_DIR);
std::fs::create_dir_all(&dir)?;
let path = dir.join(DEV_ADDRESS_FILE);
std::fs::write(
&path,
format!(
"{address}
"
),
)?;
Ok(Self { path })
}
}
impl Drop for PublishedAddress {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
pub(crate) fn node_version() -> Option<String> {
let output = std::process::Command::new("node")
.arg("--version")
.stdin(std::process::Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!version.is_empty()).then_some(version)
}
#[derive(Debug)]
pub(crate) enum ProjectError {
NoCargoToml { from: PathBuf },
NoPackageJson { root: PathBuf },
Scratch {
path: PathBuf,
source: std::io::Error,
},
}
impl std::fmt::Display for ProjectError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::NoCargoToml { from } => write!(
formatter,
"no Cargo.toml in {} or any parent directory, so there is no application to run",
from.display()
),
Self::NoPackageJson { root } => write!(
formatter,
"{} has no package.json. `arc dev` runs Vite in a Node process to serve \
assets and HMR over the single port, so the project needs a Node side. \
Run `arc new` to scaffold one, or `arc serve` to run the backend alone.",
root.display()
),
Self::Scratch { path, source } => {
write!(formatter, "could not create {}: {source}", path.display())
}
}
}
}
impl std::error::Error for ProjectError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Scratch { source, .. } => Some(source),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch_dir(label: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"arcature-project-{label}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
std::fs::create_dir_all(&dir).expect("temp dir should be creatable");
dir
}
#[test]
fn a_directory_with_no_crate_above_it_is_not_a_project() {
let dir = scratch_dir("no-crate");
let error = Project::discover(&dir).expect_err("there is no Cargo.toml here");
assert!(matches!(error, ProjectError::NoCargoToml { .. }));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_crate_without_a_node_project_is_refused_with_a_reason() {
let dir = scratch_dir("no-node");
std::fs::write(dir.join("Cargo.toml"), "[package]\n").expect("write");
let error = Project::discover(&dir).expect_err("there is no package.json here");
let message = error.to_string();
assert!(message.contains("package.json"), "{message}");
assert!(message.contains("Node"), "{message}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_project_root_is_the_nearest_ancestor_holding_a_manifest() {
let dir = scratch_dir("nested");
std::fs::write(dir.join("Cargo.toml"), "[package]\n").expect("write");
std::fs::write(dir.join("package.json"), "{}").expect("write");
let nested = dir.join("app").join("controllers");
std::fs::create_dir_all(&nested).expect("nested dirs");
let project = Project::discover(&nested).expect("the crate above should be found");
assert_eq!(
project.root().canonicalize().expect("root exists"),
dir.canonicalize().expect("dir exists")
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn the_sentinel_lives_in_the_scratch_directory() {
let dir = scratch_dir("sentinel");
std::fs::write(dir.join("Cargo.toml"), "[package]\n").expect("write");
std::fs::write(dir.join("package.json"), "{}").expect("write");
let project = Project::discover(&dir).expect("project");
let created = project.scratch().expect("scratch should be creatable");
assert!(created.is_dir());
assert_eq!(project.sentinel(), created.join(RESTART_SENTINEL));
touch_sentinel(&project.sentinel()).expect("sentinel should be writable");
assert!(project.sentinel().is_file());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn touching_the_sentinel_twice_changes_what_it_contains() {
let dir = scratch_dir("sentinel-changes");
let path = dir.join("restart");
touch_sentinel(&path).expect("first touch");
let first = std::fs::read_to_string(&path).expect("read");
std::thread::sleep(std::time::Duration::from_millis(2));
touch_sentinel(&path).expect("second touch");
let second = std::fs::read_to_string(&path).expect("read");
assert_ne!(first, second);
let _ = std::fs::remove_dir_all(&dir);
}
}